/** * Classification methods for choropleth maps. * * `'quantile'` puts an equal *count* of features in each class — good when the * data is skewed and you want every class populated. `'equal-interval'` splits * the *value range* evenly — good when the numbers themselves are the story and * empty classes are meaningful. Choosing wrongly is the most common way a * choropleth misleads, so the method is always explicit, never inferred. */ export type MapClassifyMethod = 'quantile' | 'equal-interval'; export interface MapClass { /** Inclusive lower bound. */ min: number; /** Upper bound: exclusive, except on the last class where it is inclusive. */ max: number; /** Zero-based position, used to index into the color ramp. */ index: number; } /** * Splits values into `steps` classes. * * Returns fewer classes than requested when the data cannot support more — a * single distinct value yields one class rather than a stack of empty * degenerate ranges. Non-finite values are ignored, and an empty input yields * no classes at all (the caller should then paint everything as "no data"). */ export function classifyValues( values: number[], steps: number, method: MapClassifyMethod = 'quantile' ): MapClass[] { const finite = values.filter(value => Number.isFinite(value)).sort((a, b) => a - b); if (finite.length === 0 || steps < 1) return []; const min = finite[0]; const max = finite[finite.length - 1]; if (min === max) return [{ min, max, index: 0 }]; const breaks: number[] = [min]; if (method === 'equal-interval') { const width = (max - min) / steps; for (let i = 1; i < steps; i += 1) breaks.push(min + width * i); } else { for (let i = 1; i < steps; i += 1) { // Linear interpolation between order statistics, so the break lands // between samples instead of snapping to one of them. const position = (i / steps) * (finite.length - 1); const lower = Math.floor(position); const upper = Math.ceil(position); const weight = position - lower; breaks.push(finite[lower] * (1 - weight) + finite[upper] * weight); } } breaks.push(max); const classes: MapClass[] = []; for (let i = 0; i < breaks.length - 1; i += 1) { // Quantile breaks collapse on heavily tied data; skip the empty ranges // rather than emitting classes no feature can ever fall into. if (breaks[i] === breaks[i + 1] && i < breaks.length - 2) continue; classes.push({ min: breaks[i], max: breaks[i + 1], index: classes.length }); } return classes; } /** Index of the class a value belongs to, or `-1` when it belongs to none. */ export function classIndexOf(value: number, classes: MapClass[]): number { if (!Number.isFinite(value) || classes.length === 0) return -1; for (let i = 0; i < classes.length; i += 1) { const isLast = i === classes.length - 1; if (value >= classes[i].min && (isLast ? value <= classes[i].max : value < classes[i].max)) { return i; } } return -1; } /** * Interpolates a ramp of `count` colors through the given stops. * * Choropleth ramps are declared as two or three tokens (`['--chart-2', '--destructive']`) * but need one color per class, so the intermediate shades are mixed here rather * than asking the consumer to name every step. */ export function interpolateRamp(stops: string[], count: number): string[] { if (stops.length === 0 || count < 1) return []; if (stops.length === 1) return new Array(count).fill(stops[0]); if (count === 1) return [stops[0]]; const parse = (hex: string): [number, number, number] | null => { const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); if (!match) return null; const value = parseInt(match[1], 16); return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; }; const parsed = stops.map(parse); // Any unparseable stop means we cannot mix honestly; fall back to repeating // the declared stops rather than inventing colors. if (parsed.some(stop => stop === null)) { return Array.from({ length: count }, (_, i) => stops[Math.min(i, stops.length - 1)]); } const rgb = parsed as [number, number, number][]; return Array.from({ length: count }, (_, i) => { const t = (i / (count - 1)) * (rgb.length - 1); const lower = Math.floor(t); const upper = Math.min(rgb.length - 1, lower + 1); const weight = t - lower; const channel = (index: number) => Math.round(rgb[lower][index] * (1 - weight) + rgb[upper][index] * weight) .toString(16) .padStart(2, '0'); return `#${channel(0)}${channel(1)}${channel(2)}`; }); } /** Default label for a class, e.g. `"12 – 48"`. */ export function formatClassLabel(mapClass: MapClass, digits = 0): string { return `${mapClass.min.toFixed(digits)} – ${mapClass.max.toFixed(digits)}`; }