/** * Choropleth scale for the trial map — pure functions, unit-tested. * * Class breaks use QUANTILES (equal-count classes) rather than equal intervals or a log scale: * trial-site counts are extremely skewed (the United States hosts about half of all registered * sites; the median country has a few dozen), so equal intervals would put every country but one * in the first class, and a log scale hides the difference between 1 and 30 sites, which matters * for low- and middle-income countries. Quantiles guarantee each of the 5 classes has roughly * the same number of countries; the legend shows the actual value range of every class so the * reader is never asked to infer values from colour alone. */ export const MAP_CLASS_COUNT = 5; /** Sequential teal ramp (light → dark), legible on the off-white paper and on dark surfaces; class 0 is the lightest. */ export const MAP_RAMP: readonly string[] = ['#e2eeee', '#b5d3d4', '#7fb1b3', '#3f8286', '#0b4a4d']; /** Ink colour that stays legible on each ramp step (used for optional in-map labels). */ export const MAP_RAMP_INK: readonly string[] = ['#1c1c1a', '#1c1c1a', '#1c1c1a', '#fafaf7', '#fafaf7']; /** Fill for polygons with no data at all (never confused with class 0, which always has ≥ 1). */ export const MAP_NO_DATA_FILL = 'var(--color-paper-3)'; export interface MapClass { /** 0-based class index (0 = lightest). */ index: number; /** Inclusive lower bound of the class (actual minimum value present in the class). */ lo: number; /** Inclusive upper bound of the class (actual maximum value present in the class). */ hi: number; /** Number of items in the class. */ n: number; fill: string; } export interface MapScale { method: 'quantile'; /** Upper thresholds of classes 0..k-2 (a value v belongs to the first class i with v <= breaks[i]; otherwise the last class). */ breaks: number[]; classes: MapClass[]; } /** Quantile of a SORTED ascending array (linear interpolation, R-7 like d3.quantile). */ export function quantileSorted(sorted: readonly number[], p: number): number { const n = sorted.length; if (n === 0) return NaN; if (p <= 0) return sorted[0]!; if (p >= 1) return sorted[n - 1]!; const i = (n - 1) * p; const i0 = Math.floor(i); const v0 = sorted[i0]!; const v1 = sorted[Math.min(n - 1, i0 + 1)]!; return v0 + (v1 - v0) * (i - i0); } /** * Build a quantile scale over positive values (zeros/negatives/non-finite are ignored: countries * with no sites are "no data", not class 0). Degenerate inputs (few distinct values) collapse * duplicate thresholds so classes never overlap; empty input yields no classes. */ export function quantileScale(values: readonly number[], k = MAP_CLASS_COUNT): MapScale { const sorted = values.filter((v) => Number.isFinite(v) && v > 0).sort((a, b) => a - b); if (sorted.length === 0) return { method: 'quantile', breaks: [], classes: [] }; const raw: number[] = []; for (let i = 1; i < k; i++) raw.push(Math.ceil(quantileSorted(sorted, i / k))); // Distinct, strictly increasing thresholds (integer counts → ceil keeps "v <= break" meaningful). const breaks = raw.filter((b, i) => i === 0 || b > raw[i - 1]!).filter((b) => b < sorted[sorted.length - 1]!); const buckets: number[][] = Array.from({ length: breaks.length + 1 }, () => []); for (const v of sorted) buckets[classIndex(v, breaks)]!.push(v); const filled = buckets.filter((b) => b.length > 0); const classes: MapClass[] = filled.map((b, i) => ({ index: i, lo: b[0]!, hi: b[b.length - 1]!, n: b.length, fill: rampColor(i, filled.length) })); // Final thresholds are the observed class maxima, so `classIndex(v, breaks)` and `classes[i]` agree exactly. return { method: 'quantile', breaks: classes.slice(0, -1).map((c) => c.hi), classes }; } /** Class index for a value: first i with value <= breaks[i], else breaks.length. */ export function classIndex(value: number, breaks: readonly number[]): number { for (let i = 0; i < breaks.length; i++) if (value <= breaks[i]!) return i; return breaks.length; } /** Ramp colour for class i of n (n ≤ 5 spreads across the ramp so the darkest step is always used). */ export function rampColor(i: number, n: number): string { if (n <= 1) return MAP_RAMP[MAP_RAMP.length - 1]!; const pos = Math.round((i / (n - 1)) * (MAP_RAMP.length - 1)); return MAP_RAMP[Math.max(0, Math.min(MAP_RAMP.length - 1, pos))]!; } /** Fill for a value under a scale; `MAP_NO_DATA_FILL` when the value is absent or ≤ 0. */ export function fillFor(value: number | null | undefined, scale: MapScale): string { if (value == null || !Number.isFinite(value) || value <= 0 || scale.classes.length === 0) return MAP_NO_DATA_FILL; return scale.classes[classIndex(value, scale.breaks)]?.fill ?? MAP_NO_DATA_FILL; } /** "1–12", "13–80", "608,226" — legend label for a class. */ export function classLabel(c: MapClass, fmt: (n: number) => string = String): string { return c.lo === c.hi ? fmt(c.lo) : `${fmt(c.lo)}–${fmt(c.hi)}`; } /** Radius (px) for a proportional-symbol dot: area ∝ value, clamped to [min, max]. */ export function sqrtRadius(value: number, maxValue: number, maxRadius = 14, minRadius = 1.5): number { if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(maxValue) || maxValue <= 0) return 0; return Math.max(minRadius, Math.min(maxRadius, Math.sqrt(value / maxValue) * maxRadius)); }