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%
4.6 KB · 169 lines typescript
Raw Blame History
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