diff --git a/web/index.html b/web/index.html index 1b41221..f6c507c 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ + diff --git a/web/replay.js b/web/replay.js index 3b65b4d..67307ad 100644 --- a/web/replay.js +++ b/web/replay.js @@ -27,6 +27,7 @@ – + … @@ -163,6 +164,8 @@ const SEQ_BLUE = ['#9ec5f4', '#3987e5', '#184f95']; // one hue, light → dark: psi_N 0.3, 0.6, 0.9 let shot = null, db = null, lines3dOk = true, linesFail503 = 0, use3 = false, playTimer = null, speed = 1; let initP = null, loadSeq = 0; + const SL = window.FusionLabShotLoad; // the shared shot-load machine (shotload.js): one fetch lifecycle for every shot + let watchId = null; // the shot the single-load chip watches (dropdown or deep-link pick) let wi = { on: false, gate: null, res: null, seq: 0, timer: null }; // what-if: gate = /virtual/gate, res = last /virtual/{id} answer const lineCache = new Map(); const psiCache = new Map(); @@ -171,9 +174,10 @@ // ---- multi-shot compare (A2) state. The reference (first id) keeps the single-shot panels (3D vessel, // equilibrium, what-if, logbook); every other shot adds one measured trace per time panel. Payloads cache - // per shot id, so the compare fan-out and a single pick share one fetch. + // per shot id, so the compare fan-out and a single pick share one fetch. Per-shot fetch state (queued / + // fetching / ready / error) lives in the shared machine (shotload.js) — the same one a single pick uses. const CMP_COLORS = ['#d95926', '#199e70', '#c98500', '#d55181', '#9a7fd6']; // per-shot slots 2–6; the reference keeps the blue - const cmp = { on: false, ids: [], ref: null, data: new Map(), st: new Map(), err: new Map(), seq: 0, ctrls: new Map() }; + const cmp = { on: false, ids: [], ref: null, data: new Map(), seq: 0 }; const replayCache = new Map(); const fmt = (v, nd = 2) => (v === null || v === undefined || !isFinite(v)) ? '–' : Number(v).toFixed(nd); @@ -231,6 +235,14 @@ const seen = hintSeen; $('rp-hint').hidden = seen(); $('rp-hint-x').addEventListener('click', () => { $('rp-hint').hidden = true; try { localStorage.setItem('fusionlab-hint', 'seen'); } catch (e) { /* private window */ } }); + $('rp-load').addEventListener('click', e => { + const b = e.target.closest('[data-load]'); + if (!b || watchId === null) return; + if (b.dataset.load === 'retry') { $('rp-shot').value = watchId; return void loadShot(watchId); } + SL.drop(watchId); // Drop: give up on this pick — the chip goes away, the panels keep what they had + watchId = null; + paintLoadChip(); + }); // Compute chip (design §7): where compute runs, from a polled /compute status. Never blocks // anything — a failed or slow poll keeps the last known state; the next one retries. @@ -372,31 +384,64 @@ async function loadShot(id) { if (cmp.on) return cmpSetRef(id); // in compare, every pick — dropdown, search row — re-points the reference const seq = ++loadSeq; + watchId = id; // the status chip follows this pick through the machine's states let next; try { - next = await getReplay(id); + next = await SL.fetch(id, signal => getReplay(id, signal)); } catch (e) { - if (seq === loadSeq) { - $('rp-shot').value = shot ? shot.meta.shot_id : ''; // the select stays in sync with the panels - $('rp-head').textContent = `#${id} failed to load — ${e.message || 'fetch failed'}`; - } + if (seq !== loadSeq) return; // a newer pick is in flight; it owns the panels and the chip + $('rp-shot').value = shot ? shot.meta.shot_id : ''; // the select stays in sync with the panels + $('rp-head').textContent = `#${id} failed to load — ${e.message || 'fetch failed'}`; return; } if (seq !== loadSeq) return; // a newer pick is in flight: the last click wins, not the last response - renderShot(next); + if (next) renderShot(next); // null = superseded: a newer fetch owns the panels + } + + // Single-shot load chip (spec V6): the machine's states, rendered for the pick a dropdown or deep link + // asked for. Fetching shows in the toolbar's status cluster; ready drops the chip away — the rendered + // panels are the signal then. An error chip states the reason and offers Retry · Drop. + function paintLoadChip() { + const chip = $('rp-load'); + if (!chip) return; + const s = watchId === null ? null : SL.get(watchId); + if (s === 'fetching') { + chip.hidden = false; + chip.className = 'status wait'; + chip.setAttribute('aria-busy', 'true'); + chip.innerHTML = `loading shot #${watchId}…`; + } else if (s === 'error') { + chip.hidden = false; + chip.className = 'status bad'; + chip.removeAttribute('aria-busy'); + chip.innerHTML = `couldn't load #${watchId} — ${esc(SL.errorOf(watchId) || 'fetch failed')}` + + ` ` + + ` `; + } else { + if (s === 'ready') watchId = null; // the panels rendered; the chip's job is done + chip.hidden = true; + chip.removeAttribute('aria-busy'); + chip.innerHTML = ''; + } } + // The machine repaints its watchers on every transition: the single-load chip here, and the compare + // bar's chips (cmpPaint). Both painters are idempotent innerHTML rebuilds. + SL.onChange(() => { paintLoadChip(); cmpPaint(); }); // Shared replay payload cache: the compare fan-out and a single pick hit the same promise per shot id. - // A failed fetch is not cached — a retry refetches. + // A failed fetch is not cached — a retry refetches. Each promise is stored with the signal it was created + // under: a fetch under a NEW signal must not adopt an in-flight promise that an older signal is about to + // abort (the machine supersedes per shot), so a signal mismatch refetches. function getReplay(id, signal) { const cached = replayCache.get(id); - if (cached) return cached; - const p = fetch('/replay/' + id, { signal }).then(async r => { + if (cached && (!signal || !cached.signal || cached.signal === signal)) return cached.p; + const entry = { p: null, signal: signal || null }; + entry.p = fetch('/replay/' + id, { signal }).then(async r => { if (!r.ok) throw Object.assign(new Error((await r.json().catch(() => null))?.detail || 'HTTP ' + r.status), { status: r.status }); return r.json(); - }).catch(e => { replayCache.delete(id); throw e; }); - replayCache.set(id, p); - return p; + }).catch(e => { if (replayCache.get(id) === entry) replayCache.delete(id); throw e; }); + replayCache.set(id, entry); + return entry.p; } function renderShot(next) { @@ -935,7 +980,7 @@ return CMP_COLORS[Math.max(0, cmp.ids.filter(x => x !== cmp.ref).indexOf(id)) % CMP_COLORS.length]; } - const cmpReadyIds = () => cmp.ids.filter(id => cmp.st.get(id) === 'ready' && cmpData(id)); + const cmpReadyIds = () => cmp.ids.filter(id => SL.get(id) === 'ready' && cmpData(id)); const cmpUrlParam = () => [cmp.ref, ...cmp.ids.filter(id => id !== cmp.ref)].join(','); @@ -963,50 +1008,39 @@ async function startCompare(idsIn) { cmp.seq++; - cmp.ctrls.forEach(c => c.abort()); - cmp.ctrls.clear(); + SL.cancelAll(); + cmp.ids.forEach(id => SL.drop(id)); // the machine forgets the previous batch's per-shot state cmp.ids = [...new Set((idsIn || []).map(Number).filter(Boolean))].slice(0, SRCH_MAX); cmp.ref = cmp.ids[0] ?? null; cmp.on = true; - cmp.st.clear(); cmp.err.clear(); + watchId = null; paintLoadChip(); // the compare bar owns per-shot state now; the single chip stands down cmpChrome(); cmpUrl(); cmpSyncSel(); if (cmp.ids.length < 2) { cmpPaint(); return; } // Empty: a prompt with a shortcut back to the catalog // The reference renders through the same path as a single pick; it may already be on screen (?shot= deep link). - if (shot && shot.meta.shot_id === cmp.ref) { cmp.st.set(cmp.ref, 'ready'); drawStrips(); scrub(+$('rp-t').value); } + if (shot && shot.meta.shot_id === cmp.ref) { SL.markReady(cmp.ref); drawStrips(); scrub(+$('rp-t').value); } for (const id of cmp.ids) { - if (cmp.st.get(id) === 'ready') continue; - cmp.st.set(id, 'fetching'); - cmpFetch(id, cmp.seq, id === cmp.ref ? p => renderShot(p) : null); + if (SL.get(id) === 'ready') continue; + cmpFetch(id, id === cmp.ref ? p => renderShot(p) : null); } cmpPaint(); } - // One payload per shot: on resolve, a non-reference shot redraws the overlays; the reference goes through - // renderShot like any single pick. An error (or a cancel) lands in the per-shot error list. - async function cmpFetch(id, my, onReady) { - const ctrl = new AbortController(); - cmp.ctrls.set(id, ctrl); - // Fetch and render are separate try-scopes: a rendering exception must surface as an error in the - // console, never masquerade as a fetch failure on the shot. + // One payload per shot, fetched under the shared machine — supersede and honest errors are its to record. + // On resolve, a non-reference shot redraws the overlays; the reference goes through renderShot like any + // single pick. Fetch and render stay separate try-scopes: a rendering exception must surface as an error + // in the console, never masquerade as a fetch failure on the shot. + async function cmpFetch(id, onReady) { let payload; try { - payload = await getReplay(id, ctrl.signal); + payload = await SL.fetch(id, signal => getReplay(id, signal)); } catch (e) { - if (my !== cmp.seq) return; - cmp.st.set(id, 'error'); - cmp.err.set(id, e?.name === 'AbortError' ? 'fetch cancelled' : (e?.message || 'fetch failed')); - cmp.ctrls.delete(id); - cmpPaint(); - return; + return; // the machine recorded the error; the onChange listener repainted the bar with Retry/Drop } - if (my !== cmp.seq) return; + if (payload === null || !cmp.on) return; // superseded, or compare exited while the fetch was in flight cmp.data.set(id, payload); - cmp.st.set(id, 'ready'); - cmp.err.delete(id); - cmp.ctrls.delete(id); if (onReady) onReady(payload); else if (id !== cmp.ref) { drawCmpW(); drawCmpLim(); drawDw(); cmpThomson(); } - if (my === cmp.seq) cmpPaint(); + cmpPaint(); } async function cmpSetRef(id) { // chips, dropdown picks and search rows all land here @@ -1021,21 +1055,19 @@ cmpPaint(); return sliceState(+$('rp-t').value); } - if (cmp.st.get(id) === 'ready') { renderShot(cmpData(id)); return sliceState(+$('rp-t').value); } - cmp.st.set(id, 'fetching'); // the reference itself still has to arrive (the Failed state retries through here) - cmp.err.delete(id); - cmpPaint(); - await cmpFetch(id, cmp.seq, p => { if (my === loadSeq) renderShot(p); }); + if (SL.get(id) === 'ready') { renderShot(cmpData(id)); return sliceState(+$('rp-t').value); } + // the reference itself still has to arrive (the Failed state retries through here); the machine paints + // 'fetching' on entry + await cmpFetch(id, p => { if (my === loadSeq) renderShot(p); }); return shot && shot.meta.shot_id === id ? sliceState(+$('rp-t').value) : null; } function cmpDrop(id) { // from the Partial/Failed error list, or when the selection drops below two cmp.ids = cmp.ids.filter(x => x !== id); - cmp.st.delete(id); cmp.err.delete(id); cmp.data.delete(id); - const ctrl = cmp.ctrls.get(id); - if (ctrl) { ctrl.abort(); cmp.ctrls.delete(id); } + cmp.data.delete(id); + SL.drop(id); // the machine aborts a still-in-flight fetch and forgets the shot's state if (cmp.ref === id) { - cmp.ref = (cmp.ids.find(x => cmp.st.get(x) === 'ready') ?? cmp.ids[0]) ?? null; + cmp.ref = (cmp.ids.find(x => SL.get(x) === 'ready') ?? cmp.ids[0]) ?? null; cmpChrome(); const next = cmpData(cmp.ref); if (next && shot && shot.meta.shot_id !== cmp.ref) renderShot(next); @@ -1047,10 +1079,10 @@ function exitCompare() { if (!cmp.on) return; cmp.seq++; - cmp.ctrls.forEach(c => c.abort()); - cmp.ctrls.clear(); + SL.cancelAll(); + cmp.ids.forEach(id => SL.drop(id)); // the machine forgets this batch cmp.on = false; - cmp.ids = []; cmp.ref = null; cmp.st.clear(); cmp.err.clear(); cmp.data.clear(); + cmp.ids = []; cmp.ref = null; cmp.data.clear(); cmpUrl(); cmpChrome(); srchPaint(); // the checkboxes stay; the compare button can re-enter if (shot) { drawStrips(); drawSection(); scrub(+$('rp-t').value); } @@ -1059,18 +1091,18 @@ function cmpPaint() { // the compare bar: per-shot chips, the state, and the actions the state allows if (!cmp.on) return; const n = cmp.ids.length; - const readyN = cmp.ids.filter(id => cmp.st.get(id) === 'ready').length; - const fetching = cmp.ids.some(id => (cmp.st.get(id) || 'queued') !== 'ready' && cmp.st.get(id) !== 'error'); - const failed = cmp.ids.filter(id => cmp.st.get(id) === 'error'); + const readyN = cmp.ids.filter(id => SL.get(id) === 'ready').length; + const fetching = cmp.ids.some(id => (SL.get(id) || 'queued') !== 'ready' && SL.get(id) !== 'error'); + const failed = cmp.ids.filter(id => SL.get(id) === 'error'); const STATE = { queued: 'queued', fetching: 'fetching from the archive…', ready: 'ready', error: 'failed' }; const chips = n ? cmp.ids.map(id => { - const s = cmp.st.get(id) || 'queued'; + const s = SL.get(id) || 'queued'; const cls = s === 'ready' ? 'ok' : s === 'error' ? 'bad' : 'wait'; const ref = id === cmp.ref; return ``; }).join('') : 'no shots selected'; - const errs = failed.map(id => `