concept-collection / turing-surface-cache
Show one parameter across its range, not only at a point
A second page fixes every setting except one model parameter, which runs over a list of values, and a knob steps the display through them. What makes the knob worth having is the cache: each value names one solution under the same specs the single-solution page uses, so on any selection change the page fetches the whole sweep at once -- a handful of files of about 90 KB -- and synthesizes each solution onto the render mesh as it arrives. Moving the knob afterwards touches neither the network nor the solver; it recolors the mesh from values already in memory. One color scale per species, computed over all loaded values and held fixed, covers the whole sweep, so that what changes under the knob is the pattern rather than the palette. The value list starts as the swept parameter's own choices but is a text box, and any numbers may be typed in its place. This is the one control in the app that is not a choice from a list. It is nonetheless safe for the cache, since a typed value is parsed to a number once and serialized in canonical shortest form ever after, so that it names one spec and one object as reliably as a listed value does. Of course, a value the auto-fill walk never surveyed will not already be there, so such a sweep arrives entirely as gaps. Gaps are filled either here or elsewhere. Compute missing values runs them in the browser, one after another, each through the ordinary local run of runSpec.ts, warm start and background uploads and divergence guard included; and `fill sweep '<url>'` fills the same sweep on a machine with no browser on it. Its argument is the sweep page's own URL, whose fragment already carries the selection, which parameter sweeps, its values and the knob's position, so that the copied command and the page it came from always name the same solutions and the same URL pasted into a browser shows the result. Commas in that fragment are left unencoded, which a fragment permits and a reader appreciates. The serialization now lives in src/cache/selection.ts and is read and written by both pages and the command line rather than by each in its own way; main.ts loses its own copy of it. Within the command line the full walk and a sweep are one Plan, differing only in which targets exist and how each is named, so the run loop and its reporting are shared. The end-to-end check gains a sweep pass: the file computed in pass 1 is loaded as one value of a dt sweep, the other two appear as gaps and are filled by the button, and a typed list of two values is then applied and its literal commas checked in the URL. check-live.mjs smoke-checks the sweep page beside the main one, and screenshot.mjs takes an optional page name.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit e53205ff1a85 parent 377e83e Browse files
13 changed files+1952−198
README.mdmodified+58−7View file
@@ -216,6 +216,55 @@ what a machine would take on before committing it; `--limit` and `--model`
216216 narrow the work; and ctrl-C stops after the current run, so nothing in flight
217217 is lost.
218218
219+## Sweeping one parameter
220+
221+A second page, [`sweep.html`](sweep.html) (linked from the main one), shows
222+how the solution changes across one parameter rather than at one point: every
223+setting is fixed except a single model parameter, which runs over a list of
224+values, and a knob steps the display through the range. On any selection
225+change the page fetches every value's cache file at once (a sweep is a handful
226+of files of about 90 KB) and synthesizes each solution onto the render mesh as
227+it arrives, so that moving the knob afterwards touches neither the network nor
228+the solver: it recolors the mesh from values already in memory.
229+One color scale per species, computed over all loaded values and held fixed,
230+covers the whole sweep, so what changes under the knob is the pattern rather
231+than the palette. The URL fragment carries the selection, the swept parameter
232+(`sweep=b`), its values (`values=0.7,0.9,1.1,1.3`) and the knob's position, so
233+a shared link opens on the same sweep at the same place; the serialization is
234+the main page's with two entries added (see
235+[`src/cache/selection.ts`](src/cache/selection.ts)).
236+
237+The value list starts as the swept parameter's own choices, which is what the
238+main page's dropdown offers and what the auto-fill walk surveys, but it is a
239+text box, and any numbers may be typed in its place. This is the one control
240+in the app that is not a choice from a list. It is nonetheless safe for the
241+cache, since a typed number is parsed once and serialized in canonical
242+shortest form ever after, so that it names one exact spec and one exact
243+object just as a listed value does. Of course, a value the walk never
244+surveyed will not already be there, so a sweep over typed values arrives
245+entirely as gaps and has to be computed.
246+
247+Values nobody has computed show as gaps on the knob's track. **Compute
248+missing values** runs them in the browser, one after another, each through the
249+same local run as the main page, warm start and background snapshot uploads
250+and divergence guard included. The command line fills a sweep on a machine
251+with no browser on it:
252+
253+```
254+TURING_SURFACE_CACHE_KEY=… npx …/fill.tgz sweep '<sweep page URL>'
255+```
256+
257+The argument is the sweep page's own URL: its fragment already says which
258+parameter sweeps, over which values, and what everything else is fixed to, so
259+the copied command and the page it came from always name the same solutions,
260+and pasting the same URL into a browser shows the result. The page offers this
261+command ready to copy once an upload key is entered, the key masked on screen
262+and real in the clipboard as before. `--dry-run` lists the sweep's values and
263+whether each is cached. Note that unlike the auto-fill walk, a sweep honors
264+the selection's seed and end time exactly; a run to the sweep's end time still
265+uploads every shorter listed end time it passes, so filling a sweep at
266+T = 1600 also fills the same sweep at every smaller T.
267+
219268 ## The cache file
220269
221270 Cache files are HDF5, written in the browser with
@@ -277,14 +326,16 @@ Checks:
277326
278327 - `node scripts/check-app.mjs` — end-to-end in headless Chrome (SwiftShader
279328 WebGPU) with the cloud cache mocked: a miss computes locally and produces
280- the .h5 (verified with h5py), a fresh page loads that .h5 as a hit, and a
281- third page asking for a longer end time warm-starts from it. The pages use
282- the `?tend=` query hook, which substitutes short test end times for the
283- UI's list. This is what CI runs.
329+ the .h5 (verified with h5py), a fresh page loads that .h5 as a hit, a
330+ third page asking for a longer end time warm-starts from it, and the sweep
331+ page loads that .h5 as one value of a dt sweep, shows the other two as
332+ gaps, and fills them with Compute missing values. The pages use the
333+ `?tend=` query hook, which substitutes short test end times for the UI's
334+ list. This is what CI runs.
284335 - `node scripts/check-live.mjs [url]` — smoke-check a deployed URL against
285- the real cache.
286-- `node scripts/screenshot.mjs out.png [light|dark] [tEnd]` — screenshot
287- after the boot-time solve.
336+ the real cache, both pages.
337+- `node scripts/screenshot.mjs out.png [light|dark] [tEnd] [index|sweep]` —
338+ screenshot after the boot-time solve.
288339
289340 Deployed to GitHub Pages by `.github/workflows/deploy.yml` on push to `main`.
290341
index.htmlmodified+4−107View file
@@ -5,112 +5,7 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1" />
66 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><circle cx=%2250%22 cy=%2250%22 r=%2245%22 fill=%22%232a5f7f%22/><circle cx=%2235%22 cy=%2238%22 r=%2211%22 fill=%22%23f5d547%22/><circle cx=%2265%22 cy=%2258%22 r=%229%22 fill=%22%23f5d547%22/><circle cx=%2248%22 cy=%2274%22 r=%227%22 fill=%22%23f5d547%22/><circle cx=%2268%22 cy=%2230%22 r=%226%22 fill=%22%23f5d547%22/></svg>" />
77 <title>turing-surface-cache — reaction-diffusion solutions from a shared cloud cache</title>
8- <style>
9- :root {
10- --bg: #ffffff;
11- --ink: #1f2328;
12- --ink-2: #57606a;
13- --line: #d0d7de;
14- --accent: #0969da;
15- --ok: #1a7f37;
16- --sphere-bg: #f4f6f8;
17- color-scheme: light dark;
18- }
19- @media (prefers-color-scheme: dark) {
20- :root {
21- --bg: #14171a;
22- --ink: #e6e9ec;
23- --ink-2: #9aa4af;
24- --line: #333b44;
25- --accent: #58a6ff;
26- --ok: #3fb950;
27- --sphere-bg: #14161c;
28- }
29- }
30- body {
31- margin: 0;
32- background: var(--bg);
33- color: var(--ink);
34- font: 15px/1.5 system-ui, -apple-system, sans-serif;
35- }
36- main { max-width: 1100px; margin: 0 auto; padding: 20px 16px 48px; }
37- h1 { font-size: 20px; margin: 0 0 2px; }
38- .sub { color: var(--ink-2); margin: 0 0 12px; font-size: 13px; }
39- .sub a { color: var(--accent); }
40- .controls {
41- display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
42- padding: 6px 0;
43- }
44- .controls[hidden] { display: none; }
45- .controls label { color: var(--ink-2); font-size: 13px; white-space: nowrap; }
46- select, input[type="password"], button {
47- font: inherit; font-size: 13px;
48- color: var(--ink); background: var(--bg);
49- border: 1px solid var(--line); border-radius: 6px;
50- padding: 4px 8px;
51- }
52- button { cursor: pointer; }
53- button:hover { border-color: var(--accent); }
54- button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; min-width: 8em; }
55- button:disabled { opacity: 0.5; cursor: default; }
56- #cachenote { font-size: 13px; color: var(--ink-2); }
57- #cachenote b { color: var(--ok); font-weight: 600; }
58- #status { margin-top: 10px; font-size: 13.5px; }
59- #status b { font-weight: 600; }
60- #panels { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 12px; }
61- .panel {
62- flex: 1 1 320px; min-width: 280px;
63- border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
64- display: flex;
65- }
66- .sphere-box { flex: 1; aspect-ratio: 1 / 1; max-height: 70vh; position: relative; }
67- .species-tag {
68- position: absolute; top: 8px; left: 10px; z-index: 2;
69- font-size: 15px; font-weight: 600; color: #fff;
70- background: rgba(0, 0, 0, 0.45);
71- padding: 1px 10px; border-radius: 12px;
72- pointer-events: none;
73- }
74- .colorbar {
75- display: flex; flex-direction: column; align-items: center; justify-content: center;
76- gap: 4px; padding: 8px 4px; background: var(--sphere-bg);
77- width: 52px; flex: none; box-sizing: border-box;
78- }
79- .colorbar canvas { border: 1px solid var(--line); border-radius: 2px; }
80- .colorbar-label { font-size: 11px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
81- .stats { margin-top: 10px; font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
82- .stats b { color: var(--ink); font-weight: 600; }
83- .cloud {
84- margin-top: 14px; border: 1px solid var(--line); border-radius: 8px;
85- padding: 8px 12px; font-size: 13px; color: var(--ink-2);
86- }
87- .cloud .controls { padding: 2px 0 0; }
88- /* The same walk, spelled as a command for a machine with no browser on
89- it. The key is masked here and real in the clipboard, so the command
90- is paste-and-go without the key ending up in a screenshot. */
91- #clicmd {
92- font: 12.5px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
93- color: var(--ink); background: var(--sphere-bg);
94- border: 1px solid var(--line); border-radius: 6px;
95- padding: 3px 8px; max-width: 100%;
96- overflow-x: auto; white-space: nowrap;
97- }
98- #clicopied { color: var(--ok); }
99- /* Troubleshooting, folded away: the failures below are undiscoverable
100- from the error alone, and the page is where people start. */
101- #clihelp { margin-top: 4px; font-size: 12.5px; }
102- #clihelp[hidden] { display: none; }
103- #clihelp summary { cursor: pointer; color: var(--accent); width: fit-content; }
104- #clihelp p { margin: 8px 0; max-width: 68ch; }
105- #clihelp code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
106- #clihelp pre {
107- font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
108- background: var(--sphere-bg); border: 1px solid var(--line);
109- border-radius: 6px; padding: 8px 10px; overflow-x: auto;
110- color: var(--ink);
111- }
112- #err { color: #b35900; white-space: pre-wrap; font-size: 13px; }
113- </style>
8+ <link rel="stylesheet" href="/src/styles.css" />
1149 </head>
11510 <body>
11611 <main>
@@ -123,7 +18,9 @@
12318 combination no one has computed shows empty surfaces until you press
12419 <b>Compute solution</b>, which runs it here (with WebGPU, via
12520 <a href="https://github.com/concept-collection/turing-surface">turing-surface</a>'s
126- spectral solver). Drag to rotate.
21+ spectral solver). Drag to rotate. Or take one parameter through its
22+ whole range on the <a id="sweeplink" href="sweep.html">parameter
23+ sweep</a> page.
12724 </p>
12825 <div class="controls">
12926 <label title="The reaction-diffusion system being solved">model
scripts/check-app.mjsmodified+103−0View file
@@ -292,6 +292,109 @@ print('h5py check ok; species', list(f.attrs['species']), '; adapter:', f.attrs.
292292 console.log(`pass 5: allencahn ${acPanels} panel, brusselator ${brPanels} panels`);
293293 await page4.close();
294294
295+ // ---- pass 6: the sweep page ----------------------------------------------
296+ // The pass-1 file is the dt = 0.05 member of a dt sweep at tend=T_END; the
297+ // other two values 404. The page must load the cached one up front, put the
298+ // knob on it, show a gap where a value is missing, and Compute missing
299+ // values must fill both gaps locally (no key, so nothing uploads).
300+ const page5 = await browser.newPage();
301+ watch(page5, 'sweep:');
302+ await interceptCache(page5, bytes, fileName);
303+ await page5.setViewport({ width: 1100, height: 900 });
304+ await page5.goto(
305+ `http://127.0.0.1:${port}/sweep.html?tend=${T_END},${T_END_LONG}#tend=${T_END}&sweep=dt`,
306+ { waitUntil: 'load' },
307+ );
308+ await page5.waitForFunction(
309+ () => /values in the cloud cache|failed/.test(
310+ document.getElementById('status')?.textContent ?? '') ||
311+ (document.getElementById('err')?.textContent?.length ?? 0) > 4,
312+ { timeout: 600_000 },
313+ );
314+ const s6 = await statusOf(page5);
315+ console.log('pass 6 status:', s6);
316+ if (!/1 of 3.*values in the cloud cache/.test(s6)) {
317+ problems.push(`sweep: unexpected status: ${s6}`);
318+ }
319+ const knob = await page5.$eval('#knob', (el) => ({ value: el.value, max: el.max }));
320+ if (knob.max !== '2' || knob.value !== '1') {
321+ problems.push(`sweep: knob at ${knob.value} of ${knob.max}, expected 1 of 2`);
322+ }
323+ const loadedTicks = await page5.$$eval('.tick.cached', (els) => els.length);
324+ if (loadedTicks !== 1) problems.push(`sweep: expected 1 loaded tick, got ${loadedTicks}`);
325+ const sweepPanels = await page5.$$eval('.sphere-box canvas', (els) => els.length);
326+ if (sweepPanels !== 2) problems.push(`sweep: expected 2 sphere canvases, got ${sweepPanels}`);
327+ const knobStats = await page5.$eval('#stats', (el) => el.textContent);
328+ if (!/showing dt = 0.05/.test(knobStats)) {
329+ problems.push(`sweep: stats not showing dt = 0.05: ${knobStats}`);
330+ }
331+ // A missing value under the knob is a gap: gray surfaces, nothing 'showing'.
332+ await page5.$eval('#knob', (el) => {
333+ el.value = '0';
334+ el.dispatchEvent(new Event('input'));
335+ });
336+ const gapStats = await page5.$eval('#stats', (el) => el.textContent);
337+ if (/showing/.test(gapStats)) problems.push(`sweep: a gap should not be 'showing': ${gapStats}`);
338+ await page5.click('#compute');
339+ await page5.waitForFunction(
340+ () => /all 3 values|failed|stopped/.test(
341+ document.getElementById('status')?.textContent ?? '') ||
342+ (document.getElementById('err')?.textContent?.length ?? 0) > 4,
343+ { timeout: 600_000 },
344+ );
345+ const s6b = await statusOf(page5);
346+ console.log('pass 6 after compute:', s6b);
347+ const e6 = await errOf(page5);
348+ if (e6) problems.push(`sweep: err: ${e6}`);
349+ if (!/all 3 values.*computed here/.test(s6b)) {
350+ problems.push(`sweep: unexpected status after compute: ${s6b}`);
351+ }
352+ const loadedTicks2 = await page5.$$eval('.tick.cached', (els) => els.length);
353+ if (loadedTicks2 !== 3) problems.push(`sweep: expected 3 loaded ticks, got ${loadedTicks2}`);
354+ if (!(await page5.$eval('#compute', (b) => b.disabled))) {
355+ problems.push('sweep: Compute missing values still enabled with nothing missing');
356+ }
357+ // The knob followed the walk to dt = 0.1; step it back across the sweep.
358+ const doneStats = await page5.$eval('#stats', (el) => el.textContent);
359+ if (!/showing dt = 0.1/.test(doneStats)) {
360+ problems.push(`sweep: stats after compute: ${doneStats}`);
361+ }
362+ await page5.$eval('#knob', (el) => {
363+ el.value = '0';
364+ el.dispatchEvent(new Event('input'));
365+ });
366+ const backStats = await page5.$eval('#stats', (el) => el.textContent);
367+ if (!/showing dt = 0.02/.test(backStats)) {
368+ problems.push(`sweep: knob to dt = 0.02: ${backStats}`);
369+ }
370+ if (!new URL(page5.url()).hash.includes('sweep=dt')) {
371+ problems.push(`sweep: sweep key not in URL: ${page5.url()}`);
372+ }
373+ // A typed value list replaces the offered one: two values here, one of them
374+ // (0.05) the file already in the "cache" and the other never computed. The
375+ // list travels in the URL with literal commas.
376+ await page5.$eval('#values', (el) => {
377+ el.value = '0.05, 0.2';
378+ el.dispatchEvent(new Event('change'));
379+ });
380+ await page5.waitForFunction(
381+ () => /1 of 2 values|failed/.test(document.getElementById('status')?.textContent ?? '') ||
382+ (document.getElementById('err')?.textContent?.length ?? 0) > 4,
383+ { timeout: 600_000 },
384+ );
385+ const customTicks = await page5.$$eval('.tick', (els) => els.map((e) => e.textContent));
386+ if (customTicks.join('|') !== '0.05|0.2') {
387+ problems.push(`sweep: custom ticks ${customTicks.join('|')}, expected 0.05|0.2`);
388+ }
389+ const customCached = await page5.$$eval('.tick.cached', (els) => els.length);
390+ if (customCached !== 1) problems.push(`sweep: expected 1 cached custom tick, got ${customCached}`);
391+ const customHash = new URL(page5.url()).hash;
392+ if (!customHash.includes('values=0.05,0.2')) {
393+ problems.push(`sweep: custom values not literal in URL: ${customHash}`);
394+ }
395+ console.log('pass 6 custom list:', await statusOf(page5));
396+ await page5.close();
397+
295398 } catch (e) {
296399 problems.push(`fatal: ${e.message}`);
297400 } finally {
scripts/check-live.mjsmodified+44−15View file
@@ -1,7 +1,8 @@
11 /**
22 * Smoke-check a deployed URL in headless Chrome: load it and wait for the
3- * boot-time solve to finish — either from the cloud cache or computed locally.
4- * Talks to the real cache. Usage: node scripts/check-live.mjs [url]
3+ * boot-time solve to finish — either from the cloud cache or computed locally
4+ * — then do the same for the sweep page beside it. Talks to the real cache.
5+ * Usage: node scripts/check-live.mjs [url]
56 */
67 import puppeteer from 'puppeteer-core';
78
@@ -14,21 +15,25 @@ const browser = await puppeteer.launch({
1415 // cap on a call is what a slow run trips over first.
1516 protocolTimeout: 900_000,
1617 });
18+const problems = [];
19+function watch(page, tag) {
20+ page.on('pageerror', (e) => problems.push(`${tag}pageerror: ${e.message}`));
21+ page.on('requestfailed', (r) => {
22+ // The cache lookup 404s by design when the selection is not cached.
23+ if (!r.url().startsWith('https://tempory.net/')) {
24+ problems.push(`${tag}request failed: ${r.url()}`);
25+ }
26+ });
27+ page.on('console', (m) => {
28+ if (m.type() === 'error' && !/GL Driver|favicon|tempory\.net/.test(m.text())) {
29+ problems.push(`${tag}console error: ${m.text()}`);
30+ }
31+ });
32+}
33+
1734 const page = await browser.newPage();
1835 await page.setViewport({ width: 1100, height: 900 });
19-const problems = [];
20-page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
21-page.on('requestfailed', (r) => {
22- // The cache lookup 404s by design when the selection is not cached.
23- if (!r.url().startsWith('https://tempory.net/')) {
24- problems.push(`request failed: ${r.url()}`);
25- }
26-});
27-page.on('console', (m) => {
28- if (m.type() === 'error' && !/GL Driver|favicon|tempory\.net/.test(m.text())) {
29- problems.push(`console error: ${m.text()}`);
30- }
31-});
36+watch(page, '');
3237
3338 try {
3439 await page.goto(url, { waitUntil: 'load', timeout: 60_000 });
@@ -49,6 +54,30 @@ try {
4954 const panels = await page.$$eval('.sphere-box canvas', (els) => els.length);
5055 console.log('sphere canvases:', panels);
5156 if (panels !== 2) problems.push(`expected 2 sphere canvases, got ${panels}`);
57+
58+ // The sweep page beside it, on the default sweep. Nothing computes here
59+ // either: the values that are cached load, the rest stay gaps.
60+ const sweepPage = await browser.newPage();
61+ await sweepPage.setViewport({ width: 1100, height: 900 });
62+ watch(sweepPage, 'sweep: ');
63+ await sweepPage.goto(new URL('sweep.html', page.url()).href, {
64+ waitUntil: 'load',
65+ timeout: 60_000,
66+ });
67+ await sweepPage.waitForFunction(
68+ () => /values (in the cloud cache|loaded)|failed/.test(
69+ document.getElementById('status')?.textContent ?? '') ||
70+ (document.getElementById('err')?.textContent?.length ?? 0) > 4,
71+ { timeout: 600_000 },
72+ );
73+ console.log('sweep status:', await sweepPage.$eval('#status', (el) => el.textContent));
74+ const sweepErr = await sweepPage.$eval('#err', (el) => el.textContent);
75+ if (sweepErr) problems.push(`sweep err: ${sweepErr}`);
76+ const ticks = await sweepPage.$$eval('.tick', (els) => els.length);
77+ const cached = await sweepPage.$$eval('.tick.cached', (els) => els.length);
78+ console.log(`sweep ticks: ${cached} of ${ticks} loaded`);
79+ if (ticks < 2) problems.push(`sweep: expected a knob with several values, got ${ticks}`);
80+
5281 if (problems.length) {
5382 console.log('PROBLEMS:');
5483 for (const p of new Set(problems)) console.log(' ' + p);
scripts/screenshot.mjsmodified+16−6View file
@@ -1,6 +1,7 @@
11 /** Screenshot the app (dist/) in headless Chrome after the boot-time solve
2- * finishes. Talks to the real cache.
3- * Usage: node scripts/screenshot.mjs out.png [light|dark] [tEnd] */
2+ * finishes. Talks to the real cache. The sweep page is screenshotted as it
3+ * loads, without computing: it shows whatever of the sweep is cached.
4+ * Usage: node scripts/screenshot.mjs out.png [light|dark] [tEnd] [index|sweep] */
45 import { createServer } from 'node:http';
56 import { readFile } from 'node:fs/promises';
67 import { extname, join } from 'node:path';
@@ -9,6 +10,10 @@ import puppeteer from 'puppeteer-core';
910 const out = process.argv[2] ?? 'demo.png';
1011 const scheme = process.argv[3] ?? 'light';
1112 const tEnd = process.argv[4] ?? '100';
13+const which = process.argv[5] ?? 'index';
14+if (which !== 'index' && which !== 'sweep') {
15+ throw new Error(`the page is 'index' or 'sweep', not '${which}'`);
16+}
1217 const DIST = new URL('../dist/', import.meta.url).pathname;
1318 const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
1419
@@ -40,12 +45,17 @@ await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: scheme }
4045 page.on('console', (m) => console.log(' [page]', m.text()));
4146 // The ?tend hook accepts any end time, listed or not, so a short test run
4247 // can be screenshotted too.
43-await page.goto(`http://127.0.0.1:${port}/index.html?tend=${tEnd}`, { waitUntil: 'load' });
48+await page.goto(`http://127.0.0.1:${port}/${which}.html?tend=${tEnd}`, { waitUntil: 'load' });
4449 await page.waitForSelector('#tend');
4550 // Terminal statuses only — "checking the cloud cache…" is transient. On a
46-// miss the page settles on empty windows; press the button so the screenshot
47-// shows a pattern either way.
48-const idle = /from the cloud cache|press Compute solution|failed/;
51+// miss the single-solution page settles on empty windows; press the button so
52+// the screenshot shows a pattern either way. The sweep page computes nothing:
53+// three or four runs is more than a screenshot is worth, and the cached part
54+// of the sweep is what it is meant to show.
55+const idle =
56+ which === 'sweep'
57+ ? /values (in the cloud cache|loaded)|failed/
58+ : /from the cloud cache|press Compute solution|failed/;
4959 await page.waitForFunction(
5060 (re) => new RegExp(re).test(document.getElementById('status')?.textContent ?? '') ||
5161 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
src/cache/selection.tsadded+223−0View file
@@ -0,0 +1,223 @@
1+/**
2+ * The selection — one value chosen from every discrete list — and its URL
3+ * form.
4+ *
5+ * The main page keeps its whole state in the URL fragment, every value
6+ * written explicitly, so a link keeps meaning the same spec even if a default
7+ * changes later. The sweep page carries the same fragment plus one extra
8+ * entry (`sweep=<param>`, which model parameter the knob runs over), and the
9+ * command line's `sweep <url>` accepts that page's URL as its argument. Three
10+ * readers of one serialization is the reason it lives here rather than in any
11+ * of them.
12+ *
13+ * Values are only accepted if they are exactly entries of the discrete lists
14+ * (src/cache/options.ts); anything else keeps the default. That is what makes
15+ * a fragment safe to hand to the cache: nothing typed or mistyped can name a
16+ * spec that the dropdowns could not.
17+ */
18+import type { Params } from '../mgpu/registry.ts';
19+import { DEFAULT_GEOMETRY_KEY } from '../geom/registry.ts';
20+import {
21+ DEFAULT_MODEL_KEY,
22+ GEOMETRY_CHOICES,
23+ LAM3,
24+ LMAX,
25+ MODEL_CHOICES,
26+ NITER,
27+ SEED_CHOICE,
28+ T_END_CHOICE,
29+ defaultChoiceParams,
30+ fmtChoice,
31+ type DiscreteChoice,
32+} from './options.ts';
33+import { APP_NAME, FORMAT_VERSION, type CacheSpec } from './spec.ts';
34+
35+export interface Selection {
36+ model: string;
37+ params: Params;
38+ geometry: string;
39+ geometryParams: Params;
40+ seed: number;
41+ tEnd: number;
42+}
43+
44+export function defaultSelection(): Selection {
45+ return {
46+ model: DEFAULT_MODEL_KEY,
47+ params: defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]),
48+ geometry: DEFAULT_GEOMETRY_KEY,
49+ geometryParams: defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]),
50+ seed: SEED_CHOICE.value,
51+ tEnd: T_END_CHOICE.value,
52+ };
53+}
54+
55+/** The fragment form: `model=…&a=…&…&geometry=…&…&seed=…&tend=…`. The keys
56+ * are the choices' own, except tEnd, which the URL spells `tend`. */
57+export function selectionToParams(sel: Selection): URLSearchParams {
58+ const p = new URLSearchParams();
59+ p.set('model', sel.model);
60+ for (const c of MODEL_CHOICES[sel.model]) p.set(c.key, fmtChoice(sel.params[c.key]));
61+ p.set('geometry', sel.geometry);
62+ for (const c of GEOMETRY_CHOICES[sel.geometry]) {
63+ p.set(c.key, fmtChoice(sel.geometryParams[c.key]));
64+ }
65+ p.set('seed', String(sel.seed));
66+ p.set('tend', fmtChoice(sel.tEnd));
67+ return p;
68+}
69+
70+/**
71+ * A fragment string from those parameters. URLSearchParams percent-encodes
72+ * commas, which turns a sweep's value list into `0.7%2C0.9%2C1.1` — readable
73+ * to a parser and to nobody else. A fragment is allowed to carry commas
74+ * literally (RFC 3986 counts them among the sub-delims), and the parser
75+ * reads an unencoded comma back as the same character, so they are put back.
76+ */
77+export const fragmentFor = (p: URLSearchParams): string =>
78+ p.toString().replace(/%2C/g, ',');
79+
80+/** Read a selection back from a fragment, defaults standing in for anything
81+ * absent or not exactly a listed value. */
82+export function readSelection(p: URLSearchParams): Selection {
83+ const sel = defaultSelection();
84+ const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
85+ const raw = p.get(name);
86+ if (raw === null) return current;
87+ const v = Number(raw);
88+ return choice.values.includes(v) ? v : current;
89+ };
90+ const m = p.get('model');
91+ if (m && MODEL_CHOICES[m]) {
92+ sel.model = m;
93+ sel.params = defaultChoiceParams(MODEL_CHOICES[m]);
94+ }
95+ const g = p.get('geometry');
96+ if (g && GEOMETRY_CHOICES[g]) {
97+ sel.geometry = g;
98+ sel.geometryParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
99+ }
100+ for (const c of MODEL_CHOICES[sel.model]) sel.params[c.key] = pick(c, sel.params[c.key]);
101+ for (const c of GEOMETRY_CHOICES[sel.geometry]) {
102+ sel.geometryParams[c.key] = pick(c, sel.geometryParams[c.key]);
103+ }
104+ sel.seed = pick(SEED_CHOICE, sel.seed);
105+ sel.tEnd = pick(T_END_CHOICE, sel.tEnd, 'tend');
106+ return sel;
107+}
108+
109+/** The one solution a selection names. */
110+export function specForSelection(sel: Selection): CacheSpec {
111+ return {
112+ app: APP_NAME,
113+ formatVersion: FORMAT_VERSION,
114+ model: sel.model,
115+ params: { ...sel.params },
116+ geometry: sel.geometry,
117+ geometryParams: { ...sel.geometryParams },
118+ lmax: LMAX,
119+ niter: NITER,
120+ lam3: LAM3,
121+ seed: sel.seed,
122+ tEnd: sel.tEnd,
123+ };
124+}
125+
126+// ---------------------------------------------------------------- sweeps
127+/**
128+ * A sweep: the same selection, with one model parameter designated as the
129+ * swept one and a list of values for it. The list defaults to the
130+ * parameter's own choices but may be an explicit list the user typed, which
131+ * is the one place the app steps outside its dropdown lists. That is safe
132+ * for the cache, since a typed value is parsed to a number once and
133+ * serialized in canonical shortest form ever after (src/cache/spec.ts), so
134+ * that it names one spec as reliably as a listed value does. It merely names
135+ * one the main page's dropdowns cannot reach. The selection's own value for
136+ * the swept parameter is the knob's current position, so a shared sweep link
137+ * opens at the same place.
138+ */
139+export interface SweepSelection {
140+ sel: Selection;
141+ /** Which of the model's parameters the knob runs over. */
142+ key: string;
143+ /** The values it runs over, in knob order. */
144+ values: number[];
145+}
146+
147+/** The swept parameter's underlying choice (its label and default list). */
148+export function sweepChoice(sweep: { sel: Selection; key: string }): DiscreteChoice {
149+ const choice = MODEL_CHOICES[sweep.sel.model].find((c) => c.key === sweep.key);
150+ if (!choice) {
151+ throw new Error(`${sweep.sel.model} has no parameter '${sweep.key}'`);
152+ }
153+ return choice;
154+}
155+
156+/**
157+ * An explicit value list, as typed: numbers separated by commas or spaces.
158+ * Anything that is not a finite number is dropped and duplicates collapse,
159+ * but the order is kept as given, an explicit list being taken at its word.
160+ */
161+export function parseValueList(text: string): number[] {
162+ return [
163+ ...new Set(
164+ text
165+ .split(/[,\s]+/)
166+ .filter((s) => s.length)
167+ .map(Number)
168+ .filter((v) => Number.isFinite(v)),
169+ ),
170+ ];
171+}
172+
173+/** The sweep page's fragment: the selection plus which parameter sweeps and
174+ * the values it runs over, every value written explicitly. */
175+export function sweepToParams(sweep: SweepSelection): URLSearchParams {
176+ const p = selectionToParams(sweep.sel);
177+ // The swept parameter's own entry is the knob position, which for a custom
178+ // list may be a value selectionToParams could not have written.
179+ p.set(sweep.key, fmtChoice(sweep.sel.params[sweep.key]));
180+ p.set('sweep', sweep.key);
181+ p.set('values', sweep.values.map(fmtChoice).join(','));
182+ return p;
183+}
184+
185+/**
186+ * Read a sweep from a fragment. Null when the fragment names no swept
187+ * parameter (or one the model does not have): the page falls back to its
188+ * default, the command line says the URL is not a sweep link. A missing or
189+ * empty `values` entry means the parameter's own list.
190+ */
191+export function readSweep(p: URLSearchParams): SweepSelection | null {
192+ const sel = readSelection(p);
193+ const key = p.get('sweep');
194+ if (!key || !MODEL_CHOICES[sel.model].some((c) => c.key === key)) return null;
195+ const sweep: SweepSelection = { sel, key, values: [] };
196+ const listed = p.get('values');
197+ const parsed = listed === null ? [] : parseValueList(listed);
198+ sweep.values = parsed.length ? parsed : [...sweepChoice(sweep).values];
199+ // The knob position: readSelection validated the swept entry against the
200+ // dropdown list, which a custom value is deliberately not on, so it is
201+ // read again against the sweep's own list.
202+ const raw = p.get(key);
203+ const v = raw === null ? NaN : Number(raw);
204+ sel.params[key] = sweep.values.includes(v)
205+ ? v
206+ : sweep.values.includes(sel.params[key])
207+ ? sel.params[key]
208+ : sweep.values[0];
209+ return sweep;
210+}
211+
212+/** The sweep's solutions, one per value, in knob order. */
213+export function specsForSweep(
214+ sweep: SweepSelection,
215+): { value: number; spec: CacheSpec }[] {
216+ return sweep.values.map((value) => ({
217+ value,
218+ spec: specForSelection({
219+ ...sweep.sel,
220+ params: { ...sweep.sel.params, [sweep.key]: value },
221+ }),
222+ }));
223+}
src/cli/fill.tsmodified+125−21View file
@@ -19,7 +19,9 @@ import {
1919 type DiscreteChoice,
2020 } from '../cache/options.ts';
2121 import { autoOrder, specForTarget, type AutoTarget } from '../cache/autoWalk.ts';
22+import { readSweep, specsForSweep, sweepChoice } from '../cache/selection.ts';
2223 import { headCached, lookupFor, verifyApiKey } from '../cache/client.ts';
24+import type { CacheSpec } from '../cache/spec.ts';
2325 import { SolverSession } from '../cache/solver.ts';
2426 import { fillWalk } from '../cache/fillWalk.ts';
2527 import { setScratchDir } from '../cache/h5file.ts';
@@ -33,7 +35,11 @@ const HELP = `turing-surface-cache — fill the shared cache of Turing patterns
3335 Usage
3436 fill [options] work through the parameter space, contributing what is
3537 missing, until stopped (ctrl-C)
38+ sweep '<url>' fill one parameter sweep: the argument is the sweep
39+ page's URL, whose fragment says which parameter runs
40+ over its values and what everything else is fixed to
3641 fill --dry-run [N] show the first N targets and whether they are cached
42+ (works for sweep too)
3743 login save an upload key for later runs
3844 --help
3945
@@ -41,7 +47,7 @@ Options
4147 --key <key> upload key; otherwise $${KEY_ENV}, otherwise the saved key
4248 --limit <n> stop after n solutions have been computed
4349 --model <key> only targets of one model (schnakenberg, brusselator,
44- allencahn)
50+ allencahn); the full walk only — a sweep names its model
4551 --tend <list> replace the end-time list, e.g. --tend 5,10 (for testing:
4652 a short run hashes to its own honest cache entry)
4753
@@ -53,17 +59,23 @@ URL it was given, so a newer build comes from the command the page offers,
5359 whose URL carries the build it belongs to.`;
5460
5561 interface Options {
56- command: 'fill' | 'login' | 'help';
62+ command: 'fill' | 'sweep' | 'login' | 'help';
5763 key?: string;
5864 limit: number;
5965 model?: string;
6066 dryRun: number;
67+ /** The sweep page's URL (the `sweep` command's argument). */
68+ sweepUrl?: string;
69+ /** --tend was given, so a sweep URL's own ?tend must not override it. */
70+ tendGiven: boolean;
6171 }
6272
6373 function parseArgs(argv: string[]): Options {
64- const opts: Options = { command: 'fill', limit: Infinity, dryRun: 0 };
74+ const opts: Options = { command: 'fill', limit: Infinity, dryRun: 0, tendGiven: false };
6575 const rest = [...argv];
66- if (rest[0] === 'fill' || rest[0] === 'login') opts.command = rest.shift() as 'fill' | 'login';
76+ if (rest[0] === 'fill' || rest[0] === 'login' || rest[0] === 'sweep') {
77+ opts.command = rest.shift() as Options['command'];
78+ }
6779 /** A count option whose value may be left off (--dry-run, --dry-run 40). */
6880 const count = (fallback: number): number => {
6981 const next = rest[0];
@@ -77,10 +89,21 @@ function parseArgs(argv: string[]): Options {
7789 else if (arg === '--limit') opts.limit = Number(rest.shift());
7890 else if (arg === '--model') opts.model = rest.shift();
7991 else if (arg === '--dry-run') opts.dryRun = count(20);
80- else if (arg === '--tend') setEndTimes(rest.shift());
81- else throw new Error(`unknown option ${arg}`);
92+ else if (arg === '--tend') {
93+ setEndTimes(rest.shift());
94+ opts.tendGiven = true;
95+ }
96+ else if (opts.command === 'sweep' && !opts.sweepUrl && !arg.startsWith('-')) {
97+ opts.sweepUrl = arg;
98+ } else throw new Error(`unknown option ${arg}`);
8299 }
83100 if (opts.model && !mModelByKey(opts.model)) throw new Error(`unknown model ${opts.model}`);
101+ if (opts.model && opts.command === 'sweep') {
102+ throw new Error('--model applies to the full walk only; a sweep link names its model');
103+ }
104+ if (opts.command === 'sweep' && !opts.sweepUrl) {
105+ throw new Error("sweep wants the sweep page's URL (copy the command from that page)");
106+ }
84107 if (!(opts.limit > 0)) throw new Error('--limit wants a positive number');
85108 return opts;
86109 }
@@ -150,6 +173,86 @@ const doneLine = (run: RunSummary): string =>
150173 `computed in ${duration(run.seconds)}` +
151174 (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '');
152175
176+// ---------------------------------------------------------------- plans
177+/**
178+ * What to work through: the full walk, or one sweep. The run loop, the
179+ * progress lines and the outcome reporting are identical either way; a plan
180+ * is only which targets exist, which spec each names, and how to say so.
181+ */
182+interface Plan {
183+ targets: AutoTarget[];
184+ specFor(target: AutoTarget): CacheSpec;
185+ label(target: AutoTarget): string;
186+ /** Printed once, under the header. */
187+ intro: string[];
188+}
189+
190+function autoPlan(opts: Options): Plan {
191+ const targets = autoOrder().filter((t) => !opts.model || t.model === opts.model);
192+ return {
193+ targets,
194+ specFor: specForTarget,
195+ label: describe,
196+ intro: [`${targets.length.toLocaleString()} targets, nearest the defaults first.`],
197+ };
198+}
199+
200+/**
201+ * A sweep, read from the sweep page's own URL: one serialization
202+ * (src/cache/selection.ts) shared with the page, so the copied command and
203+ * the page it came from always mean the same solutions. Only the fragment is
204+ * consulted — plus the page's ?tend test hook, honored the way the pages
205+ * honor it, so a command copied from a test page still names what that page
206+ * showed.
207+ */
208+function sweepPlan(url: string, tendGiven: boolean): Plan {
209+ const query = url.match(/\?([^#]*)/)?.[1];
210+ const tend = query ? new URLSearchParams(query).get('tend') : null;
211+ if (tend && !tendGiven) setEndTimes(tend);
212+ const fragment = url.includes('#') ? url.slice(url.indexOf('#') + 1) : url;
213+ const sweep = readSweep(new URLSearchParams(fragment));
214+ if (!sweep) {
215+ throw new Error(
216+ 'that is not a sweep link: its fragment must carry the selection and ' +
217+ 'sweep=<param>. Copy the command from the sweep page.',
218+ );
219+ }
220+ const { sel, key, values } = sweep;
221+ const choice = sweepChoice(sweep);
222+ const specs = specsForSweep(sweep);
223+ const specByValue = new Map(specs.map(({ value, spec }) => [value, spec]));
224+ const fixed = paramList(
225+ Object.fromEntries(Object.entries(sel.params).filter(([k]) => k !== key)),
226+ MODEL_CHOICES[sel.model],
227+ );
228+ const geomChoices = GEOMETRY_CHOICES[sel.geometry] ?? [];
229+ const geom = geomChoices.length
230+ ? `${sel.geometry} ${paramList(sel.geometryParams, geomChoices)}`
231+ : sel.geometry;
232+ return {
233+ targets: specs.map(
234+ ({ spec }): AutoTarget => ({
235+ model: spec.model,
236+ params: { ...spec.params },
237+ geometry: spec.geometry,
238+ geometryParams: { ...spec.geometryParams },
239+ distance: 0,
240+ }),
241+ ),
242+ specFor: (target) => specByValue.get(target.params[key])!,
243+ label: (target) => `${key} = ${fmtChoice(target.params[key])}`,
244+ intro: [
245+ `sweep: ${sel.model}, ${key} over ${plural(values.length, 'value')} ` +
246+ `(${values.map(fmtChoice).join(', ')})` +
247+ (values.length === choice.values.length &&
248+ values.every((v, i) => v === choice.values[i])
249+ ? ''
250+ : ' — a custom list, so the auto-fill walk will not have filled it'),
251+ `fixed: ${fixed} · ${geom} · seed ${sel.seed} · t = ${fmtChoice(sel.tEnd)}`,
252+ ],
253+ };
254+}
255+
153256 // ---------------------------------------------------------------- commands
154257 async function login(): Promise<void> {
155258 const key = await promptSecret('upload API key: ');
@@ -165,10 +268,10 @@ async function login(): Promise<void> {
165268 say(`key saved to ${await saveKey(key)}`);
166269 }
167270
168-async function dryRun(opts: Options, targets: AutoTarget[]): Promise<void> {
169- const shown = targets.slice(0, opts.dryRun);
170- say(`the first ${plural(shown.length, 'target')} of ${targets.length.toLocaleString()}, ` +
171- `nearest the defaults first:`);
271+async function dryRun(opts: Options, plan: Plan): Promise<void> {
272+ const shown = plan.targets.slice(0, opts.dryRun);
273+ for (const line of plan.intro) say(line);
274+ say(`the first ${plural(shown.length, 'target')} of ${plan.targets.length.toLocaleString()}:`);
172275 say();
173276 let cached = 0;
174277 // A handful at a time: a HEAD apiece, and the answers are wanted in order.
@@ -176,13 +279,13 @@ async function dryRun(opts: Options, targets: AutoTarget[]): Promise<void> {
176279 for (let i = 0; i < shown.length; i += 8) {
177280 const batch = shown.slice(i, i + 8);
178281 const present = await Promise.all(
179- batch.map(async (t) => (await headCached(await lookupFor(specForTarget(t)))) === true),
282+ batch.map(async (t) => (await headCached(await lookupFor(plan.specFor(t)))) === true),
180283 );
181284 present.forEach((isThere, k) => {
182285 if (isThere) cached++;
183286 say(
184287 ` [${String(i + k + 1).padStart(width)}] ${isThere ? 'cached ' : 'missing'} ` +
185- describe(batch[k]),
288+ plan.label(batch[k]),
186289 );
187290 });
188291 }
@@ -191,7 +294,7 @@ async function dryRun(opts: Options, targets: AutoTarget[]): Promise<void> {
191294 `${shown.length - cached}.`);
192295 }
193296
194-async function fill(opts: Options, targets: AutoTarget[], apiKey: string): Promise<void> {
297+async function fill(opts: Options, plan: Plan, apiKey: string): Promise<void> {
195298 const runtime = await installWebGpu();
196299 const device = await requestShtDevice().catch((e: unknown) => {
197300 throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
@@ -207,8 +310,8 @@ async function fill(opts: Options, targets: AutoTarget[], apiKey: string): Promi
207310 say(' machine has a GPU and its driver, or stop now.');
208311 }
209312 say('');
210- say(`${targets.length.toLocaleString()} targets, nearest the defaults first; ` +
211- `ctrl-C stops after the current run.`);
313+ for (const line of plan.intro) say(line);
314+ say('ctrl-C stops after the current run.');
212315 say('');
213316
214317 const solver = new SolverSession(device, 1, {
@@ -229,17 +332,17 @@ async function fill(opts: Options, targets: AutoTarget[], apiKey: string): Promi
229332 let slowNoted = false;
230333 let lastLog = 0;
231334 const counts = await fillWalk({
232- targets,
335+ targets: plan.targets,
233336 solver,
234337 adapter,
235338 runtime,
236339 apiKey: () => apiKey,
237340 beforeTarget: (target) => {
238341 index++;
239- return specForTarget(target);
342+ return plan.specFor(target);
240343 },
241344 events: {
242- onTarget: (target) => say(`[${index}] ${describe(target)}`),
345+ onTarget: (target) => say(`[${index}] ${plan.label(target)}`),
243346 onCached: () => say(' already cached'),
244347 onComputing: (_target, spec) =>
245348 say(` computing to t = ${fmtChoice(spec.tEnd)} ` +
@@ -319,10 +422,11 @@ async function main(): Promise<void> {
319422 await login();
320423 return;
321424 }
322- const targets = autoOrder().filter((t) => !opts.model || t.model === opts.model);
425+ const plan =
426+ opts.command === 'sweep' ? sweepPlan(opts.sweepUrl!, opts.tendGiven) : autoPlan(opts);
323427 say('turing-surface-cache — the shared cache of Turing patterns on curved surfaces');
324428 if (opts.dryRun) {
325- await dryRun(opts, targets);
429+ await dryRun(opts, plan);
326430 return;
327431 }
328432 const apiKey = await resolveKey(opts.key);
@@ -333,7 +437,7 @@ async function main(): Promise<void> {
333437 `or save one for later runs with \`login\` (kept in ${keyPath()}).`,
334438 );
335439 }
336- await fill(opts, targets, apiKey);
440+ await fill(opts, plan, apiKey);
337441 }
338442
339443 main().catch((e: unknown) => {
src/main.tsmodified+29−42View file
@@ -45,13 +45,19 @@ import {
4545 T_END_CHOICE,
4646 LMAX,
4747 NITER,
48- LAM3,
4948 AUTO_DT,
5049 defaultChoiceParams,
5150 fmtChoice,
5251 type DiscreteChoice,
5352 } from './cache/options.ts';
54-import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
53+import { stepsFor, type CacheSpec, APP_NAME } from './cache/spec.ts';
54+import {
55+ fragmentFor,
56+ readSelection,
57+ selectionToParams,
58+ specForSelection,
59+ type Selection,
60+} from './cache/selection.ts';
5561 import { lookupFor, fetchCached, headCached, type CacheLookup } from './cache/client.ts';
5662 import { autoOrder, specForTarget, type AutoTarget } from './cache/autoWalk.ts';
5763 import { decodeCacheFile } from './cache/h5file.ts';
@@ -87,6 +93,7 @@ const elCliCmd = $('clicmd');
8793 const elCliCopy = $<HTMLButtonElement>('clicopy');
8894 const elCliCopied = $('clicopied');
8995 const elCliHelp = $('clihelp');
96+const elSweepLink = $<HTMLAnchorElement>('sweeplink');
9097 const elErr = $('err');
9198
9299 /**
@@ -174,68 +181,48 @@ let downloadUrl: string | null = null;
174181 const nextFrame = () => new Promise<number>(requestAnimationFrame);
175182
176183 // ---------------------------------------------------------------- spec
177-function currentSpec(): CacheSpec {
184+function currentSelection(): Selection {
178185 return {
179- app: APP_NAME,
180- formatVersion: FORMAT_VERSION,
181186 model: model.key,
182187 params: { ...params },
183188 geometry: geometry.key,
184189 geometryParams: { ...geomParams },
185- lmax: LMAX,
186- niter: NITER,
187- lam3: LAM3,
188190 seed,
189191 tEnd,
190192 };
191193 }
192194
195+function currentSpec(): CacheSpec {
196+ return specForSelection(currentSelection());
197+}
198+
193199 // ---------------------------------------------------------------- URL state
194200 /**
195201 * The selection lives in the URL fragment, every value written explicitly
196202 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
197203 * keeps meaning the same spec even if a default changes later. The fragment
198- * is chosen over the query string to leave `?tend` to the test hook. Values
199- * are only accepted if they are exactly entries of the discrete lists;
200- * anything else keeps the default.
204+ * is chosen over the query string to leave `?tend` to the test hook. The
205+ * serialization is shared with the sweep page and the command line
206+ * (src/cache/selection.ts).
201207 */
202208 function readUrlState(): void {
203209 const hash = location.hash.replace(/^#/, '');
204210 if (!hash) return;
205- const p = new URLSearchParams(hash);
206- // `name` is the key as it appears in the URL; it defaults to the choice's
207- // own key but is passed explicitly where the two differ (tEnd vs tend).
208- const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
209- const raw = p.get(name);
210- if (raw === null) return current;
211- const v = Number(raw);
212- return choice.values.includes(v) ? v : current;
213- };
214- const m = p.get('model');
215- if (m && mModelByKey(m) && MODEL_CHOICES[m]) {
216- model = mModelByKey(m)!;
217- params = defaultChoiceParams(MODEL_CHOICES[m]);
218- }
219- const g = p.get('geometry');
220- if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
221- geometry = mGeometryByKey(g)!;
222- geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
223- }
224- for (const c of MODEL_CHOICES[model.key]) params[c.key] = pick(c, params[c.key]);
225- for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
226- seed = pick(SEED_CHOICE, seed);
227- tEnd = pick(T_END_CHOICE, tEnd, 'tend');
211+ const sel = readSelection(new URLSearchParams(hash));
212+ model = mModelByKey(sel.model)!;
213+ params = sel.params;
214+ geometry = mGeometryByKey(sel.geometry)!;
215+ geomParams = sel.geometryParams;
216+ seed = sel.seed;
217+ tEnd = sel.tEnd;
228218 }
229219
230220 function writeUrlState(): void {
231- const p = new URLSearchParams();
232- p.set('model', model.key);
233- for (const c of MODEL_CHOICES[model.key]) p.set(c.key, fmtChoice(params[c.key]));
234- p.set('geometry', geometry.key);
235- for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
236- p.set('seed', String(seed));
237- p.set('tend', fmtChoice(tEnd));
238- history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
221+ const p = fragmentFor(selectionToParams(currentSelection()));
222+ history.replaceState(null, '', `${location.pathname}${location.search}#${p}`);
223+ // The sweep page opens on the same selection (the search part keeps the
224+ // ?tend test hook alive across the two pages).
225+ elSweepLink.href = `sweep.html${location.search}#${p}`;
239226 }
240227
241228 // ---------------------------------------------------------------- controls
src/styles.cssadded+131−0View file
@@ -0,0 +1,131 @@
1+/* One stylesheet for both pages (index.html and sweep.html), linked from
2+ each head so the two stay one design rather than drifting copies. */
3+:root {
4+ --bg: #ffffff;
5+ --ink: #1f2328;
6+ --ink-2: #57606a;
7+ --line: #d0d7de;
8+ --accent: #0969da;
9+ --ok: #1a7f37;
10+ --sphere-bg: #f4f6f8;
11+ color-scheme: light dark;
12+}
13+@media (prefers-color-scheme: dark) {
14+ :root {
15+ --bg: #14171a;
16+ --ink: #e6e9ec;
17+ --ink-2: #9aa4af;
18+ --line: #333b44;
19+ --accent: #58a6ff;
20+ --ok: #3fb950;
21+ --sphere-bg: #14161c;
22+ }
23+}
24+body {
25+ margin: 0;
26+ background: var(--bg);
27+ color: var(--ink);
28+ font: 15px/1.5 system-ui, -apple-system, sans-serif;
29+}
30+main { max-width: 1100px; margin: 0 auto; padding: 20px 16px 48px; }
31+h1 { font-size: 20px; margin: 0 0 2px; }
32+.sub { color: var(--ink-2); margin: 0 0 12px; font-size: 13px; }
33+.sub a { color: var(--accent); }
34+.controls {
35+ display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
36+ padding: 6px 0;
37+}
38+.controls[hidden] { display: none; }
39+.controls label { color: var(--ink-2); font-size: 13px; white-space: nowrap; }
40+select, input[type="password"], input[type="text"], button {
41+ font: inherit; font-size: 13px;
42+ color: var(--ink); background: var(--bg);
43+ border: 1px solid var(--line); border-radius: 6px;
44+ padding: 4px 8px;
45+}
46+button { cursor: pointer; }
47+button:hover { border-color: var(--accent); }
48+button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; min-width: 8em; }
49+button:disabled { opacity: 0.5; cursor: default; }
50+#cachenote { font-size: 13px; color: var(--ink-2); }
51+#cachenote b { color: var(--ok); font-weight: 600; }
52+#status { margin-top: 10px; font-size: 13.5px; }
53+#status b { font-weight: 600; }
54+#panels { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 12px; }
55+.panel {
56+ flex: 1 1 320px; min-width: 280px;
57+ border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
58+ display: flex;
59+}
60+.sphere-box { flex: 1; aspect-ratio: 1 / 1; max-height: 70vh; position: relative; }
61+.species-tag {
62+ position: absolute; top: 8px; left: 10px; z-index: 2;
63+ font-size: 15px; font-weight: 600; color: #fff;
64+ background: rgba(0, 0, 0, 0.45);
65+ padding: 1px 10px; border-radius: 12px;
66+ pointer-events: none;
67+}
68+.colorbar {
69+ display: flex; flex-direction: column; align-items: center; justify-content: center;
70+ gap: 4px; padding: 8px 4px; background: var(--sphere-bg);
71+ width: 52px; flex: none; box-sizing: border-box;
72+}
73+.colorbar canvas { border: 1px solid var(--line); border-radius: 2px; }
74+.colorbar-label { font-size: 11px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
75+.stats { margin-top: 10px; font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
76+.stats b { color: var(--ink); font-weight: 600; }
77+.cloud {
78+ margin-top: 14px; border: 1px solid var(--line); border-radius: 8px;
79+ padding: 8px 12px; font-size: 13px; color: var(--ink-2);
80+}
81+.cloud .controls { padding: 2px 0 0; }
82+/* The same walk, spelled as a command for a machine with no browser on
83+ it. The key is masked here and real in the clipboard, so the command
84+ is paste-and-go without the key ending up in a screenshot. */
85+#clicmd {
86+ font: 12.5px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
87+ color: var(--ink); background: var(--sphere-bg);
88+ border: 1px solid var(--line); border-radius: 6px;
89+ padding: 3px 8px; max-width: 100%;
90+ overflow-x: auto; white-space: nowrap;
91+}
92+#clicopied { color: var(--ok); }
93+/* Troubleshooting, folded away: the failures below are undiscoverable
94+ from the error alone, and the page is where people start. */
95+#clihelp { margin-top: 4px; font-size: 12.5px; }
96+#clihelp[hidden] { display: none; }
97+#clihelp summary { cursor: pointer; color: var(--accent); width: fit-content; }
98+#clihelp p { margin: 8px 0; max-width: 68ch; }
99+#clihelp code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
100+#clihelp pre {
101+ font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
102+ background: var(--sphere-bg); border: 1px solid var(--line);
103+ border-radius: 6px; padding: 8px 10px; overflow-x: auto;
104+ color: var(--ink);
105+}
106+#err { color: #b35900; white-space: pre-wrap; font-size: 13px; }
107+
108+/* The sweep page's knob: one model parameter running over its listed values,
109+ everything else fixed. The tick under each value says whether that value is
110+ in the cache — dimmed ones are not there yet. */
111+.sweepbar {
112+ display: flex; align-items: flex-start; gap: 14px;
113+ margin-top: 10px; flex-wrap: wrap;
114+}
115+.knobtrack { flex: 0 1 420px; min-width: 240px; display: flex; flex-direction: column; }
116+#knob { width: 100%; margin: 2px 0 0; accent-color: var(--accent); }
117+.ticks { display: flex; justify-content: space-between; }
118+.tick {
119+ border: none; background: none; padding: 2px 0; cursor: pointer;
120+ font-family: inherit; font-size: 12.5px;
121+ font-variant-numeric: tabular-nums;
122+ color: var(--ink-2); opacity: 0.55;
123+}
124+.tick.cached { opacity: 1; }
125+.tick.current { color: var(--accent); font-weight: 600; opacity: 1; }
126+#knobval {
127+ font-size: 15px; font-weight: 600; padding-top: 14px;
128+ font-variant-numeric: tabular-nums; white-space: nowrap;
129+}
130+#values { font-variant-numeric: tabular-nums; }
131+#valuesnote { font-size: 12.5px; color: var(--ink-2); }
src/sweep.tsadded+1111−0View file
@@ -0,0 +1,1111 @@
1+/**
2+ * The parameter sweep page: one model parameter runs over its whole list of
3+ * values while every other choice stays fixed, and a knob steps the display
4+ * through the range.
5+ *
6+ * The cache is what makes the knob instant. Each value of the swept parameter
7+ * names one solution (the same specs the main page uses, so the two pages and
8+ * the walk all share one cache), and on any selection change the page fetches
9+ * all of them at once — a sweep is three to five files of ~90 KB. Each one is
10+ * decoded and synthesized to the render grid immediately, so moving the knob
11+ * afterwards touches no network and no solver: it recolors the mesh from
12+ * values already in memory. One color scale is computed over the whole sweep
13+ * and held fixed, so what changes under the knob is the pattern and not the
14+ * palette.
15+ *
16+ * Values nobody has computed show as gaps. Compute missing values runs them
17+ * here, one after another through the ordinary local run
18+ * (src/cache/runSpec.ts — warm start, background uploads with a key,
19+ * divergence guard), and the copyable command at the bottom hands the same
20+ * sweep to a machine with no browser on it (src/cli/fill.ts `sweep`). Both
21+ * read the sweep from this page's URL fragment, which carries the whole
22+ * selection plus which parameter is swept (src/cache/selection.ts).
23+ */
24+import { requestShtDevice, describeAdapter } from './sht/sht.ts';
25+import type { ModelSession } from './mgpu/session.ts';
26+import { mModels, mModelByKey, type MModel } from './mgpu/registry.ts';
27+import { formatFailure } from './mgpu/errors.ts';
28+import { mGeometries, mGeometryByKey } from './geom/registry.ts';
29+import {
30+ buildTopology,
31+ fillPositions,
32+ fillFieldValues,
33+ fillColors,
34+ type SphereMeshTopology,
35+} from './render/sphereMesh.ts';
36+import { SphereScene } from './render/SphereScene.ts';
37+import { Colorbar, floorRange } from './render/colorbar.ts';
38+import { colormaps } from './render/colormaps.ts';
39+import {
40+ MODEL_CHOICES,
41+ GEOMETRY_CHOICES,
42+ SEED_CHOICE,
43+ T_END_CHOICE,
44+ LMAX,
45+ NITER,
46+ defaultChoiceParams,
47+ fmtChoice,
48+ type DiscreteChoice,
49+} from './cache/options.ts';
50+import { APP_NAME, stepsFor, type CacheSpec } from './cache/spec.ts';
51+import { lookupFor, fetchCached } from './cache/client.ts';
52+import { decodeCacheFile } from './cache/h5file.ts';
53+import { SolverSession } from './cache/solver.ts';
54+import { type RunEvents, type RunSummary } from './cache/runSpec.ts';
55+import { fillWalk } from './cache/fillWalk.ts';
56+import type { AutoTarget } from './cache/autoWalk.ts';
57+import {
58+ defaultSelection,
59+ fragmentFor,
60+ parseValueList,
61+ readSelection,
62+ readSweep,
63+ selectionToParams,
64+ specForSelection,
65+ sweepChoice,
66+ sweepToParams,
67+ specsForSweep,
68+ type Selection,
69+ type SweepSelection,
70+} from './cache/selection.ts';
71+
72+const $ = <T extends HTMLElement>(id: string): T =>
73+ document.getElementById(id) as T;
74+
75+const elModel = $<HTMLSelectElement>('model');
76+const elSweepOver = $<HTMLSelectElement>('sweepover');
77+const elParams = $('params');
78+const elGeometry = $<HTMLSelectElement>('geometry');
79+const elGeomParams = $('geomparams');
80+const elSeed = $<HTMLSelectElement>('seed');
81+const elTend = $<HTMLSelectElement>('tend');
82+const elCompute = $<HTMLButtonElement>('compute');
83+const elStop = $<HTMLButtonElement>('stop');
84+const elReset = $<HTMLButtonElement>('reset');
85+const elValues = $<HTMLInputElement>('values');
86+const elValuesNote = $('valuesnote');
87+const elKnob = $<HTMLInputElement>('knob');
88+const elTicks = $('ticks');
89+const elKnobVal = $('knobval');
90+const elStatus = $('status');
91+const elPanels = $('panels');
92+const elResetView = $<HTMLButtonElement>('resetview');
93+const elStats = $('stats');
94+const elApiKey = $<HTMLInputElement>('apikey');
95+const elUploadNote = $('uploadnote');
96+const elCliBar = $('clibar');
97+const elCliCmd = $('clicmd');
98+const elCliCopy = $<HTMLButtonElement>('clicopy');
99+const elCliCopied = $('clicopied');
100+const elCliNote = $('clinote');
101+const elBackLink = $<HTMLAnchorElement>('backlink');
102+const elErr = $('err');
103+
104+/** The main page's ?tend test hook, honored here too (src/main.ts). */
105+{
106+ const param = new URLSearchParams(location.search).get('tend');
107+ if (param) {
108+ const values = param
109+ .split(',')
110+ .map(Number)
111+ .filter((v) => Number.isFinite(v) && v > 0);
112+ if (values.length) {
113+ T_END_CHOICE.values = values;
114+ T_END_CHOICE.value = values[0];
115+ }
116+ }
117+}
118+
119+/** Shared with the main page: one key entered once covers both. */
120+const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
121+const COLORMAP = colormaps.viridis;
122+const OVERSAMPLE = 2;
123+const RENDER_EVERY_MS = 250;
124+const STATUS_EVERY_MS = 200;
125+
126+// ---------------------------------------------------------------- state
127+let sel: Selection = defaultSelection();
128+/** Which of the model's parameters the knob runs over. */
129+let sweepKey = MODEL_CHOICES[sel.model][0].key;
130+/** The values it runs over: the parameter's own list until the values box
131+ * says otherwise (src/cache/selection.ts). */
132+let sweepValues: number[] = [...MODEL_CHOICES[sel.model][0].values];
133+
134+/** One value of the sweep: its solution, and where it stands. `fields` is
135+ * the decoded final state synthesized onto the render mesh, one array per
136+ * species — everything the knob needs, with the session out of the loop.
137+ * 'cached' came from the cloud; 'computed' was run here this session (and
138+ * is in the cloud too only if a key was present for the uploads). */
139+interface SweepEntry {
140+ value: number;
141+ spec: CacheSpec;
142+ status:
143+ | 'loading'
144+ | 'cached'
145+ | 'computed'
146+ | 'missing'
147+ | 'failed'
148+ /** No run can reach the end time from this value (a dt that does not
149+ * divide it), so there is nothing to fetch or compute. */
150+ | 'unusable'
151+ | 'refetch';
152+ fields: Float32Array[] | null;
153+}
154+let entries: SweepEntry[] = [];
155+
156+let device: GPUDevice | null = null;
157+let solver: SolverSession | null = null;
158+let adapterName = '';
159+function sess(): ModelSession | null {
160+ return solver?.session ?? null;
161+}
162+const curModel = (): MModel => mModelByKey(sel.model)!;
163+const curChoice = (): DiscreteChoice => sweepChoice({ sel, key: sweepKey });
164+const curSweep = (): SweepSelection => ({ sel, key: sweepKey, values: sweepValues });
165+
166+let generation = 0;
167+let busy = false;
168+let stopRequested = false;
169+
170+// view
171+let topo: SphereMeshTopology | null = null;
172+let scenes: SphereScene[] = [];
173+let colorbars: Colorbar[] = [];
174+let colorbarEls: HTMLElement[] = [];
175+let colorBufs: Float32Array[] = [];
176+/** Scratch per-vertex values for the live view while a value is computing. */
177+let liveBufs: Float32Array[] = [];
178+/** Smoothed display ranges for that live view (main.ts does the same). */
179+let liveRanges: { lo: number; hi: number }[] = [];
180+/** The sweep-wide color range per species, fixed while the knob moves. */
181+let ranges: { lo: number; hi: number }[] = [];
182+let resizeObs: ResizeObserver | null = null;
183+
184+const nextFrame = () => new Promise<number>(requestAnimationFrame);
185+
186+// ---------------------------------------------------------------- URL state
187+{
188+ const hash = location.hash.replace(/^#/, '');
189+ if (hash) {
190+ const p = new URLSearchParams(hash);
191+ const sweep = readSweep(p);
192+ if (sweep) {
193+ sel = sweep.sel;
194+ sweepKey = sweep.key;
195+ sweepValues = sweep.values;
196+ } else {
197+ // A main-page link: same selection, sweeping the first parameter over
198+ // its own list.
199+ sel = readSelection(p);
200+ sweepKey = MODEL_CHOICES[sel.model][0].key;
201+ sweepValues = [...MODEL_CHOICES[sel.model][0].values];
202+ }
203+ }
204+}
205+
206+function writeUrlState(): void {
207+ const p = fragmentFor(sweepToParams(curSweep()));
208+ history.replaceState(null, '', `${location.pathname}${location.search}#${p}`);
209+ // Back to the main page on the same selection (the knob's value travels as
210+ // the swept parameter's value; the search part keeps the ?tend test hook).
211+ elBackLink.href = `index.html${location.search}#${fragmentFor(selectionToParams(sel))}`;
212+ updateCliCommand();
213+}
214+
215+// ---------------------------------------------------------------- controls
216+function makeSelect(
217+ choice: DiscreteChoice,
218+ get: () => number,
219+ set: (v: number) => void,
220+): HTMLLabelElement {
221+ const label = document.createElement('label');
222+ label.textContent = `${choice.label} `;
223+ const select = document.createElement('select');
224+ for (const v of choice.values) {
225+ const opt = document.createElement('option');
226+ opt.value = String(v);
227+ opt.textContent = fmtChoice(v);
228+ select.append(opt);
229+ }
230+ select.value = String(get());
231+ select.addEventListener('change', () => {
232+ set(Number(select.value));
233+ onSelectionChange();
234+ });
235+ label.append(select);
236+ return label;
237+}
238+
239+/** The fixed parameters: every model parameter except the swept one, which
240+ * lives on the knob instead. */
241+function buildParamControls(): void {
242+ elParams.replaceChildren();
243+ for (const choice of MODEL_CHOICES[sel.model]) {
244+ if (choice.key === sweepKey) continue;
245+ elParams.append(
246+ makeSelect(choice, () => sel.params[choice.key], (v) => (sel.params[choice.key] = v)),
247+ );
248+ }
249+}
250+
251+function buildSweepOverControl(): void {
252+ elSweepOver.replaceChildren();
253+ for (const choice of MODEL_CHOICES[sel.model]) {
254+ const opt = document.createElement('option');
255+ opt.value = choice.key;
256+ opt.textContent = choice.label;
257+ elSweepOver.append(opt);
258+ }
259+ elSweepOver.value = sweepKey;
260+}
261+
262+/**
263+ * The values box: what the knob runs over, written out. It starts as the
264+ * parameter's own list, which is what the main page's dropdown offers and
265+ * what the auto-fill walk surveys, but anything may be typed in its place.
266+ * This is the one control in the app that is not a choice from a list. A
267+ * value off the list still names one exact solution and one exact cache
268+ * entry, since the spec is hashed from the number rather than from the list
269+ * position, so a sweep over typed values is cached and shared like any
270+ * other. Of course, the walk only fills the listed combinations, so such a
271+ * sweep will not already be there.
272+ */
273+function showValues(): void {
274+ elValues.value = sweepValues.map(fmtChoice).join(', ');
275+ const listed = curChoice().values;
276+ const custom =
277+ sweepValues.length !== listed.length || sweepValues.some((v, i) => v !== listed[i]);
278+ elValuesNote.textContent = custom
279+ ? `${sweepValues.length} values (the offered list is ${listed.map(fmtChoice).join(', ')})`
280+ : 'the offered values';
281+}
282+
283+/** Read the box back. An empty box means the parameter's own list; values
284+ * that are not numbers are dropped by parseValueList and the box is
285+ * rewritten with what was understood, so it never disagrees with the knob. */
286+function applyValues(): void {
287+ const parsed = parseValueList(elValues.value);
288+ sweepValues = parsed.length ? parsed : [...curChoice().values];
289+ if (!sweepValues.includes(sel.params[sweepKey])) sel.params[sweepKey] = sweepValues[0];
290+ showValues();
291+ onSelectionChange();
292+}
293+
294+function buildGeomParamControls(): void {
295+ elGeomParams.replaceChildren();
296+ for (const choice of GEOMETRY_CHOICES[sel.geometry]) {
297+ elGeomParams.append(
298+ makeSelect(
299+ choice,
300+ () => sel.geometryParams[choice.key],
301+ (v) => (sel.geometryParams[choice.key] = v),
302+ ),
303+ );
304+ }
305+}
306+
307+function buildControls(): void {
308+ for (const m of mModels) {
309+ const opt = document.createElement('option');
310+ opt.value = m.key;
311+ opt.textContent = m.label;
312+ elModel.append(opt);
313+ }
314+ elModel.value = sel.model;
315+ elModel.addEventListener('change', () => {
316+ sel.model = elModel.value;
317+ sel.params = defaultChoiceParams(MODEL_CHOICES[sel.model]);
318+ if (!MODEL_CHOICES[sel.model].some((c) => c.key === sweepKey)) {
319+ sweepKey = MODEL_CHOICES[sel.model][0].key;
320+ }
321+ // Another model's parameter means another quantity: a typed list for the
322+ // old one would rarely be meaningful for the new one, so the values go
323+ // back to what this model offers.
324+ sweepValues = [...curChoice().values];
325+ buildSweepOverControl();
326+ buildParamControls();
327+ showValues();
328+ onSelectionChange();
329+ });
330+ buildSweepOverControl();
331+ elSweepOver.addEventListener('change', () => {
332+ // The previously swept parameter keeps the value the knob was on and
333+ // returns to the fixed row; the newly swept one moves onto the knob,
334+ // over its own list.
335+ sweepKey = elSweepOver.value;
336+ sweepValues = [...curChoice().values];
337+ if (!sweepValues.includes(sel.params[sweepKey])) sel.params[sweepKey] = sweepValues[0];
338+ buildParamControls();
339+ showValues();
340+ onSelectionChange();
341+ });
342+ buildParamControls();
343+ showValues();
344+ // Applied on Enter or on leaving the box, not per keystroke: each change
345+ // refetches the whole sweep.
346+ elValues.addEventListener('change', () => applyValues());
347+
348+ for (const g of mGeometries) {
349+ const opt = document.createElement('option');
350+ opt.value = g.key;
351+ opt.textContent = g.label.toLowerCase();
352+ elGeometry.append(opt);
353+ }
354+ elGeometry.value = sel.geometry;
355+ elGeometry.addEventListener('change', () => {
356+ sel.geometry = elGeometry.value;
357+ sel.geometryParams = defaultChoiceParams(GEOMETRY_CHOICES[sel.geometry]);
358+ buildGeomParamControls();
359+ onSelectionChange();
360+ });
361+ buildGeomParamControls();
362+
363+ for (const v of SEED_CHOICE.values) {
364+ const opt = document.createElement('option');
365+ opt.value = String(v);
366+ opt.textContent = String(v);
367+ elSeed.append(opt);
368+ }
369+ elSeed.value = String(sel.seed);
370+ elSeed.addEventListener('change', () => {
371+ sel.seed = Number(elSeed.value);
372+ onSelectionChange();
373+ });
374+
375+ for (const v of T_END_CHOICE.values) {
376+ const opt = document.createElement('option');
377+ opt.value = String(v);
378+ opt.textContent = String(v);
379+ elTend.append(opt);
380+ }
381+ elTend.value = String(sel.tEnd);
382+ elTend.addEventListener('change', () => {
383+ sel.tEnd = Number(elTend.value);
384+ onSelectionChange();
385+ });
386+}
387+
388+function resetDefaults(): void {
389+ sel = defaultSelection();
390+ sweepKey = MODEL_CHOICES[sel.model][0].key;
391+ sweepValues = [...curChoice().values];
392+ elModel.value = sel.model;
393+ buildSweepOverControl();
394+ buildParamControls();
395+ showValues();
396+ elGeometry.value = sel.geometry;
397+ buildGeomParamControls();
398+ elSeed.value = String(sel.seed);
399+ elTend.value = String(sel.tEnd);
400+ onSelectionChange();
401+}
402+
403+/** Selection changes reload the whole sweep; chained so two flows never talk
404+ * to the session at once (same discipline as src/main.ts). */
405+let flowChain: Promise<void> = Promise.resolve();
406+function onSelectionChange(): void {
407+ writeUrlState();
408+ flowChain = flowChain.then(() => reloadSweep()).catch(() => undefined);
409+}
410+
411+// ---------------------------------------------------------------- the knob
412+function knobIndex(): number {
413+ const i = sweepValues.indexOf(sel.params[sweepKey]);
414+ return i >= 0 ? i : 0;
415+}
416+
417+function rebuildKnob(): void {
418+ elKnob.min = '0';
419+ elKnob.max = String(Math.max(0, sweepValues.length - 1));
420+ elKnob.step = '1';
421+ elKnob.disabled = busy || sweepValues.length < 2;
422+ elKnob.value = String(knobIndex());
423+ elTicks.replaceChildren(
424+ ...sweepValues.map((v, i) => {
425+ const b = document.createElement('button');
426+ b.className = 'tick';
427+ b.textContent = fmtChoice(v);
428+ b.addEventListener('click', () => {
429+ if (!busy) setKnob(i);
430+ });
431+ return b;
432+ }),
433+ );
434+ updateTicks();
435+}
436+
437+function updateTicks(): void {
438+ const idx = knobIndex();
439+ const label = curChoice().label;
440+ elTicks.querySelectorAll<HTMLButtonElement>('.tick').forEach((b, i) => {
441+ const e = entries[i];
442+ b.classList.toggle('cached', e?.status === 'cached' || e?.status === 'computed');
443+ b.classList.toggle('current', i === idx);
444+ b.title =
445+ e?.status === 'cached'
446+ ? 'in the cloud cache'
447+ : e?.status === 'computed'
448+ ? 'computed here'
449+ : e?.status === 'missing'
450+ ? 'not computed yet'
451+ : e?.status === 'unusable'
452+ ? `no whole number of steps reaches t = ${fmtChoice(sel.tEnd)} at this dt`
453+ : e?.status === 'failed'
454+ ? 'unavailable'
455+ : '';
456+ });
457+ elKnobVal.textContent = sweepValues.length
458+ ? `${label} = ${fmtChoice(sweepValues[idx])}`
459+ : `no values to sweep ${label} over`;
460+}
461+
462+/** Point the knob at value index `i` and show what is there. Pure display:
463+ * no network, no solver — that is what the up-front loading bought. */
464+function setKnob(i: number): void {
465+ if (!sweepValues.length) return;
466+ sel.params[sweepKey] = sweepValues[i];
467+ elKnob.value = String(i);
468+ writeUrlState();
469+ showCurrent();
470+}
471+
472+elKnob.addEventListener('input', () => {
473+ if (busy) return;
474+ setKnob(Number(elKnob.value));
475+});
476+
477+// ---------------------------------------------------------------- view
478+function disposeView(): void {
479+ for (const s of scenes) s.dispose();
480+ scenes = [];
481+ colorbars = [];
482+ colorbarEls = [];
483+ topo = null;
484+ resizeObs?.disconnect();
485+ resizeObs = null;
486+ elPanels.replaceChildren();
487+}
488+
489+function buildView(surface: Float32Array): void {
490+ const session = sess();
491+ if (!session) return;
492+ const view = session.viewSht;
493+ const { nphi } = view.cfg;
494+ const phi = new Float64Array(nphi);
495+ for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
496+ topo = buildTopology(view.cosTheta, phi);
497+ const posBuf = new Float32Array(topo.numVertices * 3);
498+ fillPositions(posBuf, surface, topo, 1);
499+
500+ const sphereBg = getComputedStyle(document.documentElement)
501+ .getPropertyValue('--sphere-bg')
502+ .trim();
503+ const model = curModel();
504+ colorBufs = [];
505+ liveBufs = [];
506+ liveRanges = [];
507+ ranges = [];
508+ for (let k = 0; k < model.species.length; k++) {
509+ const panel = document.createElement('div');
510+ panel.className = 'panel';
511+ const box = document.createElement('div');
512+ box.className = 'sphere-box';
513+ const tag = document.createElement('div');
514+ tag.className = 'species-tag';
515+ tag.textContent = model.species[k];
516+ box.append(tag);
517+ const side = document.createElement('div');
518+ panel.append(box, side);
519+ elPanels.append(panel);
520+
521+ const scene = new SphereScene(
522+ box,
523+ topo.numVertices,
524+ topo.indices,
525+ Float32Array.from(posBuf),
526+ sphereBg || undefined,
527+ );
528+ scene.fitCamera();
529+ scenes.push(scene);
530+ colorbars.push(new Colorbar(side));
531+ colorbarEls.push(side);
532+ colorBufs.push(new Float32Array(topo.numVertices * 3));
533+ liveBufs.push(new Float32Array(topo.numVertices));
534+ liveRanges.push({ lo: NaN, hi: NaN });
535+ ranges.push({ lo: NaN, hi: NaN });
536+ }
537+ for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
538+
539+ resizeObs = new ResizeObserver(() => {
540+ const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
541+ boxes.forEach((box, i) => {
542+ scenes[i]?.resize(box.clientWidth, box.clientHeight);
543+ });
544+ });
545+ elPanels
546+ .querySelectorAll<HTMLElement>('.sphere-box')
547+ .forEach((box) => resizeObs!.observe(box));
548+}
549+
550+/** Rebuild mesh and panels from the session's surface, keeping the camera. */
551+async function rebuildViewFromSession(): Promise<void> {
552+ const session = sess();
553+ if (!session) return;
554+ const surface = await session.renderPositions();
555+ const cam = scenes[0]?.cameraState();
556+ disposeView();
557+ buildView(surface);
558+ if (cam) for (const s of scenes) s.setCameraState(cam);
559+ grayDisplay();
560+}
561+
562+/** The shape with no field on it (NaN renders neutral gray in fillColors). */
563+function grayDisplay(): void {
564+ if (!topo) return;
565+ for (let k = 0; k < scenes.length; k++) {
566+ liveBufs[k].fill(NaN);
567+ fillColors(colorBufs[k], liveBufs[k], 0, 1, COLORMAP);
568+ scenes[k].updateColors(colorBufs[k]);
569+ colorbarEls[k].style.visibility = 'hidden';
570+ }
571+}
572+
573+/**
574+ * The sweep-wide color range, per species, over every loaded value. Fixed
575+ * while the knob moves, so colors mean the same thing at every position;
576+ * recomputed only when the set of loaded values changes.
577+ */
578+function recomputeRanges(): void {
579+ for (let k = 0; k < ranges.length; k++) {
580+ let lo = Infinity;
581+ let hi = -Infinity;
582+ for (const e of entries) {
583+ const f = e.fields?.[k];
584+ if (!f) continue;
585+ for (const v of f) {
586+ if (v < lo) lo = v;
587+ if (v > hi) hi = v;
588+ }
589+ }
590+ ranges[k] = lo <= hi ? floorRange(lo, hi) : { lo: NaN, hi: NaN };
591+ }
592+}
593+
594+/** Show the knob's current value from the in-memory fields. */
595+function showCurrent(): void {
596+ updateTicks();
597+ const entry = entries[knobIndex()];
598+ if (topo && entry?.fields) {
599+ for (let k = 0; k < scenes.length; k++) {
600+ fillColors(colorBufs[k], entry.fields[k], ranges[k].lo, ranges[k].hi, COLORMAP);
601+ scenes[k].updateColors(colorBufs[k]);
602+ colorbars[k].update(COLORMAP, ranges[k].lo, ranges[k].hi);
603+ colorbarEls[k].style.visibility = '';
604+ }
605+ } else {
606+ grayDisplay();
607+ }
608+ updateStats();
609+ if (!busy) updateSweepNote();
610+}
611+
612+function updateStats(): void {
613+ const session = sess();
614+ if (!session) return;
615+ const { nlat, nphi } = session.cfg;
616+ const entry = entries[knobIndex()];
617+ const showing = entry?.fields
618+ ? ` · showing <b>${curChoice().label} = ${fmtChoice(entry.value)}</b>` +
619+ ` at t = <b>${fmtChoice(sel.tEnd)}</b>`
620+ : '';
621+ elStats.innerHTML =
622+ `<b>WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}</b> · ` +
623+ `grid ${nlat}×${nphi} · lmax ${LMAX} · solve iters ${NITER}${showing}`;
624+}
625+
626+// ---------------------------------------------------------------- statuses
627+function status(html: string): void {
628+ elStatus.innerHTML = html;
629+}
630+
631+const isMissing = (e: SweepEntry): boolean =>
632+ e.status === 'missing' || e.status === 'failed';
633+
634+/** The idle status line: how much of the sweep is there, and what to do
635+ * about the rest. */
636+function updateSweepNote(): void {
637+ if (entries.some((e) => e.status === 'loading')) return;
638+ const n = entries.length;
639+ const loaded = entries.filter((e) => e.fields).length;
640+ const cloud = entries.filter((e) => e.status === 'cached').length;
641+ const unusable = entries.filter((e) => e.status === 'unusable').length;
642+ const label = curChoice().label;
643+ // "in the cloud cache" only when that is where they all came from: a
644+ // keyless local compute loads a value without contributing it.
645+ const what = cloud === loaded ? 'in the cloud cache' : 'loaded';
646+ const aside = unusable
647+ ? ` ${unusable} of them cannot be solved to t = ${fmtChoice(sel.tEnd)} at all.`
648+ : '';
649+ if (loaded === n) {
650+ const computedHere = n - cloud ? ` (${n - cloud} computed here)` : '';
651+ status(`all <b>${n} values</b> of ${label} are ${what}${computedHere} — drag the knob.`);
652+ return;
653+ }
654+ const entry = entries[knobIndex()];
655+ const here =
656+ entry && !entry.fields && entry.status !== 'unusable'
657+ ? ` <b>${label} = ${fmtChoice(entry.value)}</b> is one of them.`
658+ : '';
659+ const todo = n - loaded - unusable;
660+ status(
661+ `<b>${loaded} of ${n}</b> values ${what}; ${todo} not computed yet.${here}${aside} ` +
662+ (todo
663+ ? `<b>Compute missing values</b> runs them in your browser, one after another.`
664+ : ''),
665+ );
666+}
667+
668+function setBusy(next: boolean): void {
669+ busy = next;
670+ elStop.hidden = !next;
671+ elReset.disabled = next;
672+ elKnob.disabled = next || sweepValues.length < 2;
673+ elValues.disabled = next;
674+ document
675+ .querySelectorAll<HTMLSelectElement>('main .controls select')
676+ .forEach((s) => (s.disabled = next));
677+ updateComputeButton();
678+}
679+
680+function updateComputeButton(): void {
681+ elCompute.disabled = busy || !device || !entries.some(isMissing);
682+}
683+
684+// ---------------------------------------------------------------- loading
685+/** Synthesize per-vertex render values from the state the session holds. */
686+async function fieldsFromSession(): Promise<Float32Array[]> {
687+ const session = sess();
688+ if (!session || !topo) throw new Error('no view to synthesize into');
689+ const out: Float32Array[] = [];
690+ for (let k = 0; k < curModel().species.length; k++) {
691+ const field = await session.readSpecies(k);
692+ const vals = new Float32Array(topo.numVertices);
693+ fillFieldValues(vals, field, topo);
694+ out.push(vals);
695+ }
696+ return out;
697+}
698+
699+/** The same, for a decoded cache file: load its final state first. */
700+async function fieldsFromState(state: Record<string, Float32Array>): Promise<Float32Array[]> {
701+ const session = sess();
702+ if (!session) throw new Error('no solver session');
703+ session.loadState(state);
704+ return fieldsFromSession();
705+}
706+
707+/**
708+ * Bring the page in line with the selection: build the sweep's entries, apply
709+ * the base spec to the solver (recompiling or re-evaluating the surface only
710+ * when the model or geometry changed), then fetch every value's cache file at
711+ * once. Fetches run in parallel; the GPU synthesis of whatever arrives is
712+ * serialized through one chain, since the session is one machine.
713+ */
714+async function reloadSweep(): Promise<void> {
715+ if (!device || !solver || busy) return;
716+ generation++;
717+ const gen = generation;
718+ elErr.textContent = '';
719+ // A typed dt that does not divide the end time names a run that cannot land
720+ // on it, which stepsFor refuses. Caught here rather than in the middle of a
721+ // walk, where it would arrive as a failure per value.
722+ entries = specsForSweep(curSweep()).map(({ value, spec }) => {
723+ let usable = true;
724+ try {
725+ stepsFor(spec);
726+ } catch {
727+ usable = false;
728+ }
729+ return {
730+ value,
731+ spec,
732+ status: usable ? ('loading' as const) : ('unusable' as const),
733+ fields: null,
734+ };
735+ });
736+ const unusable = entries.filter((e) => e.status === 'unusable');
737+ if (unusable.length) {
738+ elErr.textContent =
739+ `${unusable.map((e) => `${curChoice().label} = ${fmtChoice(e.value)}`).join(', ')}: ` +
740+ `the end time ${fmtChoice(sel.tEnd)} is not a whole number of steps at this dt`;
741+ }
742+ rebuildKnob();
743+ updateComputeButton();
744+ status('checking the cloud cache…');
745+ try {
746+ await solver.apply(specForSelection(sel));
747+ } catch (e) {
748+ if (gen === generation) {
749+ elErr.textContent = formatFailure(e, curModel().source);
750+ status('failed.');
751+ }
752+ return;
753+ }
754+ if (gen !== generation) return;
755+ grayDisplay();
756+ updateStats();
757+
758+ let synth: Promise<void> = Promise.resolve();
759+ await Promise.all(
760+ entries.map(async (entry) => {
761+ if (entry.status === 'unusable') return;
762+ let bytes: Uint8Array | null = null;
763+ let unreachable = false;
764+ const lookup = await lookupFor(entry.spec);
765+ try {
766+ bytes = await fetchCached(lookup);
767+ } catch {
768+ unreachable = true;
769+ }
770+ if (gen !== generation) return;
771+ if (!bytes) {
772+ entry.status = unreachable ? 'failed' : 'missing';
773+ if (unreachable) elErr.textContent = 'cloud cache unreachable';
774+ entrySettled(gen, entry);
775+ return;
776+ }
777+ const data = bytes;
778+ synth = synth.then(async () => {
779+ if (gen !== generation) return;
780+ try {
781+ const decoded = await decodeCacheFile(data, lookup.specJson, curModel().state);
782+ if (gen !== generation) return;
783+ entry.fields = await fieldsFromState(decoded.final);
784+ entry.status = 'cached';
785+ } catch (e) {
786+ entry.status = 'failed';
787+ elErr.textContent = `${curChoice().label} = ${fmtChoice(entry.value)}: ${
788+ e instanceof Error ? e.message : e
789+ }`;
790+ }
791+ entrySettled(gen, entry);
792+ });
793+ await synth;
794+ }),
795+ );
796+ if (gen !== generation) return;
797+ updateComputeButton();
798+ updateSweepNote();
799+}
800+
801+/** A value's fate is known (loaded, missing, or broken): fold it into the
802+ * common color range and the display as it lands, not at the end. */
803+function entrySettled(gen: number, entry: SweepEntry): void {
804+ if (gen !== generation) return;
805+ if (entry.fields) recomputeRanges();
806+ showCurrent();
807+}
808+
809+// ---------------------------------------------------------------- computing
810+/** The live view while a value computes (main.ts's draw, with the smoothed
811+ * self-scaling range — the sweep-wide scale takes over once it is done). */
812+async function drawLive(gen: number): Promise<void> {
813+ const session = sess();
814+ if (!session || !topo) return;
815+ for (let k = 0; k < scenes.length; k++) {
816+ let field: Float32Array;
817+ try {
818+ field = await session.readSpecies(k);
819+ } catch (e) {
820+ if (gen !== generation) return;
821+ throw e;
822+ }
823+ if (gen !== generation || !topo) return;
824+ fillFieldValues(liveBufs[k], field, topo);
825+ let lo = Infinity;
826+ let hi = -Infinity;
827+ for (const v of liveBufs[k]) {
828+ if (v < lo) lo = v;
829+ if (v > hi) hi = v;
830+ }
831+ const r = liveRanges[k];
832+ if (!Number.isFinite(r.lo)) {
833+ r.lo = lo;
834+ r.hi = hi;
835+ } else {
836+ const a = 0.15;
837+ r.lo += a * (lo - r.lo);
838+ r.hi += a * (hi - r.hi);
839+ }
840+ const shown = floorRange(r.lo, r.hi);
841+ fillColors(colorBufs[k], liveBufs[k], shown.lo, shown.hi, COLORMAP);
842+ scenes[k].updateColors(colorBufs[k]);
843+ colorbars[k].update(COLORMAP, shown.lo, shown.hi);
844+ colorbarEls[k].style.visibility = '';
845+ }
846+}
847+
848+/**
849+ * Compute the sweep's uncached values here, in value order, watching each
850+ * pattern form. Every run is the ordinary local computation (warm start from
851+ * a shorter cached run, snapshots uploaded in the background when a key is
852+ * present, divergence guard). The knob follows along so the URL and the
853+ * readout always say which value is being computed.
854+ */
855+async function computeMissing(): Promise<void> {
856+ if (!device || !solver || busy) return;
857+ const missing = entries.filter(isMissing);
858+ if (!missing.length) return;
859+ setBusy(true);
860+ stopRequested = false;
861+ elErr.textContent = '';
862+ generation++;
863+ const gen = generation;
864+ const label = curChoice().label;
865+ let computing: SweepEntry | null = null;
866+ let uploads = 0;
867+ let lastStatus = 0;
868+ let lastDraw = 0;
869+
870+ const runLine = (run: RunSummary): string =>
871+ `<b>${label} = ${fmtChoice(computing?.value ?? NaN)}</b> — computed in ` +
872+ `${run.seconds.toFixed(1)} s` +
873+ (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
874+ '.';
875+
876+ const runEvents: RunEvents = {
877+ onPhase(phase) {
878+ const v = fmtChoice(computing?.value ?? NaN);
879+ if (phase.kind === 'warm-search') {
880+ status(`${label} = ${v}: looking for a shorter cached run…`);
881+ } else if (phase.kind === 'seeding') {
882+ status(`<b>computing ${label} = ${v}</b>: seeding…`);
883+ } else if (phase.kind === 'encoding') {
884+ status(`${runLine(phase.run)} Writing the cache file…`);
885+ } else {
886+ status(`${runLine(phase.run)} Uploading (${phase.uploaded}/${phase.started})…`);
887+ }
888+ },
889+ onProgress(p) {
890+ const now = performance.now();
891+ if (now - lastStatus < STATUS_EVERY_MS) return;
892+ lastStatus = now;
893+ const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
894+ const up = p.uploadsStarted
895+ ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
896+ : '';
897+ status(
898+ `<b>computing ${label} = ${fmtChoice(computing?.value ?? NaN)}</b> (${from}` +
899+ `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
900+ `${p.rate.toFixed(0)} steps/s${up})`,
901+ );
902+ },
903+ onStepping() {
904+ for (const r of liveRanges) {
905+ r.lo = NaN;
906+ r.hi = NaN;
907+ }
908+ },
909+ async onTick() {
910+ // As on the main page: no rendering while hidden, and never a wait on
911+ // an animation frame there, so a background tab computes at full speed.
912+ const now = performance.now();
913+ if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
914+ lastDraw = now;
915+ await drawLive(gen);
916+ if (gen !== generation) return;
917+ await nextFrame();
918+ },
919+ async onFinal() {
920+ // The session holds the finished state: synthesize it into the sweep
921+ // while it is there, and the value joins the knob's range.
922+ if (!computing) return;
923+ computing.fields = await fieldsFromSession();
924+ computing.status = 'computed';
925+ recomputeRanges();
926+ updateTicks();
927+ },
928+ onUploaded: () => void uploads++,
929+ cancelled: () => gen !== generation,
930+ stopRequested: () => stopRequested,
931+ };
932+
933+ await fillWalk({
934+ targets: missing.map(
935+ (e): AutoTarget => ({
936+ model: sel.model,
937+ params: { ...e.spec.params },
938+ geometry: sel.geometry,
939+ geometryParams: { ...e.spec.geometryParams },
940+ distance: 0,
941+ }),
942+ ),
943+ solver,
944+ adapter: adapterName,
945+ runtime: 'browser-webgpu',
946+ apiKey: () => elApiKey.value.trim(),
947+ beforeTarget(target) {
948+ const entry = entries.find((e) => e.spec.params[sweepKey] === target.params[sweepKey])!;
949+ computing = entry;
950+ // The knob follows the walk, so the page always says what is running.
951+ sel.params[sweepKey] = entry.value;
952+ elKnob.value = String(knobIndex());
953+ writeUrlState();
954+ updateTicks();
955+ return entry.spec;
956+ },
957+ events: {
958+ ...runEvents,
959+ onTarget: () => status('checking the cloud cache…'),
960+ onCached(_target) {
961+ // Somebody else computed it since the page loaded: fetch it after
962+ // the walk rather than recomputing it here.
963+ if (computing) computing.status = 'refetch';
964+ },
965+ onOutcome(_target, _spec, outcome) {
966+ if (outcome.kind === 'diverged' && computing) {
967+ computing.status = 'failed';
968+ elErr.textContent =
969+ `${label} = ${fmtChoice(computing.value)}: the solution went non-finite at ` +
970+ `t = ${outcome.t.toFixed(2)} — nothing uploaded (unstable at this dt)`;
971+ }
972+ updateTicks();
973+ },
974+ onFailure(_target, spec, e) {
975+ if (computing) computing.status = 'failed';
976+ elErr.textContent = `${label} = ${fmtChoice(spec.params[sweepKey])}: ${formatFailure(
977+ e,
978+ curModel().source,
979+ )}`;
980+ updateTicks();
981+ },
982+ walkStopped: () => stopRequested || gen !== generation,
983+ },
984+ });
985+ if (gen !== generation) return;
986+
987+ // Values that turned out to be cached meanwhile (or that a stopped run
988+ // uploaded on the way past) are fetched like any other cache hit.
989+ for (const entry of entries) {
990+ if (entry.status !== 'refetch') continue;
991+ try {
992+ const lookup = await lookupFor(entry.spec);
993+ const bytes = await fetchCached(lookup);
994+ if (gen !== generation) return;
995+ if (!bytes) {
996+ entry.status = 'missing';
997+ continue;
998+ }
999+ const decoded = await decodeCacheFile(bytes, lookup.specJson, curModel().state);
1000+ if (gen !== generation) return;
1001+ entry.fields = await fieldsFromState(decoded.final);
1002+ entry.status = 'cached';
1003+ } catch {
1004+ entry.status = 'failed';
1005+ }
1006+ }
1007+ if (gen !== generation) return;
1008+
1009+ setBusy(false);
1010+ recomputeRanges();
1011+ showCurrent();
1012+ if (stopRequested) {
1013+ status('stopped.' + (uploads ? ` ${uploads} file${uploads > 1 ? 's' : ''} uploaded.` : ''));
1014+ } else {
1015+ updateSweepNote();
1016+ }
1017+}
1018+
1019+// ---------------------------------------------------------------- cloud
1020+function updateUploadNote(): void {
1021+ const hasKey = elApiKey.value.trim().length > 0;
1022+ elUploadNote.textContent = hasKey
1023+ ? 'uploads enabled — locally computed solutions will be contributed'
1024+ : '';
1025+ elCliBar.hidden = !hasKey;
1026+ elCliNote.hidden = !hasKey;
1027+ updateCliCommand();
1028+ elCliCopied.textContent = '';
1029+}
1030+
1031+/**
1032+ * The command that fills exactly this sweep on a machine with no browser: the
1033+ * page's own URL is the argument, so there is one serialization of what a
1034+ * sweep is (src/cache/selection.ts) and a colleague can paste the same URL
1035+ * into a browser to see the result. Key masked on screen, real in the
1036+ * clipboard, as on the main page.
1037+ */
1038+function sweepFillCommand(key: string): string {
1039+ const url = new URL(`fill.tgz?v=${__BUILD_ID__}`, location.href).href;
1040+ return `TURING_SURFACE_CACHE_KEY=${key} npx ${url} sweep '${location.href}'`;
1041+}
1042+
1043+function updateCliCommand(): void {
1044+ if (!elCliBar.hidden) elCliCmd.textContent = sweepFillCommand('…');
1045+}
1046+
1047+elCliCopy.addEventListener('click', () => {
1048+ const key = elApiKey.value.trim();
1049+ if (!key) return;
1050+ navigator.clipboard.writeText(sweepFillCommand(key)).then(
1051+ () => {
1052+ elCliCopied.textContent = 'copied';
1053+ setTimeout(() => (elCliCopied.textContent = ''), 4000);
1054+ },
1055+ () => {
1056+ elCliCmd.textContent = sweepFillCommand(key);
1057+ elCliCopied.textContent = 'clipboard unavailable — the key is now shown above';
1058+ },
1059+ );
1060+});
1061+
1062+elApiKey.addEventListener('change', () => {
1063+ const key = elApiKey.value.trim();
1064+ if (key) localStorage.setItem(API_KEY_STORAGE, key);
1065+ else localStorage.removeItem(API_KEY_STORAGE);
1066+ updateUploadNote();
1067+});
1068+
1069+// ---------------------------------------------------------------- boot
1070+elCompute.addEventListener('click', () => {
1071+ flowChain = flowChain.then(() => computeMissing()).catch(() => undefined);
1072+});
1073+elStop.addEventListener('click', () => {
1074+ stopRequested = true;
1075+});
1076+elReset.addEventListener('click', () => resetDefaults());
1077+elResetView.addEventListener('click', () => {
1078+ for (const s of scenes) s.resetCamera();
1079+});
1080+
1081+async function boot(): Promise<void> {
1082+ buildControls();
1083+ rebuildKnob();
1084+ writeUrlState();
1085+ elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1086+ updateUploadNote();
1087+ try {
1088+ device = await requestShtDevice();
1089+ solver = new SolverSession(device, OVERSAMPLE, {
1090+ onCompiling: (m) => status(`compiling ${m.label}…`),
1091+ onSurface: () => rebuildViewFromSession(),
1092+ });
1093+ adapterName = await describeAdapter(device);
1094+ } catch (e) {
1095+ device = null;
1096+ solver = null;
1097+ elErr.textContent =
1098+ `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1099+ `Use a WebGPU-capable browser such as Chrome or Edge.`;
1100+ return;
1101+ }
1102+ device.lost.then((info) => {
1103+ if (info.reason !== 'destroyed') {
1104+ elErr.textContent = `WebGPU device lost: ${info.message}`;
1105+ }
1106+ });
1107+ flowChain = flowChain.then(() => reloadSweep()).catch(() => undefined);
1108+ await flowChain;
1109+}
1110+
1111+void boot();
sweep.htmladded+96−0View file
@@ -0,0 +1,96 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><circle cx=%2250%22 cy=%2250%22 r=%2245%22 fill=%22%232a5f7f%22/><circle cx=%2235%22 cy=%2238%22 r=%2211%22 fill=%22%23f5d547%22/><circle cx=%2265%22 cy=%2258%22 r=%229%22 fill=%22%23f5d547%22/><circle cx=%2248%22 cy=%2274%22 r=%227%22 fill=%22%23f5d547%22/><circle cx=%2268%22 cy=%2230%22 r=%226%22 fill=%22%23f5d547%22/></svg>" />
7+ <title>turing-surface-cache — parameter sweep</title>
8+ <link rel="stylesheet" href="/src/styles.css" />
9+ </head>
10+ <body>
11+ <main>
12+ <h1>turing-surface-cache — parameter sweep</h1>
13+ <p class="sub">
14+ One model parameter runs over a list of values while everything else
15+ stays fixed; the knob steps the display through the range. The list
16+ starts as the values the single-solution page offers, and any numbers
17+ may be typed in its place. Values already in the shared cloud cache are
18+ loaded up front, so dragging the knob is instant; values nobody has
19+ computed show as gaps until <b>Compute missing values</b> runs them
20+ here, or the command below runs them on another machine. One color
21+ scale covers the whole sweep, so differences between values are real.
22+ Back to the <a id="backlink" href="index.html">single-solution
23+ page</a>.
24+ </p>
25+ <div class="controls">
26+ <label title="The reaction-diffusion system being solved">model
27+ <select id="model"></select>
28+ </label>
29+ <label title="Which parameter the knob runs over">sweep over
30+ <select id="sweepover"></select>
31+ </label>
32+ <label title="The values the knob runs over, separated by commas. Any numbers may be given, not only the ones the single-solution page offers; each still names one exact cache entry. Empty means the offered list.">values
33+ <input id="values" type="text" size="26" autocomplete="off"
34+ placeholder="e.g. 0.7, 0.9, 1.1, 1.3" />
35+ </label>
36+ <span id="valuesnote"></span>
37+ </div>
38+ <div class="controls">
39+ <span id="params" class="controls" style="padding: 0"></span>
40+ </div>
41+ <div class="controls">
42+ <label title="The surface the pattern is solved on">geometry
43+ <select id="geometry"></select>
44+ </label>
45+ <span id="geomparams" class="controls" style="padding: 0"></span>
46+ <label title="Which random initial perturbation to start from">seed
47+ <select id="seed"></select>
48+ </label>
49+ <label title="Every solution of the sweep is reported at this simulation time">end time
50+ <select id="tend"></select>
51+ </label>
52+ <button id="compute" class="primary"
53+ title="Compute the sweep's uncached values in your browser, one after another">Compute missing values</button>
54+ <button id="stop" hidden>Stop</button>
55+ <button id="reset" title="Set every selection back to its default">Reset to defaults</button>
56+ </div>
57+ <div class="sweepbar">
58+ <div class="knobtrack">
59+ <input type="range" id="knob" min="0" max="1" step="1" value="0" />
60+ <div class="ticks" id="ticks"></div>
61+ </div>
62+ <span id="knobval"></span>
63+ </div>
64+ <p id="status"></p>
65+ <div id="panels"></div>
66+ <div class="controls">
67+ <button id="resetview">Reset view</button>
68+ </div>
69+ <p class="stats" id="stats"></p>
70+ <div class="cloud">
71+ <span>Solutions computed here can be contributed back to the shared
72+ cache, so the next visitor gets the whole sweep instantly.
73+ Contributing requires an upload API key.</span>
74+ <div class="controls">
75+ <label>upload API key
76+ <input id="apikey" type="password" autocomplete="off" placeholder="(optional)" />
77+ </label>
78+ <span id="uploadnote"></span>
79+ </div>
80+ <div class="controls" id="clibar" hidden>
81+ <span>fill this sweep from a machine with no browser on it:</span>
82+ <code id="clicmd"></code>
83+ <button id="clicopy"
84+ title="Copies the command with your key in it. The key is masked here so that it stays out of screenshots.">Copy command (includes your key)</button>
85+ <span id="clicopied"></span>
86+ </div>
87+ <p id="clinote" hidden>If the command does not run on the other
88+ machine, the notes on the
89+ <a href="index.html">single-solution page</a> cover the common
90+ failures.</p>
91+ </div>
92+ <p id="err"></p>
93+ </main>
94+ <script type="module" src="/src/sweep.ts"></script>
95+ </body>
96+</html>
vite.cli.config.tsmodified+4−0View file
@@ -22,6 +22,10 @@ export default mergeConfig(
2222 minify: false,
2323 rollupOptions: {
2424 external: ['h5wasm', 'h5wasm/node', 'webgpu'],
25+ // The page build's two-HTML input would otherwise merge in here;
26+ // a string wins over an object in mergeConfig, restoring the one
27+ // node entry.
28+ input: 'src/cli/fill.ts',
2529 output: { entryFileNames: 'fill.js', banner: '#!/usr/bin/env node' },
2630 },
2731 },
vite.config.tsmodified+8−0View file
@@ -49,5 +49,13 @@ export default defineConfig({
4949 },
5050 build: {
5151 target: 'es2022',
52+ rollupOptions: {
53+ // Two pages, deployed side by side: the single-solution page and the
54+ // parameter sweep.
55+ input: {
56+ main: resolve(import.meta.dirname, 'index.html'),
57+ sweep: resolve(import.meta.dirname, 'sweep.html'),
58+ },
59+ },
5260 },
5361 });