Files
Aura/docs/specs/0057-trace-chart-viewer-tier1.md
T
Brummel be71f0a55c spec: trace-chart viewer Tier-1 — ordinal x-axis + zoom/pan/cursor-sync (boss-signed)
Tier-1 comfort pass for the served trace chart (#103): an ordinal x-axis
default (collapses non-trading gaps) with a one-click continuous toggle, plus
wheel-zoom / drag-pan / dblclick-reset, a value-readout legend, and panel
cursor-sync. Confined to the read/render side (chart-viewer.js + render.rs
CHART_HEAD); the Rust serve contract is unchanged.

Boss-signed: grounding-check PASS (five current-behaviour assumptions ratified
by green node guards + render.rs inline tests). Derived-fork decision log in
the #103 comments.

refs #103
2026-06-19 18:16:03 +02:00

17 KiB

Trace-chart viewer comfort (Tier 1) — ordinal x-axis + zoom/pan/cursor-sync — Design Spec

Date: 2026-06-19 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Seeds: Gitea #103 (reference issue; derived-fork decision log in its comments). Lineage: #101 (trace charts, first cut), C22 (playground = trace explorer), C14 (engine headless — the viewer is a downstream read of recorded traces).

Goal

Make the served trace chart feel like a chart rather than a screenshot, and satisfy the one explicitly-requested data-model change: the x-axis must not be continuous by default (gaps for non-trading time collapse), with a continuous spacing available as a one-click option.

Two strands, both confined to the read/render side:

  1. Ordinal x-axis (default) + in-page continuous toggle. Plot every slot at an even ordinal position so nights/weekends collapse; one toggle flips to true timestamp-proportional (continuous) spacing and back.
  2. Tier-1 interaction comfort. wheel-zoom + drag-pan + double-click-reset on the x-axis; a value readout at the cursor (the real timestamp in ordinal mode); the crosshair synchronized across stacked panels.

Non-goals (explicitly deferred, named so the cut stays honest): epoch→date axis labels / session-aware ticks (needs instrument context — #102 / Tier-3); a live aura serve backend (the page stays static & self-contained); LOD / downsampling ("mipmapping"); families/comparison meta-views (C21); drawing tools (C22 forbids a scene editor).

Feature-acceptance criterion (applied prospectively)

aura's criterion (CLAUDE.md domain invariants + the skills default): the intended audience reaches for it, it improves legibility/correctness, and it reintroduces no failure class a core constraint forbids.

  • Audience reaches for it. A trader reading a recorded backtest trace expects bars evenly spaced (no weekend chasms) and expects to wheel-zoom / drag-pan — every charting tool behaves so. The worked invocation (below) is unchanged; what changes is that the emitted page is legible at a glance.
  • Improves legibility, not just decoration. Gap-collapse is the difference between a trace dominated by blank overnight stretches and one where the signal fills the width — directly serving C22's "trace explorer" mission.
  • Reintroduces no forbidden failure class. This is a pure read-side render concern. The engine, the recorded SoA traces (C7), build_chart_data's union-spine alignment, and the ColumnarTrace/TraceStore contract are all untouched (C14: engine knows no UI). Determinism/causality (C1/C2) live in the engine, not the viewer.

Architecture

The whole feature lives in two files; the Rust serve contract is unchanged.

  • crates/aura-cli/assets/chart-viewer.js — the pure buildCharts gains an xMode parameter and assembles ordinal-vs-continuous uPlot configs + the interaction plugin + the panel cursor-sync key; the browser-only mount gains a re-render-on-toggle loop.
  • crates/aura-cli/src/render.rsCHART_HEAD gains the toggle control; a small chart-only <style> is injected for it and for legend/readout polish.

Unchanged (asserted, not re-derived): build_chart_data (main.rs) still emits ChartData { xs, series }; render_chart_html still injects window.AURA_TRACES = { mode, xs, series }; xs is the real union-spine timestamps and series[*].points are Option<f64> over xs with null gaps verbatim. The viewer never needs more data than it already receives — the ordinal indices are derived client-side from xs.length, and the real timestamp for any slot is read back from the injected xs.

Why client-side, default ordinal (derived fork, recorded on #103)

An in-page toggle beats a CLI flag: it lets the viewer compare both spacings without regenerating the page, and keeps the injected payload + the entire Rust side untouched (no repeat of the #102 manifest-drift). Ordinal is the default (the issue's primary clause "should not be continuous"); continuous is the "(or only optional)" fallback, one click away. Full rationale + the three secondary derived forks (interaction gestures, ordinal label = raw xs[i], global toggle, panel-only sync) are minuted in #103's comments.

Concrete code shapes

1. Worked user-facing surface (the acceptance evidence)

The CLI invocation is unchanged — the comfort is in the emitted page:

$ aura run --trace demo            # persist taps (unchanged)
$ aura chart demo > demo.html      # overlay, self-contained (unchanged invocation)
$ aura chart demo --panels > demo_panels.html

Opening demo.html now:

  • the x-axis is ordinal by default — every recorded slot is evenly spaced, so an overnight/weekend gap in xs no longer leaves a horizontal blank; the trace fills the width.
  • a header control reads x: collapsed (gaps off); clicking it flips to x: continuous (real time) and re-renders both/all charts in that spacing; clicking again flips back. (Label text is illustrative; exact wording is the planner's.)
  • wheel over the plot zooms the x-axis around the cursor; drag pans; double-click restores the full range.
  • the uPlot legend reads out each series' value at the cursor; in ordinal mode its x-readout shows the real xs[i] timestamp for the hovered slot (not the ordinal index).
  • in --panels, the vertical crosshair is synchronized — hovering one panel lines the cursor up across all panels at the same slot.

2. buildCharts — before → after (the load-bearing change)

Before (today — implicit continuous, x = real xs):

// buildCharts(traces, mode) -> configs; data[0] is always traces.xs
function buildCharts(traces, mode) {
  var xs = traces.xs;
  var series = traces.series;
  if (mode === "panels") {
    return series.map(function (s, i) {
      return { opts: { title: s.name, scales: { x: { time: false }, [s.y_scale_id]: {} },
                       series: [{}, { label: s.name, scale: s.y_scale_id, stroke: ... }],
                       axes: [{}, { scale: s.y_scale_id }] },
               data: [xs, s.points] };
    });
  }
  // overlay …
  return [{ opts: { scales, series: uSeries, axes }, data: [xs].concat(series.map(s => s.points)) }];
}

After (x-mode parameter; ordinal default; interaction plugin + panel sync):

// buildCharts(traces, mode, xMode) -> configs.  xMode in {"ordinal","continuous"},
// default "ordinal".  In ordinal mode data[0] is the index spine [0..n-1] and the
// x-axis/legend map an index back to the real traces.xs[i]; in continuous mode data[0]
// is traces.xs (today's behaviour).  series points (data[1..]) are untouched in either
// mode — null gaps preserved verbatim.
function buildCharts(traces, mode, xMode) {
  xMode = xMode || "ordinal";
  var xs = traces.xs;
  var n = xs.length;
  var idx = []; for (var i = 0; i < n; i++) idx.push(i);   // the ordinal spine
  var ordinal = xMode !== "continuous";
  var xData = ordinal ? idx : xs;

  // map an x-position to its real-timestamp label string (total, no epoch assumption):
  //   ordinal  -> position is an index -> xs[round(pos)]
  //   continuous -> position already IS the timestamp
  function xLabel(pos) {
    if (!ordinal) return String(pos);
    var k = Math.max(0, Math.min(n - 1, Math.round(pos)));
    return String(xs[k]);
  }
  var xAxis = ordinal ? { values: function (u, splits) { return splits.map(xLabel); } } : {};
  var xSeries = { value: function (u, v) { return v == null ? "--" : xLabel(v); } };

  var plugins = [wheelZoomPlugin()];          // pure factory; hooks run only in browser
  var cursor  = { drag: { x: false, y: false } };   // drag-box-zoom off; we pan instead

  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: [xSeries, { label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }],
          axes: [xAxis, { scale: s.y_scale_id }],
          cursor: Object.assign({ sync: { key: SYNC } }, cursor),
          plugins: plugins,
        },
        data: [xData, s.points],
      };
    });
  }

  // overlay: one chart, all series, each own y-scale; same xData spine.
  var scales = { x: { time: false } };
  var uSeries = [xSeries];
  var axes = [xAxis];
  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] });
    if (i < 2) axes.push({ scale: s.y_scale_id, side: i === 0 ? 3 : 1 });
  });
  return [{
    opts: { scales: scales, series: uSeries, axes: axes, cursor: cursor, plugins: plugins },
    data: [xData].concat(traces.series.map(function (s) { return s.points; })),
  }];
}

