//! Self-contained HTML graph viewer assembly (C9: read-only render path). //! //! `render_html` walks the blueprint into the deterministic JSON model //! (`aura_engine::model_to_json`) and inlines it, the vendored Graphviz-WASM, the //! pan-zoom helper, and the ported viewer JS into one standalone `.html`. The //! emitted page fetches nothing at view time. No `eval`/build is on this path //! (C9); the model is hand-rolled JSON (C14, owned by `model_to_json`). use aura_engine::Composite; /// The shared `` (charset, viewport, ` "#; /// The graph page's `` text and `<header>` line (hover/expand/drill hint), /// graph-specific — split out of the shared shell. const GRAPH_HEAD: &str = r#"<header> <b>aura graph</b> <span id="crumb"></span> <span class="sub">hover · [+] expand · body drill</span> </header> <div id="stage"></div> <div id="tip"></div> <pre id="err"></pre> "#; /// 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> <span id="ctx" class="sub"></span> <button id="xmode-toggle" type="button">x: collapsed (gaps off)</button> </header> <div id="stage"></div> <pre id="err"></pre> "#; const VIZ_JS: &str = include_str!("../assets/viz-standalone.js"); const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js"); const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js"); const UPLOT_JS: &str = include_str!("../assets/uPlot.iife.min.js"); const UPLOT_CSS: &str = include_str!("../assets/uPlot.min.css"); const CHART_VIEWER_JS: &str = include_str!("../assets/chart-viewer.js"); /// 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; } "#; /// Assemble the self-contained interactive graph viewer page for `root`. /// /// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`, /// `svgPanZoom`) and the model (`window.AURA_MODEL`) must be defined before the /// viewer script runs. pub fn render_html(root: &Composite) -> String { let model = aura_engine::model_to_json(root); let mut html = String::with_capacity( SHELL_HEAD.len() + GRAPH_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256, ); html.push_str(&SHELL_HEAD.replace("{title}", "aura graph")); html.push_str(GRAPH_HEAD); html.push_str("<script>"); html.push_str(VIZ_JS); html.push_str("</script>\n<script>"); html.push_str(PANZOOM_JS); html.push_str("</script>\n<script>window.AURA_MODEL = "); html.push_str(&model); html.push_str(";</script>\n<script>"); html.push_str(VIEWER_JS); html.push_str("</script>\n</body>\n</html>\n"); html } /// Which way the chart draws the (timestamp-aligned) series. #[derive(Clone, Copy)] pub enum ChartMode { /// One plot, all series superimposed, each on its own y-scale. Overlay, /// One stacked plot per series, all sharing the timestamp x-axis. Panels, } /// How `decimate` reduces a series within each bucket (#111). An *envelope* series /// (a cumulative equity curve) keeps its min+max so drawdown spikes survive; a /// *level* series (the bounded exposure, f64 ∈ [-1,+1]) takes the per-bucket mean, /// so a high-flip signal shows its net/duty-cycle level instead of collapsing to a /// -1..+1 band (every bucket of a multi-year exposure straddles many flips, so /// min/max would be ±1 everywhere). Server-side only — never serialized into the /// page (the viewer reads decimated points, not how they were reduced). Rendering /// min/max as range bars / OHLC is a separate follow-up; this only fixes the /// reduction. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum ReduceKind { /// Per-bucket min and max — the envelope (equity, unbounded cumulative). #[default] MinMax, /// Per-bucket mean — the net level (exposure, bounded ∈ [-1,+1]). Mean, } /// One chart series: a name, its own y-scale id (so disparate magnitudes stay /// legible), one `Option<f64>` per shared-x slot (`null` = a gap where the tap did /// not fire at that timestamp), and how `decimate` reduces it per bucket. #[derive(serde::Serialize)] pub struct Series { pub name: String, pub y_scale_id: String, pub points: Vec<Option<f64>>, /// Per-bucket reduction for `decimate` (#111); server-side only, not part of the /// served payload. #[serde(skip)] pub reduce: ReduceKind, } /// Run-context for the page header (#102): serialized into `window.AURA_TRACES.meta` /// and rendered client-side by `chart-viewer.js`'s pure `buildHeader`. A single run /// carries its manifest fields + bound params; a family carries its shared identity /// (commit/broker — one frozen artifact, one broker profile) plus the SPANNING /// window across its members (see the `window` field) + member count — per-member /// identity is the series label (`member.key`), not repeated here. #[derive(Default, serde::Serialize)] pub struct ChartMeta { /// "run" | "family". pub kind: String, /// The chart name (run name or family id). pub name: String, /// Git commit of the run's frozen artifact (C18); `buildHeader` shows the short form. pub commit: String, /// Inclusive data window `(from, to)` epoch-ns. For a single run, the run's /// manifest window. For a family, the SPAN across all members — /// `(min member.from, max member.to)` — NOT the first member's window: a /// walk-forward family's members are disjoint OOS windows, so only the span /// labels the family's true coverage (and for sweep/MC, whose members share a /// window, the span collapses to it). pub window: (i64, i64), /// Broker profile label, e.g. "sim-optimal(pip_size=1)". pub broker: String, /// RNG seed (0 for a seed-free synthetic run). pub seed: u64, /// The charted taps (single run: all charted taps or the `--tap` pick; family: /// the one compared tap). pub taps: Vec<String>, /// Family member count; `None` for a single run. pub members: Option<usize>, /// Bound params (single run only; empty for a family, where params are the /// per-member series labels). Stringified `name -> display` for the header. pub params: Vec<(String, String)>, } /// The serve-ready chart payload: the shared timestamp axis, the per-(tap,column) /// series, and the run-context `meta`. These are exactly the fields `chart-viewer.js` /// reads; injected verbatim as `window.AURA_TRACES` (plus the `mode`). `meta` carries /// the manifest context for the header (#102); the full manifest stays on disk. pub struct ChartData { pub xs: Vec<i64>, pub series: Vec<Series>, pub meta: ChartMeta, } /// Assemble the self-contained uPlot chart page for one run's aligned traces. /// /// Order is load-bearing (mirrors `render_html`): the uPlot global and CSS, then the /// injected `window.AURA_TRACES`, must be defined before `chart-viewer.js` runs. pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String { let mode_str = match mode { ChartMode::Overlay => "overlay", ChartMode::Panels => "panels", }; let traces = serde_json::json!({ "mode": mode_str, "xs": data.xs, "series": data.series, "meta": data.meta, }) .to_string(); 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, ); html.push_str(&SHELL_HEAD.replace("{title}", "aura chart")); html.push_str(CHART_HEAD); html.push_str("<style>"); html.push_str(UPLOT_CSS); html.push_str(CHART_CSS); html.push_str("</style>\n<script>"); html.push_str(UPLOT_JS); html.push_str("</script>\n<script>window.AURA_TRACES = "); html.push_str(&traces); html.push_str(";</script>\n<script>"); html.push_str(CHART_VIEWER_JS); html.push_str("</script>\n</body>\n</html>\n"); html } #[cfg(test)] mod tests { use super::*; use crate::sample_blueprint; /// `aura graph`'s page is self-contained (no remote fetch), embeds the /// deterministic model as the viewer's data source, carries the Graphviz-WASM /// bootstrap, and keeps the C4 four-colour palette. Structural, not pixel: /// the DOT/SVG are Graphviz-version-dependent and are exercised by hand /// against the prototype. #[test] fn render_html_is_self_contained_and_embeds_the_model() { let html = render_html(&sample_blueprint()); // a complete HTML document assert!(html.starts_with("<!doctype html>"), "not an HTML doc"); assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed"); // the graph page wears its own label (not the chart's) assert!(html.contains("<title>aura graph"), "graph page title missing"); assert!(html.contains("aura graph"), "graph header label missing"); // self-contained: every asset is inlined, no remote