/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
328 lines · 11.1 KBBlameHistoryRaw
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 */
17import { fmtValue } from './colorbar.ts';
19export interface ErrorChartRow {
20 label: string;
21 color: string;
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. */
26const MIN_LOG_VALUE = 1e-12;
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. */
32const DASH_PATTERNS: number[][] = [[], [7, 4], [2, 3]];
34const MARGIN_LEFT = 44;
35const MARGIN_BOTTOM = 38;
36const MARGIN_TOP = 8;
37const MARGIN_RIGHT = 10;
39export class ErrorChart {
40 #container: HTMLElement;
41 #species: string[];
42 #rows: ErrorChartRow[];
43 #canvas: HTMLCanvasElement | null = null;
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;
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 }
61 #resetSeries(): void {
62 this.#ts = [];
63 this.#errs = this.#species.map(() => this.#rows.map(() => []));
64 this.#minLog = Infinity;
65 this.#maxLog = -Infinity;
66 }
68 #build(): void {
69 this.#container.replaceChildren();
70 this.#canvas = null;
71 if (this.#rows.length === 0 || this.#species.length === 0) return;
73 const legend = document.createElement('div');
74 legend.className = 'cmp-chart-legend';
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 }
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 });
98 legend.append(variantGroup, speciesGroup);
99 this.#container.append(legend);
101 const canvas = document.createElement('canvas');
102 canvas.className = 'cmp-chart-canvas';
103 this.#container.append(canvas);
104 this.#canvas = canvas;
105 }
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 }
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 }
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 }
141 dispose(): void {
142 this.#container.replaceChildren();
143 this.#canvas = null;
144 }
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);
164 const n = this.#ts.length;
165 if (n < 2) return;
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;
180 const style = getComputedStyle(document.documentElement);
181 const inkColor = style.getPropertyValue('--ink-2').trim() || '#666';
182 const gridColor = style.getPropertyValue('--line').trim() || '#ccc';
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);
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 };
198 ctx.font = '11px sans-serif';
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 }
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 }
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();
245 // ---- axis titles ---------------------------------------------------------
246 ctx.fillStyle = inkColor;
247 ctx.textAlign = 'center';
248 ctx.textBaseline = 'bottom';
249 ctx.fillText('t', plotX + plotW / 2, cssH - 2);
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();
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 }
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 */
298function 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 ];
323/** `count` evenly spaced ticks between t0 and t1 inclusive; just t0 if the
324 * span is degenerate (a single point pushed so far). */
325function 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));
moveopenescclose