spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { LOCALE, t } from '@/i18n';2import type { FormatSpec, IndicatorFormat } from './types';34/**5 * Number/date formatting. All Intl calls use the FIXED locale `en-US` (server and client alike) so6 * server-rendered markup never differs from the client render (hydration). Never rely on the browser default.7 */8910const NF_CACHE = new Map<string, Intl.NumberFormat>();11function nf(opts: Intl.NumberFormatOptions): Intl.NumberFormat {12 const key = JSON.stringify(opts);13 let f = NF_CACHE.get(key);14 if (!f) {15 f = new Intl.NumberFormat(LOCALE, opts);16 NF_CACHE.set(key, f);17 }18 return f;19}2021const NA = t('common.na');2223export function isNum(v: unknown): v is number {24 return typeof v === 'number' && Number.isFinite(v);25}2627/** 1.2T / 45.3B / 12.4M / 53.4k — fixed suffix set (never "trillion"), 3 significant digits max. */28export function compact(v: number, maxSig = 3): string {29 const abs = Math.abs(v);30 const units: Array<[number, string]> = [31 [1e12, 'T'],32 [1e9, 'B'],33 [1e6, 'M'],34 [1e3, 'k'],35 ];36 for (const [div, suffix] of units) {37 if (abs >= div) {38 const n = v / div;39 const digits = Math.abs(n) >= 100 ? 0 : Math.abs(n) >= 10 ? 1 : Math.min(2, maxSig - 1);40 return nf({ maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(n) + suffix;41 }42 }43 return nf({ maximumFractionDigits: abs >= 100 ? 0 : abs >= 10 ? 1 : 2 }).format(v);44}4546/** Grouped integer-ish number: 1,234,567. */47export function grouped(v: number, digits = 0): string {48 return nf({ maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(v);49}5051export function fixed(v: number, digits = 1): string {52 return nf({ maximumFractionDigits: digits, minimumFractionDigits: digits }).format(v);53}5455/** Currency prefix from the unit string (US$, intl $, €…). Defaults to US$. */56function currencyPrefix(spec: FormatSpec): string {57 const u = (spec.unit_short ?? spec.unit ?? '').toLowerCase();58 if (u.includes('intl') || u.includes('international') || u.includes('ppp')) return 'intl $';59 if (u.includes('€') || u.includes('eur')) return '€';60 if (u.includes('pps')) return 'PPS ';61 return 'US$';62}6364/**65 * Format a value according to the indicator's `format` (ARCHITECTURE §4).66 * `opts.compactBelow` (default 1e6) → numbers below it are grouped, above are compacted.67 */68export function formatValue(69 value: number | null | undefined,70 spec: FormatSpec,71 opts: { compactBelow?: number; withUnit?: boolean } = {},72): string {73 if (!isNum(value)) return NA;74 const withUnit = opts.withUnit ?? true;75 const precision = spec.precision ?? 1;76 const format = (spec.format ?? 'number') as IndicatorFormat;77 const compactBelow = opts.compactBelow ?? 1e6;78 const abs = Math.abs(value);79 switch (format) {80 case 'currency': {81 // Always carries the unit prefix ("US$55.7k", "intl $66.7k"); compact from 10k, grouped below.82 const prefix = currencyPrefix(spec);83 const body = abs >= 1e4 ? compact(value) : grouped(value, abs < 10 ? 2 : abs < 1000 ? 1 : 0);84 const suffix = spec.unit_short && /\/h$/.test(spec.unit_short) ? '/h' : '';85 return `${prefix}${body}${suffix}`;86 }87 case 'percent':88 return withUnit ? `${fixed(value, precision)} %` : fixed(value, precision);89 case 'years':90 return withUnit ? `${fixed(value, precision)} yrs` : fixed(value, precision);91 case 'per_1000':92 return withUnit ? `${fixed(value, precision)} ‰` : fixed(value, precision);93 case 'per_100k':94 return withUnit ? `${fixed(value, precision)} /100k` : fixed(value, precision);95 case 'per_million':96 return withUnit ? `${fixed(value, precision)} /M` : fixed(value, precision);97 case 'index':98 return fixed(value, precision);99 case 'ratio':100 return fixed(value, Math.max(precision, 2));101 case 'celsius':102 return `${fixed(value, Math.max(precision, 2))} °C`;103 case 'tonnes': {104 const u = spec.unit_short ?? 't';105 return withUnit ? `${abs >= compactBelow ? compact(value) : fixed(value, precision)} ${u}` : fixed(value, precision);106 }107 case 'kwh': {108 const u = spec.unit_short ?? 'kWh';109 return withUnit ? `${abs >= compactBelow ? compact(value) : grouped(value, precision)} ${u}` : grouped(value, precision);110 }111 case 'km':112 return withUnit ? `${abs >= compactBelow ? compact(value) : grouped(value)} km` : grouped(value);113 case 'ha':114 return withUnit ? `${abs >= compactBelow ? compact(value) : grouped(value)} ha` : grouped(value);115 case 'number':116 default: {117 if (abs >= compactBelow) return compact(value);118 if (Number.isInteger(value) || abs >= 1000) return grouped(value);119 return fixed(value, precision);120 }121 }122}123124/**125 * Display string for an API value object. Currencies ALWAYS go through `formatValue` (guaranteed unit126 * prefix, identical on server and client); other formats prefer the API's `formatted` string because it127 * carries the indicator precision that `MetricValue`/`RankingRow` do not expose. Use this instead of128 * `m.formatted ?? formatValue(m.value, m)`.129 */130export function displayValue(value: number | null | undefined, spec: FormatSpec, formatted?: string | null): string {131 if (!isNum(value)) return NA;132 // Unit-bearing formats: the client formatter guarantees the right unit token (the API has shipped "12.3k t"133 // for a million-tonnes indicator and bare "55.7k" for currencies).134 if (CLIENT_FORMATS.has(spec.format ?? 'number') || !formatted) return formatValue(value, spec);135 return formatted;136}137const CLIENT_FORMATS = new Set<string>(['currency', 'tonnes', 'kwh']);138139/** Units whose change is expressed in points rather than percent. */140export function isPointsUnit(spec: FormatSpec): boolean {141 return spec.format === 'percent' || spec.format === 'index' || spec.format === 'ratio' || spec.format === 'years';142}143144/**145 * Signed change: "+2.3 pts" for percent-type units, "+4.1 %" for relative change, "+1.2M" absolute.146 * Prefer `changePct` for level indicators and `changeAbs` for point-type units.147 */148export function formatChange(149 changeAbs: number | null | undefined,150 changePct: number | null | undefined,151 spec: FormatSpec,152): { text: string; direction: 'up' | 'down' | 'flat' } | null {153 if (isPointsUnit(spec)) {154 if (!isNum(changeAbs)) return null;155 const d = changeAbs > 0 ? 'up' : changeAbs < 0 ? 'down' : 'flat';156 const unit = spec.format === 'years' ? ' yrs' : spec.format === 'index' || spec.format === 'ratio' ? '' : ' pts';157 return { text: `${sign(changeAbs)}${fixed(Math.abs(changeAbs), spec.precision ?? 1)}${unit}`, direction: d };158 }159 if (isNum(changePct)) {160 const d = changePct > 0 ? 'up' : changePct < 0 ? 'down' : 'flat';161 return { text: `${sign(changePct)}${fixed(Math.abs(changePct), 1)} %`, direction: d };162 }163 if (isNum(changeAbs)) {164 const d = changeAbs > 0 ? 'up' : changeAbs < 0 ? 'down' : 'flat';165 return { text: `${sign(changeAbs)}${formatValue(Math.abs(changeAbs), spec)}`, direction: d };166 }167 return null;168}169170function sign(v: number): string {171 return v > 0 ? '+' : v < 0 ? '−' : '';172}173174const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];175176/** "2024" for annual, "Q2 2026" quarterly, "Jun 2026" monthly (period = first day, ISO date). */177export function formatPeriod(period: string | null | undefined, frequency: 'A' | 'Q' | 'M' | string | null | undefined = 'A'): string {178 if (!period) return NA;179 const m = /^(\d{4})-(\d{2})/.exec(period);180 if (!m) return period;181 const year = m[1]!;182 const month = Number(m[2]);183 if (frequency === 'Q') return `Q${Math.floor((month - 1) / 3) + 1} ${year}`;184 if (frequency === 'M') return `${MONTHS[month - 1] ?? ''} ${year}`;185 return year;186}187188/** "12th of 190". */189export function formatRank(rank: number | null | undefined, n: number | null | undefined): string {190 if (!isNum(rank) || !isNum(n)) return NA;191 return t('metric.rankWorld', { rank: ordinal(rank), n: grouped(n) });192}193194export function ordinal(n: number): string {195 const s = ['th', 'st', 'nd', 'rd'];196 const v = n % 100;197 return `${grouped(n)}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`;198}199200/** "11 Sep 2026" — deterministic (UTC) date formatting. */201export function formatDate(iso: string | null | undefined, opts: { month?: 'short' | 'long' } = {}): string {202 if (!iso) return NA;203 const d = new Date(iso);204 if (Number.isNaN(d.getTime())) return iso;205 const day = d.getUTCDate();206 const mon = MONTHS[d.getUTCMonth()] ?? '';207 const month = opts.month === 'long' ? LONG_MONTHS[d.getUTCMonth()] : mon;208 return `${day} ${month} ${d.getUTCFullYear()}`;209}210const LONG_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];211212/** Human "3 d ago" style, computed against `now` (pass a fixed `now` on the server to keep SSR stable). */213export function relativeFreshness(iso: string | null | undefined, now: Date | number = Date.now()): string {214 if (!iso) return NA;215 const then = new Date(iso).getTime();216 if (Number.isNaN(then)) return NA;217 const nowMs = typeof now === 'number' ? now : now.getTime();218 const s = Math.max(0, (nowMs - then) / 1000);219 if (s < 90) return t('common.ago.now');220 const min = s / 60;221 if (min < 60) return t('common.ago.minutes', { n: Math.round(min) });222 const h = min / 60;223 if (h < 36) return t('common.ago.hours', { n: Math.round(h) });224 const d = h / 24;225 if (d < 45) return t('common.ago.days', { n: Math.round(d) });226 const mo = d / 30.44;227 if (mo < 18) return t('common.ago.months', { n: Math.round(mo) });228 return t('common.ago.years', { n: Math.round(d / 365.25) });229}230231/** Freshness class for a retrieval date: fresh < 45 d, recent < 400 d, else stale. */232export function freshnessLevel(iso: string | null | undefined, now: number = Date.now()): 'fresh' | 'recent' | 'stale' | null {233 if (!iso) return null;234 const then = new Date(iso).getTime();235 if (Number.isNaN(then)) return null;236 const days = (now - then) / 86_400_000;237 if (days < 45) return 'fresh';238 if (days < 400) return 'recent';239 return 'stale';240}241242/** Percent 0–1 or 0–100 → "83 %". */243export function formatPct(v: number | null | undefined, digits = 0): string {244 if (!isNum(v)) return NA;245 const p = v <= 1 ? v * 100 : v;246 return `${fixed(p, digits)} %`;247}248249/** Axis tick label: compact for large magnitudes, otherwise ≤ 2 decimals, plus the short unit when useful. */250export function formatTick(v: number, spec: FormatSpec): string {251 const abs = Math.abs(v);252 if (spec.format === 'percent') return `${fixed(v, abs < 1 && abs > 0 ? 1 : 0)}%`;253 if (spec.format === 'currency') return `${currencyPrefix(spec)}${abs >= 1e3 ? compact(v, 2) : grouped(v)}`;254 if (abs >= 1e4) return compact(v, 2);255 if (Number.isInteger(v)) return grouped(v);256 return fixed(v, abs < 1 ? 2 : 1);257}258259export const IndicatorFormats: readonly IndicatorFormat[] = [260 'number',261 'percent',262 'currency',263 'index',264 'years',265 'per_1000',266 'per_100k',267 'per_million',268 'ratio',269 'celsius',270 'tonnes',271 'kwh',272 'ha',273 'km',274];275