import { useCallback, useMemo } from 'react' import { useCanvas } from '../hooks/useCanvas' import { AXIS, FONT, FRAME, formatTick, magma, niceTicks } from '../plot' import type { SpectrogramData } from '../types' type Props = { spec: SpectrogramData /** dB below the shared maximum shown as black */ dbRange: number label: string color: string } const M = { l: 48, r: 12, t: 8, b: 26 } /** Draws a dB spectrogram as an image with time and frequency axes. */ export function SpectrogramPlot({ spec, dbRange, label, color }: Props) { // Rasterize once per spectrogram, then scale to the canvas on every draw. const image = useMemo(() => { const { rows, cols, db } = spec const off = document.createElement('canvas') off.width = cols off.height = rows const ictx = off.getContext('2d') if (!ictx) return null const img = ictx.createImageData(cols, rows) const d = img.data for (let r = 0; r < rows; r++) { const yImg = rows - 1 - r // row 0 (0 Hz) at the bottom for (let c = 0; c < cols; c++) { const v = db[r * cols + c] const [R, G, B] = magma(1 + v / dbRange) const o = 4 * (yImg * cols + c) d[o] = R d[o + 1] = G d[o + 2] = B d[o + 3] = 255 } } ictx.putImageData(img, 0, 0) return off }, [spec, dbRange]) const draw = useCallback( (ctx: CanvasRenderingContext2D, w: number, h: number) => { const pw = w - M.l - M.r const ph = h - M.t - M.b ctx.font = FONT if (image) { ctx.imageSmoothingEnabled = false ctx.drawImage(image, M.l, M.t, pw, ph) } ctx.strokeStyle = FRAME ctx.lineWidth = 1 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph) ctx.fillStyle = AXIS ctx.textAlign = 'right' ctx.textBaseline = 'middle' for (const v of niceTicks(0, spec.fmax, 4)) { if (v > spec.fmax) continue const y = M.t + ph - (v / spec.fmax) * ph ctx.fillText(formatTick(v), M.l - 5, y) } ctx.textAlign = 'center' ctx.textBaseline = 'top' for (const v of niceTicks(0, spec.durationSec, 6)) { if (v > spec.durationSec) continue const x = M.l + (v / spec.durationSec) * pw ctx.fillText(formatTick(v), x, M.t + ph + 4) } ctx.textBaseline = 'bottom' ctx.fillText('time (s)', M.l + pw / 2, h - 1) ctx.save() ctx.translate(11, M.t + ph / 2) ctx.rotate(-Math.PI / 2) ctx.textBaseline = 'middle' ctx.fillText('Hz', 0, 0) ctx.restore() ctx.fillStyle = color ctx.font = 'bold 12px system-ui, sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'top' ctx.fillText(label, M.l + 6, M.t + 5) }, [image, spec, label, color], ) const ref = useCanvas(draw) return }