1import { useEffect, useMemo, useRef, useState } from "react";
2import { modeBands, spectrogram, spectrum } from "../sim/analysis";
3import type { RunPayload } from "../worker";
4import { drawAxes, fmt, heat, niceTicks, setupCanvas } from "./plot";
6function useCanvas(draw: (c: HTMLCanvasElement) => void, deps: unknown[]) {
7 const ref = useRef<HTMLCanvasElement>(null);
8 useEffect(() => {
9 const c = ref.current;
10 if (!c) return;
11 const run = () => draw(c);
12 run();
13 const ro = new ResizeObserver(run);
14 ro.observe(c);
15 const mq = window.matchMedia("(prefers-color-scheme: dark)");
16 mq.addEventListener("change", run);
17 return () => {
18 ro.disconnect();
19 mq.removeEventListener("change", run);
20 };
21 // eslint-disable-next-line react-hooks/exhaustive-deps
22 }, deps);
23 return ref;
24}
26export function Waveform({ p, cursor }: { p: RunPayload; cursor: number | null }) {
27 const { audio, sampleRate } = p.result;
28 const ref = useCanvas(
29 c => {
30 const f = setupCanvas(c);
31 let peak = 0;
32 for (const v of audio) peak = Math.max(peak, Math.abs(v));
33 peak = peak || 1;
34 const T = audio.length / sampleRate;
35 drawAxes(f, [0, T], [-peak * 1e3, peak * 1e3], { xlabel: "time (s)", ylabel: "disp (mm)" });
36 const { ctx, x0, y0, pw, ph } = f;
37 ctx.fillStyle = f.css("--series");
38 const per = audio.length / pw;
39 for (let px = 0; px < pw; px++) {
40 let lo = Infinity;
41 let hi = -Infinity;
42 const a = Math.floor(px * per);
43 const b = Math.max(a + 1, Math.floor((px + 1) * per));
44 for (let i = a; i < b && i < audio.length; i++) {
45 lo = Math.min(lo, audio[i]);
46 hi = Math.max(hi, audio[i]);
47 }
48 const yhi = y0 + ph / 2 - (hi / peak) * (ph / 2);
49 const ylo = y0 + ph / 2 - (lo / peak) * (ph / 2);
50 ctx.fillRect(x0 + px, yhi, 1, Math.max(1, ylo - yhi));
51 }
52 if (cursor !== null) {
53 ctx.fillStyle = f.css("--accent");
54 ctx.fillRect(x0 + (cursor / T) * pw, y0, 1.5, ph);
55 }
56 },
57 [audio, cursor]
58 );
59 return <canvas ref={ref} className="plot short" />;
60}
62export function Spectrogram({ p, fmax }: { p: RunPayload; fmax: number }) {
63 const { audio, sampleRate } = p.result;
64 const sg = useMemo(() => spectrogram(audio), [audio]);
65 const ref = useCanvas(
66 c => {
67 const f = setupCanvas(c);
68 const T = audio.length / sampleRate;
69 const { ctx, x0, y0, pw, ph } = f;
70 const binHz = sampleRate / sg.win;
71 const nb = Math.min(sg.nBins, Math.ceil(fmax / binHz));
72 const img = ctx.createImageData(sg.nFrames, nb);
73 const range = 80;
74 for (let fr = 0; fr < sg.nFrames; fr++) {
75 for (let b = 0; b < nb; b++) {
76 const t = (sg.db[fr * sg.nBins + b] - (sg.maxDb - range)) / range;
77 const [r, g, bl] = heat(t);
78 const o = 4 * ((nb - 1 - b) * sg.nFrames + fr);
79 img.data[o] = r;
80 img.data[o + 1] = g;
81 img.data[o + 2] = bl;
82 img.data[o + 3] = 255;
83 }
84 }
85 const off = new OffscreenCanvas(sg.nFrames, nb);
86 off.getContext("2d")!.putImageData(img, 0, 0);
87 ctx.imageSmoothingEnabled = true;
88 const tEnd = ((sg.nFrames - 1) * sg.hop + sg.win) / sampleRate;
89 ctx.drawImage(off, x0, y0, (pw * tEnd) / T, ph);
90 drawAxes(f, [0, T], [0, (nb * binHz) / 1000], { grid: false, xlabel: "time (s)", ylabel: "kHz" });
91 },
92 [sg, fmax]
93 );
94 return <canvas ref={ref} className="plot" />;
95}
97export function Energy({ p }: { p: RunPayload }) {
98 const { frameTimes, energy, dissipated } = p.result;
99 const ref = useCanvas(
100 c => {
101 const f = setupCanvas(c);
102 const T = p.sys.duration;
103 let emax = 0;
104 for (let i = 0; i < energy.length; i++) emax = Math.max(emax, energy[i] + dissipated[i]);
105 emax = emax || 1;
106 const unit = emax < 1e-3 ? 1e6 : emax < 1 ? 1e3 : 1;
107 const uname = unit === 1e6 ? "µJ" : unit === 1e3 ? "mJ" : "J";
108 drawAxes(f, [0, T], [0, emax * unit * 1.05], { xlabel: "time (s)", ylabel: `energy (${uname})` });
109 const { ctx, x0, y0, pw, ph } = f;
110 const line = (ys: (i: number) => number, color: string) => {
111 ctx.strokeStyle = color;
112 ctx.lineWidth = 1.5;
113 ctx.beginPath();
114 for (let i = 0; i < frameTimes.length; i++) {
115 const px = x0 + (frameTimes[i] / T) * pw;
116 const py = y0 + ph - (ys(i) / (emax * 1.05)) * ph;
117 if (i) ctx.lineTo(px, py);
118 else ctx.moveTo(px, py);
119 }
120 ctx.stroke();
121 };
122 line(i => energy[i] + dissipated[i], f.css("--series2"));
123 line(i => energy[i], f.css("--series"));
124 },
125 [p]
126 );
127 return (
128 <>
129 <canvas ref={ref} className="plot short" />
130 <div className="legend">
131 <span className="swatch s1" /> in the beads <span className="swatch s2" /> plus dissipated by damping
132 </div>
133 </>
134 );
135}
137export function ModeEnergy({ p }: { p: RunPayload }) {
138 const { modeEnergy, frameTimes } = p.result;
139 const [tmax, setTmax] = useState(p.sys.duration);
140 const freqs = p.modeFreqs;
141 const bands = useMemo(
142 () => (freqs && modeEnergy ? modeBands(freqs, modeEnergy, frameTimes.length) : null),
143 [freqs, modeEnergy, frameTimes]
144 );
145 const ref = useCanvas(
146 c => {
147 if (!bands) return;
148 const f = setupCanvas(c);
149 const { ctx, x0, y0, pw, ph } = f;
150 let nF = frameTimes.length;
151 while (nF > 2 && frameTimes[nF - 1] > tmax) nF--;
152 const { nBands, e, edges } = bands;
153 // log energy per band, relative to the total linear energy at each time
154 const img = ctx.createImageData(nF, nBands);
155 const range = 8; // decades
156 for (let fr = 0; fr < nF; fr++) {
157 let tot = 0;
158 for (let b = 0; b < nBands; b++) tot += e[fr * nBands + b];
159 for (let b = 0; b < nBands; b++) {
160 const v = tot > 0 ? Math.log10(e[fr * nBands + b] / tot + 1e-300) : -range;
161 const [r, g, bl] = heat(1 + v / range);
162 const o = 4 * ((nBands - 1 - b) * nF + fr);
163 img.data[o] = r;
164 img.data[o + 1] = g;
165 img.data[o + 2] = bl;
166 img.data[o + 3] = e[fr * nBands + b] > 0 ? 255 : 0; // bands with no modes stay blank
167 }
168 }
169 const off = new OffscreenCanvas(nF, nBands);
170 off.getContext("2d")!.putImageData(img, 0, 0);
171 ctx.imageSmoothingEnabled = false;
172 ctx.drawImage(off, x0, y0, pw, ph);
173 const lf0 = Math.log10(edges[0]);
174 const lf1 = Math.log10(edges[nBands]);
175 const decs: number[] = [];
176 for (let d = Math.ceil(lf0); d <= lf1; d++) for (const m of [1, 2, 5]) if (Math.log10(m) + d >= lf0 && Math.log10(m) + d <= lf1) decs.push(Math.log10(m) + d);
177 drawAxes(f, [0, frameTimes[nF - 1]], [lf0, lf1], {
178 grid: false,
179 xlabel: "time (s)",
180 ylabel: "mode freq (Hz)",
181 yticks: decs.length ? decs : niceTicks(lf0, lf1, 4),
182 yfmt: v => String(Math.round(Math.pow(10, v))),
183 });
184 },
185 [bands, tmax]
186 );
187 if (!bands) return <div className="note">Mode energies are shown when linear modes are computed (flat systems only).</div>;
188 return (
189 <>
190 <canvas ref={ref} className="plot" />
191 <div className="legend">
192 Share of the linear (quadratic) energy in each band of transverse modes, 8 decades of color. Show the first{" "}
193 <select value={tmax} onChange={e => setTmax(+e.target.value)}>
194 {[0.03, 0.1, 0.3, 1]
195 .filter(t => t < p.sys.duration)
196 .concat([p.sys.duration])
197 .map(t => (
198 <option key={t} value={t}>
199 {t} s
200 </option>
201 ))}
202 </select>
203 </div>
204 </>
205 );
206}
208export function Spectrum({ p, fmax }: { p: RunPayload; fmax: number }) {
209 const { audio, sampleRate } = p.result;
210 const T = audio.length / sampleRate;
211 const [early, setEarly] = useState(0);
212 const [late, setLate] = useState(Math.max(0, T - 0.25));
213 const len = Math.round(Math.min(0.25, T) * sampleRate);
214 const sE = useMemo(() => spectrum(audio, Math.round(early * sampleRate), len), [audio, early, len, sampleRate]);
215 const sL = useMemo(() => spectrum(audio, Math.round(late * sampleRate), len), [audio, late, len, sampleRate]);
216 const ref = useCanvas(
217 c => {
218 const f = setupCanvas(c);
219 const { ctx, x0, y0, pw, ph } = f;
220 let top = -Infinity;
221 for (const s of [sE, sL]) for (const v of s.db) top = Math.max(top, v);
222 const yr: [number, number] = [top - 90, top + 3];
223 drawAxes(f, [0, fmax / 1000], [yr[0] - top, yr[1] - top], { xlabel: "kHz", ylabel: "dB" });
224 if (p.modeFreqs) {
225 ctx.strokeStyle = f.css("--grid-strong");
226 for (const fm of p.modeFreqs) {
227 if (fm > fmax) break;
228 const px = x0 + (fm / fmax) * pw;
229 ctx.beginPath();
230 ctx.moveTo(px, y0);
231 ctx.lineTo(px, y0 + 6);
232 ctx.stroke();
233 }
234 }
235 const line = (s: typeof sE, color: string) => {
236 ctx.strokeStyle = color;
237 ctx.lineWidth = 1.2;
238 ctx.beginPath();
239 const nb = Math.min(s.db.length, Math.ceil(fmax / (s.df * sampleRate)));
240 for (let b = 0; b < nb; b++) {
241 const px = x0 + ((b * s.df * sampleRate) / fmax) * pw;
242 const py = y0 + ph - ((s.db[b] - yr[0]) / (yr[1] - yr[0])) * ph;
243 if (b) ctx.lineTo(px, Math.min(py, y0 + ph));
244 else ctx.moveTo(px, Math.min(py, y0 + ph));
245 }
246 ctx.stroke();
247 };
248 line(sL, f.css("--series2"));
249 line(sE, f.css("--series"));
250 },
251 [sE, sL, fmax, p]
252 );
253 const maxStart = Math.max(0, T - len / sampleRate);
254 return (
255 <>
256 <canvas ref={ref} className="plot" />
257 <div className="legend">
258 <span className="swatch s1" /> window at{" "}
259 <input type="range" min={0} max={maxStart} step={0.005} value={early} onChange={e => setEarly(+e.target.value)} /> {early.toFixed(3)} s
260 <span className="swatch s2" /> window at{" "}
261 <input type="range" min={0} max={maxStart} step={0.005} value={late} onChange={e => setLate(+e.target.value)} /> {late.toFixed(3)} s
262 {p.modeFreqs && <span> · ticks at top: linear mode frequencies</span>}
263 </div>
264 </>
265 );
266}
268export function Separation({ p }: { p: RunPayload }) {
269 const tw = p.twin!;
270 const ref = useCanvas(
271 c => {
272 const f = setupCanvas(c);
273 const lo = Math.floor(Math.log10(tw.eps)) - 1;
274 drawAxes(f, [0, p.sys.duration], [lo, 1], {
275 xlabel: "time (s)",
276 ylabel: "log10 separation",
277 yticks: Array.from({ length: 2 - lo }, (_, i) => lo + i).filter(v => v % 2 === 0 || 1 - lo < 8),
278 });
279 const { ctx, x0, y0, pw, ph } = f;
280 ctx.strokeStyle = f.css("--series");
281 ctx.lineWidth = 1.5;
282 ctx.beginPath();
283 let started = false;
284 for (let i = 0; i < tw.times.length; i++) {
285 if (!(tw.sep[i] > 0)) continue;
286 const px = x0 + (tw.times[i] / p.sys.duration) * pw;
287 const py = y0 + ph - ((Math.log10(tw.sep[i]) - lo) / (1 - lo)) * ph;
288 if (started) ctx.lineTo(px, py);
289 else ctx.moveTo(px, py);
290 started = true;
291 }
292 ctx.stroke();
293 },
294 [tw]
295 );
296 return (
297 <>
298 <canvas ref={ref} className="plot short" />
299 <div className="legend">
300 Largest difference in any bead coordinate between the run and its twin, relative to the largest displacement over the preceding 50 ms. A straight rise
301 means exponential growth (chaos); regular motion drifts apart slowly. It levels off near 1 when the runs are unrelated.
302 </div>
303 </>
304 );
305}
307export function DecayTimes({ p }: { p: RunPayload }) {
308 const freqs = p.modeFreqs!;
309 const sig = p.modeDecay!;
310 const ref = useCanvas(
311 c => {
312 const f = setupCanvas(c);
313 const T60 = Array.from(sig, s => (s > 0 ? Math.log(1000) / s : Infinity));
314 const finite = T60.filter(Number.isFinite);
315 const fx0 = Math.log10(freqs[0] * 0.9);
316 const fx1 = Math.log10(freqs[freqs.length - 1] * 1.1);
317 let ty0 = Math.floor(Math.log10(Math.min(...finite, 1)) * 2) / 2;
318 let ty1 = Math.ceil(Math.log10(Math.max(...finite, 1)) * 2) / 2;
319 if (ty1 - ty0 < 1) (ty0 -= 0.5), (ty1 += 0.5);
320 const logTicks = (lo: number, hi: number) => {
321 const out: number[] = [];
322 for (let d = Math.floor(lo); d <= hi; d++) for (const m of [1, 2, 5]) if (d + Math.log10(m) >= lo && d + Math.log10(m) <= hi) out.push(d + Math.log10(m));
323 return out;
324 };
325 drawAxes(f, [fx0, fx1], [ty0, ty1], {
326 xlabel: "mode frequency (Hz)",
327 ylabel: "T60 (s)",
328 xticks: logTicks(fx0, fx1),
329 yticks: logTicks(ty0, ty1),
330 xfmt: v => fmt(Math.pow(10, v)),
331 yfmt: v => fmt(Math.pow(10, v)),
332 });
333 const { ctx, x0, y0, pw, ph } = f;
334 ctx.fillStyle = f.css("--series");
335 for (let k = 0; k < freqs.length; k++) {
336 if (!Number.isFinite(T60[k])) continue;
337 const px = x0 + ((Math.log10(freqs[k]) - fx0) / (fx1 - fx0)) * pw;
338 const py = y0 + ph - ((Math.log10(T60[k]) - ty0) / (ty1 - ty0)) * ph;
339 ctx.fillRect(px - 1.5, py - 1.5, 3, 3);
340 }
341 },
342 [p]
343 );
344 const undamped = Array.from(sig).every(s => s === 0);
345 if (undamped) return <div className="note">No damping: every mode rings indefinitely.</div>;
346 return (
347 <>
348 <canvas ref={ref} className="plot short" />
349 <div className="legend">
350 Time for each transverse mode to fall 60 dB at small amplitude, from sys.friction and sys.dashpots. Use bp_decay to set it from two
351 (frequency, T60) pairs.
352 </div>
353 </>
354 );
355}