From ba25c4334b8f938fd6bb26d833995f0f3a0a4377 Mon Sep 17 00:00:00 2001 From: Ali Akherati Date: Fri, 21 Aug 2026 09:45:55 -0700 Subject: [PATCH 1/3] feat(studio): live run states over SSE, and a results view in the wizard Raised in use as three questions -- when does a run finish, where are the results, does the front-end update? The honest answers were "3-5 minutes", "only in /legacy or the API", and "no". Both gaps closed. Live states: the wizard now subscribes to the SSE stream the API has pushed since Phase 0; the rail moves queued -> running -> succeeded with no reload. The first cut carried a lifecycle bug worth recording: the effect's cleanup closed every socket on any change to `runs` -- that is, on the first pushed update -- while the already-watched set prevented reopening, so each stream died the moment it delivered once and the rail froze at "running" while the server had long said "succeeded". Found by driving a real run and comparing against the API's answer. Sockets live in a ref now, closed only on the run's terminal state or unmount. Results: click a run (or submit one -- it self-selects) for headline tiles, flags, and three charts from the RunSummary -- gas phase, total particle number, final size distribution -- read from the summary, never the npz (ADR-004). The first cut invented its own payload shape and rendered four confident zeros: headline scalars live on the RUN row, and the spectrum key is final_size_distribution. The component test's fixture now mirrors the real endpoints, so a drift in either direction fails a test rather than rendering zeros with a straight face. Verified end to end: a 1-day/40-bin run submitted from the review stage went running -> succeeded at t+18 s, tiles filled (1.7e6 pptv final SO2, 15.05 pptv peak H2SO4, 3.1e6 cm^-3 peak N), three charts drew, open_system_dilution flagged. /legacy is now fully superseded (task 1.4); deleting it is its own small PR. 371 Python Tier-A, 63 vitest (+3). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EaDHnPdiacsybr8Tp5WnqR --- docs/studio/PROGRESS.md | 31 ++++++ studio/web/src/App.tsx | 88 ++++++++++++++-- studio/web/src/Results.test.tsx | 102 +++++++++++++++++++ studio/web/src/Results.tsx | 173 ++++++++++++++++++++++++++++++++ studio/web/src/api.ts | 17 ++++ studio/web/src/styles.css | 84 ++++++++++++++++ studio/web/src/types.ts | 8 ++ 7 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 studio/web/src/Results.test.tsx create mode 100644 studio/web/src/Results.tsx diff --git a/docs/studio/PROGRESS.md b/docs/studio/PROGRESS.md index b43786a..a2cfec3 100644 --- a/docs/studio/PROGRESS.md +++ b/docs/studio/PROGRESS.md @@ -29,6 +29,37 @@ derivations to resolve rather than fixtures. --- +### 2026-08-21 — The rail moves and the results arrived + +Raised in use as three questions — *when does it finish, where do I see results, does the front-end +update?* — and the honest answers were "3–5 minutes", "only in /legacy or the API", and "no". Now: + +**Live run states.** The wizard subscribes to the SSE stream the API has pushed since Phase 0; the +rail moves queued → running → succeeded without a reload. The first cut had a lifecycle bug worth +recording: the effect's cleanup closed every socket on any `runs` change — i.e. on the first pushed +update — while the already-watched set prevented reopening, so each stream died the moment it +delivered once and the rail froze at *running* while the server said *succeeded*. Found by driving a +real run and comparing against the API. Sockets now live in a ref, closed only on terminal state or +unmount. + +**A results view in the wizard.** Click a run (or submit one — it self-selects): headline tiles +(final SO₂, peak H₂SO₄, peak N, final SA), flags, and three charts from the RunSummary — gas phase +(SO₂/H₂SO₄/OH, pptv), total particle number, and the final size distribution. All read from the +summary, never the npz (ADR-004). The first cut invented its own payload shape and rendered four +confident zeros — headline scalars live on the *run*, the spectrum under `final_size_distribution` — +so the component test's fixture now mirrors the real endpoints, and "no summary yet" states say what +is happening instead of showing an empty shell. + +Verified end to end in the browser: a 1-day/40-bin run submitted from the review stage went +running → succeeded at t+18 s with zero reloads, tiles filled (1.7e6 pptv final SO₂, 15.05 pptv peak +H₂SO₄, 3.1e6 cm⁻³ peak N), three charts drew, `open_system_dilution` flagged. + +`/legacy` is now fully superseded (task 1.4): the wizard shows results. Deletion is its own small PR. + +371 Python Tier-A, 63 vitest (+3). + +--- + ### 2026-08-20 — Interactive dilution, full-profile hover, curated backgrounds, custom modes Four review requests in one message, all landed (schema 0.4.0 → 0.5.0): diff --git a/studio/web/src/App.tsx b/studio/web/src/App.tsx index 6fabb7d..68f9fd0 100644 --- a/studio/web/src/App.tsx +++ b/studio/web/src/App.tsx @@ -27,6 +27,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api, ApiError, type ConfigState } from "./api"; import { Field } from "./Field"; import { HelpTip } from "./HelpTip"; +import { Results, type RunSummaryPayload } from "./Results"; import { Review } from "./Review"; import { PANELS_BY_STAGE } from "./panels"; import { type FieldSpec, fieldSpec } from "./schema"; @@ -56,6 +57,8 @@ export function App() { const [submitting, setSubmitting] = useState(false); const [runs, setRuns] = useState([]); const [reference, setReference] = useState>({}); + const [selectedRun, setSelectedRun] = useState(null); + const [summaries, setSummaries] = useState>({}); /** * Navigate. Assigning the hash rather than calling `history.replaceState` is deliberate: it @@ -99,6 +102,62 @@ export function App() { })(); }, []); + // Every non-terminal run gets one SSE subscription, opened when it first appears and closed on + // its terminal state (the server closes the stream; the cleanup below covers unmount). This is + // what makes the rail move without a reload: the server has pushed states since Phase 0, the + // wizard just never listened. + // Sockets live in a ref and are closed ONLY on the run's terminal state or unmount. The first + // version closed them in the effect's own cleanup -- which runs on every `runs` change, i.e. on + // the very first pushed update -- while the already-watched set prevented reopening. Net effect: + // each stream died the moment it delivered once, and the rail froze at `running` while the + // server had long since said `succeeded`. Found by driving a real run and comparing against the + // API's answer. + const sockets = useRef(new Map void>()); + useEffect(() => { + for (const run of runs) { + const terminal = ["succeeded", "failed", "cancelled", "terminated_on_limit"].includes( + run.state, + ); + const close = sockets.current.get(run.run_id); + if (terminal) { + if (close) { + close(); + sockets.current.delete(run.run_id); + } + continue; + } + if (close) continue; + sockets.current.set( + run.run_id, + api.watch(run.run_id, (update) => { + setRuns((current) => + current.map((r) => (r.run_id === update.run_id ? { ...r, ...update } : r)), + ); + }), + ); + } + }, [runs]); + useEffect(() => { + const open = sockets.current; + return () => open.forEach((close) => close()); + }, []); + + // A selected run's summary, fetched when it exists -- and refetched when the run SUCCEEDS while + // being watched, which is the moment the summary appears. + const selected = runs.find((r) => r.run_id === selectedRun) ?? null; + useEffect(() => { + if (!selectedRun || summaries[selectedRun] || selected?.state !== "succeeded") return; + void api + .summary(selectedRun) + .then((payload) => + setSummaries((current) => ({ + ...current, + [selectedRun]: payload as unknown as RunSummaryPayload, + })), + ) + .catch(() => undefined); // 404 = not finished yet; the watcher will flip state and retry + }, [selectedRun, selected?.state, summaries]); + // Follow the hash while the page is open, not only at mount. Without this, browser back/forward // moves the URL and leaves the view where it was -- and a hash typed into the address bar does // nothing at all. Found by driving the wizard with scripts/smoke.mjs. @@ -189,8 +248,8 @@ export function App() { try { const accepted = await api.submit(payload.config, label); setRuns(await api.runs()); + setSelectedRun(accepted.run_id); setError(""); - console.info(`submitted ${accepted.run_id} (${accepted.state})`); } catch (err) { setError(err instanceof ApiError ? err.message : String(err)); } finally { @@ -256,7 +315,14 @@ export function App() {
-
+ {selected ? ( + setSelectedRun(null)} + /> + ) : null} + - {stage.id === "review" ? ( + {selected ? null : stage.id === "review" ? ( {error}

: null} -
+ diff --git a/studio/web/src/styles.css b/studio/web/src/styles.css index 2b0b2fd..93b411b 100644 --- a/studio/web/src/styles.css +++ b/studio/web/src/styles.css @@ -1220,3 +1220,41 @@ figure.chart figcaption { aside.runs li:has(.run-open.current) { border-color: var(--steel); } + +/* ---- distribution explorer ----------------------------------------------------------------- */ + +.results-distributions { + margin-top: 18px; +} +.results-distributions .banana { + margin-bottom: 14px; +} +.dist-controls { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + background: var(--raised); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 10px 14px; + margin-bottom: 12px; +} +.dist-controls label { + font: 600 12px/1 var(--mono); + color: var(--navy); + min-width: 150px; +} +.dist-controls input[type="range"] { + flex: 1; + min-width: 200px; + accent-color: var(--steel); +} +.dist-toggle { + display: inline-flex; + gap: 6px; +} +figure.heatmap { + padding-bottom: 10px; +} From 333652e32e7c03a86e657b7684a6810dd47cf1e5 Mon Sep 17 00:00:00 2001 From: Ali Akherati Date: Wed, 26 Aug 2026 16:08:22 -0700 Subject: [PATCH 3/3] feat(studio): night bands, sulfur budget, particle mass -- results learned from the viz pages Review: the results view was bad; learn from d1_globe (now plume_dynamics.html on main) and inverse_lab. Studied both rather than guessing. They carry three things the results view lacked, all derivable from the summary the run already produces -- no model change. Night bands on every time series, from the run's OWN photolysis (daylight = any J > 0), aligned to the stored time grid by interval index. A time search ties at the edges and cannot distinguish the interval a step opens from the one it closes; index alignment is exact because J has one entry per interval, in order. Absent J -> no bands, never a recomputed sun (ADR-005). The OH/HO2 diurnal crash now reads against real day/night. Sulfur budget: a normalized gas-vs-particle stacked area (gold SO2 gas, steel particles), the signature panel of both reference pages. Particle sulfur is already emitted in pptv, so this is a normalization of served series, not new physics. Particle mass series (dry H2SO4-equivalent, ug/m3) from particulate_S via Avogadro and molar mass under ASSUMPTION-8, tested against a hand-computed value. Summary schema is 0.2.0 (the time-resolved spectrum landed in the prior commit; this adds `daylight`). Also fixes a rendering bug I twice wrongly called a screenshot artifact: a full-page vertical blue line. H2SO4 gas starts at exactly 0, and the log y-scale clamped 0 to Number.MIN_VALUE, giving a pixel near -1e308 -- linePath drew a segment off the chart and `overflow: visible` painted it down the page. A zero has no position on a log axis; the scale returns NaN there now and the line breaks like any gap. Two scale tests pin it, and the diagnosis came from elementsFromPoint on the line, not from assuming "capture artifact" -- the lesson being to verify that claim by DOM probe rather than default to it. 375 Python Tier-A, 67 vitest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EaDHnPdiacsybr8Tp5WnqR --- docs/studio/PROGRESS.md | 32 +++++++ studio/modelio/summary.py | 25 ++++++ studio/tests/unit/test_modelio_summary.py | 45 ++++++++++ studio/web/src/Results.tsx | 67 +++++++++++++- studio/web/src/StackedArea.tsx | 103 ++++++++++++++++++++++ studio/web/src/scales.test.ts | 18 ++++ studio/web/src/scales.ts | 7 +- studio/web/src/styles.css | 13 +++ 8 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 studio/web/src/StackedArea.tsx diff --git a/docs/studio/PROGRESS.md b/docs/studio/PROGRESS.md index a2cfec3..fe65401 100644 --- a/docs/studio/PROGRESS.md +++ b/docs/studio/PROGRESS.md @@ -29,6 +29,38 @@ derivations to resolve rather than fixtures. --- +### 2026-08-24 — Results, rebuilt from what d1_globe and inverse_lab do well + +Review: "the results after running are shit -- learn from d1_globe and inverse_lab." Studied both +pages (`origin/viz/inverse-lab`, and `plume_dynamics.html` on main, the renamed d1_globe) rather +than guessing. What they have that the results view lacked: **night bands** from the run's own +photolysis, the **sulfur-budget stacked area**, and card polish. Added all three, everything from +the summary the run already produces (no model change). + +- **Night bands on every time series**, from the run's OWN J (daylight = any photolysis rate > 0), + aligned to the stored grid by interval index (a time search ties at the edges and can't tell the + interval a step opens from the one it closes -- a test pins it). Absent J draws no bands rather + than a recomputed sun (ADR-005). The OH/HO2 diurnal crash now reads against real day/night. +- **Sulfur budget** as a normalized gas-vs-particle stack (gold SO2, steel particles), the panel + from both reference pages; particle sulfur was already emitted in pptv, so it is a normalization. +- **Particle mass** series (dry H2SO4-equivalent, ug/m3) from particulate_S -- Avogadro and molar + mass only (ASSUMPTION-8), tested against a hand-computed value. + +Summary schema 0.1.0 -> 0.2.0 carried the time-resolved spectrum in the prior commit; this adds +`daylight`. + +**Fixed a real rendering bug I twice misdiagnosed as a screenshot artifact.** A vertical blue line +ran the full page height. It was not a capture seam: H2SO4 gas starts at exactly 0, and the log +y-scale clamped 0 to Number.MIN_VALUE (~1e-308), giving a finite pixel near -1e308 -- so linePath +drew a segment plunging off the chart, and `overflow: visible` painted it down the page. A zero has +NO position on a log axis; the scale now returns NaN there and the line breaks, exactly as for +missing data. Two scale tests pin it. The lesson recorded: "capture artifact" is a claim to verify +by DOM probe, not a default explanation -- elementsFromPoint on the line is what finally caught it. + +375 Python Tier-A (+4), 67 vitest (+2). + +--- + ### 2026-08-21 — The rail moves and the results arrived Raised in use as three questions — *when does it finish, where do I see results, does the front-end diff --git a/studio/modelio/summary.py b/studio/modelio/summary.py index 6214e51..9d85727 100644 --- a/studio/modelio/summary.py +++ b/studio/modelio/summary.py @@ -160,6 +160,10 @@ class RunSummary(BaseModel): termination: TerminationReason = TerminationReason.UNKNOWN flags: tuple[SummaryFlag, ...] = () time_days: tuple[float, ...] = () + #: True where the run's own photolysis was active (daylight), aligned to ``time_days``. Empty + #: when the npz carried no J. The results view shades the complement as night bands -- the + #: same day/night the chemistry actually saw, not a recomputed sun. + daylight: tuple[bool, ...] = () series: dict[str, Series] = Field(default_factory=dict) final_size_distribution: SizeDistribution | None = None size_distribution_history: SizeDistributionHistory | None = None @@ -232,6 +236,26 @@ def _particle_mass_series(data: dict[str, Any]) -> Series | None: _SULFUR_GAS_SPECIES = ("SO2", "SO3", "H2SO4") +def _daylight(data: dict[str, Any], time_s: np.ndarray) -> tuple[bool, ...]: + """Daylight per stored step, from the run's photolysis J (any rate > 0 means the sun was up). + + J is stored on interval MIDPOINTS (J_tmid), one fewer than the step edges; each step takes the + daylight flag of the interval it opens. Absent J -> empty, and the caller draws no bands rather + than a guessed sun (ADR-005). + """ + if "J" not in data or "J_tmid" not in data: + return () + active = np.asarray(data["J"], dtype=np.float64).max(axis=1) > 0.0 + if active.size == 0: + return () + # J has exactly one entry per interval, in order, so interval i's flag is active[i] regardless + # of the midpoint values -- index alignment, not a time search (a search ties at the edges and + # cannot tell the interval a step OPENS from the one it closes). Step i opens interval i; the + # final edge has no interval after it, so it repeats the last flag. The clip also covers a + # mismatched npz (J not exactly one-per-interval) without inventing a sun. + return tuple(bool(active[min(i, active.size - 1)]) for i in range(len(time_s))) + + def summarise_state_npz( path: Path, *, @@ -312,6 +336,7 @@ def summarise_state_npz( termination=termination, flags=tuple(flags), time_days=tuple(time_s / SECONDS_PER_DAY), + daylight=_daylight(data, time_s), series=series, final_size_distribution=_final_size_distribution(data), size_distribution_history=_size_distribution_history(data), diff --git a/studio/tests/unit/test_modelio_summary.py b/studio/tests/unit/test_modelio_summary.py index 14fbe29..4b14b2a 100644 --- a/studio/tests/unit/test_modelio_summary.py +++ b/studio/tests/unit/test_modelio_summary.py @@ -298,3 +298,48 @@ def test_particle_mass_is_the_unit_conversion_it_claims(tmp_path: Path) -> None: assert series.values[1] == pytest.approx(expected, rel=1e-12) assert series.unit == "ug m^-3" assert series.basis.value == "dry" + + +@pytest.mark.tier_a +def test_daylight_comes_from_the_runs_own_photolysis(tmp_path: Path) -> None: + """Night bands are the day/night the CHEMISTRY saw (J > 0), not a recomputed sun. + + J lives on interval midpoints, one fewer than the step edges; each step takes the flag of the + interval it opens. A run with no J gets an empty tuple, and the results view draws no bands + rather than guessing (ADR-005). + """ + import numpy as np + + from studio.modelio.summary import summarise_state_npz + + # Six steps over 24 h; J on five midpoints, dark in the middle of the day. + times = np.linspace(0.0, 86400.0, 6) + jmid = (times[:-1] + times[1:]) / 2 + jvals = np.array([[1.0], [1.0], [0.0], [0.0], [1.0]]) # night in the two middle intervals + path = tmp_path / "state.npz" + np.savez( + path, + t=times, + x=np.ones((6, 1)), + species=np.array(["SO2"]), + M=1.0e18, + J=jvals, + J_tmid=jmid, + ) + daylight = summarise_state_npz(path).daylight + assert len(daylight) == len(times) + assert daylight[0] is True and daylight[1] is True + assert daylight[2] is False and daylight[3] is False + assert daylight[-1] is True + + +@pytest.mark.tier_a +def test_no_photolysis_means_no_daylight_bands(tmp_path: Path) -> None: + """Absent J -> empty daylight, so the UI shows no bands rather than a fabricated day/night.""" + import numpy as np + + from studio.modelio.summary import summarise_state_npz + + path = tmp_path / "state.npz" + np.savez(path, t=np.array([0.0, 600.0]), x=np.ones((2, 1)), species=np.array(["SO2"]), M=1e18) + assert summarise_state_npz(path).daylight == () diff --git a/studio/web/src/Results.tsx b/studio/web/src/Results.tsx index 13b25f7..8228dbd 100644 --- a/studio/web/src/Results.tsx +++ b/studio/web/src/Results.tsx @@ -17,6 +17,7 @@ import { useState } from "react"; import { Chart, type Series as ChartSeries } from "./Chart"; import { Heatmap } from "./Heatmap"; +import { StackedArea } from "./StackedArea"; import { display } from "./schema"; import { tickLabel } from "./scales"; import type { RunBrief } from "./types"; @@ -32,6 +33,8 @@ export interface RunSummaryPayload { time_days: number[]; termination: string; flags: string[]; + /** Daylight per time step from the run's own photolysis; [] when the npz had no J. */ + daylight?: boolean[]; series: Record; final_size_distribution?: { diameter_um: number[]; @@ -55,12 +58,14 @@ function TimeSeriesChart({ names, yLabel, yLog = true, + night = [], }: { summary: RunSummaryPayload; title: string; names: string[]; yLabel: string; yLog?: boolean; + night?: { from: number; to: number }[]; }) { const series: ChartSeries[] = []; for (const name of names) { @@ -78,7 +83,7 @@ function TimeSeriesChart({ return (

{title}

- +
); } @@ -95,6 +100,45 @@ export function Results({ const [timeIndex, setTimeIndex] = useState(null); const [logY, setLogY] = useState(true); + // Night intervals from the run's own daylight flags -- contiguous dark spans as [from, to] in + // days, drawn behind every time series so the OH/HO2 diurnal cycle reads against real day/night. + const nightBands = (() => { + const out: { from: number; to: number }[] = []; + const dl = summary?.daylight; + const days = summary?.time_days ?? []; + if (!dl || dl.length !== days.length) return out; + let start: number | null = null; + for (let i = 0; i < dl.length; i++) { + if (!dl[i] && start === null) start = days[i]!; + if ((dl[i] || i === dl.length - 1) && start !== null) { + out.push({ from: start, to: days[i]! }); + start = null; + } + } + return out; + })(); + + // Sulfur budget: gas-phase S (SO2+SO3+H2SO4, one S atom each, in pptv) vs particle S (pptv), + // as the gas FRACTION over time. Both are mixing ratios the summary already carries, so this is + // a normalization, not new physics. + const sulfurGasFraction = (() => { + if (!summary) return null; + const gasNames = ["SO2", "SO3", "H2SO4"]; + const n = summary.time_days.length; + const gas = new Array(n).fill(0); + for (const name of gasNames) { + const s = summary.series[name]; + if (s) for (let i = 0; i < n; i++) gas[i] += s.values[i] ?? 0; + } + const particle = summary.series["particulate_S_pptv"]; + if (!particle) return null; + const frac = gas.map((g, i) => { + const total = g + (particle.values[i] ?? 0); + return total > 0 ? g / total : 1; + }); + return frac; + })(); + const headline = run.headline; const stats: { label: string; value: string }[] = headline ? [ @@ -165,39 +209,60 @@ export function Results({ title="Total particle number" names={["total_n"]} yLabel="N (cm⁻³)" + night={nightBands} />
+ {sulfurGasFraction ? ( +
+

Sulfur budget — gas vs particle

+ +
+ ) : null} + {history ? (
diff --git a/studio/web/src/StackedArea.tsx b/studio/web/src/StackedArea.tsx new file mode 100644 index 0000000..6a26542 --- /dev/null +++ b/studio/web/src/StackedArea.tsx @@ -0,0 +1,103 @@ +// Copyright (C) 2026 University Corporation for Atmospheric Research +// SPDX-License-Identifier: Apache-2.0 +/** + * The sulfur budget: what share of the plume's sulfur is still SO2 gas versus in particles, over + * time, as a normalized 0-100% stack. Learned from the reference viz (plume_dynamics' "sulfur + * budget"), the panel that makes gas-to-particle conversion legible at a glance where two separate + * decaying curves do not. + * + * Two bands only, so two categorical hues in fixed order (gold = gas, steel = particle) with a + * 2px surface gap between them and both directly labelled -- no legend box needed. Night bands + * shade behind, from the run's own photolysis, because conversion pauses in the dark. + */ + +import { linearScale } from "./scales"; + +const WIDTH = 680; +const PAD = { top: 12, right: 96, bottom: 34, left: 44 }; + +export interface Band { + from: number; + to: number; +} + +export function StackedArea({ + timeDays, + lower, + lowerLabel, + lowerColor, + upperLabel, + upperColor, + night = [], + height = 220, +}: { + timeDays: number[]; + /** Fraction 0..1 in the LOWER band at each time; the upper band fills the rest. */ + lower: number[]; + lowerLabel: string; + lowerColor: string; + upperLabel: string; + upperColor: string; + night?: Band[]; + height?: number; +}) { + if (timeDays.length < 2) return

not enough time steps to plot a budget

; + const x = linearScale([timeDays[0]!, timeDays[timeDays.length - 1]!], [PAD.left, WIDTH - PAD.right]); + const y = linearScale([0, 1], [height - PAD.bottom, PAD.top]); + + // Lower band: baseline up to `lower`. Upper band: `lower` up to 1, drawn from the top down so a + // 2px gap between the two fills reads as a seam, not a smear (the spacer rule). + const lowerPath = + `M${x(timeDays[0]!).toFixed(1)},${y(0).toFixed(1)}` + + timeDays.map((t, i) => `L${x(t).toFixed(1)},${y(lower[i] ?? 0).toFixed(1)}`).join("") + + `L${x(timeDays[timeDays.length - 1]!).toFixed(1)},${y(0).toFixed(1)}Z`; + const upperPath = + `M${x(timeDays[0]!).toFixed(1)},${y(1).toFixed(1)}` + + timeDays.map((t, i) => `L${x(t).toFixed(1)},${y((lower[i] ?? 0) + 0.004).toFixed(1)}`).join("") + + `L${x(timeDays[timeDays.length - 1]!).toFixed(1)},${y(1).toFixed(1)}Z`; + + const lastLower = lower[lower.length - 1] ?? 0; + + return ( +
+ + {night.map((band, i) => ( + + ))} + + + {[0, 0.25, 0.5, 0.75, 1].map((frac) => ( + + {frac * 100}% + + ))} + {x.ticks(6).map((tick) => ( + + {tick} + + ))} + {/* Direct labels at the right edge, each in its band's colour -- identity without a legend. */} + + {lowerLabel} + + + {upperLabel} + + + days + + +
+ By the end, {(lastLower * 100).toFixed(0)}% of plume sulfur is still SO₂ gas;{" "} + {((1 - lastLower) * 100).toFixed(0)}% has converted to particles. +
+
+ ); +} diff --git a/studio/web/src/scales.test.ts b/studio/web/src/scales.test.ts index 6ead6e1..ee8d934 100644 --- a/studio/web/src/scales.test.ts +++ b/studio/web/src/scales.test.ts @@ -156,3 +156,21 @@ describe("minor log gridlines", () => { }); }); +describe("log scale has no position for zero", () => { + it("maps non-positive values to NaN so the line breaks instead of plunging off-chart", () => { + // Regression: H2SO4 gas starts at 0; clamping 0 to MIN_VALUE drew a full-page vertical line. + const s = logScale([1, 1000], [0, 300]); + expect(Number.isNaN(s(0))).toBe(true); + expect(Number.isNaN(s(-5))).toBe(true); + expect(s(10)).toBeCloseTo(100); + }); + + it("linePath breaks at a zero on a log y, drawing no segment through it", () => { + const x = linearScale([0, 2], [0, 100]); + const y = logScale([1, 1000], [100, 0]); + // Middle point is zero -> gap, so two moves and no line across. + const path = linePath([0, 1, 2], [10, 0, 100], x, y); + expect(path).not.toContain("L"); + expect((path.match(/M/g) ?? []).length).toBe(2); + }); +}); diff --git a/studio/web/src/scales.ts b/studio/web/src/scales.ts index cf6f724..354de69 100644 --- a/studio/web/src/scales.ts +++ b/studio/web/src/scales.ts @@ -75,8 +75,13 @@ export function logScale(domain: [number, number], range: [number, number]): Sca const l0 = Math.log10(d0); const l1 = Math.log10(d1); const span = l1 - l0 || 1; + // Non-positive values have NO position on a log axis, so they map to NaN and linePath breaks the + // line there -- exactly as it does for a gap. The old code clamped to Number.MIN_VALUE, which + // gave a finite pixel near -1e308: a series that touches zero (H2SO4 gas starts at 0) then drew + // a segment plunging off the chart, and `overflow: visible` painted it as a full-page vertical + // line. A zero on a log scale is missing data, not a point at the bottom. const scale = ((value: number) => - r0 + ((Math.log10(Math.max(value, Number.MIN_VALUE)) - l0) / span) * (r1 - r0)) as Scale; + value > 0 ? r0 + ((Math.log10(value) - l0) / span) * (r1 - r0) : NaN) as Scale; scale.domain = domain; scale.range = range; scale.invert = (pixel: number) => Math.pow(10, l0 + ((pixel - r0) / (r1 - r0 || 1)) * span); diff --git a/studio/web/src/styles.css b/studio/web/src/styles.css index 93b411b..d031a97 100644 --- a/studio/web/src/styles.css +++ b/studio/web/src/styles.css @@ -1258,3 +1258,16 @@ aside.runs li:has(.run-open.current) { figure.heatmap { padding-bottom: 10px; } + +/* Sulfur budget panel spans the results grid full-width -- it is a summary, not a small multiple. */ +.budget-panel { + margin-top: 4px; +} +.budget-panel h3 { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--steel); + margin: 0 0 6px; +}