import { useEffect, useMemo, useRef, useState } from "react"; import type { RunPayload } from "../worker"; // Orthographic 3D view of the beads, displaced by a chosen snapshot. export function BeadView({ p, onTime }: { p: RunPayload; onTime: (t: number | null) => void }) { const { pos0, n, si, sj, pinned, listenBeads, hitBeads } = p.sys; const { animFrames, animTimes } = p.result; const canvasRef = useRef(null); const [frame, setFrame] = useState(0); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1 / 64); const [gainExp, setGainExp] = useState(0); // log2 of extra exaggeration const view = useRef({ az: -0.6, el: 0.55 }); const [, redraw] = useState(0); const geom = useMemo(() => { let cx = 0, cy = 0, cz = 0; for (let i = 0; i < n; i++) { cx += pos0[3 * i]; cy += pos0[3 * i + 1]; cz += pos0[3 * i + 2]; } cx /= n; cy /= n; cz /= n; let ext = 0; for (let i = 0; i < n; i++) ext = Math.max(ext, Math.hypot(pos0[3 * i] - cx, pos0[3 * i + 1] - cy, pos0[3 * i + 2] - cz)); let dmax = 0; for (const fr of animFrames) for (let q = 0; q < fr.length; q++) dmax = Math.max(dmax, Math.abs(fr[q])); return { c: [cx, cy, cz], ext: ext || 1, dmax: dmax || 1 }; }, [pos0, n, animFrames]); const listen = useMemo(() => new Set(listenBeads), [listenBeads]); const hits = useMemo(() => new Set(hitBeads), [hitBeads]); useEffect(() => { if (!playing || animTimes.length < 2) return; const dtFrame = animTimes[1] - animTimes[0]; let last = performance.now(); let acc = frame; let raf = 0; const tick = (now: number) => { acc += ((now - last) / 1000) * speed / dtFrame; last = now; if (acc >= animTimes.length) acc = 0; setFrame(Math.floor(acc)); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, speed, animTimes]); useEffect(() => { onTime(animTimes.length ? animTimes[frame] : null); }, [frame, animTimes, onTime]); useEffect(() => { const c = canvasRef.current; if (!c) return; const draw = () => { const dpr = window.devicePixelRatio || 1; const w = c.clientWidth; const h = c.clientHeight; c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); const ctx = c.getContext("2d")!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const style = getComputedStyle(c); const css = (v: string) => style.getPropertyValue(v).trim(); const { az, el } = view.current; const [ca, sa, ce, se] = [Math.cos(az), Math.sin(az), Math.cos(el), Math.sin(el)]; const scale = (0.42 * Math.min(w, h)) / geom.ext; const exag = ((0.12 * geom.ext) / geom.dmax) * Math.pow(2, gainExp); const d = animFrames[Math.min(frame, animFrames.length - 1)]; const sx = new Float32Array(n); const sy = new Float32Array(n); for (let i = 0; i < n; i++) { const X = pos0[3 * i] - geom.c[0] + (d ? exag * d[3 * i] : 0); const Y = pos0[3 * i + 1] - geom.c[1] + (d ? exag * d[3 * i + 1] : 0); const Z = pos0[3 * i + 2] - geom.c[2] + (d ? exag * d[3 * i + 2] : 0); const x1 = ca * X - sa * Y; const y1 = sa * X + ca * Y; sx[i] = w / 2 + scale * x1; sy[i] = h / 2 - scale * (ce * Z - se * y1); } if (si.length <= 30000) { ctx.strokeStyle = css("--grid-strong"); ctx.lineWidth = 0.6; ctx.beginPath(); for (let s = 0; s < si.length; s++) { ctx.moveTo(sx[si[s]], sy[si[s]]); ctx.lineTo(sx[sj[s]], sy[sj[s]]); } ctx.stroke(); } const r = Math.max(1.2, Math.min(3, (scale * geom.ext) / Math.sqrt(n) / 3)); for (let i = 0; i < n; i++) { let col: string; if (pinned[i]) col = css("--muted"); else if (d) { const z = Math.max(-1, Math.min(1, d[3 * i + 2] / geom.dmax)); col = z >= 0 ? `rgba(214, 96, 77, ${0.25 + 0.75 * z})` : `rgba(67, 147, 195, ${0.25 - 0.75 * z})`; } else col = css("--series"); ctx.fillStyle = col; ctx.beginPath(); ctx.arc(sx[i], sy[i], r, 0, 2 * Math.PI); ctx.fill(); } const ring = (set: Set, color: string) => { ctx.strokeStyle = color; ctx.lineWidth = 2; for (const i of set) { ctx.beginPath(); ctx.arc(sx[i], sy[i], r + 3, 0, 2 * Math.PI); ctx.stroke(); } }; ring(hits, css("--hit")); ring(listen, css("--accent")); }; draw(); const ro = new ResizeObserver(draw); ro.observe(c); return () => ro.disconnect(); }); const drag = useRef<{ x: number; y: number } | null>(null); const t = animTimes.length ? animTimes[Math.min(frame, animTimes.length - 1)] : 0; return (
{ drag.current = { x: e.clientX, y: e.clientY }; (e.target as HTMLElement).setPointerCapture(e.pointerId); }} onPointerMove={e => { if (!drag.current) return; view.current.az += (e.clientX - drag.current.x) * 0.01; view.current.el = Math.max(-1.5, Math.min(1.5, view.current.el + (e.clientY - drag.current.y) * 0.01)); drag.current = { x: e.clientX, y: e.clientY }; redraw(v => v + 1); }} onPointerUp={() => (drag.current = null)} />
setFrame(+e.target.value)} /> {(t * 1000).toFixed(1)} ms
Drag to rotate. Displacement exaggerated about {(((0.12 * geom.ext) / geom.dmax) * Math.pow(2, gainExp)).toPrecision(2)}x. Red up, blue down; struck beads, listener.
); }