1import { useEffect, useRef } from 'react'
2import { Pipeline } from '../model/pipeline'
4/** Display samples generated per second; px per sample is fixed below. */
5const RATE = 220
6const PX_PER_SAMPLE = 2
7const RING_SIZE = 8192
9/** A nice round gridline step ≤ span/2. */
10function niceStep(span: number): number {
11 const raw = span / 2
12 const mag = 10 ** Math.floor(Math.log10(raw))
13 for (const m of [5, 2.5, 2, 1]) if (m * mag <= raw) return m * mag
14 return mag
15}
17/**
18 * The endlessly generated quantized signal z, drawn sample-and-hold so the
19 * integer staircase is visible once σ is small. Rendering is a canvas ring
20 * buffer fed by the same Pipeline the compression worker uses.
21 */
22export default function ScrollingView(props: {
23 kernel: Float64Array
24 sigma: number
25 dither: boolean
26 /** Predicted std of the quantized signal, for a stable y-scale. */
27 sigmaY: number
28}) {
29 const canvasRef = useRef<HTMLCanvasElement>(null)
30 const seedRef = useRef(1)
32 useEffect(() => {
33 const canvas = canvasRef.current
34 if (!canvas) return
35 const ctx = canvas.getContext('2d')
36 if (!ctx) return
38 const pipeline = new Pipeline(props.kernel, props.sigma, props.dither, seedRef.current++)
39 const ring = new Float32Array(RING_SIZE)
40 let head = 0
41 let filled = 0
42 const push = (samples: Int16Array) => {
43 for (let i = 0; i < samples.length; i++) {
44 ring[head] = samples[i]
45 head = (head + 1) % RING_SIZE
46 }
47 filled = Math.min(RING_SIZE, filled + samples.length)
48 }
50 // Start with a full screen of history so the view is never empty.
51 push(pipeline.next(2048))
53 const scale = Math.max(4 * props.sigmaY, 3.5)
54 const gridStep = niceStep(scale)
56 let styles = getComputedStyle(canvas)
57 const scheme = window.matchMedia('(prefers-color-scheme: dark)')
58 const refreshStyles = () => {
59 styles = getComputedStyle(canvas)
60 }
61 scheme.addEventListener('change', refreshStyles)
63 let raf = 0
64 let last = performance.now()
65 let carry = 0
67 const draw = (now: number) => {
68 raf = requestAnimationFrame(draw)
69 const dt = Math.min(0.25, (now - last) / 1000)
70 last = now
71 carry += dt * RATE
72 const n = Math.floor(carry)
73 carry -= n
74 if (n > 0) push(pipeline.next(n))
76 const dpr = window.devicePixelRatio || 1
77 const w = canvas.clientWidth
78 const h = canvas.clientHeight
79 if (w === 0 || h === 0) return
80 if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
81 canvas.width = Math.round(w * dpr)
82 canvas.height = Math.round(h * dpr)
83 }
84 ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
86 const surface = styles.getPropertyValue('--surface')
87 ctx.fillStyle = surface
88 ctx.fillRect(0, 0, w, h)
90 const yOf = (v: number) => h / 2 - (v / scale) * (h / 2 - 12)
92 ctx.strokeStyle = styles.getPropertyValue('--grid')
93 ctx.lineWidth = 1
94 ctx.fillStyle = styles.getPropertyValue('--muted')
95 ctx.font = '11px system-ui, sans-serif'
96 ctx.textAlign = 'left'
97 for (let g = -2; g <= 2; g++) {
98 const v = g * gridStep
99 if (Math.abs(v) > scale) continue
100 const y = Math.round(yOf(v)) + 0.5
101 ctx.beginPath()
102 ctx.moveTo(0, y)
103 ctx.lineTo(w, y)
104 if (g !== 0) ctx.stroke()
105 // A surface-colored halo keeps the label readable over the trace.
106 ctx.strokeStyle = surface
107 ctx.lineWidth = 3
108 const label = `${v > 0 ? '+' : ''}${+v.toPrecision(3)}`
109 ctx.strokeText(label, 6, y - 4)
110 ctx.fillText(label, 6, y - 4)
111 ctx.strokeStyle = styles.getPropertyValue('--grid')
112 ctx.lineWidth = 1
113 }
114 const zeroY = Math.round(yOf(0)) + 0.5
115 ctx.strokeStyle = styles.getPropertyValue('--baseline')
116 ctx.beginPath()
117 ctx.moveTo(0, zeroY)
118 ctx.lineTo(w, zeroY)
119 ctx.stroke()
121 const visible = Math.min(filled, Math.floor(w / PX_PER_SAMPLE))
122 ctx.strokeStyle = styles.getPropertyValue('--series-1')
123 ctx.lineWidth = 2
124 ctx.lineJoin = 'round'
125 ctx.beginPath()
126 for (let i = 0; i < visible; i++) {
127 const idx = (head - visible + i + RING_SIZE) % RING_SIZE
128 const x = w - (visible - i) * PX_PER_SAMPLE
129 const y = yOf(ring[idx])
130 // Sample-and-hold: horizontal run at each value, vertical jump between.
131 if (i === 0) ctx.moveTo(x, y)
132 else ctx.lineTo(x, y)
133 ctx.lineTo(x + PX_PER_SAMPLE, y)
134 }
135 ctx.stroke()
136 }
137 raf = requestAnimationFrame(draw)
139 return () => {
140 cancelAnimationFrame(raf)
141 scheme.removeEventListener('change', refreshStyles)
142 }
143 }, [props.kernel, props.sigma, props.dither, props.sigmaY])
145 return <canvas ref={canvasRef} className="scroll-canvas" />
146}