SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
5.3 KB · 105 lines typescript
Raw Blame History
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