45cc1c44c9
The served trace chart's x-axis no longer plots against raw timestamps by default — non-trading gaps (nights, weekends) left horizontal blanks that dominated the view. The x spine is now ordinal by default: every recorded slot sits at an even position, so gaps collapse and the signal fills the width. A header toggle flips to continuous (real-time-proportional) spacing and back; default ordinal honours the requested "x-axis should not be continuous", with continuous as the one-click option. Comfort, all on the read/render side (the engine and the Rust serve contract are untouched — window.AURA_TRACES still carries the real xs, the ordinal indices are derived client-side): - ordinal mode keeps the axis honest: ticks and the legend value-readout map a collapsed index back to the real xs[i], never the index itself. - wheel-zoom (x, cursor-anchored), drag-pan, double-click-reset on the plot (uPlot's default drag-box-zoom is disabled so drag pans). - panels share a cursor.sync key, so the crosshair lines up across stacked panels. Confined to chart-viewer.js (buildCharts gains an xMode param + a wheelZoomPlugin factory; mount re-renders on the toggle) and render.rs (CHART_HEAD toggle button + a chart-only style). The two twin node guards (build/gaps) re-point to the ordinal default in lockstep; a new x-mode guard pins ordinal-default / continuous / panel-sync / plugin wiring, and a label-honesty guard (gappy-traces fixture, xs far from the index spine) pins that ordinal ticks report the real timestamp. Verified: cargo test --workspace green (incl. the four chart guards + render inline tests), clippy --all-targets -D warnings clean, e2e (aura run --trace + aura chart) emits a self-contained page defaulting to ordinal with the toggle. Spec docs/specs/0057, plan docs/plans/0057 (boss-signed; grounding-check PASS). Derived-fork decision log in the issue comments. closes #103
144 lines
6.6 KiB
JavaScript
144 lines
6.6 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"];
|
|
|
|
// wheelZoomPlugin: a pure factory (safe to construct headless — its hooks run only in
|
|
// a browser). On init it wires wheel-zoom (x, cursor-anchored), drag-pan, and
|
|
// dblclick-reset onto u.over. uPlot's default drag-box-zoom is disabled in buildCharts
|
|
// (cursor.drag off) so drag pans instead.
|
|
function wheelZoomPlugin() {
|
|
var ZOOM = 0.9; // one wheel notch shrinks/grows the visible x-range by this factor
|
|
return { hooks: { init: function (u) {
|
|
var over = u.over;
|
|
over.addEventListener("wheel", function (e) {
|
|
e.preventDefault();
|
|
var rect = over.getBoundingClientRect();
|
|
var xVal = u.posToVal(e.clientX - rect.left, "x");
|
|
var lo = u.scales.x.min, hi = u.scales.x.max;
|
|
var f = e.deltaY < 0 ? ZOOM : 1 / ZOOM; // wheel up = zoom in
|
|
u.setScale("x", { min: xVal - (xVal - lo) * f, max: xVal + (hi - xVal) * f });
|
|
}, { passive: false });
|
|
over.addEventListener("mousedown", function (d) {
|
|
if (d.button !== 0) return;
|
|
var x0 = d.clientX, lo = u.scales.x.min, hi = u.scales.x.max;
|
|
var unitsPerPx = (hi - lo) / over.clientWidth;
|
|
function move(m) {
|
|
var dx = (m.clientX - x0) * unitsPerPx;
|
|
u.setScale("x", { min: lo - dx, max: hi - dx });
|
|
}
|
|
function up() {
|
|
document.removeEventListener("mousemove", move);
|
|
document.removeEventListener("mouseup", up);
|
|
}
|
|
document.addEventListener("mousemove", move);
|
|
document.addEventListener("mouseup", up);
|
|
});
|
|
over.addEventListener("dblclick", function () {
|
|
u.setScale("x", { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] });
|
|
});
|
|
} } };
|
|
}
|
|
|
|
// {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.
|
|
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 }, drag: { x: false, y: false } },
|
|
plugins: [wheelZoomPlugin()],
|
|
},
|
|
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, cursor: { drag: { x: false, y: false } }, plugins: [wheelZoomPlugin()] },
|
|
data: [xData].concat(traces.series.map(function (s) { return s.points; })),
|
|
}];
|
|
}
|
|
|
|
// Browser-only: instantiate the vendored global uPlot per config into #stage, and wire
|
|
// the header #xmode-toggle to flip the x spine (ordinal <-> continuous) and re-render.
|
|
function mount() {
|
|
var traces = window.AURA_TRACES;
|
|
var stage = document.getElementById("stage");
|
|
var toggle = document.getElementById("xmode-toggle");
|
|
var xMode = "ordinal";
|
|
var insts = [];
|
|
// 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 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
|
|
insts.push(new uPlot(Object.assign({ width: w, height: h }, cfg.opts), cfg.data, stage));
|
|
});
|
|
if (toggle) toggle.textContent = xMode === "ordinal" ? labelOrdinal : labelContinuous;
|
|
}
|
|
if (toggle) toggle.addEventListener("click", function () {
|
|
xMode = xMode === "ordinal" ? "continuous" : "ordinal";
|
|
render();
|
|
});
|
|
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 };
|
|
})();
|