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 c83e8cbb696af774bf30596053c582b1017819ea Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 3 Jun 2026 16:52:38 +0200 Subject: [PATCH 2/2] fix(build): remove leftover merge conflict markers in chart components --- src/components/distribution-chart.tsx | 10 ---------- src/components/donut-chart.tsx | 10 ---------- 2 files changed, 20 deletions(-) diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 9493b65f..fa2d3fae 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -51,10 +51,7 @@ export function DistributionChart({ onToggleExclude, onResetExcluded, topNControl, -<<<<<<< HEAD -======= disableTopN, ->>>>>>> origin/dev headerActions, }: { benchmark: Benchmark; @@ -67,10 +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 }; -<<<<<<< HEAD -======= disableTopN?: boolean; ->>>>>>> origin/dev }) { const { results, unit, higherIsBetter } = benchmark; const { excluded, toggle, reset } = useChartExclusion( @@ -96,11 +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. -<<<<<<< HEAD - const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl }); -======= const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl, disabled: disableTopN }); ->>>>>>> origin/dev 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 00f1db93..8fcb1e69 100644 --- a/src/components/donut-chart.tsx +++ b/src/components/donut-chart.tsx @@ -39,10 +39,7 @@ export function DonutChart({ excluded: controlledExcluded, onToggleExclude, topNControl, -<<<<<<< HEAD -======= disableTopN, ->>>>>>> origin/dev headerActions, }: { benchmark: Benchmark; @@ -50,10 +47,7 @@ export function DonutChart({ onToggleExclude?: (slug: string) => void; headerActions?: ReactNode; topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; -<<<<<<< HEAD -======= disableTopN?: boolean; ->>>>>>> origin/dev }) { const { results } = benchmark; const { excluded, toggle } = useChartExclusion( @@ -68,11 +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. -<<<<<<< HEAD - const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl }); -======= const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl, disabled: disableTopN }); ->>>>>>> origin/dev const liveClipped = useMemo( () => topN == null