Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/distribution-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,7 @@ export function DistributionChart({
excluded: controlledExcluded,
onToggleExclude,
onResetExcluded,
topNControl,
headerActions,
}: {
benchmark: Benchmark;
Expand All@@ -61,13 +62,16 @@ 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(
controlledExcluded,
onToggleExclude,
onResetExcluded,
);
// topNControl is destructured from the function signature below — accept it via Props.


const colors = useMemo(() => buildProviderColors(results), [results]);

Expand All@@ -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],
Expand Down
4 changes: 3 additions & 1 deletion src/components/donut-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,12 +38,14 @@ export function DonutChart({
benchmark,
excluded: controlledExcluded,
onToggleExclude,
topNControl,
headerActions,
}: {
benchmark: Benchmark;
excluded?: Set<string>;
onToggleExclude?: (slug: string) => void;
headerActions?: ReactNode;
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
}) {
const { results } = benchmark;
const { excluded, toggle } = useChartExclusion(
Expand All@@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/components/ledger-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
};

/**
Expand All@@ -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;
Expand DownExpand Up@@ -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];
Expand All@@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/components/ranked-bar-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,13 +22,15 @@ type Props = {
/** Optional slot rendered in the chart's header row, right-aligned.
* BenchmarkBody passes the <ViewSwitcher> here. */
headerActions?: import("react").ReactNode;
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
};

export function RankedBarChart({
benchmark,
excluded: controlledExcluded,
onToggleExclude,
onResetExcluded,
topNControl,
headerActions,
}: Props) {
const { excluded, toggle, reset } = useChartExclusion(
Expand DownExpand Up@@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/components/time-series-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ type Props = {
seriesOverride?: Record<string, number[]>;
metricLabelOverride?: string;
unitOverride?: Benchmark["unit"];
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
};

type Range = "1h" | "6h" | "24h" | "7d" | "30d";
Expand DownExpand Up@@ -72,6 +73,7 @@ export function TimeSeriesChart({
metricLabelOverride,
unitOverride,
onResetExcluded,
topNControl,
headerActions,
}: Props) {
const [range, setRange] = useState<Range>("24h");
Expand DownExpand Up@@ -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);
Expand Down
45 changes: 25 additions & 20 deletions src/hooks/use-top-n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number | null>(initial);
const [topNLocal, setTopNLocal] = useState<number | null>(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 };
}
Loading