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 = `
Magnetic-surface reconstruction is lossy (axisymmetric only: no islands, no 3D fields) — read the field lines as indicative.
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.
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); }); },