3. wheelZoomPlugin — new (browser-only hooks, pure factory)

A small uPlot plugin: on init it attaches wheel-zoom + pointer-drag-pan to u.over and a double-click reset. The factory is pure (returns a { hooks: { init } } object — safe to construct under the headless node guard); the hook body only runs in a browser.

function wheelZoomPlugin() {
  var ZOOM = 0.9;   // 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;            // up = zoom in
      u.setScale("x", { min: xVal - (xVal - lo) * f, max: xVal + (hi - xVal) * f });
    }, { passive: false });
    // drag-pan
    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 () {          // reset to full range
      u.setScale("x", { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] });
    });
  } } };
}

4. mount — before → after (re-render on toggle)

Before: one pass, instantiate each config. After: a render(xMode) that tears down and rebuilds, wired to the header toggle; default xMode = "ordinal".

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) {
      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();
}

5. render.rs CHART_HEAD — before → after

Add the toggle control to the chart header (graph page unaffected — it has its own GRAPH_HEAD), plus a tiny chart-only <style> injected in render_chart_html after the uPlot CSS.

// before
const CHART_HEAD: &str = r#"<header>
  <b>aura chart</b>
  <span class="sub">timestamp-aligned trace series</span>
</header>
<div id="stage"></div>
<pre id="err"></pre>
"#;

