From d4bbfc0fd361a6ce2027fcb31ff22f440f937f1d Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 07:16:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(inspector):=20redesign=20Run=20Inspector?= =?UTF-8?q?=20=E2=80=94=20overlay=20pulse=20plot,=20metrics=20rail,=20fide?= =?UTF-8?q?lity=E2=89=A0objective=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pulse plot now overlays all drives on ONE shared time/amplitude axis with a legend, instead of a stack of per-drive panels. Adapts to any run shape: overlay for <=6 drives, small multiples for 7+ (position + labels disambiguate, so no ambiguous categorical-color cycling). - brand.css: add validated --series-5 (yellow) / --series-6 (red) riding VS Code chart tokens; overlay ceiling 4 -> 6. Palette passes the dataviz validator (lightness band, chroma floor, adjacent CVD, contrast) on dark and light surfaces; order puts yellow before red so green(4) never neighbours red. - Metrics move to a left rail beside the plot (was a full-width wrapping row of uneven tiles). Order: fidelity hero · objective(+trace) · iteration · feasibility · optimality; collapses above the plot when docked narrow. - Fix fidelity != objective: the convergence sparkline (the OBJECTIVE trace) lived inside the hero card, which was relabeled "fidelity" on completion — so a "fidelity" card sat over a plot of the objective. The sparkline now belongs to the objective card; fidelity is its own scalar hero. Host<->webview message protocol and the shell/CSP contract are unchanged; only the view DOM/CSS (design lane, free to change per the contract test) is touched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/media/brand.css | 10 +- .../media/ui/components/pulseplot.ts | 261 +++++++++++++----- .../extension/media/ui/views/inspector.ts | 45 ++- 3 files changed, 226 insertions(+), 90 deletions(-) diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css index fc55f1a3..d3ab2a62 100644 --- a/packages/extension/media/brand.css +++ b/packages/extension/media/brand.css @@ -50,13 +50,19 @@ --bg-box: var(--vscode-editorWidget-background, transparent); --bg-plot: var(--vscode-editor-background); - /* series — categorical, FIXED order (drive 1..4), never cycled. Primary + /* series — categorical, FIXED order (drive 1..6), never cycled. Primary * values ride VS Code's theme-aware chart tokens; the hex fallbacks are the - * dev-harness set, validated (dataviz six checks) against the dark surface. */ + * dev-harness set, validated (dataviz six checks) against the dark surface. + * Order is deliberate: yellow precedes red so green (4) neighbours yellow, not + * red — keeping the red/green pair (CVD's weakest) non-adjacent. Six is the + * ceiling: VS Code exposes exactly these six chart hues, so past it the pulse + * plot facets into small multiples rather than inventing a 7th hue. */ --series-1: var(--vscode-charts-blue, #3794ff); --series-2: var(--vscode-charts-orange, #c17800); --series-3: var(--vscode-charts-purple, #9b6bc4); --series-4: var(--vscode-charts-green, #4d9e51); + --series-5: var(--vscode-charts-yellow, #b0881a); + --series-6: var(--vscode-charts-red, #cc5a5a); --plot-grid: var(--vscode-charts-lines, color-mix(in srgb, var(--vscode-foreground, #ccc) 18%, transparent)); --plot-band: color-mix(in srgb, var(--vscode-foreground, #ccc) 6%, transparent); } diff --git a/packages/extension/media/ui/components/pulseplot.ts b/packages/extension/media/ui/components/pulseplot.ts index dcb09514..30520753 100644 --- a/packages/extension/media/ui/components/pulseplot.ts +++ b/packages/extension/media/ui/components/pulseplot.ts @@ -1,12 +1,23 @@ // Pulseplot component (#66) — client-side render of the live pulse from raw -// data, matching plot_pulse's stacked layout: one panel per drive, step -// (stairs) rendering — faithful to zero-order hold — bounds band with dashed -// limits, per-drive labels, shared time axis labeled on the bottom panel only. +// data. ADAPTS to any run shape (any drive count): // -// Hand-rolled SVG (series are tens of points; >512 knots per drive decimated -// by stride — min/max-preserving decimation is the GA refinement). Colors ride -// the fixed-order categorical series tokens in brand.css; identity is carried -// by a colored chip beside the label, text stays in text tokens. +// drives <= 6 → OVERLAY. Every drive shares ONE graph (one time axis, one +// amplitude axis); identity rides the fixed-order categorical +// series color (brand.css has 6) + a legend. Reads the controls' +// relative shape at a glance and spends the vertical budget once. +// drives > 6 → SMALL MULTIPLES. One strip per drive, shared time axis. Past +// the 6-color limit, color can no longer carry identity alone, +// so we facet (the dataviz-sanctioned fallback) — position + +// per-strip label disambiguate, and color may repeat harmlessly. +// +// Step (stairs) rendering — faithful to the zero-order hold. Bounds are the +// constraint envelope: a filled band (when a plot's drives share one) + dashed +// limit lines (dashing is reserved for thresholds; the grid/zero rule stays +// solid). Text stays in text tokens; a colored chip carries identity. +// +// Hand-rolled SVG (series are tens of points; >512 knots per drive decimated by +// stride). preserveAspectRatio=none stretches the plot to the box, so NO +// lives in the SVG — axis/scale labels are HTML siblings (they must not distort). import { defineStyle } from "../style"; import { text } from "../atoms/text"; @@ -14,31 +25,47 @@ import { text } from "../atoms/text"; defineStyle( "pulseplot", ` - .pulseplot { display: flex; flex-direction: column; gap: var(--space-xs); - flex: 1 1 240px; min-width: 0; min-height: 240px; + .pulseplot { display: flex; flex-direction: column; gap: var(--space-sm); + flex: 1 1 240px; min-width: 0; min-height: 200px; background: var(--bg-plot); border: var(--border-width) solid var(--border-color); border-radius: var(--border-radius); padding: var(--space-sm); } - .pulseplot .pp-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; } + /* Overlay legend — categorical identity for the overlaid drives. */ + .pulseplot .pp-legend { display: flex; flex-wrap: wrap; gap: var(--space-xs) var(--space-md); } + .pulseplot .pp-key { display: flex; align-items: center; gap: var(--space-xs); } + .pulseplot .pp-chip { width: var(--square-dot); height: var(--square-dot); + border-radius: 2px; flex: none; } + /* Overlay plot region — relative so the amplitude labels can flank the SVG. */ + .pulseplot .pp-plot { position: relative; flex: 1; min-height: 0; } + .pulseplot .pp-plot > svg { width: 100%; height: 100%; min-height: 0; display: block; } + .pulseplot .pp-ylab { position: absolute; left: 0; padding: 0 var(--space-xs); pointer-events: none; } + .pulseplot .pp-ylab.hi { top: 0; } + .pulseplot .pp-ylab.lo { bottom: 0; } + /* Small-multiples column — even strips that stay >= 34px tall, scrolling only + when a very-many-drive run can't fit the box. */ + .pulseplot .pp-multi { flex: 1; min-height: 0; overflow-y: auto; + display: flex; flex-direction: column; gap: var(--space-xs); } + .pulseplot .pp-panel { flex: 1 1 0; min-height: 34px; display: flex; flex-direction: column; } .pulseplot .pp-head { display: flex; align-items: center; gap: var(--space-sm); } - .pulseplot .pp-chip { width: var(--square-dot); height: var(--square-dot); border-radius: 2px; } - .pulseplot svg { flex: 1; width: 100%; min-height: 0; display: block; } + .pulseplot .pp-panel svg { flex: 1; width: 100%; min-height: 0; display: block; } .pulseplot .pp-step { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; } .pulseplot .pp-limit { stroke: var(--color-dim); stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; } .pulseplot .pp-zero { stroke: var(--plot-grid); stroke-width: 1; vector-effect: non-scaling-stroke; } .pulseplot .pp-band { fill: var(--plot-band); } .pulseplot .pp-axis { display: flex; justify-content: space-between; align-items: baseline; } - .pulseplot.pp-empty .pp-panel, .pulseplot.pp-empty .pp-axis { display: none; } + .pulseplot.pp-empty .pp-legend, .pulseplot.pp-empty .pp-plot, + .pulseplot.pp-empty .pp-multi, .pulseplot.pp-empty .pp-axis { display: none; } .pulseplot .pp-hint { place-self: center; margin: auto; opacity: 0.55; font-style: italic; } .pulseplot:not(.pp-empty) .pp-hint { display: none; } `, ); const MAX_KNOTS = 512; // above this, stride-decimate before rendering +const SERIES_COLORS = 6; // brand.css categorical tokens → overlay ceiling const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) const H = 100; -const PAD = 0.08; // y-domain padding around the bounds band +const PAD = 0.08; // y-domain padding around the bounds envelope export interface PulsePlotMeta { drives: number; @@ -52,19 +79,18 @@ export interface PulsePlotRecord { values: number[][]; } -interface Panel { - el: HTMLDivElement; - svg: SVGSVGElement; +/** One rendered drive: its step path + the y-scale it draws against (shared in + * overlay, per-strip in small multiples). update() is mode-agnostic over these. */ +interface Series { step: SVGPathElement; - band: SVGRectElement; - limits: [SVGLineElement, SVGLineElement]; - zero: SVGLineElement; - bounds: [number, number]; + y: (v: number) => number; } const svgEl = (tag: K): SVGElementTagNameMap[K] => document.createElementNS("http://www.w3.org/2000/svg", tag); +const seriesVar = (i: number): string => `var(--series-${(i % SERIES_COLORS) + 1})`; + export interface PulsePlot { el: HTMLDivElement; meta(m: PulsePlotMeta): void; @@ -78,8 +104,8 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { const el = document.createElement("div"); el.className = "pulseplot pp-empty"; const hint = text("pp-hint", idleHint); - el.append(hint.el); + // Shared time axis (bottom) — reused by both layouts. const axis = document.createElement("div"); axis.className = "pp-axis"; const t0Label = text("label-k", "0"); @@ -87,76 +113,138 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { const tEndLabel = text("label-k", ""); axis.append(t0Label.el, xLabel.el, tEndLabel.el); - let panels: Panel[] = []; + // Layout containers are (re)built per meta; a `body` holder is swapped between + // the overlay plot and the small-multiples column so clearing is one remove. + let body: HTMLElement | undefined; + let series: Series[] = []; let currentMeta: PulsePlotMeta | undefined; + el.append(hint.el, axis); + + function reset(): void { + if (body) body.remove(); + body = undefined; + series = []; + } + function meta(m: PulsePlotMeta): void { currentMeta = m; - for (const p of panels) p.el.remove(); - axis.remove(); - panels = m.labels.map((label, i) => { - const panel = document.createElement("div"); - panel.className = "pp-panel"; + reset(); + if (m.labels.length === 0) return; // nothing to plot; hint stays - const head = document.createElement("div"); - head.className = "pp-head"; - const chip = document.createElement("span"); - chip.className = "pp-chip"; - chip.style.background = `var(--series-${(i % 4) + 1})`; - head.append(chip, text("label-k", label).el); + body = m.drives <= SERIES_COLORS ? buildOverlay(m) : buildMultiples(m); + el.insertBefore(body, axis); // keep the shared time axis last + } + + /** OVERLAY: one shared amplitude axis for every drive. */ + function buildOverlay(m: PulsePlotMeta): HTMLElement { + const holder = document.createElement("div"); + + const legend = document.createElement("div"); + legend.className = "pp-legend"; + + const plot = document.createElement("div"); + plot.className = "pp-plot"; + const svg = mkSvg(); + + // Domain = union of all bounds, padded → the one scale every drive uses. + const lo = Math.min(...m.bounds.map((b) => b[0])); + const hi = Math.max(...m.bounds.map((b) => b[1])); + const y = yScale(lo, hi); + + // A single band only when every drive shares one envelope; else dashed + // limits alone carry the (differing) constraints. + const same = m.bounds.every((b) => b[0] === m.bounds[0][0] && b[1] === m.bounds[0][1]); + if (same) svg.append(band(y(hi), y(lo) - y(hi))); + if (lo < 0 && hi > 0) svg.append(hLine("pp-zero", y(0))); + for (const v of distinctEdges(m.bounds)) svg.append(hLine("pp-limit", y(v))); + + series = m.labels.map((label, i) => { + legend.append(legendKey(label, i)); + const step = mkStep(i); + svg.append(step); + return { step, y }; + }); - const svg = svgEl("svg"); - svg.setAttribute("viewBox", `0 0 ${W} ${H}`); - svg.setAttribute("preserveAspectRatio", "none"); + const yHi = text("label-k pp-ylab hi", formatAmp(hi)); + const yLo = text("label-k pp-ylab lo", formatAmp(lo)); + plot.append(svg, yHi.el, yLo.el); + holder.append(legend, plot); + return holder; + } + /** SMALL MULTIPLES: one strip per drive, each on its own bounds. */ + function buildMultiples(m: PulsePlotMeta): HTMLElement { + const col = document.createElement("div"); + col.className = "pp-multi"; + series = m.labels.map((label, i) => { const [lo, hi] = m.bounds[i]; const y = yScale(lo, hi); - const band = svgEl("rect"); - band.setAttribute("class", "pp-band"); - band.setAttribute("x", "0"); - band.setAttribute("width", String(W)); - band.setAttribute("y", String(y(hi))); - band.setAttribute("height", String(y(lo) - y(hi))); - - const mkLine = (cls: string, v: number): SVGLineElement => { - const line = svgEl("line"); - line.setAttribute("class", cls); - line.setAttribute("x1", "0"); - line.setAttribute("x2", String(W)); - line.setAttribute("y1", String(y(v))); - line.setAttribute("y2", String(y(v))); - return line; - }; - const limits: [SVGLineElement, SVGLineElement] = [mkLine("pp-limit", hi), mkLine("pp-limit", lo)]; - const zero = mkLine("pp-zero", 0); - - const step = svgEl("path"); - step.setAttribute("class", "pp-step"); - step.setAttribute("stroke", `var(--series-${(i % 4) + 1})`); - - svg.append(band, zero, limits[0], limits[1], step); + const panel = document.createElement("div"); + panel.className = "pp-panel"; + const head = document.createElement("div"); + head.className = "pp-head"; + head.append(chip(i), text("label-k", label).el); + const svg = mkSvg(); + svg.append(band(y(hi), y(lo) - y(hi))); + if (lo < 0 && hi > 0) svg.append(hLine("pp-zero", y(0))); + svg.append(hLine("pp-limit", y(hi)), hLine("pp-limit", y(lo))); + const step = mkStep(i); + svg.append(step); panel.append(head, svg); - el.append(panel); - return { el: panel, svg, step, band, limits, zero, bounds: m.bounds[i] }; + col.append(panel); + return { step, y }; }); - el.append(axis); // shared time axis, bottom panel only + return col; } + // -- small DOM/SVG builders shared by both layouts -- + const mkSvg = (): SVGSVGElement => { + const svg = svgEl("svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + svg.setAttribute("preserveAspectRatio", "none"); + return svg; + }; + const mkStep = (i: number): SVGPathElement => { + const step = svgEl("path"); + step.setAttribute("class", "pp-step"); + step.setAttribute("stroke", seriesVar(i)); + return step; + }; + const chip = (i: number): HTMLSpanElement => { + const c = document.createElement("span"); + c.className = "pp-chip"; + c.style.background = seriesVar(i); + return c; + }; + const legendKey = (label: string, i: number): HTMLDivElement => { + const key = document.createElement("div"); + key.className = "pp-key"; + key.append(chip(i), text("label-k", label).el); + return key; + }; + const band = (y: number, h: number): SVGRectElement => { + const r = svgEl("rect"); + r.setAttribute("class", "pp-band"); + r.setAttribute("x", "0"); + r.setAttribute("width", String(W)); + r.setAttribute("y", String(y)); + r.setAttribute("height", String(Math.max(0, h))); + return r; + }; + function update(r: PulsePlotRecord): void { - if (!currentMeta || r.values.length !== panels.length) return; + if (!currentMeta || r.values.length !== series.length || series.length === 0) return; el.classList.remove("pp-empty"); - const duration = r.dt * currentMeta.knots; - tEndLabel.set(formatT(duration)); + tEndLabel.set(formatT(r.dt * currentMeta.knots)); r.values.forEach((drive, i) => { - const p = panels[i]; - const y = yScale(p.bounds[0], p.bounds[1]); - p.step.setAttribute("d", stairsPath(decimate(drive), duration, y)); + series[i].step.setAttribute("d", stairsPath(decimate(drive), series[i].y)); }); } function clear(): void { el.classList.add("pp-empty"); - for (const p of panels) p.step.removeAttribute("d"); + for (const s of series) s.step.removeAttribute("d"); tEndLabel.set(""); } @@ -168,7 +256,23 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { return { el, meta, update, clear, waiting }; } -/** y-scale: bounds band → viewBox with PAD headroom; non-finite clamps to edge. */ +/** A horizontal full-width rule at viewBox-y `y`. */ +function hLine(cls: string, y: number): SVGLineElement { + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("class", cls); + line.setAttribute("x1", "0"); + line.setAttribute("x2", String(W)); + line.setAttribute("y1", String(y)); + line.setAttribute("y2", String(y)); + return line; +} + +/** Distinct bound edges (lo/hi across all drives), ascending → dashed limits. */ +function distinctEdges(bounds: [number, number][]): number[] { + return Array.from(new Set(bounds.flat())).sort((a, b) => a - b); +} + +/** y-scale: domain → viewBox with PAD headroom; non-finite clamps to edge. */ function yScale(lo: number, hi: number): (v: number) => number { const pad = PAD * (hi - lo || 1); const min = lo - pad, @@ -179,9 +283,10 @@ function yScale(lo: number, hi: number): (v: number) => number { }; } -/** Zero-order-hold stairs: each value held for its knot interval. */ -function stairsPath(values: number[], duration: number, y: (v: number) => number): string { - if (values.length === 0 || duration <= 0) return ""; +/** Zero-order-hold stairs: each value held for its knot interval, spanning the + * full width (drives share the time axis, so x is normalized by knot count). */ +function stairsPath(values: number[], y: (v: number) => number): string { + if (values.length === 0) return ""; const x = (k: number) => (k / values.length) * W; let d = `M0,${round(y(values[0]))}`; for (let k = 0; k < values.length; k++) { @@ -204,3 +309,9 @@ const round = (v: number): number => Math.round(v * 100) / 100; function formatT(t: number): string { return t >= 100 ? t.toFixed(0) : t >= 10 ? t.toFixed(1) : t.toFixed(2); } + +/** Compact amplitude label for the y-scale ends (trims trailing zeros). */ +function formatAmp(v: number): string { + if (!Number.isFinite(v)) return ""; + return String(Number(v.toPrecision(3))); +} diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index a2cd2be1..eaccb42b 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -31,6 +31,16 @@ defineStyle( these win over .stack on specificity — not on stylesheet order. */ .pane:not(.active) { display: none; } .pane.active { display: flex; } + /* Body = metrics rail (left) + pulse plot (right). The plot is the star, so it + grows; the metrics read as a deliberate stacked column instead of a wrapping + row of uneven tiles. Wraps to a single column when the panel is docked narrow. */ + .insp-body { display: flex; gap: var(--space-md); align-items: stretch; flex-wrap: wrap; } + .insp-metrics { display: flex; flex-direction: column; gap: var(--space-sm); + flex: 1 1 160px; min-width: 148px; max-width: 220px; } + .insp-body > .pulseplot { flex: 3 1 320px; } + /* Objective's convergence trace fills the rail width (the 120px intrinsic SVG + left dead space in the column); aspect ratio carries the height. */ + .insp-metrics .sparkline { width: 100%; height: auto; } `, ); @@ -58,16 +68,23 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); - // Layout order (left→right): ITER counter · OBJECTIVE hero · FEAS · OPT. + // Layout order (left→right): FIDELITY hero · OBJECTIVE (+trace) · ITER · FEAS · OPT. + // The hero is the OUTCOME (fidelity) — the number that matters — pending until + // the run finishes. The live optimization signal (objective + its convergence + // trace) sits beside it, never inside it. + const fidelity = metric("fidelity", { variant: "hero" }); + const objective = metric("objective", { variant: "small" }); const iteration = metric("iteration", { variant: "counter" }); - const hero = metric("objective", { variant: "hero" }); const feasibility = metric("feasibility", { variant: "small" }); const optimality = metric("optimality", { variant: "small" }); - const metrics = [iteration, hero, feasibility, optimality]; + const metrics = [fidelity, objective, iteration, feasibility, optimality]; - // Convergence sparkline lives inside the hero, under the objective value. + // The convergence sparkline traces the OBJECTIVE, not the fidelity — so it + // lives on the objective card. (Before, it sat inside the hero, which got + // relabeled to "fidelity" on completion, leaving a fidelity card sitting over + // a plot of the objective. Fidelity is not the objective function.) const spark = sparkline(); - hero.el.append(spark.el); + objective.el.append(spark.el); let gotPulse = false; // per-pane: decides the completed-without-data hint @@ -81,7 +98,7 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { topbar.append(brand, runLabel.el, status.el); const grid = document.createElement("div"); - grid.className = "metric-row"; + grid.className = "insp-metrics"; grid.append(...metrics.map((m) => m.el)); // Control row — Stop / Save pulse / Open run dir. Each posts to the extension @@ -136,10 +153,14 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { footer.className = "row wrap"; footer.append(timing.el, controls); + const body = document.createElement("div"); + body.className = "insp-body"; + body.append(grid, pulse.el); + const el = document.createElement("div"); el.className = "pane stack pad-lg scroll-y"; el.style.height = "100vh"; - el.append(topbar, pulse.el, grid, footer); + el.append(topbar, body, footer); return { el, @@ -172,8 +193,7 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { break; } case "iteration": { - hero.label("objective"); - hero.value((msg.f_val as number).toExponential(4)); + objective.value((msg.f_val as number).toExponential(4)); iteration.value(String(msg.iter)); feasibility.value((msg.eq_viol as number).toExponential(2)); optimality.value((msg.kkt_error as number).toExponential(2)); @@ -192,7 +212,6 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { gotPulse = false; pulse.waiting(WARMING_HINT); for (const m of metrics) m.clear(); - hero.label("objective"); status.set("running", "warming up"); controlStatus = "warming"; hasData = false; @@ -207,10 +226,10 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { // stopped = graceful user stop (neutral, dim); completed = success; // anything else = failure. status.set(ok ? "done" : stopped ? "idle" : "failed", ok ? "converged" : String(msg.status)); - // Promote the hero card to the final fidelity — the number that matters. + // Fill in the fidelity hero — the outcome. The objective card keeps its + // final value + convergence trace; the two are never conflated. if (ok && typeof msg.fidelity === "number") { - hero.label("fidelity"); - hero.value((msg.fidelity as number).toFixed(5)); + fidelity.value((msg.fidelity as number).toFixed(5)); } if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts controlStatus = ok ? "completed" : stopped ? "stopped" : "failed";