forked from github.com/concept-collection/turing-surface
1import { requestShtDevice, describeAdapter } from './sht/sht.ts';
2import { gridForLmax } from './sht/layout.ts';
3import { ModelSession } from './mgpu/session.ts';
4import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
5import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
6import { EXTERNAL_OPS } from './mgpu/externals.ts';
7import { CodeEditor } from './editor/codeEditor.ts';
8import {
9 formatCommand,
10 resolvePreset,
11 DEFAULT_NITER,
12 DEFAULT_STEPS,
13 DEFAULT_WARMUP,
14 type RunSpec,
15} from './bench/runSpec.ts';
16import {
17 mGeometries,
18 mGeometryByKey,
19 defaultGeometryParams,
20 DEFAULT_GEOMETRY_KEY,
21 type MGeometry,
22} from './geom/registry.ts';
23import {
24 buildTopology,
25 fillFieldValues,
26 fillPositions,
27 fillColors,
28 type SphereMeshTopology,
29} from './render/sphereMesh.ts';
30import { SphereScene } from './render/SphereScene.ts';
31import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
32import { colormaps, colormapNames } from './render/colormaps.ts';
33import { MovieRecorder } from './render/movie.ts';
34import { CompareRun } from './compare/compareRun.ts';
35import {
36 crossProduct,
37 mostResolved,
38 variantKey,
39 variantLabel,
40 type Variant,
41} from './compare/variants.ts';
42import { loadReferenceFile } from './compare/referenceFile.ts';
43import type { ReferenceCase } from './compare/referenceCase.ts';
45const $ = <T extends HTMLElement>(id: string): T =>
46 document.getElementById(id) as T;
48const elModel = $<HTMLSelectElement>('model');
49const elGeometry = $<HTMLSelectElement>('geometry');
50const elMorph = $<HTMLInputElement>('morph');
51const elNiter = $<HTMLSelectElement>('niter');
52const elLmax = $<HTMLSelectElement>('lmax');
53const elOversample = $<HTMLSelectElement>('oversample');
54const elColormap = $<HTMLSelectElement>('colormap');
55const elRunPause = $<HTMLButtonElement>('runpause');
56const elRestart = $<HTMLButtonElement>('restart');
57const elBenchmark = $<HTMLButtonElement>('benchmark');
58const elReseed = $<HTMLButtonElement>('reseed');
59const elLam3 = $<HTMLInputElement>('lam3');
60const elResetView = $<HTMLButtonElement>('resetview');
61const elMovieToggle = $<HTMLButtonElement>('movietoggle');
62const elMovieBar = $('moviebar');
63const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
64const elMovieRes = $<HTMLSelectElement>('movieres');
65const elMovieRotate = $<HTMLInputElement>('movierotate');
66const elMovie = $<HTMLButtonElement>('movie');
67const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
68const elModeEffort = $<HTMLButtonElement>('mode-effort');
69const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
70const elModeDesc = $('mode-desc');
71const elCompareBar = $('comparebar');
72const elCmpNiter = $('cmp-niter');
73const elCmpLmax = $('cmp-lmax');
74const elCmpDt = $('cmp-dt');
75const elCmpRef = $<HTMLSelectElement>('cmp-ref');
76const elCmpFile = $<HTMLInputElement>('cmp-file');
77const elCmpFileInfo = $('cmp-fileinfo');
78const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
79const elCmpStart = $<HTMLButtonElement>('cmp-start');
80const elCmpCount = $('cmp-count');
81const elParams = $('params');
82const elGeomParams = $('geomparams');
83const elGeomNote = $('geomnote');
84const elPanels = $('panels');
85const elCmpChart = $('cmp-chart');
86const elStats = $('stats');
87const elBenchResult = $('benchresult');
88const elCmd = $('cmd');
89const elCopyCmd = $<HTMLButtonElement>('copycmd');
90const elBlurb = $('blurb');
91const elErr = $('err');
92const elSource = $<HTMLTextAreaElement>('source');
93const elHighlight = $('highlight');
94const elCompiled = $('compiled');
95const elEditorTitle = $('editor-title');
96const elEditorFile = $<HTMLSelectElement>('editor-file');
97const elRecompile = $<HTMLButtonElement>('recompile');
98const elRevert = $<HTMLButtonElement>('revert');
100/** The named groups the control area is organized into (index.html's
101 * `.ctrl-group[data-group]` wrappers). Each mode shows a declared subset of
102 * these — see MODE_GROUPS and applyModeVisibility below. */
103const GROUP_NAMES = [
104 'surface', 'surface-params', 'solver', 'display',
105 'playback', 'benchmark', 'seed', 'movie',
106] as const;
107type GroupName = (typeof GROUP_NAMES)[number];
108const groupEls: Record<GroupName, HTMLElement> = Object.fromEntries(
109 GROUP_NAMES.map((name) => [
110 name,
111 document.querySelector(`.ctrl-group[data-group="${name}"]`) as HTMLElement,
112 ]),
113) as Record<GroupName, HTMLElement>;
115for (const p of presets) {
116 const o = document.createElement('option');
117 o.value = p.key;
118 o.textContent = p.label;
119 elModel.append(o);
120}
121for (const g of mGeometries) {
122 const o = document.createElement('option');
123 o.value = g.key;
124 o.textContent = g.label;
125 elGeometry.append(o);
126}
127for (const [value, label] of [['model', 'the solver'], ['geometry', 'the surface']]) {
128 const o = document.createElement('option');
129 o.value = value;
130 o.textContent = label;
131 elEditorFile.append(o);
132}
133for (const name of colormapNames) {
134 const o = document.createElement('option');
135 o.value = name;
136 o.textContent = name;
137 elColormap.append(o);
138}
139elColormap.value = 'jet';
141/** Whichever .m is open: the solver or the surface. Both are MATLAB, compiled
142 * by the same backend, so one editor serves both. The host-provided operations
143 * are marked so the boundary between the file and what it is given is
144 * visible. */
145const editor = new CodeEditor({
146 textarea: elSource,
147 overlay: elHighlight,
148 external: EXTERNAL_OPS,
149 onInput: (value) => {
150 if (editing === 'geometry') editedGeomSource = value;
151 else editedSource = value;
152 elRecompile.textContent = 'Recompile *';
153 },
154});
156/**
157 * Timesteps submitted per rendered frame, at most. Nothing is read back
158 * between them, so the batch costs one submit and one readback regardless of
159 * size — but a compute pass is still real GPU work, and a browser's GPU
160 * process enforces a watchdog timeout a headless desktop run does not: a
161 * submission with enough dispatches in it can trip "device lost" outright,
162 * on weak-enough hardware, well before it would ever show up as merely slow.
163 * The `for k = 1:niter` correction loop makes a step's dispatch count scale
164 * with niter (each iteration is ~15 dispatches per species — see
165 * models/schnakenberg.m), so a fixed per-frame step count that was safe when
166 * every model's step was a handful of dispatches is not safe once niter is
167 * large. `stepsPerFrame`/`measureBurst` below scale it down — never up, so
168 * the common case does not change — to keep one submission's total dispatch
169 * count under DISPATCH_BUDGET regardless of how expensive the compiled step
170 * is.
171 */
172const STEPS_PER_FRAME_BASE = 4;
173/** See STEPS_PER_FRAME_BASE. Recomputed per rebuild in `rebuild()`. */
174let stepsPerFrame = STEPS_PER_FRAME_BASE;
176/**
177 * Steps in a solver-timing burst, and how often to run one.
178 *
179 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
180 * finished, and in a browser that is an IPC round trip into the GPU process — a
181 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
182 * swamp them on a fast GPU and make the solver look far slower than it is. So the
183 * rate is measured in an occasional larger batch, where the single sync is
184 * amortized the way the desktop benchmark amortizes its own. The state is
185 * snapshotted and restored around the batch, so measuring never advances the
186 * simulation — otherwise the pattern would visibly lurch forward at every
187 * measurement.
188 */
189const MEASURE_BURST_BASE = 32;
190/** See STEPS_PER_FRAME_BASE — the measurement burst is one submission too,
191 * and a bigger one: 32 steps is the single largest batch this app ever
192 * submits, so it is the first thing to cross DISPATCH_BUDGET as niter grows. */
193let measureBurst = MEASURE_BURST_BASE;
194const MEASURE_EVERY_MS = 2000;
196/**
197 * Upper bound on dispatches in one submission — the frame batch and the
198 * measurement burst are both scaled down to stay under this, never up, so
199 * a cheap model's pacing is unchanged. Chosen well under what this project's
200 * own desktop benchmark measures as trivially fast (single-digit ms even at
201 * niter=8's ~450 dispatches/step), because the risk here is not GPU time on
202 * capable hardware — it is a browser's GPU-process watchdog on weak
203 * (integrated-graphics) hardware, which a headless desktop run never
204 * exercises and this project has no way to benchmark directly.
205 */
206const DISPATCH_BUDGET = 1000;
208/**
209 * 'auto' display oversampling targets this many render latitudes: the factor is
210 * the smallest power of two (up to 4) that reaches it. A solver grid already
211 * this fine gains nothing visually and is not oversampled.
212 */
213const AUTO_RENDER_NLAT = 256;
215/** The display oversampling factor the UI currently asks for. */
216function resolveOversample(): number {
217 if (elOversample.value !== 'auto') return Number(elOversample.value);
218 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
219 let os = 1;
220 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
221 return os;
222}
224/**
225 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
226 * the UI, in simulation-time units per second of video; the movie's length is
227 * the run's t at that speed, and the frame count follows from it — capped by
228 * the run's own step count (a step is at most one frame) and by
229 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
230 * derived from simulation time, so a capped movie keeps its duration and
231 * speed exactly, at a lower effective frame rate.
232 */
233const MOVIE_FPS = 30;
234const MOVIE_MAX_FRAMES = 3600;
236/** Movie auto-rotation: camera revolutions per second of video. Measured in
237 * video time, so the orbit pace on screen is the same at every export speed. */
238const MOVIE_ROTATE_RPS = 1 / 120;
240// ---------------------------------------------------------------- state
241let device: GPUDevice | null = null;
242let session: ModelSession | null = null;
243let topo: SphereMeshTopology | null = null;
244let scenes: SphereScene[] = [];
245let colorbars: Colorbar[] = [];
246let valueBufs: Float32Array[] = [];
247let colorBufs: Float32Array[] = [];
248let ranges: { lo: number; hi: number }[] = [];
249let resizeObs: ResizeObserver | null = null;
251const initial = resolvePreset(presets[0].key);
252let model: MModel = mModelByKey(initial.model.key)!;
253let params: Params = initial.params;
254let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
255let geomParams: Params = defaultGeometryParams(geometry);
256/** Which file the editor is showing. */
257let editing: 'model' | 'geometry' = 'model';
258/** Each .m as edited in the page; `null` while it matches the file. */
259let editedSource: string | null = null;
260let editedGeomSource: string | null = null;
261/** Sphere (0) to surface (1). Display only; does not touch the solver. */
262let morph = 1;
263let seed = 1;
264let running = false;
265let adapterName = '';
266let pumping = false;
267let movieBusy = false;
268let movieCancel = false;
269let solverMs = 0;
270let frameMs = 0;
271let lastMeasure = 0;
272let generation = 0; // bumped on every rebuild to cancel stale pumps
273/** Surface coordinates on the render grid, interleaved xyz; null before the
274 * first build. Kept so the morph slider can re-fill positions without
275 * re-synthesizing. */
276let coords: Float32Array | null = null;
277let posBuf: Float32Array | null = null;
278/** The convergence study, when one is running; null in ordinary single-run
279 * mode. While it is non-null there is no `session`: the study owns one per
280 * variant, and the panels area is its grid. */
281let compareRun: CompareRun | null = null;
282/** `session`'s spectral state as of the last (re-)seed — what "Restart"
283 * rewinds to. Captured fresh each time a new field is actually established
284 * (rebuild/reseed), not just once, so Restart reflects the run's current
285 * starting point rather than permanently the very first draw. */
286let initialState: Record<string, Float32Array> | null = null;
288const source = (): string => editedSource ?? model.source;
289const geomSource = (): string => editedGeomSource ?? geometry.source;
291// ---------------------------------------------------------------- UI wiring
292function buildParamInputs(): void {
293 elParams.replaceChildren();
294 if (model.params.length === 0) return;
295 const tag = document.createElement('label');
296 tag.textContent = 'model parameters';
297 elParams.append(tag);
298 for (const spec of model.params) {
299 const label = document.createElement('label');
300 label.textContent = `${spec.label} `;
301 const input = document.createElement('input');
302 input.type = 'number';
303 input.min = String(spec.min);
304 input.max = String(spec.max);
305 input.step = String(spec.step);
306 input.value = String(params[spec.key]);
307 input.addEventListener('change', () => {
308 const v = Number(input.value);
309 if (Number.isFinite(v)) params[spec.key] = v;
310 // Parameters are uniforms, not constants baked into the kernels, so a
311 // change costs an upload rather than a recompile. In compare mode `dt`
312 // is the *base* timestep each variant's divisor divides, so the study
313 // re-derives every variant's dt from it.
314 session?.setParams(params);
315 compareRun?.setParams(params);
316 updateCommand();
317 });
318 label.append(input);
319 elParams.append(label);
320 }
321}
323/**
324 * The shape's own parameters. Unlike the model's, these are NOT uniforms: the
325 * surface is evaluated once at build time and reduced to coefficients, so
326 * moving one rebuilds the geometry (and with it the mesh), though not the
327 * simulation's compiled step.
328 */
329function buildGeomParamInputs(): void {
330 elGeomParams.replaceChildren();
331 if (geometry.params.length === 0) return;
332 const tag = document.createElement('label');
333 tag.textContent = 'geometry parameters';
334 elGeomParams.append(tag);
335 for (const spec of geometry.params) {
336 // A random seed picks a draw and means nothing on its own, so it gets a
337 // button to the next one rather than a box to type a number into. The
338 // shape changes; the simulation running on it does not restart.
339 if (spec.reseed) {
340 const button = document.createElement('button');
341 button.textContent = 'Re-seed shape';
342 button.title =
343 `Draw another ${geometry.label.toLowerCase()} — a new random surface, ` +
344 `leaving the pattern running on it alone.`;
345 button.addEventListener('click', () => {
346 const span = spec.max - spec.min;
347 let next = geomParams[spec.key];
348 // Never hand back the shape that is already on screen.
349 while (next === geomParams[spec.key]) {
350 next = spec.min + Math.floor(Math.random() * (span + 1));
351 }
352 geomParams[spec.key] = next;
353 viewChange = viewChange.then(() => applyGeometry());
354 });
355 elGeomParams.append(button);
356 continue;
357 }
358 const label = document.createElement('label');
359 label.textContent = `${spec.label} `;
360 const input = document.createElement('input');
361 input.type = 'number';
362 input.min = String(spec.min);
363 input.max = String(spec.max);
364 input.step = String(spec.step);
365 input.value = String(geomParams[spec.key]);
366 input.addEventListener('change', () => {
367 const v = Number(input.value);
368 if (Number.isFinite(v)) geomParams[spec.key] = v;
369 viewChange = viewChange.then(() => applyGeometry());
370 });
371 label.append(input);
372 elGeomParams.append(label);
373 }
374}
376function applyPreset(presetKey: string): void {
377 const resolved = resolvePreset(presetKey);
378 const next = mModelByKey(resolved.model.key);
379 if (!next) {
380 elErr.textContent = `No .m model for '${resolved.model.key}'`;
381 return;
382 }
383 model = next;
384 params = resolved.params;
385 editedSource = null;
386 buildParamInputs();
387 elBlurb.textContent = model.blurb;
388 showEditorFile();
389 updateCommand();
390}
392function applyGeometryChoice(key: string): void {
393 const next = mGeometryByKey(key);
394 if (!next) {
395 elErr.textContent = `No .m geometry for '${key}'`;
396 return;
397 }
398 geometry = next;
399 geomParams = defaultGeometryParams(geometry);
400 editedGeomSource = null;
401 buildGeomParamInputs();
402 showEditorFile();
403}
405/** Load the chosen file into the editor, keeping any unsaved edit to it. */
406function showEditorFile(): void {
407 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
408 if (editing === 'geometry') {
409 editor.value = geomSource();
410 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
411 } else {
412 editor.value = source();
413 elEditorTitle.textContent = `models/${model.key}.m`;
414 }
415}
417/**
418 * The run currently on screen, as the benchmark's RunSpec. While a study is
419 * running there is no single run, so this describes its *reference* variant —
420 * the one the other rows are measured against, and the only one of them whose
421 * numbers mean anything on their own.
422 */
423function currentSpec(): RunSpec {
424 const ref = compareRun?.variants[compareRefIndex()];
425 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
426 return {
427 preset: elModel.value,
428 lmax: ref ? ref.lmax : Number(elLmax.value),
429 seed,
430 steps: DEFAULT_STEPS,
431 warmup: DEFAULT_WARMUP,
432 params: dt ? { ...params, ...dt } : params,
433 geometry: geometry.key,
434 geometryParams: geomParams,
435 niter: ref ? ref.niter : Number(elNiter.value),
436 };
437}
439function updateCommand(): void {
440 // A study against a reference file replays the file, so its desktop
441 // equivalent is the ref checker, not the benchmark.
442 if (compareRun?.refFile) {
443 elCmd.textContent = `npm run ref -- --in ${compareRun.refFile.label}`;
444 return;
445 }
446 elCmd.textContent = formatCommand(currentSpec());
447}
449elModel.addEventListener('change', () => {
450 applyPreset(elModel.value);
451 void rebuild();
452});
453elLmax.addEventListener('change', () => void rebuild());
454// The solve iteration count is unrolled into the compiled step, so unlike a
455// parameter it cannot be changed without recompiling.
456elNiter.addEventListener('change', () => void rebuild());
457// Oversampling and geometry are display-or-data changes, not code ones, so
458// they swap things in place rather than rebuilding the run. Serialized through
459// one chain: a rapid second change waits its turn.
460let viewChange = Promise.resolve();
461elOversample.addEventListener('change', () => {
462 // The study picks its own display grid — one grid common to every variant is
463 // what makes their fields comparable — so this control is inert (and
464 // disabled) while one is running.
465 if (compareRun) return;
466 viewChange = viewChange.then(() => applyOversample());
467});
468elGeometry.addEventListener('change', () => {
469 applyGeometryChoice(elGeometry.value);
470 viewChange = viewChange.then(() => applyGeometry());
471});
472// The seed field's wavelength: a uniform plus a host-side redraw, so it
473// reseeds the run in place rather than recompiling it. Too small a value asks
474// for more Fourier modes than the table holds, which `drawModes` refuses —
475// report that like any other failure instead of leaving the run half-seeded.
476elLam3.addEventListener('change', () => {
477 const v = Number(elLam3.value);
478 if (!Number.isFinite(v) || v <= 0) return;
479 // Changing the wavelength redraws the field, which restarts the run — so
480 // pause first, exactly as the Re-seed button does. Without it the reseed's
481 // readback races the pump's own, and the two collide on the staging buffer.
482 setRunning(false);
483 viewChange = viewChange.then(async () => {
484 // A study seeds every variant from one field at one wavelength, so this is
485 // the same control there — set on each variant, redrawn by the one reseed.
486 const target = compareRun ?? session;
487 if (!target) return;
488 const previous = target.lam3;
489 try {
490 target.setLam3(v);
491 await reseed();
492 elErr.textContent = '';
493 } catch (e) {
494 // Too fine a wavelength asks for more Fourier modes than the table
495 // holds. Put the working value back rather than leaving the run seeded
496 // from a field that was never drawn.
497 elErr.textContent = e instanceof Error ? e.message : String(e);
498 target.setLam3(previous);
499 elLam3.value = String(previous);
500 await reseed();
501 }
502 });
503});
504// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
505elMorph.addEventListener('input', () => {
506 morph = Number(elMorph.value);
507 if (compareRun) compareRun.setMorph(morph);
508 else applyMorph();
509});
510elColormap.addEventListener('change', () => {
511 if (compareRun) void compareRun.draw();
512 else void draw();
513});
514elEditorFile.addEventListener('change', () => showEditorFile());
516function setRunning(next: boolean): void {
517 running = next;
518 elRunPause.textContent = running ? 'Pause' : 'Run';
519 if (compareRun) {
520 compareRun.setRunning(next);
521 return;
522 }
523 if (running) void pump();
524}
526elRunPause.addEventListener('click', () => setRunning(!running));
527elBenchmark.addEventListener('click', () => void benchmark());
528elReseed.addEventListener('click', () => {
529 seed = (Math.random() * 2 ** 31) >>> 0;
530 setRunning(false);
531 updateCommand();
532 void reseed();
533});
534elRestart.addEventListener('click', () => {
535 setRunning(false);
536 void restart();
537});
538elResetView.addEventListener('click', () => {
539 compareRun?.resetView();
540 for (const s of scenes) s.resetCamera();
541});
542elMovieToggle.addEventListener('click', () => {
543 elMovieBar.hidden = !elMovieBar.hidden;
544});
545elMovie.addEventListener('click', () => {
546 if (movieBusy) movieCancel = true;
547 else void recordMovie();
548});
550elRecompile.addEventListener('click', () => {
551 if (editing === 'geometry') editedGeomSource = editor.value;
552 else editedSource = editor.value;
553 void rebuild();
554});
555elRevert.addEventListener('click', () => {
556 if (editing === 'geometry') editedGeomSource = null;
557 else editedSource = null;
558 showEditorFile();
559 void rebuild();
560});
562// The command reproduces this run's parameters on the desktop; keep it
563// selectable even where the clipboard API is unavailable.
564elCopyCmd.addEventListener('click', () => {
565 const text = elCmd.textContent ?? '';
566 const flash = (msg: string): void => {
567 elCopyCmd.textContent = msg;
568 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
569 };
570 const selectCommand = (): void => {
571 const range = document.createRange();
572 range.selectNodeContents(elCmd);
573 const sel = getSelection();
574 sel?.removeAllRanges();
575 sel?.addRange(range);
576 flash('Selected');
577 };
578 if (!navigator.clipboard) return selectCommand();
579 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
580});
582// ---------------------------------------------------------------- setup
583function disposeView(): void {
584 for (const s of scenes) s.dispose();
585 scenes = [];
586 colorbars = [];
587 topo = null;
588 coords = null;
589 posBuf = null;
590 resizeObs?.disconnect();
591 resizeObs = null;
592 elPanels.replaceChildren();
593}
595/**
596 * Build the mesh, scenes, colorbars and per-species buffers on the current
597 * render grid, from surface coordinates already synthesized there. Call
598 * disposeView() first. The color ranges are kept if present, so a display-only
599 * rebuild (an oversampling change) does not pop the shading; a full rebuild
600 * clears `ranges` beforehand.
601 */
602function buildView(surface: Float32Array): void {
603 if (!session) return;
604 const view = session.viewSht;
605 const { nphi } = view.cfg;
606 const phi = new Float64Array(nphi);
607 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
608 topo = buildTopology(view.cosTheta, phi);
609 coords = surface;
610 posBuf = new Float32Array(topo.numVertices * 3);
611 fillPositions(posBuf, coords, topo, morph);
613 const sphereBg = getComputedStyle(document.documentElement)
614 .getPropertyValue('--sphere-bg')
615 .trim();
616 for (let k = 0; k < model.species.length; k++) {
617 const panel = document.createElement('div');
618 panel.className = 'panel';
619 const box = document.createElement('div');
620 box.className = 'sphere-box';
621 const tag = document.createElement('div');
622 tag.className = 'species-tag';
623 tag.textContent = model.species[k];
624 box.append(tag);
625 const side = document.createElement('div');
626 panel.append(box, side);
627 elPanels.append(panel);
629 const scene = new SphereScene(
630 box,
631 topo.numVertices,
632 topo.indices,
633 // Each scene owns its position buffer: three.js uploads from it, and the
634 // morph rewrites all of them from the one shared `coords`.
635 Float32Array.from(posBuf),
636 sphereBg || undefined,
637 );
638 scene.fitCamera();
639 scenes.push(scene);
640 colorbars.push(new Colorbar(side));
641 valueBufs[k] = new Float32Array(topo.numVertices);
642 colorBufs[k] = new Float32Array(topo.numVertices * 3);
643 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
644 }
645 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
647 resizeObs = new ResizeObserver(() => {
648 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
649 boxes.forEach((box, i) => {
650 scenes[i]?.resize(box.clientWidth, box.clientHeight);
651 });
652 });
653 elPanels
654 .querySelectorAll<HTMLElement>('.sphere-box')
655 .forEach((box) => resizeObs!.observe(box));
656}
658/**
659 * Apply the UI's oversampling choice to the running session. Display-only: the
660 * session and its state survive; only the display plan, mesh and scenes are
661 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
662 * so no readback is in flight on the plan being replaced.
663 */
664async function applyOversample(): Promise<void> {
665 if (!session) return;
666 const gen = generation;
667 const os = resolveOversample();
668 if (os === session.oversample) return;
669 const wasRunning = running;
670 setRunning(false);
671 while (pumping) await nextFrame();
672 if (gen !== generation || !session) return;
673 await session.setOversample(os);
674 if (gen !== generation || !session) return;
675 const surface = await session.renderPositions();
676 if (gen !== generation || !session) return;
677 const cam = scenes[0]?.cameraState();
678 disposeView();
679 buildView(surface);
680 if (cam) for (const s of scenes) s.setCameraState(cam);
681 await draw();
682 updateStats();
683 if (wasRunning) setRunning(true);
684}
686/**
687 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
688 * untouched and the simulation keeps its state and its model time, so a shape
689 * can be changed mid-run. Only the mesh is rebuilt.
690 */
691async function applyGeometry(): Promise<void> {
692 // The in-place swap below is a single session's trick. Each variant carries
693 // the surface band-limited at its own lmax, and the study's meshes are built
694 // from those, so a shape change goes through the full rebuild instead.
695 if (compareRun) return rebuildCompare();
696 if (!session) return;
697 const gen = generation;
698 const wasRunning = running;
699 setRunning(false);
700 while (pumping) await nextFrame();
701 if (gen !== generation || !session) return;
702 try {
703 await session.setGeometry(geometry, geomParams, geomSource());
704 } catch (e) {
705 reportCompileError(e);
706 return;
707 }
708 if (gen !== generation || !session) return;
709 const surface = await session.renderPositions();
710 if (gen !== generation || !session) return;
711 const cam = scenes[0]?.cameraState();
712 disposeView();
713 buildView(surface);
714 if (cam) for (const s of scenes) s.setCameraState(cam);
715 elErr.textContent = '';
716 await draw();
717 updateGeomNote();
718 updateStats();
719 if (wasRunning) setRunning(true);
720}
722/** Re-place the vertices for the current morph. No GPU work and no readback —
723 * the surface is already on the CPU, so this is a buffer fill per panel. */
724function applyMorph(): void {
725 if (!topo || !coords || !posBuf) return;
726 fillPositions(posBuf, coords, topo, morph);
727 for (const s of scenes) s.updatePositions(posBuf);
728}
730/** What the surface is, and how far it departs from the sphere. */
731function updateGeomNote(): void {
732 // In compare mode each variant carries the surface band-limited at its own
733 // lmax; the reference's is the one quoted, as everywhere else.
734 const s = session ?? compareRun?.referenceSession ?? null;
735 if (!s) {
736 elGeomNote.textContent = '';
737 return;
738 }
739 const { lo, hi } = s.geometry.radiusRange();
740 elGeomNote.innerHTML =
741 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
742 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
743}
745/** Report a compile failure, and select the offending text in the editor. */
746function reportCompileError(e: unknown): void {
747 elErr.textContent = formatFailure(e, source());
748 elCompiled.textContent = '';
749 if (e instanceof ModelCompileError && e.start !== undefined) {
750 editor.select(e.start, e.end ?? e.start);
751 }
752}
754async function rebuild(): Promise<void> {
755 // A study is several runs, so "rebuild the run" means rebuild all of them.
756 // Everything that recompiles — a model or preset change, an edit to either
757 // .m, a revert — arrives here, and none of it needs to know which mode is up.
758 if (compareRun) return rebuildCompare();
759 generation++;
760 const gen = generation;
761 setRunning(false);
762 disposeView();
763 session?.destroy();
764 session = null;
765 solverMs = 0;
766 frameMs = 0;
767 // Not 0: with a large niter's dispatch count not yet known (that needs the
768 // compiled plan below), the first measurement burst should wait for the
769 // ordinary per-frame batch — already sized to this model — to prove itself
770 // first, rather than firing a possibly-oversized burst before a single
771 // frame has run.
772 lastMeasure = performance.now();
773 elErr.textContent = '';
774 updateCommand();
775 if (!device) return;
777 try {
778 session = await ModelSession.create({
779 device,
780 model,
781 params,
782 lmax: Number(elLmax.value),
783 source: source(),
784 oversample: resolveOversample(),
785 geometry,
786 geometryParams: geomParams,
787 geometrySource: geomSource(),
788 niter: Number(elNiter.value),
789 lam3: Number(elLam3.value),
790 });
791 } catch (e) {
792 reportCompileError(e);
793 return;
794 }
795 if (gen !== generation) return;
797 await session.seed(seed);
798 if (gen !== generation) return;
799 initialState = await session.readState();
800 if (gen !== generation) return;
802 const plan = session.describe();
803 elCompiled.textContent =
804 `one step compiled to ${plan.step.length} GPU operations:\n` +
805 plan.step.map((l) => ` ${l}`).join('\n');
806 elRecompile.textContent = 'Recompile';
808 // Scale the frame batch and the measurement burst down — never up — so
809 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
810 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
811 const opsPerStep = Math.max(1, plan.step.length);
812 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
813 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
815 const surface = await session.renderPositions();
816 if (gen !== generation) return;
818 ranges = [];
819 buildView(surface);
821 await draw();
822 updateGeomNote();
823 updateStats();
824 void pump();
825}
827async function reseed(): Promise<void> {
828 // One new perturbation for the whole study, band-limited at its coarsest
829 // variant and evaluated on each grid — see src/compare/sharedStart.ts.
830 if (compareRun) return compareRun.reseed(seed);
831 if (!session) return;
832 const gen = generation;
833 await session.seed(seed);
834 if (gen !== generation) return;
835 initialState = await session.readState();
836 if (gen !== generation) return;
837 for (const r of ranges) {
838 r.lo = NaN;
839 r.hi = NaN;
840 }
841 await draw();
842 updateStats();
843}
845/** Rewind to the field this run is currently starting from — the last
846 * (re-)seed, not necessarily the very first one — without drawing a new
847 * one. Unlike reseed(), the seed value and lam3 are untouched, so nothing
848 * the CLI command line encodes changes. */
849async function restart(): Promise<void> {
850 if (compareRun) return compareRun.restart();
851 if (!session || !initialState) return;
852 session.loadState(initialState);
853 for (const r of ranges) {
854 r.lo = NaN;
855 r.hi = NaN;
856 }
857 await draw();
858 updateStats();
859}
861// ---------------------------------------------------------------- drawing
862async function draw(): Promise<void> {
863 if (!session || !topo) return;
864 const gen = generation;
865 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
866 for (let k = 0; k < model.species.length; k++) {
867 // The one readback per frame — the loop is otherwise entirely on the GPU.
868 // A rebuild can land while this is in flight and destroy the buffer being
869 // mapped, which rejects the map; that result is stale anyway, so drop it.
870 let field: Float32Array;
871 try {
872 field = await session.readSpecies(k);
873 } catch (e) {
874 if (gen !== generation) return;
875 throw e;
876 }
877 if (gen !== generation || !topo) return;
878 fillFieldValues(valueBufs[k], field, topo);
879 let lo = Infinity;
880 let hi = -Infinity;
881 for (const v of valueBufs[k]) {
882 if (v < lo) lo = v;
883 if (v > hi) hi = v;
884 }
885 // smooth the color range in both directions so the shading evolves
886 // gently as the pattern grows (out-of-range values clamp meanwhile)
887 const r = ranges[k];
888 if (!Number.isFinite(r.lo)) {
889 r.lo = lo;
890 r.hi = hi;
891 } else {
892 const a = 0.15;
893 r.lo += a * (lo - r.lo);
894 r.hi += a * (hi - r.hi);
895 }
896 // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
897 // exactly constant — would otherwise have the colormap stretched across its
898 // roundoff and be drawn as vivid noise. See floorRange.
899 const shown = floorRange(r.lo, r.hi);
900 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
901 scenes[k]?.updateColors(colorBufs[k]);
902 colorbars[k]?.update(cmap, shown.lo, shown.hi);
903 }
904}
906function updateStats(): void {
907 if (!session) return;
908 const { nlat, nphi } = session.cfg;
909 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
910 const solver =
911 solverMs > 0
912 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
913 : '—';
914 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
915 const view = session.viewSht.cfg;
916 const render =
917 session.oversample > 1
918 ? ` (display ${view.nlat}×${view.nphi})`
919 : '';
920 elStats.innerHTML =
921 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
922 `solver ${solver} · ${frame} · ` +
923 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
924}
926// ---------------------------------------------------------------- sim loop
927const nextFrame = () => new Promise<number>(requestAnimationFrame);
929async function pump(): Promise<void> {
930 if (pumping) return;
931 pumping = true;
932 const gen = generation;
933 try {
934 while (running && session && gen === generation) {
935 // Occasionally, a burst purely to measure the solver rate: many steps,
936 // one sync, nothing read back — directly comparable to the desktop
937 // benchmark's throughput number. State-preserving: the display and
938 // model time are unaffected.
939 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
940 const ms = await session.measure(measureBurst);
941 if (gen !== generation) break;
942 solverMs = ms;
943 lastMeasure = performance.now();
944 }
946 // The frame itself. No explicit sync here — draw()'s readback already
947 // waits for the steps, so asking twice would only add a round trip.
948 const t0 = performance.now();
949 session.step(stepsPerFrame);
950 await draw();
951 if (gen !== generation) break;
952 frameMs = frameMs === 0
953 ? performance.now() - t0
954 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
955 updateStats();
956 await nextFrame();
957 }
958 if (gen === generation) {
959 await draw();
960 updateStats();
961 }
962 } finally {
963 pumping = false;
964 }
965}
967/**
968 * Sustained solver benchmark, in the page.
969 *
970 * The same measurement `npm run bench` makes: batches of steps submitted
971 * together, waited for, never read back, with no rendering and no animation
972 * pacing in between. That makes it directly comparable to the terminal number,
973 * which is the only way to tell a genuinely slower browser GPU stack apart from
974 * the costs the app adds on top.
975 *
976 * It also reports the ramp — the first third of the run against the last. GPUs
977 * downclock when idle, and an animation-paced loop leaves them idle most of every
978 * frame, so a large ramp means the app's steady-state number is limited by clocks
979 * rather than by the work.
980 *
981 * These are ordinary steps: the simulation advances by them.
982 */
983async function benchmark(): Promise<void> {
984 if (!session || movieBusy) return;
985 setRunning(false);
986 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
987 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
988 // 32-step submission, exactly the shape of thing that risks a browser's
989 // GPU-process watchdog on weak hardware once niter makes a step expensive.
990 const BATCH = measureBurst;
991 const DURATION_MS = 2000;
992 elBenchResult.textContent = 'benchmarking…';
993 // A movie started mid-benchmark would replay while this loop still steps.
994 elMovie.disabled = true;
995 try {
996 await nextFrame();
998 const gen = generation;
999 const perStep: number[] = [];
1000 const t0 = performance.now();
1001 while (performance.now() - t0 < DURATION_MS) {
1002 const b0 = performance.now();
1003 session.step(BATCH);
1004 await session.sync();
1005 if (gen !== generation) return;
1006 perStep.push((performance.now() - b0) / BATCH);
1007 }
1009 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
1010 const all = mean(perStep);
1011 const best = Math.min(...perStep);
1012 const third = Math.max(1, Math.floor(perStep.length / 3));
1013 const first = mean(perStep.slice(0, third));
1014 const last = mean(perStep.slice(-third));
1015 const steps = perStep.length * BATCH;
1017 elBenchResult.innerHTML =
1018 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
1019 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
1020 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
1021 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
1022 await draw();
1023 updateStats();
1024 } finally {
1025 elMovie.disabled = false;
1026 }
1027}
1029// ---------------------------------------------------------------- movie
1030function saveBlob(blob: Blob, filename: string): void {
1031 const url = URL.createObjectURL(blob);
1032 const a = document.createElement('a');
1033 a.href = url;
1034 a.download = filename;
1035 a.click();
1036 setTimeout(() => URL.revokeObjectURL(url), 10_000);
1037}
1039/** Submit `n` steps in bounded command buffers — a single buffer encoding
1040 * many thousands of steps can exhaust the encoder. */
1041function submitSteps(n: number): void {
1042 while (n > 0 && session) {
1043 const chunk = Math.min(512, n);
1044 session.step(chunk);
1045 n -= chunk;
1046 }
1047}
1049/** While recording, lock everything that could change the run mid-replay;
1050 * the Movie button itself becomes the cancel button. */
1051function setMovieUi(on: boolean): void {
1052 const locked = [
1053 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
1054 elRunPause, elRestart, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
1055 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
1056 ];
1057 for (const el of locked) el.disabled = on;
1058 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
1059 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
1060 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
1061}
1063/**
1064 * Recompute the run from t = 0 and download it as an MP4.
1065 *
1066 * The movie is not a recording of what already happened — it is the same
1067 * trajectory recomputed: same seed, same source, and the *current* parameters
1068 * and colormap throughout. Determinism makes this exact: after the replay the
1069 * state is where it was, so the one session is reused and the app resumes as
1070 * if nothing happened. Frames are composited from the live panels, so the
1071 * movie shows the spheres at the current camera orientation — and the replay
1072 * doubles as the progress display, since it is visible on screen.
1073 */
1074async function recordMovie(): Promise<void> {
1075 if (!session || movieBusy) return;
1076 if (session.steps === 0) {
1077 elMovie.textContent = 'run first';
1078 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
1079 return;
1080 }
1081 movieBusy = true;
1082 movieCancel = false;
1083 const gen = generation;
1084 setMovieUi(true);
1085 let wasRunning = false;
1086 let total = 0;
1087 let done = 0;
1088 let seeded = false;
1089 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
1090 try {
1091 // An in-flight display-grid swap replaces the scenes whose canvases the
1092 // recorder captures, and resumes the run when it lands — let it finish.
1093 await viewChange;
1094 if (gen !== generation || !session) return;
1095 wasRunning = running;
1096 setRunning(false);
1097 while (pumping) await nextFrame(); // let an in-flight live frame drain
1098 if (gen !== generation || !session) return;
1099 total = session.steps;
1100 const speed = Number(elMovieSpeed.value) || 10;
1101 const sphere = Number(elMovieRes.value) || 768;
1102 const rotate = elMovieRotate.checked;
1103 if (rotate) camBefore = scenes[0]?.cameraState();
1104 // Render the scenes at exactly the chosen resolution for the recording —
1105 // independent of the window size — and restore afterwards.
1106 for (const s of scenes) s.captureSize(sphere);
1107 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
1108 const frames = Math.max(
1109 2,
1110 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
1111 );
1112 /** The step index captured as frame `i`; both endpoints land exactly. */
1113 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
1115 const title =
1116 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
1117 ` on ${geometry.label.toLowerCase()}` +
1118 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
1119 const subtitle = model.params
1120 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
1121 .join(' · ');
1122 const rec = await MovieRecorder.create({
1123 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
1124 title,
1125 subtitle,
1126 speed,
1127 fps: (frames - 1) / durationS,
1128 sphere,
1129 });
1131 let finished = false;
1132 try {
1133 // Reset the color-range smoothing as a re-seed does, so the shading
1134 // evolves in the movie the way it did live.
1135 await session.seed(seed);
1136 seeded = true;
1137 for (const r of ranges) {
1138 r.lo = NaN;
1139 r.hi = NaN;
1140 }
1141 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
1142 let lastVideoS = 0;
1143 for (let frame = 0; ; ) {
1144 await draw();
1145 if (gen !== generation) return;
1146 if (movieCancel) break;
1147 if (rotate) {
1148 // Advance the orbit by this frame's share of video time; siblings
1149 // follow scenes[0] through the usual camera sync.
1150 const videoS = session.t / speed;
1151 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
1152 lastVideoS = videoS;
1153 }
1154 for (const s of scenes) s.renderNow();
1155 await rec.addFrame(
1156 session.t,
1157 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
1158 );
1159 if (++frame >= frames) {
1160 finished = true;
1161 break;
1162 }
1163 const target = stepAt(frame);
1164 submitSteps(target - done);
1165 done = target;
1166 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
1167 }
1168 if (finished) {
1169 const blob = await rec.finish();
1170 saveBlob(
1171 blob,
1172 `turing-surface-${model.key}-${geometry.key}-` +
1173 `t${session.t.toFixed(2)}-${speed}x.mp4`,
1174 );
1175 }
1176 } finally {
1177 if (!finished) rec.cancel();
1178 }
1179 } catch (e) {
1180 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1181 } finally {
1182 // A cancelled replay stopped short of where the run was; step the
1183 // remainder — determinism makes this land exactly there.
1184 if (seeded && gen === generation && session) {
1185 while (done < total && gen === generation && session) {
1186 const n = Math.min(4096, total - done);
1187 submitSteps(n);
1188 done += n;
1189 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1190 await session.sync();
1191 }
1192 await draw();
1193 updateStats();
1194 }
1195 if (gen === generation) {
1196 for (const s of scenes) s.restoreSize();
1197 }
1198 if (camBefore && gen === generation) {
1199 for (const s of scenes) s.setCameraState(camBefore);
1200 }
1201 movieBusy = false;
1202 setMovieUi(false);
1203 if (gen === generation) setRunning(wasRunning);
1204 }
1205}
1207// ---------------------------------------------------------------- compare
1208/**
1209 * Comparing several solver settings at once.
1210 *
1211 * Deliberately a mode rather than a widening of the ordinary controls: the
1212 * single-run path above is untouched, and with the bar closed nothing about
1213 * using this page has changed. Opening it and pressing Compare tears down the
1214 * one session and hands the panels area to a CompareRun, which owns a session
1215 * per variant; pressing it again puts the single run back.
1216 *
1217 * The ceilings below are not arbitrary. Each variant compiles its whole
1218 * unrolled step with no pipeline cache between sessions (a solve iteration is
1219 * ~15 kernels per species), so the variant count is what you wait for; and
1220 * each panel is a WebGL context and a full mesh, so the panel count is what
1221 * the browser has to keep alive at once.
1222 */
1223const MAX_VARIANTS = 6;
1224const MAX_PANELS = 12;
1225/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1226 * variant lands on the same model time with no accumulated drift. */
1227const DT_DIVISORS = [1, 2, 4, 8];
1229/**
1230 * What the bar opens on: the default iteration count against the next step up,
1231 * at the default band. Two variants, so the first study is quick to compile,
1232 * and it asks the question the control exists for — is the default already
1233 * converged? A flat, low curve says yes; one that climbs says the answer is
1234 * still moving at niter 8 and the default is not enough for this shape.
1235 */
1236const cmpSelected = {
1237 niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1238 lmax: new Set<number>([63]),
1239 dt: new Set<number>([1]),
1240};
1242/** A row of toggle chips backed by a Set. At least one stays selected — an
1243 * empty axis has no meaning here, and silently falling back to a default
1244 * would hide which values are actually being run. */
1245function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1246 host.replaceChildren();
1247 for (const value of values) {
1248 const chip = document.createElement('button');
1249 chip.type = 'button';
1250 chip.className = 'chip';
1251 chip.textContent = label(value);
1252 const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1253 paint();
1254 chip.addEventListener('click', () => {
1255 if (selected.has(value)) {
1256 if (selected.size === 1) return;
1257 selected.delete(value);
1258 } else {
1259 selected.add(value);
1260 }
1261 paint();
1262 refreshVariants();
1263 });
1264 host.append(chip);
1265 }
1266}
1268const cmpVariants = (): Variant[] =>
1269 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1271/**
1272 * A loaded reference file, or null. While one is loaded the study checks the
1273 * variants against it instead of against each other: the file defines the
1274 * whole problem (model, parameters, geometry, initial state, end time), so
1275 * the page's own model and geometry choices do not enter the study at all —
1276 * only the solver knobs above do.
1277 */
1278let refCase: ReferenceCase | null = null;
1280/** The reference the user picked, clamped to the current variant list. */
1281let cmpRefKey = '';
1283/** Index of the reference in the current variant list, never negative. */
1284function compareRefIndex(): number {
1285 const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1286 return i < 0 ? 0 : i;
1287}
1289function refreshVariants(): void {
1290 const variants = cmpVariants();
1291 const showDt = cmpSelected.dt.size > 1;
1292 // With a file loaded the study's model is the file's, and its final state
1293 // is one more row of panels.
1294 const cmpModel = refCase?.model ?? model;
1295 const rowCount = variants.length + (refCase ? 1 : 0);
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;
1302 const prev = cmpRefKey;
1303 elCmpRef.replaceChildren();
1304 if (refCase) {
1305 // The file is the reference; the pick among variants means nothing here.
1306 const o = document.createElement('option');
1307 o.textContent = `the file's final state`;
1308 elCmpRef.append(o);
1309 elCmpRef.disabled = true;
1310 } else {
1311 elCmpRef.disabled = false;
1312 for (const v of variants) {
1313 const o = document.createElement('option');
1314 o.value = variantKey(v);
1315 o.textContent = variantLabel(v, showDt);
1316 elCmpRef.append(o);
1317 }
1318 const keys = variants.map(variantKey);
1319 cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1320 elCmpRef.value = cmpRefKey;
1321 }
1323 const tooMany =
1324 variants.length > MAX_VARIANTS
1325 ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1326 : panels > MAX_PANELS
1327 ? `${panels} panels — at most ${MAX_PANELS}`
1328 : '';
1329 elCmpCount.textContent = tooMany
1330 ? `too many: ${tooMany}`
1331 : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
1332 `${refCase ? ' + the file' : ''} × ` +
1333 `${cmpModel.species.length} species` +
1334 (diffRowCount > 0 ? ` + ${diffRowCount} diff row${diffRowCount === 1 ? '' : 's'}` : '') +
1335 ` = ${panels} panels`;
1336 elCmpCount.style.color = tooMany ? '#b35900' : '';
1337 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1338}
1340/**
1341 * The niter chips on offer. A loaded reference file adds its own recorded
1342 * iteration count if the standard list lacks it, so the file's settings are
1343 * always selectable; clearing the file drops any selection outside the
1344 * standard list again.
1345 */
1346function rebuildNiterChips(): void {
1347 const all = [...elNiter.options].map((o) => Number(o.value));
1348 let values = all;
1349 if (refCase && !all.includes(refCase.niter)) {
1350 values = [...all, refCase.niter].sort((a, b) => a - b);
1351 }
1352 if (!refCase) {
1353 for (const v of [...cmpSelected.niter]) if (!values.includes(v)) cmpSelected.niter.delete(v);
1354 if (cmpSelected.niter.size === 0) cmpSelected.niter.add(DEFAULT_NITER);
1355 }
1356 buildChips(elCmpNiter, values, cmpSelected.niter, String);
1357}
1359/**
1360 * The lmax chips on offer. A loaded reference file floors them at its own
1361 * band: a variant below it could not even hold the file's initial state
1362 * (prolongation only widens), so those values are not offered rather than
1363 * offered and refused.
1364 */
1365function rebuildLmaxChips(): void {
1366 const all = [...elLmax.options].map((o) => Number(o.value));
1367 let values = all;
1368 if (refCase) {
1369 const floor = refCase.lmax;
1370 values = all.filter((v) => v >= floor);
1371 if (!values.includes(floor)) values = [floor, ...values];
1372 for (const v of [...cmpSelected.lmax]) if (!values.includes(v)) cmpSelected.lmax.delete(v);
1373 if (cmpSelected.lmax.size === 0) cmpSelected.lmax.add(floor);
1374 }
1375 buildChips(elCmpLmax, values, cmpSelected.lmax, String);
1376}
1378rebuildNiterChips();
1379rebuildLmaxChips();
1380buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1381refreshVariants();
1383elCmpRef.addEventListener('change', () => {
1384 cmpRefKey = elCmpRef.value;
1385 if (compareRun) void rebuildCompare();
1386});
1388/** Reflect the loaded (or cleared) reference file in the compare bar. */
1389function applyRefUi(): void {
1390 elCmpFileInfo.hidden = elCmpFileClear.hidden = refCase === null;
1391 if (refCase) {
1392 const rc = refCase;
1393 const geomParamText = rc.geometry.params
1394 .map((p) => `${p.key}=${rc.geometryParams[p.key]}`)
1395 .join(' ');
1396 const name = document.createElement('b');
1397 name.textContent = rc.label;
1398 const info = document.createElement('span');
1399 info.textContent =
1400 ` — ${rc.model.label} on ${rc.geometry.label.toLowerCase()}` +
1401 (geomParamText ? ` (${geomParamText})` : '') +
1402 `, lmax ${rc.lmax}, T = ${(rc.steps * (rc.params.dt ?? 0)).toFixed(2)}` +
1403 ` (${rc.steps} × dt ${rc.params.dt})`;
1404 elCmpFileInfo.replaceChildren(name, info);
1405 }
1406 rebuildNiterChips();
1407 rebuildLmaxChips();
1408 refreshVariants();
1409}
1411/**
1412 * The four top-level modes and which control groups each shows (see
1413 * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1414 * `currentMode` tracks which configuration is on screen — the compare bar
1415 * being open, and in which flavor — not whether a study has actually been
1416 * started inside it. That match matters: without it, opening the bar
1417 * (which already shows the right groups) leaves its top-row button
1418 * unhighlighted until a study happens to start, which is inconsistent with
1419 * `vs-upload`'s one-click flow and reads as broken.
1420 */
1421type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1422let currentMode: Mode = 'simulate';
1424const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
1425 simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
1426 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1427 'vs-sphere': [], // unreachable — the button is disabled, no listener ever calls setMode with this
1428 // No `seed` here: nothing in that group does anything useful against a
1429 // loaded file (lam3 is silently absorbed, and Restart already covers what
1430 // Re-seed would otherwise be doing — reloading the file's fixed initial
1431 // state) — see CompareRun.restart().
1432 'vs-upload': ['display', 'playback'],
1433};
1435const MODE_DESCRIPTIONS: Record<Mode, string> = {
1436 simulate:
1437 'This mode runs one standalone reaction-diffusion solver.',
1438 'compute-effort':
1439 'When we change the computational effort of the solver by varying solve iterations, lmax, or timestep, ' +
1440 'how does the solution change? Find out by running several ' +
1441 'so you can see how each setting trades accuracy for speed.',
1442 'vs-sphere': '',
1443 'vs-upload':
1444 'Load a saved reference run (an .h5 file) and run this solver to the ' +
1445 'same physical end time from the same initial condition, to check how ' +
1446 'closely it reproduces the reference. You can adjust the solver settings ' +
1447 'to see how they affect the outcome.',
1448};
1450function setModeButtons(mode: Mode): void {
1451 elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
1452 elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1453 elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
1454 elModeDesc.textContent = MODE_DESCRIPTIONS[mode];
1455}
1457/** Show exactly the groups `mode` declares; hide the rest. */
1458function applyModeVisibility(mode: Mode): void {
1459 currentMode = mode;
1460 const shown = new Set<GroupName>(MODE_GROUPS[mode]);
1461 for (const name of GROUP_NAMES) groupEls[name].hidden = !shown.has(name);
1462 setModeButtons(mode);
1463}
1465/**
1466 * Enter `mode`: groups, top-row buttons, and the compare bar's own
1467 * visibility (open for the two compare flavors, closed for Simulate).
1468 * Doesn't touch `compareRun`/`refCase` or start/stop a study — callers
1469 * decide that; this only decides what's on screen, and it decides it
1470 * immediately, so the button you clicked lights up right away rather than
1471 * waiting on a study that may not exist yet (or may never start, if the
1472 * bar's own Compare is never pressed).
1473 */
1474function enterMode(mode: Mode): void {
1475 applyModeVisibility(mode);
1476 elCompareBar.hidden = mode === 'simulate';
1477}
1479/** Entering a mode from the top row. */
1480function setMode(mode: Mode): void {
1481 if (mode === 'vs-sphere') return; // unreachable — button is disabled
1482 if (mode === 'simulate') {
1483 if (compareRun) void stopCompare();
1484 enterMode('simulate');
1485 return;
1486 }
1487 if (mode === 'compute-effort') {
1488 // Tear down whatever study is running first (mirrors Simulate above) —
1489 // stopCompare's synchronous prefix disposes it and nulls `compareRun`
1490 // before its first `await`, so `refCase` is safe to drop right after.
1491 if (compareRun) void stopCompare();
1492 if (refCase) {
1493 refCase = null;
1494 applyRefUi();
1495 }
1496 enterMode('compute-effort');
1497 return;
1498 }
1499 // vs-upload: opens the file picker; entering the mode itself happens once
1500 // a file is actually chosen (elCmpFile's change handler below) — not here,
1501 // since cancelling the dialog must leave the current mode untouched.
1502 elCmpFile.click();
1503}
1505elModeSimulate.addEventListener('click', () => setMode('simulate'));
1506elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1507elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
1509elCmpFile.addEventListener('change', () => {
1510 const file = elCmpFile.files?.[0];
1511 // Cleared so picking the same file again still fires a change event.
1512 elCmpFile.value = '';
1513 if (!file) return;
1514 void (async () => {
1515 try {
1516 refCase = await loadReferenceFile(file);
1517 elErr.textContent = '';
1518 } catch (e) {
1519 refCase = null;
1520 elErr.textContent = `reference file ${file.name}: ${e instanceof Error ? e.message : e}`;
1521 applyRefUi();
1522 return;
1523 }
1524 // One click, one study: the file's own settings become the single
1525 // variant — its recorded niter, its band, its dt undivided — and the
1526 // comparison opens on them, paused at the initial state so what runs is
1527 // the user's choice. (Widening it is: teardown the comparison, pick more
1528 // chips, compile it again — the file stays loaded.)
1529 cmpSelected.niter.clear();
1530 cmpSelected.niter.add(refCase.niter);
1531 cmpSelected.lmax.clear();
1532 cmpSelected.lmax.add(refCase.lmax);
1533 cmpSelected.dt.clear();
1534 cmpSelected.dt.add(1);
1535 applyRefUi();
1536 enterMode('vs-upload');
1537 if (compareRun) {
1538 // A study is already up (this one loaded over it): same teardown as
1539 // rebuildCompare, then the new file's study takes its place.
1540 compareRun.dispose();
1541 compareRun = null;
1542 setCompareUi(false);
1543 }
1544 await startCompare();
1545 })();
1546});
1547elCmpFileClear.addEventListener('click', () => {
1548 refCase = null;
1549 applyRefUi();
1550 // The bar stays open — this only drops back to the plain chip comparison.
1551 // Only reachable while idle (elCmpFileClear is disabled during a study).
1552 enterMode('compute-effort');
1553});
1555elCmpStart.addEventListener('click', () => {
1556 if (compareRun) void stopCompare();
1557 else void startCompare();
1558});
1560/**
1561 * Controls the study supersedes or cannot honour while it is running.
1562 * Mode/group/button state is not this function's job — that's set the
1563 * moment a mode is entered (enterMode, above), independent of whether a
1564 * study inside it has actually started or stopped.
1565 */
1566function setCompareUi(on: boolean): void {
1567 // A study picks its own display grid, so oversample stays individually
1568 // disabled inside the still-visible display group; and clearing a loaded
1569 // file out from under a running study would leave it checking against one
1570 // that no longer exists.
1571 elOversample.disabled = on;
1572 elCmpFileClear.disabled = on;
1573 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1574 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1575 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1576 elCmpStart.textContent = on ? 'Teardown comparison' : 'Compile comparison';
1577 // The movie bar's own hidden flag is independent of the movie *group's* —
1578 // force it closed so it doesn't reappear open once the group is shown
1579 // again on returning to Simulate.
1580 if (on) elMovieBar.hidden = true;
1581}
1583async function startCompare(): Promise<void> {
1584 if (compareRun || !device) return;
1585 // Snapshotted for the whole study: `refCase` only changes with no study up
1586 // (clearing is disabled during one, and loading tears it down first).
1587 const rc = refCase;
1588 const cmpModel = rc?.model ?? model;
1589 const variants = cmpVariants();
1590 const rowCount = variants.length + (rc ? 1 : 0);
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) {
1594 return;
1595 }
1596 // Take down the single run first: its pump, its scenes, its session. The
1597 // generation bump makes any readback already in flight drop its result.
1598 generation++;
1599 setRunning(false);
1600 while (pumping) await nextFrame();
1601 disposeView();
1602 session?.destroy();
1603 session = null;
1604 elBenchResult.textContent = '';
1605 elErr.textContent = '';
1606 setCompareUi(true);
1608 try {
1609 // Against a reference file, the problem is the file's — its model,
1610 // parameters and geometry, from the registry sources (the editor's
1611 // working copies describe the page's run, not the file's).
1612 compareRun = await CompareRun.create({
1613 device,
1614 model: cmpModel,
1615 params: rc ? rc.params : params,
1616 source: rc ? rc.model.source : source(),
1617 geometry: rc ? rc.geometry : geometry,
1618 geometryParams: rc ? rc.geometryParams : geomParams,
1619 geometrySource: rc ? rc.geometry.source : geomSource(),
1620 variants,
1621 reference: rc ? 0 : compareRefIndex(),
1622 refFile: rc ?? undefined,
1623 onFinished: () => setRunning(false),
1624 seed,
1625 lam3: rc ? undefined : Number(elLam3.value),
1626 morph,
1627 colormapName: () => elColormap.value,
1628 container: elPanels,
1629 chartContainer: elCmpChart,
1630 onStatus: (html) => (elStats.innerHTML = html),
1631 });
1632 } catch (e) {
1633 compareRun = null;
1634 setCompareUi(false);
1635 refreshVariants();
1636 reportCompileError(e);
1637 await rebuild();
1638 return;
1639 }
1640 updateGeomNote();
1641 // The command describes the reference variant, which only exists now.
1642 updateCommand();
1643 elRunPause.textContent = 'Run';
1644}
1646async function stopCompare(): Promise<void> {
1647 if (!compareRun) return;
1648 compareRun.dispose();
1649 compareRun = null;
1650 setCompareUi(false);
1651 refreshVariants();
1652 elStats.textContent = '';
1653 await rebuild();
1654}
1656/** Rebuild the study in place — after a model, geometry, source or reference
1657 * change. Same teardown as stopping, without leaving the mode. */
1658async function rebuildCompare(): Promise<void> {
1659 if (!compareRun) return;
1660 compareRun.dispose();
1661 compareRun = null;
1662 setCompareUi(false);
1663 await startCompare();
1664}
1666// ---------------------------------------------------------------- boot
1667async function boot(): Promise<void> {
1668 enterMode('simulate');
1669 elModel.value = presets[0].key;
1670 // The iteration count is one default shared with the benchmark, like the
1671 // rest of the RunSpec's — take it from there rather than from the markup, so
1672 // the page and `npm run bench` cannot start out disagreeing about it.
1673 elNiter.value = String(DEFAULT_NITER);
1674 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1675 elMorph.value = String(morph);
1676 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1677 applyPreset(presets[0].key);
1678 try {
1679 device = await requestShtDevice();
1680 adapterName = await describeAdapter(device);
1681 } catch (e) {
1682 device = null;
1683 elErr.textContent =
1684 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1685 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1686 return;
1687 }
1688 device.lost.then((info) => {
1689 if (info.reason !== 'destroyed') {
1690 elErr.textContent = `WebGPU device lost: ${info.message}`;
1691 }
1692 });
1693 await rebuild();
1694}
1696void boot();