import { scaleLinear, scaleLog, type ScaleContinuousNumeric } from 'd3-scale'; import type { SeriesValue, SparkPoint } from '@/lib/types'; /** Chart-side point. Build with `pointsFromSpark` / `pointsFromSeries`. */ export interface SeriesPoint { period: string; year: number; value: number | null; is_forecast?: boolean; is_estimate?: boolean; } /** API sparkline `[year, value]` → points (annual, non-forecast). */ export function pointsFromSpark(spark: SparkPoint[] | null | undefined): SeriesPoint[] { if (!spark) return []; return spark.filter((p) => Array.isArray(p) && typeof p[0] === 'number').map(([year, value]) => ({ period: `${year}-01-01`, year, value })); } /** API series values → points (keeps forecast flags, drops rows without a period). */ export function pointsFromSeries(values: SeriesValue[] | null | undefined): SeriesPoint[] { if (!values) return []; const out: SeriesPoint[] = []; for (const v of values) { const year = v.year ?? (v.period ? Number(v.period.slice(0, 4)) : NaN); if (!Number.isFinite(year)) continue; out.push({ period: v.period ?? `${year}-01-01`, year, value: v.value, is_forecast: v.is_forecast, is_estimate: v.is_estimate }); } return out; } export interface Margin { top: number; right: number; bottom: number; left: number; } export const DEFAULT_MARGIN: Margin = { top: 12, right: 12, bottom: 24, left: 44 }; export function extent(values: Array): [number, number] | null { let lo = Infinity; let hi = -Infinity; for (const v of values) { if (typeof v !== 'number' || !Number.isFinite(v)) continue; if (v < lo) lo = v; if (v > hi) hi = v; } if (lo === Infinity) return null; return [lo, hi]; } /** * Y domain for LINE / AREA charts (dataviz rule: a line need not start at zero — a tight series such as * life expectancy 60→83 must not render flat). Zero is included only when * - the data crosses or touches zero (min ≤ 0), or * - the minimum is within 30 % of the maximum (min < 0.3·max: the zero baseline costs little), or * - the indicator is a percent share / index whose floor is 0 AND the data spans more than half of [0, max]. * Otherwise the extent is padded by ≈ 6 % on each side (then `.nice()`d by the scale). Bars and stacked * areas keep the zero baseline (they do not use this). */ export function lineDomain(dom: [number, number], spec?: { format?: string | null } | null): [number, number] { const [lo, hi] = dom; if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; return [lo - pad, hi + pad]; } const isShare = spec?.format === 'percent' || spec?.format === 'index'; const includeZero = lo <= 0 || lo < 0.3 * hi || (isShare && hi - lo > 0.5 * hi); if (includeZero) return [Math.min(0, lo), Math.max(0, hi)]; const pad = (hi - lo) * 0.06; return [lo - pad, hi + pad]; } /** Y scale: linear (zero-anchored when the data allows) or log for strictly positive values. */ export function yScale( domain: [number, number], range: [number, number], opts: { log?: boolean; includeZero?: boolean } = {}, ): ScaleContinuousNumeric { let [lo, hi] = domain; if (opts.log && lo > 0) { return scaleLog().domain([lo, hi]).range(range).nice(); } if (opts.includeZero ?? true) { if (lo > 0) lo = 0; if (hi < 0) hi = 0; } if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; } return scaleLinear().domain([lo, hi]).range(range).nice(5); } export function xYearScale(domain: [number, number], range: [number, number]) { const [lo, hi] = domain[0] === domain[1] ? [domain[0] - 1, domain[1] + 1] : domain; return scaleLinear().domain([lo, hi]).range(range); } /** ~n year ticks at round intervals (1, 2, 5, 10, 20, 25, 50). */ export function yearTicks(domain: [number, number], n = 5): number[] { const [lo, hi] = domain; const span = Math.max(1, hi - lo); const steps = [1, 2, 5, 10, 20, 25, 50, 100]; let idx = steps.findIndex((s) => span / s <= n); if (idx < 0) idx = steps.length - 1; const build = (step: number) => { const start = Math.ceil(lo / step) * step; const out: number[] = []; for (let y = start; y <= hi; y += step) out.push(y); return out; }; // Always at least two labelled years (a lone "2000" on a 1960–2025 axis reads as nothing): step down if needed. let out = build(steps[idx]!); while (out.length < 2 && idx > 0) out = build(steps[--idx]!); if (out.length === 0) out.push(lo, hi); return out; } /** Fractional year for a period (annual → year, quarterly → year + q/4, monthly → year + m/12). */ export function periodToX(p: Pick): number { const m = /^(\d{4})-(\d{2})/.exec(p.period ?? ''); if (!m) return p.year; const month = Number(m[2]); return Number(m[1]) + (month - 1) / 12; } export function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); } /** Split a series into consecutive runs of the same `is_forecast` flag (so projections draw dashed). */ export function splitForecast(points: SeriesPoint[]): Array<{ forecast: boolean; points: SeriesPoint[] }> { const runs: Array<{ forecast: boolean; points: SeriesPoint[] }> = []; let prev: SeriesPoint | null = null; for (const p of points) { if (p.value == null) { prev = null; continue; } const f = !!p.is_forecast; const last = runs[runs.length - 1]; if (!last || last.forecast !== f || prev === null) { const run = { forecast: f, points: [] as SeriesPoint[] }; // connect segments: a forecast run starts from the last actual point if (prev && last && last.forecast !== f) run.points.push(prev); runs.push(run); } runs[runs.length - 1]!.points.push(p); prev = p; } return runs; } export function firstLast(points: SeriesPoint[]): { first: SeriesPoint; last: SeriesPoint } | null { const valid = points.filter((p) => typeof p.value === 'number' && Number.isFinite(p.value) && !p.is_forecast); const first = valid[0]; const last = valid[valid.length - 1]; if (!first || !last) return null; return { first, last }; }