import { useCallback } from 'react' import { useCanvas } from '../hooks/useCanvas' import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot' type Props = { samples: Float32Array fs: number /** start of the visible window in seconds */ startSec: number /** length of the visible window in seconds */ spanSec: number yMax: number label: string color: string /** highlight this region (the zoom window) when drawing the full clip */ highlight?: [number, number] } const M = { l: 48, r: 12, t: 6, b: 22 } /** One waveform. Draws a min/max envelope when there are many samples per pixel, a line otherwise. */ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, color, highlight }: Props) { const draw = useCallback( (ctx: CanvasRenderingContext2D, w: number, h: number) => { const pw = w - M.l - M.r const ph = h - M.t - M.b const ymax = yMax > 0 ? yMax : 1 const y0 = M.t + ph / 2 const toY = (v: number) => y0 - (v / ymax) * (ph / 2) const toX = (t: number) => M.l + ((t - startSec) / spanSec) * pw ctx.font = FONT ctx.lineWidth = 1 if (highlight) { ctx.fillStyle = 'rgba(31, 95, 168, 0.10)' const a = Math.max(M.l, toX(highlight[0])) const b = Math.min(M.l + pw, toX(highlight[1])) if (b > a) ctx.fillRect(a, M.t, b - a, ph) } ctx.strokeStyle = GRID ctx.fillStyle = AXIS ctx.textAlign = 'center' ctx.textBaseline = 'top' for (const v of niceTicks(startSec, startSec + spanSec, 6)) { const x = Math.round(toX(v)) + 0.5 if (x < M.l || x > M.l + pw) continue ctx.beginPath() ctx.moveTo(x, M.t) ctx.lineTo(x, M.t + ph) ctx.stroke() ctx.fillText(spanSec < 0.5 ? `${formatTick(v * 1000)}` : formatTick(v), x, M.t + ph + 4) } ctx.textAlign = 'right' ctx.textBaseline = 'middle' ctx.fillText(formatTick(ymax), M.l - 5, toY(ymax)) ctx.fillText('0', M.l - 5, y0) ctx.fillText(formatTick(-ymax), M.l - 5, toY(-ymax)) ctx.strokeStyle = '#b8bcc2' ctx.beginPath() ctx.moveTo(M.l, Math.round(y0) + 0.5) ctx.lineTo(M.l + pw, Math.round(y0) + 0.5) ctx.stroke() ctx.strokeStyle = FRAME ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph) ctx.fillStyle = AXIS ctx.textAlign = 'right' ctx.textBaseline = 'bottom' ctx.fillText(spanSec < 0.5 ? 'ms' : 's', M.l + pw, h - 1) const i0 = Math.max(0, Math.floor(startSec * fs)) const i1 = Math.min(samples.length, Math.ceil((startSec + spanSec) * fs)) const perPx = (i1 - i0) / pw ctx.save() ctx.beginPath() ctx.rect(M.l, M.t, pw, ph) ctx.clip() ctx.strokeStyle = color ctx.fillStyle = color ctx.lineWidth = 1.2 ctx.lineJoin = 'round' if (perPx > 2) { // min/max envelope per pixel column for (let px = 0; px < pw; px++) { const a = i0 + Math.floor(px * perPx) const b = Math.min(i1, i0 + Math.floor((px + 1) * perPx)) if (b <= a) continue let lo = Infinity let hi = -Infinity for (let i = a; i < b; i++) { const v = samples[i] if (v < lo) lo = v if (v > hi) hi = v } const yTop = toY(hi) const yBot = toY(lo) ctx.fillRect(M.l + px, yTop, 1, Math.max(1, yBot - yTop)) } } else { ctx.beginPath() for (let i = i0; i < i1; i++) { const px = toX(i / fs) const py = toY(samples[i]) if (i === i0) ctx.moveTo(px, py) else ctx.lineTo(px, py) } ctx.stroke() } 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 + 4) }, [samples, fs, startSec, spanSec, yMax, label, color, highlight], ) const ref = useCanvas(draw) return }