476342d7b1
Hardens the families-comparison chart page (#107) so it is openable and legible on real multi-year data. Two halves, both confined to the CLI render path (engine + registry untouched, C14): #108 — serve-time min-max decimation. A pure `decimate(ChartData, buckets)` transform partitions the shared union-ts spine into <=~2000 buckets and emits each series' per-bucket min+max (nulls preserved), bounding the served page to a few thousand points regardless of the underlying M1 point count. Applied in emit_chart on both the single-run and family paths; a no-op under budget (every existing fixture). Full recorded data stays on disk; only the page is thinned. For a walk-forward family the null-fill blow-up collapses for free (an all-null bucket stays null). Deterministic (C1). #102 — a run-context header. A `ChartMeta` (name/commit/window/broker/seed/taps, + member count for a family, + bound params for a single run) is built from the RunManifest in build_chart_data / build_comparison_chart_data (which gain a `name` arg), injected into window.AURA_TRACES.meta, and rendered by a new pure buildHeader in chart-viewer.js (headless .mjs-guarded). The family window is the SPAN across members (min from, max to), not the first member's window — the only reading that labels a disjoint walk-forward family's true OOS coverage (it collapses to the shared window for sweep/MC). Reuses the existing render_value for param display (no new Scalar Display, keeping C7's tag-only param plane). The earlier "manifest is NOT carried into the page" render pin is flipped to a positive one. Verified: cargo test --workspace = 446 passed / 0 failed; cargo clippy --workspace --all-targets -D warnings clean. New coverage: 5 decimate unit tests, single-run + family meta wiring (incl. the span-window invariant), a buildHeader headless guard, and two end-to-end cli_run tests pinning the served-page meta at the binary boundary. closes #108 closes #102
185 lines
9.0 KiB
JavaScript
185 lines
9.0 KiB
JavaScript
// The `aura chart` viewer: turns the injected window.AURA_TRACES (xs + per-series
|
|
// Option<f64> points, already timestamp-union-aligned by the CLI) into uPlot charts.
|
|
//
|
|
// buildCharts is PURE (no DOM, no uPlot) and is the unit the headless guard drives.
|
|
// mount() is browser-only — it instantiates the vendored global `uPlot` per config.
|
|
(function () {
|
|
var PALETTE = ["#89b4fa", "#f38ba8", "#a6e3a1", "#f9e2af", "#cba6f7", "#94e2d5"];
|
|
|
|
// {xs, series:[{name, y_scale_id, points}]} + mode + xMode -> array of {opts, data}.
|
|
// xMode in {"ordinal","continuous"}, default "ordinal". ordinal: data[0] is the index
|
|
// spine [0..n-1] (gaps collapse) and the x-axis/legend map an index back to the real
|
|
// xs[i]; continuous: data[0] is xs. series points (data[1..]) are untouched either way
|
|
// (null gaps preserved verbatim). overlay -> one chart; panels -> one chart per series,
|
|
// sharing a cursor.sync key so the crosshair lines up. Mouse interaction is uPlot's
|
|
// default (drag = box-zoom on x, double-click = reset); we add no custom wheel/pan
|
|
// (the hand-rolled version was glitchy — uPlot is not built for it).
|
|
function buildCharts(traces, mode, xMode) {
|
|
xMode = xMode || "ordinal";
|
|
var xs = traces.xs;
|
|
var n = xs.length;
|
|
var idx = [];
|
|
for (var k = 0; k < n; k++) idx.push(k);
|
|
var ordinal = xMode !== "continuous";
|
|
var xData = ordinal ? idx : xs;
|
|
|
|
// map an x-position to its real-timestamp label (total; no epoch assumption):
|
|
// ordinal -> position is an index -> xs[round(pos)]; continuous -> position IS the ts.
|
|
function xLabel(pos) {
|
|
if (!ordinal) return String(pos);
|
|
var j = Math.max(0, Math.min(n - 1, Math.round(pos)));
|
|
return String(xs[j]);
|
|
}
|
|
function xAxisCfg() { return ordinal ? { values: function (u, splits) { return splits.map(xLabel); } } : {}; }
|
|
function xSeriesCfg() { return { value: function (u, v) { return v == null ? "--" : xLabel(v); } }; }
|
|
|
|
if (mode === "panels") {
|
|
var SYNC = "aura-x"; // shared key -> crosshair sync across panels
|
|
return traces.series.map(function (s, i) {
|
|
return {
|
|
opts: {
|
|
title: s.name,
|
|
scales: { x: { time: false }, [s.y_scale_id]: {} },
|
|
series: [xSeriesCfg(), { label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }],
|
|
axes: [xAxisCfg(), { scale: s.y_scale_id }],
|
|
cursor: { sync: { key: SYNC } },
|
|
},
|
|
data: [xData, s.points],
|
|
};
|
|
});
|
|
}
|
|
|
|
// overlay (default)
|
|
var scales = { x: { time: false } };
|
|
var uSeries = [xSeriesCfg()];
|
|
var axes = [xAxisCfg()];
|
|
traces.series.forEach(function (s, i) {
|
|
scales[s.y_scale_id] = {};
|
|
uSeries.push({ label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] });
|
|
// the first two series carry a drawn axis (left, then right); the rest keep their
|
|
// own auto-scale, read via the legend.
|
|
if (i < 2) axes.push({ scale: s.y_scale_id, side: i === 0 ? 3 : 1 });
|
|
});
|
|
return [{
|
|
opts: { scales: scales, series: uSeries, axes: axes },
|
|
data: [xData].concat(traces.series.map(function (s) { return s.points; })),
|
|
}];
|
|
}
|
|
|
|
// Pure: the next x-range {min,max} for a key, given the current range [lo,hi], the zoom
|
|
// anchor (the x-value to keep fixed — the cursor position), and the full data extent
|
|
// [first,last]. Returns null for a non-chart key. Zoom keeps `anchor` at the same
|
|
// relative position in the new range, so the point under the cursor stays put (zoom
|
|
// toward cursor); arrows pan by 15% of the span; 0/Home resets to the extent.
|
|
function keyXRange(key, lo, hi, anchor, ext) {
|
|
var span = hi - lo;
|
|
var PAN = 0.15, ZIN = 0.8, ZOUT = 1.25;
|
|
switch (key) {
|
|
case "ArrowLeft": return { min: lo - span * PAN, max: hi - span * PAN };
|
|
case "ArrowRight": return { min: lo + span * PAN, max: hi + span * PAN };
|
|
case "+":
|
|
case "=": return { min: anchor - (anchor - lo) * ZIN, max: anchor + (hi - anchor) * ZIN };
|
|
case "-":
|
|
case "_": return { min: anchor - (anchor - lo) * ZOUT, max: anchor + (hi - anchor) * ZOUT };
|
|
case "0":
|
|
case "Home": return { min: ext[0], max: ext[1] };
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
// {kind,name,commit,window:[from,to],broker,seed,taps,members,params} -> array of
|
|
// {label,value} chips. PURE (no DOM), the unit the headless guard drives; mount()
|
|
// renders the chips into the header's #ctx slot. Absent/empty meta -> [].
|
|
function buildHeader(meta) {
|
|
if (!meta) return [];
|
|
function fmtDay(ns) { return new Date(ns / 1e6).toISOString().slice(0, 10); }
|
|
var chips = [];
|
|
chips.push({ label: meta.kind === "family" ? "family" : "run", value: meta.name });
|
|
if (meta.members != null) chips.push({ label: "members", value: String(meta.members) });
|
|
if (meta.taps && meta.taps.length) chips.push({ label: "tap", value: meta.taps.join(", ") });
|
|
if (meta.broker) chips.push({ label: "broker", value: meta.broker });
|
|
if (meta.window) chips.push({ label: "window", value: fmtDay(meta.window[0]) + "→" + fmtDay(meta.window[1]) });
|
|
if (meta.seed) chips.push({ label: "seed", value: String(meta.seed) });
|
|
if (meta.commit) chips.push({ label: "commit", value: String(meta.commit).slice(0, 7) });
|
|
if (meta.params && meta.params.length)
|
|
chips.push({ label: "params", value: meta.params.map(function (p) { return p[0] + "=" + p[1]; }).join(" ") });
|
|
return chips;
|
|
}
|
|
|
|
// Browser-only: instantiate the vendored global uPlot per config into #stage, wire the
|
|
// header #xmode-toggle to flip the x spine, and add keyboard control (discrete, so it
|
|
// sidesteps the glitchy pointer drag): arrows pan, +/- zoom toward the cursor, 0/Home
|
|
// reset, x toggles. Keys drive every chart together (panels stay x-aligned).
|
|
function mount() {
|
|
var traces = window.AURA_TRACES;
|
|
var stage = document.getElementById("stage");
|
|
var toggle = document.getElementById("xmode-toggle");
|
|
var ctx = document.getElementById("ctx");
|
|
if (ctx) {
|
|
ctx.innerHTML = "";
|
|
buildHeader(traces.meta).forEach(function (c) {
|
|
var span = document.createElement("span");
|
|
var b = document.createElement("b");
|
|
b.textContent = c.label + " ";
|
|
span.appendChild(b);
|
|
span.appendChild(document.createTextNode(c.value));
|
|
span.style.marginRight = "12px";
|
|
ctx.appendChild(span);
|
|
});
|
|
}
|
|
var xMode = "ordinal";
|
|
var insts = [];
|
|
var anchorVal = 0, anchorActive = false; // x-value under the mouse, for zoom-to-cursor
|
|
// The ordinal ("collapsed") label is authored once, server-side, as the button's
|
|
// initial text (render.rs CHART_HEAD) — read it back rather than re-hardcoding the
|
|
// same string here, so the served page carries that label exactly once.
|
|
var labelOrdinal = toggle ? toggle.textContent : "";
|
|
var labelContinuous = "x: continuous (real time)";
|
|
|
|
function flip() { xMode = xMode === "ordinal" ? "continuous" : "ordinal"; render(); }
|
|
|
|
function render() {
|
|
insts.forEach(function (u) { u.destroy(); });
|
|
insts = [];
|
|
stage.innerHTML = "";
|
|
var configs = buildCharts(traces, traces.mode, xMode);
|
|
var w = stage.clientWidth || 900;
|
|
var h = configs.length > 1 ? Math.max(160, Math.floor(440 / configs.length)) : 460;
|
|
configs.forEach(function (cfg) {
|
|
// eslint-disable-next-line no-undef
|
|
var u = new uPlot(Object.assign({ width: w, height: h }, cfg.opts), cfg.data, stage);
|
|
insts.push(u);
|
|
// track the x-value under the cursor so keyboard zoom can anchor on it
|
|
u.over.addEventListener("mousemove", function (e) { anchorVal = u.posToVal(e.offsetX, "x"); anchorActive = true; });
|
|
u.over.addEventListener("mouseleave", function () { anchorActive = false; });
|
|
});
|
|
if (toggle) toggle.textContent = xMode === "ordinal" ? labelOrdinal : labelContinuous;
|
|
}
|
|
|
|
if (toggle) toggle.addEventListener("click", flip);
|
|
|
|
// Keyboard control. Ctrl/Cmd/Alt combos pass through (browser zoom etc.). The zoom
|
|
// anchor is the cursor's x-value when the mouse is over a chart, else the centre.
|
|
document.addEventListener("keydown", function (e) {
|
|
if (e.ctrlKey || e.metaKey || e.altKey || !insts.length) return;
|
|
if (e.key === "x" || e.key === "X") { e.preventDefault(); flip(); return; }
|
|
var u0 = insts[0];
|
|
var lo = u0.scales.x.min, hi = u0.scales.x.max;
|
|
var anchor = anchorActive ? anchorVal : (lo + hi) / 2;
|
|
var d0 = u0.data[0];
|
|
var r = keyXRange(e.key, lo, hi, anchor, [d0[0], d0[d0.length - 1]]);
|
|
if (!r) return;
|
|
e.preventDefault();
|
|
insts.forEach(function (u) { u.setScale("x", { min: r.min, max: r.max }); });
|
|
});
|
|
|
|
render();
|
|
}
|
|
|
|
if (typeof window !== "undefined" && typeof document !== "undefined" && window.AURA_TRACES) {
|
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount);
|
|
else mount();
|
|
}
|
|
if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts, keyXRange: keyXRange, buildHeader: buildHeader };
|
|
})();
|