spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { scaleLinear, scaleLog, type ScaleContinuousNumeric } from 'd3-scale';2import type { SeriesValue, SparkPoint } from '@/lib/types';34/** Chart-side point. Build with `pointsFromSpark` / `pointsFromSeries`. */5export interface SeriesPoint {6 period: string;7 year: number;8 value: number | null;9 is_forecast?: boolean;10 is_estimate?: boolean;11}1213/** API sparkline `[year, value]` → points (annual, non-forecast). */14export function pointsFromSpark(spark: SparkPoint[] | null | undefined): SeriesPoint[] {15 if (!spark) return [];16 return spark.filter((p) => Array.isArray(p) && typeof p[0] === 'number').map(([year, value]) => ({ period: `${year}-01-01`, year, value }));17}1819/** API series values → points (keeps forecast flags, drops rows without a period). */20export function pointsFromSeries(values: SeriesValue[] | null | undefined): SeriesPoint[] {21 if (!values) return [];22 const out: SeriesPoint[] = [];23 for (const v of values) {24 const year = v.year ?? (v.period ? Number(v.period.slice(0, 4)) : NaN);25 if (!Number.isFinite(year)) continue;26 out.push({ period: v.period ?? `${year}-01-01`, year, value: v.value, is_forecast: v.is_forecast, is_estimate: v.is_estimate });27 }28 return out;29}3031export interface Margin {32 top: number;33 right: number;34 bottom: number;35 left: number;36}3738export const DEFAULT_MARGIN: Margin = { top: 12, right: 12, bottom: 24, left: 44 };3940export function extent(values: Array<number | null | undefined>): [number, number] | null {41 let lo = Infinity;42 let hi = -Infinity;43 for (const v of values) {44 if (typeof v !== 'number' || !Number.isFinite(v)) continue;45 if (v < lo) lo = v;46 if (v > hi) hi = v;47 }48 if (lo === Infinity) return null;49 return [lo, hi];50}5152/**53 * Y domain for LINE / AREA charts (dataviz rule: a line need not start at zero — a tight series such as54 * life expectancy 60→83 must not render flat). Zero is included only when55 * - the data crosses or touches zero (min ≤ 0), or56 * - the minimum is within 30 % of the maximum (min < 0.3·max: the zero baseline costs little), or57 * - the indicator is a percent share / index whose floor is 0 AND the data spans more than half of [0, max].58 * Otherwise the extent is padded by ≈ 6 % on each side (then `.nice()`d by the scale). Bars and stacked59 * areas keep the zero baseline (they do not use this).60 */61export function lineDomain(dom: [number, number], spec?: { format?: string | null } | null): [number, number] {62 const [lo, hi] = dom;63 if (lo === hi) {64 const pad = Math.abs(lo) * 0.1 || 1;65 return [lo - pad, hi + pad];66 }67 const isShare = spec?.format === 'percent' || spec?.format === 'index';68 const includeZero = lo <= 0 || lo < 0.3 * hi || (isShare && hi - lo > 0.5 * hi);69 if (includeZero) return [Math.min(0, lo), Math.max(0, hi)];70 const pad = (hi - lo) * 0.06;71 return [lo - pad, hi + pad];72}7374/** Y scale: linear (zero-anchored when the data allows) or log for strictly positive values. */75export function yScale(76 domain: [number, number],77 range: [number, number],78 opts: { log?: boolean; includeZero?: boolean } = {},79): ScaleContinuousNumeric<number, number> {80 let [lo, hi] = domain;81 if (opts.log && lo > 0) {82 return scaleLog().domain([lo, hi]).range(range).nice();83 }84 if (opts.includeZero ?? true) {85 if (lo > 0) lo = 0;86 if (hi < 0) hi = 0;87 }88 if (lo === hi) {89 const pad = Math.abs(lo) * 0.1 || 1;90 lo -= pad;91 hi += pad;92 }93 return scaleLinear().domain([lo, hi]).range(range).nice(5);94}9596export function xYearScale(domain: [number, number], range: [number, number]) {97 const [lo, hi] = domain[0] === domain[1] ? [domain[0] - 1, domain[1] + 1] : domain;98 return scaleLinear().domain([lo, hi]).range(range);99}100101/** ~n year ticks at round intervals (1, 2, 5, 10, 20, 25, 50). */102export function yearTicks(domain: [number, number], n = 5): number[] {103 const [lo, hi] = domain;104 const span = Math.max(1, hi - lo);105 const steps = [1, 2, 5, 10, 20, 25, 50, 100];106 let idx = steps.findIndex((s) => span / s <= n);107 if (idx < 0) idx = steps.length - 1;108 const build = (step: number) => {109 const start = Math.ceil(lo / step) * step;110 const out: number[] = [];111 for (let y = start; y <= hi; y += step) out.push(y);112 return out;113 };114 // Always at least two labelled years (a lone "2000" on a 1960–2025 axis reads as nothing): step down if needed.115 let out = build(steps[idx]!);116 while (out.length < 2 && idx > 0) out = build(steps[--idx]!);117 if (out.length === 0) out.push(lo, hi);118 return out;119}120121/** Fractional year for a period (annual → year, quarterly → year + q/4, monthly → year + m/12). */122export function periodToX(p: Pick<SeriesPoint, 'period' | 'year'>): number {123 const m = /^(\d{4})-(\d{2})/.exec(p.period ?? '');124 if (!m) return p.year;125 const month = Number(m[2]);126 return Number(m[1]) + (month - 1) / 12;127}128129export function clamp(v: number, lo: number, hi: number): number {130 return Math.min(hi, Math.max(lo, v));131}132133/** Split a series into consecutive runs of the same `is_forecast` flag (so projections draw dashed). */134export function splitForecast(points: SeriesPoint[]): Array<{ forecast: boolean; points: SeriesPoint[] }> {135 const runs: Array<{ forecast: boolean; points: SeriesPoint[] }> = [];136 let prev: SeriesPoint | null = null;137 for (const p of points) {138 if (p.value == null) {139 prev = null;140 continue;141 }142 const f = !!p.is_forecast;143 const last = runs[runs.length - 1];144 if (!last || last.forecast !== f || prev === null) {145 const run = { forecast: f, points: [] as SeriesPoint[] };146 // connect segments: a forecast run starts from the last actual point147 if (prev && last && last.forecast !== f) run.points.push(prev);148 runs.push(run);149 }150 runs[runs.length - 1]!.points.push(p);151 prev = p;152 }153 return runs;154}155156export function firstLast(points: SeriesPoint[]): { first: SeriesPoint; last: SeriesPoint } | null {157 const valid = points.filter((p) => typeof p.value === 'number' && Number.isFinite(p.value) && !p.is_forecast);158 const first = valid[0];159 const last = valid[valid.length - 1];160 if (!first || !last) return null;161 return { first, last };162}163