From d959adbaad9ebbe3b4e349deeab64dd3a83d2502 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 18:05:32 +0200 Subject: [PATCH] distribution: redesign as percentile whisker with landmark axis --- src/components/benchmark-body.tsx | 1 + src/components/distribution-chart.tsx | 392 +++++++++++++++++--------- src/components/time-series-chart.tsx | 51 +--- src/hooks/use-animated-domain.ts | 55 ++++ 4 files changed, 309 insertions(+), 190 deletions(-) create mode 100644 src/hooks/use-animated-domain.ts diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 7bc637cf..ca238fe9 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -322,6 +322,7 @@ export function BenchmarkBody({ benchmark={benchmark} excluded={excluded} onToggleExclude={toggleExclude} + onResetExcluded={resetExcluded} headerActions={} /> )} diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 8747f45a..0cb5d42c 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -8,31 +8,52 @@ import { ProviderLogo } from "@/components/provider-logo"; import { fmtUnit } from "@/lib/format"; import { buildProviderColors } from "@/lib/series-colors"; import { useChartExclusion } from "@/hooks/use-chart-exclusion"; +import { useAnimatedDomain } from "@/hooks/use-animated-domain"; /** - * Latency-spread view. One row per provider, three markers (p50 / p90 / - * p99) plotted on a shared field-wide scale. + * Latency-spread view, "percentile whisker" design. * - * Design borrowed from Vercel Speed Insights / Honeycomb service list: - * - Sans-serif provider name (serif read amateur on data dense rows). - * - 4-column row: name | track | p50 | p99 — two values right-aligned, - * not one, so the spread story is in the table itself, not just the - * visual. - * - Thin 2 px hairline track that spans the row; per-row coloured - * band between p50 and p99 at low opacity sits on top. - * - Three differentiated markers: filled dot (p50), outlined dot (p90), - * vertical bar (p99). Reader learns the grammar from the small - * header legend. + * One row per provider. Each row carries a single continuous shape + * encoding the p50 → p90 → p99 progression on a shared log axis: + * + * ┌─ 100ms ── 1s ── 10s ── 1min ── 10min ── 1h ─┐ (landmark ticks) + * ├─────────────────────────────────────────────┤ + * │ TON ●━━━━┃ │ ← dot + thick + tail + wall + * │ Monero ●━━━━━━━━━━━┃ │ + * └─────────────────────────────────────────────┘ + * + * Two design choices that fix the previous version's pain points: + * + * 1. **Landmark axis** — the X axis snaps to fixed power-of-10 decades + * in the bench's native unit (10ms, 100ms, 1s, 10s, 1min, …). The + * visible landmarks only change when the data crosses a decade, + * so toggling a row almost never moves the rest of the chart — + * no more "shifting world". When the range does change, the + * transition runs through `useAnimatedDomain` (450 ms ease-out, + * 20 % significance threshold). + * + * 2. **Excluded rows dim in place** — the row keeps its slot and its + * rank number, the whisker collapses to opacity 0. No layout + * reflow, no marker positions to lose your eye on. + * + * The whisker itself replaces the previous 3-marker + band + median + * guide combo with one continuous shape: a thin connector from the + * axis floor to p50, a filled dot at p50, a thick bar from p50 → p90 + * (the "typical worst case" band), a thinner tail from p90 → p99 + * (the heavy-tail band), and a small vertical wall at p99. The + * silhouette encodes the full spread story in one eye-jump. */ export function DistributionChart({ benchmark, excluded: controlledExcluded, onToggleExclude, + onResetExcluded, headerActions, }: { benchmark: Benchmark; excluded?: Set; onToggleExclude?: (slug: string) => void; + onResetExcluded?: () => void; /** Optional slot rendered in the chart's header row, right-aligned. * BenchmarkBody passes the here so the control sits * on the same baseline as the chart title instead of floating in @@ -40,92 +61,126 @@ export function DistributionChart({ headerActions?: ReactNode; }) { const { results, unit, higherIsBetter } = benchmark; - const { excluded, toggle } = useChartExclusion( + const { excluded, toggle, reset } = useChartExclusion( controlledExcluded, onToggleExclude, + onResetExcluded, ); const colors = useMemo(() => buildProviderColors(results), [results]); const live = liveResults(results); - if (live.length === 0) { - return

No data.

; - } - const sorted = [...live].sort((a, b) => - higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, + // Sort once; sort order does NOT depend on the excluded set, so a + // row stays in its slot when toggled and the rank #N is stable. + const sorted = useMemo( + () => + [...live].sort((a, b) => + higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, + ), + [live, higherIsBetter], ); - // Field scale recomputes from visible rows only - excluding an outlier - // gives the remaining rows the full track width. We take both extremes - // because the auto-log switch needs the dynamic range, not just the max. + // The animated domain is driven by the *visible* set's min/max snapped + // to log-decade landmarks. Toggling a provider only moves the axis + // when the visible data crosses a decade boundary — most clicks leave + // the axis fixed (no shifting world). Hooks must run unconditionally, + // so the empty-data render below uses placeholders for the math but + // returns early before drawing anything. const visible = sorted.filter((r) => !excluded.has(r.slug)); - const fieldMax = Math.max(...visible.map((r) => r.ms.p99), 1); - const fieldMin = Math.min( - ...visible.flatMap((r) => [r.ms.p50, r.ms.p90, r.ms.p99]).filter((v) => v > 0), - fieldMax, + const visibleP50Min = visible.length > 0 + ? Math.min(...visible.map((r) => r.ms.p50).filter((v) => v > 0)) + : 1; + const visibleP99Max = visible.length > 0 + ? Math.max(...visible.map((r) => r.ms.p99), 1) + : 1; + const landmarks = useMemo( + () => pickLandmarks(visibleP50Min, visibleP99Max, unit), + [visibleP50Min, visibleP99Max, unit], + ); + const { lo: animLo, hi: animHi } = useAnimatedDomain( + Math.log10(landmarks.lo), + Math.log10(landmarks.hi), ); - // Use log scale when dynamic range > 50x. Same threshold as - // ranked-bar-chart for consistency. Critical on benches like l1-finality - // where TON (0.4 s) coexists with Monero (36 min): a linear scale - // collapses every sub-second chain into the same invisible pixel at the - // left, making 8 of 9 rows visually indistinguishable. The min-clamp - // is a tiny epsilon (not 1) so sub-1 ranges — typical for second-scale - // latency benches — get detected as wide-dynamic-range correctly. - const EPS = 1e-6; - const useLog = fieldMax / Math.max(fieldMin, EPS) > 50; + if (live.length === 0) { + return

No data.

; + } - // Both paths anchor the left edge at fieldMin (best observed value), - // not at absolute zero. Otherwise narrow-range benches with small mins - // (e.g. solana-tx-landing-latency where every provider's p50 = 2 slots - // on a 14-slot field) compress every median dot into a single sliver - // at the left, making it impossible to read dispersion. With this - // baseline the leader sits at 0 % and laggards spread the full track. - // - // For log scale we use `fieldMin / 2` as the left bound, floored at a - // tiny epsilon to avoid log(0). The earlier `Math.max(1, fieldMin/2)` - // floor silently flattened every sub-1 value onto position 0 % — - // l1-finality (TON 0.4 s, SUI 0.5 s, BNB 1 s) had its three fastest - // chains stacked on top of each other at the leftmost pixel even - // though their p50s are clearly distinct on the actual log axis. - const scale = (v: number) => { + const range = Math.max(animHi - animLo, 1e-6); + const x = (v: number) => { if (v <= 0) return 0; - if (useLog) { - const lo = Math.log10(Math.max(fieldMin / 2, EPS)); - const hi = Math.log10(fieldMax); - if (hi <= lo) return 50; - return Math.max(0, Math.min(100, ((Math.log10(v) - lo) / (hi - lo)) * 100)); - } - const range = fieldMax - fieldMin; - if (range <= 0) return 50; - return Math.max(0, Math.min(100, ((v - fieldMin) / range) * 100)); + return Math.max(0, Math.min(100, ((Math.log10(v) - animLo) / range) * 100)); }; return (
-
-

- Latency spread -

+
+
+

+ Latency spread +

+ + {live.length} {live.length === 1 ? "provider" : "providers"} + +
- + {excluded.size > 0 && ( + + )} + {headerActions}
+ + {/* Axis tick rail — landmarks above the rows so the reader can + decode any whisker's position back to a real time unit without + hovering. The rail is 1 row tall + a hairline so it reads as + a coordinate header, not a data row. */} +
+ {landmarks.ticks.map((t) => ( + + {t.label} + + ))} +
+
+ {landmarks.ticks.map((t) => ( + + ))} +
+
    - {sorted.map((r) => { + {sorted.map((r, i) => { const isOff = excluded.has(r.slug); const color = colors.get(r.slug) ?? "var(--color-ink-soft)"; - const p50Pct = isOff ? 0 : scale(r.ms.p50); - const p90Pct = isOff ? 0 : scale(r.ms.p90); - const p99Pct = isOff ? 0 : scale(r.ms.p99); + const p50Pct = x(r.ms.p50); + const p90Pct = x(r.ms.p90); + const p99Pct = x(r.ms.p99); + // Subtle gridline at every landmark — drawn per-row so it + // sits between the connector and the whisker, never above + // the data. Same x positions as the tick rail above. return (
  • toggle(r.slug)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -133,15 +188,18 @@ export function DistributionChart({ toggle(r.slug); } }} - className={`grid grid-cols-[minmax(6rem,9rem)_1fr_3rem_3rem] sm:grid-cols-[minmax(7rem,10rem)_1fr_3.5rem_3.5rem] items-center gap-3 sm:gap-4 py-3 border-b border-rule/50 last:border-b-0 cursor-pointer transition-colors hover:bg-paper-soft/40 ${ - isOff ? "opacity-40" : "" + className={`grid grid-cols-[1.5rem_minmax(6rem,9rem)_1fr_3.5rem_3.5rem] sm:grid-cols-[2rem_minmax(7rem,10rem)_1fr_4rem_4rem] items-center gap-2 sm:gap-3 py-2.5 border-b border-rule/50 last:border-b-0 cursor-pointer transition-all duration-300 hover:bg-paper-soft/40 ${ + isOff ? "opacity-30" : "" }`} + style={{ transitionProperty: "opacity, background-color" }} > - {/* Identity column right-aligned: pushes the logo+name flush - against the track start so the eye reads - name → track without a white gap to cross. Empty space - (when name is short like 'BNB') stays on the LEFT - edge of the row where it's off the visual path. */} + {/* Stable rank #N — does NOT renumber on exclude. The + reader sees the same provider in the same slot the + whole time they're toggling. */} + + #{i + 1} + + - {/* Track. 1 px reference hairline + per-row coloured band - + three differentiated markers. */} + + {/* The whisker. */}
    - {/* Reference hairline spanning the field. */} -
    - {/* Median guide at 50 % of the field. */} -
    - {!isOff && ( - <> - {/* p50 → p99 spread band */} -
    ( + + ))} + +
    + {/* Connector — faint line from axis floor to p50. + Helps the eye locate the whisker in negative + space when a row's p50 sits far to the right. */} + - {/* p99 — vertical bar, right edge of the spread */} - - - - {/* p90 — outlined ring */} - - - - {/* p50 — filled dot, the headline */} - - - - - )} + {/* p50 → p90 — the "typical worst case" band, full opacity. */} + + {/* p90 → p99 — the heavy-tail band, lower opacity so + the eye can separate "typical bad day" from + "real outlier territory". */} + + {/* p50 — filled dot, the headline. Drawn AFTER the + bands so it sits on top when the spread collapses + to a single point. */} + + {/* p99 wall — small vertical tick marking the tail's + absolute edge. Sits on top of the tail band. */} + +
    +
    + {fmtUnit(r.ms.p50, unit)} @@ -220,22 +297,16 @@ export function DistributionChart({ ); })}
-
- min {fmtUnit(fieldMin, unit)} - - {useLog ? "log scale · " : ""}max {fmtUnit(fieldMax, unit)} - -
); } -function MarkerLegend() { +function WhiskerLegend() { return ( -
+
@@ -243,20 +314,61 @@ function MarkerLegend() { - p90 + p50→p90 - p99 + p90→p99
); } + +/** + * Snap the visible [min, max] range to power-of-10 decades and emit a + * tick set in the bench's native unit. We use log decades (not semantic + * landmarks like "1 minute") so the helper works across every unit OCB + * uses (ms, s, slots, count, bps, pct, usd) without per-unit lookup + * tables. The labels are formatted via `fmtUnit` which already knows + * unit conventions ("100ms", "1.0s", "$10K", etc.). + * + * Decade endpoints are picked so the visible data sits *between* two + * displayed landmarks — the floor is the largest decade ≤ minP50, the + * ceiling is the smallest decade ≥ maxP99. This means a typical toggle + * of a single provider leaves the landmarks unchanged (the decade + * boundaries don't move just because one row went away). + */ +function pickLandmarks( + minV: number, + maxV: number, + unit: string, +): { lo: number; hi: number; ticks: { value: number; label: string }[] } { + const safeMin = Math.max(minV, 1e-6); + const safeMax = Math.max(maxV, safeMin * 10); + const minDecade = Math.floor(Math.log10(safeMin)); + const maxDecade = Math.ceil(Math.log10(safeMax)); + const lo = Math.pow(10, minDecade); + const hi = Math.pow(10, maxDecade); + const ticks: { value: number; label: string }[] = []; + // Hard cap of 8 ticks so a 1ms → 1d range (8 decades) still fits + // without crowding. Wider ranges step the sampling. + const totalDecades = maxDecade - minDecade; + const step = totalDecades <= 8 ? 1 : Math.ceil(totalDecades / 7); + for (let d = minDecade; d <= maxDecade; d += step) { + const value = Math.pow(10, d); + ticks.push({ value, label: fmtUnit(value, unit) }); + } + // Ensure the rightmost landmark sits exactly at hi when stepping + // would skip it (preserves the "ceiling tick" anchor). + const last = ticks[ticks.length - 1]; + if (last && last.value !== hi) ticks.push({ value: hi, label: fmtUnit(hi, unit) }); + return { lo, hi, ticks }; +} diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 9206c0b1..ab3f299a 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -7,6 +7,7 @@ import { brandColor } from "@/lib/brand"; import { fmtUnit } from "@/lib/format"; import { buildProviderColors } from "@/lib/series-colors"; import { useChartExclusion } from "@/hooks/use-chart-exclusion"; +import { useAnimatedDomain } from "@/hooks/use-animated-domain"; import { LiveDot } from "@/components/live-dot"; type Props = { @@ -304,56 +305,6 @@ type LineWithColor = { excluded: boolean; }; -/** - * Smoothly interpolate between the previous and current [lo, hi] domain - * when the reader excludes / re-adds a provider so the Y-axis re-scales - * with a 450 ms ease-out instead of snapping. We only run the animation - * when the new bounds differ by more than ~20% from the current ones — - * for small shifts (toggling a tightly-clustered provider) the snap is - * imperceptible and the extra frames are wasted. - */ -function useAnimatedDomain(targetLo: number, targetHi: number) { - const [displayed, setDisplayed] = useState({ lo: targetLo, hi: targetHi }); - const rafRef = useRef(null); - - useEffect(() => { - const targetRange = Math.max(targetHi - targetLo, 1); - const currentRange = Math.max(displayed.hi - displayed.lo, 1); - const hiDelta = Math.abs(targetHi - displayed.hi) / Math.max(currentRange, 1); - const loDelta = Math.abs(targetLo - displayed.lo) / Math.max(currentRange, 1); - const rangeDelta = Math.abs(targetRange - currentRange) / currentRange; - const significant = hiDelta > 0.2 || loDelta > 0.2 || rangeDelta > 0.2; - - if (!significant) { - setDisplayed({ lo: targetLo, hi: targetHi }); - return; - } - - const from = displayed; - const to = { lo: targetLo, hi: targetHi }; - const start = performance.now(); - const duration = 450; - const tick = (now: number) => { - const t = Math.min(1, (now - start) / duration); - const e = 1 - Math.pow(1 - t, 3); // ease-out cubic - setDisplayed({ - lo: from.lo + (to.lo - from.lo) * e, - hi: from.hi + (to.hi - from.hi) * e, - }); - if (t < 1) rafRef.current = requestAnimationFrame(tick); - }; - rafRef.current = requestAnimationFrame(tick); - return () => { - if (rafRef.current != null) cancelAnimationFrame(rafRef.current); - }; - // displayed intentionally omitted: we only want to react to target changes - // (the next animation frame will read the latest displayed value via closure). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetLo, targetHi]); - - return displayed; -} - function Chart({ lines, unit, diff --git a/src/hooks/use-animated-domain.ts b/src/hooks/use-animated-domain.ts new file mode 100644 index 00000000..2ec51baf --- /dev/null +++ b/src/hooks/use-animated-domain.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * Smoothly interpolate between the previous and current [lo, hi] domain + * so a chart's axis re-scales with a 450 ms ease-out instead of snapping. + * We only run the animation when the new bounds differ by more than ~20 % + * from the current ones — for small shifts (toggling a tightly-clustered + * value) the snap is imperceptible and the extra frames are wasted. + * + * Shared by `time-series-chart` (Y-axis after exclude / zoom) and + * `distribution-chart` (X-axis when a landmark threshold is crossed). + */ +export function useAnimatedDomain(targetLo: number, targetHi: number) { + const [displayed, setDisplayed] = useState({ lo: targetLo, hi: targetHi }); + const rafRef = useRef(null); + + useEffect(() => { + const targetRange = Math.max(targetHi - targetLo, 1); + const currentRange = Math.max(displayed.hi - displayed.lo, 1); + const hiDelta = Math.abs(targetHi - displayed.hi) / Math.max(currentRange, 1); + const loDelta = Math.abs(targetLo - displayed.lo) / Math.max(currentRange, 1); + const rangeDelta = Math.abs(targetRange - currentRange) / currentRange; + const significant = hiDelta > 0.2 || loDelta > 0.2 || rangeDelta > 0.2; + + if (!significant) { + setDisplayed({ lo: targetLo, hi: targetHi }); + return; + } + + const from = displayed; + const to = { lo: targetLo, hi: targetHi }; + const start = performance.now(); + const duration = 450; + const tick = (now: number) => { + const t = Math.min(1, (now - start) / duration); + const e = 1 - Math.pow(1 - t, 3); // ease-out cubic + setDisplayed({ + lo: from.lo + (to.lo - from.lo) * e, + hi: from.hi + (to.hi - from.hi) * e, + }); + if (t < 1) rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + }; + // displayed intentionally omitted: we only want to react to target changes + // (the next animation frame will read the latest displayed value via closure). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetLo, targetHi]); + + return displayed; +}