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
This commit is contained in:
2026-06-19 18:16:03 +02:00
parent 5bff7e0fb9
commit be71f0a55c
+363
View File
@@ -0,0 +1,363 @@
# 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.rs``CHART_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:
```console
$ 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`):
```js
// 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):
```js
// 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.
```js
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"`.
```js
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.
```rust
// 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 → `ColumnarTrace``TraceStore`
`build_chart_data` (union-spine `join_on_ts`) → `ChartData { xs, series }`
`render_chart_html``window.AURA_TRACES = { mode, xs, series }`.
New, client-side only: `mount``buildCharts(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).