/ proof-of-concept / cymroom
Sign in
proof-of-concept / cymroom
cymroom / src / ui / plots.ts
107 lines · 5.3 KBBlameHistoryRaw
1// Static canvas plots for the inspector: a waveform, a spectrogram of a finished signal, and the
2// energy log. Each draw sizes the backing store to the canvas's CSS box at the device pixel ratio.
3import { Stft } from "../analysis/spectrogram.ts";
5function setup(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; w: number; h: number } {
6 const dpr = Math.min(2, window.devicePixelRatio || 1);
7 const w = Math.max(50, canvas.clientWidth), h = Math.max(50, canvas.clientHeight);
8 canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
9 const ctx = canvas.getContext("2d")!;
10 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
11 ctx.fillStyle = "#101216"; ctx.fillRect(0, 0, w, h);
12 ctx.font = "10px system-ui"; ctx.textBaseline = "top";
13 return { ctx, w, h };
16/** Inferno-like colormap for spectrogram power, t in [0, 1]. */
17function heat(t: number): string {
18 const s = [[0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164]];
19 const x = Math.min(0.9999, Math.max(0, t)) * (s.length - 1), i = Math.floor(x), f = x - i;
20 const c = (k: number) => Math.round(s[i][k] + f * (s[i + 1][k] - s[i][k]));
21 return `rgb(${c(0)},${c(1)},${c(2)})`;
24export function drawWaveform(canvas: HTMLCanvasElement, samples: ArrayLike<number>, fs: number, unit: string) {
25 const { ctx, w, h } = setup(canvas);
26 const n = samples.length;
27 if (n === 0) return;
28 let peak = 0;
29 for (let i = 0; i < n; i++) { const a = Math.abs(samples[i]); if (a > peak) peak = a; }
30 peak = peak || 1;
31 const mid = h / 2, cols = Math.floor(w);
32 ctx.strokeStyle = "#2c313a"; ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke();
33 ctx.fillStyle = "#4fc3f7";
34 for (let c = 0; c < cols; c++) {
35 const i0 = Math.floor((c / cols) * n), i1 = Math.max(i0 + 1, Math.floor(((c + 1) / cols) * n));
36 let lo = Infinity, hi = -Infinity;
37 for (let i = i0; i < i1; i++) { const v = samples[i]; if (v < lo) lo = v; if (v > hi) hi = v; }
38 const y0 = mid - (hi / peak) * (mid - 12), y1 = mid - (lo / peak) * (mid - 12);
39 ctx.fillRect(c, y0, 1, Math.max(1, y1 - y0));
40 }
41 ctx.fillStyle = "#8b93a1";
42 ctx.fillText(`peak ${peak.toExponential(2)} ${unit}`, 4, 2);
43 ctx.fillText(`${(n / fs).toFixed(3)} s`, w - 50, 2);
46export function drawSpectrogram(canvas: HTMLCanvasElement, samples: Float32Array, fs: number, rangeDb = 80) {
47 const { ctx, w, h } = setup(canvas);
48 const nfft = Math.min(1024, 1 << Math.floor(Math.log2(Math.max(64, samples.length / 4))));
49 const st = new Stft(fs, nfft, Math.max(32, nfft / 4));
50 st.push(samples);
51 const C = st.columns.length, B = st.nBins;
52 if (C === 0) { ctx.fillStyle = "#8b93a1"; ctx.fillText("signal too short for a spectrogram", 4, 2); return; }
53 let pmax = 0;
54 for (const col of st.columns) for (const p of col) if (p > pmax) pmax = p;
55 pmax = pmax || 1;
56 const axis = 26, plotW = w - 2, plotH = h - axis;
57 for (let c = 0; c < C; c++) {
58 const x = (c / C) * plotW, cw = Math.max(1, plotW / C);
59 const col = st.columns[c];
60 for (let b = 0; b < B; b++) {
61 const t = 1 + (10 * Math.log10(col[b] / pmax + 1e-30)) / rangeDb;
62 if (t <= 0) continue;
63 const y = plotH - ((b + 1) / B) * plotH;
64 ctx.fillStyle = heat(t);
65 ctx.fillRect(x, y, cw, Math.max(1, plotH / B));
66 }
67 }
68 ctx.fillStyle = "#8b93a1";
69 ctx.fillText("0 Hz", 4, h - 12);
70 ctx.fillText(`${(fs / 2 / 1000).toFixed(1)} kHz (top) · ${rangeDb} dB range`, 40, h - 12);
73const ENERGY_SERIES = [
74 { col: 1, name: "kinetic", color: "#4fc3f7" }, { col: 2, name: "membrane", color: "#d9a441" },
75 { col: 3, name: "bending", color: "#ff50d0" }, { col: 4, name: "exciters", color: "#40ff90" },
76];
78/** Energy log: energies on a log scale (60 dB below the maximum), max|w|/h in a lower panel. */
79export function drawEnergy(canvas: HTMLCanvasElement, rows: number[][]) {
80 const { ctx, w, h } = setup(canvas);
81 if (rows.length < 2) { ctx.fillStyle = "#8b93a1"; ctx.fillText("no energy log", 4, 2); return; }
82 const t1 = rows[rows.length - 1][0] || 1;
83 const topH = Math.round(h * 0.62), botY = topH + 14, botH = h - botY - 4;
84 let emax = 0, wmax = 0;
85 for (const r of rows) { for (const s of ENERGY_SERIES) emax = Math.max(emax, r[s.col]); wmax = Math.max(wmax, r[5]); }
86 emax = emax || 1; wmax = wmax || 1;
87 const decades = 6;
88 const xOf = (t: number) => 2 + (t / t1) * (w - 4);
89 const yOf = (e: number) => 12 + (1 - Math.max(0, 1 + Math.log10(e / emax + 1e-30) / decades)) * (topH - 14);
90 for (const s of ENERGY_SERIES) {
91 ctx.strokeStyle = s.color; ctx.beginPath();
92 rows.forEach((r, i) => { const x = xOf(r[0]), y = yOf(r[s.col]); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); });
93 ctx.stroke();
94 }
95 ctx.strokeStyle = "#e0e4ea"; ctx.beginPath();
96 rows.forEach((r, i) => {
97 const x = xOf(r[0]), y = botY + (1 - r[5] / wmax) * botH;
98 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
99 });
100 ctx.stroke();
101 ctx.fillStyle = "#8b93a1";
102 let lx = 4;
103 for (const s of ENERGY_SERIES) { ctx.fillStyle = s.color; ctx.fillText(s.name, lx, 0); lx += ctx.measureText(s.name).width + 10; }
104 ctx.fillStyle = "#8b93a1";
105 ctx.fillText(`top: energy [J], log, ${decades} decades below ${emax.toExponential(1)}`, lx + 6, 0);
106 ctx.fillText(`max|w|/h (peak ${wmax.toFixed(2)}) · 0 to ${t1.toFixed(2)} s`, 4, botY - 12);
↑↓ move↵openescclose