1import {
2 GpuBackend,
3 CpuBackend,
4 requestShtDevice,
5 describeAdapter,
6 type ShtBackend,
7} from './solver/backend.ts';
8import { Simulation, gridForLmax } from './solver/simulation.ts';
9import { presets, type ModelSpec, type Params } from './solver/models.ts';
10import {
11 formatCommand,
12 resolvePreset,
13 DEFAULT_STEPS,
14 DEFAULT_WARMUP,
15 type BackendKind,
16 type RunSpec,
17} from './bench/runSpec.ts';
18import {
19 buildTopology,
20 fillFieldValues,
21 fillColors,
22 type SphereMeshTopology,
23} from './render/sphereMesh.ts';
24import { SphereScene } from './render/SphereScene.ts';
25import { Colorbar } from './render/colorbar.ts';
26import { colormaps, colormapNames } from './render/colormaps.ts';
28const $ = <T extends HTMLElement>(id: string): T =>
29 document.getElementById(id) as T;
31const elModel = $<HTMLSelectElement>('model');
32const elLmax = $<HTMLSelectElement>('lmax');
33const elColormap = $<HTMLSelectElement>('colormap');
34const elBackend = $<HTMLSelectElement>('backend');
35const elRunPause = $<HTMLButtonElement>('runpause');
36const elReseed = $<HTMLButtonElement>('reseed');
37const elResetView = $<HTMLButtonElement>('resetview');
38const elParams = $('params');
39const elPanels = $('panels');
40const elStats = $('stats');
41const elCmd = $('cmd');
42const elCopyCmd = $<HTMLButtonElement>('copycmd');
43const elBlurb = $('blurb');
44const elErr = $('err');
46for (const p of presets) {
47 const o = document.createElement('option');
48 o.value = p.key;
49 o.textContent = p.label;
50 elModel.append(o);
51}
52for (const name of colormapNames) {
53 const o = document.createElement('option');
54 o.value = name;
55 o.textContent = name;
56 elColormap.append(o);
57}
58elColormap.value = 'jet';
60// ---------------------------------------------------------------- state
61let device: GPUDevice | null = null;
62let backend: ShtBackend | null = null;
63let sim: Simulation | null = null;
64let topo: SphereMeshTopology | null = null;
65let scenes: SphereScene[] = [];
66let colorbars: Colorbar[] = [];
67let valueBufs: Float32Array[] = [];
68let colorBufs: Float32Array[] = [];
69let ranges: { lo: number; hi: number }[] = [];
70let resizeObs: ResizeObserver | null = null;
72const initial = resolvePreset(presets[0].key);
73let model: ModelSpec = initial.model;
74let params: Params = initial.params;
75let seed = 1;
76let running = false;
77let adapterName = '';
78let pumping = false;
79let stepMs = 0;
80let generation = 0; // bumped on every rebuild to cancel stale pumps
82// ---------------------------------------------------------------- UI wiring
83function buildParamInputs(): void {
84 elParams.replaceChildren();
85 for (const spec of model.params) {
86 const label = document.createElement('label');
87 label.textContent = `${spec.label} `;
88 const input = document.createElement('input');
89 input.type = 'number';
90 input.min = String(spec.min);
91 input.max = String(spec.max);
92 input.step = String(spec.step);
93 input.value = String(params[spec.key]);
94 input.addEventListener('change', () => {
95 const v = Number(input.value);
96 if (Number.isFinite(v)) params[spec.key] = v;
97 updateCommand();
98 });
99 label.append(input);
100 elParams.append(label);
101 }
102}
104function applyPreset(presetKey: string): void {
105 const resolved = resolvePreset(presetKey);
106 model = resolved.model;
107 params = resolved.params;
108 buildParamInputs();
109 elBlurb.textContent = model.blurb;
110 updateCommand();
111}
113/** The run currently on screen, as the benchmark's RunSpec. */
114function currentSpec(): RunSpec {
115 return {
116 preset: elModel.value,
117 lmax: Number(elLmax.value),
118 backend: elBackend.value as BackendKind,
119 seed,
120 steps: DEFAULT_STEPS,
121 warmup: DEFAULT_WARMUP,
122 params,
123 };
124}
126function updateCommand(): void {
127 elCmd.textContent = formatCommand(currentSpec());
128}
130elModel.addEventListener('change', () => {
131 applyPreset(elModel.value);
132 void rebuild();
133});
134elLmax.addEventListener('change', () => void rebuild());
135elBackend.addEventListener('change', () => void rebuild());
136elColormap.addEventListener('change', () => draw());
137function setRunning(next: boolean): void {
138 running = next;
139 elRunPause.textContent = running ? 'Pause' : 'Run';
140 if (running) void pump();
141}
143elRunPause.addEventListener('click', () => setRunning(!running));
144elReseed.addEventListener('click', () => {
145 seed = (Math.random() * 2 ** 31) >>> 0;
146 setRunning(false);
147 updateCommand();
148 void reseed();
149});
150elResetView.addEventListener('click', () => {
151 for (const s of scenes) s.resetCamera();
152});
154// The command reproduces this exact run on the desktop; keep it selectable
155// even where the clipboard API is unavailable.
156elCopyCmd.addEventListener('click', () => {
157 const text = elCmd.textContent ?? '';
158 const flash = (msg: string): void => {
159 elCopyCmd.textContent = msg;
160 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
161 };
162 const selectCommand = (): void => {
163 const range = document.createRange();
164 range.selectNodeContents(elCmd);
165 const sel = getSelection();
166 sel?.removeAllRanges();
167 sel?.addRange(range);
168 flash('Selected');
169 };
170 if (!navigator.clipboard) return selectCommand();
171 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
172});
174// ---------------------------------------------------------------- setup
175function disposeView(): void {
176 for (const s of scenes) s.dispose();
177 scenes = [];
178 colorbars = [];
179 resizeObs?.disconnect();
180 resizeObs = null;
181 elPanels.replaceChildren();
182}
184async function rebuild(): Promise<void> {
185 generation++;
186 const gen = generation;
187 // a rebuild restarts from a fresh initial state, so pause like Re-seed does
188 setRunning(false);
189 disposeView();
190 backend?.destroy();
191 backend = null;
192 sim = null;
193 stepMs = 0;
194 updateCommand();
196 const lmax = Number(elLmax.value);
197 const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
198 const cfg = { lmax, mmax: lmax, nlat, nphi };
199 const wantGpu = elBackend.value === 'webgpu' && device !== null;
200 try {
201 backend = wantGpu
202 ? await GpuBackend.create(device!, cfg)
203 : new CpuBackend(cfg);
204 } catch (e) {
205 elErr.textContent = `Failed to create transform plan: ${e}`;
206 return;
207 }
208 if (gen !== generation) return;
209 elErr.textContent =
210 !wantGpu && lmax > 31
211 ? 'Heads up: the CPU backend is a direct-summation f64 reference — expect well under 10 steps/s at this lmax.'
212 : '';
214 sim = new Simulation(backend, model, params);
215 await sim.init(seed);
216 if (gen !== generation) return;
218 // mesh + scenes
219 const phi = new Float64Array(nphi);
220 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
221 topo = buildTopology(backend.cosTheta, phi);
223 const sphereBg = getComputedStyle(document.documentElement)
224 .getPropertyValue('--sphere-bg')
225 .trim();
226 for (let k = 0; k < sim.nspecies; k++) {
227 const panel = document.createElement('div');
228 panel.className = 'panel';
229 const box = document.createElement('div');
230 box.className = 'sphere-box';
231 const tag = document.createElement('div');
232 tag.className = 'species-tag';
233 tag.textContent = model.species[k];
234 box.append(tag);
235 const side = document.createElement('div');
236 panel.append(box, side);
237 elPanels.append(panel);
239 const scene = new SphereScene(
240 box,
241 topo.numVertices,
242 topo.indices,
243 topo.sphereRef,
244 sphereBg || undefined,
245 );
246 scene.fitCamera();
247 scenes.push(scene);
248 colorbars.push(new Colorbar(side));
249 valueBufs[k] = new Float32Array(topo.numVertices);
250 colorBufs[k] = new Float32Array(topo.numVertices * 3);
251 ranges[k] = { lo: NaN, hi: NaN };
252 }
253 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
255 resizeObs = new ResizeObserver(() => {
256 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
257 boxes.forEach((box, i) => {
258 scenes[i]?.resize(box.clientWidth, box.clientHeight);
259 });
260 });
261 elPanels
262 .querySelectorAll<HTMLElement>('.sphere-box')
263 .forEach((box) => resizeObs!.observe(box));
265 draw();
266 updateStats();
267 void pump();
268}
270async function reseed(): Promise<void> {
271 if (!sim) return;
272 const gen = generation;
273 await sim.init(seed);
274 if (gen !== generation) return;
275 for (const r of ranges) {
276 r.lo = NaN;
277 r.hi = NaN;
278 }
279 draw();
280}
282// ---------------------------------------------------------------- drawing
283function draw(): void {
284 if (!sim || !topo) return;
285 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
286 for (let k = 0; k < sim.nspecies; k++) {
287 fillFieldValues(valueBufs[k], sim.V[k], topo);
288 let lo = Infinity;
289 let hi = -Infinity;
290 for (const v of valueBufs[k]) {
291 if (v < lo) lo = v;
292 if (v > hi) hi = v;
293 }
294 // smooth the color range in both directions so the shading evolves
295 // gently as the pattern grows (out-of-range values clamp meanwhile)
296 const r = ranges[k];
297 if (!Number.isFinite(r.lo)) {
298 r.lo = lo;
299 r.hi = hi;
300 } else {
301 const a = 0.15;
302 r.lo += a * (lo - r.lo);
303 r.hi += a * (hi - r.hi);
304 }
305 if (r.hi - r.lo < 1e-9) {
306 const mid = (r.hi + r.lo) / 2;
307 r.lo = mid - 5e-10;
308 r.hi = mid + 5e-10;
309 }
310 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
311 scenes[k]?.updateColors(colorBufs[k]);
312 colorbars[k]?.update(cmap, r.lo, r.hi);
313 }
314}
316function updateStats(): void {
317 if (!sim || !backend) return;
318 const { nlat, nphi } = backend.cfg;
319 const kind =
320 backend.kind === 'webgpu'
321 ? `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`
322 : 'CPU f64 (direct summation)';
323 const rate = stepMs > 0 ? `${(1000 / stepMs).toFixed(1)} steps/s` : '—';
324 elStats.innerHTML =
325 `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${backend.nlm.toLocaleString()} · ` +
326 `${stepMs > 0 ? stepMs.toFixed(1) : '—'} ms/step · ${rate} · ` +
327 `t = <b>${sim.t.toFixed(2)}</b> (${sim.stepCount} steps)`;
328}
330// ---------------------------------------------------------------- sim loop
331const nextFrame = () => new Promise<number>(requestAnimationFrame);
333async function pump(): Promise<void> {
334 if (pumping) return;
335 pumping = true;
336 const gen = generation;
337 let lastYield = performance.now();
338 try {
339 while (running && sim && gen === generation) {
340 const t0 = performance.now();
341 await sim.step();
342 const dtMs = performance.now() - t0;
343 stepMs = stepMs === 0 ? dtMs : stepMs + 0.05 * (dtMs - stepMs);
344 const now = performance.now();
345 if (now - lastYield > 25 || backend?.kind === 'cpu') {
346 draw();
347 updateStats();
348 await nextFrame();
349 lastYield = performance.now();
350 }
351 }
352 // final frame after pausing
353 if (gen === generation) {
354 draw();
355 updateStats();
356 }
357 } finally {
358 pumping = false;
359 }
360}
362// ---------------------------------------------------------------- boot
363async function boot(): Promise<void> {
364 elModel.value = presets[0].key;
365 applyPreset(presets[0].key);
366 try {
367 device = await requestShtDevice();
368 adapterName = await describeAdapter(device);
369 } catch (e) {
370 device = null;
371 elLmax.value = '31';
372 elBackend.value = 'cpu';
373 elBackend.options[0].disabled = true;
374 elErr.textContent =
375 `WebGPU is not available (${e instanceof Error ? e.message : e}); ` +
376 `falling back to the slow CPU transform at low resolution. ` +
377 `Use a WebGPU-capable browser (Chrome/Edge 113+) for the full experience.`;
378 }
379 device?.lost.then((info) => {
380 if (info.reason !== 'destroyed') {
381 elErr.textContent = `WebGPU device lost: ${info.message}`;
382 }
383 });
384 await rebuild();
385}
387void boot();