Files
Aura/docs/plans/0057-trace-chart-viewer-tier1.md
T
Brummel bb8045ea59 plan: trace-chart viewer Tier-1 — ordinal x-axis + zoom/pan/cursor-sync
Five tasks: RED x-mode guard; buildCharts gains xMode (ordinal default) +
wheelZoomPlugin + panel cursor-sync, with the two twin guards re-pointed in
lockstep; mount render(xMode)+toggle; render.rs CHART_HEAD toggle + chart-only
style + chrome tests; full-suite/clippy/e2e gate.

refs #103
2026-06-19 18:23:42 +02:00

23 KiB

Trace-chart viewer Tier-1 (ordinal x-axis + zoom/pan/cursor-sync) — Implementation Plan

Parent spec: docs/specs/0057-trace-chart-viewer-tier1.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Make the served trace chart's x-axis ordinal (gap-collapsed) by default with a one-click continuous toggle, and add wheel-zoom / drag-pan / dblclick-reset, a value-readout legend, and panel cursor-sync — confined to the read/render side.

Architecture: The whole feature lives in crates/aura-cli/assets/chart-viewer.js (the pure buildCharts gains an xMode param + a wheelZoomPlugin factory; the browser-only mount gains a render(xMode) teardown/rebuild wired to a header toggle) and crates/aura-cli/src/render.rs (CHART_HEAD gains the toggle button; a chart-only <style> is injected by render_chart_html). The Rust serve contract (build_chart_data, the injected window.AURA_TRACES = {mode, xs, series}, ColumnarTrace/TraceStore) is unchanged.

Tech Stack: vendored uPlot 1.6.32 (IIFE global); pure JS buildCharts driven by headless node guards; Rust render_chart_html string assembly + inline #[cfg(test)] tests.


Files this plan creates or modifies

  • Create: crates/aura-cli/tests/chart_viewer_xmode.mjs — headless guard: default-ordinal data[0] index spine, "continuous"data[0] === xs, panel cursor.sync.key equality, opts.plugins[0].hooks.init present.
  • Create: crates/aura-cli/tests/chart_viewer_xmode.rs — Rust build-guard shell mirroring chart_viewer.rs (shells node at tests/chart_viewer_xmode.mjs); auto-discovered.
  • Modify: crates/aura-cli/assets/chart-viewer.js:7-61 — add wheelZoomPlugin; rewrite buildCharts (lines 12-40) with xMode; rewrite mount (lines 43-54).
  • Modify: crates/aura-cli/src/render.rs:63-69 (CHART_HEAD toggle button), :75-77 (add CHART_CSS const), :136-163 (render_chart_html injects CHART_CSS), :175-250 (inline tests: assert xmode-toggle present on the chart page, absent on the graph page).
  • Test: crates/aura-cli/tests/chart_viewer_build.mjs:32-46 — re-point the two data[0] === xs assertions to the ordinal index spine; add an explicit "continuous" check.
  • Test: crates/aura-cli/tests/chart_viewer_gaps.mjs:32-46 — re-point the two data[0] === xs assertions to the ordinal index spine; the null-gap assertions on data[1..] stay unchanged.
  • Fixtures (read-only, not modified): crates/aura-cli/tests/fixtures/sample-traces.json (xs:[1,2,3], 2 series), crates/aura-cli/tests/fixtures/sparse-traces.json (xs:[1,2,3,4], 2 series with null gaps).

Task 1: New RED guard — x-mode / sync / interaction-plugin

Files:

  • Create: crates/aura-cli/tests/chart_viewer_xmode.mjs

  • Create: crates/aura-cli/tests/chart_viewer_xmode.rs

  • Step 1: Write the failing node guard

Create crates/aura-cli/tests/chart_viewer_xmode.mjs:

// Headless guard for the trace-chart viewer's x-mode + interaction wiring.
// Properties: (1) the DEFAULT x spine is ORDINAL — data[0] is [0..n-1], not the raw
// timestamps, so non-trading gaps collapse; the x-axis carries an index->label values
// formatter. (2) "continuous" mode restores data[0] === xs verbatim. (3) panels carry a
// shared cursor.sync.key so the crosshair lines up. (4) every config attaches an
// interaction plugin with an init hook. Drives the pure buildCharts; no DOM/uPlot.
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
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 traces = JSON.parse(readFileSync(join(here, "fixtures", "sample-traces.json"), "utf8"));

const fail = (msg) => { console.error("FAIL: " + msg); process.exit(1); };
const sameArr = (a, b, msg) => {
  if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.some((v, i) => v !== b[i])) fail(msg);
};

