/** Tiny scale/format helpers shared by chart components (client-safe, no deps). */ export const SERIES_COLORS = ['var(--ri-series-1)', 'var(--ri-series-2)', 'var(--ri-series-3)', 'var(--ri-series-4)'] as const; export function niceTicks(min: number, max: number, count = 4): number[] { if (!Number.isFinite(min) || !Number.isFinite(max)) return []; if (min === max) { const pad = Math.abs(min) * 0.1 || 1; min -= pad; max += pad; } const span = max - min; const step0 = span / Math.max(1, count); const mag = 10 ** Math.floor(Math.log10(step0)); const norm = step0 / mag; const step = (norm >= 5 ? 10 : norm >= 2 ? 5 : norm >= 1 ? 2 : 1) * mag; const start = Math.ceil(min / step) * step; const out: number[] = []; for (let v = start; v <= max + 1e-9; v += step) out.push(Number(v.toFixed(10))); return out; } export function compactNumber(v: number, opts: { currency?: boolean } = {}): string { const abs = Math.abs(v); const sign = v < 0 ? '-' : ''; const pre = opts.currency ? '$' : ''; if (abs >= 1e9) return `${sign}${pre}${(abs / 1e9).toFixed(abs >= 1e10 ? 0 : 1)}B`; if (abs >= 1e6) return `${sign}${pre}${(abs / 1e6).toFixed(abs >= 1e7 ? 0 : 1)}M`; if (abs >= 1e3) return `${sign}${pre}${(abs / 1e3).toFixed(abs >= 1e4 ? 0 : 1)}K`; if (abs >= 100) return `${sign}${pre}${abs.toFixed(0)}`; if (abs >= 1) return `${sign}${pre}${abs.toFixed(abs % 1 === 0 ? 0 : 2)}`; return `${sign}${pre}${abs.toFixed(2)}`; } export function fullNumber(v: number, opts: { currency?: boolean; digits?: number } = {}): string { return new Intl.NumberFormat('en-US', { style: opts.currency ? 'currency' : 'decimal', currency: 'USD', maximumFractionDigits: opts.digits ?? (Math.abs(v) >= 1000 ? 0 : 2) }).format(v); } export function parseDay(d: string): number { return new Date(d.length === 10 ? `${d}T00:00:00Z` : d).getTime(); } export function fmtDay(ms: number, span: number): string { const d = new Date(ms); if (span > 3 * 365 * 86_400_000) return d.toLocaleDateString('en-US', { year: 'numeric', timeZone: 'UTC' }); if (span > 120 * 86_400_000) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' }); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); } export function fmtDayFull(ms: number): string { return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); } /** Diverging colour for percentage change: loss → neutral → gain, symmetric around 0 (clamped at ±limit). */ export function divergingColor(change: number | null | undefined, limit = 0.15): string { if (change === null || change === undefined || !Number.isFinite(change)) return 'var(--ri-bg-inset)'; const t = Math.max(-1, Math.min(1, change / limit)); const a = Math.abs(t); const alpha = 0.12 + a * 0.68; return t >= 0 ? `color-mix(in oklab, var(--ri-gain) ${Math.round(alpha * 100)}%, var(--ri-bg-elevated))` : `color-mix(in oklab, var(--ri-loss) ${Math.round(alpha * 100)}%, var(--ri-bg-elevated))`; } /** Sequential colour (single hue) for magnitude in [0,1]. */ export function sequentialColor(t: number): string { const a = Math.max(0.08, Math.min(1, t)); return `color-mix(in oklab, var(--ri-index) ${Math.round(a * 100)}%, var(--ri-bg-elevated))`; }