/** * Data explorer URL state (§ "Our World in Data"-style explorer). Pure: shared by the /explore page, * the CSV route and unit tests. Defaults that depend on the database (top cancers, year range, * available metric/geography) are injected by the caller — nothing is hardcoded here. */ import type { SP } from '@/lib/search-params'; export const MAX_CANCERS = 6; export const TABLE_PAGE_SIZE = 50; export const VIEWS = ['lines', 'multiples'] as const; export type ExplorerView = (typeof VIEWS)[number]; export const NORMALIZE = ['none'] as const; export type ExplorerNormalize = (typeof NORMALIZE)[number]; /** "any" = no sex filter: series become cancer × sex inside a comparable group. */ export const SEX_ANY = 'any'; export interface ExplorerState { metric: string; cancers: string[]; // cancer slugs (or CI-CAN ids), ≤ MAX_CANCERS, deduplicated, order kept geography: string; // geography slug or ISO3 sex: string; // all | male | female | any age: string; // age group label, 'all' by default from: number | null; to: number | null; view: ExplorerView; normalize: ExplorerNormalize; page: number; // observations table page (1-based) } export interface ExplorerDefaults { metric: string; geography: string; cancers: string[]; sex?: string; age?: string; from?: number | null; to?: number | null; } const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,99}$/; const CI_ID_RE = /^CI-CAN-\d{8}$/i; const METRIC_RE = /^[a-z][a-z0-9_]{1,63}$/; const GEO_RE = /^[a-z0-9][a-z0-9-]{0,99}$/i; const AGE_RE = /^[A-Za-z0-9+_\- ]{1,24}$/; // e.g. "all", "0-14", "65+", "85 and over" const SEX_RE = /^[a-z_]{1,16}$/; const YEAR_MIN = 1900; const YEAR_MAX = 2100; function first(v: string | string[] | undefined): string { const s = Array.isArray(v) ? v[0] : v; return (s ?? '').toString().trim(); } /** * Cancer references from `cancers=a,b,c`, repeated `cancers=a&cancers=b`, or a mix (checkboxes + a free * comma-separated input share the same name). Lower-cases slugs, upper-cases CI ids, drops invalid tokens * and duplicates, keeps at most `max` in the order given. */ export function parseCancerList(v: string | string[] | undefined, max = MAX_CANCERS): string[] { const raw = (Array.isArray(v) ? v : v == null ? [] : [v]).flatMap((s) => String(s).split(/[,\s]+/)); const out: string[] = []; for (const tok of raw) { const t = tok.trim(); if (!t) continue; const norm = CI_ID_RE.test(t) ? t.toUpperCase() : t.toLowerCase(); if (!CI_ID_RE.test(norm) && !SLUG_RE.test(norm)) continue; if (out.includes(norm)) continue; out.push(norm); if (out.length >= max) break; } return out; } function parseYear(v: string, fallback: number | null): number | null { if (v === '') return fallback; const n = Number.parseInt(v, 10); if (!Number.isFinite(n) || n < YEAR_MIN || n > YEAR_MAX) return fallback; return n; } /** Parse the search params into a complete state, filling gaps from the (database-computed) defaults. */ export function parseExplorerParams(sp: SP, d: ExplorerDefaults): ExplorerState { const metricRaw = first(sp.metric).toLowerCase(); const geoRaw = first(sp.geography); const sexRaw = first(sp.sex).toLowerCase(); const ageRaw = first(sp.age); const viewRaw = first(sp.view).toLowerCase(); const normRaw = first(sp.normalize).toLowerCase(); const cancers = parseCancerList(sp.cancers); let from = parseYear(first(sp.from), d.from ?? null); let to = parseYear(first(sp.to), d.to ?? null); if (from != null && to != null && from > to) [from, to] = [to, from]; const pageN = Number.parseInt(first(sp.page), 10); return { metric: METRIC_RE.test(metricRaw) ? metricRaw : d.metric, cancers: cancers.length > 0 ? cancers : d.cancers.slice(0, MAX_CANCERS), geography: GEO_RE.test(geoRaw) ? (geoRaw.length === 3 ? geoRaw.toUpperCase() : geoRaw.toLowerCase()) : d.geography, sex: SEX_RE.test(sexRaw) ? sexRaw : (d.sex ?? 'all'), age: AGE_RE.test(ageRaw) ? ageRaw : (d.age ?? 'all'), from, to, view: (VIEWS as readonly string[]).includes(viewRaw) ? (viewRaw as ExplorerView) : 'lines', normalize: (NORMALIZE as readonly string[]).includes(normRaw) ? (normRaw as ExplorerNormalize) : 'none', page: Number.isFinite(pageN) && pageN > 1 ? Math.min(pageN, 100_000) : 1, }; } /** * Serialize a state to a query string. Every dimension is written explicitly so a permalink is * self-describing and stable when the computed defaults change; `view`, `normalize` and `page` are * omitted at their default values. `cancers` is comma-separated. */ export function serializeExplorerParams(s: ExplorerState, overrides: Partial = {}): string { const m = { ...s, ...overrides }; const qs = new URLSearchParams(); qs.set('metric', m.metric); if (m.cancers.length > 0) qs.set('cancers', m.cancers.join(',')); qs.set('geography', m.geography); qs.set('sex', m.sex); qs.set('age', m.age); if (m.from != null) qs.set('from', String(m.from)); if (m.to != null) qs.set('to', String(m.to)); if (m.view !== 'lines') qs.set('view', m.view); if (m.normalize !== 'none') qs.set('normalize', m.normalize); if (m.page > 1) qs.set('page', String(m.page)); return `?${qs.toString()}`; } /** Same filters expressed for the public API (`/api/v1/epidemiology`) — repeated `cancer=` params, `from`/`to`. */ export function apiQueryFor(s: ExplorerState, limit = 200): string { const qs = new URLSearchParams(); qs.set('metric', s.metric); for (const c of s.cancers) qs.append('cancer', c); qs.set('geography', s.geography); if (s.sex !== SEX_ANY) qs.set('sex', s.sex); qs.set('age', s.age); if (s.from != null) qs.set('from', String(s.from)); if (s.to != null) qs.set('to', String(s.to)); qs.set('limit', String(limit)); return `?${qs.toString()}`; } /** Year label for headers and captions: "1999–2024", "2024" or "all years". */ export function yearRangeLabel(from: number | null, to: number | null): string { if (from != null && to != null) return from === to ? String(from) : `${from}–${to}`; if (from != null) return `from ${from}`; if (to != null) return `to ${to}`; return 'all years'; } export function sexLabel(sex: string): string { switch (sex) { case 'all': return 'both sexes'; case 'male': return 'male'; case 'female': return 'female'; case SEX_ANY: return 'by sex'; default: return sex.replace(/_/g, ' '); } } export function ageLabel(age: string): string { return age === 'all' ? 'all ages' : `ages ${age}`; }