global.window = { AURA_TRACES: traces };
const { buildCharts } = require(join(here, "..", "assets", "chart-viewer.js"));

const n = traces.xs.length;
const idx = Array.from({ length: n }, (_, i) => i);

// (1) default is ordinal: data[0] is the index spine, with an x-axis values formatter.
const ov = buildCharts(traces, "overlay");
sameArr(ov[0].data[0], idx, "default overlay x is the ordinal index spine [0..n-1]");
if (typeof ov[0].opts.axes[0].values !== "function") fail("ordinal overlay x-axis lacks a values formatter");

// (4) interaction wired: a plugin with an init hook.
if (!Array.isArray(ov[0].opts.plugins) || ov[0].opts.plugins.length < 1) fail("no interaction plugin attached");
if (typeof ov[0].opts.plugins[0].hooks.init !== "function") fail("interaction plugin has no init hook");

// (2) continuous mode: data[0] is xs verbatim.
const ovc = buildCharts(traces, "overlay", "continuous");
sameArr(ovc[0].data[0], traces.xs, "continuous overlay x is xs verbatim");

// (3) panels: cursor.sync.key present and equal across panels; default still ordinal.
const panels = buildCharts(traces, "panels");
const keys = panels.map((p) => p.opts.cursor && p.opts.cursor.sync && p.opts.cursor.sync.key);
if (keys.some((k) => !k)) fail("a panel lacks cursor.sync.key");
if (new Set(keys).size !== 1) fail("panels do not share one cursor.sync key");
sameArr(panels[0].data[0], idx, "default panel x is the ordinal index spine");

console.log("OK — ordinal default + continuous toggle + panel cursor-sync + interaction plugin.");
process.exit(0);
  • Step 2: Write the Rust build-guard shell

Create crates/aura-cli/tests/chart_viewer_xmode.rs:

//! Integration guard for the `aura chart` viewer's x-mode (ordinal default + continuous
//! toggle), panel cursor-sync, and interaction-plugin wiring. These live in
//! `chart-viewer.js` and cannot be checked from Rust; this shells out to `node`, running
//! `tests/chart_viewer_xmode.mjs`, which drives the pure `buildCharts`. `node` is
//! REQUIRED: if absent this test FAILS.

use std::path::PathBuf;
use std::process::Command;

#[test]
fn chart_viewer_ordinal_default_continuous_toggle_and_sync() {
    let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("chart_viewer_xmode.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 x-mode 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 x-mode guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
        out.status.code()
    );
}
  • Step 3: Run the new guard to verify it FAILS against current code

Run: cargo test -p aura-cli --test chart_viewer_xmode Expected: FAIL — the node guard prints FAIL: default overlay x is the ordinal index spine [0..n-1] (today buildCharts ignores the 3rd arg and sets data[0] = xs, i.e. [1,2,3], not [0,1,2]), node exits 1, the Rust assertion panics with chart-viewer x-mode guard failed.


Task 2: buildChartsxMode + ordinal default + sync + plugin (and re-point the twin guards)

Files:

  • Modify: crates/aura-cli/assets/chart-viewer.js:7-40

  • Test: crates/aura-cli/tests/chart_viewer_build.mjs:32-46

  • Test: crates/aura-cli/tests/chart_viewer_gaps.mjs:32-46

  • Step 1: Add wheelZoomPlugin and rewrite buildCharts

In crates/aura-cli/assets/chart-viewer.js, replace the current buildCharts function (lines 12-40) with the wheelZoomPlugin factory followed by the new buildCharts. The PALETTE declaration (line 7) and the leading comment block stay; mount (Task 3) and the bootstrap/exports (lines 56-61) stay for now. New text:

  // 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; })),
    }];
  }
  • Step 2: Re-point the build guard for the ordinal default

In crates/aura-cli/tests/chart_viewer_build.mjs, the default buildCharts(traces, "overlay") now yields an ordinal data[0]. Add an index spine and re-point the two data[0] === xs assertions; add an explicit continuous check. Replace lines 29-46 (from const n = traces.series.length; through the panels loop) with:

const n = traces.series.length;
const idx = Array.from({ length: traces.xs.length }, (_, i) => i);

