1// Interactive sequence timeline: stacked lanes (RF magnitude, RF phase,
2// gx/gy/gz, ADC) over a shared time axis with wheel zoom, drag pan, and
3// click-to-select-block. Canvas-rendered with per-pixel min/max decimation
4// so full sequences (hundreds of thousands of RF samples) stay responsive.
5import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6import type { Reconstructed, Series } from '../seq/reconstruct.ts'
7import { formatTick, formatTime } from './format'
9export interface TimelineProps {
10 rec: Reconstructed
11 selectedBlock: number | null
12 onSelectBlock: (index: number | null) => void
13}
15interface Lane {
16 label: string
17 unit: string
18 color: string
19 height: number
20 kind: 'series' | 'adc'
21 series?: Series
22 /** y range; symmetric lanes use [-max, max] */
23 vMin: number
24 vMax: number
25}
27const AXIS_HEIGHT = 26
28const GUTTER = 58
29const MIN_SPAN = 2e-6
31function laneRange(s: Series, symmetric: boolean): [number, number] {
32 let max = 0
33 for (let i = 0; i < s.v.length; i++) {
34 const v = s.v[i]
35 if (!Number.isNaN(v)) max = Math.max(max, Math.abs(v))
36 }
37 if (max === 0) max = 1
38 max *= 1.08
39 return symmetric ? [-max, max] : [0, max]
40}
42/** Nice tick step: 1/2/5 * 10^k so that span/step is 4..10 ticks. */
43function tickStep(span: number): number {
44 const raw = span / 7
45 const mag = Math.pow(10, Math.floor(Math.log10(raw)))
46 for (const m of [1, 2, 5, 10]) {
47 if (raw <= m * mag) return m * mag
48 }
49 return 10 * mag
50}
52function lowerBound(t: Float64Array, x: number): number {
53 let lo = 0
54 let hi = t.length
55 while (lo < hi) {
56 const mid = (lo + hi) >> 1
57 if (t[mid] < x) lo = mid + 1
58 else hi = mid
59 }
60 return lo
61}
63export default function Timeline({ rec, selectedBlock, onSelectBlock }: TimelineProps) {
64 const hostRef = useRef<HTMLDivElement>(null)
65 const canvasRef = useRef<HTMLCanvasElement>(null)
66 const [view, setView] = useState<[number, number]>([0, rec.duration || 1])
67 const [width, setWidth] = useState(800)
68 const [hoverT, setHoverT] = useState<number | null>(null)
69 const [panning, setPanning] = useState(false)
70 const dragRef = useRef<{ x0: number; view0: [number, number]; moved: boolean } | null>(null)
72 // Reset the view when a different sequence is shown
73 useEffect(() => {
74 setView([0, rec.duration || 1])
75 }, [rec])
77 const lanes: Lane[] = useMemo(() => {
78 const [rfLo, rfHi] = laneRange(rec.rfMag, false)
79 const gx = laneRange(rec.gx, true)
80 const gy = laneRange(rec.gy, true)
81 const gz = laneRange(rec.gz, true)
82 return [
83 { label: 'RF', unit: 'Hz', color: '#ffc861', height: 88, kind: 'series', series: rec.rfMag, vMin: rfLo, vMax: rfHi },
84 { label: 'RF φ', unit: 'rad', color: '#c49bff', height: 54, kind: 'series', series: rec.rfPhase, vMin: -Math.PI * 1.15, vMax: Math.PI * 1.15 },
85 { label: 'GX', unit: 'Hz/m', color: '#ff6b6b', height: 72, kind: 'series', series: rec.gx, vMin: gx[0], vMax: gx[1] },
86 { label: 'GY', unit: 'Hz/m', color: '#51cf66', height: 72, kind: 'series', series: rec.gy, vMin: gy[0], vMax: gy[1] },
87 { label: 'GZ', unit: 'Hz/m', color: '#43a7f5', height: 72, kind: 'series', series: rec.gz, vMin: gz[0], vMax: gz[1] },
88 { label: 'ADC', unit: '', color: '#2fd3c6', height: 36, kind: 'adc', vMin: 0, vMax: 1 },
89 ]
90 }, [rec])
92 const totalHeight = lanes.reduce((acc, l) => acc + l.height, 0) + AXIS_HEIGHT
94 useEffect(() => {
95 const host = hostRef.current
96 if (!host) return
97 const ro = new ResizeObserver(() => setWidth(host.clientWidth))
98 ro.observe(host)
99 setWidth(host.clientWidth)
100 return () => ro.disconnect()
101 }, [])
103 const clampView = useCallback(
104 (t0: number, t1: number): [number, number] => {
105 const total = rec.duration || 1
106 let span = Math.min(Math.max(t1 - t0, MIN_SPAN), total)
107 let lo = t0
108 if (lo < 0) lo = 0
109 if (lo + span > total) lo = total - span
110 return [lo, lo + span]
111 },
112 [rec.duration],
113 )
115 // Keep the selected block in view (e.g. when navigated via the inspector
116 // arrows). Pans to center it, zooming out only if it's wider than the view.
117 // No-op when the block is already fully visible, so clicking a visible block
118 // doesn't shift the timeline.
119 useEffect(() => {
120 if (selectedBlock === null) return
121 const b = rec.blockSpans[selectedBlock]
122 if (!b) return
123 const bStart = b.start
124 const bEnd = b.start + b.duration
125 setView((v) => {
126 const [t0, t1] = v
127 if (bStart >= t0 && bEnd <= t1) return v // already visible
128 const span = t1 - t0
129 const newSpan = b.duration > span ? b.duration * 1.3 : span
130 const center = (bStart + bEnd) / 2
131 return clampView(center - newSpan / 2, center + newSpan / 2)
132 })
133 }, [selectedBlock, rec.blockSpans, clampView])
135 // ── rendering ─────────────────────────────────────────────────────────
136 useEffect(() => {
137 const canvas = canvasRef.current
138 if (!canvas) return
139 const dpr = window.devicePixelRatio || 1
140 canvas.width = Math.round(width * dpr)
141 canvas.height = Math.round(totalHeight * dpr)
142 canvas.style.height = `${totalHeight}px`
143 const ctx = canvas.getContext('2d')
144 if (!ctx) return
145 ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
147 const [t0, t1] = view
148 const span = t1 - t0
149 const plotW = width - GUTTER
150 const xOf = (t: number) => GUTTER + ((t - t0) / span) * plotW
152 ctx.fillStyle = '#12161d'
153 ctx.fillRect(0, 0, width, totalHeight)
155 // Selected block highlight (behind everything)
156 if (selectedBlock !== null && rec.blockSpans[selectedBlock]) {
157 const b = rec.blockSpans[selectedBlock]
158 const x0 = Math.max(GUTTER, xOf(b.start))
159 const x1 = Math.min(width, xOf(b.start + b.duration))
160 if (x1 > GUTTER && x0 < width) {
161 ctx.fillStyle = '#4da3ff1c'
162 ctx.fillRect(x0, 0, Math.max(x1 - x0, 2), totalHeight - AXIS_HEIGHT)
163 }
164 }
166 // Block boundaries when they are resolvable
167 const spans = rec.blockSpans
168 const iFirst = Math.max(0, spans.findIndex((b) => b.start + b.duration >= t0))
169 let visibleBlocks = 0
170 for (let i = iFirst; i < spans.length && spans[i].start <= t1; i++) visibleBlocks++
171 if (visibleBlocks > 0 && visibleBlocks < plotW / 6) {
172 ctx.strokeStyle = '#ffffff10'
173 ctx.beginPath()
174 for (let i = iFirst; i < spans.length && spans[i].start <= t1; i++) {
175 const x = Math.round(xOf(spans[i].start)) + 0.5
176 if (x < GUTTER) continue
177 ctx.moveTo(x, 0)
178 ctx.lineTo(x, totalHeight - AXIS_HEIGHT)
179 }
180 ctx.stroke()
181 }
183 let y0 = 0
184 for (const lane of lanes) {
185 const h = lane.height
186 // separator + baseline
187 ctx.strokeStyle = '#262d38'
188 ctx.beginPath()
189 ctx.moveTo(0, y0 + h - 0.5)
190 ctx.lineTo(width, y0 + h - 0.5)
191 ctx.stroke()
193 const pad = 6
194 const yOf = (v: number) =>
195 y0 + h - pad - ((v - lane.vMin) / (lane.vMax - lane.vMin)) * (h - 2 * pad)
197 if (lane.vMin < 0) {
198 ctx.strokeStyle = '#ffffff14'
199 ctx.beginPath()
200 ctx.moveTo(GUTTER, yOf(0) + 0.5)
201 ctx.lineTo(width, yOf(0) + 0.5)
202 ctx.stroke()
203 }
205 if (lane.kind === 'adc') {
206 ctx.fillStyle = lane.color + '55'
207 ctx.strokeStyle = lane.color
208 for (const a of rec.adcSpans) {
209 if (a.end < t0 || a.start > t1) continue
210 const x0 = Math.max(GUTTER, xOf(a.start))
211 const x1 = Math.min(width, xOf(a.end))
212 ctx.fillRect(x0, y0 + pad, Math.max(x1 - x0, 1.5), h - 2 * pad)
213 // sample ticks when resolvable
214 const pxPerSample = (x1 - x0) / a.num
215 if (pxPerSample > 4) {
216 ctx.beginPath()
217 for (let k = 0; k < a.num; k++) {
218 const x = xOf(a.start + (k + 0.5) * a.dwell)
219 if (x < GUTTER || x > width) continue
220 ctx.moveTo(x, y0 + h / 2 - 3)
221 ctx.lineTo(x, y0 + h / 2 + 3)
222 }
223 ctx.stroke()
224 }
225 }
226 } else if (lane.series) {
227 drawSeries(ctx, lane.series, xOf, yOf, t0, t1, plotW, lane.color)
228 }
230 // lane label
231 ctx.fillStyle = '#12161dd8'
232 ctx.fillRect(0, y0, GUTTER - 6, h - 1)
233 ctx.fillStyle = lane.color
234 ctx.font = '600 11px system-ui'
235 ctx.textBaseline = 'top'
236 ctx.fillText(lane.label, 8, y0 + 6)
237 if (lane.unit) {
238 ctx.fillStyle = '#8b95a5'
239 ctx.font = '10px system-ui'
240 ctx.fillText(lane.unit, 8, y0 + 19)
241 }
242 y0 += h
243 }
245 // ── time axis ──
246 const axisY = totalHeight - AXIS_HEIGHT
247 ctx.fillStyle = '#0f1115'
248 ctx.fillRect(0, axisY, width, AXIS_HEIGHT)
249 ctx.strokeStyle = '#262d38'
250 ctx.beginPath()
251 ctx.moveTo(0, axisY + 0.5)
252 ctx.lineTo(width, axisY + 0.5)
253 ctx.stroke()
254 const step = tickStep(span)
255 const first = Math.ceil(t0 / step) * step
256 ctx.font = '10.5px system-ui'
257 ctx.textBaseline = 'top'
258 for (let t = first; t <= t1 + 1e-12; t += step) {
259 const x = xOf(t)
260 if (x < GUTTER - 1) continue
261 ctx.strokeStyle = '#3a4452'
262 ctx.beginPath()
263 ctx.moveTo(x + 0.5, axisY)
264 ctx.lineTo(x + 0.5, axisY + 5)
265 ctx.stroke()
266 ctx.fillStyle = '#8b95a5'
267 const label = formatTick(t, step)
268 ctx.fillText(label, x - ctx.measureText(label).width / 2, axisY + 8)
269 }
271 // hover crosshair
272 if (hoverT !== null && hoverT >= t0 && hoverT <= t1) {
273 const x = xOf(hoverT)
274 ctx.strokeStyle = '#ffffff30'
275 ctx.beginPath()
276 ctx.moveTo(x + 0.5, 0)
277 ctx.lineTo(x + 0.5, axisY)
278 ctx.stroke()
279 ctx.fillStyle = '#d7dde7'
280 ctx.font = '11px system-ui'
281 const label = formatTime(hoverT)
282 const tw = ctx.measureText(label).width
283 const lx = Math.min(Math.max(x - tw / 2, GUTTER), width - tw - 4)
284 ctx.fillStyle = '#1a212bee'
285 ctx.fillRect(lx - 4, axisY + 6, tw + 8, 15)
286 ctx.fillStyle = '#d7dde7'
287 ctx.fillText(label, lx, axisY + 9)
288 }
289 }, [view, width, lanes, rec, selectedBlock, hoverT, totalHeight])
291 // ── interactions ──────────────────────────────────────────────────────
292 const tAtClientX = useCallback(
293 (clientX: number): number => {
294 const rect = canvasRef.current!.getBoundingClientRect()
295 const x = clientX - rect.left
296 const [t0, t1] = view
297 return t0 + ((x - GUTTER) / (width - GUTTER)) * (t1 - t0)
298 },
299 [view, width],
300 )
302 // Native wheel listener: React's onWheel is passive and can't preventDefault
303 useEffect(() => {
304 const canvas = canvasRef.current
305 if (!canvas) return
306 const onWheel = (e: WheelEvent) => {
307 e.preventDefault()
308 const t = tAtClientX(e.clientX)
309 setView(([t0, t1]) => {
310 const factor = Math.exp(e.deltaY * 0.002)
311 return clampView(t + (t0 - t) * factor, t + (t1 - t) * factor)
312 })
313 }
314 canvas.addEventListener('wheel', onWheel, { passive: false })
315 return () => canvas.removeEventListener('wheel', onWheel)
316 }, [tAtClientX, clampView])
318 const onMouseDown = (e: React.MouseEvent) => {
319 dragRef.current = { x0: e.clientX, view0: view, moved: false }
320 setPanning(true)
321 }
323 useEffect(() => {
324 if (!panning) return
325 const onMove = (e: MouseEvent) => {
326 const drag = dragRef.current
327 if (!drag) return
328 const dx = e.clientX - drag.x0
329 if (Math.abs(dx) > 3) drag.moved = true
330 const [t0, t1] = drag.view0
331 const dt = (dx / (width - GUTTER)) * (t1 - t0)
332 setView(clampView(t0 - dt, t1 - dt))
333 }
334 const onUp = (e: MouseEvent) => {
335 const drag = dragRef.current
336 dragRef.current = null
337 setPanning(false)
338 if (drag && !drag.moved) {
339 // click: select the block at this time
340 const t = tAtClientX(e.clientX)
341 const idx = rec.blockSpans.findIndex((b) => t >= b.start && t < b.start + b.duration)
342 onSelectBlock(idx >= 0 ? idx : null)
343 }
344 }
345 window.addEventListener('mousemove', onMove)
346 window.addEventListener('mouseup', onUp)
347 return () => {
348 window.removeEventListener('mousemove', onMove)
349 window.removeEventListener('mouseup', onUp)
350 }
351 }, [panning, width, clampView, tAtClientX, rec.blockSpans, onSelectBlock])
353 return (
354 <div className="timeline-host" ref={hostRef}>
355 <canvas
356 ref={canvasRef}
357 className={`timeline-canvas${panning ? ' panning' : ''}`}
358 onMouseDown={onMouseDown}
359 onDoubleClick={() => setView([0, rec.duration || 1])}
360 onMouseMove={(e) => setHoverT(tAtClientX(e.clientX))}
361 onMouseLeave={() => setHoverT(null)}
362 />
363 </div>
364 )
365}
367/** Draw a piecewise-linear series with per-pixel min/max decimation. */
368function drawSeries(
369 ctx: CanvasRenderingContext2D,
370 s: Series,
371 xOf: (t: number) => number,
372 yOf: (v: number) => number,
373 t0: number,
374 t1: number,
375 plotW: number,
376 color: string,
377) {
378 const { t, v } = s
379 if (t.length === 0) return
380 let i0 = Math.max(0, lowerBound(t, t0) - 1)
381 let i1 = Math.min(t.length - 1, lowerBound(t, t1) + 1)
383 ctx.save()
384 ctx.beginPath()
385 ctx.rect(GUTTER, 0, plotW, ctx.canvas.height)
386 ctx.clip()
387 ctx.strokeStyle = color
388 ctx.lineWidth = 1.2
389 ctx.lineJoin = 'round'
390 ctx.beginPath()
392 const dense = i1 - i0 > plotW * 3
393 if (dense) {
394 // min/max per pixel column; each column's range includes the previous
395 // column's closing value so adjacent strokes connect
396 let px = -1
397 let lo = 0
398 let hi = 0
399 let close = 0
400 let open = false
401 const flush = () => {
402 if (open && px >= 0) {
403 ctx.moveTo(px + 0.5, yOf(lo))
404 ctx.lineTo(px + 0.5, yOf(hi))
405 }
406 }
407 for (let i = i0; i <= i1; i++) {
408 if (Number.isNaN(v[i])) {
409 flush()
410 px = -1
411 open = false
412 continue
413 }
414 const x = Math.round(xOf(t[i]))
415 if (x !== px) {
416 flush()
417 const carry = open ? close : v[i]
418 px = x
419 lo = Math.min(v[i], carry)
420 hi = Math.max(v[i], carry)
421 open = true
422 } else {
423 if (v[i] < lo) lo = v[i]
424 if (v[i] > hi) hi = v[i]
425 }
426 close = v[i]
427 }
428 flush()
429 } else {
430 let pen = false
431 for (let i = i0; i <= i1; i++) {
432 if (Number.isNaN(v[i])) {
433 pen = false
434 continue
435 }
436 const x = xOf(t[i])
437 const y = yOf(v[i])
438 if (!pen) {
439 ctx.moveTo(x, y)
440 pen = true
441 } else {
442 ctx.lineTo(x, y)
443 }
444 }
445 }
446 ctx.stroke()
447 ctx.restore()
448}