From d14eed5497f80d9bff725f9d0cd439dd74a16d25 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 3 Jun 2026 11:33:44 +0200 Subject: [PATCH 1/2] sync(topN): lift Top-N to parent so chart and ledger share one value --- src/components/distribution-chart.tsx | 6 +++- src/components/donut-chart.tsx | 4 ++- src/components/ledger-table.tsx | 6 ++-- src/components/ranked-bar-chart.tsx | 4 ++- src/components/time-series-chart.tsx | 4 ++- src/hooks/use-top-n.ts | 45 +++++++++++++++------------ 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 77e85fb4..9092d744 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -50,6 +50,7 @@ export function DistributionChart({ excluded: controlledExcluded, onToggleExclude, onResetExcluded, + topNControl, headerActions, }: { benchmark: Benchmark; @@ -61,6 +62,7 @@ export function DistributionChart({ * on the same baseline as the chart title instead of floating in * the card corner or eating a footer row of its own. */ headerActions?: ReactNode; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }) { const { results, unit, higherIsBetter } = benchmark; const { excluded, toggle, reset } = useChartExclusion( @@ -68,6 +70,8 @@ export function DistributionChart({ onToggleExclude, onResetExcluded, ); + // topNControl is destructured from the function signature below — accept it via Props. + const colors = useMemo(() => buildProviderColors(results), [results]); @@ -84,7 +88,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); + const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl }); 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 7487b042..fd1e0b75 100644 --- a/src/components/donut-chart.tsx +++ b/src/components/donut-chart.tsx @@ -38,12 +38,14 @@ export function DonutChart({ benchmark, excluded: controlledExcluded, onToggleExclude, + topNControl, headerActions, }: { benchmark: Benchmark; excluded?: Set; onToggleExclude?: (slug: string) => void; headerActions?: ReactNode; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }) { const { results } = benchmark; const { excluded, toggle } = useChartExclusion( @@ -58,7 +60,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); + const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl }); const liveClipped = useMemo( () => topN == null diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index f5350a8e..ae6b081f 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -20,6 +20,7 @@ type Props = { * providers in the same order. When null/undefined the ledger uses * the headline p50 metric. */ activePanel?: MetricPanel | null; + topN?: number | null; }; /** @@ -29,7 +30,7 @@ type Props = { * to recognition; sort order remains mechanical (ascending p50) and no * row is highlighted as the "winner". */ -export function LedgerTable({ benchmark, activePanel }: Props) { +export function LedgerTable({ benchmark, activePanel, topN }: Props) { const { results, extras } = benchmark; const unit = activePanel?.unit ?? benchmark.unit; const higherIsBetter = activePanel?.higherIsBetter ?? benchmark.higherIsBetter; @@ -66,7 +67,7 @@ export function LedgerTable({ benchmark, activePanel }: Props) { // The chart's panel tabs still surface those providers via // seriesByProvider when the reader switches metric, so coverage isn't // lost — only the noisy ledger rows are pruned. - const sorted = [...results] + const sortedAll = [...results] .filter((r) => { if (activePanel) { const v = activePanel.values[r.slug]; @@ -79,6 +80,7 @@ export function LedgerTable({ benchmark, activePanel }: Props) { const bv = pickValue(b); return higherIsBetter ? bv - av : av - bv; }); + const sorted = topN == null ? sortedAll : sortedAll.slice(0, topN); const colors = useMemo(() => buildProviderColors(results), [results]); const allSeries = Object.values(extras.series24h).flat(); diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx index 544637e4..98dfee13 100644 --- a/src/components/ranked-bar-chart.tsx +++ b/src/components/ranked-bar-chart.tsx @@ -22,6 +22,7 @@ type Props = { /** Optional slot rendered in the chart's header row, right-aligned. * BenchmarkBody passes the here. */ headerActions?: import("react").ReactNode; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }; export function RankedBarChart({ @@ -29,6 +30,7 @@ export function RankedBarChart({ excluded: controlledExcluded, onToggleExclude, onResetExcluded, + topNControl, headerActions, }: Props) { const { excluded, toggle, reset } = useChartExclusion( @@ -75,7 +77,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); + const { topN, setTopN, topNOptions } = useTopN(allRows.length, { external: topNControl }); 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 3ae652fc..0626a608 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -34,6 +34,7 @@ type Props = { seriesOverride?: Record; metricLabelOverride?: string; unitOverride?: Benchmark["unit"]; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }; type Range = "1h" | "6h" | "24h" | "7d" | "30d"; @@ -72,6 +73,7 @@ export function TimeSeriesChart({ metricLabelOverride, unitOverride, onResetExcluded, + topNControl, headerActions, }: Props) { const [range, setRange] = useState("24h"); @@ -157,7 +159,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); + const { topN, setTopN, topNOptions } = useTopN(allLines.length, { external: topNControl }); const lines = useMemo(() => { if (topN == null) return allLines; return allLines.slice(0, topN); diff --git a/src/hooks/use-top-n.ts b/src/hooks/use-top-n.ts index 15072169..cb614545 100644 --- a/src/hooks/use-top-n.ts +++ b/src/hooks/use-top-n.ts @@ -4,39 +4,44 @@ import { useEffect, useMemo, useState } from "react"; /** * Shared Top-N selector state for chart views. Sized off the count - * of providers that actually have data on the active metric — passing - * the raw cohort makes the toolbar offer useless options (Top 10 when - * only 7 providers scored). The hook curates the option set so an N - * button only appears when at least N+1 providers exist, plus an - * "All" anchor whenever any filtering option is offered. + * of providers that actually have data on the active metric. * - * Returned `topN`: - * - `null` means "show every provider that has data" - * - `number` means "slice to the first N (already sorted upstream)" + * Two control modes: + * - Uncontrolled (default): the hook owns the value via useState. + * - Controlled via `options.external`: the parent owns the value so + * every chart view + the ledger can share one Top-N selection. * - * Returned `topNOptions` is the exact button list the chart should - * render, in order. Hide the toolbar entirely when the array is empty - * (cohort too sparse for filtering to matter). - * - * `useEffect` gracefully resets to "All" when the active selection - * disappears from the option set (reader swapped to a sparser panel). + * `options.disabled` short-circuits the option set (returns []) which + * hides the selector entirely. */ -export function useTopN(scoredCount: number): { +export function useTopN( + scoredCount: number, + options?: { + disabled?: boolean; + external?: { topN: number | null; setTopN: (n: number | null) => void }; + }, +): { topN: number | null; setTopN: (n: number | null) => void; topNOptions: (number | null)[]; } { + const disabled = options?.disabled === true; + const external = options?.external; const topNOptions = useMemo<(number | null)[]>(() => { + if (disabled) return []; const opts: (number | null)[] = []; for (const n of [5, 10, 20]) if (n < scoredCount) opts.push(n); if (opts.length > 0) opts.push(null); return opts; - }, [scoredCount]); + }, [scoredCount, disabled]); const initial = topNOptions[0] ?? null; - const [topN, setTopN] = useState(initial); + const [topNLocal, setTopNLocal] = useState(initial); + const topN = external ? external.topN : topNLocal; + const setTopN = external ? external.setTopN : setTopNLocal; useEffect(() => { - if (topN == null) return; - if (!topNOptions.includes(topN)) setTopN(null); - }, [topNOptions, topN]); + if (external) return; + if (topNLocal == null) return; + if (!topNOptions.includes(topNLocal)) setTopNLocal(null); + }, [topNOptions, topNLocal, external]); return { topN, setTopN, topNOptions }; } From f2c5bdcf399579277e9943ad36401f98c8458803 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 3 Jun 2026 11:38:31 +0200 Subject: [PATCH 2/2] fix(network-fees): top-of-page layer toggle, drop top-N selector --- src/components/benchmark-body.tsx | 117 ++++++++++++++------------ src/components/distribution-chart.tsx | 4 +- src/components/donut-chart.tsx | 4 +- src/components/ranked-bar-chart.tsx | 4 +- src/components/time-series-chart.tsx | 4 +- 5 files changed, 73 insertions(+), 60 deletions(-) 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);