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: packages/widget/src/render.ts6 * Purpose: Pure, DOM-free widget helpers — config resolution, model payload parsing, display text building (unit-testable)7 */89import {10 type CounterModel,11 type CounterWindow,12 formatRate,13 formatValue,14 rateAt,15 windowValue,16} from "@earth-now/counter";1718/** Default API origin the widget talks to when data-api is not provided. */19export const DEFAULT_API = "https://www.earth-now.co";2021export interface WidgetConfig {22 metricId: string;23 window: CounterWindow;24 lang: string;25 api: string;26}2728/**29 * Resolve a widget config from a DOMStringMap-like object30 * (data-earth-now-metric / data-window / data-lang / data-api).31 * Returns null when no metric id is declared.32 */33export function resolveConfig(dataset: Partial<Record<string, string>>): WidgetConfig | null {34 const metricId = dataset["earthNowMetric"]?.trim();35 if (!metricId) return null;36 const w = dataset["window"];37 const window: CounterWindow =38 w === "today" || w === "ytd" || w === "session" || w === "total" ? w : "total";39 return {40 metricId,41 window,42 lang: dataset["lang"] ?? "en",43 api: (dataset["api"] ?? DEFAULT_API).replace(/\/+$/, ""),44 };45}4647/** URL of the CounterModel endpoint for a config. */48export function modelUrl(config: WidgetConfig): string {49 return `${config.api}/v1/metrics/${encodeURIComponent(config.metricId)}/model`;50}5152/**53 * Validate a fetched JSON payload into a CounterModel.54 * Accepts either the model itself or an `{ model: ... }` envelope.55 * Throws a descriptive error on anything malformed — the caller renders "data pending".56 */57export function parseModelPayload(json: unknown): CounterModel {58 const candidate =59 typeof json === "object" && json !== null && "model" in json60 ? (json as { model: unknown }).model61 : json;62 if (typeof candidate !== "object" || candidate === null)63 throw new Error("earth-now widget: model payload is not an object");64 const m = candidate as Partial<CounterModel>;65 if (typeof m.metricId !== "string") throw new Error("earth-now widget: model missing metricId");66 if (typeof m.anchorValue !== "number" || !Number.isFinite(m.anchorValue))67 throw new Error("earth-now widget: model missing finite anchorValue");68 if (typeof m.anchorTime !== "string" || Number.isNaN(Date.parse(m.anchorTime)))69 throw new Error("earth-now widget: model missing valid anchorTime");70 if (typeof m.rateFn !== "object" || m.rateFn === null || typeof m.rateFn.kind !== "string")71 throw new Error("earth-now widget: model missing rateFn");72 if (typeof m.displayHints !== "object" || m.displayHints === null)73 throw new Error("earth-now widget: model missing displayHints");74 return m as CounterModel;75}7677export interface DisplayText {78 /** Formatted counter value (applySigFigs false — the ticker animates the trailing digits). */79 value: string;80 /** Formatted instantaneous rate incl. cadence suffix, e.g. "4.3/s". */81 rate: string;82 /** Display unit label from the model's display hints. */83 unit: string;84}8586/**87 * Compute the text a widget shows at instant tMs — pure, time is a parameter.88 * "session" windows use the caller-provided session start (widget load time).89 */90export function buildDisplayText(91 model: CounterModel,92 tMs: number,93 config: WidgetConfig,94 sessionStartMs?: number,95): DisplayText {96 const v = windowValue(model, tMs, config.window, sessionStartMs);97 const opts = { locale: config.lang, applySigFigs: false };98 return {99 value: formatValue(v, model.displayHints, opts),100 rate: formatRate(rateAt(model, tMs), model.displayHints, { locale: config.lang }),101 unit: model.displayHints.unit,102 };103}104