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%
2.3 KB · 62 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    apps/api/src/badge.ts6 * Purpose: Server-side SVG badge rendering using the SAME counterValue/formatValue as every client runtime7 */89import { type CounterModel, counterValue, formatValue } from "@earth-now/counter";10import type { MetricEntry } from "@earth-now/registry";1112const FONT = "Verdana,Geneva,DejaVu Sans,sans-serif";13const ATTRIBUTION = "earth-now.co";14const HEIGHT = 28;15const PAD = 12;16const GAP = 12;1718const XML_ESCAPES: Record<string, string> = {19  "&": "&amp;",20  "<": "&lt;",21  ">": "&gt;",22  '"': "&quot;",23  "'": "&apos;",24};2526function escapeXml(text: string): string {27  return text.replace(/[&<>"']/g, (c) => XML_ESCAPES[c] ?? c);28}2930/**31 * Dark pill badge. The value is evaluated with the shared runtime at render32 * time; badges are static contexts so the sigFigs honesty cap applies strictly.33 * A metric without a deployable model renders "data pending" (never NaN).34 */35export function renderBadgeSvg(36  entry: MetricEntry,37  model: CounterModel | undefined,38  nowMs: number,39): string {40  const name = entry.name.en;41  const valueText = model42    ? `${formatValue(counterValue(model, nowMs), model.displayHints, { applySigFigs: true })} ${model.displayHints.unit}`.trim()43    : "data pending";4445  // Approximate text advance widths (no font metrics server-side by design).46  const nameW = Math.ceil(name.length * 6.4);47  const valueW = Math.ceil(valueText.length * 7.4);48  const attrW = Math.ceil(ATTRIBUTION.length * 4.8);49  const width = PAD + nameW + GAP + valueW + GAP + attrW + PAD;50  const label = `${name}: ${valueText}`;5152  return [53    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" role="img" aria-label="${escapeXml(label)}">`,54    `<title>${escapeXml(label)} — ${ATTRIBUTION}</title>`,55    `<rect width="${width}" height="${HEIGHT}" rx="${HEIGHT / 2}" fill="#0f172a"/>`,56    `<text x="${PAD}" y="18" font-family="${FONT}" font-size="11" fill="#94a3b8">${escapeXml(name)}</text>`,57    `<text x="${PAD + nameW + GAP}" y="18" font-family="${FONT}" font-size="12" font-weight="bold" fill="${model ? "#f1f5f9" : "#fbbf24"}">${escapeXml(valueText)}</text>`,58    `<text x="${width - PAD}" y="18" text-anchor="end" font-family="${FONT}" font-size="8" fill="#64748b">${ATTRIBUTION}</text>`,59    `</svg>`,60  ].join("");61}62