/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
Widget: Open the full app, the bundle's own instrument page in a closeable full-window overlay; unedited form fields keep their exact values, so the page and the widget hash the same cache keys; vault1 site in README
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 3760879b4a95 parent 4e56865 Browse files
7 changed files+112−19
README.mdmodified+3−2View file
@@ -1,6 +1,6 @@
11 # toy-instruments
22
3-Live: https://magland.github.io/toy-instruments/ (deployed from `main` by `.github/workflows/pages.yml`).
3+Live: https://magland.github.io/toy-instruments/ and https://toy-instruments--proof-of-concept.vault1-sites.magland.org/, both deployed from `main` by `.github/workflows/pages.yml` (on GitHub, and on vault1, whose site source is set to `actions`).
44
55 A browser gallery of toy physical-model instruments. Pick an instrument, set its geometry, properties and excitation, run the simulation, and get the pickup waveform, a spectrogram, a playable/downloadable `.wav`, and the bead displacements over time. Static site (Vite + TypeScript); the solver runs in a Web Worker, on the CPU or with WebGPU (see Solvers).
66
@@ -67,7 +67,7 @@ Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `to
6767
6868 ## Shared links
6969
70-"Copy link to these settings" (under the preset) copies `…/#/<instrument>?<param>=<value>&…&preset=<index>&gpu=<0|1>`: every parameter in SI units, the preset the settings started from (its description is shown) and the WebGPU toggle (CPU and WebGPU results are cached separately). Opening the link applies the settings and then removes them from the address bar (`history.replaceState`), so reloading or sharing the plain address shows the page as usual; the WebGPU setting from a link is not saved as this browser's preference. Parameters missing from a link, invalid or not among a select's options keep the values of the link's preset, and unknown ones are ignored, so links survive added or removed parameters. When the shared cache has a result for the linked settings, the usual Load cached button appears.
70+"Copy link to these settings" (under the preset) copies `…/#/<instrument>?<param>=<value>&…&preset=<index>&gpu=<0|1>`: every parameter in SI units, the preset the settings started from (its description is shown) and the WebGPU toggle (CPU and WebGPU results are cached separately). The form shows numbers in display units rounded to 10 digits, so a field the user has not edited keeps its exact value rather than being read back from the text (which can change the last bit, and with it the cache key). Opening the link applies the settings and then removes them from the address bar (`history.replaceState`), so reloading or sharing the plain address shows the page as usual; the WebGPU setting from a link is not saved as this browser's preference. Parameters missing from a link, invalid or not among a select's options keep the values of the link's preset, and unknown ones are ignored, so links survive added or removed parameters. When the shared cache has a result for the linked settings, the usual Load cached button appears.
7171
7272 ## Embedding
7373
@@ -80,6 +80,7 @@ Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `to
8080 - `settings` is a link from "Copy link to these settings" (or its `#/<instrument>?…` part). `exaggeration` magnifies the drawn motion (default 5); it is not part of the cache key. `name` names the downloaded `.wav`.
8181 - The reader sees the model at rest (the 3D view without its tabs, view buttons or exaggeration input; drag to rotate) and two buttons. "Play sound" downloads the light entry and plays it in an audio player; "Play motion (N MB)" downloads the frames and plays the replay in slow motion, with pause, a time slider and a speed choice. Each row expands in place, so the other button does not move. On load the widget only sends HEAD requests (for the link's backend first, then the other).
8282 - If the cache has no result, or cannot be reached, the widget offers to run the simulation in the browser, with a rough time estimate, and then shows both players. With the cache passcode saved in that browser (under "Shared cache" in the widget), the run is stored under the same key.
83+- "Open the full app" shows the whole instrument page (every parameter, the plots, Run and Load cached) over the host page, starting from the widget's settings; Close or Escape returns to the post, and reopening keeps its state. It is the bundle's own copy of the page (not the live app in a frame), so it computes the same cache keys as the widget. It has no back link or "Copy link", and leaves the address alone.
8384 - The key hashes what the bundle's own model code builds, so a page that keeps one build of the bundle asks for, and can always refill, the same entries, whatever later versions of the app do. jeremy.magland.org uploads each build under a content hash and never replaces it.
8485 - It takes the host page's colors and font from jeremy.magland.org's CSS variables (`--bg`, `--text`, `--link`, ...), with this app's colors otherwise, and redraws when the page changes theme.
8586 - Build: `npm run build:embed` writes `embed-dist/toy-instruments-embed.js` (one ES module, about 1 MB, 230 kB gzipped), with three.js, the styles and the solver worker inlined; a worker must come from the page's origin, so it starts from a blob URL. The `prepare` script runs the same build, so the package can be installed as a git dependency (`import 'toy-instruments/embed'`); `embed-dist/` is not committed.
src/embed.cssmodified+22−1View file
@@ -26,6 +26,12 @@
2626 --series-1: #2a78d6;
2727 --series-2: #eb6834;
2828 --series-3: #1baf7a;
29+ --grid: #ecebe7;
30+ --axis: #b9b7b0;
31+ --warn-bg: #fdf3dc;
32+ --warn-ink: #6b4a00;
33+ --ramp: #fcfcfb, #cde2fb, #86b6ef, #3987e5, #1c5cab, #0d366b;
34+ --diverging: #1c5cab, #86b6ef, #f0efec, #f0a3a2, #b42d2c;
2935 font: 14px/1.45 var(--font-ui, system-ui, -apple-system, 'Segoe UI', sans-serif);
3036 color: var(--text-primary);
3137 background: var(--surface-1);
@@ -37,6 +43,12 @@
3743 --series-1: #3987e5;
3844 --series-2: #d95926;
3945 --series-3: #199e70;
46+ --grid: #262624;
47+ --axis: #4a4a46;
48+ --warn-bg: #3a2e12;
49+ --warn-ink: #f3d18a;
50+ --ramp: #1a1a19, #104281, #1c5cab, #3987e5, #86b6ef, #cde2fb;
51+ --diverging: #6da7ec, #1c5cab, #383835, #a83434, #e66767;
4052 }
4153 .scene-wrap { height: 340px; }
4254 @media (max-width: 600px) { .scene-wrap { height: 260px; } }
@@ -49,5 +61,14 @@
4961 .player .audio-row, .player .viewer-bar { margin: 0; }
5062 .error { color: #b42d2c; font-size: 13px; margin: 8px 0 0; }
5163 .tis.dark .error { color: #e66767; }
52-.cache-ctl { font-size: 12px; margin-top: 10px; }
64+.footer { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; margin-top: 10px; }
65+.footer .cache-ctl { margin: 0; flex: 1; }
66+.cache-ctl { font-size: 12px; }
67+
68+/* the full app over the whole window */
69+.overlay { position: fixed; inset: 0; z-index: 2147483000; background: var(--page); color: var(--text-primary); overflow: auto; overscroll-behavior: contain; }
70+.overlay-bar { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 8px 16px; background: var(--surface-1); border-bottom: 1px solid var(--border); font-weight: 600; }
71+.overlay .instrument { padding-top: 12px; }
72+.overlay .panel { top: 60px; max-height: calc(100vh - 76px); }
73+.overlay .btn { color: var(--accent-ink); }
5374 .cache-ctl input { width: auto; flex: 1; }
src/embed.tsmodified+59−3View file
@@ -5,8 +5,9 @@ import { CONFIGS, type AnyConfig } from './instruments/configs';
55 import { BeadScene } from './ui/scene3d';
66 import { RATES, Timeline } from './ui/timeline';
77 import { encodeWav } from './ui/wav';
8-import { fmtNum } from './ui/plot';
9-import { CPU_RATE, parseSettings, runCost, simOptions } from './ui/settings';
8+import { fmtNum, setStyleRoot } from './ui/plot';
9+import { mountInstrumentPage } from './ui/instrumentPage';
10+import { CPU_RATE, parseSettings, runCost, simOptions, type Settings } from './ui/settings';
1011 import { runInWorker, type Running } from './sim/run';
1112 import { checkPasscode, getPasscode, loadFrames, loadLight, probeFrames, probeResult, resultKey, setPasscode, storeResult, type Backend, type LightResult } from './sim/cache';
1213 import type { Built, SimParams } from './ui/instrumentPage';
@@ -24,7 +25,10 @@ import type { SimResult } from './sim/types';
2425 // what it needs from the shared cache on first press (the small light entry for the sound,
2526 // the frames for the motion, played in slow motion). When the cache has no result, the
2627 // widget offers to run the simulation in the browser; with the cache passcode set, the run
27-// is stored under the same key, so the next reader gets it from the cache.
28+// is stored under the same key, so the next reader gets it from the cache. "Open the full
29+// app" shows the whole instrument page (all parameters, plots, runs) over the host page,
30+// starting from these settings; it is this bundle's copy of the app, so it computes the
31+// same cache keys as the widget.
2832 //
2933 // The key hashes what this bundle's model code builds from the settings, so a page that
3034 // keeps this exact bundle (jeremy.magland.org freezes each upload) always asks for, and
@@ -49,6 +53,9 @@ class ToyInstrumentSim extends HTMLElement {
4953 private found: Found | null = null;
5054 private light: Promise<LightResult> | null = null;
5155 private exaggeration = 5;
56+ private cfg!: AnyConfig;
57+ private settings!: Settings<SimParams>;
58+ private overlayMounted = false;
5259
5360 connectedCallback() {
5461 if (this.shadowRoot) return;
@@ -79,12 +86,19 @@ class ToyInstrumentSim extends HTMLElement {
7986 </div>
8087 <progress data-r="progress" max="1" value="0" hidden></progress>
8188 <p class="error" data-r="error" hidden></p>
89+ <div class="footer">
90+ <button class="btn-small" data-r="open-app" hidden>Open the full app</button>
8291 <details class="cache-ctl">
8392 <summary data-r="cache-state"></summary>
8493 <p class="note">Results come from a shared cache. With its passcode, simulations run here are stored there, for everyone who opens this page.</p>
8594 <div class="cache-form"><input type="password" data-r="cache-pass" placeholder="Passcode" autocomplete="off"><button class="btn-small" data-r="cache-save">Save</button><button class="btn-small" data-r="cache-forget">Forget</button></div>
8695 <p class="note" data-r="cache-msg"></p>
8796 </details>
97+ </div>
98+ <div class="overlay" data-r="overlay" role="dialog" aria-modal="true" aria-label="Full app" hidden>
99+ <div class="overlay-bar"><span>Toy instruments</span><button class="btn-small" data-r="close-app">Close ✕</button></div>
100+ <div data-r="app"></div>
101+ </div>
88102 </div>`;
89103 this.mountPasscode();
90104 this.mountMotionBar();
@@ -95,6 +109,7 @@ class ToyInstrumentSim extends HTMLElement {
95109 this.running?.cancel();
96110 this.abort.abort();
97111 this.timeline.pause();
112+ this.closeApp();
98113 for (const f of this.cleanup) f();
99114 this.cleanup = [];
100115 }
@@ -124,7 +139,10 @@ class ToyInstrumentSim extends HTMLElement {
124139 return this.fail(`Unknown instrument "${id}" in the settings attribute.`);
125140 }
126141 const settings = parseSettings(cfg, query);
142+ this.cfg = cfg;
143+ this.settings = settings;
127144 this.p = settings.params;
145+ this.mountOverlay();
128146 const built = cfg.setup(this.p).built;
129147 if (!built) {
130148 this.status('');
@@ -316,6 +334,44 @@ class ToyInstrumentSim extends HTMLElement {
316334 this.q('audio-row').parentElement!.classList.add('wide');
317335 }
318336
337+ // The full instrument page in a full-window overlay, mounted on first open and kept
338+ // (with its state) while closed.
339+ private mountOverlay() {
340+ const open = this.q<HTMLButtonElement>('open-app');
341+ open.hidden = false;
342+ open.addEventListener('click', () => this.openApp());
343+ this.q('close-app').addEventListener('click', () => this.closeApp());
344+ this.q('overlay').addEventListener('keydown', (e) => {
345+ if (e.key === 'Escape') this.closeApp();
346+ });
347+ }
348+
349+ private bodyOverflow: string | null = null;
350+
351+ private openApp() {
352+ const overlay = this.q('overlay');
353+ this.timeline.pause();
354+ this.q<HTMLAudioElement>('audio').pause();
355+ setStyleRoot(overlay);
356+ if (!this.overlayMounted) {
357+ this.overlayMounted = true;
358+ mountInstrumentPage(this.q('app'), this.cfg, { makeWorker: () => new SolverWorker(), embedded: { settings: this.settings } });
359+ }
360+ overlay.hidden = false;
361+ this.bodyOverflow = document.body.style.overflow;
362+ document.body.style.overflow = 'hidden';
363+ this.q('close-app').focus();
364+ }
365+
366+ private closeApp() {
367+ const overlay = this.q('overlay');
368+ if (overlay.hidden) return;
369+ overlay.hidden = true;
370+ document.body.style.overflow = this.bodyOverflow ?? '';
371+ setStyleRoot(null);
372+ this.q('open-app').focus();
373+ }
374+
319375 // Dark or light series colors from the host page's color-scheme, and a redraw when the
320376 // page switches theme (jeremy.magland.org sets data-theme on <html>).
321377 private followTheme() {
src/instruments/registry.tsmodified+3−1View file
@@ -1,7 +1,9 @@
11 import { mountInstrumentPage } from '../ui/instrumentPage';
22 import { CONFIGS } from './configs';
33
4-const mount = (id: string) => (root: HTMLElement) => mountInstrumentPage(root, CONFIGS[id]);
4+// the app loads the solver worker from its own file (the embedded widget inlines it)
5+const makeWorker = () => new Worker(new URL('../sim/worker.ts', import.meta.url), { type: 'module' });
6+const mount = (id: string) => (root: HTMLElement) => mountInstrumentPage(root, CONFIGS[id], { makeWorker });
57
68 export interface Instrument {
79 id: string;
src/ui/controls.tsmodified+5−0View file
@@ -99,6 +99,11 @@ export function renderControls<P extends object>(
9999 const v = el.value;
100100 p[spec.key] = spec.kind === 'select' && typeof p[spec.key] === 'number' ? Number(v) : v;
101101 } else {
102+ // A field left as written keeps its exact value: the text is rounded to 10 digits
103+ // and in display units, so reading it back can change the last bit, and with it the
104+ // cache key (presets and shared links must hash as in the embedded widget).
105+ const prev = p[spec.key];
106+ if (typeof prev === 'number' && el.value === fmt(prev * (spec.scale ?? 1))) continue;
102107 let v = Number(el.value) / (spec.scale ?? 1);
103108 if (spec.kind === 'int') v = Math.round(v);
104109 if (Number.isFinite(v)) p[spec.key] = v;
src/ui/instrumentPage.tsmodified+14−9View file
@@ -5,7 +5,7 @@ import { spectrum } from './fft';
55 import { encodeWav } from './wav';
66 import { BeadScene, type SceneSpec } from './scene3d';
77 import { Timeline } from './timeline';
8-import { runInWorker } from '../sim/run';
8+import { runInWorker, type MakeWorker } from '../sim/run';
99 import { CPU_RATE, parseSettings, runCost, settingsQuery, simOptions, type Settings } from './settings';
1010 import { checkPasscode, getPasscode, loadResult, probeResult, resultKey, setPasscode, storeResult, type Backend } from '../sim/cache';
1111 import type { BeadSystem, Excitation, SimResult } from '../sim/types';
@@ -55,17 +55,24 @@ export interface InstrumentConfig<P extends SimParams> {
5555 mountExtras?(q: <T extends HTMLElement>(r: string) => T, timeline: Timeline): Extras<P>;
5656 }
5757
58+export interface PageOptions<P> {
59+ makeWorker: MakeWorker; // starts a solver worker (src/sim/worker.ts)
60+ // Mounted inside another page (the embedded widget's full-app view): start from these
61+ // settings, leave the address alone, and leave out the back link and "Copy link".
62+ embedded?: { settings: Settings<P> };
63+}
64+
5865 const statRows = (rows: [string, string][]) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
5966
60-export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg: InstrumentConfig<P>) {
67+export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg: InstrumentConfig<P>, opts: PageOptions<P>) {
6168 root.innerHTML = `
6269 <div class="instrument">
6370 <aside class="panel">
64- <a href="#/" class="back">← All instruments</a>
71+ ${opts.embedded ? '' : '<a href="#/" class="back">← All instruments</a>'}
6572 <h1>${cfg.title}</h1>
6673 <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
6774 <p class="sub" data-r="preset-desc"></p>
68- <div class="share-row"><button class="btn-small" data-r="share" title="Copy a link that opens this page with the current settings">Copy link to these settings</button><span class="note" data-r="share-msg"></span></div>
75+ <div class="share-row"${opts.embedded ? ' hidden' : ''}><button class="btn-small" data-r="share" title="Copy a link that opens this page with the current settings">Copy link to these settings</button><span class="note" data-r="share-msg"></span></div>
6976 <input class="share-url" data-r="share-url" readonly hidden aria-label="link to these settings">
7077 <div class="run-row top">
7178 <button class="btn" data-r="run">Run simulation</button>
@@ -175,7 +182,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
175182 let pendingAudio: Blob | null = null; // a newer partial result's audio, held back while the player plays
176183
177184 // settings from a shared link, applied once and then removed from the address
178- const shared = readSharedLink(cfg);
185+ const shared = opts.embedded?.settings ?? readSharedLink(cfg);
179186 if (shared) {
180187 presetSel.value = String(shared.preset);
181188 if (shared.gpu !== undefined && hasGpu) gpuBox.checked = shared.gpu;
@@ -191,7 +198,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
191198
192199 const shareMsg = q<HTMLSpanElement>('share-msg');
193200 const shareUrl = q<HTMLInputElement>('share-url');
194- if (shared) shareMsg.textContent = 'Settings loaded from a shared link.';
201+ if (shared && !opts.embedded) shareMsg.textContent = 'Settings loaded from a shared link.';
195202 q('share').addEventListener('click', async () => {
196203 const url = sharedLink(controls.get(), Number(presetSel.value), gpuBox.checked);
197204 shareUrl.hidden = true;
@@ -424,7 +431,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
424431 });
425432 latest = r;
426433 };
427- const running = runInWorker(pageWorker, built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial);
434+ const running = runInWorker(opts.makeWorker, built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial);
428435 setBusy(running.cancel);
429436 try {
430437 const r = await running.promise;
@@ -527,8 +534,6 @@ function readSharedLink<P extends SimParams>(cfg: InstrumentConfig<P>): Settings
527534 return parseSettings(cfg, query);
528535 }
529536
530-const pageWorker = () => new Worker(new URL('../sim/worker.ts', import.meta.url), { type: 'module' });
531-
532537 // separate contact intervals in the contact-force record
533538 function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] {
534539 const out: { start: number; ms: number; peak: number }[] = [];
src/ui/plot.tsmodified+6−3View file
@@ -1,9 +1,12 @@
11 // Small canvas plotting helpers: one axis per chart, recessive grid,
22 // min/max decimation for long signals, crosshair + tooltip on hover.
33
4-// a CSS custom property's value, as seen by element el (the page root by default; an
5-// embedded widget passes an element inside its shadow root, where its own tokens apply)
6-export const css = (name: string, el: Element = document.documentElement) => getComputedStyle(el).getPropertyValue(name).trim();
4+// A CSS custom property's value, as seen by element el, by default the style root: the
5+// page root, or an element inside the embedded widget's shadow root while its full-app
6+// view is open (the widget's color tokens are defined there, not on the page).
7+let styleRoot: Element | null = null;
8+export const setStyleRoot = (el: Element | null) => (styleRoot = el);
9+export const css = (name: string, el?: Element) => getComputedStyle(el ?? styleRoot ?? document.documentElement).getPropertyValue(name).trim();
710
811 export interface Series {
912 x: ArrayLike<number>; // increasing
↑↓ move↵openescclose