/ concept-collection / timeseries-compressibility
Sign in
concept-collection / timeseries-compressibility
timeseries-compressibility / src / components / FilterViz.tsx
221 lines · 7.9 KBBlameHistoryRaw
1import { useMemo, useRef, useState } from 'react'
2import { magnitudeResponse } from '../model/filters'
3import { useWidth } from './useWidth'
5const MARGIN = { left: 44, right: 12, top: 10, bottom: 26 }
6const HEIGHT = 190
7const DB_FLOOR = -100
8const RESPONSE_POINTS = 512
10interface Tip {
11 x: number
12 y: number
13 value: string
14 label: string
17function Tooltip({ tip }: { tip: Tip | null }) {
18 if (!tip) return null
19 return (
20 <div className="viz-tooltip" style={{ left: tip.x + 12, top: tip.y - 10 }}>
21 <span className="tip-value">{tip.value}</span> <span className="tip-label">{tip.label}</span>
22 </div>
23 )
26/** ~n round tick values covering [lo, hi]. */
27function ticks(lo: number, hi: number, n: number): number[] {
28 const span = hi - lo
29 if (!(span > 0)) return [lo]
30 const raw = span / n
31 const mag = 10 ** Math.floor(Math.log10(raw))
32 const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => span / s <= n) ?? raw
33 const out: number[] = []
34 for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(v)
35 return out
38function formatHz(hz: number): string {
39 return hz >= 1000 ? `${+(hz / 1000).toFixed(1)}k` : `${Math.round(hz)}`
42/** The convolution kernel, tap value against time. */
43function KernelPanel({ kernel, sampleRateHz }: { kernel: Float64Array; sampleRateHz: number }) {
44 const ref = useRef<HTMLDivElement>(null)
45 const width = useWidth(ref)
46 const [tip, setTip] = useState<Tip | null>(null)
48 const L = kernel.length
49 const mid = (L - 1) / 2
50 const msAt = (i: number) => ((i - mid) / sampleRateHz) * 1000
51 // A one-tap kernel still needs a nonzero span to draw on.
52 const tSpan = L > 1 ? msAt(L - 1) - msAt(0) : 1000 / sampleRateHz
53 const t0 = msAt(0) - (L === 1 ? tSpan / 2 : 0)
54 const plotW = width - MARGIN.left - MARGIN.right
55 const plotH = HEIGHT - MARGIN.top - MARGIN.bottom
56 let yMin = 0
57 let yMax = 0
58 for (const v of kernel) {
59 yMin = Math.min(yMin, v)
60 yMax = Math.max(yMax, v)
61 }
62 const pad = (yMax - yMin || 1) * 0.1
63 yMin -= pad
64 yMax += pad
65 const xOf = (i: number) => MARGIN.left + ((msAt(i) - t0) / tSpan) * plotW
66 const yOf = (v: number) => MARGIN.top + ((yMax - v) / (yMax - yMin)) * plotH
68 const path = Array.from(kernel, (v, i) => `${i ? 'L' : 'M'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join('')
70 const onMove = (e: React.PointerEvent<SVGSVGElement>) => {
71 const box = e.currentTarget.getBoundingClientRect()
72 const px = e.clientX - box.left
73 const i = L === 1 ? 0 : Math.max(0, Math.min(L - 1, Math.round(((px - MARGIN.left) / plotW) * (L - 1))))
74 setTip({
75 x: px,
76 y: e.clientY - box.top,
77 value: kernel[i].toPrecision(3),
78 label: `tap ${i} · ${msAt(i).toFixed(2)} ms`,
79 })
80 }
82 return (
83 <div className="panel" ref={ref}>
84 <h3>Convolution kernel h</h3>
85 <svg width={width} height={HEIGHT} onPointerMove={onMove} onPointerLeave={() => setTip(null)}>
86 <line
87 x1={MARGIN.left}
88 x2={width - MARGIN.right}
89 y1={yOf(0)}
90 y2={yOf(0)}
91 stroke="var(--baseline)"
92 strokeWidth={1}
93 />
94 {ticks(t0, t0 + tSpan, 5)
95 .filter(t => MARGIN.left + ((t - t0) / tSpan) * plotW < width - MARGIN.right - 34)
96 .map(t => (
97 <text key={t} x={MARGIN.left + ((t - t0) / tSpan) * plotW} y={HEIGHT - 8} textAnchor="middle" className="axis-tick">
98 {+t.toFixed(2)}
99 </text>
100 ))}
101 <text x={width - MARGIN.right} y={HEIGHT - 8} textAnchor="end" className="axis-tick">
102 ms
103 </text>
104 {ticks(yMin, yMax, 3).map(v => (
105 <text key={v} x={MARGIN.left - 6} y={yOf(v) + 4} textAnchor="end" className="axis-tick">
106 {+v.toPrecision(2)}
107 </text>
108 ))}
109 <path d={path} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
110 {L <= 31 &&
111 Array.from(kernel, (v, i) => (
112 <circle key={i} cx={xOf(i)} cy={yOf(v)} r={4} fill="var(--series-1)" stroke="var(--surface)" strokeWidth={2} />
113 ))}
114 </svg>
115 <Tooltip tip={tip} />
116 </div>
117 )
120/** |H(f)| in dB up to Nyquist, with the S(f) = 1 step² threshold. */
121function ResponsePanel({
122 kernel,
123 sampleRateHz,
124 sigma,
125}: {
126 kernel: Float64Array
127 sampleRateHz: number
128 sigma: number
129}) {
130 const ref = useRef<HTMLDivElement>(null)
131 const width = useWidth(ref)
132 const [tip, setTip] = useState<Tip | null>(null)
134 const db = useMemo(() => {
135 const mag = magnitudeResponse(kernel, RESPONSE_POINTS)
136 return Array.from(mag, m => Math.max(DB_FLOOR, 20 * Math.log10(Math.max(m, 1e-12))))
137 }, [kernel])
139 const nyquist = sampleRateHz / 2
140 const plotW = width - MARGIN.left - MARGIN.right
141 const plotH = HEIGHT - MARGIN.top - MARGIN.bottom
142 const dbMax = Math.max(5, Math.ceil(Math.max(...db) / 5) * 5)
143 const yOf = (v: number) => MARGIN.top + ((dbMax - v) / (dbMax - DB_FLOOR)) * plotH
144 const xOf = (i: number) => MARGIN.left + (i / (RESPONSE_POINTS - 1)) * plotW
146 const path = db.map((v, i) => `${i ? 'L' : 'M'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join('')
148 // S(f) = σ²|H|² = 1 (one step² of spectral power) sits at |H| = 1/σ. Below
149 // this line the high-resolution formula is on thin ice.
150 const stepDb = -20 * Math.log10(sigma)
151 const showStep = stepDb < dbMax && stepDb > DB_FLOOR
153 const onMove = (e: React.PointerEvent<SVGSVGElement>) => {
154 const box = e.currentTarget.getBoundingClientRect()
155 const px = e.clientX - box.left
156 const i = Math.max(0, Math.min(RESPONSE_POINTS - 1, Math.round(((px - MARGIN.left) / plotW) * (RESPONSE_POINTS - 1))))
157 setTip({
158 x: px,
159 y: e.clientY - box.top,
160 value: `${db[i].toFixed(1)} dB`,
161 label: `at ${formatHz((i / (RESPONSE_POINTS - 1)) * nyquist)} Hz`,
162 })
163 }
165 return (
166 <div className="panel" ref={ref}>
167 <h3>Frequency response |H(f)|</h3>
168 <svg width={width} height={HEIGHT} onPointerMove={onMove} onPointerLeave={() => setTip(null)}>
169 {[0, -20, -40, -60, -80, -100].map(v => (
170 <g key={v}>
171 <line x1={MARGIN.left} x2={width - MARGIN.right} y1={yOf(v)} y2={yOf(v)} stroke="var(--grid)" strokeWidth={1} />
172 <text x={MARGIN.left - 6} y={yOf(v) + 4} textAnchor="end" className="axis-tick">
173 {v}
174 </text>
175 </g>
176 ))}
177 <text x={MARGIN.left - 6} y={MARGIN.top - 1} textAnchor="end" className="axis-tick">
178 dB
179 </text>
180 {ticks(0, nyquist, 5)
181 // Leave the right corner to the unit label.
182 .filter(f => MARGIN.left + (f / nyquist) * plotW < width - MARGIN.right - 34)
183 .map(f => (
184 <text key={f} x={MARGIN.left + (f / nyquist) * plotW} y={HEIGHT - 8} textAnchor="middle" className="axis-tick">
185 {formatHz(f)}
186 </text>
187 ))}
188 <text x={width - MARGIN.right} y={HEIGHT - 8} textAnchor="end" className="axis-tick">
189 Hz
190 </text>
191 {showStep && (
192 <g>
193 <line
194 x1={MARGIN.left}
195 x2={width - MARGIN.right}
196 y1={yOf(stepDb)}
197 y2={yOf(stepDb)}
198 stroke="var(--muted)"
199 strokeWidth={1}
200 strokeDasharray="4 3"
201 />
202 <text x={width - MARGIN.right} y={yOf(stepDb) - 4} textAnchor="end" className="axis-tick">
203 S(f) = 1 step²
204 </text>
205 </g>
206 )}
207 <path d={path} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
208 </svg>
209 <Tooltip tip={tip} />
210 </div>
211 )
214export default function FilterViz(props: { kernel: Float64Array; sampleRateHz: number; sigma: number }) {
215 return (
216 <div className="filter-panels">
217 <KernelPanel kernel={props.kernel} sampleRateHz={props.sampleRateHz} />
218 <ResponsePanel kernel={props.kernel} sampleRateHz={props.sampleRateHz} sigma={props.sigma} />
219 </div>
220 )
moveopenescclose