/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/widget/src/widget.ts * Purpose: Embeddable widget entrypoint — mounts live counters on [data-earth-now-metric] elements (IIFE, zero runtime deps) */ import type { CounterModel } from "@earth-now/counter"; import { type WidgetConfig, buildDisplayText, modelUrl, parseModelPayload, resolveConfig, } from "./render.js"; /** "session" windows count from widget load time. */ const SESSION_START_MS = Date.now(); /** Re-fetch the model every 15 minutes (no SSE in v0.1 to keep size down). */ const REFRESH_MS = 15 * 60 * 1000; const FONT_STACK = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; interface WidgetInstance { config: WidgetConfig; valueEl: HTMLElement; rateEl: HTMLElement; model: CounterModel | null; } const instances: WidgetInstance[] = []; let rafRunning = false; function styleAttribution(a: HTMLAnchorElement): void { // MANDATORY attribution — license/attribution is a product commitment. Never remove. a.href = "https://earth-now.co"; a.target = "_blank"; a.rel = "noopener"; a.textContent = "earth-now.co"; const s = a.style; s.color = "#7aa2ff"; s.fontSize = "10px"; s.textDecoration = "none"; s.letterSpacing = "0.04em"; s.marginTop = "4px"; } function buildCard(host: HTMLElement, config: WidgetConfig): WidgetInstance { const card = document.createElement("div"); const cs = card.style; cs.display = "inline-flex"; cs.flexDirection = "column"; cs.alignItems = "flex-start"; cs.background = "#0b1220"; cs.color = "#e6edf7"; cs.border = "1px solid #1d2a44"; cs.borderRadius = "14px"; cs.padding = "10px 16px"; cs.fontFamily = FONT_STACK; cs.lineHeight = "1.35"; cs.boxSizing = "border-box"; cs.minWidth = "160px"; const label = document.createElement("div"); label.textContent = config.metricId.replace(/_/g, " "); label.style.fontSize = "10px"; label.style.textTransform = "uppercase"; label.style.letterSpacing = "0.08em"; label.style.color = "#8b98b3"; const valueEl = document.createElement("div"); valueEl.textContent = "—"; valueEl.style.fontSize = "22px"; valueEl.style.fontWeight = "700"; valueEl.style.fontVariantNumeric = "tabular-nums"; valueEl.style.whiteSpace = "nowrap"; const rateEl = document.createElement("div"); rateEl.textContent = ""; rateEl.style.fontSize = "11px"; rateEl.style.color = "#8b98b3"; rateEl.style.fontVariantNumeric = "tabular-nums"; const attribution = document.createElement("a"); styleAttribution(attribution); card.appendChild(label); card.appendChild(valueEl); card.appendChild(rateEl); card.appendChild(attribution); host.appendChild(card); return { config, valueEl, rateEl, model: null }; } function renderError(inst: WidgetInstance): void { inst.valueEl.textContent = "—"; inst.rateEl.textContent = "data pending"; } function tick(): void { const now = Date.now(); for (const inst of instances) { if (!inst.model) continue; try { const text = buildDisplayText(inst.model, now, inst.config, SESSION_START_MS); inst.valueEl.textContent = `${text.value} ${text.unit}`; inst.rateEl.textContent = text.rate; } catch { inst.model = null; renderError(inst); } } requestAnimationFrame(tick); } function startLoop(): void { if (rafRunning) return; rafRunning = true; requestAnimationFrame(tick); } function loadModel(inst: WidgetInstance): void { fetch(modelUrl(inst.config)) .then((res) => { if (!res.ok) throw new Error(`earth-now widget: HTTP ${res.status}`); return res.json(); }) .then((json: unknown) => { inst.model = parseModelPayload(json); }) .catch(() => { inst.model = null; renderError(inst); }) .finally(() => { setTimeout(() => loadModel(inst), REFRESH_MS); }); } function mount(node: HTMLElement): void { if (node.dataset["earthNowMounted"] === "1") return; node.dataset["earthNowMounted"] = "1"; const config = resolveConfig(node.dataset); if (!config) return; let host = node; if (node.tagName === "SCRIPT") { host = document.createElement("div"); node.insertAdjacentElement("afterend", host); } const inst = buildCard(host, config); instances.push(inst); loadModel(inst); startLoop(); } function init(): void { document.querySelectorAll("[data-earth-now-metric]").forEach(mount); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init, { once: true }); } else { init(); }