import { useMemo, useRef, useState } from 'react' import { magnitudeResponse } from '../model/filters' import { useWidth } from './useWidth' const MARGIN = { left: 44, right: 12, top: 10, bottom: 24 } const HEIGHT = 120 const DB_FLOOR = -80 const DB_TICKS = [0, -20, -40, -60, -80] const RESPONSE_POINTS = 512 interface Tip { x: number y: number value: string label: string } function Tooltip({ tip }: { tip: Tip | null }) { if (!tip) return null return (
{tip.value} {tip.label}
) } /** ~n round tick values covering [lo, hi]. */ function ticks(lo: number, hi: number, n: number): number[] { const span = hi - lo if (!(span > 0)) return [lo] const raw = span / n const mag = 10 ** Math.floor(Math.log10(raw)) const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => span / s <= n) ?? raw const out: number[] = [] for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(v) return out } function formatHz(hz: number): string { return hz >= 1000 ? `${+(hz / 1000).toFixed(1)}k` : `${Math.round(hz)}` } /** The convolution kernel, tap value against time. */ function KernelPanel({ kernel, sampleRateHz }: { kernel: Float64Array; sampleRateHz: number }) { const ref = useRef(null) const width = useWidth(ref) const [tip, setTip] = useState(null) const L = kernel.length const mid = (L - 1) / 2 const msAt = (i: number) => ((i - mid) / sampleRateHz) * 1000 // A one-tap kernel still needs a nonzero span to draw on. const tSpan = L > 1 ? msAt(L - 1) - msAt(0) : 1000 / sampleRateHz const t0 = msAt(0) - (L === 1 ? tSpan / 2 : 0) const plotW = width - MARGIN.left - MARGIN.right const plotH = HEIGHT - MARGIN.top - MARGIN.bottom let yMin = 0 let yMax = 0 for (const v of kernel) { yMin = Math.min(yMin, v) yMax = Math.max(yMax, v) } const pad = (yMax - yMin || 1) * 0.1 yMin -= pad yMax += pad const xOf = (i: number) => MARGIN.left + ((msAt(i) - t0) / tSpan) * plotW const yOf = (v: number) => MARGIN.top + ((yMax - v) / (yMax - yMin)) * plotH const path = Array.from(kernel, (v, i) => `${i ? 'L' : 'M'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join('') const onMove = (e: React.PointerEvent) => { const box = e.currentTarget.getBoundingClientRect() const px = e.clientX - box.left const i = L === 1 ? 0 : Math.max(0, Math.min(L - 1, Math.round(((px - MARGIN.left) / plotW) * (L - 1)))) setTip({ x: px, y: e.clientY - box.top, value: kernel[i].toPrecision(3), label: `tap ${i} ยท ${msAt(i).toFixed(2)} ms`, }) } return (

Convolution kernel h

setTip(null)}> {ticks(t0, t0 + tSpan, 5) .filter(t => MARGIN.left + ((t - t0) / tSpan) * plotW < width - MARGIN.right - 34) .map(t => ( {+t.toFixed(2)} ))} ms {ticks(yMin, yMax, 3).map(v => ( {+v.toPrecision(2)} ))} {L <= 31 && Array.from(kernel, (v, i) => ( ))}
) } /** |H(f)| in dB up to Nyquist. */ function ResponsePanel({ kernel, sampleRateHz }: { kernel: Float64Array; sampleRateHz: number }) { const ref = useRef(null) const width = useWidth(ref) const [tip, setTip] = useState(null) const db = useMemo(() => { const mag = magnitudeResponse(kernel, RESPONSE_POINTS) return Array.from(mag, m => Math.max(DB_FLOOR, 20 * Math.log10(Math.max(m, 1e-12)))) }, [kernel]) const nyquist = sampleRateHz / 2 const plotW = width - MARGIN.left - MARGIN.right const plotH = HEIGHT - MARGIN.top - MARGIN.bottom const dbMax = Math.max(5, Math.ceil(Math.max(...db) / 5) * 5) const yOf = (v: number) => MARGIN.top + ((dbMax - v) / (dbMax - DB_FLOOR)) * plotH const xOf = (i: number) => MARGIN.left + (i / (RESPONSE_POINTS - 1)) * plotW const path = db.map((v, i) => `${i ? 'L' : 'M'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join('') const onMove = (e: React.PointerEvent) => { const box = e.currentTarget.getBoundingClientRect() const px = e.clientX - box.left const i = Math.max(0, Math.min(RESPONSE_POINTS - 1, Math.round(((px - MARGIN.left) / plotW) * (RESPONSE_POINTS - 1)))) setTip({ x: px, y: e.clientY - box.top, value: `${db[i].toFixed(1)} dB`, label: `at ${formatHz((i / (RESPONSE_POINTS - 1)) * nyquist)} Hz`, }) } return (

Frequency response |H(f)|

setTip(null)}> {DB_TICKS.map(v => ( {v} ))} dB {ticks(0, nyquist, 5) // Leave the right corner to the unit label. .filter(f => MARGIN.left + (f / nyquist) * plotW < width - MARGIN.right - 34) .map(f => ( {formatHz(f)} ))} Hz
) } export default function FilterViz(props: { kernel: Float64Array; sampleRateHz: number }) { return (
) }