// Panels shared by every chapter. // // Almost every figure in the tutorial is one of three things: a signal against // time, a spectrum against frequency, or an error against a resolution on // log-log axes. SeriesPlot draws all three from a declarative list of series, // so that a chapter's panels file says what is plotted rather than how. // // The conventions are the sibling tutorials': the exact answer is grey and is // drawn first, every method is coloured and drawn over it, identity is never // colour alone (markers, dashes, a legend and, on convergence plots, direct // end-labels), and hovering a marker reads out its numbers. import { useState, type ReactNode } from 'react' import Plot, { type PointerPx } from '../../plot/Plot.tsx' import Legend, { type LegendItem } from '../../plot/Legend.tsx' import { EndLabel, Line, Markers, PointTip, RefSlope, Stems, type Shape } from '../../plot/Curves.tsx' import { ink } from '../../plot/palette.ts' import { extent, padDomain, type Frame } from '../../plot/scales.ts' import type { Num } from '../../engine/types.ts' export function fmtNum(v: Num | undefined, d = 3): string { if (v == null || !isFinite(v)) return '--' return Number(v.toPrecision(d)).toString() } export function fmtErr(v: Num | undefined): string { if (v == null || !isFinite(v)) return '--' if (v === 0) return '0' return v.toExponential(2) } export function fmtCents(v: Num | undefined): string { if (v == null || !isFinite(v)) return '--' const a = Math.abs(v) const s = a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : a >= 0.01 ? v.toFixed(2) : v.toExponential(1) return `${s} ¢` } export function fmtDb(v: Num | undefined): string { if (v == null || !isFinite(v)) return '--' return `${v.toFixed(1)} dB` } export function fmtMs(v: Num | undefined): string { if (v == null || !isFinite(v)) return '--' if (v >= 1000) return `${(v / 1000).toPrecision(3)} s` if (v >= 1) return `${v.toPrecision(3)} ms` return `${(v * 1000).toFixed(0)} µs` } export function fmtHz(v: Num | undefined): string { if (v == null || !isFinite(v)) return '--' return v >= 1000 ? `${(v / 1000).toPrecision(4)} kHz` : `${v.toPrecision(4)} Hz` } export interface Series { x: readonly Num[] y: readonly Num[] color: string label?: string dash?: string width?: number /** markers at the data points */ shape?: Shape /** draw the polyline (default true unless `stems`) */ line?: boolean /** vertical stems from `base` instead of a polyline */ stems?: boolean base?: number opacity?: number /** omit from the legend */ noLegend?: boolean /** label the last finite point directly on the plot */ endLabel?: string /** extra lines for the hover read-out of point i */ tip?: (i: number) => string[] } export interface RefLine { p: number through: [number, number] label: string labelAt?: 'start' | 'end' span?: number } export interface SeriesPlotProps { title?: ReactNode series: Series[] height?: number xLog?: boolean yLog?: boolean xDomain?: [number, number] yDomain?: [number, number] xLabel?: string yLabel?: string xTicks?: number[] yTicks?: number[] formatX?: (v: number) => string formatY?: (v: number) => string refs?: RefLine[] /** thin vertical guides, e.g. the harmonics of a note or the Nyquist frequency */ vlines?: { x: readonly number[]; color?: string; dash?: string; opacity?: number }[] /** thin horizontal guides, e.g. a threshold of audibility */ hlines?: { y: number; color?: string; dash?: string; label?: string }[] legend?: boolean extraLegend?: LegendItem[] /** anything else, drawn in the frame over the data */ overlay?: (f: Frame) => ReactNode margin?: Partial<{ l: number; r: number; t: number; b: number }> } function finite(v: Num | undefined): v is number { return v != null && isFinite(v) } /** A plot of several series with legend, guides and a hover read-out. */ export function SeriesPlot(props: SeriesPlotProps) { const { title, series, height = 230, xLog = false, yLog = false, xLabel, yLabel, refs, vlines, hlines, legend = true, } = props const [ptr, setPtr] = useState(null) const pos = (v: Num) => (finite(v) && (!yLog || v > 0) ? v : null) const xs = series.flatMap((s) => s.x.filter((v) => finite(v) && (!xLog || v > 0)) as number[]) const ys = series.flatMap((s) => s.y.map(pos).filter(finite)) let xd: [number, number] = props.xDomain ?? extent(xs) let yd: [number, number] = props.yDomain ?? extent(ys) if (!props.xDomain) { xd = xLog ? [xd[0] / 1.25, xd[1] * 1.25] : padDomain(xd, 0.02) } if (!props.yDomain) { yd = yLog ? [yd[0] / 2, yd[1] * 2] : padDomain(yd) } const hasTips = series.some((s) => s.shape || s.tip) const over = (f: Frame) => { const out: ReactNode[] = [] series.forEach((s, k) => { if (!s.endLabel) return for (let i = s.y.length - 1; i >= 0; i--) { const xv = s.x[i] const yv = s.y[i] if (finite(xv) && finite(yv) && (!yLog || yv > 0)) { out.push() break } } }) if (ptr && hasTips) { let best: { k: number; i: number; d: number; px: number; py: number } | null = null series.forEach((s, k) => { if (!s.shape && !s.tip) return for (let i = 0; i < s.x.length; i++) { const xv = s.x[i] const yv = s.y[i] if (!finite(xv) || !finite(yv) || (yLog && yv <= 0) || (xLog && xv <= 0)) continue const px = f.sx(xv) const py = f.sy(yv) const d = Math.hypot(px - ptr.px, py - ptr.py) if (d < 24 && (!best || d < best.d)) best = { k, i, d, px, py } } }) if (best) { const b = best as { k: number; i: number; px: number; py: number } const s = series[b.k] const fx = props.formatX ?? ((v: number) => fmtNum(v, 4)) const fy = props.formatY ?? ((v: number) => fmtNum(v, 4)) const lines = [ s.label ?? '', ...(s.tip ? s.tip(b.i) : [`${xLabel ?? 'x'} ${fx(s.x[b.i] as number)}`, `${yLabel ?? 'y'} ${fy(s.y[b.i] as number)}`]), ] out.push() } } if (props.overlay) out.push({props.overlay(f)}) return <>{out} } const items: LegendItem[] = series .filter((s) => s.label && !s.noLegend) .map((s) => ({ label: s.label!, color: s.color, dash: s.dash, shape: s.stems ? ('line' as const) : s.line === false && s.shape ? ('dot' as const) : undefined, })) return (
{title &&
{title}
} s.endLabel) ? { r: 78 } : undefined)} onPointer={hasTips ? setPtr : undefined} over={over} > {(f) => ( <> {vlines?.map((g, k) => g.x.map((xv, i) => finite(xv) ? ( ) : null, ), )} {hlines?.map((g, k) => ( {g.label && ( {g.label} )} ))} {refs?.map((r, k) => ( ))} {series.map((s, k) => { const x = s.x.map((v) => (finite(v) ? v : NaN)) const y = s.y.map((v) => (finite(v) && (!yLog || v > 0) ? v : null)) return ( {s.stems ? ( ) : ( s.line !== false && )} {s.shape && } ) })} )} {legend && items.length + (props.extraLegend?.length ?? 0) > 0 && ( )}
) } /** A small table of numbers, the way the sibling tutorials print a sweep. */ export function NumTable({ head, rows, caption, }: { head: string[] rows: (string | number)[][] caption?: string }) { return (
{caption &&
{caption}
} {head.map((h) => ( ))} {rows.map((r, i) => ( {r.map((c, j) => ( ))} ))}
{h}
{c}
) } /** Two panels side by side, wrapping to one column on a narrow screen. */ export function Row({ children }: { children: ReactNode }) { return
{children}
}