'use client'; import { ArrowDownWideNarrow, ArrowUpNarrowWide, BarChart3, Map as MapIcon, Search, SlidersHorizontal, Table2, X } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { cn } from '@/lib/cn'; import { compact, displayValue, formatValue, grouped, isNum, ordinal } from '@/lib/format'; import { INCOME_GROUPS } from '@/lib/regions'; import { MIN_COV_OPTIONS, MIN_POP_OPTIONS, rankingQuery, type RankingState, type RankingView as ViewKind } from '@/lib/ranking-state'; import { routes } from '@/lib/site'; import type { RankingResponse, RankingRow } from '@/lib/types'; import type { CountryLite, RegionItem } from '@/lib/types-compare'; import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view'; import { MARK } from '@/components/charts/palette'; import { RankedBars, rankedRowFromCountry, staleYear } from '@/components/charts/ranked-bars'; import { Segmented } from '@/components/controls/indicator-select'; import type { BaseFeature } from '@/components/indicators/indicator-map'; import { pointsFromSpark } from '@/components/charts/scales'; import { Sparkline } from '@/components/charts/sparkline'; import { CountryTypeahead } from '@/components/compare/country-picker'; import { BottomSheet } from '@/components/data/bottom-sheet'; import { ChangeChip } from '@/components/data/change-chip'; import { useProvenance, type ProvenancePayload } from '@/components/data/provenance-context'; const PAGE = 25; const GROUP_KINDS = ['region', 'continent', 'income', 'org'] as const; /** * Interactive ranking: controls (year · group · sort · highlight · search; a compact sticky bar + bottom * sheet on phones), the ranked list (rank, flag+name, bar, value, 1 y / 10 y change, sparkline), 25 rows per * page with "Show more". Year/group/sort/highlight/q live in the URL (server re-renders the data). */ export function RankingView({ data, regions, countries, state, geometry, sphere }: { data: RankingResponse & { label?: string }; regions: RegionItem[]; countries: CountryLite[]; state: RankingState; geometry?: BaseFeature[]; sphere?: string }) { const router = useRouter(); const pathname = usePathname(); const { open } = useProvenance(); const [q, setQ] = useState(state.q); const [shown, setShown] = useState(PAGE); const [sheet, setSheet] = useState(false); const [jump, setJump] = useState(false); const listRef = useRef(null); const ind = data.indicator; const hib = ind.higher_is_better; const sort = (data.sort as 'asc' | 'desc') ?? 'desc'; const setState = useCallback( (patch: Partial) => router.replace(`${pathname}${rankingQuery({ ...state, ...patch })}`, { scroll: false }), [router, pathname, state], ); // Search is client-side; keep the URL in sync (debounced) so a reload restores it. useEffect(() => { if (q === state.q) return; const id = setTimeout(() => setState({ q }), 400); return () => clearTimeout(id); }, [q, state.q, setState]); // Client-side filters (income group · minimum population · minimum coverage) then re-rank within the result. const popById = useMemo(() => new Map(countries.map((c) => [c.id, c.population ?? null])), [countries]); const incomeCode = state.income ? INCOME_GROUPS.find((g) => g.slug === state.income || g.id.toLowerCase() === state.income)?.id ?? null : null; const rows = useMemo(() => { let r = data.rows; if (incomeCode) r = r.filter((x) => (x.country.income ?? '').toUpperCase() === incomeCode); if (state.minpop) r = r.filter((x) => (popById.get(x.country.id) ?? 0) >= state.minpop!); if (state.mincov && data.year_used != null) r = r.filter((x) => (x.year ?? 0) >= data.year_used! - state.mincov!); if (r.length !== data.rows.length) r = r.map((x, i) => ({ ...x, rank: i + 1 })); return r; }, [data.rows, data.year_used, incomeCode, state.minpop, state.mincov, popById]); // Freshness honesty: rows ≥ 2 years older than the ranking year show their year next to the value. const refYear = useMemo(() => Math.max(data.year_used ?? 0, ...rows.map((r) => r.year ?? 0)), [rows, data.year_used]); const max = useMemo(() => Math.max(0, ...rows.map((r) => (isNum(r.value) ? Math.abs(r.value) : 0))), [rows]); const ql = q.trim().toLowerCase(); const filtered = useMemo(() => (ql ? rows.filter((r) => (r.country.name ?? '').toLowerCase().includes(ql) || r.country.id.toLowerCase() === ql || (r.country.region_name ?? '').toLowerCase().includes(ql)) : rows), [rows, ql]); const highlightRow = state.highlight ? rows.find((r) => (r.country.slug ?? r.country.id.toLowerCase()) === state.highlight) ?? null : null; const highlightCountry = state.highlight ? countries.find((c) => c.slug === state.highlight) ?? null : null; const visible = filtered.slice(0, shown); // Scroll to the highlighted row after a user-initiated highlight. useEffect(() => { if (!jump || !highlightRow) return; const idx = filtered.findIndex((r) => r.country.id === highlightRow.country.id); if (idx >= shown) setShown(Math.ceil((idx + 1) / PAGE) * PAGE); const el = listRef.current?.querySelector(`[data-country="${highlightRow.country.id}"]`); if (el) { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); setJump(false); } }, [jump, highlightRow, filtered, shown]); const payloadOf = (r: RankingRow): ProvenancePayload => ({ indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, higher_is_better: hib }, value: { value: r.value, formatted: r.formatted, period: r.year ? `${r.year}-01-01` : null, year: r.year, unit: ind.unit, provenance: r.provenance }, country: { id: r.country.id, slug: r.country.slug, name: r.country.name ?? r.country.id, flag: r.country.flag }, downloadHref: routes.indicatorDownload(ind.slug), }); const groupsByKind = GROUP_KINDS.map((k) => ({ kind: k, items: regions.filter((g) => g.kind === k).sort((a, b) => a.name.localeCompare(b.name)) })).filter((g) => g.items.length); const activeFilters = (state.group !== 'world' ? 1 : 0) + (state.sort ? 1 : 0) + (state.highlight ? 1 : 0) + (state.income ? 1 : 0) + (state.minpop ? 1 : 0) + (state.mincov ? 1 : 0); const years = [...data.years_available].sort((a, b) => b - a); const yearSelect = ( ); const groupSelect = ( ); const sortToggle = ( ); const incomeSelect = ( ); const minPopSelect = ( ); const minCovSelect = ( ); const viewSwitch = ( value={state.view} onChange={(v) => setState({ view: v })} label={t('control.view')} size="sm" options={[{ value: 'table', label: t('ranking.view.table'), icon: }, { value: 'bars', label: t('ranking.view.bars'), icon: }, { value: 'map', label: t('ranking.view.map'), icon: }]} /> ); const highlightControl = highlightCountry ? ( {highlightCountry.flag} {highlightCountry.name} ) : ( { setState({ highlight: c.slug }); setJump(true); }} placeholder={t('ranking.highlight.placeholder')} className="w-full sm:w-56" /> ); const searchBox = (
{ setQ(e.target.value); setShown(PAGE); }} placeholder={t('ranking.search')} aria-label={t('ranking.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-9" /> {q ? ( ) : null}
); const rankNote = hib == null ? (sort === 'desc' ? t('ranking.rankNote.higher') : t('ranking.rankNote.lowest')) : t('ranking.rankNote.better', { direction: hib ? t('ranking.direction.higher') : t('ranking.direction.lower') }); return (
{/* Sticky compact bar (phones) */}
{yearSelect} {searchBox}
{/* Desktop controls */}
{yearSelect} {groupSelect} {incomeSelect} {minPopSelect} {minCovSelect} {sortToggle} {highlightControl}
{viewSwitch}
{searchBox}
{viewSwitch}
{rankNote} {t('ranking.showing', { shown: grouped(Math.min(shown, filtered.length)), n: grouped(filtered.length) })} {data.year != null && data.year_used != null && data.year !== data.year_used ? ` · ${t('ranking.yearUsed', { year: data.year_used, requested: data.year })}` : ''}
{/* Pinned highlighted row */} {highlightRow ? (
open(payloadOf(highlightRow))} />
) : state.highlight && highlightCountry ? (

{t('ranking.highlight.notInGroup', { name: highlightCountry.name, year: data.year_used ?? '' })}

) : null} {state.view === 'bars' ? (
rankedRowFromCountry(r.country, r.value, r.rank, r.change_10y?.formatted ?? null, staleYear(r.year, refYear)))} spec={ind} highlightId={highlightRow?.country.id ?? null} provenance={filtered[0]?.provenance ?? null} /> {filtered.length > 25 ?

{t('ranking.view.barsNote', { n: 25, total: filtered.length })}

: null}
) : null} {state.view === 'map' ? ( geometry && sphere ? ( ) : (

{t('ranking.map.none')}

) ) : null} {/* Header (sm+) */}
{t('ranking.col.rank')} {t('ranking.col.country')} {t('ranking.col.value')} {t('ranking.col.change1y')} {t('ranking.col.change10y')} {t('ranking.col.trend')}
{filtered.length === 0 ?

{ql ? t('ranking.noMatch', { q }) : t('ranking.empty')}

: null}
    {visible.map((r) => ( open(payloadOf(r))} /> ))}
{state.view === 'table' && filtered.length > shown ? (
) : null} setSheet(false)} side="center" title={t('ranking.filters')}>
{t('ranking.group')}
{groupSelect}
{t('common.income')}
{incomeSelect}
{minPopSelect} {minCovSelect}
{t('ranking.sort')}
{sortToggle}
{t('ranking.highlight')}
{highlightControl}
); } function Row({ r, max, spec, refYear, highlight, onValue }: { r: RankingRow; max: number; spec: RankingResponse['indicator']; refYear: number; highlight?: boolean; onValue: () => void }) { const stale = staleYear(r.year, refYear); const pct = isNum(r.value) && max > 0 ? Math.max(0, (Math.abs(r.value) / max) * 100) : 0; const points = pointsFromSpark(r.sparkline); const dir = r.change_1y?.abs == null ? null : r.change_1y.abs > 0 ? 'up' : r.change_1y.abs < 0 ? 'down' : 'flat'; const value = displayValue(r.value, spec, r.formatted); const worldRank = isNum(r.rank_world) && isNum(r.n_world) ? t('ranking.worldRank', { rank: ordinal(r.rank_world), n: grouped(r.n_world) }) : null; return (
  • {r.rank} {r.country.flag} {r.country.name} {worldRank ?? r.country.region_name ?? ''}
    {points.length >= 2 ? : }
    {/* Phone: bar + changes + sparkline on a second line */}
    {points.length >= 2 ? : null}
  • ); } /** Map view of the (filtered) ranking rows: quantile classes computed client-side, highlighted country outlined. */ function RankingMap({ rows, geometry, sphere, spec, year, highlight }: { rows: RankingRow[]; geometry: BaseFeature[]; sphere: string; spec: RankingResponse['indicator']; year: number | null; highlight: string | null }) { const model = useMemo(() => { const values = new Map(rows.filter((r) => isNum(r.value)).map((r) => [r.country.id, r.value as number])); const sorted = Array.from(values.values()).sort((a, b) => a - b); const k = sorted.length >= 40 ? 6 : Math.max(3, Math.min(5, sorted.length)); const breaks: number[] = []; for (let i = 1; i < k; i++) { const pos = (i / k) * (sorted.length - 1); const lo = Math.floor(pos); const hi = Math.min(lo + 1, sorted.length - 1); const v = sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (pos - lo); if (!breaks.length || v > breaks[breaks.length - 1]!) breaks.push(v); } const features: ChoroplethFeature[] = geometry.map((g) => { const v = g.iso3 ? values.get(g.iso3) : undefined; return { ...g, value: v ?? null, cls: v != null ? classFor(v, breaks) : null }; }); const fs = { format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, name: spec.short_name ?? spec.name }; return { features, legend: legendFromBreaks(breaks, sorted[0] ?? null, sorted[sorted.length - 1] ?? null, fs), k: breaks.length + 1, fs, n: values.size, min: sorted[0], max: sorted[sorted.length - 1] }; }, [rows, geometry, spec]); return (
    ); }