// after — a toggle button the viewer wires up; default label set by mount()
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>
"#;

The injected chart-only style (small; lives in render_chart_html, not the shared SHELL_HEAD): a button rule (#xmode-toggle { … cursor:pointer; … }) consistent with the dark palette. Exact bytes are the planner's.

Components

Component File Change
buildCharts chart-viewer.js gains xMode (default "ordinal"); ordinal index spine + x label/legend mapping; attaches wheelZoomPlugin + (panels) cursor.sync
wheelZoomPlugin chart-viewer.js new pure factory; browser-only wheel-zoom / drag-pan / dblclick-reset hooks
mount chart-viewer.js render(xMode) teardown+rebuild; toggle wiring; default ordinal
CHART_HEAD + chart <style> render.rs toggle button + its styling
node guards tests/*.mjs + .rs new xmode guard; existing build/gap guards updated for the ordinal default

Data flow

Unchanged through Rust: drained taps → ColumnarTraceTraceStorebuild_chart_data (union-spine join_on_ts) → ChartData { xs, series }render_chart_htmlwindow.AURA_TRACES = { mode, xs, series }.

New, client-side only: mountbuildCharts(traces, mode, xMode):

  • xData = xMode==="ordinal" ? [0..n-1] : xs becomes data[0].
  • series[*].points become data[1..] verbatim (null gaps preserved — unchanged).
  • the x-axis tick values and the x-series legend value map a position back to xs[round(pos)] (ordinal) or show it directly (continuous).
  • panels carry a shared cursor.sync.key; the plugin's hooks drive zoom/pan/reset.

Error handling

No new error surface. buildCharts with an unrecognized xMode falls back to ordinal (xMode !== "continuous" ⇒ ordinal) — total, never throws. An empty trace (xs.length === 0) yields an empty index spine and empty data arrays, exactly as today. The Rust serve path's existing errors (missing run → stderr + exit 2) are unchanged.

Testing strategy

Headless node guards (the established pattern: stub global.window, require the real module, drive the pure buildCharts; uPlot/DOM never instantiated). RED-first is not applicable end-to-end for browser interaction, but the data-shape contract is fully node-guardable and is where the load-bearing behaviour lives:

  1. New chart_viewer_xmode.mjs (+ .rs shell): over sample-traces.json
    • buildCharts(traces, "overlay") (no xMode) ⇒ data[0] is the index spine [0,1,…,n-1] (default is ordinal), and opts.axes[0].values is a function (the index→label mapper);
    • buildCharts(traces, "overlay", "continuous")data[0] is traces.xs verbatim;
    • buildCharts(traces, "panels") ⇒ every panel's opts.cursor.sync.key is defined and equal across panels (crosshair sync);
    • every config carries a wheelZoomPlugin entry in opts.plugins (interaction wired).
  2. Update chart_viewer_build.mjs: its data[0] === xs assertions move to the explicit "continuous" call; under the default it asserts the index spine. The one-chart-overlay / N-panel structure assertions stay.
  3. Update chart_viewer_gaps.mjs: the null-gap-verbatim assertions are on data[1..] and stay unchanged in spirit; only the data[0] === xs line is re-pointed (ordinal default ⇒ data[0] is indices; the gap property is independent of the x spine). The "no null coerced to 0, no point dropped" guarantee must remain green.
  4. Rust side: existing render_chart_html tests stay green (the injected window.AURA_TRACES shape is unchanged); add an assertion that the chart page carries the xmode-toggle control and that aura graph's page does not (no chrome leak).

cargo test --workspace green + cargo clippy --workspace --all-targets -- -D warnings exit 0 + a real e2e (aura run --trace demo && aura chart demo → a self-contained page whose injected viewer defaults to ordinal and carries the toggle) gate the cut.

Acceptance criteria

  1. The default served page opens with an ordinal (gap-collapsed) x-axis; the data array's x spine is [0..n-1], not the raw timestamps.
  2. A visible header toggle flips to continuous (x = real xs) and back; both modes render the same series with null gaps intact.
  3. In ordinal mode the crosshair/legend reports the real timestamp (xs[i]) for the hovered slot, not the ordinal index.
  4. wheel zooms the x-axis around the cursor; drag pans; double-click restores the full range.
  5. In --panels the crosshair is synchronized across panels (shared cursor.sync).
  6. buildCharts stays pure and headless-guardable; the new + updated node guards pass and the null-gap-verbatim guard stays green.
  7. The page remains fully self-contained (no remote <script src>; uPlot + data inlined), and the chart page wears its own chrome (no aura graph leak).