06fdafa925
Keyboard control for the served chart, deliberately the discrete counterpart to the
glitchy hand-rolled pointer drag that was just removed: each key is one clean
setScale("x", …), deterministic, with none of uPlot's pointer-pan jank.
- arrows pan (15% of the visible span), +/- zoom, 0/Home reset, x toggles the spine.
- zoom anchors on the CURSOR: the x-value under the mouse keeps its relative position,
so the point under the pointer stays put while zooming (centre when the mouse is off
the chart). The mouse position is tracked per chart via posToVal on mousemove.
- keys drive every chart instance together, so panels stay x-aligned.
- uPlot's default mouse (drag = box-zoom, double-click = reset) is retained — you have
both.
The load-bearing math is the pure keyXRange(key, lo, hi, anchor, ext) helper (the rest
is browser-only DOM wiring in mount); it is node-guarded by chart_viewer_keys.mjs, which
pins the zoom-toward-cursor invariant (anchor keeps its relative position) with an
off-centre anchor so a centre-zoom regression fails loudly. All five chart guards green.
refs #103
153 lines
7.4 KiB
JavaScript
153 lines
7.4 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;
|
|
}
|
|
}
|
|
|
|
// 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 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 };
|
|
})();
|