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
)
}
/** |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)|
)
}
export default function FilterViz(props: { kernel: Float64Array; sampleRateHz: number }) {
return (
)
}