d8098edAn interactive tutorial on the wave equation in acoustics: strings, drums and rooms, measured by earJeremy Magland 1// Marks drawn inside a Plot frame: polylines, markers, reference slopes and
2// direct end-labels. Every mark is thin (2px lines, >=8px markers) and every
3// marker carries a shape as well as a colour.
4import type { ReactNode } from 'react'
5import type { Num } from '../engine/types.ts'
6import { ink, surface } from './palette.ts'
7import { linePath, type Frame } from './scales.ts'
9export type Shape = 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'star'
11export function Line({
12 x,
13 y,
14 f,
15 color,
16 dash,
17 width = 2,
18 opacity = 1,
19}: {
20 x: readonly number[]
21 y: readonly Num[]
22 f: Frame
23 color: string
24 dash?: string
25 width?: number
26 opacity?: number
27}) {
28 return (
29 <path
30 d={linePath(x, y, f)}
31 fill="none"
32 stroke={color}
33 strokeWidth={width}
34 strokeDasharray={dash}
35 strokeLinejoin="round"
36 strokeLinecap="round"
37 opacity={opacity}
38 />
39 )
40}
42/** One marker, centred on (cx, cy) in pixels, about 9px across. */
43export function markerPath(shape: Shape, cx: number, cy: number, r = 4.5): string {
44 switch (shape) {
45 case 'square':
46 return `M${cx - r} ${cy - r}h${2 * r}v${2 * r}h${-2 * r}z`
47 case 'triangle':
48 return `M${cx} ${cy - r * 1.15}L${cx + r} ${cy + r * 0.8}L${cx - r} ${cy + r * 0.8}z`
49 case 'diamond':
50 return `M${cx} ${cy - r * 1.2}L${cx + r * 1.2} ${cy}L${cx} ${cy + r * 1.2}L${cx - r * 1.2} ${cy}z`
51 case 'cross':
52 return `M${cx - r} ${cy - r}L${cx + r} ${cy + r}M${cx + r} ${cy - r}L${cx - r} ${cy + r}`
53 case 'plus':
54 return `M${cx - r * 1.2} ${cy}h${2.4 * r}M${cx} ${cy - r * 1.2}v${2.4 * r}`
55 case 'star': {
56 let d = ''
57 for (let k = 0; k < 3; k++) {
58 const a = (Math.PI * k) / 3
59 d += `M${cx - r * Math.cos(a)} ${cy - r * Math.sin(a)}L${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`
60 }
61 return d
62 }
63 default:
64 return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0z`
65 }
66}
68export function Markers({
69 x,
70 y,
71 f,
72 color,
73 shape = 'circle',
74 r = 4.5,
75}: {
76 x: readonly number[]
77 y: readonly Num[]
78 f: Frame
79 color: string
80 shape?: Shape
81 r?: number
82}) {
83 const stroked = shape === 'cross' || shape === 'plus' || shape === 'star'
84 const out: ReactNode[] = []
85 for (let i = 0; i < x.length && i < y.length; i++) {
86 const v = y[i]
87 if (v == null || !isFinite(v) || !isFinite(x[i])) continue
88 const px = f.sx(x[i])
89 const py = f.sy(v)
90 if (py < -20 || py > f.ih + 20) continue
91 out.push(
92 <path
93 key={i}
94 d={markerPath(shape, px, py, r)}
95 fill={stroked ? 'none' : color}
96 stroke={stroked ? color : surface}
97 strokeWidth={stroked ? 2 : 1.5}
98 />,
99 )
100 }
101 return <g>{out}</g>
102}
104/**
105 * A filled band between two curves: an exact posterior's plus-or-minus two
106 * standard deviations, a spread over repetitions. Drawn under everything
107 * else, at low opacity, so that it reads as context rather than as a series.
108 */
109export function Band({
110 x,
111 lo,
112 hi,
113 f,
114 color,
115 opacity = 0.16,
116}: {
117 x: readonly number[]
118 lo: readonly Num[]
119 hi: readonly Num[]
120 f: Frame
121 color: string
122 opacity?: number
123}) {
124 const up: string[] = []
125 const down: string[] = []
126 for (let i = 0; i < x.length; i++) {
127 const a = lo[i]
128 const b = hi[i]
129 if (a == null || b == null || !isFinite(a) || !isFinite(b)) continue
130 up.push(`${f.sx(x[i]).toFixed(2)} ${f.sy(b).toFixed(2)}`)
131 down.push(`${f.sx(x[i]).toFixed(2)} ${f.sy(a).toFixed(2)}`)
132 }
133 if (up.length === 0) return null
134 down.reverse()
135 return <path d={`M${up.join('L')}L${down.join('L')}Z`} fill={color} opacity={opacity} stroke="none" />
136}
138/** Vertical stems from a baseline: a weighted rug, a spike train. */
139export function Stems({
140 x,
141 y,
142 f,
143 color,
144 base = 0,
145 width = 1,
146 opacity = 1,
147}: {
148 x: readonly Num[]
149 y: readonly Num[]
150 f: Frame
151 color: string
152 base?: number
153 width?: number
154 opacity?: number
155}) {
156 let d = ''
157 const y0 = f.sy(base)
158 for (let i = 0; i < x.length && i < y.length; i++) {
159 const xv = x[i]
160 const yv = y[i]
161 if (xv == null || yv == null || !isFinite(xv) || !isFinite(yv)) continue
162 d += `M${f.sx(xv).toFixed(2)} ${y0.toFixed(2)}L${f.sx(xv).toFixed(2)} ${f.sy(yv).toFixed(2)}`
163 }
164 return <path d={d} stroke={color} strokeWidth={width} opacity={opacity} fill="none" />
165}
167/** A step function through (edges implied by x), for a histogram. */
168export function Steps({
169 x,
170 y,
171 f,
172 color,
173 width = 1.8,
174 dash,
175 opacity = 1,
176}: {
177 /** bin centres, equally spaced */
178 x: readonly number[]
179 y: readonly Num[]
180 f: Frame
181 color: string
182 width?: number
183 dash?: string
184 opacity?: number
185}) {
186 if (x.length < 2) return null
187 const h = (x[1] - x[0]) / 2
188 let d = ''
189 let pen = false
190 for (let i = 0; i < x.length; i++) {
191 const v = y[i]
192 if (v == null || !isFinite(v)) {
193 pen = false
194 continue
195 }
196 const yy = f.sy(v).toFixed(2)
197 d += `${pen ? 'L' : 'M'}${f.sx(x[i] - h).toFixed(2)} ${yy}L${f.sx(x[i] + h).toFixed(2)} ${yy}`
198 pen = true
199 }
200 return <path d={d} stroke={color} strokeWidth={width} strokeDasharray={dash} opacity={opacity} fill="none" />
201}
203/**
204 * A dashed reference line of slope p through a chosen point, for log-log
205 * convergence plots: the eye reads "is the data parallel to this?" much better
206 * than it reads a printed rate.
207 */
208export function RefSlope({
209 f,
210 p,
211 through,
212 label,
213 span = 0.6,
214 labelAt = 'start',
215}: {
216 f: Frame
217 /** the exponent: error ~ n^p */
218 p: number
219 /** a point the line passes through, in data coordinates */
220 through: [number, number]
221 label: string
222 /** fraction of the x range the line covers */
223 span?: number
224 labelAt?: 'start' | 'end'
225}) {
226 const [lx, hx] = f.xDomain
227 const l = Math.log10(lx)
228 const h = Math.log10(hx)
229 const c = Math.log10(through[0])
230 const x0 = Math.pow(10, Math.max(l, c - (span * (h - l)) / 2))
231 const x1 = Math.pow(10, Math.min(h, c + (span * (h - l)) / 2))
232 const y = (xx: number) => through[1] * Math.pow(xx / through[0], p)
233 return (
234 <g>
235 <path
236 d={`M${f.sx(x0)} ${f.sy(y(x0))}L${f.sx(x1)} ${f.sy(y(x1))}`}
237 stroke={ink.muted}
238 strokeWidth={1.5}
239 strokeDasharray="5 4"
240 fill="none"
241 />
242 {labelAt === 'end' ? (
243 <text x={f.sx(x1) - 3} y={f.sy(y(x1)) - 6} fill={ink.muted} fontSize={11} textAnchor="end">
244 {label}
245 </text>
246 ) : (
247 <text x={f.sx(x0) + 3} y={f.sy(y(x0)) + 14} fill={ink.muted} fontSize={11} textAnchor="start">
248 {label}
249 </text>
250 )}
251 </g>
252 )
253}
255/**
256 * A ring around the data point under the cursor, and a box of its numbers.
257 *
258 * Drawn as SVG rather than an HTML overlay, like everything else in this
259 * layer, so it needs no positioning relative to the host element. The box
260 * width is estimated from the character count, which is why the lines are kept
261 * short; it flips to the other side of the point rather than leave the frame.
262 */
263export function PointTip({
264 f,
265 px,
266 py,
267 color,
268 lines,
269}: {
270 f: Frame
271 px: number
272 py: number
273 color: string
274 /** first line is the series, in its own colour; the rest are the numbers */
275 lines: string[]
276}) {
277 const size = 11.5
278 const pad = 6
279 const lh = 15
280 const w = Math.max(...lines.map((l) => l.length)) * 6.15 + 2 * pad
281 const h = lines.length * lh + 2 * pad - 3
282 const right = px + 14 + w < f.iw
283 const bx = Math.min(Math.max(right ? px + 14 : px - 14 - w, 2), Math.max(2, f.iw - w - 2))
284 const by = Math.min(Math.max(py - h / 2, 2), Math.max(2, f.ih - h - 2))
285 return (
286 <g pointerEvents="none">
287 <circle cx={px} cy={py} r={7} fill="none" stroke={color} strokeWidth={2} />
288 <circle cx={px} cy={py} r={7} fill="none" stroke={surface} strokeWidth={1} opacity={0.7} />
289 <rect
290 x={bx}
291 y={by}
292 width={w}
293 height={h}
294 rx={4}
295 fill={surface}
296 fillOpacity={0.94}
297 stroke={ink.axis}
298 strokeWidth={1}
299 />
300 {lines.map((l, i) => (
301 <text
302 key={i}
303 x={bx + pad}
304 y={by + pad + lh * i + size}
305 fill={i === 0 ? color : ink.secondary}
306 fontSize={size}
307 style={i === 0 ? undefined : { fontVariantNumeric: 'tabular-nums' }}
308 >
309 {l}
310 </text>
311 ))}
312 </g>
313 )
314}
316/** A label placed at the end of a curve, so the reader need not use the legend. */
317export function EndLabel({
318 f,
319 x,
320 y,
321 text,
322 color,
323 dx = 6,
324 dy = 4,
325 anchor = 'start',
326}: {
327 f: Frame
328 x: number
329 y: number
330 text: string
331 color: string
332 dx?: number
333 dy?: number
334 anchor?: 'start' | 'end' | 'middle'
335}) {
336 return (
337 <text
338 x={f.sx(x) + dx}
339 y={f.sy(y) + dy}
340 fill={color}
341 fontSize={11}
342 textAnchor={anchor}
343 style={{ paintOrder: 'stroke', stroke: surface, strokeWidth: 3 }}
344 >
345 {text}
346 </text>
347 )
348}