- {/* 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;
+}