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/widget.ts6 * Purpose: Embeddable widget entrypoint — mounts live counters on [data-earth-now-metric] elements (IIFE, zero runtime deps)7 */89import type { CounterModel } from "@earth-now/counter";10import {11 type WidgetConfig,12 buildDisplayText,13 modelUrl,14 parseModelPayload,15 resolveConfig,16} from "./render.js";1718/** "session" windows count from widget load time. */19const SESSION_START_MS = Date.now();2021/** Re-fetch the model every 15 minutes (no SSE in v0.1 to keep size down). */22const REFRESH_MS = 15 * 60 * 1000;2324const FONT_STACK =25 "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";2627interface WidgetInstance {28 config: WidgetConfig;29 valueEl: HTMLElement;30 rateEl: HTMLElement;31 model: CounterModel | null;32}3334const instances: WidgetInstance[] = [];35let rafRunning = false;3637function styleAttribution(a: HTMLAnchorElement): void {38 // MANDATORY attribution — license/attribution is a product commitment. Never remove.39 a.href = "https://earth-now.co";40 a.target = "_blank";41 a.rel = "noopener";42 a.textContent = "earth-now.co";43 const s = a.style;44 s.color = "#7aa2ff";45 s.fontSize = "10px";46 s.textDecoration = "none";47 s.letterSpacing = "0.04em";48 s.marginTop = "4px";49}5051function buildCard(host: HTMLElement, config: WidgetConfig): WidgetInstance {52 const card = document.createElement("div");53 const cs = card.style;54 cs.display = "inline-flex";55 cs.flexDirection = "column";56 cs.alignItems = "flex-start";57 cs.background = "#0b1220";58 cs.color = "#e6edf7";59 cs.border = "1px solid #1d2a44";60 cs.borderRadius = "14px";61 cs.padding = "10px 16px";62 cs.fontFamily = FONT_STACK;63 cs.lineHeight = "1.35";64 cs.boxSizing = "border-box";65 cs.minWidth = "160px";6667 const label = document.createElement("div");68 label.textContent = config.metricId.replace(/_/g, " ");69 label.style.fontSize = "10px";70 label.style.textTransform = "uppercase";71 label.style.letterSpacing = "0.08em";72 label.style.color = "#8b98b3";7374 const valueEl = document.createElement("div");75 valueEl.textContent = "—";76 valueEl.style.fontSize = "22px";77 valueEl.style.fontWeight = "700";78 valueEl.style.fontVariantNumeric = "tabular-nums";79 valueEl.style.whiteSpace = "nowrap";8081 const rateEl = document.createElement("div");82 rateEl.textContent = "";83 rateEl.style.fontSize = "11px";84 rateEl.style.color = "#8b98b3";85 rateEl.style.fontVariantNumeric = "tabular-nums";8687 const attribution = document.createElement("a");88 styleAttribution(attribution);8990 card.appendChild(label);91 card.appendChild(valueEl);92 card.appendChild(rateEl);93 card.appendChild(attribution);94 host.appendChild(card);9596 return { config, valueEl, rateEl, model: null };97}9899function renderError(inst: WidgetInstance): void {100 inst.valueEl.textContent = "—";101 inst.rateEl.textContent = "data pending";102}103104function tick(): void {105 const now = Date.now();106 for (const inst of instances) {107 if (!inst.model) continue;108 try {109 const text = buildDisplayText(inst.model, now, inst.config, SESSION_START_MS);110 inst.valueEl.textContent = `${text.value} ${text.unit}`;111 inst.rateEl.textContent = text.rate;112 } catch {113 inst.model = null;114 renderError(inst);115 }116 }117 requestAnimationFrame(tick);118}119120function startLoop(): void {121 if (rafRunning) return;122 rafRunning = true;123 requestAnimationFrame(tick);124}125126function loadModel(inst: WidgetInstance): void {127 fetch(modelUrl(inst.config))128 .then((res) => {129 if (!res.ok) throw new Error(`earth-now widget: HTTP ${res.status}`);130 return res.json();131 })132 .then((json: unknown) => {133 inst.model = parseModelPayload(json);134 })135 .catch(() => {136 inst.model = null;137 renderError(inst);138 })139 .finally(() => {140 setTimeout(() => loadModel(inst), REFRESH_MS);141 });142}143144function mount(node: HTMLElement): void {145 if (node.dataset["earthNowMounted"] === "1") return;146 node.dataset["earthNowMounted"] = "1";147 const config = resolveConfig(node.dataset);148 if (!config) return;149 let host = node;150 if (node.tagName === "SCRIPT") {151 host = document.createElement("div");152 node.insertAdjacentElement("afterend", host);153 }154 const inst = buildCard(host, config);155 instances.push(inst);156 loadModel(inst);157 startLoop();158}159160function init(): void {161 document.querySelectorAll<HTMLElement>("[data-earth-now-metric]").forEach(mount);162}163164if (document.readyState === "loading") {165 document.addEventListener("DOMContentLoaded", init, { once: true });166} else {167 init();168}169