Visualize intraday analysis with 3D signals and metric meters

This commit is contained in:
2569718930@qq.com
2026-03-27 23:34:48 +08:00
parent 98bcfe2a21
commit 0c8ad54780
3 changed files with 824 additions and 18 deletions
@@ -2437,6 +2437,91 @@
font-variant-numeric: tabular-nums;
}
.root :global(.future-v2-daylight) {
margin-top: 14px;
padding: 12px 12px 10px;
border-radius: 12px;
border: 1px solid rgba(245, 158, 11, 0.14);
background: linear-gradient(
180deg,
rgba(245, 158, 11, 0.08) 0%,
rgba(34, 211, 238, 0.04) 100%
);
}
.root :global(.future-v2-daylight-head) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.root :global(.future-v2-daylight-head span) {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.root :global(.future-v2-daylight-head strong) {
color: #fde68a;
font-size: 12px;
font-weight: 700;
}
.root :global(.future-v2-daylight-bar) {
position: relative;
height: 12px;
border-radius: 999px;
background:
linear-gradient(
90deg,
rgba(15, 23, 42, 0.85) 0%,
rgba(56, 189, 248, 0.18) 15%,
rgba(250, 204, 21, 0.24) 50%,
rgba(56, 189, 248, 0.16) 85%,
rgba(15, 23, 42, 0.85) 100%
);
overflow: hidden;
}
.root :global(.future-v2-daylight-bar::before) {
content: "";
position: absolute;
inset: 0;
width: var(--daylight-progress, 0%);
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0.22) 0%,
rgba(250, 204, 21, 0.4) 70%,
rgba(251, 146, 60, 0.5) 100%
);
}
.root :global(.future-v2-daylight-bar::after) {
content: "";
position: absolute;
top: 50%;
left: var(--daylight-progress, 0%);
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle, #fff7cc 0%, #facc15 65%, #fb923c 100%);
box-shadow:
0 0 0 3px rgba(250, 204, 21, 0.16),
0 0 18px rgba(250, 204, 21, 0.22);
transform: translate(-50%, -50%);
}
.root :global(.future-v2-daylight-times) {
margin-top: 8px;
display: flex;
justify-content: space-between;
color: var(--text-secondary);
font-size: 11px;
}
.root :global(.future-v2-mini-grid) {
margin-top: 14px;
display: grid;
@@ -2457,6 +2542,17 @@
gap: 4px;
}
.root :global(.future-v2-signal-item) {
gap: 8px;
}
.root :global(.future-v2-signal-head) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.root :global(.future-v2-mini-item span) {
color: var(--text-muted);
font-size: 11px;
@@ -2469,6 +2565,134 @@
font-weight: 700;
}
.root :global(.future-v2-signal-tag) {
font-style: normal;
font-size: 10px;
font-weight: 700;
line-height: 1;
padding: 5px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
}
.root :global(.future-v2-signal-tag.cyan) {
color: #67e8f9;
border-color: rgba(34, 211, 238, 0.22);
background: rgba(34, 211, 238, 0.08);
}
.root :global(.future-v2-signal-tag.blue) {
color: #93c5fd;
border-color: rgba(96, 165, 250, 0.22);
background: rgba(96, 165, 250, 0.08);
}
.root :global(.future-v2-signal-tag.amber) {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.22);
background: rgba(251, 191, 36, 0.08);
}
.root :global(.future-v2-signal-meter) {
position: relative;
height: 7px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.root :global(.future-v2-signal-fill) {
height: 100%;
border-radius: inherit;
background: linear-gradient(
90deg,
rgba(203, 213, 225, 0.25) 0%,
rgba(255, 255, 255, 0.32) 100%
);
}
.root :global(.future-v2-signal-fill.cyan) {
background: linear-gradient(90deg, #155e75 0%, #22d3ee 100%);
}
.root :global(.future-v2-signal-fill.blue) {
background: linear-gradient(90deg, #2563eb 0%, #60a5fa 100%);
}
.root :global(.future-v2-signal-fill.amber) {
background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
}
.root :global(.intraday-scene-shell) {
margin-top: 12px;
margin-bottom: 10px;
border-radius: 14px;
border: 1px solid rgba(99, 102, 241, 0.14);
background:
radial-gradient(
circle at 50% 18%,
rgba(34, 211, 238, 0.14) 0%,
rgba(255, 255, 255, 0.02) 48%,
rgba(255, 255, 255, 0.015) 100%
);
overflow: hidden;
}
.root :global(.intraday-scene-frame) {
height: 196px;
}
.root :global(.intraday-scene-frame canvas) {
width: 100%;
height: 100%;
display: block;
}
.root :global(.intraday-scene-legend) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(2, 6, 23, 0.18);
}
.root :global(.intraday-scene-chip) {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.root :global(.intraday-scene-chip-dot) {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 5px;
box-shadow: 0 0 10px currentColor;
flex: 0 0 auto;
}
.root :global(.intraday-scene-chip-copy) {
min-width: 0;
display: grid;
gap: 2px;
}
.root :global(.intraday-scene-chip-copy strong) {
color: var(--text-primary);
font-size: 11px;
font-weight: 700;
}
.root :global(.intraday-scene-chip-copy span) {
color: var(--text-secondary);
font-size: 11px;
line-height: 1.35;
}
.root :global(.future-v2-card-title) {
margin: 0;
color: var(--text-primary);
@@ -2743,6 +2967,54 @@
font-weight: 700;
}
.root :global(.future-trend-meter) {
position: relative;
height: 8px;
margin-top: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.root :global(.future-trend-meter.center) {
background:
linear-gradient(
90deg,
rgba(56, 189, 248, 0.1) 0%,
rgba(255, 255, 255, 0.05) 48%,
rgba(255, 255, 255, 0.05) 52%,
rgba(245, 158, 11, 0.1) 100%
);
}
.root :global(.future-trend-meter-midline) {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.18);
transform: translateX(-50%);
z-index: 1;
}
.root :global(.future-trend-meter-fill) {
position: absolute;
top: 0;
left: 0;
bottom: 0;
border-radius: inherit;
background: linear-gradient(90deg, #334155 0%, #94a3b8 100%);
}
.root :global(.future-trend-meter-fill.warm) {
background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
}
.root :global(.future-trend-meter-fill.cold) {
background: linear-gradient(90deg, #0f766e 0%, #22d3ee 100%);
}
.root :global(.future-trend-note) {
margin-top: 6px;
color: var(--text-secondary);
@@ -2838,6 +3110,11 @@
font-size: 18px;
}
.root :global(.future-v2-daylight-head) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.future-modal-grid),
.root :global(.future-trend-grid) {
grid-template-columns: 1fr;
@@ -2881,6 +3158,11 @@
font-size: 20px;
}
.root :global(.future-v2-signal-head) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.future-v2-main-chart) {
min-height: 280px;
}
@@ -2897,6 +3179,14 @@
padding: 10px;
}
.root :global(.intraday-scene-frame) {
height: 174px;
}
.root :global(.intraday-scene-legend) {
grid-template-columns: 1fr;
}
.root :global(.future-trend-value) {
font-size: 16px;
}
@@ -19,6 +19,7 @@ import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { IntradaySignalScene } from "@/components/dashboard/IntradaySignalScene";
import {
ModelForecast,
ProbabilityDistribution,
@@ -181,6 +182,159 @@ function parseVisibilityText(
return "--";
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function parseClockMinutes(value?: string | null) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
return hours * 60 + minutes;
}
function parseVisibilityMiles(value?: string | null) {
const text = String(value || "").trim();
if (!text || text === "--") return null;
const match = text.match(/(\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function parseLeadingNumber(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) return value;
const text = String(value || "").trim();
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
if (!match) return null;
const numeric = Number(match[0]);
return Number.isFinite(numeric) ? numeric : null;
}
function getCurrentMetricDescriptor(
kind: "humidity" | "dewpoint" | "wind" | "visibility",
value: string,
numeric: number | null,
aux?: number | null,
) {
if (kind === "humidity") {
const percent = numeric != null ? clamp(numeric, 0, 100) : null;
const hint =
percent == null
? "--"
: percent >= 75
? "偏湿"
: percent >= 45
? "适中"
: "偏干";
const tone =
percent == null ? "neutral" : percent >= 75 ? "cyan" : percent >= 45 ? "blue" : "amber";
return { fill: percent, hint, tone, value };
}
if (kind === "dewpoint") {
const spread = aux != null && numeric != null ? aux - numeric : null;
const closeness =
spread != null ? clamp(100 - spread * 12, 0, 100) : null;
const hint =
spread == null
? "--"
: spread <= 2
? "近饱和"
: spread <= 6
? "偏湿"
: "偏干";
const tone =
spread == null ? "neutral" : spread <= 2 ? "cyan" : spread <= 6 ? "blue" : "amber";
return { fill: closeness, hint, tone, value };
}
if (kind === "wind") {
const percent = numeric != null ? clamp((numeric / 25) * 100, 0, 100) : null;
const hint =
numeric == null
? "--"
: numeric >= 18
? "偏强"
: numeric >= 8
? "中等"
: "较弱";
const tone =
numeric == null ? "neutral" : numeric >= 18 ? "amber" : numeric >= 8 ? "blue" : "cyan";
return { fill: percent, hint, tone, value };
}
const percent = numeric != null ? clamp((numeric / 10) * 100, 0, 100) : null;
const hint =
numeric == null
? "--"
: numeric >= 6
? "通透"
: numeric >= 3
? "一般"
: "受限";
const tone =
numeric == null ? "neutral" : numeric >= 6 ? "cyan" : numeric >= 3 ? "blue" : "amber";
return { fill: percent, hint, tone, value };
}
function getTrendMetricVisual(metric: {
label?: string;
value?: string;
tone?: string;
}) {
const label = String(metric.label || "").toLowerCase();
const value = String(metric.value || "");
const numeric = parseLeadingNumber(value);
if (numeric == null) return null;
if (label.includes("降水") || label.includes("precip")) {
return {
mode: "fill" as const,
percent: clamp(numeric, 0, 100),
tone: "cold" as const,
};
}
if (label.includes("温度") || label.includes("temp")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const,
};
}
if (label.includes("露点") || label.includes("dew")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const,
};
}
if (label.includes("气压") || label.includes("pressure")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const,
};
}
if (label.includes("云量") || label.includes("cloud")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
tone: numeric >= 0 ? "cold" as const : "warm" as const,
};
}
return null;
}
function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
const store = useDashboardStore();
const { locale, t } = useI18n();
@@ -796,6 +950,55 @@ export function FutureForecastModal() {
detail.current?.raw_metar,
detail.current?.visibility_mi,
);
const humidityValue =
fallbackHumidity != null ? Math.round(fallbackHumidity) : null;
const windValue =
detail.current?.wind_speed_kt != null
? Number(detail.current.wind_speed_kt)
: null;
const visibilityValue = parseVisibilityMiles(visibilityText);
const daylightProgress = (() => {
const now = parseClockMinutes(detail.current?.obs_time);
const sunrise = parseClockMinutes(detail.forecast?.sunrise);
const sunset = parseClockMinutes(detail.forecast?.sunset);
if (now == null || sunrise == null || sunset == null || sunset <= sunrise) {
return null;
}
const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100);
const phase =
now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中";
return {
phase,
percent,
};
})();
const currentMetricVisuals = [
{
key: "humidity",
label: locale === "en-US" ? "Humidity" : "湿度",
...getCurrentMetricDescriptor("humidity", humidityText, humidityValue),
},
{
key: "dewpoint",
label: locale === "en-US" ? "Dew Point" : "露点",
...getCurrentMetricDescriptor(
"dewpoint",
dewpointText,
fallbackDewpoint != null ? Number(fallbackDewpoint) : null,
detail.current?.temp != null ? Number(detail.current.temp) : null,
),
},
{
key: "wind",
label: locale === "en-US" ? "Wind" : "风速",
...getCurrentMetricDescriptor("wind", windText, windValue),
},
{
key: "visibility",
label: locale === "en-US" ? "Visibility" : "能见度",
...getCurrentMetricDescriptor("visibility", visibilityText, visibilityValue),
},
];
const ai = getAirportNarrative(detail, locale);
const risk = detail.risk || {};
const settlementSourceCode = String(
@@ -1001,6 +1204,34 @@ export function FutureForecastModal() {
<div className="future-v2-hero-obs">
@{detail.current?.obs_time || "--"}
</div>
{daylightProgress ? (
<div className="future-v2-daylight">
<div className="future-v2-daylight-head">
<span>
{locale === "en-US"
? "Solar Window"
: "昼夜进度"}
</span>
<strong>
{locale === "en-US"
? `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`
: `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`}
</strong>
</div>
<div
className="future-v2-daylight-bar"
style={
{
"--daylight-progress": `${daylightProgress.percent}%`,
} as CSSProperties & { "--daylight-progress": string }
}
/>
<div className="future-v2-daylight-times">
<span>{detail.forecast?.sunrise || "--"}</span>
<span>{detail.forecast?.sunset || "--"}</span>
</div>
</div>
) : null}
<div className="future-v2-mini-grid">
<div className="future-v2-mini-item">
<span>
@@ -1040,25 +1271,43 @@ export function FutureForecastModal() {
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Metrics" : "当前指标"}
</h4>
<IntradaySignalScene
metrics={currentMetricVisuals}
score={view.front.score}
/>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Humidity" : "湿度"}</span>
<strong>{humidityText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Dew Point" : "露点"}</span>
<strong>{dewpointText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Wind" : "风速"}</span>
<strong>{windText}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Visibility" : "能见度"}
</span>
<strong>{visibilityText}</strong>
</div>
{currentMetricVisuals.map((metric) => (
<div key={metric.key} className="future-v2-mini-item future-v2-signal-item">
<div className="future-v2-signal-head">
<span>{metric.label}</span>
<em
className={clsx(
"future-v2-signal-tag",
metric.tone === "cyan" && "cyan",
metric.tone === "blue" && "blue",
metric.tone === "amber" && "amber",
)}
>
{metric.hint}
</em>
</div>
<strong>{metric.value}</strong>
<div className="future-v2-signal-meter">
<div
className={clsx(
"future-v2-signal-fill",
metric.tone === "cyan" && "cyan",
metric.tone === "blue" && "blue",
metric.tone === "amber" && "amber",
)}
style={{
width:
metric.fill != null ? `${metric.fill}%` : "18%",
}}
/>
</div>
</div>
))}
</div>
</section>
@@ -1322,6 +1571,31 @@ export function FutureForecastModal() {
>
{metric.value}
</div>
{getTrendMetricVisual(metric) ? (
<div
className={clsx(
"future-trend-meter",
getTrendMetricVisual(metric)?.mode === "center" &&
"center",
)}
>
{getTrendMetricVisual(metric)?.mode === "center" ? (
<span className="future-trend-meter-midline" />
) : null}
<div
className={clsx(
"future-trend-meter-fill",
getTrendMetricVisual(metric)?.tone === "warm" &&
"warm",
getTrendMetricVisual(metric)?.tone === "cold" &&
"cold",
)}
style={{
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
}}
/>
</div>
) : null}
<div className="future-trend-note">{metric.note}</div>
</div>
))}
@@ -1350,6 +1624,31 @@ export function FutureForecastModal() {
>
{metric.value}
</div>
{getTrendMetricVisual(metric) ? (
<div
className={clsx(
"future-trend-meter",
getTrendMetricVisual(metric)?.mode === "center" &&
"center",
)}
>
{getTrendMetricVisual(metric)?.mode === "center" ? (
<span className="future-trend-meter-midline" />
) : null}
<div
className={clsx(
"future-trend-meter-fill",
getTrendMetricVisual(metric)?.tone === "warm" &&
"warm",
getTrendMetricVisual(metric)?.tone === "cold" &&
"cold",
)}
style={{
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
}}
/>
</div>
) : null}
<div className="future-trend-note">{metric.note}</div>
</div>
))}
@@ -0,0 +1,217 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
export interface IntradaySignalMetric {
key: string;
label: string;
value: string;
hint: string;
fill: number | null;
tone: string;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getToneColor(tone: string) {
if (tone === "cyan") return "#22d3ee";
if (tone === "blue") return "#60a5fa";
if (tone === "amber") return "#f59e0b";
return "#94a3b8";
}
export function IntradaySignalScene({
metrics,
score,
}: {
metrics: IntradaySignalMetric[];
score: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const prefersReducedMotion = usePrefersReducedMotion();
useEffect(() => {
const host = containerRef.current;
if (!host) return;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "low-power",
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
camera.position.set(0, 2.8, 7.2);
camera.lookAt(0, 1.2, 0);
const ambient = new THREE.AmbientLight(0xbfe8ff, 1.25);
const keyLight = new THREE.PointLight(0x67e8f9, 22, 18, 2);
keyLight.position.set(-3.8, 5.6, 4.8);
const warmLight = new THREE.PointLight(0xf59e0b, 12, 16, 2);
warmLight.position.set(4.2, 2.8, 4);
scene.add(ambient, keyLight, warmLight);
const stage = new THREE.Group();
scene.add(stage);
const floor = new THREE.Mesh(
new THREE.CylinderGeometry(3.3, 3.8, 0.12, 48),
new THREE.MeshStandardMaterial({
color: new THREE.Color(score >= 0 ? "#10263b" : "#2c1d12"),
emissive: new THREE.Color(score >= 0 ? "#0e7490" : "#b45309"),
emissiveIntensity: 0.18 + clamp(Math.abs(score) / 8, 0, 0.24),
metalness: 0.2,
roughness: 0.78,
}),
);
floor.position.y = -0.12;
stage.add(floor);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.8, 0.03, 18, 100),
new THREE.MeshBasicMaterial({
color: new THREE.Color(score >= 0 ? "#22d3ee" : "#f59e0b"),
transparent: true,
opacity: 0.5,
}),
);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.03;
stage.add(ring);
const barGeometry = new THREE.BoxGeometry(0.8, 1, 0.8);
const capGeometry = new THREE.SphereGeometry(0.16, 16, 16);
const bars: Array<{
mesh: THREE.Mesh;
cap: THREE.Mesh;
glow: THREE.Mesh;
baseY: number;
targetHeight: number;
}> = [];
const xPositions = [-1.8, -0.6, 0.6, 1.8];
metrics.slice(0, 4).forEach((metric, index) => {
const height = 0.5 + ((metric.fill ?? 20) / 100) * 2.8;
const color = new THREE.Color(getToneColor(metric.tone));
const material = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.22,
metalness: 0.14,
roughness: 0.38,
});
const mesh = new THREE.Mesh(barGeometry, material);
mesh.position.set(xPositions[index] || 0, height / 2, 0);
mesh.scale.y = height;
stage.add(mesh);
const cap = new THREE.Mesh(
capGeometry,
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.95,
}),
);
cap.position.set(mesh.position.x, height + 0.2, 0);
stage.add(cap);
const glow = new THREE.Mesh(
new THREE.CylinderGeometry(0.46, 0.58, 0.08, 32),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.22,
}),
);
glow.position.set(mesh.position.x, 0.06, 0);
stage.add(glow);
bars.push({ mesh, cap, glow, baseY: cap.position.y, targetHeight: height });
});
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
resize();
host.appendChild(renderer.domElement);
const clock = new THREE.Clock();
let frameId = 0;
const renderFrame = () => {
frameId = window.requestAnimationFrame(renderFrame);
const elapsed = clock.getElapsedTime();
stage.rotation.y = Math.sin(elapsed * 0.35) * 0.16;
ring.material.opacity = 0.38 + Math.sin(elapsed * 0.8) * 0.08;
bars.forEach((bar, index) => {
const pulse = prefersReducedMotion
? 0
: Math.sin(elapsed * 1.5 + index * 0.8) * 0.08;
bar.cap.position.y = bar.baseY + pulse;
bar.glow.scale.x = 1 + Math.sin(elapsed * 1.2 + index) * 0.06;
bar.glow.scale.z = 1 + Math.sin(elapsed * 1.2 + index) * 0.06;
});
renderer.render(scene, camera);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
frameId = window.requestAnimationFrame(renderFrame);
return () => {
observer.disconnect();
window.cancelAnimationFrame(frameId);
stage.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
}
});
renderer.dispose();
if (renderer.domElement.parentNode === host) {
host.removeChild(renderer.domElement);
}
};
}, [metrics, prefersReducedMotion, score]);
return (
<div className="intraday-scene-shell">
<div ref={containerRef} className="intraday-scene-frame" aria-hidden="true" />
<div className="intraday-scene-legend">
{metrics.slice(0, 4).map((metric) => (
<div key={metric.key} className="intraday-scene-chip">
<span
className="intraday-scene-chip-dot"
style={{ backgroundColor: getToneColor(metric.tone) }}
/>
<div className="intraday-scene-chip-copy">
<strong>{metric.label}</strong>
<span>
{metric.value} · {metric.hint}
</span>
</div>
</div>
))}
</div>
</div>
);
}