Copy link to these settings: a shareable link with every parameter, applied on load and then removed from the address
4 changed files+79−3
README.mdmodified+4−0View file
@@ -65,6 +65,10 @@ Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (app `to
6565 - As the settings (or the WebGPU toggle) change, the page checks whether the cache has a result for them (a HEAD request, debounced) and, if so, shows its date and size with a Load cached button next to Run. Run always simulates; when the passcode is set, every completed run is stored, replacing any existing entry.
6666 - Because the key hashes floating-point inputs, a browser whose `Math` functions round differently in the last bit (possible in Safari) builds a different key and misses entries computed elsewhere. It never gets a wrong result.
6767
68+## Shared links
69+
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.
71+
6872 ## Layout
6973
7074 - `src/sim/` generic 3D bead-spring system, CPU solver (`solver.ts`), WebGPU solver (`gpu.ts`), worker.
src/main.tsmodified+2−1View file
@@ -15,7 +15,8 @@ function gallery() {
1515 }
1616
1717 function route() {
18- const id = location.hash.replace(/^#\/?/, '');
18+ // #/<instrument>, optionally ?<settings> from a shared link (the page reads and removes them)
19+ const id = location.hash.replace(/^#\/?/, '').split('?')[0];
1920 const inst = INSTRUMENTS.find((i) => i.id === id && i.mount);
2021 window.scrollTo(0, 0);
2122 if (!inst) return gallery();
src/style.cssmodified+3−0View file
@@ -115,6 +115,9 @@ input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1
115115 .btn { background: var(--accent); color: var(--accent-ink); border: none; padding: 8px 16px; font-weight: 600; }
116116 .btn:disabled { opacity: 0.5; cursor: default; }
117117 .btn-small { background: transparent; color: var(--text-primary); border: 1px solid var(--border); padding: 4px 10px; font-size: 13px; }
118+.share-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 10px; }
119+.share-row .note { margin: 0; }
120+.share-url { width: 100%; box-sizing: border-box; margin: 0 0 10px; font-size: 12px; }
118121 .run-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
119122 .run-row > * { white-space: nowrap; }
120123 .run-row.top { margin-top: 4px; }
src/ui/instrumentPage.tsmodified+70−2View file
@@ -65,6 +65,8 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
6565 <h1>${cfg.title}</h1>
6666 <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
6767 <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>
69+ <input class="share-url" data-r="share-url" readonly hidden aria-label="link to these settings">
6870 <div class="run-row top">
6971 <button class="btn" data-r="run">Run simulation</button>
7072 <button class="btn-small" data-r="cancel" hidden>Cancel</button>
@@ -172,14 +174,39 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
172174 let audioUrl: string | null = null;
173175 let pendingAudio: Blob | null = null; // a newer partial result's audio, held back while the player plays
174176
175- const controls = renderControls(q('controls'), cfg.sections, cfg.presets[0].params, () => updateSetup());
176- presetDesc.textContent = cfg.presets[0].description;
177+ // settings from a shared link, applied once and then removed from the address
178+ const shared = readSharedLink(cfg);
179+ if (shared) {
180+ presetSel.value = String(shared.preset);
181+ if (shared.gpu !== undefined && hasGpu) gpuBox.checked = shared.gpu;
182+ }
183+ const controls = renderControls(q('controls'), cfg.sections, shared?.params ?? cfg.presets[0].params, () => updateSetup());
184+ presetDesc.textContent = cfg.presets[Number(presetSel.value)].description;
177185 presetSel.addEventListener('change', () => {
178186 const pr = cfg.presets[Number(presetSel.value)];
179187 presetDesc.textContent = pr.description;
188+ shareMsg.textContent = '';
180189 controls.set(pr.params);
181190 });
182191
192+ const shareMsg = q<HTMLSpanElement>('share-msg');
193+ const shareUrl = q<HTMLInputElement>('share-url');
194+ if (shared) shareMsg.textContent = 'Settings loaded from a shared link.';
195+ q('share').addEventListener('click', async () => {
196+ const url = sharedLink(controls.get(), Number(presetSel.value), gpuBox.checked);
197+ shareUrl.hidden = true;
198+ try {
199+ await navigator.clipboard.writeText(url);
200+ shareMsg.textContent = 'Link copied.';
201+ } catch {
202+ // no clipboard access: show the link to copy by hand
203+ shareMsg.textContent = 'Copy this link:';
204+ shareUrl.value = url;
205+ shareUrl.hidden = false;
206+ shareUrl.select();
207+ }
208+ });
209+
183210 function updateSetup() {
184211 const p = controls.get();
185212 const { rows, warnings, built } = cfg.setup(p);
@@ -490,6 +517,47 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
490517 });
491518 }
492519
520+// A link to this page with settings p: every parameter, the preset they started
521+// from (for its description) and the WebGPU toggle (CPU and WebGPU results are cached separately).
522+function sharedLink<P extends SimParams>(p: P, preset: number, gpu: boolean): string {
523+ const qs = new URLSearchParams();
524+ for (const [k, v] of Object.entries(p)) qs.set(k, String(v));
525+ qs.set('preset', String(preset));
526+ qs.set('gpu', gpu ? '1' : '0');
527+ const route = location.hash.split('?')[0];
528+ return `${location.origin}${location.pathname}${route}?${qs}`;
529+}
530+
531+// Settings from a shared link in the address (#/<instrument>?...), which is
532+// then removed. Parameters missing from the link (or invalid) keep the values
533+// of the link's preset; unknown ones are ignored, so old links still open.
534+function readSharedLink<P extends SimParams>(cfg: InstrumentConfig<P>): { params: P; preset: number; gpu?: boolean } | null {
535+ const [route, query] = location.hash.split('?');
536+ if (query === undefined) return null;
537+ history.replaceState(history.state, '', `${location.pathname}${location.search}${route}`);
538+ const qs = new URLSearchParams(query);
539+ const n = Number(qs.get('preset'));
540+ const preset = Number.isInteger(n) && n >= 0 && n < cfg.presets.length ? n : 0;
541+ const base = cfg.presets[preset].params;
542+ const params = { ...base } as Record<string, unknown>;
543+ const options = new Map<string, string[]>();
544+ for (const sec of cfg.sections) for (const s of sec.params) if (s.options) options.set(s.key, s.options.map(([v]) => v));
545+ for (const [k, v0] of Object.entries(base)) {
546+ const v = qs.get(k);
547+ if (v === null) continue;
548+ if (typeof v0 === 'number') {
549+ const x = Number(v);
550+ if (v.trim() !== '' && Number.isFinite(x) && (!options.has(k) || options.get(k)!.includes(String(x)))) params[k] = x;
551+ } else if (typeof v0 === 'string' && (!options.has(k) || options.get(k)!.includes(v))) params[k] = v;
552+ }
553+ const gpu = qs.get('gpu');
554+ return {
555+ params: params as P,
556+ preset,
557+ gpu: gpu === null ? undefined : gpu === '1',
558+ };
559+}
560+
493561 // separate contact intervals in the contact-force record
494562 function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] {
495563 const out: { start: number; ms: number; peak: number }[] = [];