// Static canvas plots for the inspector: a waveform, a spectrogram of a finished signal, and the // energy log. Each draw sizes the backing store to the canvas's CSS box at the device pixel ratio. import { Stft } from "../analysis/spectrogram.ts"; function setup(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; w: number; h: number } { const dpr = Math.min(2, window.devicePixelRatio || 1); const w = Math.max(50, canvas.clientWidth), h = Math.max(50, canvas.clientHeight); canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); const ctx = canvas.getContext("2d")!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = "#101216"; ctx.fillRect(0, 0, w, h); ctx.font = "10px system-ui"; ctx.textBaseline = "top"; return { ctx, w, h }; } /** Inferno-like colormap for spectrogram power, t in [0, 1]. */ function heat(t: number): string { 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]]; const x = Math.min(0.9999, Math.max(0, t)) * (s.length - 1), i = Math.floor(x), f = x - i; const c = (k: number) => Math.round(s[i][k] + f * (s[i + 1][k] - s[i][k])); return `rgb(${c(0)},${c(1)},${c(2)})`; } export function drawWaveform(canvas: HTMLCanvasElement, samples: ArrayLike, fs: number, unit: string) { const { ctx, w, h } = setup(canvas); const n = samples.length; if (n === 0) return; let peak = 0; for (let i = 0; i < n; i++) { const a = Math.abs(samples[i]); if (a > peak) peak = a; } peak = peak || 1; const mid = h / 2, cols = Math.floor(w); ctx.strokeStyle = "#2c313a"; ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke(); ctx.fillStyle = "#4fc3f7"; for (let c = 0; c < cols; c++) { const i0 = Math.floor((c / cols) * n), i1 = Math.max(i0 + 1, Math.floor(((c + 1) / cols) * n)); let lo = Infinity, hi = -Infinity; for (let i = i0; i < i1; i++) { const v = samples[i]; if (v < lo) lo = v; if (v > hi) hi = v; } const y0 = mid - (hi / peak) * (mid - 12), y1 = mid - (lo / peak) * (mid - 12); ctx.fillRect(c, y0, 1, Math.max(1, y1 - y0)); } ctx.fillStyle = "#8b93a1"; ctx.fillText(`peak ${peak.toExponential(2)} ${unit}`, 4, 2); ctx.fillText(`${(n / fs).toFixed(3)} s`, w - 50, 2); } export function drawSpectrogram(canvas: HTMLCanvasElement, samples: Float32Array, fs: number, rangeDb = 80) { const { ctx, w, h } = setup(canvas); const nfft = Math.min(1024, 1 << Math.floor(Math.log2(Math.max(64, samples.length / 4)))); const st = new Stft(fs, nfft, Math.max(32, nfft / 4)); st.push(samples); const C = st.columns.length, B = st.nBins; if (C === 0) { ctx.fillStyle = "#8b93a1"; ctx.fillText("signal too short for a spectrogram", 4, 2); return; } let pmax = 0; for (const col of st.columns) for (const p of col) if (p > pmax) pmax = p; pmax = pmax || 1; const axis = 26, plotW = w - 2, plotH = h - axis; for (let c = 0; c < C; c++) { const x = (c / C) * plotW, cw = Math.max(1, plotW / C); const col = st.columns[c]; for (let b = 0; b < B; b++) { const t = 1 + (10 * Math.log10(col[b] / pmax + 1e-30)) / rangeDb; if (t <= 0) continue; const y = plotH - ((b + 1) / B) * plotH; ctx.fillStyle = heat(t); ctx.fillRect(x, y, cw, Math.max(1, plotH / B)); } } ctx.fillStyle = "#8b93a1"; ctx.fillText("0 Hz", 4, h - 12); ctx.fillText(`${(fs / 2 / 1000).toFixed(1)} kHz (top) · ${rangeDb} dB range`, 40, h - 12); } const ENERGY_SERIES = [ { col: 1, name: "kinetic", color: "#4fc3f7" }, { col: 2, name: "membrane", color: "#d9a441" }, { col: 3, name: "bending", color: "#ff50d0" }, { col: 4, name: "exciters", color: "#40ff90" }, ]; /** Energy log: energies on a log scale (60 dB below the maximum), max|w|/h in a lower panel. */ export function drawEnergy(canvas: HTMLCanvasElement, rows: number[][]) { const { ctx, w, h } = setup(canvas); if (rows.length < 2) { ctx.fillStyle = "#8b93a1"; ctx.fillText("no energy log", 4, 2); return; } const t1 = rows[rows.length - 1][0] || 1; const topH = Math.round(h * 0.62), botY = topH + 14, botH = h - botY - 4; let emax = 0, wmax = 0; for (const r of rows) { for (const s of ENERGY_SERIES) emax = Math.max(emax, r[s.col]); wmax = Math.max(wmax, r[5]); } emax = emax || 1; wmax = wmax || 1; const decades = 6; const xOf = (t: number) => 2 + (t / t1) * (w - 4); const yOf = (e: number) => 12 + (1 - Math.max(0, 1 + Math.log10(e / emax + 1e-30) / decades)) * (topH - 14); for (const s of ENERGY_SERIES) { ctx.strokeStyle = s.color; ctx.beginPath(); 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); }); ctx.stroke(); } ctx.strokeStyle = "#e0e4ea"; ctx.beginPath(); rows.forEach((r, i) => { const x = xOf(r[0]), y = botY + (1 - r[5] / wmax) * botH; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.fillStyle = "#8b93a1"; let lx = 4; for (const s of ENERGY_SERIES) { ctx.fillStyle = s.color; ctx.fillText(s.name, lx, 0); lx += ctx.measureText(s.name).width + 10; } ctx.fillStyle = "#8b93a1"; ctx.fillText(`top: energy [J], log, ${decades} decades below ${emax.toExponential(1)}`, lx + 6, 0); ctx.fillText(`max|w|/h (peak ${wmax.toFixed(2)}) · 0 to ${t1.toFixed(2)} s`, 4, botY - 12); }