/ tutorials / wave-acoustics
Sign in
tutorials / wave-acoustics
wave-acoustics / src / chapters / common / panels.tsx
319 lines · 10.2 KBBlameHistoryRaw
1// Panels shared by every chapter.
2//
3// Almost every figure in the tutorial is one of three things: a signal against
4// time, a spectrum against frequency, or an error against a resolution on
5// log-log axes. SeriesPlot draws all three from a declarative list of series,
6// so that a chapter's panels file says what is plotted rather than how.
7//
8// The conventions are the sibling tutorials': the exact answer is grey and is
9// drawn first, every method is coloured and drawn over it, identity is never
10// colour alone (markers, dashes, a legend and, on convergence plots, direct
11// end-labels), and hovering a marker reads out its numbers.
12import { useState, type ReactNode } from 'react'
13import Plot, { type PointerPx } from '../../plot/Plot.tsx'
14import Legend, { type LegendItem } from '../../plot/Legend.tsx'
15import { EndLabel, Line, Markers, PointTip, RefSlope, Stems, type Shape } from '../../plot/Curves.tsx'
16import { ink } from '../../plot/palette.ts'
17import { extent, padDomain, type Frame } from '../../plot/scales.ts'
18import type { Num } from '../../engine/types.ts'
20export function fmtNum(v: Num | undefined, d = 3): string {
21 if (v == null || !isFinite(v)) return '--'
22 return Number(v.toPrecision(d)).toString()
25export function fmtErr(v: Num | undefined): string {
26 if (v == null || !isFinite(v)) return '--'
27 if (v === 0) return '0'
28 return v.toExponential(2)
31export function fmtCents(v: Num | undefined): string {
32 if (v == null || !isFinite(v)) return '--'
33 const a = Math.abs(v)
34 const s = a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : a >= 0.01 ? v.toFixed(2) : v.toExponential(1)
35 return `${s} ¢`
38export function fmtDb(v: Num | undefined): string {
39 if (v == null || !isFinite(v)) return '--'
40 return `${v.toFixed(1)} dB`
43export function fmtMs(v: Num | undefined): string {
44 if (v == null || !isFinite(v)) return '--'
45 if (v >= 1000) return `${(v / 1000).toPrecision(3)} s`
46 if (v >= 1) return `${v.toPrecision(3)} ms`
47 return `${(v * 1000).toFixed(0)} µs`
50export function fmtHz(v: Num | undefined): string {
51 if (v == null || !isFinite(v)) return '--'
52 return v >= 1000 ? `${(v / 1000).toPrecision(4)} kHz` : `${v.toPrecision(4)} Hz`
55export interface Series {
56 x: readonly Num[]
57 y: readonly Num[]
58 color: string
59 label?: string
60 dash?: string
61 width?: number
62 /** markers at the data points */
63 shape?: Shape
64 /** draw the polyline (default true unless `stems`) */
65 line?: boolean
66 /** vertical stems from `base` instead of a polyline */
67 stems?: boolean
68 base?: number
69 opacity?: number
70 /** omit from the legend */
71 noLegend?: boolean
72 /** label the last finite point directly on the plot */
73 endLabel?: string
74 /** extra lines for the hover read-out of point i */
75 tip?: (i: number) => string[]
78export interface RefLine {
79 p: number
80 through: [number, number]
81 label: string
82 labelAt?: 'start' | 'end'
83 span?: number
86export interface SeriesPlotProps {
87 title?: ReactNode
88 series: Series[]
89 height?: number
90 xLog?: boolean
91 yLog?: boolean
92 xDomain?: [number, number]
93 yDomain?: [number, number]
94 xLabel?: string
95 yLabel?: string
96 xTicks?: number[]
97 yTicks?: number[]
98 formatX?: (v: number) => string
99 formatY?: (v: number) => string
100 refs?: RefLine[]
101 /** thin vertical guides, e.g. the harmonics of a note or the Nyquist frequency */
102 vlines?: { x: readonly number[]; color?: string; dash?: string; opacity?: number }[]
103 /** thin horizontal guides, e.g. a threshold of audibility */
104 hlines?: { y: number; color?: string; dash?: string; label?: string }[]
105 legend?: boolean
106 extraLegend?: LegendItem[]
107 /** anything else, drawn in the frame over the data */
108 overlay?: (f: Frame) => ReactNode
109 margin?: Partial<{ l: number; r: number; t: number; b: number }>
112function finite(v: Num | undefined): v is number {
113 return v != null && isFinite(v)
116/** A plot of several series with legend, guides and a hover read-out. */
117export function SeriesPlot(props: SeriesPlotProps) {
118 const {
119 title,
120 series,
121 height = 230,
122 xLog = false,
123 yLog = false,
124 xLabel,
125 yLabel,
126 refs,
127 vlines,
128 hlines,
129 legend = true,
130 } = props
131 const [ptr, setPtr] = useState<PointerPx | null>(null)
133 const pos = (v: Num) => (finite(v) && (!yLog || v > 0) ? v : null)
134 const xs = series.flatMap((s) => s.x.filter((v) => finite(v) && (!xLog || v > 0)) as number[])
135 const ys = series.flatMap((s) => s.y.map(pos).filter(finite))
136 let xd: [number, number] = props.xDomain ?? extent(xs)
137 let yd: [number, number] = props.yDomain ?? extent(ys)
138 if (!props.xDomain) {
139 xd = xLog ? [xd[0] / 1.25, xd[1] * 1.25] : padDomain(xd, 0.02)
140 }
141 if (!props.yDomain) {
142 yd = yLog ? [yd[0] / 2, yd[1] * 2] : padDomain(yd)
143 }
145 const hasTips = series.some((s) => s.shape || s.tip)
146 const over = (f: Frame) => {
147 const out: ReactNode[] = []
148 series.forEach((s, k) => {
149 if (!s.endLabel) return
150 for (let i = s.y.length - 1; i >= 0; i--) {
151 const xv = s.x[i]
152 const yv = s.y[i]
153 if (finite(xv) && finite(yv) && (!yLog || yv > 0)) {
154 out.push(<EndLabel key={`e${k}`} f={f} x={xv} y={yv} text={s.endLabel} color={s.color} />)
155 break
156 }
157 }
158 })
159 if (ptr && hasTips) {
160 let best: { k: number; i: number; d: number; px: number; py: number } | null = null
161 series.forEach((s, k) => {
162 if (!s.shape && !s.tip) return
163 for (let i = 0; i < s.x.length; i++) {
164 const xv = s.x[i]
165 const yv = s.y[i]
166 if (!finite(xv) || !finite(yv) || (yLog && yv <= 0) || (xLog && xv <= 0)) continue
167 const px = f.sx(xv)
168 const py = f.sy(yv)
169 const d = Math.hypot(px - ptr.px, py - ptr.py)
170 if (d < 24 && (!best || d < best.d)) best = { k, i, d, px, py }
171 }
172 })
173 if (best) {
174 const b = best as { k: number; i: number; px: number; py: number }
175 const s = series[b.k]
176 const fx = props.formatX ?? ((v: number) => fmtNum(v, 4))
177 const fy = props.formatY ?? ((v: number) => fmtNum(v, 4))
178 const lines = [
179 s.label ?? '',
180 ...(s.tip ? s.tip(b.i) : [`${xLabel ?? 'x'} ${fx(s.x[b.i] as number)}`, `${yLabel ?? 'y'} ${fy(s.y[b.i] as number)}`]),
181 ]
182 out.push(<PointTip key="tip" f={f} px={b.px} py={b.py} color={s.color} lines={lines} />)
183 }
184 }
185 if (props.overlay) out.push(<g key="ov">{props.overlay(f)}</g>)
186 return <>{out}</>
187 }
189 const items: LegendItem[] = series
190 .filter((s) => s.label && !s.noLegend)
191 .map((s) => ({
192 label: s.label!,
193 color: s.color,
194 dash: s.dash,
195 shape: s.stems ? ('line' as const) : s.line === false && s.shape ? ('dot' as const) : undefined,
196 }))
198 return (
199 <div>
200 {title && <div className="plot-title">{title}</div>}
201 <Plot
202 height={height}
203 xDomain={xd}
204 yDomain={yd}
205 xLog={xLog}
206 yLog={yLog}
207 xLabel={xLabel}
208 yLabel={yLabel}
209 xTicks={props.xTicks}
210 yTicks={props.yTicks}
211 formatX={props.formatX}
212 formatY={props.formatY}
213 margin={props.margin ?? (series.some((s) => s.endLabel) ? { r: 78 } : undefined)}
214 onPointer={hasTips ? setPtr : undefined}
215 over={over}
216 >
217 {(f) => (
218 <>
219 {vlines?.map((g, k) =>
220 g.x.map((xv, i) =>
221 finite(xv) ? (
222 <line
223 key={`v${k}-${i}`}
224 x1={f.sx(xv)}
225 x2={f.sx(xv)}
226 y1={0}
227 y2={f.ih}
228 stroke={g.color ?? ink.axis}
229 strokeDasharray={g.dash}
230 opacity={g.opacity ?? 1}
231 strokeWidth={1}
232 />
233 ) : null,
234 ),
235 )}
236 {hlines?.map((g, k) => (
237 <g key={`h${k}`}>
238 <line
239 x1={0}
240 x2={f.iw}
241 y1={f.sy(g.y)}
242 y2={f.sy(g.y)}
243 stroke={g.color ?? ink.muted}
244 strokeDasharray={g.dash ?? '4 4'}
245 strokeWidth={1}
246 />
247 {g.label && (
248 <text x={f.iw - 4} y={f.sy(g.y) - 5} fill={g.color ?? ink.muted} fontSize={11} textAnchor="end">
249 {g.label}
250 </text>
251 )}
252 </g>
253 ))}
254 {refs?.map((r, k) => (
255 <RefSlope key={`r${k}`} f={f} p={r.p} through={r.through} label={r.label} labelAt={r.labelAt} span={r.span} />
256 ))}
257 {series.map((s, k) => {
258 const x = s.x.map((v) => (finite(v) ? v : NaN))
259 const y = s.y.map((v) => (finite(v) && (!yLog || v > 0) ? v : null))
260 return (
261 <g key={k} opacity={s.opacity ?? 1}>
262 {s.stems ? (
263 <Stems x={s.x} y={y} f={f} color={s.color} base={s.base ?? yd[0]} width={s.width ?? 1.5} />
264 ) : (
265 s.line !== false && <Line x={x} y={y} f={f} color={s.color} dash={s.dash} width={s.width ?? 2} />
266 )}
267 {s.shape && <Markers x={x} y={y} f={f} color={s.color} shape={s.shape} />}
268 </g>
269 )
270 })}
271 </>
272 )}
273 </Plot>
274 {legend && items.length + (props.extraLegend?.length ?? 0) > 0 && (
275 <Legend items={[...items, ...(props.extraLegend ?? [])]} />
276 )}
277 </div>
278 )
281/** A small table of numbers, the way the sibling tutorials print a sweep. */
282export function NumTable({
283 head,
284 rows,
285 caption,
286}: {
287 head: string[]
288 rows: (string | number)[][]
289 caption?: string
290}) {
291 return (
292 <div>
293 {caption && <div className="plot-title">{caption}</div>}
294 <table className="numtable">
295 <thead>
296 <tr>
297 {head.map((h) => (
298 <th key={h}>{h}</th>
299 ))}
300 </tr>
301 </thead>
302 <tbody>
303 {rows.map((r, i) => (
304 <tr key={i}>
305 {r.map((c, j) => (
306 <td key={j}>{c}</td>
307 ))}
308 </tr>
309 ))}
310 </tbody>
311 </table>
312 </div>
313 )
316/** Two panels side by side, wrapping to one column on a narrow screen. */
317export function Row({ children }: { children: ReactNode }) {
318 return <div className="figure-row">{children}</div>
↑↓ move↵openescclose