1import { useCallback } from 'react'
2import { useCanvas } from '../hooks/useCanvas'
3import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot'
5type Props = {
6 samples: Float32Array
7 fs: number
8 /** start of the visible window in seconds */
9 startSec: number
10 /** length of the visible window in seconds */
11 spanSec: number
12 yMax: number
13 label: string
14 color: string
15 /** highlight this region (the zoom window) when drawing the full clip */
16 highlight?: [number, number]
17}
19const M = { l: 48, r: 12, t: 6, b: 22 }
21/** One waveform. Draws a min/max envelope when there are many samples per pixel, a line otherwise. */
22export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, color, highlight }: Props) {
23 const draw = useCallback(
24 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
25 const pw = w - M.l - M.r
26 const ph = h - M.t - M.b
27 const ymax = yMax > 0 ? yMax : 1
28 const y0 = M.t + ph / 2
29 const toY = (v: number) => y0 - (v / ymax) * (ph / 2)
30 const toX = (t: number) => M.l + ((t - startSec) / spanSec) * pw
32 ctx.font = FONT
33 ctx.lineWidth = 1
34 if (highlight) {
35 ctx.fillStyle = 'rgba(31, 95, 168, 0.10)'
36 const a = Math.max(M.l, toX(highlight[0]))
37 const b = Math.min(M.l + pw, toX(highlight[1]))
38 if (b > a) ctx.fillRect(a, M.t, b - a, ph)
39 }
40 ctx.strokeStyle = GRID
41 ctx.fillStyle = AXIS
42 ctx.textAlign = 'center'
43 ctx.textBaseline = 'top'
44 for (const v of niceTicks(startSec, startSec + spanSec, 6)) {
45 const x = Math.round(toX(v)) + 0.5
46 if (x < M.l || x > M.l + pw) continue
47 ctx.beginPath()
48 ctx.moveTo(x, M.t)
49 ctx.lineTo(x, M.t + ph)
50 ctx.stroke()
51 ctx.fillText(spanSec < 0.5 ? `${formatTick(v * 1000)}` : formatTick(v), x, M.t + ph + 4)
52 }
53 ctx.textAlign = 'right'
54 ctx.textBaseline = 'middle'
55 ctx.fillText(formatTick(ymax), M.l - 5, toY(ymax))
56 ctx.fillText('0', M.l - 5, y0)
57 ctx.fillText(formatTick(-ymax), M.l - 5, toY(-ymax))
58 ctx.strokeStyle = '#b8bcc2'
59 ctx.beginPath()
60 ctx.moveTo(M.l, Math.round(y0) + 0.5)
61 ctx.lineTo(M.l + pw, Math.round(y0) + 0.5)
62 ctx.stroke()
63 ctx.strokeStyle = FRAME
64 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
65 ctx.fillStyle = AXIS
66 ctx.textAlign = 'right'
67 ctx.textBaseline = 'bottom'
68 ctx.fillText(spanSec < 0.5 ? 'ms' : 's', M.l + pw, h - 1)
70 const i0 = Math.max(0, Math.floor(startSec * fs))
71 const i1 = Math.min(samples.length, Math.ceil((startSec + spanSec) * fs))
72 const perPx = (i1 - i0) / pw
73 ctx.save()
74 ctx.beginPath()
75 ctx.rect(M.l, M.t, pw, ph)
76 ctx.clip()
77 ctx.strokeStyle = color
78 ctx.fillStyle = color
79 ctx.lineWidth = 1.2
80 ctx.lineJoin = 'round'
81 if (perPx > 2) {
82 // min/max envelope per pixel column
83 for (let px = 0; px < pw; px++) {
84 const a = i0 + Math.floor(px * perPx)
85 const b = Math.min(i1, i0 + Math.floor((px + 1) * perPx))
86 if (b <= a) continue
87 let lo = Infinity
88 let hi = -Infinity
89 for (let i = a; i < b; i++) {
90 const v = samples[i]
91 if (v < lo) lo = v
92 if (v > hi) hi = v
93 }
94 const yTop = toY(hi)
95 const yBot = toY(lo)
96 ctx.fillRect(M.l + px, yTop, 1, Math.max(1, yBot - yTop))
97 }
98 } else {
99 ctx.beginPath()
100 for (let i = i0; i < i1; i++) {
101 const px = toX(i / fs)
102 const py = toY(samples[i])
103 if (i === i0) ctx.moveTo(px, py)
104 else ctx.lineTo(px, py)
105 }
106 ctx.stroke()
107 }
108 ctx.restore()
110 ctx.fillStyle = color
111 ctx.font = 'bold 12px system-ui, sans-serif'
112 ctx.textAlign = 'left'
113 ctx.textBaseline = 'top'
114 ctx.fillText(label, M.l + 6, M.t + 4)
115 },
116 [samples, fs, startSec, spanSec, yMax, label, color, highlight],
117 )
118 const ref = useCanvas(draw)
119 return <canvas ref={ref} className="plot waveform-plot" />
120}