diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx index 05366f96..48ef0abb 100644 --- a/src/components/ranked-bar-chart.tsx +++ b/src/components/ranked-bar-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { Benchmark } from "@/types/benchmark"; import { fmtUnit } from "@/lib/format"; import { buildProviderColors } from "@/lib/series-colors"; @@ -69,13 +69,24 @@ export function RankedBarChart({ })); }, [benchmark, colors]); - // Top-N selector — sized off the registered cohort, mirroring the - // time-series chart. When the bench ships more than 10 providers the - // toolbar exposes Top 5 / 10 / 20 / All so a cluttered leaderboard - // can be focused without losing the option to widen. - const cohortSize = benchmark.results.length; - const TOP_N_DEFAULT = cohortSize > 20 ? 20 : null; + // Top-N selector — sized off the providers that actually scored on + // the headline metric (`allRows`), not the registered cohort. An N + // button only renders when at least N providers have data, and "All" + // only appears when there's enough to make filtering meaningful + // (>10). Below 10 the toolbar disappears entirely. + const scoredCount = allRows.length; + const topNOptions = useMemo<(number | null)[]>(() => { + const opts: (number | null)[] = []; + for (const n of [5, 10, 20]) if (n < scoredCount) opts.push(n); + if (scoredCount > 10) opts.push(null); + return opts; + }, [scoredCount]); + const TOP_N_DEFAULT = scoredCount > 20 ? 20 : null; const [topN, setTopN] = useState(TOP_N_DEFAULT); + useEffect(() => { + if (topN == null) return; + if (!topNOptions.includes(topN)) setTopN(null); + }, [topNOptions, topN]); const rows = useMemo(() => { if (topN == null) return allRows; return allRows.slice(0, topN); @@ -131,12 +142,12 @@ export function RankedBarChart({ {headerActions} - {cohortSize > 10 && ( + {topNOptions.length > 1 && (
Show - {([5, 10, 20, null] as const).map((n) => { + {topNOptions.map((n) => { const active = topN === n; const label = n == null ? "All" : `Top ${n}`; return ( diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 25c5c9cf..5f6d0ade 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -152,17 +152,32 @@ export function TimeSeriesChart({ return built; }, [benchmark, range, region, colors, excluded, seriesOverride]); - // Top-N selector. Sized off the REGISTERED cohort, not the subset - // that happened to emit data this cycle. On the headline metric some - // providers can return empty series for legitimate reasons (zero - // activity in the window, fresh registry entry not yet backfilled by - // Prom), but the reader still has a 60 row cohort and wants the - // filter affordance — sizing off `allLines` would hide the selector - // for exactly the views where it matters most. The slice itself - // operates on `allLines` so empty providers stay out of the chart. - const cohortSize = benchmark.results.length; - const TOP_N_DEFAULT = cohortSize > 20 ? 20 : null; + // Top-N selector. Sized off the providers that actually have data + // for the active range / panel — `allLines.length`, which is the + // post-filter count after empty series are dropped. Earlier this + // sized off the whole registered cohort so the toolbar would always + // offer Top 5 / 10 / 20 / All, but on panels where only e.g. 7 + // builders emit data the Top 10 / Top 20 buttons were useless + // placeholders. Now the option set is curated per render: an N + // button only appears when at least N providers have data; "All" + // shows up whenever the cohort is wider than 10. Below-10 cohorts + // skip the toolbar entirely. + const scoredCount = allLines.length; + const topNOptions = useMemo<(number | null)[]>(() => { + const opts: (number | null)[] = []; + for (const n of [5, 10, 20]) if (n < scoredCount) opts.push(n); + if (scoredCount > 10) opts.push(null); + return opts; + }, [scoredCount]); + const TOP_N_DEFAULT = scoredCount > 20 ? 20 : null; const [topN, setTopN] = useState(TOP_N_DEFAULT); + // If the active option disappeared (e.g. reader swapped to a sparser + // panel where Top 20 is no longer offered), gracefully reset to the + // widest still-available option. + useEffect(() => { + if (topN == null) return; + if (!topNOptions.includes(topN)) setTopN(null); + }, [topNOptions, topN]); const lines = useMemo(() => { if (topN == null) return allLines; return allLines.slice(0, topN); @@ -282,12 +297,12 @@ export function TimeSeriesChart({
)} - {cohortSize > 10 && ( + {topNOptions.length > 1 && (
Show - {([5, 10, 20, null] as const).map((n) => { + {topNOptions.map((n) => { const active = topN === n; const label = n == null ? "All" : `Top ${n}`; return (