/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/web/components/LiveCounter.tsx * Purpose: The live counter — rAF animation of a CounterModel with windows, derived transforms, smoothing, honesty guardrails and info popover (display/card/row sizes) */ "use client"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { formatRate, formatUncertainty, formatValue, rateAt, type CounterModel, type CounterWindow, } from "@earth-now/counter"; import type { MetricSummary } from "@/lib/api"; import { displayRawValue, hintsFor, isEstimate } from "@/lib/derived"; import { blendValues, SMOOTHING_DURATION_MS } from "@/lib/smoothing"; import { useI18n } from "@/lib/i18n"; import { formatDateUtc, type MessageKey } from "@/lib/messages"; import FitValue from "./FitValue"; export interface PreviousModel { model: CounterModel; swapStartMs: number; } export interface LiveCounterProps { metric: MetricSummary; model?: CounterModel | undefined; /** Previous model + swap instant — enables the 60 s no-jump smoothing blend. */ prev?: PreviousModel | undefined; window: CounterWindow; sessionStart?: number | undefined; /** display = the page's hero figure; card = KPI tile; row = compact reference row. */ size?: "display" | "card" | "row"; /** Shows the pulsing live indicator (set by the tier system, not hand-picked). */ live?: boolean; /** Disables the link to the metric's own page (used ON that page). */ noLink?: boolean; } interface DisplayState { main: string; rate: string | null; capped: string; pending: boolean; } const INITIAL_DISPLAY: DisplayState = { main: "—", rate: null, capped: "—", pending: false }; const PERCENT_HINTS = { decimals: 0, unit: "%" } as const; /** Muted, sober treatment for mortality metrics — editorial rule, no celebratory styling. */ function isSomber(metric: MetricSummary): boolean { return metric.id.includes("death"); } export default function LiveCounter({ metric, model, prev, window: win, sessionStart, size = "card", live = false, noLink = false, }: LiveCounterProps) { const { locale, t } = useI18n(); const [display, setDisplay] = useState(INITIAL_DISPLAY); const [infoOpen, setInfoOpen] = useState(false); const lastFinite = useRef(null); const hints = hintsFor(metric, locale); const isRateOf = metric.derived?.op === "rate-of"; const showRateLine = !isRateOf && (metric.kind === "cumulative" || metric.derived?.op === "window" || size === "display"); useEffect(() => { if (model === undefined) return; let raf = 0; const localHints = hintsFor(metric, locale); const tick = () => { const now = Date.now(); let raw = displayRawValue(metric, model, now, win, sessionStart); // 60 s smoothing between model versions — no visible jump (production guardrail). if (prev !== undefined && now < prev.swapStartMs + SMOOTHING_DURATION_MS) { const oldRaw = displayRawValue(metric, prev.model, now, win, sessionStart); if (Number.isFinite(oldRaw) && Number.isFinite(raw)) { raw = blendValues(oldRaw, raw, now, prev.swapStartMs, SMOOTHING_DURATION_MS); } } // Never NaN/undefined on screen: freeze the last finite value + "data pending" chip. let pending = false; if (Number.isFinite(raw)) { lastFinite.current = raw; } else { pending = true; raw = lastFinite.current ?? NaN; } const main = isRateOf ? formatRate(raw, localHints, { locale }) : formatValue(raw, localHints, { locale, applySigFigs: false }); const capped = isRateOf ? main : formatValue(raw, localHints, { locale }); const rate = showRateLine ? formatRate(rateAt(model, now), localHints, { locale }) : null; setDisplay((d) => d.main === main && d.rate === rate && d.capped === capped && d.pending === pending ? d : { main, rate, capped, pending }, ); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [metric, model, prev, win, sessionStart, locale, isRateOf, showRateLine]); const somber = isSomber(metric); const source = metric.sources[0]; const windowKey = `window.${win}` as MessageKey; const observedAt = model?.observedAt ?? metric.observedAt; const modelVersion = model?.modelVersion ?? metric.modelVersion; const unitLabel = metric.display.unit[locale]; const chips = (
{t(windowKey)} {metric.stale && ( ⚠ {t("chip.stale")} )} {display.pending && ( ⚠ {t("chip.pending")} )} {isEstimate(metric) && metric.uncertaintyFraction !== undefined && ( ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % ·{" "} {t("chip.estimate")} )}
); const infoButton = ( ); const infoPanel = infoOpen && (
{t("info.cappedValue")} :
{display.capped} {isRateOf ? "" : unitLabel}
{model?.uncertainty !== undefined && (
{t("info.uncertainty")} :
{formatUncertainty(model.uncertainty.low, model.uncertainty.high, hints, { locale, })}{" "} {unitLabel}
)} {metric.uncertaintyFraction !== undefined && (
{t("info.uncertainty")} :
± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % ( {t("info.estimateNote")})
)} {source !== undefined && (
{t("info.source")} :
{source.name} {" "} · {t("info.license")} : {source.license}
)} {observedAt !== undefined && (
{t("info.observed")} :
{formatDateUtc(observedAt, locale)}
)}
{t("info.model")} :
{metric.model} {modelVersion !== undefined && modelVersion !== metric.model ? ` (${modelVersion})` : ""}
{metric.editorialNote !== undefined && (
{metric.editorialNote[locale]}
)}
); if (size === "row") { // Compact reference row: slow-moving values, no big animation, dense layout. return (
{noLink ? ( {metric.name[locale]} ) : ( {metric.name[locale]} )} {display.main} {isRateOf ? "" : unitLabel} {observedAt !== undefined && ( {formatDateUtc(observedAt, locale)} )} {(metric.stale || display.pending) && ( ⚠ {t(metric.stale ? "chip.stale" : "chip.pending")} )} {infoButton} {infoPanel}
); } const isDisplay = size === "display"; return (

{live && !somber && } {noLink ? ( {metric.name[locale]} ) : ( {metric.name[locale]} )}

{infoButton}
{/* Value line carries DIGITS ONLY (unit moves below) and is scale-fitted: it can never overflow the tile, on any viewport. */}

{isRateOf ? "" : unitLabel} {display.rate !== null && ( {isRateOf ? "" : " · "} {t("rate.current")} : {display.rate} )}

{chips}
{infoPanel}
); }