Files
PolyWeather/frontend/components/dashboard/IntradaySignalScene.tsx
T
2026-03-27 23:34:48 +08:00

218 lines
6.4 KiB
TypeScript

"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>
);
}