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 => `
#${id} — ${esc(cmp.err.get(id) || 'failed')} + const errs = failed.map(id => `
#${id} — ${esc(SL.errorOf(id) || 'failed')}
`).join(''); $('rp-cmp-bar').innerHTML = @@ -1088,13 +1120,12 @@ if (chip) return void cmpSetRef(+chip.dataset.ref); const b = e.target.closest('[data-act],[data-retry],[data-drop]'); if (!b) return; - if (b.dataset.act === 'cancel') { cmp.ctrls.forEach(c => c.abort()); return; } // fetched shots stay plotted + if (b.dataset.act === 'cancel') return SL.cancelAll(); // fetched shots stay plotted if (b.dataset.act === 'exit') return exitCompare(); if (b.dataset.act === 'search') return window.FusionLab.showTab('compare'); if (b.dataset.retry) { const id = +b.dataset.retry; - cmp.st.set(id, 'fetching'); cmp.err.delete(id); cmpPaint(); - return void cmpFetch(id, cmp.seq, id === cmp.ref ? p => renderShot(p) : null); + return void cmpFetch(id, id === cmp.ref ? p => renderShot(p) : null); // the machine paints 'fetching' on entry } if (b.dataset.drop) return cmpDrop(+b.dataset.drop); }); @@ -1118,7 +1149,7 @@ for (const id of cmp.ids) { if (id === cmp.ref) continue; const d = cmpData(id); - if (!d || cmp.st.get(id) !== 'ready') continue; + if (!d || SL.get(id) !== 'ready') continue; if (playTimer) continue; // playback: the reference's profile follows the slider; the others catch up on pause const t = d.measured.t_s; let k = 0; @@ -1181,7 +1212,7 @@ if (id === cmp.ref) continue; const d = cmpData(id); if (!d) continue; - const Wi = cmp.st.get(id) === 'ready' ? interpOnto(t0, d.measured.t_s, d.measured.W_MJ) : null; + const Wi = SL.get(id) === 'ready' ? interpOnto(t0, d.measured.t_s, d.measured.W_MJ) : null; const c = cmpColor(id); traces.push(line(t0, Wi ? Wi.map((w, i) => w == null || W0[i] == null ? null : (w - W0[i]) * 1e3) : [], `ΔW #${id} − #${cmp.ref} (computed)`, c, { line: { color: c, width: 2 } })); diff --git a/web/shotload.js b/web/shotload.js new file mode 100644 index 0000000..ddbca98 --- /dev/null +++ b/web/shotload.js @@ -0,0 +1,83 @@ +// FusionLab shared shot-load state machine (spec L4): THE one fetch lifecycle every shot goes through. +// Generalized from the compare fan-out's chip machine, so single-shot loading renders the same states the +// compare bar always had — queued → fetching → ready | error, with AbortController supersede and honest +// per-shot errors. A superseded fetch (a newer fetch for the same shot, or an external cancel) is not a +// failure: it lands back in 'queued'. The machine owns state only; payloads stay with the caller's render +// layer (replayCache / cmp.data). Consumers subscribe with onChange and read get()/errorOf(). +(() => { + 'use strict'; + + const st = new Map(); // shot id → 'queued' | 'fetching' | 'ready' | 'error' + const err = new Map(); // shot id → failure reason (error state only) + const ctrls = new Map(); // shot id → AbortController of the in-flight fetch (at most one per shot) + const gen = new Map(); // shot id → fetch generation; a stale generation's resolve/reject is dropped + let listeners = []; + + const emit = () => listeners.forEach(f => { + try { f(); } catch (e) { console.error('FusionLab: load-state listener failed:', e); } + }); + + function get(id) { return st.get(id) || null; } + function errorOf(id) { return err.get(id) || null; } + + // Returns an unsubscribe; every machine transition repaints whoever is watching. + function onChange(fn) { + listeners.push(fn); + return () => { listeners = listeners.filter(f => f !== fn); }; + } + + function cancel(id) { // abort the in-flight fetch; its rejection resolves as 'queued' (superseded, not an error) + const c = ctrls.get(id); + if (c) { c.abort(); ctrls.delete(id); } + } + function cancelAll() { [...ctrls.keys()].forEach(cancel); } + + function drop(id) { // give up on a shot: forget its state entirely (Drop in an error chip, a compare exit) + cancel(id); + st.delete(id); err.delete(id); + gen.set(id, (gen.get(id) || 0) + 1); // a late resolve from the dropped fetch must not resurrect state + emit(); + } + + function markReady(id) { // a payload already on screen: same terminal state a fetch would end at + if (st.get(id) === 'ready') return; + cancel(id); + st.set(id, 'ready'); err.delete(id); + emit(); + } + + // Run one fetch under the machine: run(signal) → Promise. Resolves with the payload, or null + // when superseded (a newer fetch or cancel owns the shot now — callers must not render from null). + // Rejects only on real failure, after the error state is recorded, so the caller's catch can render + // without re-recording anything. + async function fetch(id, run) { + cancel(id); // supersede: at most one in-flight fetch per shot + const my = (gen.get(id) || 0) + 1; + gen.set(id, my); + const ctrl = new AbortController(); + ctrls.set(id, ctrl); + st.set(id, 'fetching'); err.delete(id); + emit(); + try { + const payload = await run(ctrl.signal); + if (gen.get(id) !== my) return null; // a newer fetch took over mid-flight + ctrls.delete(id); + st.set(id, 'ready'); err.delete(id); + emit(); + return payload; + } catch (e) { + if (ctrls.get(id) === ctrl) ctrls.delete(id); + const superseded = gen.get(id) !== my || (e && e.name === 'AbortError'); + if (superseded) { // an abort is a supersede, not a failure — unless this fetch was already replaced + if (gen.get(id) === my) { st.set(id, 'queued'); emit(); } + return null; + } + st.set(id, 'error'); + err.set(id, (e && e.message) || 'fetch failed'); + emit(); + throw e; // caller decides what else to render; the machine state is already recorded + } + } + + window.FusionLabShotLoad = { get, errorOf, onChange, fetch, cancel, cancelAll, drop, markReady }; +})(); diff --git a/web/style.css b/web/style.css index 23aa9be..10b8284 100644 --- a/web/style.css +++ b/web/style.css @@ -150,6 +150,8 @@ select { background:var(--bg); color:var(--fg); border:1px solid var(--line); pa .status.ok { color:var(--ok); border-color:var(--ok); } .status.warn { color:var(--warn); border-color:var(--warn); } .status.bad { color:var(--bad); border-color:var(--bad); } +.status.wait { color:var(--muted); } +.status .tool { color:inherit; } /* Retry/Drop inside an error chip inherit its severity color, as in .cmp-err */ .plot { width:100%; height:420px; } footer { padding:10px 16px 16px; font-size:12px; color:var(--muted); border-top:1px solid var(--line); }