Visualize intraday analysis with 3D signals and metric meters
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user