Update comparison UI
4 changed files+599−43
index.htmlmodified+42−0View file
@@ -225,6 +225,47 @@
225225 border: 1px solid var(--line); border-radius: 2px;
226226 }
227227 .cmp-rangelab { font-variant-numeric: tabular-nums; white-space: nowrap; }
228+ /* The diff row sits right under its variant's value row: same label
229+ width so the columns still line up, but a lighter tone since it names
230+ no new variant, only the difference from one already named above. */
231+ .cmp-diffrow .cmp-rowlabel { color: var(--ink-2); font-style: italic; }
232+ .cmp-diffbox { position: relative; }
233+ .cmp-diffcap {
234+ position: absolute; bottom: 4px; right: 6px;
235+ font-size: 11px; font-variant-numeric: tabular-nums;
236+ color: #fff; background: rgba(0, 0, 0, 0.45);
237+ padding: 1px 5px; border-radius: 4px; pointer-events: none;
238+ }
239+ .cmp-chart {
240+ margin-top: 8px; padding: 8px 10px;
241+ border: 1px solid var(--line); border-radius: 8px;
242+ }
243+ .cmp-chart-legend {
244+ display: flex; flex-wrap: wrap; gap: 6px 18px;
245+ font-size: 12px; color: var(--ink-2); margin-bottom: 6px;
246+ }
247+ .cmp-chart-legend-group { display: flex; flex-wrap: wrap; gap: 12px; }
248+ /* The species group (dash styles) reads as a separate cluster from the
249+ variant group (colors) — a rule between them, not just a gap, since
250+ both groups otherwise look like more of the same kind of chip. */
251+ .cmp-chart-legend-species {
252+ padding-left: 14px; border-left: 1px solid var(--line);
253+ }
254+ .cmp-chart-legend-item { display: inline-flex; align-items: center; gap: 5px; }
255+ .cmp-chart-legend-item i {
256+ display: inline-block; width: 10px; height: 10px; border-radius: 2px;
257+ }
258+ /* Species swatches reuse the color chip's box model but draw a dashed
259+ line instead of a filled square, so the two legend groups still read
260+ as one visual language (a small icon + a name). */
261+ .cmp-chart-legend-species i.cmp-chart-dash {
262+ width: 16px; height: 0; border-radius: 0;
263+ border-top-width: 2px; border-top-color: var(--ink-2);
264+ }
265+ .cmp-chart-dash-0 { border-top-style: solid; }
266+ .cmp-chart-dash-1 { border-top-style: dashed; }
267+ .cmp-chart-dash-2 { border-top-style: dotted; }
268+ .cmp-chart-canvas { width: 100%; height: 240px; display: block; }
228269 @media (max-width: 860px) {
229270 .cmp-row { flex-direction: column; }
230271 .cmp-rowlabel { width: auto; flex-direction: row; gap: 10px; }
@@ -380,6 +421,7 @@
380421 </div>
381422 <p id="geomnote" class="stats"></p>
382423 <div id="panels"></div>
424+ <div id="cmp-chart" class="cmp-chart" hidden></div>
383425 <p class="stats" id="stats"></p>
384426 <p class="stats" id="benchresult"></p>
385427 <div class="editor">
src/compare/compareRun.tsmodified+216−40View 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
@@ -98,6 +99,8 @@ export interface CompareOptions {
9899 colormapName: () => string;
99100 /** Where the variant grid goes (the app's #panels). */
100101 container: HTMLElement;
102+ /** Where the error-vs-time chart goes (the app's #cmp-chart). */
103+ chartContainer: HTMLElement;
101104 /** Progress and, afterwards, the standing description of the study. */
102105 onStatus: (html: string) => void;
103106 }
@@ -114,6 +117,21 @@ interface Row {
114117 colorBufs: Float32Array[];
115118 /** Fields read this frame, one per species, on the shared grid. */
116119 fields: Float32Array[];
120+ /** Pointwise difference from the reference, one per species, on the shared
121+ * grid — filled by #measureDifference as it accumulates the norm below.
122+ * Unused (and never touched) for the reference row itself. */
123+ diffFields: Float32Array[];
124+ /** The diff row's own panels, one per species — empty for the reference
125+ * row, whose diff against itself is trivially zero. */
126+ diffScenes: SphereScene[];
127+ diffValueBufs: Float32Array[];
128+ diffColorBufs: Float32Array[];
129+ /** This row's own smoothed symmetric color range per species — each row
130+ * is scaled to its own diff extent, not a range shared across the
131+ * column, so one row's diff panels never affect another's coloring. */
132+ diffRanges: { lo: number; hi: number }[];
133+ /** Each diff panel's "± magnitude" caption, parallel to diffScenes. */
134+ diffCaps: HTMLElement[];
117135 /** Relative difference from the reference, one per species. */
118136 err: number[];
119137 /** False once any species has left the floating-point numbers — the shape a
@@ -161,6 +179,7 @@ export class CompareRun {
161179 /** Smoothed color range per species, shared by every variant so the panels
162180 * in a column are directly comparable by eye and not just by number. */
163181 #ranges: { lo: number; hi: number }[] = [];
182+ #errorChart: ErrorChart | null = null;
164183 #resizeObs: ResizeObserver | null = null;
165184
166185 #running = false;
@@ -234,6 +253,7 @@ export class CompareRun {
234253 // canvases from the DOM would leave both running.
235254 let built: Row[] = [];
236255 let builtFile: FileRow | null = null;
256+ let errorChart: ErrorChart | null = null;
237257
238258 try {
239259 for (let i = 0; i < variants.length; i++) {
@@ -336,6 +356,17 @@ export class CompareRun {
336356 built = rows;
337357 builtFile = fileRow;
338358
359+ // ---- the error-vs-time chart, one line per row with a diff panel ----
360+ const chartRows = rows.filter((r) => r.diffScenes.length > 0);
361+ errorChart = new ErrorChart(
362+ opts.chartContainer,
363+ model.species,
364+ chartRows.map((r): ErrorChartRow => ({ label: variantLabel(r.variant, showDt), color: r.color })),
365+ );
366+ // Nothing to chart with a single variant and no reference file — the
367+ // one row present is the reference itself.
368+ opts.chartContainer.hidden = chartRows.length === 0;
369+
339370 const solverGrid = sessions.map((s) => `${s.cfg.nlat}×${s.cfg.nphi}`);
340371 const note =
341372 `${variants.length} variant${variants.length === 1 ? '' : 's'} · ` +
@@ -349,14 +380,20 @@ export class CompareRun {
349380 const run = new CompareRun({
350381 opts, rows, fileRow, topo, weights, rangeBars, frameSteps, note, initial, initialLmax,
351382 });
383+ run.#errorChart = errorChart;
352384 await run.draw();
353385 run.#observeResize();
354386 run.#status();
355387 return run;
356388 } catch (e) {
357- for (const r of built) for (const s of r.scenes) s.dispose();
389+ for (const r of built) {
390+ for (const s of r.scenes) s.dispose();
391+ for (const s of r.diffScenes) s.dispose();
392+ }
358393 for (const s of builtFile?.scenes ?? []) s.dispose();
359394 for (const s of sessions) s.destroy();
395+ errorChart?.dispose();
396+ opts.chartContainer.hidden = true;
360397 opts.container.replaceChildren();
361398 opts.container.classList.remove('compare');
362399 throw e;
@@ -412,6 +449,13 @@ export class CompareRun {
412449 r.lo = NaN;
413450 r.hi = NaN;
414451 }
452+ for (const r of this.#rows) {
453+ for (const rr of r.diffRanges) {
454+ rr.lo = NaN;
455+ rr.hi = NaN;
456+ }
457+ }
458+ this.#errorChart?.reset();
415459 await this.draw();
416460 this.#status();
417461 if (!this.#disposed && wasRunning) this.setRunning(true);
@@ -437,6 +481,13 @@ export class CompareRun {
437481 r.lo = NaN;
438482 r.hi = NaN;
439483 }
484+ for (const r of this.#rows) {
485+ for (const rr of r.diffRanges) {
486+ rr.lo = NaN;
487+ rr.hi = NaN;
488+ }
489+ }
490+ this.#errorChart?.reset();
440491 await this.draw();
441492 this.#status();
442493 if (!this.#disposed && wasRunning) this.setRunning(true);
@@ -492,17 +543,24 @@ export class CompareRun {
492543 this.#resizeObs = null;
493544 for (const r of this.#rows) {
494545 for (const s of r.scenes) s.dispose();
546+ for (const s of r.diffScenes) s.dispose();
495547 r.session.destroy();
496548 }
497549 for (const s of this.#fileRow?.scenes ?? []) s.dispose();
498550 this.#rows = [];
499551 this.#fileRow = null;
552+ this.#errorChart?.dispose();
553+ this.#errorChart = null;
554+ this.#opts.chartContainer.hidden = true;
500555 this.#opts.container.replaceChildren();
501556 this.#opts.container.classList.remove('compare');
502557 }
503558
504559 #allScenes(): SphereScene[] {
505- return [...this.#rows.flatMap((r) => r.scenes), ...(this.#fileRow?.scenes ?? [])];
560+ return [
561+ ...this.#rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
562+ ...(this.#fileRow?.scenes ?? []),
563+ ];
506564 }
507565
508566 // ----------------------------------------------------------------- drawing
@@ -611,7 +669,54 @@ export class CompareRun {
611669 }
612670
613671 this.#measureDifference();
672+ this.#colorDiffPanels();
614673 this.#updateRowStats();
674+ this.#errorChart?.push(
675+ this.#t,
676+ this.#rows.filter((r) => r.diffScenes.length > 0).map((r) => r.err),
677+ );
678+ }
679+
680+ /**
681+ * Color every diff panel from #measureDifference's pointwise diffFields, on
682+ * a *symmetric* range that is each row's own — not shared across the
683+ * column. A diverging row's diff explodes, but with a per-row range that
684+ * only saturates its own panels; it can no longer affect how any other
685+ * row's diff panels are scaled, which is a simpler fix for exactly the
686+ * flooding problem the value panels' shared #ranges/leastPeak logic exists
687+ * to manage there. The cost: diff-panel color alone no longer tells you
688+ * which row has more error than another — that comparison now lives in the
689+ * error chart, which has actual numbers. Always drawn with a diverging
690+ * colormap regardless of the user's chosen (sequential) one — a signed
691+ * quantity centered at zero needs a diverging map to read correctly, which
692+ * coolwarm is and viridis etc. are not.
693+ */
694+ #colorDiffPanels(): void {
695+ const species = this.#opts.model.species;
696+ for (const r of this.#rows) {
697+ if (r.diffScenes.length === 0) continue;
698+ for (let k = 0; k < species.length; k++) {
699+ const m = r.healthy ? maxAbsFinite(r.diffFields[k]) : null;
700+ const range = r.diffRanges[k];
701+ if (m !== null) {
702+ if (!Number.isFinite(range.lo)) {
703+ range.lo = -m;
704+ range.hi = m;
705+ } else {
706+ const a = 0.15;
707+ range.lo += a * (-m - range.lo);
708+ range.hi += a * (m - range.hi);
709+ }
710+ }
711+ if (!Number.isFinite(range.lo) || !Number.isFinite(range.hi)) continue;
712+ const shown = floorRange(range.lo, range.hi);
713+ fillFieldValues(r.diffValueBufs[k], r.diffFields[k], this.#topo);
714+ fillColors(r.diffColorBufs[k], r.diffValueBufs[k], shown.lo, shown.hi, colormaps.coolwarm);
715+ r.diffScenes[k]?.updateColors(r.diffColorBufs[k]);
716+ const cap = r.diffCaps[k];
717+ if (cap) cap.textContent = `± ${fmtValue(shown.hi)}`;
718+ }
719+ }
615720 }
616721
617722 /**
@@ -640,11 +745,13 @@ export class CompareRun {
640745 r.err[k] = NaN;
641746 continue;
642747 }
748+ const diff = r.diffFields[k];
643749 let num = 0;
644750 let den = 0;
645751 for (let i = 0; i < a.length; i++) {
646752 const w = this.#weights[i];
647753 const d = a[i] - b[i];
754+ diff[i] = d;
648755 num += w * d * d;
649756 den += w * b[i] * b[i];
650757 }
@@ -661,21 +768,19 @@ export class CompareRun {
661768 * usually the one that has converged and the fast one the one that has not.
662769 */
663770 #updateRowStats(): void {
664- const species = this.#opts.model.species;
665771 const ref = this.#fileRow ? null : this.#rows[this.#opts.reference];
666772 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>');
670773 // Divergence is said, not implied. Scaled to a healthy row, a blown-up
671774 // variant is a flat saturated panel, which on its own is easy to misread
672- // as a converged uniform state.
775+ // as a converged uniform state. Δ itself now lives in the error chart,
776+ // not here.
673777 const body = !r.healthy
674778 ? '<b class="cmp-diverged">diverged</b>'
675779 : r === ref
676780 ? '<b>reference</b>'
677- : `Δ ${per}`;
678- r.statEl.innerHTML = `${r.session.steps.toLocaleString()} steps<br>${body}`;
781+ : '';
782+ r.statEl.innerHTML =
783+ `${r.session.steps.toLocaleString()} steps` + (body ? `<br>${body}` : '');
679784 }
680785 }
681786
@@ -683,9 +788,7 @@ export class CompareRun {
683788 const refFile = this.#opts.refFile;
684789 const clock = refFile
685790 ? `<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`)
791+ ` · NOTE: vertical axis measures difference from uploaded simulation's end state.`
689792 : `<b>t = ${this.#t.toFixed(2)}</b> (same for every variant)`;
690793 this.#opts.onStatus(
691794 `${clock} · ` +
@@ -701,11 +804,13 @@ export class CompareRun {
701804 const box = s.canvas.parentElement;
702805 if (box) s.resize(box.clientWidth, box.clientHeight);
703806 }
807+ this.#errorChart?.redraw();
704808 });
705809 for (const s of scenes) {
706810 const box = s.canvas.parentElement;
707811 if (box) this.#resizeObs.observe(box);
708812 }
813+ this.#resizeObs.observe(this.#opts.chartContainer);
709814 }
710815
711816 // -------------------------------------------------------------- the clock
@@ -801,6 +906,22 @@ function leastPeak(all: (Bounds | null)[]): Bounds | null {
801906 return best;
802907 }
803908
909+/** Max |value| over the finite entries of a field; null if none are finite —
910+ * the diff panels' analogue of finiteRange below, since a symmetric range
911+ * only needs the one number. */
912+function maxAbsFinite(f: Float32Array): number | null {
913+ let m = -Infinity;
914+ let any = false;
915+ for (let i = 0; i < f.length; i++) {
916+ const v = f[i];
917+ if (!Number.isFinite(v)) continue;
918+ any = true;
919+ const a = Math.abs(v);
920+ if (a > m) m = a;
921+ }
922+ return any ? m : null;
923+}
924+
804925 /** Min and max over the finite entries only; null when there are none. */
805926 function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } | null {
806927 if (!f) return null;
@@ -826,6 +947,38 @@ function finiteRange(f: Float32Array | undefined): { lo: number; hi: number } |
826947 * a variant: it is the thing they are all measured against. */
827948 const FILE_ROW_COLOR = '#57606a';
828949
950+/**
951+ * One sphere panel: a boxed SphereScene plus the value/color buffers that
952+ * feed it. Shared by the value row, the diff row, and the file row — all
953+ * three build a panel the same way, only differing in the class on the box
954+ * (for styling) and in what fills the buffers afterward.
955+ */
956+function makeSpherePanel(
957+ colsEl: HTMLElement,
958+ topo: SphereMeshTopology,
959+ posBuf: Float32Array,
960+ background: string | undefined,
961+ extraClass = '',
962+): { box: HTMLElement; scene: SphereScene; valueBuf: Float32Array; colorBuf: Float32Array } {
963+ const box = document.createElement('div');
964+ box.className = extraClass ? `sphere-box cmp-box ${extraClass}` : 'sphere-box cmp-box';
965+ colsEl.append(box);
966+ const scene = new SphereScene(
967+ box,
968+ topo.numVertices,
969+ topo.indices,
970+ Float32Array.from(posBuf),
971+ background,
972+ );
973+ scene.fitCamera();
974+ return {
975+ box,
976+ scene,
977+ valueBuf: new Float32Array(topo.numVertices),
978+ colorBuf: new Float32Array(topo.numVertices * 3),
979+ };
980+}
981+
829982 async function buildGrid(
830983 opts: CompareOptions,
831984 sessions: ModelSession[],
@@ -889,6 +1042,10 @@ async function buildGrid(
8891042 const session = sessions[i];
8901043 const variant = opts.variants[i];
8911044 const color = VARIANT_COLORS[i % VARIANT_COLORS.length];
1045+ // The reference itself never gets a diff row — its diff against itself
1046+ // is trivially zero, and a flat zero panel would just spend a WebGL
1047+ // context for nothing.
1048+ const isRef = !opts.refFile && i === opts.reference;
8921049
8931050 const coords = await session.renderPositions();
8941051 const posBuf = new Float32Array(topo.numVertices * 3);
@@ -914,25 +1071,52 @@ async function buildGrid(
9141071 const valueBufs: Float32Array[] = [];
9151072 const colorBufs: Float32Array[] = [];
9161073 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();
1074+ const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
9281075 scenes.push(scene);
929- valueBufs.push(new Float32Array(topo.numVertices));
930- colorBufs.push(new Float32Array(topo.numVertices * 3));
1076+ valueBufs.push(valueBuf);
1077+ colorBufs.push(colorBuf);
9311078 }
9321079
1080+ // ---- its diff row, right underneath ----------------------------------
1081+ const diffFields = model.species.map(() => new Float32Array(topo.nlat * topo.nphi));
1082+ const diffScenes: SphereScene[] = [];
1083+ const diffValueBufs: Float32Array[] = [];
1084+ const diffColorBufs: Float32Array[] = [];
1085+ const diffCaps: HTMLElement[] = [];
1086+ if (!isRef) {
1087+ const diffRowEl = document.createElement('div');
1088+ diffRowEl.className = 'cmp-row cmp-diffrow';
1089+ const diffLabelEl = document.createElement('div');
1090+ diffLabelEl.className = 'cmp-rowlabel';
1091+ diffLabelEl.style.setProperty('--c', color);
1092+ const diffNameEl = document.createElement('div');
1093+ diffNameEl.className = 'cmp-rowname';
1094+ diffNameEl.textContent = 'Δ vs reference';
1095+ diffLabelEl.append(diffNameEl);
1096+ const diffColsEl = document.createElement('div');
1097+ diffColsEl.className = 'cmp-cols';
1098+ diffRowEl.append(diffLabelEl, diffColsEl);
1099+ container.append(diffRowEl);
1100+
1101+ for (let k = 0; k < model.species.length; k++) {
1102+ const { box, scene, valueBuf, colorBuf } = makeSpherePanel(
1103+ diffColsEl, topo, posBuf, sphereBg || undefined, 'cmp-diffbox',
1104+ );
1105+ diffScenes.push(scene);
1106+ diffValueBufs.push(valueBuf);
1107+ diffColorBufs.push(colorBuf);
1108+ const cap = document.createElement('span');
1109+ cap.className = 'cmp-diffcap';
1110+ box.append(cap);
1111+ diffCaps.push(cap);
1112+ }
1113+ }
1114+
1115+ const diffRanges = model.species.map(() => ({ lo: NaN, hi: NaN }));
9331116 rows.push({
9341117 variant, session, color, coords, posBuf, scenes, valueBufs, colorBufs,
935- fields: [], err: model.species.map(() => 0), healthy: true, statEl,
1118+ fields: [], diffFields, diffScenes, diffValueBufs, diffColorBufs, diffCaps, diffRanges,
1119+ err: model.species.map(() => 0), healthy: true, statEl,
9361120 });
9371121 }
9381122
@@ -987,32 +1171,24 @@ async function buildGrid(
9871171 const fields: Float32Array[] = [];
9881172 const bounds: (Bounds | null)[] = [];
9891173 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();
1174+ const { scene, valueBuf, colorBuf } = makeSpherePanel(colsEl, topo, posBuf, sphereBg || undefined);
10011175 scenes.push(scene);
10021176 const field = await on(rf.final[model.state[k]]);
10031177 fields.push(field);
10041178 bounds.push(finiteRange(field));
1005- const valueBuf = new Float32Array(topo.numVertices);
10061179 fillFieldValues(valueBuf, field, topo);
10071180 valueBufs.push(valueBuf);
1008- colorBufs.push(new Float32Array(topo.numVertices * 3));
1181+ colorBufs.push(colorBuf);
10091182 }
10101183 fileRow = { coords, posBuf, scenes, valueBufs, colorBufs, fields, bounds };
10111184 }
10121185
10131186 // Every panel shares one camera: the study is about the fields, and looking
10141187 // at two of them from different angles is not comparing them.
1015- const all = [...rows.flatMap((r) => r.scenes), ...(fileRow?.scenes ?? [])];
1188+ const all = [
1189+ ...rows.flatMap((r) => [...r.scenes, ...r.diffScenes]),
1190+ ...(fileRow?.scenes ?? []),
1191+ ];
10161192 for (let i = 1; i < all.length; i++) all[0].syncCamerasWith(all[i]);
10171193
10181194 return { rows, fileRow, rangeBars };
src/main.tsmodified+13−3View file
@@ -82,6 +82,7 @@ const elParams = $('params');
8282 const elGeomParams = $('geomparams');
8383 const elGeomNote = $('geomnote');
8484 const elPanels = $('panels');
85+const elCmpChart = $('cmp-chart');
8586 const elStats = $('stats');
8687 const elBenchResult = $('benchresult');
8788 const elCmd = $('cmd');
@@ -1292,7 +1293,11 @@ function refreshVariants(): void {
12921293 // is one more row of panels.
12931294 const cmpModel = refCase?.model ?? model;
12941295 const rowCount = variants.length + (refCase ? 1 : 0);
1295- const panels = rowCount * cmpModel.species.length;
1296+ // Every row but the reference variant (or, against a file, every variant)
1297+ // gets a second diff row underneath it — each one more WebGL context per
1298+ // species, so MAX_PANELS has to bound the real total, not just the values.
1299+ const diffRowCount = refCase ? variants.length : Math.max(0, variants.length - 1);
1300+ const panels = (rowCount + diffRowCount) * cmpModel.species.length;
12961301
12971302 const prev = cmpRefKey;
12981303 elCmpRef.replaceChildren();
@@ -1325,7 +1330,9 @@ function refreshVariants(): void {
13251330 ? `too many: ${tooMany}`
13261331 : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
13271332 `${refCase ? ' + the file' : ''} × ` +
1328- `${cmpModel.species.length} species = ${panels} panels`;
1333+ `${cmpModel.species.length} species` +
1334+ (diffRowCount > 0 ? ` + ${diffRowCount} diff row${diffRowCount === 1 ? '' : 's'}` : '') +
1335+ ` = ${panels} panels`;
13291336 elCmpCount.style.color = tooMany ? '#b35900' : '';
13301337 elCmpStart.disabled = tooMany !== '' && compareRun === null;
13311338 }
@@ -1581,7 +1588,9 @@ async function startCompare(): Promise<void> {
15811588 const cmpModel = rc?.model ?? model;
15821589 const variants = cmpVariants();
15831590 const rowCount = variants.length + (rc ? 1 : 0);
1584- if (variants.length > MAX_VARIANTS || rowCount * cmpModel.species.length > MAX_PANELS) {
1591+ const diffRowCount = rc ? variants.length : Math.max(0, variants.length - 1);
1592+ const panels = (rowCount + diffRowCount) * cmpModel.species.length;
1593+ if (variants.length > MAX_VARIANTS || panels > MAX_PANELS) {
15851594 return;
15861595 }
15871596 // Take down the single run first: its pump, its scenes, its session. The
@@ -1617,6 +1626,7 @@ async function startCompare(): Promise<void> {
16171626 morph,
16181627 colormapName: () => elColormap.value,
16191628 container: elPanels,
1629+ chartContainer: elCmpChart,
16201630 onStatus: (html) => (elStats.innerHTML = html),
16211631 });
16221632 } 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+}