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/lib/derived.ts6 * Purpose: Pure derived-metric transforms used by LiveCounter — primary window, depletion countdown, model resolution, display hints7 */89import {10 counterValue,11 rateAt,12 windowValue,13 type CounterModel,14 type CounterWindow,15 type DisplayHints,16} from "@earth-now/counter";17import type { Locale, MetricSummary } from "./api";1819/** Mean Gregorian year in seconds (365.2425 × 86 400) — matches YEAR_SECONDS in @earth-now/counter. */20export const YEAR_SECONDS = 31_556_952;2122/**23 * The window a metric's card should display by default:24 * derived op=window → that window; cumulative → ytd; everything else → total.25 */26export function resolvePrimaryWindow(metric: MetricSummary): CounterWindow {27 if (metric.derived?.op === "window" && metric.derived.window !== undefined) {28 return metric.derived.window;29 }30 // The registry declares window priority: windows[0] is the primary reading31 // (e.g. cigarettes/coffee/GDP lead with "today", births with "ytd").32 const declared = metric.windows[0];33 if (declared === "today" || declared === "ytd" || declared === "session" || declared === "total") {34 return declared;35 }36 if (metric.kind === "cumulative") return "ytd";37 return "total";38}3940/**41 * Years until a depleting stock reaches zero at the current instantaneous rate:42 * value(t) / (−rate(t) × YEAR_SECONDS). NaN when the stock is not depleting43 * (rate ≥ 0) — the caller renders the "data pending" fallback.44 */45export function depletionYears(model: CounterModel, tMs: number): number {46 const remaining = counterValue(model, tMs);47 const perSecond = rateAt(model, tMs);48 const perYear = -perSecond * YEAR_SECONDS;49 if (!Number.isFinite(remaining) || !(perYear > 0)) return NaN;50 return remaining / perYear;51}5253/**54 * Model powering a metric's card: its own CounterModel when the API materialized55 * one, otherwise the model of its first derived input (rate-of / depletion-countdown56 * / window metrics are transforms over an input model).57 */58export function resolveModel(59 metric: MetricSummary,60 models: Record<string, CounterModel>,61): CounterModel | undefined {62 const own = models[metric.id];63 if (own !== undefined) return own;64 const inputId = metric.derived?.inputs[0]?.id;65 return inputId !== undefined ? models[inputId] : undefined;66}6768/** DisplayHints for the shared formatters, with the unit localized from the registry. */69export function hintsFor(metric: MetricSummary, locale: Locale): DisplayHints {70 return {71 decimals: metric.display.decimals,72 unit: metric.display.unit[locale],73 ...(metric.display.sigFigs !== undefined ? { sigFigs: metric.display.sigFigs } : {}),74 ...(metric.display.scale !== undefined ? { scale: metric.display.scale } : {}),75 };76}7778/**79 * Raw (unformatted) number a card displays at time t, applying the metric's80 * derived transform and window. Pure — time is a parameter. Returns NaN instead81 * of throwing when a session window has no session start yet.82 */83export function displayRawValue(84 metric: MetricSummary,85 model: CounterModel,86 tMs: number,87 window: CounterWindow,88 sessionStartMs?: number,89): number {90 const op = metric.derived?.op;91 if (op === "rate-of") return rateAt(model, tMs);92 if (op === "depletion-countdown") return depletionYears(model, tMs);93 if (window === "total") return counterValue(model, tMs);94 if (window === "session" && sessionStartMs === undefined) return NaN;95 return windowValue(model, tMs, window, sessionStartMs);96}9798/** Whether a card must carry the mandatory "estimation / estimate" label. */99export function isEstimate(metric: MetricSummary): boolean {100 return metric.uncertaintyFraction !== undefined;101}102