44//! HTML routes for the bench.vortex.dev v3 web UI.
55//!
66//! Three pages, all backed by the same per-chart UX:
7- //! - `GET /` — landing page. Every group is a collapsible `<details>`. The
8- //! first group is open by default and its charts pre-inline their JSON
9- //! payload for a fast first paint; closed groups carry only the chart-card
10- //! shell and their payloads are fetched on first toggle (`details.open`).
7+ //! - `GET /` — landing page. Every group is a collapsible `<details>`,
8+ //! all collapsed by default; the user picks which to expand. The
9+ //! *first* group's chart payloads are still pre-inlined in the HTML
10+ //! so opening it skips the JS fetch round-trip; every other group
11+ //! ships only chart-card shells and is fetched on first toggle.
1112//! - `GET /chart/{slug}` — single chart page; permalink for sharing.
1213//! - `GET /group/{slug}` — every chart in one group on a single page.
1314//!
1415//! Each chart card owns its own compact toolbar (scope slider + Y-axis). There
1516//! is no page-level toolbar — every chart is independent. Scope is
16- //! **zoom-as-scope**: each chart fetches up to [`api::MAX_COMMIT_WINDOW`]
17- //! commits once, then the toolbar manipulates `chart.options.scales.x.min`/
18- //! `max` to set the visible window. No refetches on scope change.
17+ //! **zoom-as-scope**: each chart fetches a generous window once, then the
18+ //! toolbar manipulates `chart.options.scales.x.min`/`max` to set the visible
19+ //! window. No refetches on scope change.
1920//!
20- //! URL query params (`?n=`) are accepted as power-user overrides on the
21- //! initial fetch but are not written back from the toolbar. Per-chart UI
21+ //! Every HTML route defaults to the unbounded commit window
22+ //! ([`CommitWindow::All`]) so users can pan/zoom all the way back to the
23+ //! very first commit. The chart payload is sent **raw** — any visual
24+ //! downsampling happens client-side in `chart-init.js`, applied only to
25+ //! the currently visible commit range. The common case (a chart zoomed in
26+ //! to the last ~100 commits) renders raw with no LTTB at all.
27+ //!
28+ //! URL query param `?n=` is accepted as a power-user override on the
29+ //! initial fetch but is not written back from the toolbar. Per-chart UI
2230//! state is intentionally not persisted in the URL — the user feedback
2331//! emphasised that this UX should feel local-and-immediate, not "share a
24- //! perfect view via URL". Permalinks (`/chart/{slug}`, `/group/{slug}`) are
25- //! the sharing mechanism, not query strings.
32+ //! perfect view via URL". Permalinks (`/chart/{slug}`, `/group/{slug}`)
33+ //! are the sharing mechanism, not query strings.
2634//!
2735//! Slugs are opaque strings the server received from `/api/groups`; the
2836//! handler echoes them straight into [`crate::slug::ChartKey::from_slug`]
3240//! script) are served from `/static/...` via [`include_bytes!`] so the
3341//! binary is fully self-contained.
3442
35- use std:: num:: NonZeroU32 ;
36-
3743use anyhow:: Result ;
3844use axum:: Router ;
3945use axum:: extract:: Path ;
@@ -62,18 +68,19 @@ use crate::db;
6268use crate :: slug:: ChartKey ;
6369use crate :: slug:: GroupKey ;
6470
65- /// How many commits each chart pre-fetches. The toolbar's scope slider zooms
66- /// into smaller windows of this slice; we never refetch on scope change.
67- /// Capped at the API ceiling so a future bigger ceiling is picked up here too.
68- const PER_CHART_FETCH_N : u32 = api:: MAX_COMMIT_WINDOW ;
71+ // All HTML routes default to the unbounded commit window. The wire payload
72+ // is the raw `(commits, series)` data; visual downsampling (LTTB on the
73+ // currently visible commit range) happens client-side in
74+ // `static/chart-init.js`. `?n=` remains a power-user override on the
75+ // commit window itself (not on the rendered point count).
6976
7077const CHART_JS : & [ u8 ] = include_bytes ! ( "../static/chart.umd.js" ) ;
7178const CHART_ZOOM_JS : & [ u8 ] = include_bytes ! ( "../static/chartjs-plugin-zoom.umd.min.js" ) ;
7279const CHART_INIT_JS : & [ u8 ] = include_bytes ! ( "../static/chart-init.js" ) ;
7380const STYLE_CSS : & [ u8 ] = include_bytes ! ( "../static/style.css" ) ;
7481const VORTEX_BLACK_SVG : & [ u8 ] = include_bytes ! ( "../../public/vortex_black_nobg.svg" ) ;
7582const VORTEX_WHITE_SVG : & [ u8 ] = include_bytes ! ( "../../public/vortex_white_nobg.svg" ) ;
76- const STATIC_ASSET_VERSION : & str = "bench-v3-ui-10 " ;
83+ const STATIC_ASSET_VERSION : & str = "bench-v3-ui-15 " ;
7784
7885/// HTML routes mounted under `/`.
7986pub fn router ( ) -> Router < AppState > {
@@ -92,15 +99,14 @@ pub fn router() -> Router<AppState> {
9299 . route ( "/vortex_white_nobg.svg" , get ( serve_vortex_white_svg) )
93100}
94101
95- /// Query string for HTML routes. `?n=` overrides the per-chart fetch size ;
102+ /// Query string for HTML routes. `?n=` overrides the commit window ;
96103/// `?engine=` and `?format=` carry the global filter bar's selection so a
97104/// shared link or refresh preserves which engines/formats are visible. The
98105/// per-chart toolbar (Y axis, scope slider) remains local-only — its state
99106/// is intentionally not in the URL.
100107#[ derive( Debug , Default , Deserialize ) ]
101108pub struct UiQuery {
102- /// Override for the per-chart fetch size. Defaults to `PER_CHART_FETCH_N`.
103- /// Accepts `25|50|100|250|all`.
109+ /// Override for the per-chart fetch size. Accepts `25|50|100|250|all`.
104110 pub n : Option < String > ,
105111 /// Comma-separated list of engines to keep visible across every chart.
106112 /// Empty / unset means no engine filter is active. Unknown engines are
@@ -113,14 +119,15 @@ pub struct UiQuery {
113119}
114120
115121impl UiQuery {
116- /// Resolve the [`CommitWindow`] for the initial fetch. When `?n=` is
117- /// unset, falls back to [`PER_CHART_FETCH_N`].
122+ /// Resolve the [`CommitWindow`] for HTML routes. Defaults to
123+ /// [`CommitWindow::All`] so users can pan/zoom all the way back to
124+ /// the very first commit on every chart, including the first
125+ /// (open-by-default) group on the landing page. Visual downsampling
126+ /// happens client-side on the visible commit range only.
118127 fn fetch_window ( & self ) -> CommitWindow {
119128 match self . n . as_deref ( ) {
120129 Some ( _) => CommitWindow :: parse ( self . n . as_deref ( ) ) ,
121- None => {
122- CommitWindow :: Last ( NonZeroU32 :: new ( PER_CHART_FETCH_N ) . expect ( "non-zero default" ) )
123- }
130+ None => CommitWindow :: All ,
124131 }
125132 }
126133
@@ -190,8 +197,9 @@ async fn landing(State(state): State<AppState>, Query(ui): Query<UiQuery>) -> Re
190197
191198/// One group's worth of data for the landing page.
192199///
193- /// The first group (in canonical order) ships with `charts` populated so the
194- /// open-by-default `<details>` paints immediately. Subsequent groups ship
200+ /// The first group (in canonical order) ships with `charts` populated so
201+ /// the moment the user expands it the chart hydrates from the inline
202+ /// JSON without a network round-trip. Every other group ships
195203/// with `charts` empty and only their chart-card shells — payloads are
196204/// fetched client-side on first `details.toggle` to keep the cold landing
197205/// HTML small.
@@ -220,8 +228,9 @@ fn collect_landing_groups(conn: &Connection, window: &CommitWindow) -> Result<Ve
220228 let mut out = Vec :: with_capacity ( groups. len ( ) ) ;
221229 for ( i, group) in groups. into_iter ( ) . enumerate ( ) {
222230 let inlined = if i == 0 {
223- // First (open-by-default) group: pre-fetch every chart so the
224- // first paint is fast and there is no JS round-trip.
231+ // First group in canonical order: pre-fetch every chart so
232+ // the moment the user expands it the chart hydrates from
233+ // the inline JSON without a JS round-trip.
225234 let mut v = Vec :: with_capacity ( group. charts . len ( ) ) ;
226235 for link in & group. charts {
227236 let key = ChartKey :: from_slug ( & link. slug ) ?;
@@ -512,9 +521,9 @@ fn landing_body(groups: &[LandingGroup]) -> Markup {
512521 // `<script id="chart-data-N">` agree across groups.
513522 let mut idx_iter = 0usize ..total_charts;
514523 html ! {
515- @for ( group_idx , group) in groups. iter( ) . enumerate ( ) {
524+ @for group in groups. iter( ) {
516525 section. group-details data-group-name=( group. name) {
517- details. group-disclosure open [ group_idx == 0 ] {
526+ details. group-disclosure {
518527 summary. group-summary {
519528 span. group-summary-row {
520529 span. group-name { ( group. name) }
@@ -549,6 +558,7 @@ fn chart_card(link: &api::ChartLink, idx: usize, inlined: Option<&NamedChartResp
549558 section. chart-card data-chart-index=( idx) data-chart-slug=( link. slug) {
550559 h3. chart-card-title {
551560 a href=( permalink) { ( link. name) }
561+ ( downsample_badge_slot( ) )
552562 }
553563 ( per_chart_toolbar( idx) )
554564 div. chart-tooltip-host { }
@@ -568,6 +578,18 @@ fn chart_card(link: &api::ChartLink, idx: usize, inlined: Option<&NamedChartResp
568578 }
569579}
570580
581+ /// Empty hidden slot for the LTTB badge. `chart-init.js` flips it on when
582+ /// the *currently visible* commit range exceeds the LTTB threshold and the
583+ /// rendered point count is therefore less than the raw point count in that
584+ /// range.
585+ fn downsample_badge_slot ( ) -> Markup {
586+ html ! {
587+ span. chart-badge. chart-badge--downsampled
588+ data-role="downsample-badge"
589+ hidden { }
590+ }
591+ }
592+
571593fn chart_body ( chart : & ChartResponse , slug : & str , payload_json : & str ) -> Markup {
572594 let series_count = chart. series . len ( ) ;
573595 let commit_count = chart. commits . len ( ) ;
@@ -577,6 +599,7 @@ fn chart_body(chart: &ChartResponse, slug: &str, payload_json: &str) -> Markup {
577599 " · "
578600 ( series_count) " series · "
579601 ( commit_count) " commit" @if commit_count != 1 { "s" }
602+ ( downsample_badge_slot( ) )
580603 }
581604 section. chart-card data-chart-index="0" data-chart-slug=( slug) {
582605 ( per_chart_toolbar( 0 ) )
@@ -609,6 +632,7 @@ fn group_body(group: &GroupChartsResponse) -> Markup {
609632 section. chart-card data-chart-index=( i) data-chart-slug=( item. slug) {
610633 h3. chart-card-title {
611634 a href=( permalink) { ( item. name) }
635+ ( downsample_badge_slot( ) )
612636 }
613637 ( per_chart_toolbar( i) )
614638 div. chart-tooltip-host { }
@@ -859,8 +883,11 @@ fn per_chart_toolbar(idx: usize) -> Markup {
859883 div. toolbar. toolbar--card aria-label="Chart controls" {
860884 div. toolbar-group role="group" aria-label="Visible commits" {
861885 span. toolbar-label { "Show" }
886+ // `max` and `step` are placeholders — `chart-init.js` resets
887+ // them after constructing the chart so the slider tracks the
888+ // actual loaded commit count, not the initial markup.
862889 input id=( slider_id) . toolbar-slider type ="range"
863- min="5" max="1000 " step="5 " value="100"
890+ min="5" max="100 " step="1 " value="100"
864891 data-role="scope-slider"
865892 aria-label="Custom commit window" ;
866893 }
@@ -991,12 +1018,9 @@ mod tests {
9911018 }
9921019
9931020 #[ test]
994- fn fetch_window_default_is_max ( ) {
1021+ fn fetch_window_default_is_all ( ) {
9951022 let ui = UiQuery :: default ( ) ;
996- match ui. fetch_window ( ) {
997- CommitWindow :: Last ( n) => assert_eq ! ( n. get( ) , PER_CHART_FETCH_N ) ,
998- CommitWindow :: All => panic ! ( "default should be Last(N)" ) ,
999- }
1023+ assert ! ( matches!( ui. fetch_window( ) , CommitWindow :: All ) ) ;
10001024 }
10011025
10021026 #[ test]
@@ -1009,6 +1033,11 @@ mod tests {
10091033 CommitWindow :: Last ( n) => assert_eq ! ( n. get( ) , 25 ) ,
10101034 CommitWindow :: All => panic ! ( ) ,
10111035 }
1036+ let ui = UiQuery {
1037+ n : Some ( "all" . into ( ) ) ,
1038+ ..Default :: default ( )
1039+ } ;
1040+ assert ! ( matches!( ui. fetch_window( ) , CommitWindow :: All ) ) ;
10121041 }
10131042
10141043 #[ test]
0 commit comments