Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/extension/media/brand.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
261 changes: 186 additions & 75 deletions packages/extension/media/ui/components/pulseplot.ts
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,71 @@
// 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 <text>
// lives in the SVG — axis/scale labels are HTML siblings (they must not distort).

import { defineStyle } from "../style";
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;
Expand All@@ -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 = <K extends keyof SVGElementTagNameMap>(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;
Expand All@@ -78,85 +104,147 @@ 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");
const xLabel = text("label-k", "Time");
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("");
}

Expand All@@ -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,
Expand All@@ -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++) {
Expand All@@ -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)));
}
Loading
Loading