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