SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
10.9 KB · 275 lines typescript
Raw Blame History
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