/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/counter/src/format.ts * Purpose: All UI number formatting — sigFigs honesty cap, Intl.NumberFormat, rates and uncertainty; never inline toFixed elsewhere */ import type { DisplayHints } from "./counter-model.js"; /** Round to n significant digits (the honesty cap: never display precision the model can't justify). */ export function roundToSigFigs(value: number, sigFigs: number): number { if (value === 0 || !Number.isFinite(value)) return value; const magnitude = Math.floor(Math.log10(Math.abs(value))); const factor = 10 ** (sigFigs - 1 - magnitude); return Math.round(value * factor) / factor; } export interface FormatOptions { locale?: string; /** * Animated tickers keep the trailing digits moving (the units digit animates) * while static contexts (badge alt text, tooltips, share cards) apply the * sigFigs cap strictly. Default: true (strict). */ applySigFigs?: boolean; } /** Format a counter value according to its display hints. Fallback text on non-finite input. */ export function formatValue( value: number, hints: DisplayHints, options: FormatOptions = {}, ): string { if (!Number.isFinite(value)) return "—"; const { locale = "en", applySigFigs = true } = options; const scaled = value * (hints.scale ?? 1); const v = applySigFigs && hints.sigFigs !== undefined ? roundToSigFigs(scaled, hints.sigFigs) : scaled; return new Intl.NumberFormat(locale, { minimumFractionDigits: hints.decimals, maximumFractionDigits: hints.decimals, }).format(v); } /** Format an instantaneous rate at a human-friendly cadence (/s, /min, /h). */ export function formatRate( perSecond: number, hints: DisplayHints, options: FormatOptions = {}, ): string { if (!Number.isFinite(perSecond)) return "—"; const { locale = "en" } = options; const perSecondScaled = perSecond * (hints.scale ?? 1); const abs = Math.abs(perSecondScaled); let scaled = perSecondScaled; let suffix = "/s"; if (abs < 1 / 60) { scaled = perSecondScaled * 3600; suffix = "/h"; } else if (abs < 1) { scaled = perSecondScaled * 60; suffix = "/min"; } const formatted = new Intl.NumberFormat(locale, { maximumSignificantDigits: Math.min(hints.sigFigs ?? 3, 3), }).format(scaled); return `${formatted}${suffix}`; } /** * Compact display for chart axis ticks (8.31 Md / 8.31B) — never used for the * ticking value itself. Honors the display scale and caps significant digits. */ export function formatCompact( value: number, hints: DisplayHints, options: FormatOptions = {}, ): string { if (!Number.isFinite(value)) return "—"; const { locale = "en" } = options; const scaled = value * (hints.scale ?? 1); return new Intl.NumberFormat(locale, { notation: "compact", maximumSignificantDigits: Math.min(hints.sigFigs ?? 3, 4), }).format(scaled); } /** Format a 90 % CI as "low – high" with the same hints (for tooltips and the methodology page). */ export function formatUncertainty( low: number, high: number, hints: DisplayHints, options: FormatOptions = {}, ): string { return `${formatValue(low, hints, options)} – ${formatValue(high, hints, options)}`; }