/** Small geometry helpers for the explorer map (client-safe, pure). */ export interface BBox { x: number; y: number; w: number; h: number; } /** Bounding box of an SVG path made of absolute M/L/Z commands (what d3 geoPath emits). */ export function pathBBox(d: string): BBox | null { const nums = d.match(/-?\d+(?:\.\d+)?/g); if (!nums || nums.length < 4) return null; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (let i = 0; i + 1 < nums.length; i += 2) { const x = Number(nums[i]); const y = Number(nums[i + 1]); if (!Number.isFinite(x) || !Number.isFinite(y)) continue; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } if (minX === Infinity) return null; return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; } /** Class index 0..breaks.length for a value against sorted quantile breaks. */ export function classIndex(value: number, breaks: number[]): number { let i = 0; while (i < breaks.length && value >= breaks[i]!) i++; return i; } /** Map a class index (0..k-1) onto the 7-step sequential ramp (same rule as ChoroplethView). */ export function stepFor(cls: number, k: number): number { if (k <= 1) return 4; const start = k >= 6 ? 1 : 2; return Math.round(start + (cls / (k - 1)) * (7 - start)); } /** Median of finite numbers (null when empty). */ export function median(values: Array): number | null { const v = values.filter((x): x is number => typeof x === 'number' && Number.isFinite(x)).sort((a, b) => a - b); if (!v.length) return null; const mid = Math.floor(v.length / 2); return v.length % 2 ? v[mid]! : (v[mid - 1]! + v[mid]!) / 2; } /** Equal-width histogram (log10 when `log`), 20 bins by default. */ export function histogramOf(values: number[], bins = 20, log = false): { edges: number[]; counts: number[]; log: boolean } { const v = log ? values.filter((x) => x > 0) : values; if (!v.length) return { edges: [], counts: [], log }; const t = log ? v.map((x) => Math.log10(x)) : v; let lo = Math.min(...t); let hi = Math.max(...t); if (lo === hi) { const pad = Math.abs(lo) * 0.05 || 0.5; lo -= pad; hi += pad; } const counts = new Array(bins).fill(0); const w = (hi - lo) / bins; for (const x of t) { let i = Math.floor((x - lo) / w); if (i >= bins) i = bins - 1; if (i < 0) i = 0; counts[i]!++; } const edges = Array.from({ length: bins + 1 }, (_, i) => { const e = lo + i * w; return log ? Math.pow(10, e) : e; }); return { edges, counts, log }; } /** Heuristic: a level series spanning > 50× on positive values reads better on a log axis. */ export function wantsLog(spec: { format?: string | null }, values: number[]): boolean { if (!['currency', 'number', 'tonnes', 'kwh', 'per_million'].includes(spec.format ?? '')) return false; const pos = values.filter((v) => v > 0); if (pos.length < 10 || pos.length < values.length * 0.95) return false; return Math.max(...pos) / Math.min(...pos) > 50; }