eb36f3fMake the signal view stationary by default with a play toggleJeremy Magland 1import { useEffect, useRef, useState } from 'react'
36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 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/**
eb36f3fMake the signal view stationary by default with a play toggleJeremy Magland 18 * The generated quantized signal z, drawn sample-and-hold so the integer
19 * staircase is visible once σ is small. Rendering is a canvas ring buffer fed
20 * by the same Pipeline the compression worker uses. Stationary by default — a
21 * fresh window per parameter change — with a play toggle to let it stream.
36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 22 */
23export default function ScrollingView(props: {
24 kernel: Float64Array
25 sigma: number
26 dither: boolean
27 /** Predicted std of the quantized signal, for a stable y-scale. */
28 sigmaY: number
29}) {
30 const canvasRef = useRef<HTMLCanvasElement>(null)
31 const seedRef = useRef(1)
eb36f3fMake the signal view stationary by default with a play toggleJeremy Magland 32 const [playing, setPlaying] = useState(false)
33 const playingRef = useRef(playing)
34 playingRef.current = playing
36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 35
36 useEffect(() => {
37 const canvas = canvasRef.current
38 if (!canvas) return
39 const ctx = canvas.getContext('2d')
40 if (!ctx) return
42 const pipeline = new Pipeline(props.kernel, props.sigma, props.dither, seedRef.current++)
43 const ring = new Float32Array(RING_SIZE)
44 let head = 0
45 let filled = 0
46 const push = (samples: Int16Array) => {
47 for (let i = 0; i < samples.length; i++) {
48 ring[head] = samples[i]
49 head = (head + 1) % RING_SIZE
50 }
51 filled = Math.min(RING_SIZE, filled + samples.length)
52 }
54 // Start with a full screen of history so the view is never empty.
55 push(pipeline.next(2048))
57 const scale = Math.max(4 * props.sigmaY, 3.5)
58 const gridStep = niceStep(scale)
60 let styles = getComputedStyle(canvas)
61 const scheme = window.matchMedia('(prefers-color-scheme: dark)')
62 const refreshStyles = () => {
63 styles = getComputedStyle(canvas)
64 }
65 scheme.addEventListener('change', refreshStyles)
67 let raf = 0
68 let last = performance.now()
69 let carry = 0
71 const draw = (now: number) => {
72 raf = requestAnimationFrame(draw)
73 const dt = Math.min(0.25, (now - last) / 1000)
74 last = now
eb36f3fMake the signal view stationary by default with a play toggleJeremy Magland 75 if (playingRef.current) {
76 carry += dt * RATE
77 const n = Math.floor(carry)
78 carry -= n
79 if (n > 0) push(pipeline.next(n))
80 } else {
81 carry = 0
82 }
36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 83
84 const dpr = window.devicePixelRatio || 1
85 const w = canvas.clientWidth
86 const h = canvas.clientHeight
87 if (w === 0 || h === 0) return
88 if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
89 canvas.width = Math.round(w * dpr)
90 canvas.height = Math.round(h * dpr)
91 }
92 ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
94 const surface = styles.getPropertyValue('--surface')
95 ctx.fillStyle = surface
96 ctx.fillRect(0, 0, w, h)
98 const yOf = (v: number) => h / 2 - (v / scale) * (h / 2 - 12)
100 ctx.strokeStyle = styles.getPropertyValue('--grid')
101 ctx.lineWidth = 1
102 ctx.fillStyle = styles.getPropertyValue('--muted')
103 ctx.font = '11px system-ui, sans-serif'
104 ctx.textAlign = 'left'
105 for (let g = -2; g <= 2; g++) {
106 const v = g * gridStep
107 if (Math.abs(v) > scale) continue
108 const y = Math.round(yOf(v)) + 0.5
109 ctx.beginPath()
110 ctx.moveTo(0, y)
111 ctx.lineTo(w, y)
112 if (g !== 0) ctx.stroke()
113 // A surface-colored halo keeps the label readable over the trace.
114 ctx.strokeStyle = surface
115 ctx.lineWidth = 3
116 const label = `${v > 0 ? '+' : ''}${+v.toPrecision(3)}`
117 ctx.strokeText(label, 6, y - 4)
118 ctx.fillText(label, 6, y - 4)
119 ctx.strokeStyle = styles.getPropertyValue('--grid')
120 ctx.lineWidth = 1
121 }
122 const zeroY = Math.round(yOf(0)) + 0.5
123 ctx.strokeStyle = styles.getPropertyValue('--baseline')
124 ctx.beginPath()
125 ctx.moveTo(0, zeroY)
126 ctx.lineTo(w, zeroY)
127 ctx.stroke()
129 const visible = Math.min(filled, Math.floor(w / PX_PER_SAMPLE))
130 ctx.strokeStyle = styles.getPropertyValue('--series-1')
131 ctx.lineWidth = 2
132 ctx.lineJoin = 'round'
133 ctx.beginPath()
134 for (let i = 0; i < visible; i++) {
135 const idx = (head - visible + i + RING_SIZE) % RING_SIZE
136 const x = w - (visible - i) * PX_PER_SAMPLE
137 const y = yOf(ring[idx])
138 // Sample-and-hold: horizontal run at each value, vertical jump between.
139 if (i === 0) ctx.moveTo(x, y)
140 else ctx.lineTo(x, y)
141 ctx.lineTo(x + PX_PER_SAMPLE, y)
142 }
143 ctx.stroke()
144 }
145 raf = requestAnimationFrame(draw)
147 return () => {
148 cancelAnimationFrame(raf)
149 scheme.removeEventListener('change', refreshStyles)
150 }
151 }, [props.kernel, props.sigma, props.dither, props.sigmaY])
154 <div className="scroll-wrap">
155 <canvas ref={canvasRef} className="scroll-canvas" />
156 <button className="play-btn" onClick={() => setPlaying(p => !p)}>
157 {playing ? '⏸ pause' : '▶ play'}
158 </button>
159 </div>
160 )