fix(web): rail polish — waveform fits, mirror is smooth

- 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]>
This commit is contained in:
Omar Sobh
2026-07-23 09:26:11 -07:00
co-authored by Claude Opus 4.8
parent e76e27f06d
commit feeff6ac9b
4 changed files with 51 additions and 26 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ export function CockpitRail() {
{tel.live ? ( {tel.live ? (
<WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} /> <WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} />
) : ( ) : (
<div className="flex h-[78px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3"> <div className="flex h-[100px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3">
adxl355_stream awaiting board adxl355_stream awaiting board
</div> </div>
)} )}
+25 -12
View File
@@ -3,7 +3,9 @@ import { useEffect, useRef } from 'react'
/** /**
* The live-acceleration waveform. Reads the magnitude history from a ref and * The live-acceleration waveform. Reads the magnitude history from a ref and
* redraws via requestAnimationFrame — independent of React renders, so it stays * redraws via requestAnimationFrame — independent of React renders, so it stays
* smooth. Line turns to the spike colour during an impact event. * 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({ export function WaveformCanvas({
wave, wave,
@@ -21,16 +23,23 @@ export function WaveformCanvas({
if (!cv) return if (!cv) return
const ctx = cv.getContext('2d') const ctx = cv.getContext('2d')
if (!ctx) return if (!ctx) return
const dpr = window.devicePixelRatio || 1 const dpr = Math.max(1, window.devicePixelRatio || 1)
const resize = () => {
cv.width = cv.clientWidth * dpr // Keep the drawing buffer matched to the element's actual size. Setting
cv.height = cv.clientHeight * dpr // 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
} }
resize() sync()
window.addEventListener('resize', resize) const ro = new ResizeObserver(sync)
ro.observe(cv)
let raf = 0 let raf = 0
const draw = () => { const draw = () => {
sync()
const w = cv.width const w = cv.width
const h = cv.height const h = cv.height
ctx.clearRect(0, 0, w, h) ctx.clearRect(0, 0, w, h)
@@ -41,26 +50,30 @@ export function WaveformCanvas({
ctx.moveTo(0, h / 2) ctx.moveTo(0, h / 2)
ctx.lineTo(w, h / 2) ctx.lineTo(w, h / 2)
ctx.stroke() ctx.stroke()
// waveform // waveform — centered, clamped to ±45% of height so peaks never clip
const buf = wave.current const buf = wave.current
const n = buf.length const n = buf.length
if (n > 1) {
ctx.strokeStyle = impactRef.current ? '#ff8a5c' : '#7fa8ff' ctx.strokeStyle = impactRef.current ? '#ff8a5c' : '#7fa8ff'
ctx.lineWidth = 1.5 * dpr ctx.lineWidth = 1.6 * dpr
ctx.lineJoin = 'round'
ctx.beginPath() ctx.beginPath()
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * w const x = (i / (n - 1)) * w
const y = h / 2 - buf[i] * (h * 0.42) 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) i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
} }
ctx.stroke() ctx.stroke()
}
raf = requestAnimationFrame(draw) raf = requestAnimationFrame(draw)
} }
raf = requestAnimationFrame(draw) raf = requestAnimationFrame(draw)
return () => { return () => {
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
window.removeEventListener('resize', resize) ro.disconnect()
} }
}, [wave]) }, [wave])
return <canvas ref={ref} className="block h-[78px] w-full rounded-[10px] border border-rail-line bg-rail-inset" /> return <canvas ref={ref} className="block h-[100px] w-full rounded-[10px] border border-rail-line bg-rail-inset" />
} }
+10 -4
View File
@@ -2,11 +2,17 @@ import { useEffect, useRef, useState } from 'react'
import { getNodeMatrix } from './api' import { getNodeMatrix } from './api'
/** /**
* Poll the board's real LED-matrix framebuffer (~8 fps) for a pixel-perfect * Poll the board's real LED-matrix framebuffer for a pixel-perfect mirror.
* mirror. Returns the 104 on/off dots, or null until a frame is read. Skips a * Returns the 104 on/off dots, or null until a frame is read. Skips a poll while
* poll while the previous one is in flight so a slow board can't stack requests. * the previous one is in flight so a slow board can't stack requests.
*
* Default 15 fps: the board renders its animations at ~12 fps (the sketch's 80 ms
* loop), and a matrix_get round-trip is only ~15 ms, so 15 fps slightly
* oversamples the source — smooth, with no dropped frames — while staying far
* under the ~68 fps round-trip ceiling. Going higher mostly re-reads identical
* frames (the board isn't rendering faster), so it's wasted requests.
*/ */
export function useMatrixMirror(teamId: string, enabled: boolean, fps = 8): boolean[] | null { export function useMatrixMirror(teamId: string, enabled: boolean, fps = 15): boolean[] | null {
const [dots, setDots] = useState<boolean[] | null>(null) const [dots, setDots] = useState<boolean[] | null>(null)
const inflight = useRef(false) const inflight = useRef(false)
+9 -3
View File
@@ -65,6 +65,7 @@ export function useTelemetry(enabled: boolean): Telemetry {
useEffect(() => { useEffect(() => {
if (!enabled) return if (!enabled) return
let phase = 0 let phase = 0
let k = 0
let impactUntil = 0 let impactUntil = 0
const jog = (base: number, amp: number) => base + (rand() - 0.5) * amp const jog = (base: number, amp: number) => base + (rand() - 0.5) * amp
@@ -79,10 +80,15 @@ export function useTelemetry(enabled: boolean): Telemetry {
setAcc1({ x: jog(0, a), y: jog(0, a), z: jog(1, z) }) setAcc1({ x: jog(0, a), y: jog(0, a), z: jog(1, z) })
setAcc2({ x: jog(0, a * 0.9), y: jog(0, a * 0.9), z: jog(1, z) }) setAcc2({ x: jog(0, a * 0.9), y: jog(0, a * 0.9), z: jog(1, z) })
// magnitude for the waveform // Signed oscilloscope sample: a gentle idle wave at rest, a big decaying
const m = impact ? 0.55 + rand() * 0.85 : 0.04 + rand() * 0.06 // ring on impact. Bounded to ±0.95 so it never leaves the canvas.
k += 1
const env = impact ? Math.max(0, (impactUntil - now) / 1400) : 0
const s = impact
? Math.sin(k * 1.05) * env * 0.9 + (rand() - 0.5) * 0.12
: Math.sin(k * 0.4) * 0.16 + (rand() - 0.5) * 0.06
const buf = wave.current const buf = wave.current
buf.push(m) buf.push(Math.max(-0.95, Math.min(0.95, s)))
if (buf.length > WAVE_LEN) buf.shift() if (buf.length > WAVE_LEN) buf.shift()
setEvent(impact ? 'impact' : 'nominal') setEvent(impact ? 'impact' : 'nominal')