spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Choropleth scale for the trial map — pure functions, unit-tested.3 *4 * Class breaks use QUANTILES (equal-count classes) rather than equal intervals or a log scale:5 * trial-site counts are extremely skewed (the United States hosts about half of all registered6 * sites; the median country has a few dozen), so equal intervals would put every country but one7 * in the first class, and a log scale hides the difference between 1 and 30 sites, which matters8 * for low- and middle-income countries. Quantiles guarantee each of the 5 classes has roughly9 * the same number of countries; the legend shows the actual value range of every class so the10 * reader is never asked to infer values from colour alone.11 */1213export const MAP_CLASS_COUNT = 5;1415/** Sequential teal ramp (light → dark), legible on the off-white paper and on dark surfaces; class 0 is the lightest. */16export const MAP_RAMP: readonly string[] = ['#e2eeee', '#b5d3d4', '#7fb1b3', '#3f8286', '#0b4a4d'];1718/** Ink colour that stays legible on each ramp step (used for optional in-map labels). */19export const MAP_RAMP_INK: readonly string[] = ['#1c1c1a', '#1c1c1a', '#1c1c1a', '#fafaf7', '#fafaf7'];2021/** Fill for polygons with no data at all (never confused with class 0, which always has ≥ 1). */22export const MAP_NO_DATA_FILL = 'var(--color-paper-3)';2324export interface MapClass {25 /** 0-based class index (0 = lightest). */26 index: number;27 /** Inclusive lower bound of the class (actual minimum value present in the class). */28 lo: number;29 /** Inclusive upper bound of the class (actual maximum value present in the class). */30 hi: number;31 /** Number of items in the class. */32 n: number;33 fill: string;34}3536export interface MapScale {37 method: 'quantile';38 /** Upper thresholds of classes 0..k-2 (a value v belongs to the first class i with v <= breaks[i]; otherwise the last class). */39 breaks: number[];40 classes: MapClass[];41}4243/** Quantile of a SORTED ascending array (linear interpolation, R-7 like d3.quantile). */44export function quantileSorted(sorted: readonly number[], p: number): number {45 const n = sorted.length;46 if (n === 0) return NaN;47 if (p <= 0) return sorted[0]!;48 if (p >= 1) return sorted[n - 1]!;49 const i = (n - 1) * p;50 const i0 = Math.floor(i);51 const v0 = sorted[i0]!;52 const v1 = sorted[Math.min(n - 1, i0 + 1)]!;53 return v0 + (v1 - v0) * (i - i0);54}5556/**57 * Build a quantile scale over positive values (zeros/negatives/non-finite are ignored: countries58 * with no sites are "no data", not class 0). Degenerate inputs (few distinct values) collapse59 * duplicate thresholds so classes never overlap; empty input yields no classes.60 */61export function quantileScale(values: readonly number[], k = MAP_CLASS_COUNT): MapScale {62 const sorted = values.filter((v) => Number.isFinite(v) && v > 0).sort((a, b) => a - b);63 if (sorted.length === 0) return { method: 'quantile', breaks: [], classes: [] };64 const raw: number[] = [];65 for (let i = 1; i < k; i++) raw.push(Math.ceil(quantileSorted(sorted, i / k)));66 // Distinct, strictly increasing thresholds (integer counts → ceil keeps "v <= break" meaningful).67 const breaks = raw.filter((b, i) => i === 0 || b > raw[i - 1]!).filter((b) => b < sorted[sorted.length - 1]!);68 const buckets: number[][] = Array.from({ length: breaks.length + 1 }, () => []);69 for (const v of sorted) buckets[classIndex(v, breaks)]!.push(v);70 const filled = buckets.filter((b) => b.length > 0);71 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) }));72 // Final thresholds are the observed class maxima, so `classIndex(v, breaks)` and `classes[i]` agree exactly.73 return { method: 'quantile', breaks: classes.slice(0, -1).map((c) => c.hi), classes };74}7576/** Class index for a value: first i with value <= breaks[i], else breaks.length. */77export function classIndex(value: number, breaks: readonly number[]): number {78 for (let i = 0; i < breaks.length; i++) if (value <= breaks[i]!) return i;79 return breaks.length;80}8182/** Ramp colour for class i of n (n ≤ 5 spreads across the ramp so the darkest step is always used). */83export function rampColor(i: number, n: number): string {84 if (n <= 1) return MAP_RAMP[MAP_RAMP.length - 1]!;85 const pos = Math.round((i / (n - 1)) * (MAP_RAMP.length - 1));86 return MAP_RAMP[Math.max(0, Math.min(MAP_RAMP.length - 1, pos))]!;87}8889/** Fill for a value under a scale; `MAP_NO_DATA_FILL` when the value is absent or ≤ 0. */90export function fillFor(value: number | null | undefined, scale: MapScale): string {91 if (value == null || !Number.isFinite(value) || value <= 0 || scale.classes.length === 0) return MAP_NO_DATA_FILL;92 return scale.classes[classIndex(value, scale.breaks)]?.fill ?? MAP_NO_DATA_FILL;93}9495/** "1–12", "13–80", "608,226" — legend label for a class. */96export function classLabel(c: MapClass, fmt: (n: number) => string = String): string {97 return c.lo === c.hi ? fmt(c.lo) : `${fmt(c.lo)}–${fmt(c.hi)}`;98}99100/** Radius (px) for a proportional-symbol dot: area ∝ value, clamped to [min, max]. */101export function sqrtRadius(value: number, maxValue: number, maxRadius = 14, minRadius = 1.5): number {102 if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(maxValue) || maxValue <= 0) return 0;103 return Math.max(minRadius, Math.min(maxRadius, Math.sqrt(value / maxValue) * maxRadius));104}105