SPB Git

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%
11.4 KB · 328 lines tsx
Raw Blame History
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    >161162    </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