From a8d7839174b03b165743c4e00e6a7b992febfd65 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 20:54:05 +0000 Subject: [PATCH 1/5] feat(edit drawer): anchored what-if becomes a modal drawer over Replay Replace the cell swap (setWhatIf hiding the state cell's children) with a right-side drawer (~420px, role=dialog, aria-modal): the shot state, limit gauges and logbook underneath stay live and follow every edit, because scrub() repaints them from the what-if result without any hiding. - Entry: the toolbar button is now "Edit this shot" (aria-haspopup=dialog, aria-expanded mirrors state); Esc and the drawer's close button both close natively, and focus returns to the opening control (wi.opener). - Sliders start from the measured programme (data-zero), persist across close/reopen (persistent DOM + memoized gate), and "Reset edits" restores. - Gate failure opens the drawer just enough to carry a one-line error with a working Retry button, instead of an unopenable hidden panel. - Drawer styling composes from the token system: --drawer token, anchored to #tab-replay, single-column .wi-grid inside. - ?edit=1 now owns the drawer (workspaces.js comment); lesson 8.1 copy names the new label. data-guide anchors and the window.FusionLab / fusionlab:* contracts unchanged. Co-authored-by: Tapan Ganatma Nakkina --- web/lessons.json | 2 +- web/replay.js | 96 +++++++++++++++++++++++++++++++++-------------- web/style.css | 19 ++++++++++ web/workspaces.js | 4 +- 4 files changed, 90 insertions(+), 31 deletions(-) diff --git a/web/lessons.json b/web/lessons.json index 5e73f51..14fccd0 100644 --- a/web/lessons.json +++ b/web/lessons.json @@ -222,6 +222,6 @@ { "id": "8.1", "ch": 8, "title": "Into the lab", "tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "overlay": true, "highlight": null, "view": "cutaway", "focus": [], "last": true, - "body": "Look at the screen again. Flux surfaces from magnetics. A Thomson profile. Stored energy beside two laws and a learned correction. Three limit gauges. A logbook. Field lines with q counted along them. None of it is jargon now. Three things to try: replay the ohmic shot #30420 and find which limit it came closest to; press What-if on #30192 and give its beams 30 % more power; pick any shot and check the twin's reading against the logbook. The ? on any panel brings you back to its lesson." } + "body": "Look at the screen again. Flux surfaces from magnetics. A Thomson profile. Stored energy beside two laws and a learned correction. Three limit gauges. A logbook. Field lines with q counted along them. None of it is jargon now. Three things to try: replay the ohmic shot #30420 and find which limit it came closest to; press Edit this shot on #30192 and give its beams 30 % more power; pick any shot and check the twin's reading against the logbook. The ? on any panel brings you back to its lesson." } ] } diff --git a/web/replay.js b/web/replay.js index 67307ad..f658017 100644 --- a/web/replay.js +++ b/web/replay.js @@ -25,7 +25,7 @@ – - + … @@ -61,18 +61,6 @@
-

Shot state ${TAG.meas}

t–s
@@ -82,10 +70,10 @@
W–kJ
H98–meas / IPB98
-

Distance to the operating limits ${TAG.comp} limit formulas on the measured values · 1.0 = limit

-
Greenwald density
–
-
Troyon βN
–
-
Kink (q95 < 2)
–
+

Distance to the operating limits ${TAG.comp} limit formulas on the measured values · 1.0 = limit

+
Greenwald density
–
+
Troyon βN
–
+
Kink (q95 < 2)
–

What the comparison shows ${TAG.comp}

Session leader's logbook ${TAG.meas}

@@ -102,7 +90,28 @@

The vessel and PF coils are the FAIR-MAST limiter contour and coil filaments revolved about the axis; the glowing shell is EFIT's last closed flux surface at this time slice, coloured by core Te.

Each line is integrated through the EFIT flux map of this time slice (RK4 in toroidal angle, one GPU thread per line, every slice of the shot in one kernel launch). Counting toroidal turns per poloidal turn gives q without using EFIT's own q, so the agreement is a check of the whole chain: archive → units → interpolation → integrator. Axisymmetric reconstruction only: no islands, no 3D fields. Drag to rotate. The same lines are in the OpenUSD download.

-
`; + + + +
+
+

Edit this shot ${TAG.model} ${TAG.learn}

+

The real programme, edited and re-flown · educational, not a prediction

+
+ +
+
+
+
+

+
+
what-if, anchored on the measurement what-if, blind + blind re-fly of the real programme outside the training range
+
+
How this works, and what it leaves out
+
+
+
`; // Catalog search filter set (A1): the ranges the API exposes, one row each — [label, unit, url prefix]. const SRCH_RANGES = [['Ip', 'MA', 'ip'], ['B', 'T', 'bt'], ['Pnbi', 'MW', 'pnbi'], @@ -166,7 +175,7 @@ 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 + let wi = { on: false, gate: null, res: null, seq: 0, timer: null, opener: null }; // what-if: gate = /virtual/gate, res = last /virtual/{id} answer, opener = the control focus returns to on close. The edited values live in the drawer's sliders (persistent DOM), so close/reopen keeps them. const lineCache = new Map(); const psiCache = new Map(); const linesNow = new Map(); // resolved field lines, for the synchronous path in scrub() @@ -292,6 +301,8 @@ seg('rp-view', v => { if (use3) window.Vessel3D.setView(v); document.dispatchEvent(new CustomEvent('fusionlab:view', { detail: { view: v } })); }); $('rp-eq-on').addEventListener('change', () => drawPsi(+$('rp-t').value)); $('rp-whatif').addEventListener('click', () => whatIf(!wi.on)); + $('rp-wi-x').addEventListener('click', () => whatIf(false)); + $('rp-wi').addEventListener('close', finishWhatIf); $('rp-wi-reset').addEventListener('click', () => { $('rp-wi-sliders').querySelectorAll('input').forEach(el => { el.value = el.dataset.zero; }); askWhatIf(); }); const fit = new ResizeObserver(es => es.forEach(e => e.target.data && e.target.offsetParent && Plotly.Plots.resize(e.target))); [...TIME_PLOTS, 'rp-xs', 'rp-te', 'rp-dw'].forEach(id => fit.observe($(id))); @@ -591,9 +602,14 @@ async function whatIf(on) { const want = !!on && !!shot; - if (want && !wi.gate) { + if (!want) { // close: Esc, the ✕ button and programmatic calls (guide.js entering guided) all land here + if ($('rp-wi').open) $('rp-wi').close(); // the 'close' listener runs finishWhatIf + else finishWhatIf(); + return; + } + if (!wi.gate) { // Fetch and validate the gate into a local before any UI mutation: a failed or malformed gate must not - // leave the panel half-open, and only a validated gate is memoized — a poisoned wi.gate would skip this + // leave the drawer half-open, and only a validated gate is memoized — a poisoned wi.gate would skip this // refetch on every later click and keep what-if broken until reload. let gate = null; try { @@ -602,9 +618,11 @@ if (r.ok && j && j.sliders && Array.isArray(j.caveats) && WI.every(([k]) => j.sliders[k] && typeof j.sliders[k].enabled === 'boolean')) gate = j; } catch (e) { /* network failure: the error render just below carries it */ } if (!gate) { - $('rp-wi').hidden = false; // open the panel just enough to carry the one-line error + $('rp-wi-out').innerHTML = '

The anchored editor needs the FusionLab server (/virtual/gate).

'; + $('rp-wi-retry').addEventListener('click', () => whatIf(true)); $('rp-whatif').classList.remove('on'); - $('rp-wi-out').innerHTML = '

What-if needs the FusionLab server (/virtual/gate). Click What-if again to retry.

'; + $('rp-whatif').setAttribute('aria-expanded', 'false'); + showEditDrawer(); // open the drawer just enough to carry the one-line error and its Retry return; } wi.gate = gate; @@ -620,13 +638,35 @@ $('rp-wi-sliders').addEventListener('input', () => { clearTimeout(wi.timer); wi.timer = setTimeout(askWhatIf, 120); labelsWhatIf(); }); $('rp-wi-notes').innerHTML = wi.gate.caveats.map(c => `

${esc(c)}

`).join(''); } - wi.on = want; - $('rp-whatif').classList.toggle('on', wi.on); - $('rp-wi').hidden = !wi.on; - [...$('cell-st').children].forEach(el => { if (el.id !== 'rp-wi') el.style.display = wi.on && !el.classList.contains('wi-keep') ? 'none' : ''; }); // the limit gauges stay: they follow the what-if - if (!wi.on) { wi.res = null; drawWhatIf(); scrub(+$('rp-t').value); return; } + wi.on = true; + $('rp-whatif').classList.add('on'); + $('rp-whatif').setAttribute('aria-expanded', 'true'); + showEditDrawer(); askWhatIf(); } + + // The anchored editor is a modal drawer over the Replay room (spec L2): the state cell, gauges and + // logbook underneath stay live — every edit re-flies the model and repaints them. showModal puts it + // on the top layer: Esc closes natively (cancel → close) and the rest of the page is inert meanwhile. + // Sliders start from the measured programme (data-zero); edits persist across close/reopen because the + // drawer's DOM and the memoized gate survive; "Reset edits" restores the measured programme. + function showEditDrawer() { + if ($('rp-wi').open) return; + wi.opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; // focus returns here on close + $('rp-wi').showModal(); + } + + function finishWhatIf() { // runs from the dialog 'close' event and from whatIf(false) while closed + if (wi.on) { + wi.on = false; + $('rp-whatif').classList.remove('on'); + $('rp-whatif').setAttribute('aria-expanded', 'false'); + wi.res = null; + if (shot) { drawWhatIf(); scrub(+$('rp-t').value); } // the strips read the measured programme again + } + const op = wi.opener; wi.opener = null; + if (op && op.isConnected) op.focus(); + } function labelsWhatIf() { WI.forEach(([k, , , , , , f]) => { $('rp-wi-' + k + '-out').textContent = f(+$('rp-wi-' + k).value); }); } async function askWhatIf() { diff --git a/web/style.css b/web/style.css index 10b8284..29c9108 100644 --- a/web/style.css +++ b/web/style.css @@ -6,6 +6,7 @@ --panel:#10141b; --line:#1f2733; --ok:#3fb950; --warn:#d29922; --bad:#f85149; --mono:ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; --head:46px; + --drawer:420px; --focus-ring:2px solid var(--accent); } * { box-sizing:border-box; } @@ -285,3 +286,21 @@ body.guided header { margin-left:calc(-1 * var(--rail)); } .prov, header h1 .dim { display:none; } header .tabs { order:9; flex:1 1 100%; align-self:auto; height:34px; } } +auto; } +.srch-rows td { white-space:nowrap; } +.bad { color:var(--bad); } + +/* Explore workspace: the visible path into the anchored editor (Replay, edit intent in the URL) */ +.entry-action { margin:14px 0 0; } +.entry-action a { color:var(--accent); font:600 13px/1.4 system-ui, sans-serif; text-decoration:underline; } +.entry-action a:hover { color:var(--fg); } +.entry-action a:focus-visible { outline:1px solid var(--accent); outline-offset:2px; } + +/* Mobile header: the legend (its tags repeat on every panel) and the subtitle step aside, and the + tabs take their own row, so the title never clips (audit defect at 375 px, screenshot fl_AmO2E2fS). + Last in the file: it must out-order the base .prov/.tabs rules it overrides (equal specificity). */ +@media (max-width:760px) { + header { flex-wrap:wrap; height:auto; min-height:var(--head); padding:8px 16px; row-gap:2px; } + .prov, header h1 .dim { display:none; } + header .tabs { order:9; flex:1 1 100%; align-self:auto; height:34px; } +} diff --git a/web/workspaces.js b/web/workspaces.js index dd1fe73..1dd3c20 100644 --- a/web/workspaces.js +++ b/web/workspaces.js @@ -48,8 +48,8 @@ const t = q.get('t'); F.replay.select(id).then((st) => { // renders its own error in the replay cell on failure (never rejects) if (t !== null && st && isFinite(Number(t))) F.replay.scrubTo({ t: Number(t) }); - // ?edit=1: edit intent — Explore's "Edit a real shot instead →" lands here. Until the drawer change - // owns ?edit=1 (spec L2), it opens today's anchored what-if on the replayed shot. + // ?edit=1: edit intent — Explore's "Edit a real shot instead →" lands here and opens the anchored + // edit drawer (spec L2) over the replayed shot. if (q.has('edit') && F.replay.whatIf) F.replay.whatIf(true); }); }, From f4a4960a6565c5d20566557c9e4c42cd8a8e0507 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 20:58:41 +0000 Subject: [PATCH 2/5] =?UTF-8?q?style(toolbar=20clusters):=20three=20labell?= =?UTF-8?q?ed=20groups=20=E2=80=94=20transport,=20shot=20actions,=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar row mixed transport, document actions and infrastructure status in one flat line (audit H1). It now reads in three labelled clusters: - transport: shot select, play, speed, time scrubber - shot actions: Edit this shot, OpenUSD, Share (copy this view's URL), Field lines (toggle — off stops line fetches and clears them from the 3D panel, q95 chips go quiet), Compare… (navigates to the Compare workspace) - status: load chip, transient flash chip (role=status), compute chip Share reproduces the current view (explicit hash routes pass through; the bare landing builds #/replay/{id}?t=SECONDS). The 3D panel gains a visible note that magnetic-surface reconstruction is lossy — field lines are indicative. Also restores the drawer CSS block lost from the previous commit's style.css (the --drawer token had survived, the rules had not). Clusters wrap as units in the ≤1100px container query. data-guide anchors unchanged. Co-authored-by: Tapan Ganatma Nakkina --- web/replay.js | 101 +++++++++++++++++++++++++++++++++++++++++++------- web/style.css | 23 ++++++++++++ 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/web/replay.js b/web/replay.js index f658017..fca22b6 100644 --- a/web/replay.js +++ b/web/replay.js @@ -19,16 +19,35 @@ root.innerHTML = `
- - - - - - – - - - - … +
+ + + + + + + + – + +
+
+ + + + + + + + +
+
+ + + + + … + +
New here? This is a real discharge of the MAST tokamak (UKAEA open data), not a simulation. @@ -86,6 +105,7 @@
q95 traced –q95 EFIT –
+

Magnetic-surface reconstruction is lossy (axisymmetric only: no islands, no 3D fields) — read the field lines as indicative.

What am I looking at?

The vessel and PF coils are the FAIR-MAST limiter contour and coil filaments revolved about the axis; the glowing shell is EFIT's last closed flux surface at this time slice, coloured by core Te.

Each line is integrated through the EFIT flux map of this time slice (RK4 in toroidal angle, one GPU thread per line, every slice of the shot in one kernel launch). Counting toroidal turns per poloidal turn gives q without using EFIT's own q, so the agreement is a check of the whole chain: archive → units → interpolation → integrator. Axisymmetric reconstruction only: no islands, no 3D fields. Drag to rotate. The same lines are in the OpenUSD download.

@@ -172,6 +192,7 @@ 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 linesOn = true; // the Field lines toggle (shot actions): false stops line fetches and clears them from the 3D panel 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) @@ -303,6 +324,24 @@ $('rp-whatif').addEventListener('click', () => whatIf(!wi.on)); $('rp-wi-x').addEventListener('click', () => whatIf(false)); $('rp-wi').addEventListener('close', finishWhatIf); + // Share (audit seed 8): the URL is the state — copy a link that reproduces this view. + $('rp-share').addEventListener('click', async () => { + const url = await shareUrl(); + let ok = false; + try { await navigator.clipboard.writeText(url); ok = true; } + catch (e) { // clipboard API absent (non-secure context) or refused: fall back to a temp input + const inp = document.createElement('input'); + inp.value = url; document.body.appendChild(inp); inp.select(); + try { ok = document.execCommand('copy'); } catch (e2) { ok = false; } + inp.remove(); + } + if (ok) flash('ok', 'Link copied — paste it to share this view'); + else flash('bad', 'Copy refused — the address bar still holds this view'); + }); + // Field lines are an action, not a given: on by default, off with one click (setLines()). + $('rp-lines').addEventListener('click', () => setLines(!linesOn)); + // Compare lives in its own workspace (spec Workspace 02): navigate there instead of re-carving this room. + $('rp-cmp-go').addEventListener('click', () => window.FusionLabRouter.navigate('compare')); $('rp-wi-reset').addEventListener('click', () => { $('rp-wi-sliders').querySelectorAll('input').forEach(el => { el.value = el.dataset.zero; }); askWhatIf(); }); const fit = new ResizeObserver(es => es.forEach(e => e.target.data && e.target.offsetParent && Plotly.Plots.resize(e.target))); [...TIME_PLOTS, 'rp-xs', 'rp-te', 'rp-dw'].forEach(id => fit.observe($(id))); @@ -388,7 +427,7 @@ for (let k = 0; k < n; k++) { if (!shot || shot.meta.shot_id !== id) return; await getPsi(id, k); - if (lines3dOk) await getLines(id, k); + if (lines3dOk && linesOn) await getLines(id, k); } } @@ -578,7 +617,7 @@ Plotly.restyle('rp-xs', { x: [shot.lcfs.R[i]], y: [shot.lcfs.Z[i]] }, [n - 2]); Plotly.restyle('rp-xs', { x: [[me.R_mag_m[i]]], y: [[me.Z_mag_m[i]]] }, [n - 1]); if (db) markDb(i); - if (use3) window.Vessel3D.setSlice(i, linesNow.get(shot.meta.shot_id + ':' + i)); // boundary follows the slider at once + if (use3) window.Vessel3D.setSlice(i, linesOn ? linesNow.get(shot.meta.shot_id + ':' + i) : null); // boundary follows the slider at once clearTimeout(psiTimer); psiTimer = setTimeout(() => { drawPsi(i); drawLines(i); cmpThomson(); }, 40); // cmpThomson: the compared shots' profiles follow the cursor document.dispatchEvent(new CustomEvent('fusionlab:slice', { detail: sliceState(i) })); @@ -667,6 +706,27 @@ const op = wi.opener; wi.opener = null; if (op && op.isConnected) op.focus(); } + + // Transient status chip in the status cluster (role=status): share feedback, USD export progress/result. + // ms=0 sticks until the next flash — an in-flight export keeps its chip up while the request runs. + let flashTimer = null; + function flash(cls, text, ms = 4000) { + const el = $('rp-flash'); + clearTimeout(flashTimer); + el.className = 'status' + (cls ? ' ' + cls : ''); + el.textContent = text; + el.hidden = false; + if (ms) flashTimer = setTimeout(() => { el.hidden = true; }, ms); + } + + // Share (audit seed 8): the URL is the state — copy a link that reproduces this view. An explicit hash + // route reproduces itself; on the bare landing, build the canonical route from the loaded shot and slice. + async function shareUrl() { + if (/^#\//.test(location.hash)) return location.href; + if (!shot) return location.origin + location.pathname; + const t = shot.measured.t_s[+$('rp-t').value] || 0; + return location.origin + location.pathname + '#/replay/' + shot.meta.shot_id + '?t=' + Number(t.toFixed(3)); + } function labelsWhatIf() { WI.forEach(([k, , , , , , f]) => { $('rp-wi-' + k + '-out').textContent = f(+$('rp-wi-' + k).value); }); } async function askWhatIf() { @@ -762,7 +822,7 @@ } async function drawLines(i) { - if (!lines3dOk) return; + if (!lines3dOk || !linesOn) return; const id = shot.meta.shot_id, f = await getLines(id, i); if (!f) { // Transient failures (a network blip, one 503) leave lines3dOk true and retry on the next scrub; only a @@ -782,6 +842,21 @@ $('rp-3d-note').innerHTML = `Over all ${c.n_compared} slices of this shot the traced q95 differs from EFIT's by a median ${(c.median_rel_err * 100).toFixed(2)}% (95th percentile ${(c.p95_rel_err * 100).toFixed(2)}%), and a line drifts off its flux surface by at most ${c.psi_n_drift_max.toExponential(1)} in ψN. Traced on ${esc(f.device)}.${f.computed_on ? ` Computed on ${esc(f.computed_on)}.` : ''}`; } + // Field lines are an action, not a given (shot actions cluster): on by default, off with one click — + // the vessel and boundary stay, the lines (and their fetches) stop, and the q95 chips go quiet with them. + function setLines(on) { + linesOn = !!on; + const b = $('rp-lines'); + b.classList.toggle('on', linesOn); + b.setAttribute('aria-pressed', String(linesOn)); + if (!shot) return; + const i = +$('rp-t').value; + if (linesOn) { drawLines(i); return; } // cached slices repaint at once + if (use3) window.Vessel3D.setSlice(i, null); + else if ($('rp-3d').data) Plotly.restyle('rp-3d', { x: [[]], y: [[]], z: [[]] }, [2, 3, 4, 5, 6]); // the five line traces; vessel and LCFS stay + $('rp-q-tr').textContent = '–'; $('rp-q-ef').textContent = '–'; + } + async function drawPsi(i) { const id = shot.meta.shot_id, p = await getPsi(id, i); if (!p || +$('rp-t').value !== i || shot.meta.shot_id !== id) return; // the slider has moved on diff --git a/web/style.css b/web/style.css index 29c9108..dd97a41 100644 --- a/web/style.css +++ b/web/style.css @@ -53,6 +53,28 @@ section[id^="tab-"]:not(#tab-replay) { padding:16px; } .cr-bar { grid-area:bar; display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--bg); } .cr-bar select { width:auto; max-width:300px; } .cr-bar input[type=range] { flex:1 1 200px; } + +/* the anchored edit drawer (spec L2): a modal layer over the Replay room — the state cell, gauges and + logbook underneath stay live while the programme is edited. Anchored to the room itself, right side. */ +#tab-replay { position:relative; } /* the drawer anchors below the header, not to the page */ +#rp-wi { position:absolute; top:0; right:0; bottom:0; width:min(var(--drawer), 100%); max-height:none; margin:0; padding:12px 16px; + background:var(--panel); border:0; border-left:1px solid var(--line); box-shadow:-12px 0 48px rgba(0,0,0,.55); } +#rp-wi[open] { display:flex; flex-direction:column; } +#rp-wi::backdrop { background:rgba(8,10,13,.45); } +.wi-head { display:flex; align-items:flex-start; gap:10px; } +.wi-head-title { flex:1; min-width:0; } +.wi-head h2 { margin:0; } +.wi-head p { margin:2px 0 8px; } +.wi-scroll { flex:1 1 0; min-height:0; overflow:auto; } +#rp-wi .wi-grid { grid-template-columns:1fr; } /* the two-up layout is a wide-panel pattern; the drawer reads top to bottom */ + +/* the toolbar reads in three labelled clusters (spec §Replay): transport · shot actions · status */ +.cr-cluster { display:flex; align-items:center; gap:8px; min-width:0; } +.cluster-label { flex:none; font:600 10px/1 var(--mono); letter-spacing:.14em; text-transform:uppercase; color:var(--dim); } +.cluster-body { display:flex; align-items:center; gap:10px; min-width:0; } +.cr-cluster-transport { flex:1 1 auto; } +.cr-cluster-status { flex:none; margin-left:auto; } +.cr-note { margin:6px 0 2px; } .cr-cell { background:var(--panel); padding:10px 12px; min-width:0; min-height:0; display:flex; flex-direction:column; } .cr-cell.scroll { overflow:auto; } #cell-eq { grid-area:eq; } #cell-tr { grid-area:tr; } #cell-st { grid-area:st; } #rp-3d-panel { grid-area:v3; } @@ -63,6 +85,7 @@ section[id^="tab-"]:not(#tab-replay) { padding:16px; } .cr { height:auto; grid-template-columns:1fr; grid-template-rows:none; grid-template-areas:"bar" "hint" "v3" "tr" "st" "eq"; } .cr-bar { flex-wrap:wrap; } + .cr-cluster { flex-wrap:wrap; } .cluster-body { flex-wrap:wrap; } /* clusters wrap as units, then within themselves */ #rp-3d-panel, #cell-eq { height:520px; } #cell-tr { height:460px; } } From 847788ef146a0dc1001c2ce5feb302884af279f7 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 21:05:22 +0000 Subject: [PATCH 3/5] fix(usd error surfacing): failed OpenUSD exports reach the banner and status chip, not console only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed export previously did console.error and nothing else (audit state-handling gap): the button's aria-busy was the sole signal, and only while the request was in flight. Now renderFallback is the single painter for the notice row — a compute fallback outranks a failed export, which outranks the onboarding hint; one notice per grid row, with the reason escaped. The banner takes an error severity (red edge) when it carries the export failure; the status chip flashes wait → ok/bad through the new role=status flash element. Dismiss is per-visit; a clean /compute or a successful export re-arms the row. Recovery paths unchanged. Co-authored-by: Tapan Ganatma Nakkina --- web/replay.js | 22 +++++++++++++++++----- web/style.css | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/web/replay.js b/web/replay.js index fca22b6..fb7ba82 100644 --- a/web/replay.js +++ b/web/replay.js @@ -247,6 +247,7 @@ if (!shot) return; const id = shot.meta.shot_id; $('rp-usd').setAttribute('aria-busy', 'true'); + flash('wait', 'Exporting the OpenUSD stage…', 0); // sticks until the export settles try { const r = await fetch(`/replay/${id}/usd`, { method: 'POST' }); if (!r.ok) throw new Error(`export failed: ${r.status}`); @@ -255,8 +256,14 @@ const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: name }); a.click(); URL.revokeObjectURL(a.href); + flash('ok', `Exported ${name} — check your downloads`); + usdErr = null; renderFallback(lastCompute); // a clean export re-arms the notice row for the next failure } catch (e) { console.error('USD export failed:', e); + usdErr = `OpenUSD export of shot #${id} failed. ${esc(String(e && e.message || e))} — the stage writer needs the FusionLab server. Check the compute status, then try again.`; + usdSeen = false; + renderFallback(lastCompute); // surfaced in the fallback banner + status chip, not console-only + flash('bad', 'OpenUSD export failed'); } finally { $('rp-usd').removeAttribute('aria-busy'); } @@ -299,16 +306,21 @@ // re-arms it, so the next failure is announced again. Nothing here blocks replay interactions. // Both notices render in the same grid row, so the banner takes the row while it is up: the // operational status outranks the onboarding hint, and the hint comes back (per its own - // dismissed state) when the banner goes away. - let fallbackSeen = false; + // dismissed state) when the banner goes away. renderFallback is the single painter — the /compute + // poll and the USD export handler only set state — so a poll cannot overwrite a fresh export error. + let fallbackSeen = false, usdErr = null, usdSeen = false; function renderFallback(s) { if (s && !s.fallback) fallbackSeen = false; - const show = !!s && !!s.fallback && !fallbackSeen; + const compute = !!s && !!s.fallback && !fallbackSeen; // the compute fallback outranks a failed export + const usd = !compute && !!usdErr && !usdSeen; + const show = compute || usd; // one notice per grid row: it takes the row from the onboarding hint $('rp-fallback').hidden = !show; + $('rp-fallback').classList.toggle('error', usd); // red edge for the export failure, amber for compute $('rp-hint').hidden = show ? true : hintSeen(); - if (show) $('rp-fallback-text').innerHTML = `Remote compute unavailable — running on Local CPU. ${esc(s.reason || '')}`; + if (compute) $('rp-fallback-text').innerHTML = `Remote compute unavailable — running on Local CPU. ${esc(s.reason || '')}`; + if (usd) $('rp-fallback-text').innerHTML = usdErr; // composed at set time, with esc() on the reason } - $('rp-fallback-x').addEventListener('click', () => { fallbackSeen = true; renderFallback(lastCompute); }); + $('rp-fallback-x').addEventListener('click', () => { fallbackSeen = true; usdSeen = true; renderFallback(lastCompute); }); async function pollCompute() { try { const ctrl = new AbortController(), t = setTimeout(() => ctrl.abort(), COMPUTE_TIMEOUT_MS); diff --git a/web/style.css b/web/style.css index dd97a41..f6aa356 100644 --- a/web/style.css +++ b/web/style.css @@ -49,6 +49,7 @@ section[id^="tab-"]:not(#tab-replay) { padding:16px; } grid-template-areas:"bar bar bar" "hint hint hint" "eq tr v3" "eq st v3"; } .cr-hint { grid-area:hint; display:flex; align-items:center; gap:16px; padding:7px 12px; background:#17120c; border-left:2px solid var(--accent); } .cr-hint.fallback { border-left-color:var(--warn); background:#1c150a; } +.cr-hint.error { border-left-color:var(--bad); background:#1c0f0c; } .cr-hint span { flex:1; } .cr-bar { grid-area:bar; display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--bg); } .cr-bar select { width:auto; max-width:300px; } From a1ac1f6e8be2a2b8f65fa7c893e01bd85cee2df4 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 21:18:36 +0000 Subject: [PATCH 4/5] fix(units): stored energy in MJ on every Replay surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W strip, its live value, the shot-state readout, the compare traces and the ΔW difference strip all multiplied the MJ data by 1e3 and labelled it kJ, while the catalog table and the search filters already used MJ — adjacent surfaces flipped the scale of the same quantity (audit consistency finding). One unit now: the arrays render directly, the ΔW difference drops the ×1e3, and the guided-study lessons read 0.01 MJ / 0.13 MJ. The sliceState contract field W_kJ is unchanged (guided-study surface, L3). Also: the SHOT ACTIONS cluster could overflow its box and paint over the status cluster when the toolbar ran tight — the bar and cluster bodies now wrap as whole units at any width, and the time label stays on one line. Co-authored-by: Tapan Ganatma Nakkina --- web/lessons.json | 4 ++-- web/replay.js | 36 ++++++++++++++++++------------------ web/style.css | 5 +++-- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/web/lessons.json b/web/lessons.json index 14fccd0..514a6f4 100644 --- a/web/lessons.json +++ b/web/lessons.json @@ -170,7 +170,7 @@ { "id": "5.2", "ch": 5, "title": "Beams on", "tab": "replay", "shot": 30166, "slice": { "t": 0.1 }, "focus": ["strip-p", "strip-w", "bar"], - "body": "So far the only heating is ohmic, under 1 MW, and the stored energy is about 10 kJ. Find the moment the [[neutral beam|nbi]] switches on.", + "body": "So far the only heating is ohmic, under 1 MW, and the stored energy is about 0.01 MJ. Find the moment the [[neutral beam|nbi]] switches on.", "task": { "text": "Scrub forward to the first slice with the beam on.", "check": { "event": "fusionlab:slice", "all": [{ "path": "nbi_on", "op": "==", "value": true }, { "path": "t_s", "op": "<=", "value": 0.135 }] } }, "deeper": "From 0.12 s about 2 MW of beam power arrives and the stored energy triples within 30 ms. Part of that early rise is the beam's own fast ions, which EFIT counts as stored energy." }, @@ -181,7 +181,7 @@ { "id": "5.4", "ch": 5, "title": "The peak", "tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["params", "gauge-troyon", "strip-w"], - "body": "At 0.27 s the stored energy peaks at 127 kJ and the Troyon gauge reaches 0.92. H98 reads 1.3, which would be excellent confinement. Be careful. The measured energy includes fast beam ions that the scaling law knows nothing about, so H98 reads high whenever beams are on. A number is only as good as your knowledge of what went into it, and the panel says so." }, + "body": "At 0.27 s the stored energy peaks at 0.13 MJ and the Troyon gauge reaches 0.92. H98 reads 1.3, which would be excellent confinement. Be careful. The measured energy includes fast beam ions that the scaling law knows nothing about, so H98 reads high whenever beams are on. A number is only as good as your knowledge of what went into it, and the panel says so." }, { "id": "5.5", "ch": 5, "title": "What the comparison shows", "tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["insight", "logbook"], diff --git a/web/replay.js b/web/replay.js index fb7ba82..b0fcae7 100644 --- a/web/replay.js +++ b/web/replay.js @@ -74,8 +74,8 @@

Stored energy and drive ${TAG.meas} ${TAG.model} ${TAG.learn} laws are fed the measured power

- ${strip('rp-w', 'W', 'kJ', 'strip-w')}${strip('rp-p', 'P', 'MW', 'strip-p')}${strip('rp-ip', 'Ip', 'MA', 'strip-ip')}${strip('rp-lim', 'limits', '1.0 = limit', 'strip-lim')} - + ${strip('rp-w', 'W', 'MJ', 'strip-w')}${strip('rp-p', 'P', 'MW', 'strip-p')}${strip('rp-ip', 'Ip', 'MA', 'strip-ip')}${strip('rp-lim', 'limits', '1.0 = limit', 'strip-lim')} +
@@ -86,7 +86,7 @@
Ip–MA
n̄e–10²⁰ m⁻³
Te0–keV
-
W–kJ
+
W–MJ
H98–meas / IPB98

Distance to the operating limits ${TAG.comp} limit formulas on the measured values · 1.0 = limit

@@ -545,10 +545,10 @@ Plotly.react('rp-ip', [line(t, me.Ip_MA, 'Ip', C.blue)], stripLayout(false, { showlegend: false }), CFG); if (cmp.on) { drawCmpW(); drawCmpLim(); return; } Plotly.react('rp-w', [ - line(t, scale(me.W_MJ, 1e3), 'Measured (EFIT)', C.blue, { line: { color: C.blue, width: 3 } }), - line(t, scale(mo.W_L_MJ, 1e3), 'ITER89-P (L-mode law)', C.orange), - line(t, scale(mo.W_H_MJ, 1e3), 'IPB98(y,2) (H-mode law)', C.yellow), - ...(mo.W_hybrid_MJ ? [line(t, scale(mo.W_hybrid_MJ, 1e3), 'IPB98 × PhysicsNeMo correction', C.aqua, + line(t, me.W_MJ, 'Measured (EFIT)', C.blue, { line: { color: C.blue, width: 3 } }), + line(t, mo.W_L_MJ, 'ITER89-P (L-mode law)', C.orange), + line(t, mo.W_H_MJ, 'IPB98(y,2) (H-mode law)', C.yellow), + ...(mo.W_hybrid_MJ ? [line(t, mo.W_hybrid_MJ, 'IPB98 × PhysicsNeMo correction', C.aqua, { line: { color: C.aqua, width: 2, dash: 'dot' } })] : []), ], stripLayout(false, { margin: { l: 44, r: 64, t: 32, b: 4 } }), CFG); // four legend entries: two rows Plotly.react('rp-lim', [ @@ -609,7 +609,7 @@ const me = shot.measured, mo = shot.model, t = me.t_s[i]; $('rp-tlabel').textContent = `t = ${fmt(t, 3)} s · ${i + 1}/${me.t_s.length}`; const worst = Math.max(...[mo.f_greenwald[i], mo.troyon[i], mo.kink[i]].filter(v => v !== null && isFinite(v)), 0); - $('rp-w-val').textContent = fmt(me.W_MJ[i] === null ? null : me.W_MJ[i] * 1e3, 0) + ' kJ'; + $('rp-w-val').textContent = fmt(me.W_MJ[i] === null ? null : me.W_MJ[i], 2) + ' MJ'; $('rp-p-val').textContent = fmt((me.P_ohm_MW[i] || 0) + (me.P_nbi_MW[i] || 0), 2) + ' MW'; $('rp-ip-val').textContent = fmt(me.Ip_MA[i]) + ' MA'; $('rp-lim-val').textContent = fmt(worst) + '×'; @@ -618,7 +618,7 @@ $('rp-r-ip').textContent = fmt(me.Ip_MA[i]); $('rp-r-n').textContent = fmt(me.n_e20[i], 3); $('rp-r-te').textContent = fmt(me.Te0_keV?.[i]); - $('rp-r-w').textContent = fmt(me.W_MJ[i] === null ? null : me.W_MJ[i] * 1e3, 1); + $('rp-r-w').textContent = fmt(me.W_MJ[i] === null ? null : me.W_MJ[i], 3); $('rp-r-h').textContent = fmt(mo.H98[i]); const L = wi.on && wi.res && !wi.res.is_identity ? wi.res.limits : { greenwald: mo.f_greenwald, troyon: mo.troyon, kink: mo.kink }; gauge('greenwald', L.greenwald[i]); gauge('troyon', L.troyon[i]); gauge('kink', L.kink[i]); @@ -771,13 +771,13 @@ Plotly.relayout(W, { shapes: (W.layout.shapes || []).filter(s => s.name !== 'ood') }); $('rp-lim-src').textContent = wi.on && r && !r.is_identity ? 'scaled from the measured values by the what-if · 1.0 = limit' : 'limit formulas on the measured values · 1.0 = limit'; if (!wi.on || !r) { $('rp-wi-out').innerHTML = ''; return; } - const t = r.t_s, k = a => scale(a, 1e3), dash = (x, y, name, color, d = 'dash') => line(x, y, name, color, { line: { color, width: 2, dash: d } }); + const t = r.t_s, dash = (x, y, name, color, d = 'dash') => line(x, y, name, color, { line: { color, width: 2, dash: d } }); const band = r.timing_edit ? 0.10 : 0.28; // a timing edit keeps the real shot's events at their old times: the anchored band is shown faint Plotly.addTraces(W, [ - line(t, k(r.anchored.lo), WI_TRACES[0], C.magenta, { line: { color: C.magenta, width: 0 }, showlegend: false, hoverinfo: 'skip' }), - line(t, k(r.anchored.hi), WI_TRACES[1], C.magenta, { fill: 'tonexty', fillcolor: `rgba(213,81,129,${band})`, line: { color: C.magenta, width: 1 }, showlegend: false }), - Object.assign(dash(t, k(r.blind.hi.map((v, i) => v === null ? null : (v + r.blind.lo[i]) / 2)), WI_TRACES[2], C.magenta), { showlegend: false }), - Object.assign(dash(t, k(r.W_refly_MJ), WI_TRACES[3], C.ink, 'dot'), { showlegend: false })]); + line(t, r.anchored.lo, WI_TRACES[0], C.magenta, { line: { color: C.magenta, width: 0 }, showlegend: false, hoverinfo: 'skip' }), + line(t, r.anchored.hi, WI_TRACES[1], C.magenta, { fill: 'tonexty', fillcolor: `rgba(213,81,129,${band})`, line: { color: C.magenta, width: 1 }, showlegend: false }), + Object.assign(dash(t, r.blind.hi.map((v, i) => v === null ? null : (v + r.blind.lo[i]) / 2), WI_TRACES[2], C.magenta), { showlegend: false }), + Object.assign(dash(t, r.W_refly_MJ, WI_TRACES[3], C.ink, 'dot'), { showlegend: false })]); // shade the stretches where the learned correction is outside the range it was trained on (ramps, big edits) const ood = []; let a = null; r.in_distribution.forEach((ok, i) => { if (!ok && a === null) a = i; if ((ok || i === t.length - 1) && a !== null) { ood.push([t[a], t[ok ? i - 1 : i]]); a = null; } }); @@ -1130,7 +1130,7 @@ $('rp-dw-strip').hidden = !showing; document.querySelector('#cell-tr .strips').classList.toggle('cmp-on', showing); $('rp-tr-note').textContent = cmp.on ? 'one measured trace per shot' : 'laws are fed the measured power'; - if (showing) $('rp-dw-label').innerHTML = `ΔW vs #${cmp.ref} kJ · dashed W/Wref · computed`; + if (showing) $('rp-dw-label').innerHTML = `ΔW vs #${cmp.ref} MJ · dashed W/Wref · computed`; } async function startCompare(idsIn) { @@ -1293,7 +1293,7 @@ function drawCmpW() { const traces = cmpReadyIds().map(id => { const d = cmpData(id), c = cmpColor(id), ref = id === cmp.ref; - return line(d.measured.t_s, scale(d.measured.W_MJ, 1e3), `#${id} measured${ref ? ' · reference' : ''}`, c, + return line(d.measured.t_s, d.measured.W_MJ, `#${id} measured${ref ? ' · reference' : ''}`, c, { line: { color: c, width: ref ? 3 : 2 } }); }); Plotly.react('rp-w', traces, stripLayout(false, { margin: { l: 44, r: 64, t: 32, b: 4 } }), CFG); @@ -1327,7 +1327,7 @@ return out; } - // The difference strip: ΔW(t) in kJ (solid) and W/W_ref (dotted, right axis), both computed from the + // The difference strip: ΔW(t) in MJ (solid) and W/W_ref (dotted, right axis), both computed from the // measured EFIT stored energies relative to the reference shot. function drawDw() { if (!cmp.on) return; @@ -1341,7 +1341,7 @@ if (!d) continue; 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) : [], + traces.push(line(t0, Wi ? Wi.map((w, i) => w == null || W0[i] == null ? null : (w - W0[i])) : [], `ΔW #${id} − #${cmp.ref} (computed)`, c, { line: { color: c, width: 2 } })); traces.push(line(t0, Wi ? Wi.map((w, i) => (!w || W0[i] == null) ? null : w / W0[i]) : [], `#${id} / #${cmp.ref} W (computed)`, c, diff --git a/web/style.css b/web/style.css index f6aa356..2801641 100644 --- a/web/style.css +++ b/web/style.css @@ -51,9 +51,10 @@ section[id^="tab-"]:not(#tab-replay) { padding:16px; } .cr-hint.fallback { border-left-color:var(--warn); background:#1c150a; } .cr-hint.error { border-left-color:var(--bad); background:#1c0f0c; } .cr-hint span { flex:1; } -.cr-bar { grid-area:bar; display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--bg); } +.cr-bar { grid-area:bar; display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--bg); flex-wrap:wrap; } /* clusters wrap as whole units when tight — a cluster's children never overflow into its neighbour */ .cr-bar select { width:auto; max-width:300px; } .cr-bar input[type=range] { flex:1 1 200px; } +#rp-tlabel { white-space:nowrap; } /* the anchored edit drawer (spec L2): a modal layer over the Replay room — the state cell, gauges and logbook underneath stay live while the programme is edited. Anchored to the room itself, right side. */ @@ -72,7 +73,7 @@ section[id^="tab-"]:not(#tab-replay) { padding:16px; } /* the toolbar reads in three labelled clusters (spec §Replay): transport · shot actions · status */ .cr-cluster { display:flex; align-items:center; gap:8px; min-width:0; } .cluster-label { flex:none; font:600 10px/1 var(--mono); letter-spacing:.14em; text-transform:uppercase; color:var(--dim); } -.cluster-body { display:flex; align-items:center; gap:10px; min-width:0; } +.cluster-body { display:flex; align-items:center; gap:10px; min-width:0; flex-wrap:wrap; } .cr-cluster-transport { flex:1 1 auto; } .cr-cluster-status { flex:none; margin-left:auto; } .cr-note { margin:6px 0 2px; } From 8035ea771e57493a8e2308cb54c4408636bbbe8b Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 21:44:57 +0000 Subject: [PATCH 5/5] fix(drawer): anchor the edit drawer to the right edge at full height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UA stylesheet gives modal dialogs inset:0 and height:fit-content; with a fixed width, LTR resolution takes the UA's left:0 over the rule's right:0 and the fixed height defeats the top/bottom stretch — the drawer rendered as a shrink-wrapped panel at the room's top-left instead of a 420px right sheet. left:auto and height:auto restore the intended anchor (verified in-browser: x=1005, w=420, h=900 at 1440x900). Co-authored-by: Tapan Ganatma Nakkina --- web/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/style.css b/web/style.css index 2801641..30d5cde 100644 --- a/web/style.css +++ b/web/style.css @@ -59,7 +59,7 @@ section[id^="tab-"]:not(#tab-replay) { padding:16px; } /* the anchored edit drawer (spec L2): a modal layer over the Replay room — the state cell, gauges and logbook underneath stay live while the programme is edited. Anchored to the room itself, right side. */ #tab-replay { position:relative; } /* the drawer anchors below the header, not to the page */ -#rp-wi { position:absolute; top:0; right:0; bottom:0; width:min(var(--drawer), 100%); max-height:none; margin:0; padding:12px 16px; +#rp-wi { position:absolute; top:0; right:0; bottom:0; left:auto; height:auto; width:min(var(--drawer), 100%); max-height:none; margin:0; padding:12px 16px; background:var(--panel); border:0; border-left:1px solid var(--line); box-shadow:-12px 0 48px rgba(0,0,0,.55); } #rp-wi[open] { display:flex; flex-direction:column; } #rp-wi::backdrop { background:rgba(8,10,13,.45); }