// Interactive sequence timeline: stacked lanes (RF magnitude, RF phase, // gx/gy/gz, ADC) over a shared time axis with wheel zoom, drag pan, and // click-to-select-block. Canvas-rendered with per-pixel min/max decimation // so full sequences (hundreds of thousands of RF samples) stay responsive. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Reconstructed, Series } from '../seq/reconstruct.ts' import { formatTick, formatTime } from './format' export interface TimelineProps { rec: Reconstructed selectedBlock: number | null onSelectBlock: (index: number | null) => void } interface Lane { label: string unit: string color: string height: number kind: 'series' | 'adc' series?: Series /** y range; symmetric lanes use [-max, max] */ vMin: number vMax: number } const AXIS_HEIGHT = 26 const GUTTER = 58 const MIN_SPAN = 2e-6 function laneRange(s: Series, symmetric: boolean): [number, number] { let max = 0 for (let i = 0; i < s.v.length; i++) { const v = s.v[i] if (!Number.isNaN(v)) max = Math.max(max, Math.abs(v)) } if (max === 0) max = 1 max *= 1.08 return symmetric ? [-max, max] : [0, max] } /** Nice tick step: 1/2/5 * 10^k so that span/step is 4..10 ticks. */ function tickStep(span: number): number { const raw = span / 7 const mag = Math.pow(10, Math.floor(Math.log10(raw))) for (const m of [1, 2, 5, 10]) { if (raw <= m * mag) return m * mag } return 10 * mag } function lowerBound(t: Float64Array, x: number): number { let lo = 0 let hi = t.length while (lo < hi) { const mid = (lo + hi) >> 1 if (t[mid] < x) lo = mid + 1 else hi = mid } return lo } export default function Timeline({ rec, selectedBlock, onSelectBlock }: TimelineProps) { const hostRef = useRef(null) const canvasRef = useRef(null) const [view, setView] = useState<[number, number]>([0, rec.duration || 1]) const [width, setWidth] = useState(800) const [hoverT, setHoverT] = useState(null) const [panning, setPanning] = useState(false) const dragRef = useRef<{ x0: number; view0: [number, number]; moved: boolean } | null>(null) // Reset the view when a different sequence is shown useEffect(() => { setView([0, rec.duration || 1]) }, [rec]) const lanes: Lane[] = useMemo(() => { const [rfLo, rfHi] = laneRange(rec.rfMag, false) const gx = laneRange(rec.gx, true) const gy = laneRange(rec.gy, true) const gz = laneRange(rec.gz, true) return [ { label: 'RF', unit: 'Hz', color: '#ffc861', height: 88, kind: 'series', series: rec.rfMag, vMin: rfLo, vMax: rfHi }, { label: 'RF φ', unit: 'rad', color: '#c49bff', height: 54, kind: 'series', series: rec.rfPhase, vMin: -Math.PI * 1.15, vMax: Math.PI * 1.15 }, { label: 'GX', unit: 'Hz/m', color: '#ff6b6b', height: 72, kind: 'series', series: rec.gx, vMin: gx[0], vMax: gx[1] }, { label: 'GY', unit: 'Hz/m', color: '#51cf66', height: 72, kind: 'series', series: rec.gy, vMin: gy[0], vMax: gy[1] }, { label: 'GZ', unit: 'Hz/m', color: '#43a7f5', height: 72, kind: 'series', series: rec.gz, vMin: gz[0], vMax: gz[1] }, { label: 'ADC', unit: '', color: '#2fd3c6', height: 36, kind: 'adc', vMin: 0, vMax: 1 }, ] }, [rec]) const totalHeight = lanes.reduce((acc, l) => acc + l.height, 0) + AXIS_HEIGHT useEffect(() => { const host = hostRef.current if (!host) return const ro = new ResizeObserver(() => setWidth(host.clientWidth)) ro.observe(host) setWidth(host.clientWidth) return () => ro.disconnect() }, []) const clampView = useCallback( (t0: number, t1: number): [number, number] => { const total = rec.duration || 1 let span = Math.min(Math.max(t1 - t0, MIN_SPAN), total) let lo = t0 if (lo < 0) lo = 0 if (lo + span > total) lo = total - span return [lo, lo + span] }, [rec.duration], ) // Keep the selected block in view (e.g. when navigated via the inspector // arrows). Pans to center it, zooming out only if it's wider than the view. // No-op when the block is already fully visible, so clicking a visible block // doesn't shift the timeline. useEffect(() => { if (selectedBlock === null) return const b = rec.blockSpans[selectedBlock] if (!b) return const bStart = b.start const bEnd = b.start + b.duration setView((v) => { const [t0, t1] = v if (bStart >= t0 && bEnd <= t1) return v // already visible const span = t1 - t0 const newSpan = b.duration > span ? b.duration * 1.3 : span const center = (bStart + bEnd) / 2 return clampView(center - newSpan / 2, center + newSpan / 2) }) }, [selectedBlock, rec.blockSpans, clampView]) // ── rendering ───────────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current if (!canvas) return const dpr = window.devicePixelRatio || 1 canvas.width = Math.round(width * dpr) canvas.height = Math.round(totalHeight * dpr) canvas.style.height = `${totalHeight}px` const ctx = canvas.getContext('2d') if (!ctx) return ctx.setTransform(dpr, 0, 0, dpr, 0, 0) const [t0, t1] = view const span = t1 - t0 const plotW = width - GUTTER const xOf = (t: number) => GUTTER + ((t - t0) / span) * plotW ctx.fillStyle = '#12161d' ctx.fillRect(0, 0, width, totalHeight) // Selected block highlight (behind everything) if (selectedBlock !== null && rec.blockSpans[selectedBlock]) { const b = rec.blockSpans[selectedBlock] const x0 = Math.max(GUTTER, xOf(b.start)) const x1 = Math.min(width, xOf(b.start + b.duration)) if (x1 > GUTTER && x0 < width) { ctx.fillStyle = '#4da3ff1c' ctx.fillRect(x0, 0, Math.max(x1 - x0, 2), totalHeight - AXIS_HEIGHT) } } // Block boundaries when they are resolvable const spans = rec.blockSpans const iFirst = Math.max(0, spans.findIndex((b) => b.start + b.duration >= t0)) let visibleBlocks = 0 for (let i = iFirst; i < spans.length && spans[i].start <= t1; i++) visibleBlocks++ if (visibleBlocks > 0 && visibleBlocks < plotW / 6) { ctx.strokeStyle = '#ffffff10' ctx.beginPath() for (let i = iFirst; i < spans.length && spans[i].start <= t1; i++) { const x = Math.round(xOf(spans[i].start)) + 0.5 if (x < GUTTER) continue ctx.moveTo(x, 0) ctx.lineTo(x, totalHeight - AXIS_HEIGHT) } ctx.stroke() } let y0 = 0 for (const lane of lanes) { const h = lane.height // separator + baseline ctx.strokeStyle = '#262d38' ctx.beginPath() ctx.moveTo(0, y0 + h - 0.5) ctx.lineTo(width, y0 + h - 0.5) ctx.stroke() const pad = 6 const yOf = (v: number) => y0 + h - pad - ((v - lane.vMin) / (lane.vMax - lane.vMin)) * (h - 2 * pad) if (lane.vMin < 0) { ctx.strokeStyle = '#ffffff14' ctx.beginPath() ctx.moveTo(GUTTER, yOf(0) + 0.5) ctx.lineTo(width, yOf(0) + 0.5) ctx.stroke() } if (lane.kind === 'adc') { ctx.fillStyle = lane.color + '55' ctx.strokeStyle = lane.color for (const a of rec.adcSpans) { if (a.end < t0 || a.start > t1) continue const x0 = Math.max(GUTTER, xOf(a.start)) const x1 = Math.min(width, xOf(a.end)) ctx.fillRect(x0, y0 + pad, Math.max(x1 - x0, 1.5), h - 2 * pad) // sample ticks when resolvable const pxPerSample = (x1 - x0) / a.num if (pxPerSample > 4) { ctx.beginPath() for (let k = 0; k < a.num; k++) { const x = xOf(a.start + (k + 0.5) * a.dwell) if (x < GUTTER || x > width) continue ctx.moveTo(x, y0 + h / 2 - 3) ctx.lineTo(x, y0 + h / 2 + 3) } ctx.stroke() } } } else if (lane.series) { drawSeries(ctx, lane.series, xOf, yOf, t0, t1, plotW, lane.color) } // lane label ctx.fillStyle = '#12161dd8' ctx.fillRect(0, y0, GUTTER - 6, h - 1) ctx.fillStyle = lane.color ctx.font = '600 11px system-ui' ctx.textBaseline = 'top' ctx.fillText(lane.label, 8, y0 + 6) if (lane.unit) { ctx.fillStyle = '#8b95a5' ctx.font = '10px system-ui' ctx.fillText(lane.unit, 8, y0 + 19) } y0 += h } // ── time axis ── const axisY = totalHeight - AXIS_HEIGHT ctx.fillStyle = '#0f1115' ctx.fillRect(0, axisY, width, AXIS_HEIGHT) ctx.strokeStyle = '#262d38' ctx.beginPath() ctx.moveTo(0, axisY + 0.5) ctx.lineTo(width, axisY + 0.5) ctx.stroke() const step = tickStep(span) const first = Math.ceil(t0 / step) * step ctx.font = '10.5px system-ui' ctx.textBaseline = 'top' for (let t = first; t <= t1 + 1e-12; t += step) { const x = xOf(t) if (x < GUTTER - 1) continue ctx.strokeStyle = '#3a4452' ctx.beginPath() ctx.moveTo(x + 0.5, axisY) ctx.lineTo(x + 0.5, axisY + 5) ctx.stroke() ctx.fillStyle = '#8b95a5' const label = formatTick(t, step) ctx.fillText(label, x - ctx.measureText(label).width / 2, axisY + 8) } // hover crosshair if (hoverT !== null && hoverT >= t0 && hoverT <= t1) { const x = xOf(hoverT) ctx.strokeStyle = '#ffffff30' ctx.beginPath() ctx.moveTo(x + 0.5, 0) ctx.lineTo(x + 0.5, axisY) ctx.stroke() ctx.fillStyle = '#d7dde7' ctx.font = '11px system-ui' const label = formatTime(hoverT) const tw = ctx.measureText(label).width const lx = Math.min(Math.max(x - tw / 2, GUTTER), width - tw - 4) ctx.fillStyle = '#1a212bee' ctx.fillRect(lx - 4, axisY + 6, tw + 8, 15) ctx.fillStyle = '#d7dde7' ctx.fillText(label, lx, axisY + 9) } }, [view, width, lanes, rec, selectedBlock, hoverT, totalHeight]) // ── interactions ────────────────────────────────────────────────────── const tAtClientX = useCallback( (clientX: number): number => { const rect = canvasRef.current!.getBoundingClientRect() const x = clientX - rect.left const [t0, t1] = view return t0 + ((x - GUTTER) / (width - GUTTER)) * (t1 - t0) }, [view, width], ) // Native wheel listener: React's onWheel is passive and can't preventDefault useEffect(() => { const canvas = canvasRef.current if (!canvas) return const onWheel = (e: WheelEvent) => { e.preventDefault() const t = tAtClientX(e.clientX) setView(([t0, t1]) => { const factor = Math.exp(e.deltaY * 0.002) return clampView(t + (t0 - t) * factor, t + (t1 - t) * factor) }) } canvas.addEventListener('wheel', onWheel, { passive: false }) return () => canvas.removeEventListener('wheel', onWheel) }, [tAtClientX, clampView]) const onMouseDown = (e: React.MouseEvent) => { dragRef.current = { x0: e.clientX, view0: view, moved: false } setPanning(true) } useEffect(() => { if (!panning) return const onMove = (e: MouseEvent) => { const drag = dragRef.current if (!drag) return const dx = e.clientX - drag.x0 if (Math.abs(dx) > 3) drag.moved = true const [t0, t1] = drag.view0 const dt = (dx / (width - GUTTER)) * (t1 - t0) setView(clampView(t0 - dt, t1 - dt)) } const onUp = (e: MouseEvent) => { const drag = dragRef.current dragRef.current = null setPanning(false) if (drag && !drag.moved) { // click: select the block at this time const t = tAtClientX(e.clientX) const idx = rec.blockSpans.findIndex((b) => t >= b.start && t < b.start + b.duration) onSelectBlock(idx >= 0 ? idx : null) } } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) return () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } }, [panning, width, clampView, tAtClientX, rec.blockSpans, onSelectBlock]) return (
setView([0, rec.duration || 1])} onMouseMove={(e) => setHoverT(tAtClientX(e.clientX))} onMouseLeave={() => setHoverT(null)} />
) } /** Draw a piecewise-linear series with per-pixel min/max decimation. */ function drawSeries( ctx: CanvasRenderingContext2D, s: Series, xOf: (t: number) => number, yOf: (v: number) => number, t0: number, t1: number, plotW: number, color: string, ) { const { t, v } = s if (t.length === 0) return let i0 = Math.max(0, lowerBound(t, t0) - 1) let i1 = Math.min(t.length - 1, lowerBound(t, t1) + 1) ctx.save() ctx.beginPath() ctx.rect(GUTTER, 0, plotW, ctx.canvas.height) ctx.clip() ctx.strokeStyle = color ctx.lineWidth = 1.2 ctx.lineJoin = 'round' ctx.beginPath() const dense = i1 - i0 > plotW * 3 if (dense) { // min/max per pixel column; each column's range includes the previous // column's closing value so adjacent strokes connect let px = -1 let lo = 0 let hi = 0 let close = 0 let open = false const flush = () => { if (open && px >= 0) { ctx.moveTo(px + 0.5, yOf(lo)) ctx.lineTo(px + 0.5, yOf(hi)) } } for (let i = i0; i <= i1; i++) { if (Number.isNaN(v[i])) { flush() px = -1 open = false continue } const x = Math.round(xOf(t[i])) if (x !== px) { flush() const carry = open ? close : v[i] px = x lo = Math.min(v[i], carry) hi = Math.max(v[i], carry) open = true } else { if (v[i] < lo) lo = v[i] if (v[i] > hi) hi = v[i] } close = v[i] } flush() } else { let pen = false for (let i = i0; i <= i1; i++) { if (Number.isNaN(v[i])) { pen = false continue } const x = xOf(t[i]) const y = yOf(v[i]) if (!pen) { ctx.moveTo(x, y) pen = true } else { ctx.lineTo(x, y) } } } ctx.stroke() ctx.restore() }