From 2f5d7f7689e84b48659f1691834bc762657c750e Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Tue, 2 Jun 2026 15:15:24 +0200
Subject: [PATCH 1/2] =?UTF-8?q?feat(bench):=20network=20fees=20=E2=80=94?=
=?UTF-8?q?=20current=20native=20transfer=20cost=20USD=20across=2011=20L1?=
=?UTF-8?q?=20chains?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
benchmarks/network-fees.yml | 214 ++++++++++++++++++++++++++++++++++++
1 file changed, 214 insertions(+)
create mode 100644 benchmarks/network-fees.yml
diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml
new file mode 100644
index 00000000..2a75701a
--- /dev/null
+++ b/benchmarks/network-fees.yml
@@ -0,0 +1,214 @@
+# OpenChainBench. Bench № 031
+
+slug: network-fees
+number: "031"
+title: Current native transfer fee across L1 blockchains
+seo_title: "Cheapest blockchain transaction fee 2026: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero USD live"
+seo_description: "Live USD cost of a native-token transfer on 11 L1 chains: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero. Slow/standard/fast tiers refreshed every 30s, USD price from Mobula."
+subtitle: "Current USD cost of moving the chain's native asset, refreshed every 30 seconds for the same 11 L1s tracked on the finality bench."
+
+seo_intro: |
+ This page shows the live USD cost of sending the native token on every major Layer-1 blockchain. We sample the chain-native fee market (eth_feeHistory for EVM chains, getRecentPrioritizationFees for Solana, koios protocol params for Cardano, fee_stats for Stellar, fee_estimate for Monero, mempool oracles for Litecoin, getChainParameters for TRON, sui_getReferenceGasPrice for SUI, hardcoded typical observed for TON), convert to the chain's smallest unit, then multiply by the native-token USD price pulled from Mobula every 30 seconds. The result is a head-to-head comparison of what it actually costs an end user to move one unit of value on each chain, rather than a gas-price comparison in gwei or lamports which is meaningless across protocols.
+
+faq:
+ - q: "What does this benchmark measure?"
+ a: "The current USD cost of a single native-token transfer on each of the 11 tracked L1 chains. A native transfer is the simplest possible action: send the chain's main asset from one address to another. ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not measure ERC-20 transfers, DEX swaps, or contract calls."
+ - q: "Why USD instead of gas price?"
+ a: "Gas price in gwei (Ethereum) cannot be compared to lamports per CU (Solana) or stroops per operation (Stellar). The only honest cross-chain unit is the dollar cost of a user-facing action, computed at scrape time using a live USD price for each native token. Mobula's market API provides the prices we multiply by."
+ - q: "What do slow, standard, and fast tiers mean?"
+ a: "Tiers exist for chains with a priority market where users can pay more to get faster inclusion. Slow targets roughly the 25th percentile of recent priority bids, standard the 50th, fast the 90th. Chains with deterministic or near-deterministic fees (Cardano, Stellar, TON, TRON native transfer) emit a single tier because there is no priority market to bid into."
+ - q: "Why is the Solana fee so low?"
+ a: "Solana charges 5000 lamports per signature as a hard base, plus an optional priority fee priced in micro-lamports per compute unit. A simple SOL transfer uses around 200 compute units, so the priority component is typically dwarfed by the base. At current SOL prices the headline fee sits well under one cent for non-congested blocks."
+ - q: "Why is the Cardano fee always similar?"
+ a: "Cardano fees are deterministic. The protocol parameters (`min_fee_a` per byte, `min_fee_b` base) are set by governance and updated rarely; a standard ADA transfer is roughly 250 bytes, so the lovelace cost is essentially fixed until the next parameter vote. The USD figure on the leaderboard only moves because ADA's USD price moves."
+ - q: "How are L2 fees handled?"
+ a: "Not on this page. L2 fees are a separate measurement problem (rollup fee = L2 execution + L1 data posting, the latter being a non-trivial blob market). The L2 bench is in progress as a companion to this L1 page; once shipped, you'll be able to compare L1-fee vs L2-fee for any wallet decision in one click."
+ - q: "How often does the page refresh?"
+ a: "Every 30 seconds. The harness re-queries each chain's fee oracle and Mobula's price API every 30 seconds, so headline values are at most 30 seconds stale plus chain RPC latency (typically under a second)."
+ - q: "Why are some chains using single tier?"
+ a: "Cardano fees are protocol-deterministic. Stellar's base fee is 100 stroops per operation network-wide. TON's typical fee is a hardcoded conservative estimate because TON has no clean fee-estimate RPC. TRON native transfers consume bandwidth at the published rate per byte. None of these chains expose a priority market a user can bid into for a TRX, ADA, XLM or TON transfer, so emitting a single tier is more honest than fabricating three identical values."
+
+category: Blockchains
+status: live
+metric: Native transfer fee USD
+unit: usd
+higher_is_better: false
+
+abstract: |
+ Every 30 seconds we ask each L1 chain its current native-transfer fee
+ in the chain's smallest unit (wei, lamport, lovelace, stroop, sun,
+ MIST, nanoton, litoshi, atomic), multiply by the live USD price of
+ the chain's native token from Mobula's market API, and publish the
+ result. Chains with a priority market expose three tiers (slow / std
+ / fast) mapped to roughly the 25th, 50th and 90th percentile of the
+ recent fee distribution. Deterministic-fee chains emit a single tier.
+ The 11-chain list mirrors the L1 finality bench so users can compare
+ cost and speed head-to-head.
+
+methodology:
+ - "Refresh cadence: 30 s. One process samples all 11 chains in parallel goroutines."
+ - "Ethereum / BNB / Avalanche (EVM): `eth_feeHistory` over the last 4 blocks at percentiles [25, 50, 90]. Cost = (base_fee + reward_percentile) × 21_000 gas, mapped to slow/std/fast."
+ - "Solana: `getRecentPrioritizationFees` percentiles 25/50/90 of micro-lamports per CU × 200 CU + 5_000 lamport base. Empty-fees response collapses to single std tier at the 5_000 base."
+ - "TRON: `getChainParameters.getTransactionFee` (currently 1_000 SUN/byte) × 268 bytes typical native transfer."
+ - "Cardano: koios `epoch_params.min_fee_a / min_fee_b` × 250 bytes typical native transfer. Deterministic, refreshes only when on-chain parameters change."
+ - "Stellar: horizon `fee_stats.last_ledger_base_fee` × 1 operation. Single tier."
+ - "SUI: `suix_getReferenceGasPrice` × 2_000_000 gas budget (typical observed for `Coin::transfer`). Single std tier."
+ - "TON: hardcoded 0.005 TON, the typical observed wallet-v4 transfer. TON's fee model is BoC-emulation only; a clean fee-estimate RPC does not exist."
+ - "Litecoin: litecoinspace.org `/api/v1/fees/recommended` (hourFee / halfHourFee / fastestFee in litoshi/vByte) × 225 vBytes typical 1-in-1-out P2WPKH transfer."
+ - "Monero: monero-rpc `get_fee_estimate.fees[0..2]` × 1500 bytes typical 1-in-2-out RingCT transaction."
+ - "USD prices: `api.mobula.io/api/1/market/multi-data` polled every 30 s for all 11 native tokens in one call."
+ - "Failures: any upstream error leaves the previous gauge in place, increments `tx_fee_fetch_errors_total{chain, error_type}`, and sets `tx_fee_health{chain}=0`."
+
+findings:
+ - "{{best_name}} is the cheapest L1 native transfer at {{best_p50}} (std, 24h)."
+ - "{{name:ethereum}} sits at {{p50:ethereum}} (std, 24h), the most expensive L1 transfer on this leaderboard during normal congestion."
+ - "{{name:bnb}}, {{name:avalanche}}, {{name:stellar}}, {{name:solana}}, {{name:litecoin}} cluster near or below one cent for a standard transfer."
+ - "{{name:cardano}}, {{name:tron}}, {{name:monero}} use deterministic or near-deterministic fee models; USD movement on the leaderboard reflects native-token price movement, not network congestion."
+ - "USD costs are computed at scrape time. A 10% intraday move in the native token's USD price moves the headline by 10% even if the chain-native fee is flat. We surface that intentionally — the dollar cost is what a user actually pays."
+
+source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/transaction-fee
+
+prometheus:
+ window: 24h
+
+# Real metrics exposed by the transaction-fee harness:
+# tx_fee_native_transfer_usd{chain, tier} gauge (USD)
+# tx_fee_native_transfer_native{chain, tier} gauge (smallest unit)
+# tx_fee_gas_price_gwei{chain, tier} gauge (EVM only)
+# tx_fee_native_token_price_usd{chain} gauge (USD per token)
+# tx_fee_last_refresh_timestamp_seconds{chain} gauge (unix s)
+# tx_fee_fetch_errors_total{chain, error_type} counter
+# tx_fee_health{chain} gauge (0|1)
+
+providers:
+ - slug: ethereum
+ name: Ethereum
+ tag: EIP-1559 fee market, 21000 gas × (base + p50 priority)
+ formula: "Median USD cost of a native ETH transfer over 24h: (base_fee + p50 priority_fee) × 21000 gas × ETH USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="ethereum"}[24h])
+ series: tx_fee_native_transfer_usd{chain="ethereum",tier="std"}
+
+ - slug: bnb
+ name: BNB Chain
+ tag: EIP-1559 fee market, 21000 gas
+ formula: "Median USD cost of a native BNB transfer over 24h."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="bnb"}[24h])
+ series: tx_fee_native_transfer_usd{chain="bnb",tier="std"}
+
+ - slug: avalanche
+ name: Avalanche
+ tag: C-Chain EIP-1559 fee market, 21000 gas
+ formula: "Median USD cost of a native AVAX transfer over 24h."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="avalanche"}[24h])
+ series: tx_fee_native_transfer_usd{chain="avalanche",tier="std"}
+
+ - slug: solana
+ name: Solana
+ tag: 5000 lamport base + getRecentPrioritizationFees × 200 CU
+ formula: "Median USD cost of a native SOL transfer over 24h: 5000 lamports + p50 priority × 200 CU × SOL USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="solana"}[24h])
+ series: tx_fee_native_transfer_usd{chain="solana",tier="std"}
+
+ - slug: tron
+ name: TRON
+ tag: Bandwidth model, 268 bytes × getTransactionFee SUN/byte
+ formula: "Deterministic USD cost of a native TRX transfer using the bandwidth-rate parameter × 268 bytes typical transfer × TRX USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h])
+ success: avg_over_time(tx_fee_health{chain="tron"}[24h])
+ series: tx_fee_native_transfer_usd{chain="tron",tier="single"}
+
+ - slug: cardano
+ name: Cardano
+ tag: min_fee_b + size × min_fee_a, deterministic
+ formula: "Protocol-deterministic ADA transfer cost: koios live min_fee_a/b × 250 bytes typical × ADA USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h])
+ success: avg_over_time(tx_fee_health{chain="cardano"}[24h])
+ series: tx_fee_native_transfer_usd{chain="cardano",tier="single"}
+
+ - slug: sui
+ name: Sui
+ tag: suix_getReferenceGasPrice × 2_000_000 gas budget
+ formula: "USD cost of a native SUI transfer: live reference gas price × 2M gas budget × SUI USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="sui"}[24h])
+ series: tx_fee_native_transfer_usd{chain="sui",tier="std"}
+
+ - slug: ton
+ name: TON
+ tag: Hardcoded 0.005 TON typical wallet-v4 transfer
+ formula: "Conservative typical-observed cost of a TON wallet transfer × TON USD price. TON has no fee-estimate RPC; this is the observed median."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h])
+ success: avg_over_time(tx_fee_health{chain="ton"}[24h])
+ series: tx_fee_native_transfer_usd{chain="ton",tier="single"}
+
+ - slug: stellar
+ name: Stellar
+ tag: 100 stroops base per operation, surge during congestion
+ formula: "Live horizon fee_stats.last_ledger_base_fee × 1 op × XLM USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h])
+ success: avg_over_time(tx_fee_health{chain="stellar"}[24h])
+ series: tx_fee_native_transfer_usd{chain="stellar",tier="single"}
+
+ - slug: litecoin
+ name: Litecoin
+ tag: litecoinspace fee oracle × 225 vBytes
+ formula: "Mempool-based litoshi/vByte × 225 vBytes typical P2WPKH transfer × LTC USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="litecoin"}[24h])
+ series: tx_fee_native_transfer_usd{chain="litecoin",tier="std"}
+
+ - slug: monero
+ name: Monero
+ tag: get_fee_estimate × 1500 bytes RingCT
+ formula: "Monero-rpc fee per byte × 1500 bytes typical 1-in-2-out RingCT × XMR USD price."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="monero"}[24h])
+ series: tx_fee_native_transfer_usd{chain="monero",tier="std"}
From 0c04771281ba3fdde91a20479cb4f8f705b5e371 Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Tue, 2 Jun 2026 15:21:51 +0200
Subject: [PATCH 2/2] chart: Top-N selector on all 4 chart views via shared
hook+component
---
src/components/distribution-chart.tsx | 12 +++++-
src/components/donut-chart.tsx | 18 +++++++-
src/components/ranked-bar-chart.tsx | 54 +++++-------------------
src/components/time-series-chart.tsx | 59 ++++-----------------------
src/components/top-n-selector.tsx | 47 +++++++++++++++++++++
src/hooks/use-top-n.ts | 42 +++++++++++++++++++
6 files changed, 134 insertions(+), 98 deletions(-)
create mode 100644 src/components/top-n-selector.tsx
create mode 100644 src/hooks/use-top-n.ts
diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx
index 44184d7c..77e85fb4 100644
--- a/src/components/distribution-chart.tsx
+++ b/src/components/distribution-chart.tsx
@@ -9,6 +9,8 @@ import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
import { useAnimatedDomain } from "@/hooks/use-animated-domain";
+import { useTopN } from "@/hooks/use-top-n";
+import { TopNSelector } from "@/components/top-n-selector";
/**
* Latency-spread view, "percentile whisker" design.
@@ -73,13 +75,20 @@ export function DistributionChart({
// Sort once; sort order does NOT depend on the excluded set, so a
// row stays in its slot when toggled and the rank #N is stable.
- const sorted = useMemo(
+ const sortedAll = useMemo(
() =>
[...live].sort((a, b) =>
higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50,
),
[live, higherIsBetter],
);
+ // 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 sorted = useMemo(
+ () => (topN == null ? sortedAll : sortedAll.slice(0, topN)),
+ [sortedAll, topN],
+ );
// The animated domain is driven by the *visible* set's min/max snapped
// to log-decade landmarks. Toggling a provider only moves the axis
@@ -134,6 +143,7 @@ export function DistributionChart({
Reset · {excluded.size} excluded
)}
+
{headerActions}
diff --git a/src/components/donut-chart.tsx b/src/components/donut-chart.tsx
index 67aba5b0..7487b042 100644
--- a/src/components/donut-chart.tsx
+++ b/src/components/donut-chart.tsx
@@ -7,7 +7,9 @@ import { ProviderLogo } from "@/components/provider-logo";
import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
+import { useTopN } from "@/hooks/use-top-n";
import { rankResults } from "@/lib/ranking";
+import { TopNSelector } from "@/components/top-n-selector";
/**
* Share-of-field donut. Each provider is a slice sized by p50 vs the
@@ -53,7 +55,20 @@ export function DonutChart({
() => liveResults(results),
[results],
);
- const live = liveAll.filter((r) => !excluded.has(r.slug));
+ // 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 liveClipped = useMemo(
+ () =>
+ topN == null
+ ? liveAll
+ : [...liveAll]
+ .sort((a, b) => b.ms.p50 - a.ms.p50)
+ .slice(0, topN),
+ [liveAll, topN],
+ );
+ const live = liveClipped.filter((r) => !excluded.has(r.slug));
const total = live.reduce((s, r) => s + r.ms.p50, 0);
const colors = useMemo(() => buildProviderColors(results), [results]);
const sorted = useMemo(
@@ -117,6 +132,7 @@ export function DonutChart({
Share by p50
+
{live.length} of {liveAll.length} live
diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx
index 48ef0abb..544637e4 100644
--- a/src/components/ranked-bar-chart.tsx
+++ b/src/components/ranked-bar-chart.tsx
@@ -1,12 +1,14 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import type { Benchmark } from "@/types/benchmark";
import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
+import { useTopN } from "@/hooks/use-top-n";
import { LiveDot } from "@/components/live-dot";
import { ProviderLogo } from "@/components/provider-logo";
+import { TopNSelector } from "@/components/top-n-selector";
type Props = {
benchmark: Benchmark;
@@ -70,23 +72,10 @@ export function RankedBarChart({
}, [benchmark, colors]);
// 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]);
+ // 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 rows = useMemo(() => {
if (topN == null) return allRows;
return allRows.slice(0, topN);
@@ -142,32 +131,9 @@ export function RankedBarChart({
{headerActions}
{rows.map((r) => {
const isOff = excluded.has(r.slug);
diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx
index 5f6d0ade..3ae652fc 100644
--- a/src/components/time-series-chart.tsx
+++ b/src/components/time-series-chart.tsx
@@ -8,7 +8,9 @@ import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
import { useAnimatedDomain } from "@/hooks/use-animated-domain";
+import { useTopN } from "@/hooks/use-top-n";
import { LiveDot } from "@/components/live-dot";
+import { TopNSelector } from "@/components/top-n-selector";
type Props = {
benchmark: Benchmark;
@@ -152,32 +154,10 @@ export function TimeSeriesChart({
return built;
}, [benchmark, range, region, colors, excluded, seriesOverride]);
- // 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]);
+ // 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 lines = useMemo(() => {
if (topN == null) return allLines;
return allLines.slice(0, topN);
@@ -297,32 +277,7 @@ export function TimeSeriesChart({
)}
- {topNOptions.length > 1 && (
-
- )}
+
{lines.length === 0 ? (
diff --git a/src/components/top-n-selector.tsx b/src/components/top-n-selector.tsx
new file mode 100644
index 00000000..47a3b08b
--- /dev/null
+++ b/src/components/top-n-selector.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+/**
+ * Shared button group for chart Top-N selection. Renders nothing when
+ * the option set is empty so the caller can include it unconditionally.
+ * The active button styling mirrors the range / region tabs across
+ * the chart toolbars so the whole control row reads as one unit.
+ */
+export function TopNSelector({
+ value,
+ options,
+ onChange,
+ className = "",
+}: {
+ value: number | null;
+ options: (number | null)[];
+ onChange: (n: number | null) => void;
+ className?: string;
+}) {
+ if (options.length === 0) return null;
+ return (
+
+
+ Show
+
+ {options.map((n) => {
+ const active = value === n;
+ const label = n == null ? "All" : `Top ${n}`;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/hooks/use-top-n.ts b/src/hooks/use-top-n.ts
new file mode 100644
index 00000000..15072169
--- /dev/null
+++ b/src/hooks/use-top-n.ts
@@ -0,0 +1,42 @@
+"use client";
+
+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.
+ *
+ * Returned `topN`:
+ * - `null` means "show every provider that has data"
+ * - `number` means "slice to the first N (already sorted upstream)"
+ *
+ * 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).
+ */
+export function useTopN(scoredCount: number): {
+ topN: number | null;
+ setTopN: (n: number | null) => void;
+ topNOptions: (number | null)[];
+} {
+ const topNOptions = useMemo<(number | null)[]>(() => {
+ 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]);
+ const initial = topNOptions[0] ?? null;
+ const [topN, setTopN] = useState(initial);
+ useEffect(() => {
+ if (topN == null) return;
+ if (!topNOptions.includes(topN)) setTopN(null);
+ }, [topNOptions, topN]);
+ return { topN, setTopN, topNOptions };
+}