/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / components / WaveformPlot.tsx
130 lines · 4.3 KBBlameHistoryRaw
1import { useCallback } from 'react'
2import { useCanvas } from '../hooks/useCanvas'
3import { useZoomPan, type View } from '../hooks/useZoomPan'
4import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot'
6type Props = {
7 samples: Float32Array
8 fs: number
9 /** visible window in seconds; shared between plots so they stay synchronized */
10 view: View
11 onView: (v: View) => void
12 yMax: number
13 label: string
14 color: string
17const M = { l: 48, r: 12, t: 6, b: 22 }
18const MARGINS = { left: M.l, right: M.r }
19const MIN_SPAN_SAMPLES = 16
21/**
22 * One waveform with wheel zoom, drag pan, and double-click reset. Draws a
23 * min/max envelope when there are many samples per pixel, a line otherwise.
24 */
25export function WaveformPlot({ samples, fs, view, onView, yMax, label, color }: Props) {
26 const duration = samples.length / fs
27 const { start: startSec, span: spanSec } = view
28 const draw = useCallback(
29 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
30 const pw = w - M.l - M.r
31 const ph = h - M.t - M.b
32 const ymax = yMax > 0 ? yMax : 1
33 const y0 = M.t + ph / 2
34 const toY = (v: number) => y0 - (v / ymax) * (ph / 2)
35 const toX = (t: number) => M.l + ((t - startSec) / spanSec) * pw
36 const ms = spanSec < 0.5
38 ctx.font = FONT
39 ctx.lineWidth = 1
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(formatTick(ms ? v * 1000 : 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(ms ? '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) + 1)
72 const perPx = ((i1 - i0) * 1.0) / 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 const sampPerPx = (spanSec * fs) / pw
84 for (let px = 0; px < pw; px++) {
85 const a = Math.max(0, Math.floor(startSec * fs + px * sampPerPx))
86 const b = Math.min(samples.length, Math.floor(startSec * fs + (px + 1) * sampPerPx))
87 if (b <= a) continue
88 let lo = Infinity
89 let hi = -Infinity
90 for (let i = a; i < b; i++) {
91 const v = samples[i]
92 if (v < lo) lo = v
93 if (v > hi) hi = v
94 }
95 const yTop = toY(hi)
96 const yBot = toY(lo)
97 ctx.fillRect(M.l + px, yTop, 1, Math.max(1, yBot - yTop))
98 }
99 } else {
100 ctx.beginPath()
101 for (let i = i0; i < i1; i++) {
102 const px = toX(i / fs)
103 const py = toY(samples[i])
104 if (i === i0) ctx.moveTo(px, py)
105 else ctx.lineTo(px, py)
106 }
107 ctx.stroke()
108 if (perPx < 0.25) {
109 // individual samples are visible: mark them
110 for (let i = i0; i < i1; i++) {
111 ctx.beginPath()
112 ctx.arc(toX(i / fs), toY(samples[i]), 2, 0, 2 * Math.PI)
113 ctx.fill()
114 }
115 }
116 }
117 ctx.restore()
119 ctx.fillStyle = color
120 ctx.font = 'bold 12px system-ui, sans-serif'
121 ctx.textAlign = 'left'
122 ctx.textBaseline = 'top'
123 ctx.fillText(label, M.l + 6, M.t + 4)
124 },
125 [samples, fs, startSec, spanSec, yMax, label, color],
126 )
127 const ref = useCanvas(draw)
128 useZoomPan(ref, view, duration, MIN_SPAN_SAMPLES / fs, MARGINS, onView)
129 return <canvas ref={ref} className="plot waveform-plot" />
↑↓ move↵openescclose