spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/LiveCounter.tsx6 * Purpose: The live counter — rAF animation of a CounterModel with windows, derived transforms, smoothing, honesty guardrails and info popover (display/card/row sizes)7 */89"use client";1011import Link from "next/link";12import { useEffect, useRef, useState } from "react";13import {14 formatRate,15 formatUncertainty,16 formatValue,17 rateAt,18 type CounterModel,19 type CounterWindow,20} from "@earth-now/counter";21import type { MetricSummary } from "@/lib/api";22import { displayRawValue, hintsFor, isEstimate } from "@/lib/derived";23import { blendValues, SMOOTHING_DURATION_MS } from "@/lib/smoothing";24import { useI18n } from "@/lib/i18n";25import { formatDateUtc, type MessageKey } from "@/lib/messages";26import FitValue from "./FitValue";2728export interface PreviousModel {29 model: CounterModel;30 swapStartMs: number;31}3233export interface LiveCounterProps {34 metric: MetricSummary;35 model?: CounterModel | undefined;36 /** Previous model + swap instant — enables the 60 s no-jump smoothing blend. */37 prev?: PreviousModel | undefined;38 window: CounterWindow;39 sessionStart?: number | undefined;40 /** display = the page's hero figure; card = KPI tile; row = compact reference row. */41 size?: "display" | "card" | "row";42 /** Shows the pulsing live indicator (set by the tier system, not hand-picked). */43 live?: boolean;44 /** Disables the link to the metric's own page (used ON that page). */45 noLink?: boolean;46}4748interface DisplayState {49 main: string;50 rate: string | null;51 capped: string;52 pending: boolean;53}5455const INITIAL_DISPLAY: DisplayState = { main: "—", rate: null, capped: "—", pending: false };5657const PERCENT_HINTS = { decimals: 0, unit: "%" } as const;5859/** Muted, sober treatment for mortality metrics — editorial rule, no celebratory styling. */60function isSomber(metric: MetricSummary): boolean {61 return metric.id.includes("death");62}6364export default function LiveCounter({65 metric,66 model,67 prev,68 window: win,69 sessionStart,70 size = "card",71 live = false,72 noLink = false,73}: LiveCounterProps) {74 const { locale, t } = useI18n();75 const [display, setDisplay] = useState<DisplayState>(INITIAL_DISPLAY);76 const [infoOpen, setInfoOpen] = useState(false);77 const lastFinite = useRef<number | null>(null);7879 const hints = hintsFor(metric, locale);80 const isRateOf = metric.derived?.op === "rate-of";81 const showRateLine =82 !isRateOf &&83 (metric.kind === "cumulative" || metric.derived?.op === "window" || size === "display");8485 useEffect(() => {86 if (model === undefined) return;87 let raf = 0;88 const localHints = hintsFor(metric, locale);89 const tick = () => {90 const now = Date.now();91 let raw = displayRawValue(metric, model, now, win, sessionStart);92 // 60 s smoothing between model versions — no visible jump (production guardrail).93 if (prev !== undefined && now < prev.swapStartMs + SMOOTHING_DURATION_MS) {94 const oldRaw = displayRawValue(metric, prev.model, now, win, sessionStart);95 if (Number.isFinite(oldRaw) && Number.isFinite(raw)) {96 raw = blendValues(oldRaw, raw, now, prev.swapStartMs, SMOOTHING_DURATION_MS);97 }98 }99 // Never NaN/undefined on screen: freeze the last finite value + "data pending" chip.100 let pending = false;101 if (Number.isFinite(raw)) {102 lastFinite.current = raw;103 } else {104 pending = true;105 raw = lastFinite.current ?? NaN;106 }107 const main = isRateOf108 ? formatRate(raw, localHints, { locale })109 : formatValue(raw, localHints, { locale, applySigFigs: false });110 const capped = isRateOf ? main : formatValue(raw, localHints, { locale });111 const rate = showRateLine112 ? formatRate(rateAt(model, now), localHints, { locale })113 : null;114 setDisplay((d) =>115 d.main === main && d.rate === rate && d.capped === capped && d.pending === pending116 ? d117 : { main, rate, capped, pending },118 );119 raf = requestAnimationFrame(tick);120 };121 raf = requestAnimationFrame(tick);122 return () => cancelAnimationFrame(raf);123 }, [metric, model, prev, win, sessionStart, locale, isRateOf, showRateLine]);124125 const somber = isSomber(metric);126 const source = metric.sources[0];127 const windowKey = `window.${win}` as MessageKey;128 const observedAt = model?.observedAt ?? metric.observedAt;129 const modelVersion = model?.modelVersion ?? metric.modelVersion;130 const unitLabel = metric.display.unit[locale];131132 const chips = (133 <div className="flex flex-wrap items-center gap-1.5 text-[10px] uppercase tracking-wide">134 <span className="rounded bg-page px-1.5 py-0.5 text-muted">{t(windowKey)}</span>135 {metric.stale && (136 <span className="rounded border border-warn/50 px-1.5 py-0.5 text-ink2">137 ⚠ {t("chip.stale")}138 </span>139 )}140 {display.pending && (141 <span className="rounded border border-warn/50 px-1.5 py-0.5 text-ink2">142 ⚠ {t("chip.pending")}143 </span>144 )}145 {isEstimate(metric) && metric.uncertaintyFraction !== undefined && (146 <span className="rounded bg-wash px-1.5 py-0.5 normal-case text-ink2">147 ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % ·{" "}148 {t("chip.estimate")}149 </span>150 )}151 </div>152 );153154 const infoButton = (155 <button156 type="button"157 aria-label={t("info.open")}158 onClick={() => setInfoOpen((o) => !o)}159 className="shrink-0 rounded-full px-1.5 text-xs text-muted transition-colors hover:text-ink"160 >161 ⓘ162 </button>163 );164165 const infoPanel = infoOpen && (166 <div className="absolute left-2 right-2 top-full z-20 mt-1 rounded-lg border bg-surface p-3 text-xs leading-relaxed text-ink2 shadow-lg">167 <dl className="space-y-1">168 <div>169 <dt className="inline font-medium text-ink">{t("info.cappedValue")} : </dt>170 <dd className="inline tabular-nums">171 {display.capped} {isRateOf ? "" : unitLabel}172 </dd>173 </div>174 {model?.uncertainty !== undefined && (175 <div>176 <dt className="inline font-medium text-ink">{t("info.uncertainty")} : </dt>177 <dd className="inline tabular-nums">178 {formatUncertainty(model.uncertainty.low, model.uncertainty.high, hints, {179 locale,180 })}{" "}181 {unitLabel}182 </dd>183 </div>184 )}185 {metric.uncertaintyFraction !== undefined && (186 <div>187 <dt className="inline font-medium text-ink">{t("info.uncertainty")} : </dt>188 <dd className="inline tabular-nums">189 ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % (190 {t("info.estimateNote")})191 </dd>192 </div>193 )}194 {source !== undefined && (195 <div>196 <dt className="inline font-medium text-ink">{t("info.source")} : </dt>197 <dd className="inline">198 <a199 href={source.url}200 target="_blank"201 rel="noreferrer"202 className="text-accent underline decoration-accent/40 hover:opacity-80"203 >204 {source.name}205 </a>{" "}206 · {t("info.license")} : {source.license}207 </dd>208 </div>209 )}210 {observedAt !== undefined && (211 <div>212 <dt className="inline font-medium text-ink">{t("info.observed")} : </dt>213 <dd className="inline">{formatDateUtc(observedAt, locale)}</dd>214 </div>215 )}216 <div>217 <dt className="inline font-medium text-ink">{t("info.model")} : </dt>218 <dd className="inline">219 {metric.model}220 {modelVersion !== undefined && modelVersion !== metric.model221 ? ` (${modelVersion})`222 : ""}223 </dd>224 </div>225 {metric.editorialNote !== undefined && (226 <div className="pt-1 text-muted">{metric.editorialNote[locale]}</div>227 )}228 </dl>229 <button230 type="button"231 onClick={() => setInfoOpen(false)}232 className="mt-2 text-[10px] uppercase tracking-wide text-muted hover:text-ink"233 >234 {t("info.close")}235 </button>236 </div>237 );238239 if (size === "row") {240 // Compact reference row: slow-moving values, no big animation, dense layout.241 return (242 <div className="relative flex min-w-0 items-center gap-3 px-4 py-2.5 transition-colors hover:bg-page/60">243 {noLink ? (244 <span className={`min-w-0 flex-1 truncate text-sm ${somber ? "text-muted" : "text-ink2"}`}>245 {metric.name[locale]}246 </span>247 ) : (248 <Link249 href={`/metric/${metric.id}`}250 className={`min-w-0 flex-1 truncate text-sm underline-offset-2 transition-colors hover:text-accent hover:underline ${somber ? "text-muted" : "text-ink2"}`}251 >252 {metric.name[locale]}253 </Link>254 )}255 <span className="shrink-0 text-sm font-medium text-ink tabular-nums">256 {display.main}257 <span className="ml-1 font-normal text-muted">{isRateOf ? "" : unitLabel}</span>258 </span>259 {observedAt !== undefined && (260 <span className="hidden shrink-0 text-xs text-muted lg:block">261 {formatDateUtc(observedAt, locale)}262 </span>263 )}264 {(metric.stale || display.pending) && (265 <span className="shrink-0 text-[10px] uppercase tracking-wide text-ink2">266 ⚠ {t(metric.stale ? "chip.stale" : "chip.pending")}267 </span>268 )}269 {infoButton}270 {infoPanel}271 </div>272 );273 }274275 const isDisplay = size === "display";276277 return (278 <div279 className={`group relative flex min-w-0 flex-col gap-1 rounded-2xl border bg-surface transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg ${280 isDisplay ? "p-6 sm:p-8" : "p-4"281 }`}282 >283 <div className="flex items-start justify-between gap-2">284 <h3285 className={`flex min-w-0 items-center gap-2 font-medium leading-snug ${286 somber ? "text-muted" : "text-ink2"287 } ${isDisplay ? "text-base" : "text-sm"}`}288 >289 {live && !somber && <span aria-hidden className="live-dot shrink-0" />}290 {noLink ? (291 <span className="min-w-0">{metric.name[locale]}</span>292 ) : (293 <Link294 href={`/metric/${metric.id}`}295 className="min-w-0 underline-offset-2 transition-colors hover:text-accent hover:underline"296 >297 {metric.name[locale]}298 </Link>299 )}300 </h3>301 {infoButton}302 </div>303304 {/* Value line carries DIGITS ONLY (unit moves below) and is scale-fitted:305 it can never overflow the tile, on any viewport. */}306 <FitValue307 text={display.main}308 className={`tabular-nums font-semibold tracking-tight text-ink ${309 isDisplay ? "text-5xl leading-tight sm:text-7xl" : "text-2xl leading-snug sm:text-3xl"310 }`}311 />312313 <p className={`text-muted ${isDisplay ? "text-base" : "text-xs"}`}>314 {isRateOf ? "" : unitLabel}315 {display.rate !== null && (316 <span className="tabular-nums">317 {isRateOf ? "" : " · "}318 {t("rate.current")} : {display.rate}319 </span>320 )}321 </p>322323 <div className="mt-1">{chips}</div>324 {infoPanel}325 </div>326 );327}328