/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/api/src/badge.ts * Purpose: Server-side SVG badge rendering using the SAME counterValue/formatValue as every client runtime */ import { type CounterModel, counterValue, formatValue } from "@earth-now/counter"; import type { MetricEntry } from "@earth-now/registry"; const FONT = "Verdana,Geneva,DejaVu Sans,sans-serif"; const ATTRIBUTION = "earth-now.co"; const HEIGHT = 28; const PAD = 12; const GAP = 12; const XML_ESCAPES: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; function escapeXml(text: string): string { return text.replace(/[&<>"']/g, (c) => XML_ESCAPES[c] ?? c); } /** * Dark pill badge. The value is evaluated with the shared runtime at render * time; badges are static contexts so the sigFigs honesty cap applies strictly. * A metric without a deployable model renders "data pending" (never NaN). */ export function renderBadgeSvg( entry: MetricEntry, model: CounterModel | undefined, nowMs: number, ): string { const name = entry.name.en; const valueText = model ? `${formatValue(counterValue(model, nowMs), model.displayHints, { applySigFigs: true })} ${model.displayHints.unit}`.trim() : "data pending"; // Approximate text advance widths (no font metrics server-side by design). const nameW = Math.ceil(name.length * 6.4); const valueW = Math.ceil(valueText.length * 7.4); const attrW = Math.ceil(ATTRIBUTION.length * 4.8); const width = PAD + nameW + GAP + valueW + GAP + attrW + PAD; const label = `${name}: ${valueText}`; return [ ``, `${escapeXml(label)} — ${ATTRIBUTION}`, ``, `${escapeXml(name)}`, `${escapeXml(valueText)}`, `${ATTRIBUTION}`, ``, ].join(""); }