- WaveformCanvas: a ResizeObserver keeps the drawing buffer matched to the element size (was measured once at mount), so the trace is centered and peaks never clip; taller 100px window; clamp to ±45% height. - useTelemetry: signed oscilloscope signal (idle wave at rest, decaying ring on impact, bounded ±0.95) instead of a one-sided magnitude that overshot the box. - useMatrixMirror: poll 8 → 15 fps. The board renders at ~12 fps and a matrix_get round-trip is only ~15 ms, so 8 fps under-sampled (stutter); 15 fps oversamples cleanly, well under the ~68 fps ceiling. Co-Authored-By: Claude Opus 4.8 <[email protected]>
80 lines
2.4 KiB
TypeScript
80 lines
2.4 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
|
|
/**
|
|
* The live-acceleration waveform. Reads the magnitude history from a ref and
|
|
* redraws via requestAnimationFrame — independent of React renders, so it stays
|
|
* smooth. A ResizeObserver keeps the drawing buffer exactly matched to the
|
|
* element's pixel size (so the trace is always centered and peaks never clip),
|
|
* and the line turns to the spike colour during an impact event.
|
|
*/
|
|
export function WaveformCanvas({
|
|
wave,
|
|
impact,
|
|
}: {
|
|
wave: React.MutableRefObject<number[]>
|
|
impact: boolean
|
|
}) {
|
|
const ref = useRef<HTMLCanvasElement>(null)
|
|
const impactRef = useRef(impact)
|
|
impactRef.current = impact
|
|
|
|
useEffect(() => {
|
|
const cv = ref.current
|
|
if (!cv) return
|
|
const ctx = cv.getContext('2d')
|
|
if (!ctx) return
|
|
const dpr = Math.max(1, window.devicePixelRatio || 1)
|
|
|
|
// Keep the drawing buffer matched to the element's actual size. Setting
|
|
// width/height clears the canvas, so only assign when it actually changed.
|
|
const sync = () => {
|
|
const w = Math.round(cv.clientWidth * dpr)
|
|
const h = Math.round(cv.clientHeight * dpr)
|
|
if (w && cv.width !== w) cv.width = w
|
|
if (h && cv.height !== h) cv.height = h
|
|
}
|
|
sync()
|
|
const ro = new ResizeObserver(sync)
|
|
ro.observe(cv)
|
|
|
|
let raf = 0
|
|
const draw = () => {
|
|
sync()
|
|
const w = cv.width
|
|
const h = cv.height
|
|
ctx.clearRect(0, 0, w, h)
|
|
// center baseline
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
|
ctx.lineWidth = 1
|
|
ctx.beginPath()
|
|
ctx.moveTo(0, h / 2)
|
|
ctx.lineTo(w, h / 2)
|
|
ctx.stroke()
|
|
// waveform — centered, clamped to ±45% of height so peaks never clip
|
|
const buf = wave.current
|
|
const n = buf.length
|
|
if (n > 1) {
|
|
ctx.strokeStyle = impactRef.current ? '#ff8a5c' : '#7fa8ff'
|
|
ctx.lineWidth = 1.6 * dpr
|
|
ctx.lineJoin = 'round'
|
|
ctx.beginPath()
|
|
for (let i = 0; i < n; i++) {
|
|
const x = (i / (n - 1)) * w
|
|
const v = Math.max(-1, Math.min(1, buf[i]))
|
|
const y = h / 2 - v * (h * 0.45)
|
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
|
|
}
|
|
ctx.stroke()
|
|
}
|
|
raf = requestAnimationFrame(draw)
|
|
}
|
|
raf = requestAnimationFrame(draw)
|
|
return () => {
|
|
cancelAnimationFrame(raf)
|
|
ro.disconnect()
|
|
}
|
|
}, [wave])
|
|
|
|
return <canvas ref={ref} className="block h-[100px] w-full rounded-[10px] border border-rail-line bg-rail-inset" />
|
|
}
|