/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / ui / BeadView.tsx
180 lines · 6.8 KBCodeBlameHistory
2import type { RunPayload } from "../worker";
4// Orthographic 3D view of the beads, displaced by a chosen snapshot.
5export function BeadView({ p, onTime }: { p: RunPayload; onTime: (t: number | null) => void }) {
6 const { pos0, n, si, sj, pinned, listenBeads, hitBeads } = p.sys;
7 const { animFrames, animTimes } = p.result;
8 const canvasRef = useRef<HTMLCanvasElement>(null);
9 const [frame, setFrame] = useState(0);
10 const [playing, setPlaying] = useState(false);
11 const [speed, setSpeed] = useState(1 / 64);
12 const [gainExp, setGainExp] = useState(0); // log2 of extra exaggeration
13 const view = useRef({ az: -0.6, el: 0.55 });
14 const [, redraw] = useState(0);
16 const geom = useMemo(() => {
17 let cx = 0, cy = 0, cz = 0;
18 for (let i = 0; i < n; i++) {
19 cx += pos0[3 * i];
20 cy += pos0[3 * i + 1];
21 cz += pos0[3 * i + 2];
22 }
23 cx /= n; cy /= n; cz /= n;
24 let ext = 0;
25 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));
26 let dmax = 0;
27 for (const fr of animFrames) for (let q = 0; q < fr.length; q++) dmax = Math.max(dmax, Math.abs(fr[q]));
28 return { c: [cx, cy, cz], ext: ext || 1, dmax: dmax || 1 };
29 }, [pos0, n, animFrames]);
30 const listen = useMemo(() => new Set(listenBeads), [listenBeads]);
31 const hits = useMemo(() => new Set(hitBeads), [hitBeads]);
33 useEffect(() => {
34 if (!playing || animTimes.length < 2) return;
35 const dtFrame = animTimes[1] - animTimes[0];
36 let last = performance.now();
37 let acc = frame;
38 let raf = 0;
39 const tick = (now: number) => {
40 acc += ((now - last) / 1000) * speed / dtFrame;
41 last = now;
42 if (acc >= animTimes.length) acc = 0;
43 setFrame(Math.floor(acc));
44 raf = requestAnimationFrame(tick);
45 };
46 raf = requestAnimationFrame(tick);
47 return () => cancelAnimationFrame(raf);
48 // eslint-disable-next-line react-hooks/exhaustive-deps
49 }, [playing, speed, animTimes]);
51 useEffect(() => {
52 onTime(animTimes.length ? animTimes[frame] : null);
53 }, [frame, animTimes, onTime]);
55 useEffect(() => {
56 const c = canvasRef.current;
57 if (!c) return;
58 const draw = () => {
59 const dpr = window.devicePixelRatio || 1;
60 const w = c.clientWidth;
61 const h = c.clientHeight;
62 c.width = Math.round(w * dpr);
63 c.height = Math.round(h * dpr);
64 const ctx = c.getContext("2d")!;
65 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
66 ctx.clearRect(0, 0, w, h);
67 const style = getComputedStyle(c);
68 const css = (v: string) => style.getPropertyValue(v).trim();
69 const { az, el } = view.current;
70 const [ca, sa, ce, se] = [Math.cos(az), Math.sin(az), Math.cos(el), Math.sin(el)];
71 const scale = (0.42 * Math.min(w, h)) / geom.ext;
72 const exag = ((0.12 * geom.ext) / geom.dmax) * Math.pow(2, gainExp);
73 const d = animFrames[Math.min(frame, animFrames.length - 1)];
74 const sx = new Float32Array(n);
75 const sy = new Float32Array(n);
76 for (let i = 0; i < n; i++) {
77 const X = pos0[3 * i] - geom.c[0] + (d ? exag * d[3 * i] : 0);
78 const Y = pos0[3 * i + 1] - geom.c[1] + (d ? exag * d[3 * i + 1] : 0);
79 const Z = pos0[3 * i + 2] - geom.c[2] + (d ? exag * d[3 * i + 2] : 0);
80 const x1 = ca * X - sa * Y;
81 const y1 = sa * X + ca * Y;
82 sx[i] = w / 2 + scale * x1;
83 sy[i] = h / 2 - scale * (ce * Z - se * y1);
84 }
85 if (si.length <= 30000) {
86 ctx.strokeStyle = css("--grid-strong");
87 ctx.lineWidth = 0.6;
88 ctx.beginPath();
89 for (let s = 0; s < si.length; s++) {
90 ctx.moveTo(sx[si[s]], sy[si[s]]);
91 ctx.lineTo(sx[sj[s]], sy[sj[s]]);
92 }
93 ctx.stroke();
94 }
95 const r = Math.max(1.2, Math.min(3, (scale * geom.ext) / Math.sqrt(n) / 3));
96 for (let i = 0; i < n; i++) {
97 let col: string;
98 if (pinned[i]) col = css("--muted");
99 else if (d) {
100 const z = Math.max(-1, Math.min(1, d[3 * i + 2] / geom.dmax));
101 col = z >= 0 ? `rgba(214, 96, 77, ${0.25 + 0.75 * z})` : `rgba(67, 147, 195, ${0.25 - 0.75 * z})`;
102 } else col = css("--series");
103 ctx.fillStyle = col;
104 ctx.beginPath();
105 ctx.arc(sx[i], sy[i], r, 0, 2 * Math.PI);
106 ctx.fill();
107 }
108 const ring = (set: Set<number>, color: string) => {
109 ctx.strokeStyle = color;
110 ctx.lineWidth = 2;
111 for (const i of set) {
112 ctx.beginPath();
113 ctx.arc(sx[i], sy[i], r + 3, 0, 2 * Math.PI);
114 ctx.stroke();
115 }
116 };
117 ring(hits, css("--hit"));
118 ring(listen, css("--accent"));
119 };
120 draw();
121 const ro = new ResizeObserver(draw);
122 ro.observe(c);
123 return () => ro.disconnect();
124 });
126 const drag = useRef<{ x: number; y: number } | null>(null);
127 const t = animTimes.length ? animTimes[Math.min(frame, animTimes.length - 1)] : 0;
128 return (
129 <div>
130 <canvas
131 ref={canvasRef}
132 className="plot tall grab"
133 onPointerDown={e => {
134 drag.current = { x: e.clientX, y: e.clientY };
135 (e.target as HTMLElement).setPointerCapture(e.pointerId);
136 }}
137 onPointerMove={e => {
138 if (!drag.current) return;
139 view.current.az += (e.clientX - drag.current.x) * 0.01;
140 view.current.el = Math.max(-1.5, Math.min(1.5, view.current.el + (e.clientY - drag.current.y) * 0.01));
141 drag.current = { x: e.clientX, y: e.clientY };
142 redraw(v => v + 1);
143 }}
144 onPointerUp={() => (drag.current = null)}
145 />
146 <div className="controls">
147 <button onClick={() => setPlaying(!playing)} disabled={animTimes.length < 2}>
148 {playing ? "Pause" : "Play"}
149 </button>
150 <input
151 type="range"
152 className="grow"
153 min={0}
154 max={Math.max(0, animTimes.length - 1)}
155 value={frame}
156 onChange={e => setFrame(+e.target.value)}
157 />
158 <span className="mono">{(t * 1000).toFixed(1)} ms</span>
159 <label>
160 speed{" "}
161 <select value={speed} onChange={e => setSpeed(+e.target.value)}>
162 {[1 / 256, 1 / 64, 1 / 16, 1 / 4].map(s => (
163 <option key={s} value={s}>
164 1/{1 / s}
165 </option>
166 ))}
167 </select>
168 </label>
169 <label>
170 exaggerate{" "}
171 <input type="range" min={-3} max={3} step={0.5} value={gainExp} onChange={e => setGainExp(+e.target.value)} />
172 </label>
173 </div>
174 <div className="legend">
175 Drag to rotate. Displacement exaggerated about {(((0.12 * geom.ext) / geom.dmax) * Math.pow(2, gainExp)).toPrecision(2)}x.
176 Red up, blue down; <span className="ring hit" /> struck beads, <span className="ring listen" /> listener.
177 </div>
178 </div>
179 );
↑↓ move↵openescclose