spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Data explorer URL state (§ "Our World in Data"-style explorer). Pure: shared by the /explore page,3 * the CSV route and unit tests. Defaults that depend on the database (top cancers, year range,4 * available metric/geography) are injected by the caller — nothing is hardcoded here.5 */6import type { SP } from '@/lib/search-params';78export const MAX_CANCERS = 6;9export const TABLE_PAGE_SIZE = 50;10export const VIEWS = ['lines', 'multiples'] as const;11export type ExplorerView = (typeof VIEWS)[number];12export const NORMALIZE = ['none'] as const;13export type ExplorerNormalize = (typeof NORMALIZE)[number];14/** "any" = no sex filter: series become cancer × sex inside a comparable group. */15export const SEX_ANY = 'any';1617export interface ExplorerState {18 metric: string;19 cancers: string[]; // cancer slugs (or CI-CAN ids), ≤ MAX_CANCERS, deduplicated, order kept20 geography: string; // geography slug or ISO321 sex: string; // all | male | female | any22 age: string; // age group label, 'all' by default23 from: number | null;24 to: number | null;25 view: ExplorerView;26 normalize: ExplorerNormalize;27 page: number; // observations table page (1-based)28}2930export interface ExplorerDefaults {31 metric: string;32 geography: string;33 cancers: string[];34 sex?: string;35 age?: string;36 from?: number | null;37 to?: number | null;38}3940const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,99}$/;41const CI_ID_RE = /^CI-CAN-\d{8}$/i;42const METRIC_RE = /^[a-z][a-z0-9_]{1,63}$/;43const GEO_RE = /^[a-z0-9][a-z0-9-]{0,99}$/i;44const AGE_RE = /^[A-Za-z0-9+_\- ]{1,24}$/; // e.g. "all", "0-14", "65+", "85 and over"45const SEX_RE = /^[a-z_]{1,16}$/;46const YEAR_MIN = 1900;47const YEAR_MAX = 2100;4849function first(v: string | string[] | undefined): string {50 const s = Array.isArray(v) ? v[0] : v;51 return (s ?? '').toString().trim();52}5354/**55 * Cancer references from `cancers=a,b,c`, repeated `cancers=a&cancers=b`, or a mix (checkboxes + a free56 * comma-separated input share the same name). Lower-cases slugs, upper-cases CI ids, drops invalid tokens57 * and duplicates, keeps at most `max` in the order given.58 */59export function parseCancerList(v: string | string[] | undefined, max = MAX_CANCERS): string[] {60 const raw = (Array.isArray(v) ? v : v == null ? [] : [v]).flatMap((s) => String(s).split(/[,\s]+/));61 const out: string[] = [];62 for (const tok of raw) {63 const t = tok.trim();64 if (!t) continue;65 const norm = CI_ID_RE.test(t) ? t.toUpperCase() : t.toLowerCase();66 if (!CI_ID_RE.test(norm) && !SLUG_RE.test(norm)) continue;67 if (out.includes(norm)) continue;68 out.push(norm);69 if (out.length >= max) break;70 }71 return out;72}7374function parseYear(v: string, fallback: number | null): number | null {75 if (v === '') return fallback;76 const n = Number.parseInt(v, 10);77 if (!Number.isFinite(n) || n < YEAR_MIN || n > YEAR_MAX) return fallback;78 return n;79}8081/** Parse the search params into a complete state, filling gaps from the (database-computed) defaults. */82export function parseExplorerParams(sp: SP, d: ExplorerDefaults): ExplorerState {83 const metricRaw = first(sp.metric).toLowerCase();84 const geoRaw = first(sp.geography);85 const sexRaw = first(sp.sex).toLowerCase();86 const ageRaw = first(sp.age);87 const viewRaw = first(sp.view).toLowerCase();88 const normRaw = first(sp.normalize).toLowerCase();89 const cancers = parseCancerList(sp.cancers);90 let from = parseYear(first(sp.from), d.from ?? null);91 let to = parseYear(first(sp.to), d.to ?? null);92 if (from != null && to != null && from > to) [from, to] = [to, from];93 const pageN = Number.parseInt(first(sp.page), 10);94 return {95 metric: METRIC_RE.test(metricRaw) ? metricRaw : d.metric,96 cancers: cancers.length > 0 ? cancers : d.cancers.slice(0, MAX_CANCERS),97 geography: GEO_RE.test(geoRaw) ? (geoRaw.length === 3 ? geoRaw.toUpperCase() : geoRaw.toLowerCase()) : d.geography,98 sex: SEX_RE.test(sexRaw) ? sexRaw : (d.sex ?? 'all'),99 age: AGE_RE.test(ageRaw) ? ageRaw : (d.age ?? 'all'),100 from,101 to,102 view: (VIEWS as readonly string[]).includes(viewRaw) ? (viewRaw as ExplorerView) : 'lines',103 normalize: (NORMALIZE as readonly string[]).includes(normRaw) ? (normRaw as ExplorerNormalize) : 'none',104 page: Number.isFinite(pageN) && pageN > 1 ? Math.min(pageN, 100_000) : 1,105 };106}107108/**109 * Serialize a state to a query string. Every dimension is written explicitly so a permalink is110 * self-describing and stable when the computed defaults change; `view`, `normalize` and `page` are111 * omitted at their default values. `cancers` is comma-separated.112 */113export function serializeExplorerParams(s: ExplorerState, overrides: Partial<ExplorerState> = {}): string {114 const m = { ...s, ...overrides };115 const qs = new URLSearchParams();116 qs.set('metric', m.metric);117 if (m.cancers.length > 0) qs.set('cancers', m.cancers.join(','));118 qs.set('geography', m.geography);119 qs.set('sex', m.sex);120 qs.set('age', m.age);121 if (m.from != null) qs.set('from', String(m.from));122 if (m.to != null) qs.set('to', String(m.to));123 if (m.view !== 'lines') qs.set('view', m.view);124 if (m.normalize !== 'none') qs.set('normalize', m.normalize);125 if (m.page > 1) qs.set('page', String(m.page));126 return `?${qs.toString()}`;127}128129/** Same filters expressed for the public API (`/api/v1/epidemiology`) — repeated `cancer=` params, `from`/`to`. */130export function apiQueryFor(s: ExplorerState, limit = 200): string {131 const qs = new URLSearchParams();132 qs.set('metric', s.metric);133 for (const c of s.cancers) qs.append('cancer', c);134 qs.set('geography', s.geography);135 if (s.sex !== SEX_ANY) qs.set('sex', s.sex);136 qs.set('age', s.age);137 if (s.from != null) qs.set('from', String(s.from));138 if (s.to != null) qs.set('to', String(s.to));139 qs.set('limit', String(limit));140 return `?${qs.toString()}`;141}142143/** Year label for headers and captions: "1999–2024", "2024" or "all years". */144export function yearRangeLabel(from: number | null, to: number | null): string {145 if (from != null && to != null) return from === to ? String(from) : `${from}–${to}`;146 if (from != null) return `from ${from}`;147 if (to != null) return `to ${to}`;148 return 'all years';149}150151export function sexLabel(sex: string): string {152 switch (sex) {153 case 'all':154 return 'both sexes';155 case 'male':156 return 'male';157 case 'female':158 return 'female';159 case SEX_ANY:160 return 'by sex';161 default:162 return sex.replace(/_/g, ' ');163 }164}165166export function ageLabel(age: string): string {167 return age === 'all' ? 'all ages' : `ages ${age}`;168}169