// overlay: ONE chart, all series superimposed, each on its own scale. Default x spine is
// ORDINAL (indices) so non-trading gaps collapse.
const overlay = buildCharts(traces, "overlay");
eq(overlay.length, 1, "overlay is one chart");
eq(overlay[0].data.length, 1 + n, "overlay data = xs + N series");
eq(overlay[0].opts.series.length, 1 + n, "overlay uPlot series = x + N");
sameArr(overlay[0].data[0], idx, "default overlay x-axis is the ordinal index spine");
const scaleIds = overlay[0].opts.series.slice(1).map((s) => s.scale);
eq(new Set(scaleIds).size, n, "each overlay series has a distinct y-scale");

// continuous mode restores the real-timestamp x spine.
const overlayC = buildCharts(traces, "overlay", "continuous");
sameArr(overlayC[0].data[0], traces.xs, "continuous overlay x-axis is the shared xs");

// panels: ONE chart per series, all positionally aligned on the SAME ordinal spine.
const panels = buildCharts(traces, "panels");
eq(panels.length, n, "panels = one chart per series");
for (const p of panels) {
  eq(p.data.length, 2, "each panel = xs + its one series");
  sameArr(p.data[0], idx, "panel shares the same ordinal index spine (position-aligned)");
}
  • Step 3: Re-point the gaps guard for the ordinal default

In crates/aura-cli/tests/chart_viewer_gaps.mjs, re-point only the two data[0] === xs assertions (lines 37 and 43); the null-gap assertions on data[1..] (lines 38-39, 44-45) stay byte-for-byte. Replace lines 32-45 (from const eqPoints through the panel-1 assertion) with:

const eqPoints = traces.series[0].points;   // [0.0, 0.001, null, 0.004]
const exPoints = traces.series[1].points;   // [1.0, null, null, -1.0]
const idx = Array.from({ length: traces.xs.length }, (_, i) => i);

// overlay: the data array is [ordinal spine, ...each series' points], nulls preserved.
const overlay = buildCharts(traces, "overlay")[0];
sameArr(overlay.data[0], idx, "overlay x is the ordinal index spine (gaps collapsed)");
sameArr(overlay.data[1], eqPoints, "overlay equity points carry their null gap verbatim");
sameArr(overlay.data[2], exPoints, "overlay exposure points carry their null gaps verbatim");

// panels: each panel is [ordinal spine, its own series' points] — same nulls.
const panels = buildCharts(traces, "panels");
sameArr(panels[0].data[0], idx, "panel 0 shares the ordinal index spine");
sameArr(panels[0].data[1], eqPoints, "panel 0 carries equity's null gap verbatim");
sameArr(panels[1].data[1], exPoints, "panel 1 carries exposure's null gaps verbatim");
  • Step 4: Run the new guard — now PASSES

Run: cargo test -p aura-cli --test chart_viewer_xmode Expected: PASS — node prints OK — ordinal default + continuous toggle + panel cursor-sync + interaction plugin.

  • Step 5: Run the re-pointed build guard

Run: cargo test -p aura-cli --test chart_viewer Expected: PASS — node prints OK — overlay 1 chart / 2 series, panels 2 charts, shared xs. (the build guard's existing success line; structural assertions unchanged).

  • Step 6: Run the re-pointed gaps guard

Run: cargo test -p aura-cli --test chart_viewer_gaps Expected: PASS — node prints OK — null gaps preserved verbatim across overlay + panels, shared union xs.


Task 3: mount — re-render on toggle (browser-only)

Files:

  • Modify: crates/aura-cli/assets/chart-viewer.js:43-54

  • Step 1: Rewrite mount for a toggle-driven re-render

In crates/aura-cli/assets/chart-viewer.js, replace the current mount function (lines 43-54) with:

  // 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 = [];
    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" ? "x: collapsed (gaps off)" : "x: continuous (real time)";
    }
    if (toggle) toggle.addEventListener("click", function () {
      xMode = xMode === "ordinal" ? "continuous" : "ordinal";
      render();
    });
    render();
  }
  • Step 2: Build the CLI to confirm the asset still compiles in

chart-viewer.js is include_str!'d into the binary; mount is browser-only and not exercised by the node guards. Confirm the crate builds (the asset string is embedded):

Run: cargo build -p aura-cli Expected: clean build, exit 0.

  • Step 3: Re-run all three node guards to confirm no regression

Run: cargo test -p aura-cli --test chart_viewer_xmode Expected: PASS (mount changes do not touch the pure buildCharts).


Task 4: render.rs — toggle button + chart-only style + chrome tests

Files:

  • Modify: crates/aura-cli/src/render.rs:63-69 (CHART_HEAD), :75-77 (new CHART_CSS), :136-163 (render_chart_html), :175-250 (inline tests)

  • Step 1: Add the toggle button to CHART_HEAD

