/** * URL contract of `/rankings/[indicator]`: * ?year=2020 ranking year (default: latest; the API falls back to the nearest available year) * &group=oecd group slug (world · regions · continents · income groups · organisations); default world * &income=high-income income-group slug applied client-side on top of the group (hic/umc/lmc/lic slugs) * &minpop=1000000 minimum population (client-side; 0/absent = none) * &mincov=2 minimum coverage: rows whose year ≥ ranking year − N (client-side) * &view=table|bars|map presentation (default table) * &sort=asc|desc default = the API's ("asc" when lower is better, else "desc") * &highlight=canada country slug pinned at the top of the list * &q=fra text filter within the ranking * &history=CAN,USA,FRA countries of the rank-over-time panel (≤ 5 ISO3) * Canonical URL = path + `group` (when not world); everything else is volatile. */ export type RankingView = 'table' | 'bars' | 'map'; export const RANKING_VIEWS: readonly RankingView[] = ['table', 'bars', 'map']; export const MIN_POP_OPTIONS = [0, 1_000_000, 5_000_000, 10_000_000, 50_000_000] as const; export const MIN_COV_OPTIONS = [0, 1, 2, 5] as const; export interface RankingState { year: number | null; group: string; income: string | null; minpop: number | null; mincov: number | null; view: RankingView; sort: 'asc' | 'desc' | null; highlight: string | null; q: string; history: string[]; } type ParamsLike = { get(name: string): string | null } | Record; function read(params: ParamsLike, key: string): string | null { if (typeof (params as { get?: unknown }).get === 'function') return (params as { get(name: string): string | null }).get(key); const v = (params as Record)[key]; return Array.isArray(v) ? (v[0] ?? null) : (v ?? null); } export function parseRankingState(params: ParamsLike): RankingState { const y = Number(read(params, 'year')); const sort = read(params, 'sort'); const group = (read(params, 'group') ?? 'world').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'world'; const income = (read(params, 'income') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null; const minpop = Number(read(params, 'minpop')); const mincov = Number(read(params, 'mincov')); const viewRaw = read(params, 'view'); const highlight = (read(params, 'highlight') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null; const history = (read(params, 'history') ?? '') .split(',') .map((s) => s.trim().toUpperCase()) .filter((s) => /^[A-Z]{3}$/.test(s)) .slice(0, 5); return { year: Number.isInteger(y) && y >= 1800 && y <= 2100 ? y : null, group, income, minpop: Number.isFinite(minpop) && minpop > 0 ? minpop : null, mincov: Number.isFinite(mincov) && mincov > 0 ? mincov : null, view: (RANKING_VIEWS as readonly string[]).includes(viewRaw ?? '') ? (viewRaw as RankingView) : 'table', sort: sort === 'asc' || sort === 'desc' ? sort : null, highlight, q: (read(params, 'q') ?? '').slice(0, 60), history, }; } export function rankingQuery(state: Partial): string { const p = new URLSearchParams(); if (state.year != null) p.set('year', String(state.year)); if (state.group && state.group !== 'world') p.set('group', state.group); if (state.income) p.set('income', state.income); if (state.minpop) p.set('minpop', String(state.minpop)); if (state.mincov) p.set('mincov', String(state.mincov)); if (state.view && state.view !== 'table') p.set('view', state.view); if (state.sort) p.set('sort', state.sort); if (state.highlight) p.set('highlight', state.highlight); if (state.q) p.set('q', state.q); if (state.history?.length) p.set('history', state.history.join(',')); const s = p.toString(); return s ? `?${s}` : ''; }