1// Figures for the one-dimensional labs. Every lab in Part I sends back one of
2// these two shapes, so the same two panels serve all of them.
3import { Fragment, useState } from 'react'
4import Plot, { type PointerPx } from '../../plot/Plot.tsx'
5import Legend from '../../plot/Legend.tsx'
6import { EndLabel, Line, Markers, PointTip, RefSlope } from '../../plot/Curves.tsx'
7import { ink, marker, method, type MethodKey } from '../../plot/palette.ts'
8import { extent, padDomain, type Frame } from '../../plot/scales.ts'
9import type { Num } from '../../engine/types.ts'
11export interface SingleOut {
12 names: string[]
13 ms: number
14 n: number
15 x: number[]
16 u: Num[]
17 uex: number[]
18 err: Num[]
19 maxerr: Num
20 xf: number[]
21 uf: number[]
22}
24export interface SweepOut {
25 names: string[]
26 ns: number[]
27 /** per-method resolutions, when the methods do not share a ladder */
28 NS?: Num[][]
29 E: Num[][]
30 T: Num[][]
31 orders: Num[][]
32}
34/** The x values for series k: its own ladder if it has one. */
35function xsOf(data: SweepOut, k: number): number[] {
36 const row = data.NS?.[k]
37 if (!row) return data.ns
38 return row.map((v) => (v == null ? NaN : v))
39}
41export function fmtErr(v: Num): string {
42 if (v == null || !isFinite(v)) return '--'
43 if (v === 0) return '0'
44 return v.toExponential(2)
45}
47/** A time in milliseconds, at three significant figures either side of a second. */
48export function fmtMs(v: Num): string {
49 if (v == null || !isFinite(v)) return '--'
50 if (v >= 1000) return `${(v / 1000).toPrecision(3)} s`
51 return `${v.toPrecision(3)} ms`
52}
54/** The computed solution over the exact one, and the error underneath. */
55export function Solution1D({ data, slot = 'fd2' }: { data: SingleOut; slot?: MethodKey }) {
56 const color = method[slot]
57 const yd = padDomain(extent(data.uf, data.u))
58 const ed = padDomain(extent(data.err))
59 const showNodes = data.x.length <= 130
60 return (
61 <>
62 <div className="plot-title">the solution: exact, and computed at the grid points</div>
63 <Plot height={220} xDomain={[0, 1]} yDomain={yd} xLabel="x" yLabel="u">
64 {(f) => (
65 <>
66 <Line x={data.xf} y={data.uf} f={f} color={ink.reference} width={2} />
67 <Line x={data.x} y={data.u} f={f} color={color} width={1.5} dash="4 3" />
68 {showNodes && <Markers x={data.x} y={data.u} f={f} color={color} shape={marker[slot]} r={3.2} />}
69 </>
70 )}
71 </Plot>
72 <Legend
73 items={[
74 { label: 'exact solution', color: ink.reference },
75 { label: `computed (${data.n} intervals)`, color, dash: '4 3' },
76 ]}
77 />
78 <div className="plot-title">the error, computed minus exact, at the grid points</div>
79 <Plot height={170} xDomain={[0, 1]} yDomain={ed} xLabel="x" yLabel="error">
80 {(f) => (
81 <>
82 <Line x={[0, 1]} y={[0, 0]} f={f} color={ink.axis} width={1} />
83 <Line x={data.x} y={data.err} f={f} color={color} width={2} />
84 {showNodes && <Markers x={data.x} y={data.err} f={f} color={color} shape={marker[slot]} r={3.2} />}
85 </>
86 )}
87 </Plot>
88 </>
89 )
90}
92export interface SweepStyle {
93 /** palette slot per method, in the order the script lists them */
94 slots: MethodKey[]
95 /** dashed reference lines: exponent, label, and an optional anchor point */
96 refs?: { p: number; label: string; at?: [number, number] }[]
97 /** per-series dash pattern, when two series share a colour on purpose */
98 dashes?: (string | undefined)[]
99}
101function seriesExtent(rows: Num[][]): [number, number] {
102 let lo = Infinity
103 let hi = -Infinity
104 for (const r of rows) {
105 for (const v of r) {
106 if (v == null || !isFinite(v) || v <= 0) continue
107 if (v < lo) lo = v
108 if (v > hi) hi = v
109 }
110 }
111 return isFinite(lo) ? [lo / 2, hi * 2] : [1e-16, 1]
112}
114/** Max error against resolution, on log-log axes. */
115export function Convergence({
116 data,
117 style,
118 height = 300,
119 xLabel = 'N',
120}: {
121 data: SweepOut
122 style: SweepStyle
123 height?: number
124 xLabel?: string
125}) {
126 const yd = seriesExtent(data.E)
127 const allx = data.NS ? data.NS.flat().filter((v): v is number => v != null && v > 0) : data.ns
128 const xd: [number, number] = [Math.min(...allx) / 1.3, Math.max(...allx) * 1.3]
129 // reference lines are anchored a factor of 5 below the first data point, so
130 // that the reader compares slopes rather than trying to see through them
131 const e0 = data.E[0]?.find((v) => v != null && isFinite(v) && v > 0) ?? 1
132 return (
133 <>
134 <div className="plot-title">max error against resolution</div>
135 <Plot height={height} xDomain={xd} yDomain={yd} xLog yLog xLabel={xLabel} yLabel="max |error|">
136 {(f) => (
137 <>
138 {style.refs?.map((r) => (
139 <RefSlope
140 key={r.label}
141 f={f}
142 p={r.p}
143 through={r.at ?? [data.ns[0], (e0 as number) / 5]}
144 span={1}
145 label={r.label}
146 />
147 ))}
148 {data.E.map((row, k) => {
149 const slot = style.slots[k] ?? 'fd2'
150 return (
151 <g key={k}>
152 <Line x={xsOf(data, k)} y={row} f={f} color={method[slot]} dash={style.dashes?.[k]} />
153 <Markers x={xsOf(data, k)} y={row} f={f} color={method[slot]} shape={marker[slot]} />
154 </g>
155 )
156 })}
157 </>
158 )}
159 </Plot>
160 <Legend
161 items={data.names.map((n, k) => ({
162 label: n,
163 color: method[style.slots[k] ?? 'fd2'],
164 dash: style.dashes?.[k],
165 }))}
166 />
167 <OrderTable data={data} />
168 </>
169 )
170}
172export function OrderTable({ data }: { data: SweepOut }) {
173 return (
174 <table className="numtable">
175 <thead>
176 <tr>
177 <th>{data.NS ? 'resolution' : 'N'}</th>
178 {data.names.map((n) => (
179 <th key={n} colSpan={2}>
180 {n}
181 </th>
182 ))}
183 </tr>
184 <tr>
185 <th />
186 {data.names.map((n) => (
187 <Fragment key={n}>
188 <th>error</th>
189 <th>order</th>
190 </Fragment>
191 ))}
192 </tr>
193 </thead>
194 <tbody>
195 {data.ns.map((n, j) => (
196 <tr key={n}>
197 <td>{data.NS ? data.NS.map((r) => r[j] ?? '--').join(' / ') : n}</td>
198 {data.names.map((nm, k) => (
199 <Fragment key={nm}>
200 <td>{fmtErr(data.E[k][j])}</td>
201 <td style={{ color: ink.muted }}>
202 {data.orders[k][j] == null ? '' : (data.orders[k][j] as number).toFixed(2)}
203 </td>
204 </Fragment>
205 ))}
206 </tr>
207 ))}
208 </tbody>
209 </table>
210 )
211}
213/** The data point nearest the cursor, if one is close enough to have been meant. */
214function nearestPoint(data: SweepOut, f: Frame, p: PointerPx | null): { k: number; j: number } | null {
215 if (!p) return null
216 let best: { k: number; j: number } | null = null
217 let bestD = 26 * 26
218 data.E.forEach((row, k) => {
219 const t = data.T[k]
220 for (let j = 0; j < row.length; j++) {
221 const e = row[j]
222 const tt = t[j]
223 if (e == null || tt == null || !isFinite(e) || !isFinite(tt) || e <= 0 || tt <= 0) continue
224 const dx = f.sx(tt) - p.px
225 const dy = f.sy(e) - p.py
226 const d = dx * dx + dy * dy
227 if (d < bestD) {
228 bestD = d
229 best = { k, j }
230 }
231 }
232 })
233 return best
234}
236/** Error against wall clock: the only comparison that decides anything. */
237export function WorkPrecision({
238 data,
239 style,
240 height = 320,
241 label = 'milliseconds for one solve',
242 labelCount,
243 resLabel = 'N',
244}: {
245 data: SweepOut
246 style: SweepStyle
247 height?: number
248 label?: string
249 /** how many series get a direct end-label; the rest rely on the legend */
250 labelCount?: number
251 /** what the resolution parameter is called in the hover box */
252 resLabel?: string
253}) {
254 const yd = seriesExtent(data.E)
255 const xd = seriesExtent(data.T)
256 // Two ways to ask which curve is which: point at a marker, which names its
257 // series and reads out that point's numbers, or point at a legend entry,
258 // which picks the curve out of the rest. Neither is decoration here: once
259 // offline results are loaded, several series share a colour and differ only
260 // in their dash pattern, and the legend alone cannot settle it.
261 const [ptr, setPtr] = useState<PointerPx | null>(null)
262 const [picked, setPicked] = useState<number | null>(null)
263 const emph = (k: number, active: number | null) => (active == null || active === k ? 1 : 0.18)
265 return (
266 <>
267 <div className="plot-title">
268 accuracy against cost; down and to the left is better. Point at a marker for its numbers, or at a legend
269 entry for its curve.
270 </div>
271 <Plot
272 height={height}
273 xDomain={xd}
274 yDomain={yd}
275 xLog
276 yLog
277 xLabel={label}
278 yLabel="max |error|"
279 margin={{ r: 30 }}
280 onPointer={setPtr}
281 over={(f) => {
282 const hit = nearestPoint(data, f, ptr)
283 const active = hit ? hit.k : picked
284 return (
285 <>
286 {data.E.map((row, k) => {
287 // label the cheapest end of each curve, which is where the
288 // curves are furthest apart; alternate the vertical offset so
289 // that labels of curves starting at similar cost do not
290 // collide. A picked curve is labelled whether or not it is one
291 // of the few that carry a label by default.
292 if (active != null ? active !== k : labelCount != null && k >= labelCount) return null
293 const t = data.T[k]
294 let first = -1
295 for (let i = 0; i < row.length; i++) {
296 if (row[i] != null && t[i] != null) {
297 first = i
298 break
299 }
300 }
301 if (first < 0) return null
302 const px = f.sx(t[first] as number)
303 const right = px > f.iw * 0.65
304 return (
305 <EndLabel
306 key={k}
307 f={f}
308 x={t[first] as number}
309 y={row[first] as number}
310 text={data.names[k]}
311 color={method[style.slots[k] ?? 'fd2']}
312 anchor={right ? 'end' : 'start'}
313 dx={right ? -8 : 8}
314 dy={12 + (k % 2) * 14}
315 />
316 )
317 })}
318 {hit && (
319 <PointTip
320 f={f}
321 px={f.sx(data.T[hit.k][hit.j] as number)}
322 py={f.sy(data.E[hit.k][hit.j] as number)}
323 color={method[style.slots[hit.k] ?? 'fd2']}
324 lines={[
325 data.names[hit.k],
326 `${resLabel} = ${xsOf(data, hit.k)[hit.j] ?? '--'}`,
327 `error ${fmtErr(data.E[hit.k][hit.j])}`,
328 `${fmtMs(data.T[hit.k][hit.j])} for one solve`,
329 ]}
330 />
331 )}
332 </>
333 )
334 }}
335 >
336 {(f) => {
337 const active = nearestPoint(data, f, ptr)?.k ?? picked
338 return (
339 <>
340 {data.E.map((row, k) => {
341 const slot = style.slots[k] ?? 'fd2'
342 const t = data.T[k]
343 const o = emph(k, active)
344 return (
345 <g key={k} opacity={o}>
346 <Line
347 x={t.map((v) => v ?? NaN)}
348 y={row}
349 f={f}
350 color={method[slot]}
351 dash={style.dashes?.[k]}
352 width={active === k ? 2.8 : 2}
353 />
354 <Markers x={t.map((v) => v ?? NaN)} y={row} f={f} color={method[slot]} shape={marker[slot]} />
355 </g>
356 )
357 })}
358 </>
359 )
360 }}
361 </Plot>
362 <Legend
363 items={data.names.map((n, k) => ({
364 label: n,
365 color: method[style.slots[k] ?? 'fd2'],
366 dash: style.dashes?.[k],
367 }))}
368 onHover={setPicked}
369 active={picked}
370 />
371 </>
372 )
373}