forked from github.com/concept-collection/turing-surface
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1import { requestShtDevice, describeAdapter } from './sht/sht.ts';
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 2import { gridForLmax, nlmCalc } from './sht/layout.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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,
13 DEFAULT_WARMUP,
14 type RunSpec,
15} from './bench/runSpec.ts';
16import {
17 mGeometries,
18 mGeometryByKey,
19 defaultGeometryParams,
20 DEFAULT_GEOMETRY_KEY,
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 22 IMPORTED_GEOMETRY_KEY,
23 makeImportedGeometry,
24 type ImportedCoeffs,
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';
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 35import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 36import { colormaps, colormapNames } from './render/colormaps.ts';
37import { MovieRecorder } from './render/movie.ts';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 38import { CompareRun } from './compare/compareRun.ts';
39import {
40 crossProduct,
41 mostResolved,
42 variantKey,
43 variantLabel,
44 type Variant,
45} from './compare/variants.ts';
c90d0e2Check reference files in the browser's compare modeJeremy Magland 46import { loadReferenceFile } from './compare/referenceFile.ts';
47import type { ReferenceCase } from './compare/referenceCase.ts';
9b71d5bexport standalone matlabJeremy Magland 48import { generateMatlabScript, MATLAB_SCRIPT_NAME } from './export/matlabScript.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 49
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');
ca37955Adding a way to reset simulation from same random IC.Owen Melia 61const elRestart = $<HTMLButtonElement>('restart');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 62const elBenchmark = $<HTMLButtonElement>('benchmark');
63const elReseed = $<HTMLButtonElement>('reseed');
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 64const elLam3 = $<HTMLInputElement>('lam3');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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');
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 72const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
73const elModeEffort = $<HTMLButtonElement>('mode-effort');
cf4af12WIP: comparison with unit sphereOwen Melia 74const elModeVsSphere = $<HTMLButtonElement>('mode-vs-sphere');
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 75const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 77const elCompareBar = $('comparebar');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 79const elCmpNiter = $('cmp-niter');
80const elCmpLmax = $('cmp-lmax');
81const elCmpDt = $('cmp-dt');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 83const elCmpRef = $<HTMLSelectElement>('cmp-ref');
c90d0e2Check reference files in the browser's compare modeJeremy Magland 84const elCmpFile = $<HTMLInputElement>('cmp-file');
85const elCmpFileInfo = $('cmp-fileinfo');
86const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 87const elCmpStart = $<HTMLButtonElement>('cmp-start');
88const elCmpCount = $('cmp-count');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 89const elParams = $('params');
90const elGeomParams = $('geomparams');
91const elGeomNote = $('geomnote');
92const elPanels = $('panels');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 94const elStats = $('stats');
95const elBenchResult = $('benchresult');
96const elCmd = $('cmd');
97const elCopyCmd = $<HTMLButtonElement>('copycmd');
99const elMatlabScript = $('matlabscript');
100const elCopyMatlab = $<HTMLButtonElement>('copymatlab');
101const elDownloadMatlab = $<HTMLButtonElement>('downloadmatlab');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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');
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 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>;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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});
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 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;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 187
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 */
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 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;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 206const MEASURE_EVERY_MS = 2000;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 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;
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;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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;
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 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;
ca37955Adding a way to reset simulation from same random IC.Owen Melia 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;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 302
303const source = (): string => editedSource ?? model.source;
304const geomSource = (): string => editedGeomSource ?? geometry.source;
306// ---------------------------------------------------------------- UI wiring
307function buildParamInputs(): void {
308 elParams.replaceChildren();
310 const tag = document.createElement('label');
311 tag.textContent = 'model parameters';
312 elParams.append(tag);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 329 session?.setParams(params);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 330 compareRun?.setParams(params);
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');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 349 elGeomParams.append(tag);
350 for (const spec of geometry.params) {
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 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;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 369 viewChange = viewChange.then(() => applyGeometry());
370 });
371 elGeomParams.append(button);
372 continue;
373 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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 {
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 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);
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();
9b71d5bexport standalone matlabJeremy Magland 423 // The command line and the MATLAB export both bake the surface in.
424 updateCommand();
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();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 432 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
434 editor.value = source();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 435 elEditorTitle.textContent = `models/${model.key}.m`;
437}
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 */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 445function currentSpec(): RunSpec {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 446 const ref = compareRun?.variants[compareRefIndex()];
447 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
449 preset: elModel.value,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 450 lmax: ref ? ref.lmax : Number(elLmax.value),
452 steps: DEFAULT_STEPS,
453 warmup: DEFAULT_WARMUP,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 454 params: dt ? { ...params, ...dt } : params,
456 geometryParams: geomParams,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 457 niter: ref ? ref.niter : Number(elNiter.value),
459}
461function updateCommand(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 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}`;
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 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)';
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 {
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 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 }
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;
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', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 533 viewChange = viewChange.then(() => applyOversample());
534});
535elGeometry.addEventListener('change', () => {
536 applyGeometryChoice(elGeometry.value);
537 viewChange = viewChange.then(() => applyGeometry());
538});
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 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;
9b71d5bexport standalone matlabJeremy Magland 546 // Not in the bench command, but the MATLAB export bakes lam3 in.
547 updateCommand();
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 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 () => {
beac00aMerge main into random-fieldsJeremy Magland 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;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 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);
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 568 elLam3.value = String(previous);
569 await reseed();
570 }
571 });
572});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 573// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
574elMorph.addEventListener('input', () => {
575 morph = Number(elMorph.value);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 576 if (compareRun) compareRun.setMorph(morph);
577 else applyMorph();
578});
579elColormap.addEventListener('change', () => {
580 if (compareRun) void compareRun.draw();
581 else void draw();
583elEditorFile.addEventListener('change', () => showEditorFile());
585function setRunning(next: boolean): void {
586 running = next;
587 elRunPause.textContent = running ? 'Pause' : 'Run';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 588 if (compareRun) {
589 compareRun.setRunning(next);
590 return;
591 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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});
ca37955Adding a way to reset simulation from same random IC.Owen Melia 603elRestart.addEventListener('click', () => {
604 setRunning(false);
605 void restart();
606});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 607elResetView.addEventListener('click', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 608 compareRun?.resetView();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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});
9b71d5bexport standalone matlabJeremy Magland 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});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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();
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}
f132fe4Drop the work-in-progress bannerDan Fortunato 839/** What the surface is, and how far it departs from the sphere. */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 840function updateGeomNote(): void {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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) {
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 845 elGeomNote.textContent = '';
846 return;
847 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 848 const { lo, hi } = s.geometry.radiusRange();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 850 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
f132fe4Drop the work-in-progress bannerDan Fortunato 851 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
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> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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();
869 const gen = generation;
870 setRunning(false);
871 disposeView();
872 session?.destroy();
873 session = null;
874 solverMs = 0;
875 frameMs = 0;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 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();
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),
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 898 lam3: Number(elLam3.value),
900 } catch (e) {
901 reportCompileError(e);
902 return;
903 }
904 if (gen !== generation) return;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 906 await session.seed(seed);
ca37955Adding a way to reset simulation from same random IC.Owen Melia 907 if (gen !== generation) return;
908 initialState = await session.readState();
909 if (gen !== generation) return;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 910
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';
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 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)));
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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);
941 const gen = generation;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 942 await session.seed(seed);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 943 if (gen !== generation) return;
ca37955Adding a way to reset simulation from same random IC.Owen Melia 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);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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 }
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 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);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1010 scenes[k]?.updateColors(colorBufs[k]);
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 1011 colorbars[k]?.update(cmap, shown.lo, shown.hi);
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
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1021 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1023 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1024 const view = session.viewSht.cfg;
1025 const render =
1026 session.oversample > 1
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1027 ? ` (display ${view.nlat}×${view.nphi})`
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) {
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 1049 const ms = await session.measure(measureBurst);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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();
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 1058 session.step(stepsPerFrame);
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);
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 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;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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)} · ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1129 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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,
ca37955Adding a way to reset simulation from same random IC.Owen Melia 1163 elRunPause, elRestart, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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.
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 1244 await session.seed(seed);
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}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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]);
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;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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 {
cf4af12WIP: comparison with unit sphereOwen Melia 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 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1408 const variants = cmpVariants();
1409 const showDt = cmpSelected.dt.size > 1;
c90d0e2Check reference files in the browser's compare modeJeremy Magland 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);
02e7a36Update comparison UIOwen Melia 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();
1423 // The file is the reference; the pick among variants means nothing here.
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1424 const o = document.createElement('option');
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1425 o.textContent = `the file's final state`;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1426 elCmpRef.append(o);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 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;
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}`
3210e7dOpen the reference comparison in one clickJeremy Magland 1449 : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
1450 `${refCase ? ' + the file' : ''} × ` +
1452 (diffRowCount > 0 ? ` + ${diffRowCount} diff row${diffRowCount === 1 ? '' : 's'}` : '') +
1453 ` = ${panels} panels`;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1454 elCmpCount.style.color = tooMany ? '#b35900' : '';
1455 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1456}
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}
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}
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';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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});
c90d0e2Check reference files in the browser's compare modeJeremy Magland 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 }
1545 refreshVariants();
1546}
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 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'],
cf4af12WIP: comparison with unit sphereOwen Melia 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'],
ca37955Adding a way to reset simulation from same random IC.Owen Melia 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'],
2bb4f78Add short descriptions for each modeOwen Melia 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.',
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.',
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};
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1581function setModeButtons(mode: Mode): void {
1582 elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
1583 elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
cf4af12WIP: comparison with unit sphereOwen Melia 1584 elModeVsSphere.setAttribute('aria-pressed', String(mode === 'vs-sphere'));
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1585 elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
2bb4f78Add short descriptions for each modeOwen Melia 1586 elModeDesc.textContent = MODE_DESCRIPTIONS[mode];
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';
cf4af12WIP: comparison with unit sphereOwen Melia 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';
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 }
cf4af12WIP: comparison with unit sphereOwen Melia 1624 if (mode === 'compute-effort' || mode === 'vs-sphere') {
c8e230aFixing some bugs in the UIOwen Melia 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.
cf4af12WIP: comparison with unit sphereOwen Melia 1628 // vs-sphere never uses a reference file either — its "reference" is
1629 // always the selected geometry — so the same drop applies there too.
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1631 if (refCase) {
1632 refCase = null;
1633 applyRefUi();
1634 }
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'));
cf4af12WIP: comparison with unit sphereOwen Melia 1646elModeVsSphere.addEventListener('click', () => setMode('vs-sphere'));
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1647elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
c90d0e2Check reference files in the browser's compare modeJeremy Magland 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}`;
1662 return;
3210e7dOpen the reference comparison in one clickJeremy Magland 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
c8e230aFixing some bugs in the UIOwen Melia 1667 // the user's choice. (Widening it is: teardown the comparison, pick more
1668 // chips, compile it again — the file stays loaded.)
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);
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1676 enterMode('vs-upload');
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();
1686});
1687elCmpFileClear.addEventListener('click', () => {
1688 refCase = null;
1689 applyRefUi();
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 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');
1695elCmpStart.addEventListener('click', () => {
1696 if (compareRun) void stopCompare();
1697 else void startCompare();
1698});
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 */
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1706function setCompareUi(on: boolean): void {
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 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;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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));
c8e230aFixing some bugs in the UIOwen Melia 1716 elCmpStart.textContent = on ? 'Teardown comparison' : 'Compile comparison';
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 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.
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1720 if (on) elMovieBar.hidden = true;
1721}
1723async function startCompare(): Promise<void> {
1724 if (compareRun || !device) return;
3210e7dOpen the reference comparison in one clickJeremy Magland 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).
cf4af12WIP: comparison with unit sphereOwen Melia 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;
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1730 const cmpModel = rc?.model ?? model;
cf4af12WIP: comparison with unit sphereOwen Melia 1731
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 }
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1759 const rowCount = variants.length + (rc ? 1 : 0);
02e7a36Update comparison UIOwen Melia 1760 const diffRowCount = rc ? variants.length : Math.max(0, variants.length - 1);
1761 const panels = (rowCount + diffRowCount) * cmpModel.species.length;
cf4af12WIP: comparison with unit sphereOwen Melia 1762 if (!isVsSphere && (variants.length > MAX_VARIANTS || panels > MAX_PANELS)) {
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 {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 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).
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1781 compareRun = await CompareRun.create({
1782 device,
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(),
1791 geometries,
1792 renderOnReferenceGeometry,
1793 rowLabels,
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1794 refFile: rc ?? undefined,
1795 onFinished: () => setRunning(false),
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1797 lam3: rc ? undefined : Number(elLam3.value),
1799 colormapName: () => elColormap.value,
1800 container: elPanels,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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}
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 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});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1950// ---------------------------------------------------------------- boot
1951async function boot(): Promise<void> {
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1952 enterMode('simulate');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1953 elModel.value = presets[0].key;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 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);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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}). ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1969 `Use a WebGPU-capable browser such as Chrome or Edge.`;
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 1970 importGate = 'WebGPU is not available in the opened tab';
1971 announceImportGate();
1973 }
1974 device.lost.then((info) => {
1975 if (info.reason !== 'destroyed') {
1976 elErr.textContent = `WebGPU device lost: ${info.message}`;
1977 }
1978 });
1979 await rebuild();
77f627eAccept a surface handed over as coefficients (reharm's export button)Dan Fortunato 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 }
2001void boot();