Skip to content

Commit 7106686

Browse files
connortsui20claude
andauthored
[claude] Rebuild chart UI with per-card toolbars and lazy-fetch on toggle (#7685)
## Summary This PR rebuilds the benchmarks website chart UI from a page-level toolbar model to a per-card toolbar model with lazy-fetch-on-toggle for closed groups on the landing page. ### Key Changes **Landing Page (`/`):** - Groups are now collapsible `<details>` elements instead of always-visible sections - The first group opens by default with all chart payloads pre-inlined for fast first paint - Subsequent groups render only chart-card shells; payloads are fetched client-side on first `details.toggle` - Removed page-level toolbar; each chart now has its own independent toolbar **Per-Chart UX:** - Each `.chart-card` carries a `.toolbar.toolbar--card` with scope buttons, slider, Y-axis toggle, and mode toggle - Charts fetch up to 1000 commits once; the toolbar's "Show" buttons and slider manipulate `chart.options.scales.x.min/max` to reveal windows of that fetched slice (no refetch on scope change) - Slider is throttled to ~16ms (60fps) for smooth dragging - Mouse wheel pans horizontally; drag-pan and drag-rectangle-zoom are wired through the new `chartjs-plugin-zoom` plugin - Custom inline crosshair plugin draws a vertical line at the hovered commit - External tooltip is offset 12px from cursor and flips horizontally on overflow; `pointer-events: none` fixes the flicker described in the rebuild brief **Server-Side (`html.rs`):** - Removed URL state management (`?y=`, `?mode=`, `?hidden=`) from the HTML layer; these are now client-side only - `?n=` is accepted as a power-user override on initial fetch but not written back from the toolbar - Added `collect_landing_groups()` to pre-fetch payloads for the open-by-default first group only - Implemented canonical group ordering via `GROUP_ORDER` constant (ported from v2) - Per-chart toolbar state lives on the canvas element (`canvas.__bench_state`) **Client-Side (`chart-init.js`):** - Removed URL state parsing and rewriting logic - Added `throttle()` utility for slider drag throttling - Added `crosshairPlugin` for vertical line at hover - Refactored tooltip handler to work with per-card state - Added lazy-fetch handler for closed `<details>` groups (triggered on `details.toggle`) - Chart construction now reads state from canvas element instead of URL **Styling (`style.css`):** - Redesigned landing page with collapsible group cards and per-chart toolbars - Reduced toolbar size and spacing for compact per-card layout - Added loading/error indicators for lazy-fetch states - Updated chart-card grid and spacing **Dependencies:** - Added `chartjs-plugin-zoom` v2.2.0 for drag-pan and drag-rectangle-zoom support ### Testing - Updated existing snapshot tests for landing page, chart page, and group page - Added new tests: - `details_first_group_open_others_closed()` — verifies first group opens by default - `chart_card_carries_per_chart_toolbar()` — verifies every chart has its own toolbar - `landing_groups_render_in_v2_order()` — verifies canonical group ordering - All existing tests pass with updated snapshots https://claude.ai/code/session_01NhtGnaLstPEAh7cRJ4qDFt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5083e80 commit 7106686

10 files changed

Lines changed: 1110 additions & 623 deletions

File tree

benchmarks-website/server/src/api.rs

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,39 @@ pub const DEFAULT_COMMIT_WINDOW: u32 = 100;
3434
/// Hard server-side ceiling on `?n=NNN`.
3535
pub const MAX_COMMIT_WINDOW: u32 = 1000;
3636

37+
/// Canonical group ordering, ported from the v2 site's hard-coded list at
38+
/// `origin/ct/vfvb:benchmarks-website/index.html`. Group names not in this
39+
/// list sort after every listed name in alphabetical order. The order is
40+
/// significant for the landing page render — the first group is opened by
41+
/// default and the rest are collapsed.
42+
pub const GROUP_ORDER: &[&str] = &[
43+
"Random Access",
44+
"Compression",
45+
"Compression Size",
46+
"Clickbench",
47+
"TPC-H (NVMe) (SF=1)",
48+
"TPC-H (S3) (SF=1)",
49+
"TPC-H (NVMe) (SF=10)",
50+
"TPC-H (S3) (SF=10)",
51+
"TPC-H (NVMe) (SF=100)",
52+
"TPC-H (S3) (SF=100)",
53+
"TPC-H (NVMe) (SF=1000)",
54+
"TPC-H (S3) (SF=1000)",
55+
"TPC-DS (NVMe) (SF=1)",
56+
"TPC-DS (NVMe) (SF=10)",
57+
];
58+
59+
/// Sort key for a group name against [`GROUP_ORDER`]. Names in the list sort
60+
/// by position (0..GROUP_ORDER.len()); names not in the list sort after, by
61+
/// the same primary index plus an alphabetical tiebreaker.
62+
pub fn group_sort_key(name: &str) -> (usize, &str) {
63+
let pos = GROUP_ORDER
64+
.iter()
65+
.position(|&n| n == name)
66+
.unwrap_or(GROUP_ORDER.len());
67+
(pos, name)
68+
}
69+
3770
/// Server-side cap on how many of the most recent commits a chart includes.
3871
///
3972
/// `Last(n)` keeps the most recent `n` commits by `commits.timestamp`; `All`
@@ -107,10 +140,19 @@ impl CommitWindow {
107140
}
108141

109142
/// Query string for `/api/chart/{slug}` and `/chart/{slug}`.
143+
///
144+
/// `y` (linear|log) and `mode` (abs|rel) are accepted but ignored by the SQL —
145+
/// the JSON response is identical regardless. They exist on the API surface so
146+
/// the client can drive deep links and refetches with a single URL shape; the
147+
/// rendering hints are applied client-side in `chart-init.js`.
110148
#[derive(Debug, Default, Deserialize)]
111149
pub struct ChartQuery {
112150
/// Commit window: `25`, `50`, `100`, `250`, `all`, etc.
113151
pub n: Option<String>,
152+
/// Y-axis hint (linear|log). Echoed for client-side rendering only.
153+
pub y: Option<String>,
154+
/// Display mode hint (abs|rel). Echoed for client-side rendering only.
155+
pub mode: Option<String>,
114156
}
115157

116158
impl ChartQuery {
@@ -208,7 +250,7 @@ pub async fn chart(
208250
.map_err(|e| ApiError::BadRequest(format!("invalid slug: {e}")))?;
209251
let window = q.window();
210252
let response =
211-
db::run_blocking(&state.db, move |conn| collect_chart(conn, &key, &window)).await?;
253+
db::run_blocking(&state.db, move |conn| chart_payload(conn, &key, &window)).await?;
212254
let response =
213255
response.ok_or_else(|| ApiError::NotFound(format!("no data for slug {slug:?}")))?;
214256
Ok(Json(response))
@@ -289,6 +331,11 @@ pub(crate) fn collect_groups(conn: &Connection) -> Result<Vec<Group>> {
289331
let vsr_groups = collect_vector_search_groups(conn)?;
290332
groups.extend(vsr_groups);
291333

334+
// Apply canonical ordering. `sort_by_key` is stable, so groups whose
335+
// names map to the same key (the `GROUP_ORDER.len()` bucket — i.e. not in
336+
// the canonical list) keep the order the discovery passes produced.
337+
groups.sort_by(|a, b| group_sort_key(&a.name).cmp(&group_sort_key(&b.name)));
338+
292339
Ok(groups)
293340
}
294341

@@ -358,12 +405,44 @@ fn collect_query_groups(conn: &Connection) -> Result<Vec<Group>> {
358405
Ok(groups)
359406
}
360407

408+
/// Render a query group name in the same shape v2 used (per the hard-coded
409+
/// list in `origin/ct/vfvb:benchmarks-website/index.html`):
410+
///
411+
/// - `tpch` + storage + scale_factor → `TPC-H (NVMe) (SF=1)`
412+
/// - `tpcds` + storage + scale_factor → `TPC-DS (NVMe) (SF=1)`
413+
/// - `clickbench` → `Clickbench`
414+
/// - anything else → fall back to the legacy `dataset[/variant] sf=N [storage]`
415+
/// shape so unknown datasets still get a deterministic name.
416+
///
417+
/// Variant disambiguation: for tpch/tpcds, if `dataset_variant` is set we
418+
/// append ` / variant`, since v2's list flattened variants but v3 ingests
419+
/// them. Without this, two ingestion variants would collide.
361420
fn group_name_query(
362421
dataset: &str,
363422
dataset_variant: &Option<String>,
364423
scale_factor: &Option<String>,
365424
storage: &str,
366425
) -> String {
426+
let storage_label = match storage {
427+
"nvme" => Some("NVMe"),
428+
"s3" => Some("S3"),
429+
_ => None,
430+
};
431+
let base = match (dataset, storage_label, scale_factor.as_deref()) {
432+
("tpch", Some(s), Some(sf)) => Some(format!("TPC-H ({s}) (SF={sf})")),
433+
("tpcds", Some(s), Some(sf)) => Some(format!("TPC-DS ({s}) (SF={sf})")),
434+
("clickbench", ..) => Some("Clickbench".to_string()),
435+
_ => None,
436+
};
437+
if let Some(mut name) = base {
438+
if let Some(v) = dataset_variant {
439+
name.push_str(" / ");
440+
name.push_str(v);
441+
}
442+
return name;
443+
}
444+
// Legacy fallback for unknown datasets — keeps the page rendering rather
445+
// than silently dropping data.
367446
let mut name = dataset.to_string();
368447
if let Some(v) = dataset_variant {
369448
name.push('/');
@@ -548,9 +627,14 @@ fn collect_vector_search_groups(conn: &Connection) -> Result<Vec<Group>> {
548627
Ok(groups)
549628
}
550629

551-
/// Collect the data for one chart by key. Used by both `GET /api/chart/:slug`
552-
/// and the HTML chart page. `window` caps the number of recent commits.
553-
pub(crate) fn collect_chart(
630+
/// Build the JSON payload for one chart by key. This is the shared
631+
/// implementation behind `GET /api/chart/{slug}`, the inline `<script>` JSON
632+
/// rendered into the HTML pages, and `collect_group_charts`.
633+
///
634+
/// `window` caps the number of recent commits returned. `y` / `mode` are not
635+
/// inputs here — they're rendering hints applied client-side, so the SQL is
636+
/// unaffected and the cached payload is identical across hint values.
637+
pub(crate) fn chart_payload(
554638
conn: &Connection,
555639
key: &ChartKey,
556640
window: &CommitWindow,
@@ -588,8 +672,22 @@ pub(crate) fn collect_chart(
588672
}
589673
}
590674

675+
/// Thin wrapper around [`chart_payload`] kept for callers that prefer the old
676+
/// name. New code should prefer [`chart_payload`].
677+
pub(crate) fn collect_chart(
678+
conn: &Connection,
679+
key: &ChartKey,
680+
window: &CommitWindow,
681+
) -> Result<Option<ChartResponse>> {
682+
chart_payload(conn, key, window)
683+
}
684+
591685
/// Collect every chart inside one group. Returns `None` if the group has no
592686
/// data at all (callers should render a 404).
687+
// TODO: this currently re-runs the entire `collect_groups` discovery pass
688+
// (api.rs) per call before fetching each chart, which makes the landing page
689+
// O(groups * charts_per_group) DB queries plus the discovery scan. Fine for
690+
// the current dataset; revisit when chart counts grow.
593691
pub(crate) fn collect_group_charts(
594692
conn: &Connection,
595693
key: &GroupKey,
@@ -604,7 +702,7 @@ pub(crate) fn collect_group_charts(
604702
for link in group.charts {
605703
let chart_key = ChartKey::from_slug(&link.slug)
606704
.with_context(|| format!("invalid chart slug in group: {}", link.slug))?;
607-
let Some(chart) = collect_chart(conn, &chart_key, window)? else {
705+
let Some(chart) = chart_payload(conn, &chart_key, window)? else {
608706
continue;
609707
};
610708
charts.push(NamedChartResponse {

0 commit comments

Comments
 (0)