diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 7394a128..2e8a9f97 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -144,23 +144,30 @@ export function BenchmarkBody({ const searchParams = useSearchParams(); const urlChain = searchParams.get("chain"); const urlRegion = searchParams.get("region"); + const urlLayer = searchParams.get("layer"); const resolvedInitialChain = (urlChain && chainOptions.find((c) => c.value === urlChain)?.value) ?? initialChain; const resolvedInitialRegion = (urlRegion && regionOptions.find((r) => r.value === urlRegion)?.value) ?? initialRegion; + const resolvedInitialLayer: ProviderLayer = + urlLayer === "l2" ? "l2" : "l1"; const [chain, setChain] = useState(resolvedInitialChain); const [region, setRegion] = useState(resolvedInitialRegion); + const [layer, setLayer] = useState(resolvedInitialLayer); useEffect(() => { const url = new URL(window.location.href); syncParam(url, "chain", chain, chainOptions); syncParam(url, "region", region, regionOptions); + // Layer param: drop when default ("l1"), keep when user picked l2. + if (layer === "l1") url.searchParams.delete("layer"); + else url.searchParams.set("layer", layer); const next = url.pathname + (url.search ? url.search : ""); if (next !== window.location.pathname + window.location.search) { window.history.replaceState(null, "", next); } - }, [chain, region, chainOptions, regionOptions]); + }, [chain, region, layer, chainOptions, regionOptions]); const fallbackChain = chainOptions[0]?.value ?? null; const fallbackRegion = regionOptions[0]?.value ?? null; @@ -172,8 +179,9 @@ export function BenchmarkBody({ Object.values(variants)[0]; if (!benchmark) return null; - // L1/L2 filter counts, derived from the full unfiltered results so the - // pill counts are stable as the user toggles the filter. + // L1/L2 layer counts. When both > 0 the bench mixes L1 and L2 chains + // and we render a top-level Layer toggle that filters the entire page + // (chart + summary + ledger) to one layer at a time. Default is L1. const layerCounts = useMemo(() => { let l1 = 0; let l2 = 0; @@ -183,33 +191,32 @@ export function BenchmarkBody({ } return { all: benchmark.results.length, l1, l2 }; }, [benchmark.results]); - - // When the bench mixes L1 and L2 chains we render two separate ledger - // tables — one per layer — so the ranking inside each layer reads - // cleanly. Mixing them in a single sort buries Avalanche between - // Blast and Optimism, which is technically correct but unreadable for - // wallet UX decisions ("what does it cost on L1 vs L2"). Keeping the - // full unfiltered benchmark for the chart above. const hasLayerSplit = layerCounts.l1 > 0 && layerCounts.l2 > 0; - const filterByLayer = (l: ProviderLayer) => ({ - ...benchmark, - results: benchmark.results.filter((r) => r.layer === l), - }); - const l1Benchmark = useMemo(() => filterByLayer("l1"), [benchmark]); - const l2Benchmark = useMemo(() => filterByLayer("l2"), [benchmark]); - const isDraft = benchmark.status === "draft"; + // Filter the benchmark to the active layer for the entire page. When + // hasLayerSplit is false the original benchmark is returned untouched + // so non-layer benches keep their existing behavior. The chart, the + // summary stats and the ledger all read from `viewBenchmark`. + const viewBenchmark = useMemo(() => { + if (!hasLayerSplit) return benchmark; + return { + ...benchmark, + results: benchmark.results.filter((r) => r.layer === layer), + }; + }, [benchmark, hasLayerSplit, layer]); + + const isDraft = viewBenchmark.status === "draft"; const { fieldMin, fieldMedian, fieldMax, tailMin, tailMax, tailSpread } = - computeFieldStats(benchmark.results); + computeFieldStats(viewBenchmark.results); // View switcher state. Per-bench, persisted via localStorage. Default // mirrors the heuristic the page used before the switcher existed so // an anonymous user with no prior preference sees the same layout // they always saw. - const allowedViews = viewsForBenchmark(benchmark); - const defaultView = defaultViewFor(benchmark); + const allowedViews = viewsForBenchmark(viewBenchmark); + const defaultView = defaultViewFor(viewBenchmark); const [view, setView, viewMounted] = useViewPreference( - benchmark.slug, + viewBenchmark.slug, defaultView, allowedViews, ); @@ -254,8 +261,19 @@ export function BenchmarkBody({ return ( <> - {(chainOptions.length > 0 || regionOptions.length > 0) && ( + {(hasLayerSplit || chainOptions.length > 0 || regionOptions.length > 0) && (
+ {hasLayerSplit && ( + setLayer(v as ProviderLayer)} + /> + )} {chainOptions.length > 0 && ( {view === "countLeaderboard" && ( } /> )} {view === "rankedBar" && ( } /> )} {view === "distribution" && ( } /> )} {view === "donut" && ( } /> )} @@ -381,7 +402,7 @@ export function BenchmarkBody({ /> )} 0 ? (region ?? fallbackRegion ?? undefined) @@ -392,6 +413,7 @@ export function BenchmarkBody({ excluded={excluded} onToggleExclude={toggleExclude} onResetExcluded={resetExcluded} + disableTopN={hasLayerSplit} headerActions={} seriesOverride={activePanel?.seriesByProvider} metricLabelOverride={activePanel?.label} @@ -407,39 +429,22 @@ export function BenchmarkBody({
- {hasLayerSplit ? ( - <> -
-

- Layer 1 · {layerCounts.l1} chains · sorted by p50 -

- -
-
-

- Layer 2 · {layerCounts.l2} chains · sorted by p50 -

- -
- - ) : ( -
-

- {benchmark.unit === "count" - ? "Product ledger" - : activePanel - ? `Product ledger · sorted by ${activePanel.label}` - : "Product ledger · sorted by p50"} -

- -
- )} +
+

+ {viewBenchmark.unit === "count" + ? "Product ledger" + : activePanel + ? `Product ledger · sorted by ${activePanel.label}` + : "Product ledger · sorted by p50"} +

+ +
- {benchmark.unit !== "count" && + {viewBenchmark.unit !== "count" && Object.keys(benchmark.extras.regions).length > 0 && (

By region

- +
)} diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 9092d744..fa2d3fae 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -51,6 +51,7 @@ export function DistributionChart({ onToggleExclude, onResetExcluded, topNControl, + disableTopN, headerActions, }: { benchmark: Benchmark; @@ -63,6 +64,7 @@ export function DistributionChart({ * the card corner or eating a footer row of its own. */ headerActions?: ReactNode; topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; + disableTopN?: boolean; }) { const { results, unit, higherIsBetter } = benchmark; const { excluded, toggle, reset } = useChartExclusion( @@ -88,7 +90,7 @@ export function DistributionChart({ ); // Top-N selector — shared shape with the other chart views so the // reader can focus on the top tail without losing the option to widen. - const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl }); + const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl, disabled: disableTopN }); const sorted = useMemo( () => (topN == null ? sortedAll : sortedAll.slice(0, topN)), [sortedAll, topN], diff --git a/src/components/donut-chart.tsx b/src/components/donut-chart.tsx index fd1e0b75..8fcb1e69 100644 --- a/src/components/donut-chart.tsx +++ b/src/components/donut-chart.tsx @@ -39,6 +39,7 @@ export function DonutChart({ excluded: controlledExcluded, onToggleExclude, topNControl, + disableTopN, headerActions, }: { benchmark: Benchmark; @@ -46,6 +47,7 @@ export function DonutChart({ onToggleExclude?: (slug: string) => void; headerActions?: ReactNode; topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; + disableTopN?: boolean; }) { const { results } = benchmark; const { excluded, toggle } = useChartExclusion( @@ -60,7 +62,7 @@ export function DonutChart({ // Top-N selector — clip the cohort BEFORE applying exclusion so the // "top N" semantic matches what every other view shows. Sized off // the live provider count via the shared `useTopN` hook. - const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl }); + const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl, disabled: disableTopN }); const liveClipped = useMemo( () => topN == null diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx index 98dfee13..4ed215d6 100644 --- a/src/components/ranked-bar-chart.tsx +++ b/src/components/ranked-bar-chart.tsx @@ -23,6 +23,7 @@ type Props = { * BenchmarkBody passes the here. */ headerActions?: import("react").ReactNode; topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; + disableTopN?: boolean; }; export function RankedBarChart({ @@ -31,6 +32,7 @@ export function RankedBarChart({ onToggleExclude, onResetExcluded, topNControl, + disableTopN, headerActions, }: Props) { const { excluded, toggle, reset } = useChartExclusion( @@ -77,7 +79,7 @@ export function RankedBarChart({ // the headline metric (`allRows`), via the shared `useTopN` hook so // every chart view (ranked bar, time series, distribution, donut) // agrees on the option set and the empty-toolbar rule. - const { topN, setTopN, topNOptions } = useTopN(allRows.length, { external: topNControl }); + const { topN, setTopN, topNOptions } = useTopN(allRows.length, { external: topNControl, disabled: disableTopN }); const rows = useMemo(() => { if (topN == null) return allRows; return allRows.slice(0, topN); diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 0626a608..27a41305 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -35,6 +35,7 @@ type Props = { metricLabelOverride?: string; unitOverride?: Benchmark["unit"]; topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; + disableTopN?: boolean; }; type Range = "1h" | "6h" | "24h" | "7d" | "30d"; @@ -74,6 +75,7 @@ export function TimeSeriesChart({ unitOverride, onResetExcluded, topNControl, + disableTopN, headerActions, }: Props) { const [range, setRange] = useState("24h"); @@ -159,7 +161,7 @@ export function TimeSeriesChart({ // Top-N selector — sized off the post-filter line count via the // shared `useTopN` hook so the option set agrees across every // chart view on the bench page. - const { topN, setTopN, topNOptions } = useTopN(allLines.length, { external: topNControl }); + const { topN, setTopN, topNOptions } = useTopN(allLines.length, { external: topNControl, disabled: disableTopN }); const lines = useMemo(() => { if (topN == null) return allLines; return allLines.slice(0, topN);