import { COMPARE_UI_MODES, type CompareUiMode } from './types-compare'; import type { TopicId } from './types'; /** * URL contract of `/compare/[...slugs]` — every piece of view state lives in the query string so a view is * shareable and survives a reload: * * /compare/canada/united-states/france * ?tab=snapshot|economy|population|health|energy|climate|digital|housing|custom (default snapshot = Overview) * &indicator=gdp-per-capita hero chart at the top of a topic tab (also switches to that tab) * &indicators=a,b,c custom tab only, ≤ 6 slugs * &from=1990&to=2025 year range (omitted = full history) * &mode=absolute|per-capita|index100|pct|percentile|change (default absolute; percentile/change are client-side) * &log=1 log y-scale (charts only) * * Canonical URL = path + `tab` (when not snapshot); everything else is volatile. */ export const COMPARE_TOPIC_TABS = ['economy', 'population', 'health', 'energy', 'climate', 'digital', 'housing'] as const satisfies readonly TopicId[]; export type CompareTopicTab = (typeof COMPARE_TOPIC_TABS)[number]; export type CompareTab = 'snapshot' | CompareTopicTab | 'custom'; export const COMPARE_TABS: readonly CompareTab[] = ['snapshot', ...COMPARE_TOPIC_TABS, 'custom']; export const MAX_COMPARE_COUNTRIES = 8; export const MIN_COMPARE_COUNTRIES = 2; export const MAX_CUSTOM_INDICATORS = 6; export interface CompareState { tab: CompareTab; indicator: string | null; indicators: string[]; from: number | null; to: number | null; mode: CompareUiMode; log: boolean; } export const DEFAULT_COMPARE_STATE: CompareState = { tab: 'snapshot', indicator: null, indicators: [], from: null, to: null, mode: 'absolute', log: false }; 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); } function yearOf(v: string | null): number | null { if (!v) return null; const n = Number(v); return Number.isInteger(n) && n >= 1800 && n <= 2100 ? n : null; } const SLUG = /^[a-z0-9][a-z0-9-]*$/; export function isCompareTab(v: string | null | undefined): v is CompareTab { return !!v && (COMPARE_TABS as readonly string[]).includes(v); } /** Parse from `useSearchParams()` (client) or the `searchParams` prop (server). Invalid values fall back silently. */ export function parseCompareState(params: ParamsLike): CompareState { const tabRaw = read(params, 'tab'); const indicator = (read(params, 'indicator') ?? '').toLowerCase(); const indicators = (read(params, 'indicators') ?? '') .split(',') .map((s) => s.trim().toLowerCase()) .filter((s) => SLUG.test(s)) .slice(0, MAX_CUSTOM_INDICATORS); let tab: CompareTab = isCompareTab(tabRaw) ? tabRaw : 'snapshot'; if (tab === 'snapshot' && (indicators.length || indicator)) tab = indicators.length ? 'custom' : tab; const modeRaw = read(params, 'mode'); const mode = (COMPARE_UI_MODES as readonly string[]).includes(modeRaw ?? '') ? (modeRaw as CompareUiMode) : 'absolute'; let from = yearOf(read(params, 'from')); let to = yearOf(read(params, 'to')); if (from != null && to != null && from > to) [from, to] = [to, from]; return { tab, indicator: SLUG.test(indicator) ? indicator : null, indicators, from, to, mode, log: read(params, 'log') === '1' }; } /** Serialize back to a query string (empty string when everything is default). Keys are emitted in a stable order. */ export function compareQuery(state: Partial): string { const p = new URLSearchParams(); if (state.tab && state.tab !== 'snapshot') p.set('tab', state.tab); if (state.indicator) p.set('indicator', state.indicator); if (state.indicators?.length) p.set('indicators', state.indicators.join(',')); if (state.from != null) p.set('from', String(state.from)); if (state.to != null) p.set('to', String(state.to)); if (state.mode && state.mode !== 'absolute') p.set('mode', state.mode); if (state.log) p.set('log', '1'); const s = p.toString(); return s ? `?${s}` : ''; } /** Canonical (indexable) query: only the tab. */ export function compareCanonicalQuery(state: CompareState): string { return state.tab !== 'snapshot' && state.tab !== 'custom' ? `?tab=${state.tab}` : ''; } /** Split `/compare/a/b/c` segments into clean slugs (also accepts `a,b,c` in one segment and ISO3 codes). */ export function splitCompareSlugs(segments: string[]): string[] { const out: string[] = []; for (const seg of segments) { for (const part of decodeURIComponent(seg).split(',')) { const s = part.trim().toLowerCase(); if (s && /^[a-z0-9-]+$/.test(s) && !out.includes(s)) out.push(s); } } return out.slice(0, MAX_COMPARE_COUNTRIES); }