/ concept-collection / turing-surface
concept-collection / turing-surface

Comparing changes

Swap
compare-against-sphere is 2 commits ahead of main and is 2 commits behind. Create pull request
WIP: comparison with unit sphere
Owen Melia committed
cf4af12
Update comparison UI
Owen Melia committed
02e7a36
4 changed files+779−80
index.htmlmodified+53−3View file
@@ -177,6 +177,12 @@
177177 /* The bar's own layout: three chip rows stacked, then the reference
178178 picker and the button beside them. */
179179 .cmp-axes { display: flex; flex-direction: column; gap: 4px; }
180+ /* Without this, JS setting the `hidden` attribute (vs-sphere mode,
181+ which has no chip grid) does nothing: [hidden] and .cmp-axes tie on
182+ specificity, and the author rule above wins the tie over the
183+ browser's default — the exact opposite of what `hidden` is supposed
184+ to mean. */
185+ .cmp-axes[hidden] { display: none; }
180186 .cmp-axis { display: flex; align-items: center; gap: 8px; }
181187 .cmp-axis > span:first-child {
182188 color: var(--ink-2); font-size: 13px; width: 5.5em; text-align: right;
@@ -225,6 +231,47 @@
225231 border: 1px solid var(--line); border-radius: 2px;
226232 }
227233 .cmp-rangelab { font-variant-numeric: tabular-nums; white-space: nowrap; }
234+ /* The diff row sits right under its variant's value row: same label
235+ width so the columns still line up, but a lighter tone since it names
236+ no new variant, only the difference from one already named above. */
237+ .cmp-diffrow .cmp-rowlabel { color: var(--ink-2); font-style: italic; }
238+ .cmp-diffbox { position: relative; }
239+ .cmp-diffcap {
240+ position: absolute; bottom: 4px; right: 6px;
241+ font-size: 11px; font-variant-numeric: tabular-nums;
242+ color: #fff; background: rgba(0, 0, 0, 0.45);
243+ padding: 1px 5px; border-radius: 4px; pointer-events: none;
244+ }
245+ .cmp-chart {
246+ margin-top: 8px; padding: 8px 10px;
247+ border: 1px solid var(--line); border-radius: 8px;
248+ }
249+ .cmp-chart-legend {
250+ display: flex; flex-wrap: wrap; gap: 6px 18px;
251+ font-size: 12px; color: var(--ink-2); margin-bottom: 6px;
252+ }
253+ .cmp-chart-legend-group { display: flex; flex-wrap: wrap; gap: 12px; }
254+ /* The species group (dash styles) reads as a separate cluster from the
255+ variant group (colors) — a rule between them, not just a gap, since
256+ both groups otherwise look like more of the same kind of chip. */
257+ .cmp-chart-legend-species {
258+ padding-left: 14px; border-left: 1px solid var(--line);
259+ }
260+ .cmp-chart-legend-item { display: inline-flex; align-items: center; gap: 5px; }
261+ .cmp-chart-legend-item i {
262+ display: inline-block; width: 10px; height: 10px; border-radius: 2px;
263+ }
264+ /* Species swatches reuse the color chip's box model but draw a dashed
265+ line instead of a filled square, so the two legend groups still read
266+ as one visual language (a small icon + a name). */
267+ .cmp-chart-legend-species i.cmp-chart-dash {
268+ width: 16px; height: 0; border-radius: 0;
269+ border-top-width: 2px; border-top-color: var(--ink-2);
270+ }
271+ .cmp-chart-dash-0 { border-top-style: solid; }
272+ .cmp-chart-dash-1 { border-top-style: dashed; }
273+ .cmp-chart-dash-2 { border-top-style: dotted; }
274+ .cmp-chart-canvas { width: 100%; height: 240px; display: block; }
228275 @media (max-width: 860px) {
229276 .cmp-row { flex-direction: column; }
230277 .cmp-rowlabel { width: auto; flex-direction: row; gap: 10px; }
@@ -252,7 +299,9 @@
252299 <button type="button" class="chip" id="mode-simulate" aria-pressed="true">Simulate</button>
253300 <button type="button" class="chip" id="mode-effort"
254301 title="Run several solver settings side by side on one clock">Compare computational effort</button>
255- <button type="button" class="chip" id="mode-vs-sphere" disabled title="Coming soon">Compare against sphere (Coming soon)</button>
302+ <button type="button" class="chip" id="mode-vs-sphere"
303+ title="Run this model once on the true geometry and once on the unit sphere, from the same starting state">
304+ Compare against sphere</button>
256305 <button type="button" class="chip" id="mode-vs-upload"
257306 title="Check this solver against a saved reference run">
258307 Compare against uploaded data</button>
@@ -272,7 +321,7 @@
272321 <div class="controls" id="geomparams"></div>
273322 </div>
274323 <div class="controls" id="comparebar" hidden>
275- <div class="cmp-axes">
324+ <div class="cmp-axes" id="cmp-axes">
276325 <div class="cmp-axis">
277326 <span title="Iterations of the implicit diffusion solve">solve iters</span>
278327 <span id="cmp-niter" class="chips"></span>
@@ -286,7 +335,7 @@
286335 <span id="cmp-dt" class="chips"></span>
287336 </div>
288337 </div>
289- <label title="The run everything else is measured against">reference
338+ <label title="The run everything else is measured against" id="cmp-reflabel">reference
290339 <select id="cmp-ref"></select>
291340 </label>
292341 <span id="cmp-fileinfo" class="stats" hidden></span>
@@ -380,6 +429,7 @@
380429 </div>
381430 <p id="geomnote" class="stats"></p>
382431 <div id="panels"></div>
432+ <div id="cmp-chart" class="cmp-chart" hidden></div>
383433 <p class="stats" id="stats"></p>
384434 <p class="stats" id="benchresult"></p>
385435 <div class="editor">
src/compare/compareRun.tsmodified+297−53View file
@@ -44,6 +44,7 @@ import { fmtValue, floorRange } from '../render/colorbar.ts';
4444 import { prolongCoeffs, sharedModes, sharedNoise } from './sharedStart.ts';
4545 import { variantLabel, VARIANT_COLORS, type Variant } from './variants.ts';
4646 import type { ReferenceCase } from './referenceCase.ts';
47+import { ErrorChart, type ErrorChartRow } from '../render/errorChart.ts';
4748
4849 /**
4950 * Latitudes of the shared display grid. 256 is the same target the single-run
@@ -76,6 +77,21 @@ export interface CompareOptions {
7677 geometry: MGeometry;
7778 geometryParams: Params;
7879 geometrySource: string;
80+ /** Per-row geometry override, parallel-indexed to `variants` — the
81+ * "several rows on several geometries" case (vs-sphere mode), as opposed
82+ * to every other mode's "several rows, one geometry." Rows without an
83+ * entry (or when this is omitted entirely) fall back to the single
84+ * `geometry`/`geometryParams`/`geometrySource` above. */
85+ geometries?: { geometry: MGeometry; geometryParams: Params; geometrySource: string }[];
86+ /** Render every row — value panels and diff panels alike — on the
87+ * *reference* row's own surface (`coords`/`posBuf`) instead of each row's
88+ * own. For comparing two different geometries where only the *field*
89+ * should read as different, not the displayed shape. */
90+ renderOnReferenceGeometry?: boolean;
91+ /** Row names, overriding `variantLabel(variant, showDt)` — for a mode
92+ * where every row shares the same niter/lmax/dt, so that label alone
93+ * wouldn't tell the rows apart. */
94+ rowLabels?: string[];
7995 variants: Variant[];
8096 /** Index into `variants` of the run everything else is measured against.
8197 * Ignored when `refFile` is given — the file is the reference then. */
@@ -98,6 +114,8 @@ export interface CompareOptions {
98114 colormapName: () => string;
99115 /** Where the variant grid goes (the app's #panels). */
100116 container: HTMLElement;
117+ /** Where the error-vs-time chart goes (the app's #cmp-chart). */
118+ chartContainer: HTMLElement;
101119 /** Progress and, afterwards, the standing description of the study. */
102120 onStatus: (html: string) => void;
103121 }
@@ -114,6 +132,21 @@ interface Row {
114132 colorBufs: Float32Array[];
115133 /** Fields read this frame, one per species, on the shared grid. */
116134 fields: Float32Array[];
135+ /** Pointwise difference from the reference, one per species, on the shared
136+ * grid — filled by #measureDifference as it accumulates the norm below.
137+ * Unused (and never touched) for the reference row itself. */
138+ diffFields: Float32Array[];
139+ /** The diff row's own panels, one per species — empty for the reference
140+ * row, whose diff against itself is trivially zero. */
141+ diffScenes: SphereScene[];
142+ diffValueBufs: Float32Array[];
143+ diffColorBufs: Float32Array[];
144+ /** This row's own smoothed symmetric color range per species — each row
145+ * is scaled to its own diff extent, not a range shared across the
146+ * column, so one row's diff panels never affect another's coloring. */
147+ diffRanges: { lo: number; hi: number }[];
148+ /** Each diff panel's "± magnitude" caption, parallel to diffScenes. */
149+ diffCaps: HTMLElement[];
117150 /** Relative difference from the reference, one per species. */
118151 err: number[];
119152 /** False once any species has left the floating-point numbers — the shape a
@@ -161,6 +194,7 @@ export class CompareRun {
161194 /** Smoothed color range per species, shared by every variant so the panels
162195 * in a column are directly comparable by eye and not just by number. */
163196 #ranges: { lo: number; hi: number }[] = [];
197+ #errorChart: ErrorChart | null = null;
164198 #resizeObs: ResizeObserver | null = null;
165199
166200 #running = false;
@@ -234,10 +268,12 @@ export class CompareRun {
234268 // canvases from the DOM would leave both running.
235269 let built: Row[] = [];
236270 let builtFile: FileRow | null = null;
271+ let errorChart: ErrorChart | null = null;
237272
238273 try {
239274 for (let i = 0; i < variants.length; i++) {
240275 const v = variants[i];
276+ const g = opts.geometries?.[i];
241277 opts.onStatus(
242278 `compiling ${i + 1}/${variants.length} — ${variantLabel(v, showDt)} ` +
243279 `(a solve iteration is ~15 kernels per species, and there is no ` +
@@ -252,9 +288,9 @@ export class CompareRun {
252288 params: { ...opts.params, dt: baseDt / v.dtDiv },
253289 lmax: v.lmax,
254290 source: opts.source,
255- geometry: opts.geometry,
256- geometryParams: opts.geometryParams,
257- geometrySource: opts.geometrySource,
291+ geometry: g?.geometry ?? opts.geometry,
292+ geometryParams: g?.geometryParams ?? opts.geometryParams,
293+ geometrySource: g?.geometrySource ?? opts.geometrySource,
258294 niter: v.niter,
259295 lam3: opts.lam3,
260296 }),
@@ -298,10 +334,30 @@ export class CompareRun {
298334 // One at a time: a seed submits its whole mode sum in pieces, and there
299335 // is nothing to gain from interleaving several variants' worth of it.
300336 for (let i = 0; i < sessions.length; i++) await sessions[i].seedWith(noise[i], modes);
301- let coarsest = sessions[0];
302- for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
303- initial = await coarsest.readState();
304- initialLmax = coarsest.cfg.lmax;
337+ if (opts.geometries) {
338+ // Several geometries, not several lmax bands: the seeding above
339+ // drew a spatially shared field, but evaluated it on each row's
340+ // own (geometry-dependent) points — a different field per row in
341+ // coefficient space. The reference's exact resulting coefficients
342+ // replace that for every other row, so every row starts from the
343+ // identical spectral state and only the operator applied to it
344+ // differs from then on.
345+ const refSession = sessions[opts.reference];
346+ const refState = await refSession.readState();
347+ for (let i = 0; i < sessions.length; i++) {
348+ if (i === opts.reference) continue;
349+ sessions[i].loadState(
350+ prolongState(refState, model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
351+ );
352+ }
353+ initial = refState;
354+ initialLmax = refSession.cfg.lmax;
355+ } else {
356+ let coarsest = sessions[0];
357+ for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
358+ initial = await coarsest.readState();
359+ initialLmax = coarsest.cfg.lmax;
360+ }
305361 }
306362
307363 // ---- the mesh, shared; the surface, per variant ---------------------
@@ -336,6 +392,17 @@ export class CompareRun {
336392 built = rows;
337393 builtFile = fileRow;
338394
395+ // ---- the error-vs-time chart, one line per row with a diff panel ----
396+ const chartRows = rows.filter((r) => r.diffScenes.length > 0);
397+ errorChart = new ErrorChart(
398+ opts.chartContainer,
399+ model.species,
400+ chartRows.map((r): ErrorChartRow => ({ label: variantLabel(r.variant, showDt), color: r.color })),
401+ );
402+ // Nothing to chart with a single variant and no reference file — the
403+ // one row present is the reference itself.
404+ opts.chartContainer.hidden = chartRows.length === 0;
405+
339406 const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
340407 const note =
341408 `${variants.length} variant${variants.length === 1 ? '' : 's'} · ` +
@@ -349,14 +416,20 @@ export class CompareRun {
349416 const run = new CompareRun({
350417 opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note, initial, initialLmax,
351418 });
419+ run.#errorChart = errorChart;
352420 await run.draw();
353421 run.#observeResize();
354422 run.#status();
355423 return run;
356424 } catch (e) {
357- for (const r of built) for (const s of r.scenes) s.dispose();
425+ for (const r of built) {
426+ for (const s of r.scenes) s.dispose();
427+ for (const s of r.diffScenes) s.dispose();
428+ }
358429 for (const s of builtFile?.scenes ?? []) s.dispose();
359430 for (const s of sessions) s.destroy();
431+ errorChart?.dispose();
432+ opts.chartContainer.hidden = true;
360433 opts.container.replaceChildren();
361434 opts.container.classList.remove('compare');
362435 throw e;
@@ -400,10 +473,26 @@ export class CompareRun {
400473 // This draw becomes what restart() rewinds to from now on — see the
401474 // identical selection in create(). Recaptured here rather than left
402475 // pointing at the pre-reseed field.
403- let coarsest = sessions[0];
404- for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
405- this.#initial = await coarsest.readState();
406- this.#initialLmax = coarsest.cfg.lmax;
476+ if (this.#opts.geometries) {
477+ // Mirrors create()'s IC block: copy the reference's exact resulting
478+ // coefficients into every other row rather than trusting their own
479+ // (geometry-dependent) seeding to have landed on the same state.
480+ const refSession = sessions[this.#opts.reference];
481+ const refState = await refSession.readState();
482+ for (let i = 0; i < sessions.length; i++) {
483+ if (i === this.#opts.reference) continue;
484+ sessions[i].loadState(
485+ prolongState(refState, this.#opts.model.state, refSession.cfg.lmax, sessions[i].cfg.lmax),
486+ );
487+ }
488+ this.#initial = refState;
489+ this.#initialLmax = refSession.cfg.lmax;
490+ } else {
491+ let coarsest = sessions[0];
492+ for (const s of sessions) if (s.cfg.lmax < coarsest.cfg.lmax) coarsest = s;
493+ this.#initial = await coarsest.readState();
494+ this.#initialLmax = coarsest.cfg.lmax;
495+ }
407496 }
408497 this.#t = 0;
409498 this.#stepsDone = 0;
@@ -412,6 +501,13 @@ export class CompareRun {
412501 r.lo = NaN;
413502 r.hi = NaN;
414503 }
504+ for (const r of this.#rows) {
505+ for (const rr of r.diffRanges) {
506+ rr.lo = NaN;
507+ rr.hi = NaN;
508+ }
509+ }
510+ this.#errorChart?.reset();
415511 await this.draw();
416512 this.#status();
417513 if (!this.#disposed && wasRunning) this.setRunning(true);
@@ -437,6 +533,13 @@ export class CompareRun {
437533 r.lo = NaN;
438534 r.hi = NaN;
439535 }
536+ for (const r of this.#rows) {
537+ for (const rr of r.diffRanges) {
538+ rr.lo = NaN;
539+ rr.hi = NaN;
540+ }
541+ }
542+ this.#errorChart?.reset();
440543 await this.draw();
441544 this.#status();
442545 if (!this.#disposed && wasRunning) this.setRunning(true);
@@ -473,6 +576,11 @@ export class CompareRun {
473576 for (const r of this.#rows) {
474577 fillPositions(r.posBuf, r.coords, this.#topo, morph);
475578 for (const s of r.scenes) s.updatePositions(r.posBuf);
579+ // The diff row sits on the exact same mesh as the value row above it
580+ // (same coords, same posBuf) — it just never got told to re-render
581+ // when this method was first written, so it stayed fixed at whatever
582+ // shape the study was compiled with.
583+ for (const s of r.diffScenes) s.updatePositions(r.posBuf);
476584 }
477585 const f = this.#fileRow;
478586 if (f) {
@@ -492,17 +600,24 @@ export class CompareRun {
492600 this.#resizeObs = null;
493601 for (const r of this.#rows) {
494602 for (const s of r.scenes) s.dispose();
603+ for (const s of r.diffScenes) s.dispose();
495604 r.session.destroy();
496605 }
497606 for (const s of this.#fileRow?.scenes ?? []) s.dispose();
498607 this.#rows = [];
499608 this.#fileRow = null;
609+ this.#errorChart?.dispose();
610+ this.#errorChart = null;
611+ this.#opts.chartContainer.hidden = true;
500612 this.#opts.container.replaceChildren();
501613 this.#opts.container.classList.remove('compare');
502614 }
503615
504616 #allScenes(): SphereScene[] {
505- return [...this.#rows.flatMap((r) => r.scenes), ...(this.#fileRow?.scenes ?? [])];
617+ return [
618+ ...this.#rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
619+ ...(this.#fileRow?.scenes ?? []),
620+ ];
506621 }
507622
508623 // ----------------------------------------------------------------- drawing
@@ -611,7 +726,54 @@ export class CompareRun {
611726 }
612727
613728 this.#measureDifference();
729+ this.#colorDiffPanels();
614730 this.#updateRowStats();
731+ this.#errorChart?.push(
732+ this.#t,
733+ this.#rows.filter((r) => r.diffScenes.length > 0).map((r) => r.err),
734+ );
735+ }
736+
737+ /**
738+ * Color every diff panel from #measureDifference's pointwise diffFields, on
739+ * a *symmetric* range that is each row's own — not shared across the
740+ * column. A diverging row's diff explodes, but with a per-row range that
741+ * only saturates its own panels; it can no longer affect how any other
742+ * row's diff panels are scaled, which is a simpler fix for exactly the
743+ * flooding problem the value panels' shared #ranges/leastPeak logic exists
744+ * to manage there. The cost: diff-panel color alone no longer tells you
745+ * which row has more error than another — that comparison now lives in the
746+ * error chart, which has actual numbers. Always drawn with a diverging
747+ * colormap regardless of the user's chosen (sequential) one — a signed
748+ * quantity centered at zero needs a diverging map to read correctly, which
749+ * coolwarm is and viridis etc. are not.
750+ */
751+ #colorDiffPanels(): void {
752+ const species = this.#opts.model.species;
753+ for (const r of this.#rows) {
754+ if (r.diffScenes.length === 0) continue;
755+ for (let k = 0; k < species.length; k++) {
756+ const m = r.healthy ? maxAbsFinite(r.diffFields[k]) : null;
757+ const range = r.diffRanges[k];
758+ if (m !== null) {
759+ if (!Number.isFinite(range.lo)) {
760+ range.lo = -m;
761+ range.hi = m;
762+ } else {
763+ const a = 0.15;
764+ range.lo += a * (-m - range.lo);
765+ range.hi += a * (m - range.hi);
766+ }
767+ }
768+ if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
769+ const shown = floorRange(range.lo, range.hi);
770+ fillFieldValues(r.diffValueBufs[k], r.diffFields[k], this.#topo);
771+ fillColors(r.diffColorBufs[k], r.diffValueBufs[k], shown.lo, shown.hi, colormaps.coolwarm);
772+ r.diffScenes[k]?.updateColors(r.diffColorBufs[k]);
773+ const cap = r.diffCaps[k];
774+ if (cap) cap.textContent = `± ${fmtValue(shown.hi)}`;
775+ }
776+ }
615777 }
616778
617779 /**
@@ -640,11 +802,13 @@ export class CompareRun {
640802 r.err[k] = NaN;
641803 continue;
642804 }
805+ const diff = r.diffFields[k];
643806 let num = 0;
644807 let den = 0;
645808 for (let i = 0; i < a.length; i++) {
646809 const w = this.#weights[i];
647810 const d = a[i] - b[i];
811+ diff[i] = d;
648812 num += w * d * d;
649813 den += w * b[i] * b[i];
650814 }
@@ -661,21 +825,19 @@ export class CompareRun {
661825 * usually the one that has converged and the fast one the one that has not.
662826 */
663827 #updateRowStats(): void {
664- const species = this.#opts.model.species;
665828 const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
666829 for (const r of this.#rows) {
667- const per = species
668- .map((s, k) => `${s} ${Number.isFinite(r.err[k]) ? r.err[k].toExponential(2) : '—'}`)
669- .join('<br>');
670830 // Divergence is said, not implied. Scaled to a healthy row, a blown-up
671831 // variant is a flat saturated panel, which on its own is easy to misread
672- // as a converged uniform state.
832+ // as a converged uniform state. Δ itself now lives in the error chart,
833+ // not here.
673834 const body = !r.healthy
674835 ? '<b class="cmp-diverged">diverged</b>'
675836 : r === ref
676837 ? '<b>reference</b>'
677- : `Δ ${per}`;
678- r.statEl.innerHTML = `${r.session.steps.toLocaleString()} steps<br>${body}`;
838+ : '';
839+ r.statEl.innerHTML =
840+ `${r.session.steps.toLocaleString()} steps` + (body ? `<br>${body}` : '');
679841 }
680842 }
681843
@@ -683,9 +845,7 @@ export class CompareRun {
683845 const refFile = this.#opts.refFile;
684846 const clock = refFile
685847 ? `<b>t = ${this.#t.toFixed(2)} / ${(refFile.steps * CompareRun.baseDt(this.#opts.params)).toFixed(2)}</b>` +
686- (this.#finished
687- ? ` — <b>at the file's end time</b>: Δ is the final comparison against its final state`
688- : ` · Δ is the distance still to the file's <i>final</i> state — read it at the end time`)
848+ ` · NOTE: vertical axis measures difference from uploaded simulation's end state.`
689849 : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
690850 this.#opts.onStatus(
691851 `${clock} · ` +
@@ -701,11 +861,13 @@ export class CompareRun {
701861 const box = s.canvas.parentElement;
702862 if (box) s.resize(box.clientWidth, box.clientHeight);
703863 }
864+ this.#errorChart?.redraw();
704865 });
705866 for (const s of scenes) {
706867 const box = s.canvas.parentElement;
707868 if (box) this.#resizeObs.observe(box);
708869 }
870+ this.#resizeObs.observe(this.#opts.chartContainer);
709871 }
710872
711873 // -------------------------------------------------------------- the clock
@@ -801,6 +963,22 @@ function leastPeak(all: (Bounds | null)[]): Bounds | null {
801963 return best;
802964 }
803965
966+/** Max |value| over the finite entries of a field; null if none are finite —
967+ * the diff panels' analogue of finiteRange below, since a symmetric range
968+ * only needs the one number. */
969+function maxAbsFinite(f: Float32Array): number | null {
970+ let m = -Infinity;
971+ let any = false;
972+ for (let i = 0; i < f.length; i++) {
973+ const v = f[i];
974+ if (!Number.isFinite(v)) continue;
975+ any = true;
976+ const a = Math.abs(v);
977+ if (a > m) m = a;
978+ }
979+ return any ? m : null;
980+}
981+
804982 /** Min and max over the finite entries only; null when there are none. */
805983 function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } | null {
806984 if (!f) return null;
@@ -826,6 +1004,38 @@ function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } |
8261004 * a variant: it is the thing they are all measured against. */
8271005 const FILE_ROW_COLOR = '#57606a';
8281006
1007+/**
1008+ * One sphere panel: a boxed SphereScene plus the value/color buffers that
1009+ * feed it. Shared by the value row, the diff row, and the file row — all
1010+ * three build a panel the same way, only differing in the class on the box
1011+ * (for styling) and in what fills the buffers afterward.
1012+ */
1013+function makeSpherePanel(
1014+ colsEl: HTMLElement,
1015+ topo: SphereMeshTopology,
1016+ posBuf: Float32Array,
1017+ background: string | undefined,
1018+ extraClass = '',
1019+): { box: HTMLElement; scene: SphereScene; valueBuf: Float32Array; colorBuf: Float32Array } {
1020+ const box = document.createElement('div');
1021+ box.className = extraClass ? `sphere-box cmp-box ${extraClass}` : 'sphere-box cmp-box';
1022+ colsEl.append(box);
1023+ const scene = new SphereScene(
1024+ box,
1025+ topo.numVertices,
1026+ topo.indices,
1027+ Float32Array.from(posBuf),
1028+ background,
1029+ );
1030+ scene.fitCamera();
1031+ return {
1032+ box,
1033+ scene,
1034+ valueBuf: new Float32Array(topo.numVertices),
1035+ colorBuf: new Float32Array(topo.numVertices * 3),
1036+ };
1037+}
1038+
8291039 async function buildGrid(
8301040 opts: CompareOptions,
8311041 sessions: ModelSession[],
@@ -884,13 +1094,28 @@ async function buildGrid(
8841094 .getPropertyValue('--sphere-bg')
8851095 .trim();
8861096
1097+ // When every row should be drawn on the same shape (vs-sphere: the point
1098+ // is to isolate the field, not the surface), fetch that shape once from
1099+ // the reference row's session and hand the identical array to every row.
1100+ // fillPositions/fillFieldValues already treat "whose mesh this is" and
1101+ // "whose field this is" as fully independent buffers, so this is the only
1102+ // place that needs to know about it — every method downstream that reads
1103+ // r.coords/r.posBuf just sees one row's shape reused by every other.
1104+ const sharedCoords = opts.renderOnReferenceGeometry
1105+ ? await sessions[opts.reference].renderPositions()
1106+ : null;
1107+
8871108 const rows: Row[] = [];
8881109 for (let i = 0; i < sessions.length; i++) {
8891110 const session = sessions[i];
8901111 const variant = opts.variants[i];
8911112 const color = VARIANT_COLORS[i % VARIANT_COLORS.length];
1113+ // The reference itself never gets a diff row — its diff against itself
1114+ // is trivially zero, and a flat zero panel would just spend a WebGL
1115+ // context for nothing.
1116+ const isRef = !opts.refFile && i === opts.reference;
8921117
893- const coords = await session.renderPositions();
1118+ const coords = sharedCoords ?? (await session.renderPositions());
8941119 const posBuf = new Float32Array(topo.numVertices * 3);
8951120 fillPositions(posBuf, coords, topo, opts.morph);
8961121
@@ -901,7 +1126,7 @@ async function buildGrid(
9011126 labelEl.style.setProperty('--c', color);
9021127 const nameEl = document.createElement('div');
9031128 nameEl.className = 'cmp-rowname';
904- nameEl.textContent = variantLabel(variant, showDt);
1129+ nameEl.textContent = opts.rowLabels?.[i] ?? variantLabel(variant, showDt);
9051130 const statEl = document.createElement('div');
9061131 statEl.className = 'cmp-rowstat';
9071132 labelEl.append(nameEl, statEl);
@@ -914,25 +1139,52 @@ async function buildGrid(
9141139 const valueBufs: Float32Array[] = [];
9151140 const colorBufs: Float32Array[] = [];
9161141 for (let k = 0; k < model.species.length; k++) {
917- const box = document.createElement('div');
918- box.className = 'sphere-box cmp-box';
919- colsEl.append(box);
920- const scene = new SphereScene(
921- box,
922- topo.numVertices,
923- topo.indices,
924- Float32Array.from(posBuf),
925- sphereBg || undefined,
926- );
927- scene.fitCamera();
1142+ const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
9281143 scenes.push(scene);
929- valueBufs.push(new Float32Array(topo.numVertices));
930- colorBufs.push(new Float32Array(topo.numVertices * 3));
1144+ valueBufs.push(valueBuf);
1145+ colorBufs.push(colorBuf);
1146+ }
1147+
1148+ // ---- its diff row, right underneath ----------------------------------
1149+ const diffFields = model.species.map(() => new Float32Array(topo.nlat * topo.nphi));
1150+ const diffScenes: SphereScene[] = [];
1151+ const diffValueBufs: Float32Array[] = [];
1152+ const diffColorBufs: Float32Array[] = [];
1153+ const diffCaps: HTMLElement[] = [];
1154+ if (!isRef) {
1155+ const diffRowEl = document.createElement('div');
1156+ diffRowEl.className = 'cmp-row cmp-diffrow';
1157+ const diffLabelEl = document.createElement('div');
1158+ diffLabelEl.className = 'cmp-rowlabel';
1159+ diffLabelEl.style.setProperty('--c', color);
1160+ const diffNameEl = document.createElement('div');
1161+ diffNameEl.className = 'cmp-rowname';
1162+ diffNameEl.textContent = 'Δ vs reference';
1163+ diffLabelEl.append(diffNameEl);
1164+ const diffColsEl = document.createElement('div');
1165+ diffColsEl.className = 'cmp-cols';
1166+ diffRowEl.append(diffLabelEl, diffColsEl);
1167+ container.append(diffRowEl);
1168+
1169+ for (let k = 0; k < model.species.length; k++) {
1170+ const { box, scene, valueBuf, colorBuf } = makeSpherePanel(
1171+ diffColsEl, topo, posBuf, sphereBg || undefined, 'cmp-diffbox',
1172+ );
1173+ diffScenes.push(scene);
1174+ diffValueBufs.push(valueBuf);
1175+ diffColorBufs.push(colorBuf);
1176+ const cap = document.createElement('span');
1177+ cap.className = 'cmp-diffcap';
1178+ box.append(cap);
1179+ diffCaps.push(cap);
1180+ }
9311181 }
9321182
1183+ const diffRanges = model.species.map(() => ({ lo: NaN, hi: NaN }));
9331184 rows.push({
9341185 variant, session, color, coords, posBuf, scenes, valueBufs, colorBufs,
935- fields: [], err: model.species.map(() => 0), healthy: true, statEl,
1186+ fields: [], diffFields, diffScenes, diffValueBufs, diffColorBufs, diffCaps, diffRanges,
1187+ err: model.species.map(() => 0), healthy: true, statEl,
9361188 });
9371189 }
9381190
@@ -987,32 +1239,24 @@ async function buildGrid(
9871239 const fields: Float32Array[] = [];
9881240 const bounds: (Bounds | null)[] = [];
9891241 for (let k = 0; k < model.species.length; k++) {
990- const box = document.createElement('div');
991- box.className = 'sphere-box cmp-box';
992- colsEl.append(box);
993- const scene = new SphereScene(
994- box,
995- topo.numVertices,
996- topo.indices,
997- Float32Array.from(posBuf),
998- sphereBg || undefined,
999- );
1000- scene.fitCamera();
1242+ const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
10011243 scenes.push(scene);
10021244 const field = await on(rf.final[model.state[k]]);
10031245 fields.push(field);
10041246 bounds.push(finiteRange(field));
1005- const valueBuf = new Float32Array(topo.numVertices);
10061247 fillFieldValues(valueBuf, field, topo);
10071248 valueBufs.push(valueBuf);
1008- colorBufs.push(new Float32Array(topo.numVertices * 3));
1249+ colorBufs.push(colorBuf);
10091250 }
10101251 fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
10111252 }
10121253
10131254 // Every panel shares one camera: the study is about the fields, and looking
10141255 // at two of them from different angles is not comparing them.
1015- const all = [...rows.flatMap((r) => r.scenes), ...(fileRow?.scenes ?? [])];
1256+ const all = [
1257+ ...rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
1258+ ...(fileRow?.scenes ?? []),
1259+ ];
10161260 for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
10171261
10181262 return { rows, fileRow, rangeBars };
src/main.tsmodified+101−24View file
@@ -18,6 +18,7 @@ import {
1818 mGeometryByKey,
1919 defaultGeometryParams,
2020 DEFAULT_GEOMETRY_KEY,
21+ SPHERE_KEY,
2122 type MGeometry,
2223 } from './geom/registry.ts';
2324 import {
@@ -66,12 +67,15 @@ const elMovieRotate = $<HTMLInputElement>('movierotate');
6667 const elMovie = $<HTMLButtonElement>('movie');
6768 const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
6869 const elModeEffort = $<HTMLButtonElement>('mode-effort');
70+const elModeVsSphere = $<HTMLButtonElement>('mode-vs-sphere');
6971 const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
7072 const elModeDesc = $('mode-desc');
7173 const elCompareBar = $('comparebar');
74+const elCmpAxes = $('cmp-axes');
7275 const elCmpNiter = $('cmp-niter');
7376 const elCmpLmax = $('cmp-lmax');
7477 const elCmpDt = $('cmp-dt');
78+const elCmpRefLabel = $('cmp-reflabel');
7579 const elCmpRef = $<HTMLSelectElement>('cmp-ref');
7680 const elCmpFile = $<HTMLInputElement>('cmp-file');
7781 const elCmpFileInfo = $('cmp-fileinfo');
@@ -82,6 +86,7 @@ const elParams = $('params');
8286 const elGeomParams = $('geomparams');
8387 const elGeomNote = $('geomnote');
8488 const elPanels = $('panels');
89+const elCmpChart = $('cmp-chart');
8590 const elStats = $('stats');
8691 const elBenchResult = $('benchresult');
8792 const elCmd = $('cmd');
@@ -1286,13 +1291,26 @@ function compareRefIndex(): number {
12861291 }
12871292
12881293 function refreshVariants(): void {
1294+ // vs-sphere has no chip grid feeding cmpVariants() — always exactly two
1295+ // fixed rows plus one diff row, regardless of MAX_VARIANTS/MAX_PANELS —
1296+ // so the grid-count logic below doesn't apply here at all.
1297+ if (currentMode === 'vs-sphere') {
1298+ elCmpCount.textContent = `2 rows × ${model.species.length} species + 1 diff row`;
1299+ elCmpCount.style.color = '';
1300+ elCmpStart.disabled = false;
1301+ return;
1302+ }
12891303 const variants = cmpVariants();
12901304 const showDt = cmpSelected.dt.size > 1;
12911305 // With a file loaded the study's model is the file's, and its final state
12921306 // is one more row of panels.
12931307 const cmpModel = refCase?.model ?? model;
12941308 const rowCount = variants.length + (refCase ? 1 : 0);
1295- const panels = rowCount * cmpModel.species.length;
1309+ // Every row but the reference variant (or, against a file, every variant)
1310+ // gets a second diff row underneath it — each one more WebGL context per
1311+ // species, so MAX_PANELS has to bound the real total, not just the values.
1312+ const diffRowCount = refCase ? variants.length : Math.max(0, variants.length - 1);
1313+ const panels = (rowCount + diffRowCount) * cmpModel.species.length;
12961314
12971315 const prev = cmpRefKey;
12981316 elCmpRef.replaceChildren();
@@ -1325,7 +1343,9 @@ function refreshVariants(): void {
13251343 ? `too many: ${tooMany}`
13261344 : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
13271345 `${refCase ? ' + the file' : ''} × ` +
1328- `${cmpModel.species.length} species = ${panels} panels`;
1346+ `${cmpModel.species.length} species` +
1347+ (diffRowCount > 0 ? ` + ${diffRowCount} diff row${diffRowCount === 1 ? '' : 's'}` : '') +
1348+ ` = ${panels} panels`;
13291349 elCmpCount.style.color = tooMany ? '#b35900' : '';
13301350 elCmpStart.disabled = tooMany !== '' && compareRun === null;
13311351 }
@@ -1368,6 +1388,25 @@ function rebuildLmaxChips(): void {
13681388 buildChips(elCmpLmax, values, cmpSelected.lmax, String);
13691389 }
13701390
1391+/**
1392+ * The four top-level modes and which control groups each shows (see
1393+ * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1394+ * `currentMode` tracks which configuration is on screen — the compare bar
1395+ * being open, and in which flavor — not whether a study has actually been
1396+ * started inside it. That match matters: without it, opening the bar
1397+ * (which already shows the right groups) leaves its top-row button
1398+ * unhighlighted until a study happens to start, which is inconsistent with
1399+ * `vs-upload`'s one-click flow and reads as broken.
1400+ *
1401+ * Declared here, ahead of the top-level `refreshVariants()` call just below
1402+ * — that call reads `currentMode` (to know whether to skip its chip-grid
1403+ * counting for vs-sphere), so the declaration has to be in scope by the time
1404+ * this file's top-level code actually runs, not merely by the time
1405+ * `refreshVariants` is later invoked from an event handler.
1406+ */
1407+type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1408+let currentMode: Mode = 'simulate';
1409+
13711410 rebuildNiterChips();
13721411 rebuildLmaxChips();
13731412 buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
@@ -1401,23 +1440,13 @@ function applyRefUi(): void {
14011440 refreshVariants();
14021441 }
14031442
1404-/**
1405- * The four top-level modes and which control groups each shows (see
1406- * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1407- * `currentMode` tracks which configuration is on screen — the compare bar
1408- * being open, and in which flavor — not whether a study has actually been
1409- * started inside it. That match matters: without it, opening the bar
1410- * (which already shows the right groups) leaves its top-row button
1411- * unhighlighted until a study happens to start, which is inconsistent with
1412- * `vs-upload`'s one-click flow and reads as broken.
1413- */
1414-type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1415-let currentMode: Mode = 'simulate';
1416-
14171443 const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
14181444 simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
14191445 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1420- 'vs-sphere': [], // unreachable — the button is disabled, no listener ever calls setMode with this
1446+ // Unlike compute-effort, there is no separate chip grid for this mode —
1447+ // both rows always mirror whatever niter/lmax the Simulate controls have,
1448+ // so `solver` has to stay visible; it's the only place those get set.
1449+ 'vs-sphere': ['surface', 'surface-params', 'solver', 'display', 'playback', 'seed'],
14211450 // No `seed` here: nothing in that group does anything useful against a
14221451 // loaded file (lam3 is silently absorbed, and Restart already covers what
14231452 // Re-seed would otherwise be doing — reloading the file's fixed initial
@@ -1432,7 +1461,11 @@ const MODE_DESCRIPTIONS: Record<Mode, string> = {
14321461 'When we change the computational effort of the solver by varying solve iterations, lmax, or timestep, ' +
14331462 'how does the solution change? Find out by running several ' +
14341463 'so you can see how each setting trades accuracy for speed.',
1435- 'vs-sphere': '',
1464+ 'vs-sphere':
1465+ 'Run this model once on the selected geometry and once on the plain unit sphere, from the exact same ' +
1466+ 'starting state and the same solve iterations, lmax and timestep, so geometry is the only thing that ' +
1467+ 'differs. See how much resolving the true shape actually changes the pattern, versus approximating it ' +
1468+ 'as a sphere.',
14361469 'vs-upload':
14371470 'Load a saved reference run (an .h5 file) and run this solver to the ' +
14381471 'same physical end time from the same initial condition, to check how ' +
@@ -1443,6 +1476,7 @@ const MODE_DESCRIPTIONS: Record<Mode, string> = {
14431476 function setModeButtons(mode: Mode): void {
14441477 elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
14451478 elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1479+ elModeVsSphere.setAttribute('aria-pressed', String(mode === 'vs-sphere'));
14461480 elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
14471481 elModeDesc.textContent = MODE_DESCRIPTIONS[mode];
14481482 }
@@ -1467,26 +1501,33 @@ function applyModeVisibility(mode: Mode): void {
14671501 function enterMode(mode: Mode): void {
14681502 applyModeVisibility(mode);
14691503 elCompareBar.hidden = mode === 'simulate';
1504+ // vs-sphere has no chip grid to pick from — both rows mirror the Simulate
1505+ // panel's own niter/lmax — and no reference to pick among variants either,
1506+ // since the reference is always "the selected geometry." Just the
1507+ // description and the Compile button apply.
1508+ elCmpAxes.hidden = mode === 'vs-sphere';
1509+ elCmpRefLabel.hidden = mode === 'vs-sphere';
14701510 }
14711511
14721512 /** Entering a mode from the top row. */
14731513 function setMode(mode: Mode): void {
1474- if (mode === 'vs-sphere') return; // unreachable — button is disabled
14751514 if (mode === 'simulate') {
14761515 if (compareRun) void stopCompare();
14771516 enterMode('simulate');
14781517 return;
14791518 }
1480- if (mode === 'compute-effort') {
1519+ if (mode === 'compute-effort' || mode === 'vs-sphere') {
14811520 // Tear down whatever study is running first (mirrors Simulate above) —
14821521 // stopCompare's synchronous prefix disposes it and nulls `compareRun`
14831522 // before its first `await`, so `refCase` is safe to drop right after.
1523+ // vs-sphere never uses a reference file either — its "reference" is
1524+ // always the selected geometry — so the same drop applies there too.
14841525 if (compareRun) void stopCompare();
14851526 if (refCase) {
14861527 refCase = null;
14871528 applyRefUi();
14881529 }
1489- enterMode('compute-effort');
1530+ enterMode(mode);
14901531 return;
14911532 }
14921533 // vs-upload: opens the file picker; entering the mode itself happens once
@@ -1497,6 +1538,7 @@ function setMode(mode: Mode): void {
14971538
14981539 elModeSimulate.addEventListener('click', () => setMode('simulate'));
14991540 elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1541+elModeVsSphere.addEventListener('click', () => setMode('vs-sphere'));
15001542 elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
15011543
15021544 elCmpFile.addEventListener('change', () => {
@@ -1577,11 +1619,42 @@ async function startCompare(): Promise<void> {
15771619 if (compareRun || !device) return;
15781620 // Snapshotted for the whole study: `refCase` only changes with no study up
15791621 // (clearing is disabled during one, and loading tears it down first).
1580- const rc = refCase;
1622+ // vs-sphere never has one — its "reference" is always the selected
1623+ // geometry, not a file.
1624+ const rc = currentMode === 'vs-sphere' ? null : refCase;
15811625 const cmpModel = rc?.model ?? model;
1582- const variants = cmpVariants();
1626+
1627+ // vs-sphere: exactly two rows — the selected geometry (the reference) and
1628+ // the plain unit sphere — mirroring whatever niter/lmax the Simulate
1629+ // controls have, so geometry is the only thing that differs between them.
1630+ // Every other mode still drives its rows from the chip grid.
1631+ const isVsSphere = currentMode === 'vs-sphere';
1632+ let variants: Variant[];
1633+ let reference: number;
1634+ let geometries: { geometry: MGeometry; geometryParams: Params; geometrySource: string }[] | undefined;
1635+ let renderOnReferenceGeometry: boolean | undefined;
1636+ let rowLabels: string[] | undefined;
1637+ if (isVsSphere) {
1638+ const niter = Number(elNiter.value);
1639+ const lmax = Number(elLmax.value);
1640+ variants = [{ niter, lmax, dtDiv: 1 }, { niter, lmax, dtDiv: 1 }];
1641+ reference = 0;
1642+ const sphereGeom = mGeometryByKey(SPHERE_KEY)!;
1643+ geometries = [
1644+ { geometry, geometryParams: geomParams, geometrySource: geomSource() },
1645+ { geometry: sphereGeom, geometryParams: defaultGeometryParams(sphereGeom), geometrySource: sphereGeom.source },
1646+ ];
1647+ renderOnReferenceGeometry = true;
1648+ rowLabels = [geometry.label, 'unit sphere'];
1649+ } else {
1650+ variants = cmpVariants();
1651+ reference = rc ? 0 : compareRefIndex();
1652+ }
1653+
15831654 const rowCount = variants.length + (rc ? 1 : 0);
1584- if (variants.length > MAX_VARIANTS || rowCount * cmpModel.species.length > MAX_PANELS) {
1655+ const diffRowCount = rc ? variants.length : Math.max(0, variants.length - 1);
1656+ const panels = (rowCount + diffRowCount) * cmpModel.species.length;
1657+ if (!isVsSphere && (variants.length > MAX_VARIANTS || panels > MAX_PANELS)) {
15851658 return;
15861659 }
15871660 // Take down the single run first: its pump, its scenes, its session. The
@@ -1609,7 +1682,10 @@ async function startCompare(): Promise<void> {
16091682 geometryParams: rc ? rc.geometryParams : geomParams,
16101683 geometrySource: rc ? rc.geometry.source : geomSource(),
16111684 variants,
1612- reference: rc ? 0 : compareRefIndex(),
1685+ reference,
1686+ geometries,
1687+ renderOnReferenceGeometry,
1688+ rowLabels,
16131689 refFile: rc ?? undefined,
16141690 onFinished: () => setRunning(false),
16151691 seed,
@@ -1617,6 +1693,7 @@ async function startCompare(): Promise<void> {
16171693 morph,
16181694 colormapName: () => elColormap.value,
16191695 container: elPanels,
1696+ chartContainer: elCmpChart,
16201697 onStatus: (html) => (elStats.innerHTML = html),
16211698 });
16221699 } catch (e) {
src/render/errorChart.tsadded+328−0View file
@@ -0,0 +1,328 @@
1+/**
2+ * Live Δ(t) chart for a comparison study: one canvas, one line per
3+ * (variant, species) pair, sharing a log-scaled y-axis and an x-axis in
4+ * model time `t`. A variant's lines are colored with that variant's own
5+ * accent color (the same color shown on the left edge of its row of
6+ * panels); species are told apart within one color by line style (solid,
7+ * dashed, dotted, …), matched by the legend's dash swatches.
8+ *
9+ * Deliberately dumb — no library, just a canvas redrawn from scratch on every
10+ * push(). History is unbounded (a study is a "does it converge" question, and
11+ * capping the window would hide exactly the slow drift that question is
12+ * about); only the running log-min/max is maintained incrementally so a
13+ * redraw stays O(points on screen), not O(history) beyond what it already
14+ * draws.
15+ */
16+
17+import { fmtValue } from './colorbar.ts';
18+
19+export interface ErrorChartRow {
20+ label: string;
21+ color: string;
22+}
23+
24+/** Floor for the log scale — well below any error this app ever measures,
25+ * just far enough that log10 never sees zero or a negative. */
26+const MIN_LOG_VALUE = 1e-12;
27+
28+/** Canvas line-dash patterns by species index — solid, dashed, dotted,
29+ * repeating for a fourth-plus species (no shipped model has one). Mirrored
30+ * in index.html's `.cmp-chart-dash-{k}` border-style classes for the
31+ * legend swatches. */
32+const DASH_PATTERNS: number[][] = [[], [7, 4], [2, 3]];
33+
34+const MARGIN_LEFT = 44;
35+const MARGIN_BOTTOM = 38;
36+const MARGIN_TOP = 8;
37+const MARGIN_RIGHT = 10;
38+
39+export class ErrorChart {
40+ #container: HTMLElement;
41+ #species: string[];
42+ #rows: ErrorChartRow[];
43+ #canvas: HTMLCanvasElement | null = null;
44+
45+ #ts: number[] = [];
46+ /** #errs[k][i] is one species' one row's history, same length as #ts. */
47+ #errs: number[][][] = [];
48+ /** Running log10 extent across every (species, row) pushed — one shared
49+ * y-axis now, not one per species. */
50+ #minLog = Infinity;
51+ #maxLog = -Infinity;
52+
53+ constructor(container: HTMLElement, species: string[], rows: ErrorChartRow[]) {
54+ this.#container = container;
55+ this.#species = species;
56+ this.#rows = rows;
57+ this.#resetSeries();
58+ this.#build();
59+ }
60+
61+ #resetSeries(): void {
62+ this.#ts = [];
63+ this.#errs = this.#species.map(() => this.#rows.map(() => []));
64+ this.#minLog = Infinity;
65+ this.#maxLog = -Infinity;
66+ }
67+
68+ #build(): void {
69+ this.#container.replaceChildren();
70+ this.#canvas = null;
71+ if (this.#rows.length === 0 || this.#species.length === 0) return;
72+
73+ const legend = document.createElement('div');
74+ legend.className = 'cmp-chart-legend';
75+
76+ const variantGroup = document.createElement('div');
77+ variantGroup.className = 'cmp-chart-legend-group';
78+ for (const r of this.#rows) {
79+ const item = document.createElement('span');
80+ item.className = 'cmp-chart-legend-item';
81+ const swatch = document.createElement('i');
82+ swatch.style.background = r.color;
83+ item.append(swatch, document.createTextNode(r.label));
84+ variantGroup.append(item);
85+ }
86+
87+ const speciesGroup = document.createElement('div');
88+ speciesGroup.className = 'cmp-chart-legend-group cmp-chart-legend-species';
89+ this.#species.forEach((name, k) => {
90+ const item = document.createElement('span');
91+ item.className = 'cmp-chart-legend-item';
92+ const swatch = document.createElement('i');
93+ swatch.className = `cmp-chart-dash cmp-chart-dash-${k % DASH_PATTERNS.length}`;
94+ item.append(swatch, document.createTextNode(name));
95+ speciesGroup.append(item);
96+ });
97+
98+ legend.append(variantGroup, speciesGroup);
99+ this.#container.append(legend);
100+
101+ const canvas = document.createElement('canvas');
102+ canvas.className = 'cmp-chart-canvas';
103+ this.#container.append(canvas);
104+ this.#canvas = canvas;
105+ }
106+
107+ /** One frame's sample: `t` shared by every row, `perRowErr[i][k]` the
108+ * relative-L2 error of row i's species k against the reference. Rows here
109+ * are exactly the ones passed to the constructor, in the same order — the
110+ * reference row is never included (its Δ against itself is always 0). */
111+ push(t: number, perRowErr: number[][]): void {
112+ this.#ts.push(t);
113+ for (let k = 0; k < this.#species.length; k++) {
114+ for (let i = 0; i < this.#rows.length; i++) {
115+ const v = perRowErr[i]?.[k];
116+ this.#errs[k][i].push(v === undefined ? NaN : v);
117+ if (Number.isFinite(v) && v! > 0) {
118+ const lv = Math.log10(v!);
119+ if (lv < this.#minLog) this.#minLog = lv;
120+ if (lv > this.#maxLog) this.#maxLog = lv;
121+ }
122+ }
123+ }
124+ this.#draw();
125+ }
126+
127+ /** Back to no history — called wherever the study's clock itself resets
128+ * (restart, reseed), so the chart never shows a curve spanning a rewind. */
129+ reset(): void {
130+ this.#resetSeries();
131+ this.#draw();
132+ }
133+
134+ /** Redraw with the data as it stands — for a container resize, where
135+ * nothing new has been measured but the canvas backing buffer has to
136+ * change to match. */
137+ redraw(): void {
138+ this.#draw();
139+ }
140+
141+ dispose(): void {
142+ this.#container.replaceChildren();
143+ this.#canvas = null;
144+ }
145+
146+ #draw(): void {
147+ const canvas = this.#canvas;
148+ if (!canvas) return;
149+ const rect = canvas.getBoundingClientRect();
150+ const dpr = window.devicePixelRatio || 1;
151+ const cssW = Math.max(1, rect.width);
152+ const cssH = Math.max(1, rect.height || 240);
153+ const pxW = Math.max(1, Math.round(cssW * dpr));
154+ const pxH = Math.max(1, Math.round(cssH * dpr));
155+ if (canvas.width !== pxW) canvas.width = pxW;
156+ if (canvas.height !== pxH) canvas.height = pxH;
157+ const ctx = canvas.getContext('2d');
158+ if (!ctx) return;
159+ // Draw in CSS-pixel coordinates throughout; the transform alone accounts
160+ // for devicePixelRatio, rather than scaling every margin/font by hand.
161+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
162+ ctx.clearRect(0, 0, cssW, cssH);
163+
164+ const n = this.#ts.length;
165+ if (n < 2) return;
166+
167+ const rawLo = this.#minLog;
168+ const rawHi = this.#maxLog;
169+ if (!Number.isFinite(rawLo) || !Number.isFinite(rawHi)) return;
170+ let lo = rawLo;
171+ let hi = rawHi;
172+ if (hi - lo < 1e-6) {
173+ lo -= 0.5;
174+ hi += 0.5;
175+ }
176+ const pad = (hi - lo) * 0.08;
177+ lo -= pad;
178+ hi += pad;
179+
180+ const style = getComputedStyle(document.documentElement);
181+ const inkColor = style.getPropertyValue('--ink-2').trim() || '#666';
182+ const gridColor = style.getPropertyValue('--line').trim() || '#ccc';
183+
184+ const plotX = MARGIN_LEFT;
185+ const plotY = MARGIN_TOP;
186+ const plotW = Math.max(1, cssW - MARGIN_LEFT - MARGIN_RIGHT);
187+ const plotH = Math.max(1, cssH - MARGIN_TOP - MARGIN_BOTTOM);
188+
189+ const t0 = this.#ts[0];
190+ const t1 = this.#ts[n - 1];
191+ const tSpan = t1 - t0 || 1;
192+ const xAt = (t: number): number => plotX + ((t - t0) / tSpan) * plotW;
193+ const yAt = (v: number): number => {
194+ const lv = Math.max(lo, Math.min(hi, Math.log10(Math.max(v, MIN_LOG_VALUE))));
195+ return plotY + (1 - (lv - lo) / (hi - lo)) * plotH;
196+ };
197+
198+ ctx.font = '11px sans-serif';
199+
200+ // ---- y-axis: decade gridlines + ticks + labels -----------------------
201+ const yTicks = decadeTicks(lo, hi, rawLo, rawHi);
202+ ctx.textAlign = 'right';
203+ ctx.textBaseline = 'middle';
204+ for (const tick of yTicks) {
205+ const y = yAt(tick.value);
206+ ctx.strokeStyle = gridColor;
207+ ctx.globalAlpha = 0.3;
208+ ctx.lineWidth = 1;
209+ ctx.beginPath();
210+ ctx.moveTo(plotX, y);
211+ ctx.lineTo(plotX + plotW, y);
212+ ctx.stroke();
213+ ctx.globalAlpha = 1;
214+ ctx.beginPath();
215+ ctx.moveTo(plotX - 4, y);
216+ ctx.lineTo(plotX, y);
217+ ctx.stroke();
218+ ctx.fillStyle = inkColor;
219+ ctx.fillText(tick.label, plotX - 6, y);
220+ }
221+
222+ // ---- x-axis: ticks + labels --------------------------------------------
223+ const xTicks = evenTicks(t0, t1, 5);
224+ ctx.textAlign = 'center';
225+ ctx.textBaseline = 'top';
226+ ctx.strokeStyle = gridColor;
227+ for (const t of xTicks) {
228+ const x = xAt(t);
229+ ctx.beginPath();
230+ ctx.moveTo(x, plotY + plotH);
231+ ctx.lineTo(x, plotY + plotH + 4);
232+ ctx.stroke();
233+ ctx.fillStyle = inkColor;
234+ ctx.fillText(fmtValue(t), x, plotY + plotH + 6);
235+ }
236+
237+ // ---- axis frame ---------------------------------------------------------
238+ ctx.strokeStyle = gridColor;
239+ ctx.beginPath();
240+ ctx.moveTo(plotX, plotY);
241+ ctx.lineTo(plotX, plotY + plotH);
242+ ctx.lineTo(plotX + plotW, plotY + plotH);
243+ ctx.stroke();
244+
245+ // ---- axis titles ---------------------------------------------------------
246+ ctx.fillStyle = inkColor;
247+ ctx.textAlign = 'center';
248+ ctx.textBaseline = 'bottom';
249+ ctx.fillText('t', plotX + plotW / 2, cssH - 2);
250+
251+ ctx.save();
252+ ctx.translate(12, plotY + plotH / 2);
253+ ctx.rotate(-Math.PI / 2);
254+ ctx.textAlign = 'center';
255+ ctx.textBaseline = 'alphabetic';
256+ ctx.fillText('relative L2 error', 0, 0);
257+ ctx.restore();
258+
259+ // ---- the data: one line per (species, row) -----------------------------
260+ for (let k = 0; k < this.#species.length; k++) {
261+ const dash = DASH_PATTERNS[k % DASH_PATTERNS.length];
262+ for (let i = 0; i < this.#rows.length; i++) {
263+ const series = this.#errs[k][i];
264+ ctx.setLineDash(dash);
265+ ctx.strokeStyle = this.#rows[i].color;
266+ ctx.lineWidth = 1.5;
267+ ctx.beginPath();
268+ let started = false;
269+ for (let j = 0; j < n; j++) {
270+ const v = series[j];
271+ // A non-finite or non-positive sample (a diverged row, or a norm
272+ // with zero denominator) breaks the line rather than drawing a
273+ // spurious segment through a point that has no place on a log axis.
274+ if (!Number.isFinite(v) || v <= 0) {
275+ started = false;
276+ continue;
277+ }
278+ const x = xAt(this.#ts[j]);
279+ const y = yAt(v);
280+ if (started) ctx.lineTo(x, y);
281+ else ctx.moveTo(x, y);
282+ started = true;
283+ }
284+ ctx.stroke();
285+ }
286+ }
287+ ctx.setLineDash([]);
288+ }
289+}
290+
291+/**
292+ * Y-axis ticks: one per decade spanned by [lo, hi] (the padded plotting
293+ * range), labeled `1e{n}`. If the visible range covers less than a full
294+ * decade — a study that hasn't had time to spread out yet — decade ticks
295+ * would give zero or one of them, so fall back to two ticks at the actual
296+ * (unpadded) data extent instead, labeled with their real value.
297+ */
298+function decadeTicks(
299+ lo: number,
300+ hi: number,
301+ rawLo: number,
302+ rawHi: number,
303+): { value: number; label: string }[] {
304+ const start = Math.ceil(lo);
305+ const end = Math.floor(hi);
306+ const decades: number[] = [];
307+ for (let d = start; d <= end; d++) decades.push(d);
308+ if (decades.length >= 2) {
309+ return decades.map((d) => ({
310+ value: 10 ** d,
311+ label: (10 ** d).toExponential(0).replace('e+', 'e'),
312+ }));
313+ }
314+ const loVal = 10 ** rawLo;
315+ const hiVal = 10 ** rawHi;
316+ if (hiVal <= loVal) return [{ value: loVal, label: fmtValue(loVal) }];
317+ return [
318+ { value: loVal, label: fmtValue(loVal) },
319+ { value: hiVal, label: fmtValue(hiVal) },
320+ ];
321+}
322+
323+/** `count` evenly spaced ticks between t0 and t1 inclusive; just t0 if the
324+ * span is degenerate (a single point pushed so far). */
325+function evenTicks(t0: number, t1: number, count: number): number[] {
326+ if (t1 <= t0) return [t0];
327+ return Array.from({ length: count }, (_, i) => t0 + (i * (t1 - t0)) / (count - 1));
328+}