In crates/aura-cli/src/render.rs, replace the CHART_HEAD const (lines 63-69) with:

/// The chart page's `<title>` text and `<header>` line — describes what the chart
/// view actually renders (timestamp-aligned trace series), not the graph viewer. The
/// `#xmode-toggle` button is wired by `chart-viewer.js` (`mount`) to flip the x-axis
/// between the ordinal (gap-collapsed) default and continuous (real-time) spacing.
const CHART_HEAD: &str = r#"<header>
  <b>aura chart</b>
  <span class="sub">timestamp-aligned trace series</span>
  <button id="xmode-toggle" type="button">x: collapsed (gaps off)</button>
</header>
<div id="stage"></div>
<pre id="err"></pre>
"#;
  • Step 2: Add the chart-only CHART_CSS const

In crates/aura-cli/src/render.rs, immediately after the CHART_VIEWER_JS const (line 77), add:

/// Chart-page-only styling (the `#xmode-toggle` button) — injected by
/// `render_chart_html` after the vendored uPlot CSS, NOT into the shared `SHELL_HEAD`
/// (the graph page has no toggle and must not inherit its rule).
const CHART_CSS: &str = r#"
#xmode-toggle { margin-left: auto; background: #1e1e2e; color: #cdd6f4;
  border: 1px solid #313244; border-radius: 6px; padding: 4px 10px;
  font-family: ui-monospace, monospace; font-size: 12px; cursor: pointer; }
#xmode-toggle:hover { border-color: #89b4fa; color: #f5e0dc; }
"#;
  • Step 3: Inject CHART_CSS in render_chart_html

In crates/aura-cli/src/render.rs render_chart_html, the style block currently pushes only UPLOT_CSS (around lines 153-155):

    html.push_str("<style>");
    html.push_str(UPLOT_CSS);
    html.push_str("</style>\n<script>");

Replace it with (append CHART_CSS inside the same <style>):

    html.push_str("<style>");
    html.push_str(UPLOT_CSS);
    html.push_str(CHART_CSS);
    html.push_str("</style>\n<script>");

And extend the String::with_capacity hint (around lines 148-150) to include CHART_CSS.len():

    let mut html = String::with_capacity(
        SHELL_HEAD.len() + CHART_HEAD.len() + UPLOT_JS.len() + UPLOT_CSS.len() + CHART_CSS.len() + CHART_VIEWER_JS.len() + traces.len() + 256,
    );
  • Step 4: Assert the toggle is present on the chart page

In crates/aura-cli/src/render.rs, the test render_chart_html_wears_its_own_chart_label (lines 228-235) asserts the chart chrome. Add a toggle-present assertion. After the existing assert!(html.contains("<b>aura chart</b>"), ...) line, insert:

        // the x-axis toggle control is part of the chart chrome
        assert!(html.contains("id=\"xmode-toggle\""), "x-axis toggle control missing from the chart page");
  • Step 5: Assert the toggle does NOT leak onto the graph page

In crates/aura-cli/src/render.rs, the test render_html_is_self_contained_and_embeds_the_model (lines 176-197) renders the graph page. Before its closing brace, add:

        // the chart-only toggle control must not leak onto the graph page
        assert!(!html.contains("xmode-toggle"), "chart toggle leaked onto the graph page");
  • Step 6: Run the render inline tests

Run: cargo test -p aura-cli --lib render Expected: PASS — all render_chart_html_* and render_html_* tests green, including the two new assertions.


Task 5: Full-suite + clippy + e2e gate

Files: none (verification only)

  • Step 1: Full workspace test suite

Run: cargo test --workspace Expected: PASS — all tests green, including the new chart_viewer_xmode guard, the re-pointed chart_viewer / chart_viewer_gaps guards, and the render inline tests.

  • Step 2: Clippy with warnings-as-errors

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: exit 0, no warnings.

  • Step 3: Real e2e — persist then chart, confirm ordinal default + toggle in the page

Run: cargo run -q -p aura-cli -- run --trace demo && cargo run -q -p aura-cli -- chart demo > /tmp/demo-chart.html && grep -c 'id="xmode-toggle"' /tmp/demo-chart.html && grep -c 'x: collapsed (gaps off)' /tmp/demo-chart.html Expected: both grep -c print 1 — the served page carries the toggle control and its default "collapsed" label. (aura run --trace demo writes runs/traces/demo/; aura chart demo emits the self-contained page to stdout.)