import { useCallback, useRef } from 'react' import { useCanvas } from '../hooks/useCanvas' import { useZoomPan, type View } from '../hooks/useZoomPan' import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot' type Props = { samples: Float32Array fs: number /** visible window in seconds; shared between plots so they stay synchronized */ view: View onView: (v: View) => void yMax: number label: string color: string } const M = { l: 48, r: 12, t: 6, b: 22 } const MARGINS = { left: M.l, right: M.r } const MIN_SPAN_SAMPLES = 16 /** * One waveform with drag-to-zoom, wheel zoom, and double-click reset. Draws a * min/max envelope when there are many samples per pixel, a line otherwise. */ export function WaveformPlot({ samples, fs, view, onView, yMax, label, color }: Props) { const duration = samples.length / fs const { start: startSec, span: spanSec } = view 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 const ms = spanSec < 0.5 ctx.font = FONT ctx.lineWidth = 1 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(formatTick(ms ? v * 1000 : 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(ms ? '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) + 1) const perPx = ((i1 - i0) * 1.0) / 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 const sampPerPx = (spanSec * fs) / pw for (let px = 0; px < pw; px++) { const a = Math.max(0, Math.floor(startSec * fs + px * sampPerPx)) const b = Math.min(samples.length, Math.floor(startSec * fs + (px + 1) * sampPerPx)) 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() if (perPx < 0.25) { // individual samples are visible: mark them for (let i = i0; i < i1; i++) { ctx.beginPath() ctx.arc(toX(i / fs), toY(samples[i]), 2, 0, 2 * Math.PI) ctx.fill() } } } 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], ) const ref = useCanvas(draw) const overlay = useRef(null) useZoomPan(ref, overlay, view, duration, MIN_SPAN_SAMPLES / fs, MARGINS, onView) return (
) }