audit(viewer-tier1): cycle close — drift-clean, retire spec/plan 0057
Architect drift review over 5bff7e0..HEAD: clean, no drift, no debt. C14/C7
firewall intact (main.rs byte-unchanged → build_chart_data still returns
ChartData{xs,series}, the injected window.AURA_TRACES payload shape untouched;
the ordinal index spine is derived client-side). C22 holds — the
ordinal/continuous toggle + wheel-zoom/drag-pan/dblclick-reset are view
affordances over a recorded trace (read-only setScale on the x range), not a
scene editor. The #102 manifest-header gap is neither closed nor regressed,
still tracked.
Regression gate: cargo test --workspace green (incl. the four chart guards +
render inline tests), clippy --all-targets -D warnings clean; aura has no
separate regression script, so the suite + architect are the gates. cycle
clean.
Spec/plan 0057 retired per the ephemeral-artifact lifecycle (durable record:
the feature commit 45cc1c4, the #103 decision-log comments, and git history).
refs #103
This commit is contained in:
@@ -1,528 +0,0 @@
|
||||
# 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`:
|
||||
|
||||
```js
|
||||
// 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`:
|
||||
|
||||
```rust
|
||||
//! 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: `buildCharts` — `xMode` + 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:
|
||||
|
||||
```js
|
||||
// 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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```js
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
/// 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:
|
||||
|
||||
```rust
|
||||
/// 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):
|
||||
|
||||
```rust
|
||||
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>`):
|
||||
|
||||
```rust
|
||||
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()`:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
// 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.)
|
||||
@@ -1,363 +0,0 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user