diff --git a/web/lessons.json b/web/lessons.json index 5e73f51..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"], @@ -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..b0fcae7 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. @@ -55,37 +74,25 @@

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')} +
-

Shot state ${TAG.meas}

t–s
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

-
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}

@@ -98,11 +105,33 @@
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.

-
`; +
+ + +
+
+

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'], @@ -163,10 +192,11 @@ 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) - 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() @@ -217,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}`); @@ -225,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'); } @@ -269,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); @@ -292,6 +334,26 @@ 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); + // 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))); @@ -377,7 +439,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); } } @@ -483,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', [ @@ -547,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) + '×'; @@ -556,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]); @@ -567,7 +629,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) })); @@ -591,9 +653,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 +669,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 +689,56 @@ $('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(); + } + + // 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() { @@ -659,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; } }); @@ -722,7 +834,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 @@ -742,6 +854,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 @@ -1003,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) { @@ -1166,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); @@ -1200,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; @@ -1214,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 10b8284..30d5cde 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; } @@ -48,10 +49,34 @@ 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 { 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. */ +#tab-replay { position:relative; } /* the drawer anchors below the header, not to the page */ +#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); } +.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; flex-wrap:wrap; } +.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; } @@ -62,6 +87,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; } } @@ -285,3 +311,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); }); },