feat(trace-charts): keyboard chart control — pan / zoom-toward-cursor / reset / toggle

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
This commit is contained in:
2026-06-19 23:28:54 +02:00
parent 08a272c73d
commit 06fdafa925
3 changed files with 138 additions and 7 deletions
+52 -7
View File
@@ -66,19 +66,46 @@
}];
}
// 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.
// 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 = [];
@@ -88,14 +115,32 @@
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));
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", function () {
xMode = xMode === "ordinal" ? "continuous" : "ordinal";
render();
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();
}
@@ -103,5 +148,5 @@
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount);
else mount();
}
if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts };
if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts, keyXRange: keyXRange };
})();
@@ -0,0 +1,54 @@
// Headless guard for the trace-chart viewer's keyboard-control math (keyXRange).
//
// keyXRange is PURE: given a key + the current x-range [lo,hi] + the zoom anchor (the
// x-value to keep fixed, i.e. the cursor position) + the data extent [first,last], it
// returns the next {min,max} — or null for a non-chart key. The load-bearing property is
// "zoom toward the cursor": the anchor keeps its RELATIVE position in the new range, so
// the point under the mouse stays put. A regression that zoomed around the centre instead
// would still shrink the range but slide the cursor's point — caught here by an anchor
// off-centre (anchor=2 in a [0,10] range). Arrows pan by 15% of the span; 0/Home reset to
// the extent. Drives the pure export; no DOM/uPlot.
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const fail = (msg) => { console.error("FAIL: " + msg); process.exit(1); };
const close = (a, b) => Math.abs(a - b) < 1e-9;
const eqRange = (r, min, max, msg) => {
if (!r || !close(r.min, min) || !close(r.max, max)) fail(`${msg} (got ${JSON.stringify(r)}, want {min:${min},max:${max}})`);
};
// No document / no AURA_TRACES -> the IIFE's browser mount is skipped; we get the export.
global.window = {};
const { keyXRange } = require(join(here, "..", "assets", "chart-viewer.js"));
// lo=0, hi=10 (span 10), anchor=2 (off-centre, left), full extent [0, 99].
const lo = 0, hi = 10, anchor = 2, ext = [0, 99];
// pan: 15% of span = 1.5, span preserved.
eqRange(keyXRange("ArrowLeft", lo, hi, anchor, ext), -1.5, 8.5, "ArrowLeft pans left by 15% of the span");
eqRange(keyXRange("ArrowRight", lo, hi, anchor, ext), 1.5, 11.5, "ArrowRight pans right by 15% of the span");
// zoom in (factor 0.8) toward anchor=2 -> newLo=0.4, newHi=8.4 (span 8).
const zin = keyXRange("+", lo, hi, anchor, ext);
eqRange(zin, 0.4, 8.4, "+ zooms in toward the anchor");
if (!close((anchor - zin.min) / (zin.max - zin.min), (anchor - lo) / (hi - lo)))
fail("+ must keep the anchor at the same relative position (zoom toward cursor, not centre)");
eqRange(keyXRange("=", lo, hi, anchor, ext), 0.4, 8.4, "= aliases + (zoom in)");
// zoom out (factor 1.25) toward anchor=2 -> newLo=-0.5, newHi=12.
eqRange(keyXRange("-", lo, hi, anchor, ext), -0.5, 12, "- zooms out toward the anchor");
eqRange(keyXRange("_", lo, hi, anchor, ext), -0.5, 12, "_ aliases - (zoom out)");
// reset to the full data extent.
eqRange(keyXRange("0", lo, hi, anchor, ext), 0, 99, "0 resets to the data extent");
eqRange(keyXRange("Home", lo, hi, anchor, ext), 0, 99, "Home resets to the data extent");
// a non-chart key is ignored.
if (keyXRange("q", lo, hi, anchor, ext) !== null) fail("a non-chart key must return null");
console.log("OK — keyboard pan / zoom-toward-cursor / reset math; non-chart keys ignored.");
process.exit(0);
@@ -0,0 +1,32 @@
//! Integration guard for the `aura chart` viewer's keyboard-control math: the pure
//! `keyXRange` (arrow-pan, +/- zoom-toward-cursor, 0/Home reset) lives in
//! `chart-viewer.js` and cannot be checked from Rust; this shells out to `node`, running
//! `tests/chart_viewer_keys.mjs`, which drives the pure export. `node` is REQUIRED: if
//! absent this test FAILS.
use std::path::PathBuf;
use std::process::Command;
#[test]
fn chart_viewer_keyboard_pan_zoom_toward_cursor() {
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("chart_viewer_keys.mjs");
assert!(script.exists(), "guard script missing at {}", script.display());
let out = match Command::new("node").arg(&script).output() {
Ok(out) => out,
Err(e) => panic!(
"node is required for the chart-viewer keyboard guard but could not be run \
({e}); install Node.js or ensure `node` is on PATH"
),
};
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"chart-viewer keyboard guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
out.status.code()
);
}