// Figures for the one-dimensional labs. Every lab in Part I sends back one of // these two shapes, so the same two panels serve all of them. import { Fragment, useState } from 'react' import Plot, { type PointerPx } from '../../plot/Plot.tsx' import Legend from '../../plot/Legend.tsx' import { EndLabel, Line, Markers, PointTip, RefSlope } from '../../plot/Curves.tsx' import { ink, marker, method, type MethodKey } from '../../plot/palette.ts' import { extent, padDomain, type Frame } from '../../plot/scales.ts' import type { Num } from '../../engine/types.ts' export interface SingleOut { names: string[] ms: number n: number x: number[] u: Num[] uex: number[] err: Num[] maxerr: Num xf: number[] uf: number[] } export interface SweepOut { names: string[] ns: number[] /** per-method resolutions, when the methods do not share a ladder */ NS?: Num[][] E: Num[][] T: Num[][] orders: Num[][] } /** The x values for series k: its own ladder if it has one. */ function xsOf(data: SweepOut, k: number): number[] { const row = data.NS?.[k] if (!row) return data.ns return row.map((v) => (v == null ? NaN : v)) } export function fmtErr(v: Num): string { if (v == null || !isFinite(v)) return '--' if (v === 0) return '0' return v.toExponential(2) } /** A time in milliseconds, at three significant figures either side of a second. */ export function fmtMs(v: Num): string { if (v == null || !isFinite(v)) return '--' if (v >= 1000) return `${(v / 1000).toPrecision(3)} s` return `${v.toPrecision(3)} ms` } /** The computed solution over the exact one, and the error underneath. */ export function Solution1D({ data, slot = 'fd2' }: { data: SingleOut; slot?: MethodKey }) { const color = method[slot] const yd = padDomain(extent(data.uf, data.u)) const ed = padDomain(extent(data.err)) const showNodes = data.x.length <= 130 return ( <>
the solution: exact, and computed at the grid points
{(f) => ( <> {showNodes && } )}
the error, computed minus exact, at the grid points
{(f) => ( <> {showNodes && } )} ) } export interface SweepStyle { /** palette slot per method, in the order the script lists them */ slots: MethodKey[] /** dashed reference lines: exponent, label, and an optional anchor point */ refs?: { p: number; label: string; at?: [number, number] }[] /** per-series dash pattern, when two series share a colour on purpose */ dashes?: (string | undefined)[] } function seriesExtent(rows: Num[][]): [number, number] { let lo = Infinity let hi = -Infinity for (const r of rows) { for (const v of r) { if (v == null || !isFinite(v) || v <= 0) continue if (v < lo) lo = v if (v > hi) hi = v } } return isFinite(lo) ? [lo / 2, hi * 2] : [1e-16, 1] } /** Max error against resolution, on log-log axes. */ export function Convergence({ data, style, height = 300, xLabel = 'N', }: { data: SweepOut style: SweepStyle height?: number xLabel?: string }) { const yd = seriesExtent(data.E) const allx = data.NS ? data.NS.flat().filter((v): v is number => v != null && v > 0) : data.ns const xd: [number, number] = [Math.min(...allx) / 1.3, Math.max(...allx) * 1.3] // reference lines are anchored a factor of 5 below the first data point, so // that the reader compares slopes rather than trying to see through them const e0 = data.E[0]?.find((v) => v != null && isFinite(v) && v > 0) ?? 1 return ( <>
max error against resolution
{(f) => ( <> {style.refs?.map((r) => ( ))} {data.E.map((row, k) => { const slot = style.slots[k] ?? 'fd2' return ( ) })} )} ({ label: n, color: method[style.slots[k] ?? 'fd2'], dash: style.dashes?.[k], }))} /> ) } export function OrderTable({ data }: { data: SweepOut }) { return ( {data.names.map((n) => ( ))} ))} {data.ns.map((n, j) => ( {data.names.map((nm, k) => ( ))} ))}
{data.NS ? 'resolution' : 'N'} {n}
{data.names.map((n) => ( error order
{data.NS ? data.NS.map((r) => r[j] ?? '--').join(' / ') : n}{fmtErr(data.E[k][j])} {data.orders[k][j] == null ? '' : (data.orders[k][j] as number).toFixed(2)}
) } /** The data point nearest the cursor, if one is close enough to have been meant. */ function nearestPoint(data: SweepOut, f: Frame, p: PointerPx | null): { k: number; j: number } | null { if (!p) return null let best: { k: number; j: number } | null = null let bestD = 26 * 26 data.E.forEach((row, k) => { const t = data.T[k] for (let j = 0; j < row.length; j++) { const e = row[j] const tt = t[j] if (e == null || tt == null || !isFinite(e) || !isFinite(tt) || e <= 0 || tt <= 0) continue const dx = f.sx(tt) - p.px const dy = f.sy(e) - p.py const d = dx * dx + dy * dy if (d < bestD) { bestD = d best = { k, j } } } }) return best } /** Error against wall clock: the only comparison that decides anything. */ export function WorkPrecision({ data, style, height = 320, label = 'milliseconds for one solve', labelCount, resLabel = 'N', }: { data: SweepOut style: SweepStyle height?: number label?: string /** how many series get a direct end-label; the rest rely on the legend */ labelCount?: number /** what the resolution parameter is called in the hover box */ resLabel?: string }) { const yd = seriesExtent(data.E) const xd = seriesExtent(data.T) // Two ways to ask which curve is which: point at a marker, which names its // series and reads out that point's numbers, or point at a legend entry, // which picks the curve out of the rest. Neither is decoration here: once // offline results are loaded, several series share a colour and differ only // in their dash pattern, and the legend alone cannot settle it. const [ptr, setPtr] = useState(null) const [picked, setPicked] = useState(null) const emph = (k: number, active: number | null) => (active == null || active === k ? 1 : 0.18) return ( <>
accuracy against cost; down and to the left is better. Point at a marker for its numbers, or at a legend entry for its curve.
{ const hit = nearestPoint(data, f, ptr) const active = hit ? hit.k : picked return ( <> {data.E.map((row, k) => { // label the cheapest end of each curve, which is where the // curves are furthest apart; alternate the vertical offset so // that labels of curves starting at similar cost do not // collide. A picked curve is labelled whether or not it is one // of the few that carry a label by default. if (active != null ? active !== k : labelCount != null && k >= labelCount) return null const t = data.T[k] let first = -1 for (let i = 0; i < row.length; i++) { if (row[i] != null && t[i] != null) { first = i break } } if (first < 0) return null const px = f.sx(t[first] as number) const right = px > f.iw * 0.65 return ( ) })} {hit && ( )} ) }} > {(f) => { const active = nearestPoint(data, f, ptr)?.k ?? picked return ( <> {data.E.map((row, k) => { const slot = style.slots[k] ?? 'fd2' const t = data.T[k] const o = emph(k, active) return ( v ?? NaN)} y={row} f={f} color={method[slot]} dash={style.dashes?.[k]} width={active === k ? 2.8 : 2} /> v ?? NaN)} y={row} f={f} color={method[slot]} shape={marker[slot]} /> ) })} ) }} ({ label: n, color: method[style.slots[k] ?? 'fd2'], dash: style.dashes?.[k], }))} onHover={setPicked} active={picked} /> ) }