diff --git a/crates/aura-cli/assets/chart-viewer.js b/crates/aura-cli/assets/chart-viewer.js index b949466..53833ce 100644 --- a/crates/aura-cli/assets/chart-viewer.js +++ b/crates/aura-cli/assets/chart-viewer.js @@ -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 }; })(); diff --git a/crates/aura-cli/tests/chart_viewer_keys.mjs b/crates/aura-cli/tests/chart_viewer_keys.mjs new file mode 100644 index 0000000..b1f6a93 --- /dev/null +++ b/crates/aura-cli/tests/chart_viewer_keys.mjs @@ -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); diff --git a/crates/aura-cli/tests/chart_viewer_keys.rs b/crates/aura-cli/tests/chart_viewer_keys.rs new file mode 100644 index 0000000..5b0399f --- /dev/null +++ b/crates/aura-cli/tests/chart_viewer_keys.rs @@ -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() + ); +}