spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { COMPARE_UI_MODES, type CompareUiMode } from './types-compare';2import type { TopicId } from './types';34/**5 * URL contract of `/compare/[...slugs]` — every piece of view state lives in the query string so a view is6 * shareable and survives a reload:7 *8 * /compare/canada/united-states/france9 * ?tab=snapshot|economy|population|health|energy|climate|digital|housing|custom (default snapshot = Overview)10 * &indicator=gdp-per-capita hero chart at the top of a topic tab (also switches to that tab)11 * &indicators=a,b,c custom tab only, ≤ 6 slugs12 * &from=1990&to=2025 year range (omitted = full history)13 * &mode=absolute|per-capita|index100|pct|percentile|change (default absolute; percentile/change are client-side)14 * &log=1 log y-scale (charts only)15 *16 * Canonical URL = path + `tab` (when not snapshot); everything else is volatile.17 */18export const COMPARE_TOPIC_TABS = ['economy', 'population', 'health', 'energy', 'climate', 'digital', 'housing'] as const satisfies readonly TopicId[];19export type CompareTopicTab = (typeof COMPARE_TOPIC_TABS)[number];20export type CompareTab = 'snapshot' | CompareTopicTab | 'custom';21export const COMPARE_TABS: readonly CompareTab[] = ['snapshot', ...COMPARE_TOPIC_TABS, 'custom'];2223export const MAX_COMPARE_COUNTRIES = 8;24export const MIN_COMPARE_COUNTRIES = 2;25export const MAX_CUSTOM_INDICATORS = 6;2627export interface CompareState {28 tab: CompareTab;29 indicator: string | null;30 indicators: string[];31 from: number | null;32 to: number | null;33 mode: CompareUiMode;34 log: boolean;35}3637export const DEFAULT_COMPARE_STATE: CompareState = { tab: 'snapshot', indicator: null, indicators: [], from: null, to: null, mode: 'absolute', log: false };3839type ParamsLike = { get(name: string): string | null } | Record<string, string | string[] | undefined>;4041function read(params: ParamsLike, key: string): string | null {42 if (typeof (params as { get?: unknown }).get === 'function') return (params as { get(name: string): string | null }).get(key);43 const v = (params as Record<string, string | string[] | undefined>)[key];44 return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);45}4647function yearOf(v: string | null): number | null {48 if (!v) return null;49 const n = Number(v);50 return Number.isInteger(n) && n >= 1800 && n <= 2100 ? n : null;51}5253const SLUG = /^[a-z0-9][a-z0-9-]*$/;5455export function isCompareTab(v: string | null | undefined): v is CompareTab {56 return !!v && (COMPARE_TABS as readonly string[]).includes(v);57}5859/** Parse from `useSearchParams()` (client) or the `searchParams` prop (server). Invalid values fall back silently. */60export function parseCompareState(params: ParamsLike): CompareState {61 const tabRaw = read(params, 'tab');62 const indicator = (read(params, 'indicator') ?? '').toLowerCase();63 const indicators = (read(params, 'indicators') ?? '')64 .split(',')65 .map((s) => s.trim().toLowerCase())66 .filter((s) => SLUG.test(s))67 .slice(0, MAX_CUSTOM_INDICATORS);68 let tab: CompareTab = isCompareTab(tabRaw) ? tabRaw : 'snapshot';69 if (tab === 'snapshot' && (indicators.length || indicator)) tab = indicators.length ? 'custom' : tab;70 const modeRaw = read(params, 'mode');71 const mode = (COMPARE_UI_MODES as readonly string[]).includes(modeRaw ?? '') ? (modeRaw as CompareUiMode) : 'absolute';72 let from = yearOf(read(params, 'from'));73 let to = yearOf(read(params, 'to'));74 if (from != null && to != null && from > to) [from, to] = [to, from];75 return { tab, indicator: SLUG.test(indicator) ? indicator : null, indicators, from, to, mode, log: read(params, 'log') === '1' };76}7778/** Serialize back to a query string (empty string when everything is default). Keys are emitted in a stable order. */79export function compareQuery(state: Partial<CompareState>): string {80 const p = new URLSearchParams();81 if (state.tab && state.tab !== 'snapshot') p.set('tab', state.tab);82 if (state.indicator) p.set('indicator', state.indicator);83 if (state.indicators?.length) p.set('indicators', state.indicators.join(','));84 if (state.from != null) p.set('from', String(state.from));85 if (state.to != null) p.set('to', String(state.to));86 if (state.mode && state.mode !== 'absolute') p.set('mode', state.mode);87 if (state.log) p.set('log', '1');88 const s = p.toString();89 return s ? `?${s}` : '';90}9192/** Canonical (indexable) query: only the tab. */93export function compareCanonicalQuery(state: CompareState): string {94 return state.tab !== 'snapshot' && state.tab !== 'custom' ? `?tab=${state.tab}` : '';95}9697/** Split `/compare/a/b/c` segments into clean slugs (also accepts `a,b,c` in one segment and ISO3 codes). */98export function splitCompareSlugs(segments: string[]): string[] {99 const out: string[] = [];100 for (const seg of segments) {101 for (const part of decodeURIComponent(seg).split(',')) {102 const s = part.trim().toLowerCase();103 if (s && /^[a-z0-9-]+$/.test(s) && !out.includes(s)) out.push(s);104 }105 }106 return out.slice(0, MAX_COMPARE_COUNTRIES);107}108