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