| 1 | <!-- 🤖 Public carbon page: two honest ledgers side by side (docs/comparison-carbon.md). |
| 2 | Fully self-contained: inline CSS/JS, no external requests except |
| 3 | ./carbon.json (relative). The JSON is { updated_at, methodology, |
| 4 | built:{…}, run:{…} }: |
| 5 | built — COMMITTED data (web/status/carbon.json, accrued by |
| 6 | tools/carbon-tally.mjs): the one-time AI cost of BUILDING the app, |
| 7 | republished as-is by deploy/status-publish.sh (token counts + kg only). |
| 8 | run — a LIVE operational estimate the publisher INJECTS at upload |
| 9 | (deploy/status-publish.sh § build_carbon): the ongoing cost of running |
| 10 | every service in the fleet, banded, as a daily rate + cumulative + |
| 11 | per-space. Absent (no live inputs) → the Run section shows a pending |
| 12 | note, never a fake zero. |
| 13 | The JSON stays metric (canonical); the metric/imperial toggle is purely |
| 14 | presentational (?units=imperial|metric, remembered per browser). --> |
| 15 | <!doctype html> |
| 16 | <html lang="en"> |
| 17 | <head> |
| 18 | <meta charset="utf-8" /> |
| 19 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 20 | <meta name="color-scheme" content="light dark" /> |
| 21 | <title>Eleven — the carbon tally</title> |
| 22 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 23 | <link rel="stylesheet" href="./status.css" /> |
| 24 | <style> |
| 25 | /* Carbon-page-only styles — the Built/Run sections, units toggle, everyday |
| 26 | anchors, accrual chart, and the ledger bars' wider columns. Everything |
| 27 | shared (tokens, header, nav, .bars base, #error, footer) is in status.css. */ |
| 28 | |
| 29 | .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 12px 0 24px; } |
| 30 | .row h2 { margin: 0; } |
| 31 | /* Metric/imperial: a labelled two-button group; the selected unit reads by |
| 32 | weight + an underline mark, never color alone. One toggle drives both |
| 33 | sections. */ |
| 34 | .units { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } |
| 35 | .units button { |
| 36 | font: inherit; font-size: 13px; padding: 4px 12px; border: 0; cursor: pointer; |
| 37 | background: var(--surface); color: var(--ink-2); |
| 38 | } |
| 39 | .units button + button { border-left: 1px solid var(--border); } |
| 40 | .units button[aria-pressed="true"] { |
| 41 | color: var(--ink); font-weight: 650; |
| 42 | box-shadow: inset 0 -2px 0 var(--series); |
| 43 | } |
| 44 | .units button:focus-visible { outline: 2px solid var(--series); outline-offset: -2px; } |
| 45 | |
| 46 | /* Two peer sections — Built (one-time) and Run (ongoing). The big heading |
| 47 | names each; sub-heads inside are h3. */ |
| 48 | .part { border-top: 1px solid var(--border); padding-top: 22px; margin-top: 30px; } |
| 49 | .part:first-of-type { border-top: 0; padding-top: 0; margin-top: 8px; } |
| 50 | .part-title { font-size: 19px; font-weight: 650; margin: 0 0 4px; } |
| 51 | .part-lede { color: var(--ink-2); margin: 0 0 16px; } |
| 52 | .part h3 { font-size: 15px; font-weight: 650; margin: 22px 0 10px; } |
| 53 | |
| 54 | .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 12px; } |
| 55 | .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| 56 | .tile .label { font-size: 13px; color: var(--ink-2); } |
| 57 | .tile .value { font-size: 28px; font-weight: 600; margin-top: 2px; } |
| 58 | .equiv { color: var(--ink-2); margin: 0 0 14px; } |
| 59 | #built-notcounted { margin-top: 18px; } |
| 60 | |
| 61 | /* Everyday anchors: a short list, each an "≈ …" line of real text; the mark |
| 62 | is decorative (aria-hidden) so screen readers just read the phrase. */ |
| 63 | .anchors { list-style: none; padding: 0; margin: 2px 0 8px; } |
| 64 | .anchors li { color: var(--ink-2); padding: 3px 0 3px 22px; position: relative; } |
| 65 | .anchors li::before { |
| 66 | content: "\2248"; position: absolute; left: 0; color: var(--series); |
| 67 | font-weight: 650; |
| 68 | } |
| 69 | |
| 70 | /* Cumulative chart (Built): single series (no legend box — the title names |
| 71 | it), 2px line, 10%-wash uncertainty band, hairline gridlines, crosshair + |
| 72 | one tooltip. The ledger list below is the chart's table view. */ |
| 73 | figure { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 8px 6px; position: relative; } |
| 74 | figure svg { display: block; width: 100%; height: auto; } |
| 75 | figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; } |
| 76 | #tip { |
| 77 | display: none; position: absolute; z-index: 1; pointer-events: none; |
| 78 | background: var(--surface); border: 1px solid var(--border); border-radius: 8px; |
| 79 | padding: 8px 10px; font-size: 13px; max-width: 240px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); |
| 80 | } |
| 81 | #tip .big { font-size: 15px; font-weight: 650; } |
| 82 | #tip .sub { color: var(--ink-2); } |
| 83 | .pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; } |
| 84 | |
| 85 | /* Built ledger + Run breakdown both use .bars (base in status.css); they |
| 86 | just want a wider first column + a kg cell. */ |
| 87 | .bars li { grid-template-columns: minmax(120px, 200px) 1fr 5.5em; } |
| 88 | .bars .period { font-size: 14px; font-weight: 600; } |
| 89 | .bars .basis { display: block; font-size: 12px; font-weight: 400; color: var(--ink-2); } |
| 90 | .bars .kg { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } |
| 91 | |
| 92 | #run-pending { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; color: var(--ink-2); } |
| 93 | </style> |
| 94 | </head> |
| 95 | <body> |
| 96 | <div class="wrap"> |
| 97 | <header data-title="The carbon tally"></header> |
| 98 | <nav class="status-nav" aria-label="Status"></nav> |
| 99 | <script src="./status-nav.js"></script> |
| 100 | <p class="lede">Eleven is AI-written, and the honest carbon ledger has two |
| 101 | halves: what it cost to <em>build</em> the app (the AI tokens spent writing |
| 102 | it) and what it costs to <em>run</em> it (every service in the fleet, every |
| 103 | day). Building is one-time and grows only as more AI development happens; |
| 104 | running is ongoing and rises with the fleet. Full methodology and honesty |
| 105 | notes: |
| 106 | <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md">the |
| 107 | carbon comparison</a>.</p> |
| 108 | |
| 109 | <div class="row"> |
| 110 | <p class="asof">Last measured <time id="asof" datetime="">—</time></p> |
| 111 | <div class="units" role="group" aria-label="Measurement units"> |
| 112 | <button type="button" id="u-metric" aria-pressed="false">Metric</button> |
| 113 | <button type="button" id="u-imperial" aria-pressed="false">Imperial</button> |
| 114 | </div> |
| 115 | </div> |
| 116 | |
| 117 | <p id="error" role="alert">Couldn’t load the carbon tally. It may be mid-publish — this page retries every minute.</p> |
| 118 | |
| 119 | <!-- BUILT — the one-time AI cost of making the app --> |
| 120 | <section class="part" id="built" aria-labelledby="h-built"> |
| 121 | <h2 class="part-title" id="h-built">Built — one-time, to make it</h2> |
| 122 | <p class="part-lede">A running estimate of the CO₂e cost of the LLM tokens |
| 123 | spent building Eleven. Token counts are exact; the carbon conversion is an |
| 124 | estimate with wide error bars, because no model provider publishes per-token |
| 125 | figures — <span id="factors">—</span>. This grows only when more AI |
| 126 | development happens.</p> |
| 127 | <div class="tiles"> |
| 128 | <div class="tile"><div class="label">Central estimate</div><div class="value" id="bt-central">—</div></div> |
| 129 | <div class="tile"><div class="label">Honest range</div><div class="value" id="bt-range">—</div></div> |
| 130 | <div class="tile"><div class="label">Output tokens</div><div class="value" id="bt-tokens">—</div></div> |
| 131 | </div> |
| 132 | <p class="equiv" style="margin-bottom:4px">That’s about the same CO₂e as, centrally:</p> |
| 133 | <ul class="anchors" id="built-anchors"></ul> |
| 134 | <p class="equiv" id="built-band"></p> |
| 135 | |
| 136 | <h3 id="h-built-chart">Accrual over time</h3> |
| 137 | <figure> |
| 138 | <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg> |
| 139 | <div id="tip"></div> |
| 140 | <figcaption id="chart-cap">Cumulative CO₂e — the line is the central |
| 141 | estimate, the wash is the low–high band. Exact figures are in the ledger |
| 142 | below.</figcaption> |
| 143 | </figure> |
| 144 | |
| 145 | <h3 id="h-built-entries">The ledger (central estimate per period)</h3> |
| 146 | <ul class="bars" id="built-entries"><li><span class="period">Loading…</span></li></ul> |
| 147 | <p class="equiv" id="built-notcounted"></p> |
| 148 | </section> |
| 149 | |
| 150 | <!-- RUN — the ongoing cost of operating the whole fleet --> |
| 151 | <section class="part" id="run" aria-labelledby="h-run"> |
| 152 | <h2 class="part-title" id="h-run">Run — ongoing, to operate it</h2> |
| 153 | <p class="part-lede">A live estimate of the CO₂e cost of running every |
| 154 | service in the fleet — the router box and its instances, object storage, and |
| 155 | bot inference — banded low to high. It rises as boxes are added; the |
| 156 | per-space figure is what stays meaningful at any size.</p> |
| 157 | |
| 158 | <div id="run-body" hidden> |
| 159 | <div class="tiles"> |
| 160 | <div class="tile"><div class="label">Per day, whole fleet</div><div class="value" id="rt-daily">—</div><div class="label" id="rt-daily-range">—</div></div> |
| 161 | <div class="tile"><div class="label" id="rt-cum-label">Cumulative to date</div><div class="value" id="rt-cum">—</div><div class="label" id="rt-cum-range">—</div></div> |
| 162 | <div class="tile"><div class="label">Per space, per day</div><div class="value" id="rt-space">—</div></div> |
| 163 | </div> |
| 164 | <p class="equiv" style="margin-bottom:4px" id="run-anchors-lede">Cumulatively so far, that’s about the same CO₂e as, centrally:</p> |
| 165 | <ul class="anchors" id="run-anchors"></ul> |
| 166 | <p class="equiv" id="run-band"></p> |
| 167 | |
| 168 | <h3 id="h-run-breakdown">Where the watts go (central, per day)</h3> |
| 169 | <ul class="bars" id="run-bars"></ul> |
| 170 | <p class="equiv" id="run-assumptions"></p> |
| 171 | </div> |
| 172 | <p id="run-pending" hidden>Operational estimate pending — the live fleet |
| 173 | inputs (server type and grid) weren’t available at the last publish. It |
| 174 | fills in on the next successful publish; nothing here is ever shown as a |
| 175 | fake zero.</p> |
| 176 | </section> |
| 177 | |
| 178 | <footer> |
| 179 | <p>The build tally accrues when we re-measure (at least weekly — it’s |
| 180 | committed to the repo and republished with these dashboards). The run |
| 181 | estimate is recomputed live at each publish. How the two compare in full is |
| 182 | in the carbon comparison linked above.</p> |
| 183 | </footer> |
| 184 | </div> |
| 185 | |
| 186 | <script> |
| 187 | // contract: the fields this page reads from ./carbon.json — kept in sync with |
| 188 | // web/status/carbon.json (committed `built`) + the run block the publisher |
| 189 | // injects, and checked by test/status-contract.test.mjs. Built fields resolve |
| 190 | // against the committed file; run fields resolve against the live/dry-run |
| 191 | // fixture (run is absent from the committed file → the Run section is pending). |
| 192 | const FIELDS = [ |
| 193 | "updated_at", |
| 194 | "built.factors_mg_co2e_per_output_token.low", |
| 195 | "built.factors_mg_co2e_per_output_token.central", |
| 196 | "built.factors_mg_co2e_per_output_token.high", |
| 197 | "built.not_counted", |
| 198 | "built.entries[].period_start", "built.entries[].period_end", "built.entries[].source", |
| 199 | "built.entries[].basis", "built.entries[].output_tokens", "built.entries[].kg_low", |
| 200 | "built.entries[].kg_central", "built.entries[].kg_high", "built.entries[].note", |
| 201 | "built.totals.output_tokens", "built.totals.kg_low", "built.totals.kg_central", "built.totals.kg_high", |
| 202 | "run.generated_at", "run.since", "run.spaces", "run.pue", "run.vcpus", |
| 203 | "run.watts.low", "run.watts.central", "run.watts.high", |
| 204 | "run.grid_g_per_kwh.low", "run.grid_g_per_kwh.central", "run.grid_g_per_kwh.high", |
| 205 | "run.daily_kg.low", "run.daily_kg.central", "run.daily_kg.high", |
| 206 | "run.cumulative_kg.low", "run.cumulative_kg.central", "run.cumulative_kg.high", |
| 207 | "run.per_space_daily_kg.low", "run.per_space_daily_kg.central", "run.per_space_daily_kg.high", |
| 208 | "run.services[].name", "run.services[].daily_kg_central", |
| 209 | ]; |
| 210 | |
| 211 | const $ = (id) => document.getElementById(id); |
| 212 | const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); |
| 213 | const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); |
| 214 | const NS = "http://www.w3.org/2000/svg"; |
| 215 | |
| 216 | // Everyday-equivalent factors (all documented + banded + dated in |
| 217 | // docs/comparison-carbon.md § What the anchors mean; snapshot July 2026): |
| 218 | // petrol car ~170 g CO₂e/km, burning 1 L petrol ≈ 2.3 kg (DEFRA class). |
| 219 | // short-haul air ~0.145 kg CO₂e per economy passenger-km INCLUDING the |
| 220 | // non-CO₂ altitude uplift (~1.9× radiative forcing; DEFRA |
| 221 | // short-haul-with-RF class, band 0.13–0.16). Expressed against |
| 222 | // one real short leg — Dublin–London (DUB–LHR) ≈ 460 km great |
| 223 | // circle → ≈ 67 kg CO₂e per economy leg. |
| 224 | // leaf blower 2-stroke ~1 L/h → ~2.3 kg CO₂/h. |
| 225 | // beef ~60 kg CO₂e/kg, land-use-inclusive (Poore & Nemecek 2018). |
| 226 | // tree a mature tree absorbs ~21 kg CO₂/yr. |
| 227 | // These apply to BOTH sections, each scaled to its own magnitude. |
| 228 | const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3; |
| 229 | const FLIGHT_KG_PER_PAX_KM = 0.145, FLIGHT_LEG_KM = 460; |
| 230 | const FLIGHT_LEG_KG = FLIGHT_KG_PER_PAX_KM * FLIGHT_LEG_KM; // ≈ 67 kg / leg |
| 231 | const LEAFBLOWER_KG_PER_H = 2.3; |
| 232 | const BEEF_KG_CO2E_PER_KG = 60; |
| 233 | const TREE_KG_PER_YEAR = 21; |
| 234 | |
| 235 | // The JSON is canonical metric; imperial is a display conversion only. Distances |
| 236 | // and volumes convert (car km/litres); the new anchors are mass/time/count — |
| 237 | // mass converts via massF (beef), counts/durations stay unit-agnostic. |
| 238 | const UNITS = { |
| 239 | metric: { mass: "kg", massF: 1, dist: "km", distF: 1, vol: "litres", volF: 1 }, |
| 240 | imperial: { mass: "lb", massF: 2.20462, dist: "miles", distF: 0.621371, vol: "US gallons", volF: 0.264172 }, |
| 241 | }; |
| 242 | // Resolution: ?units= wins (and is remembered), then the saved choice, then |
| 243 | // the reader's locale (US customary regions default imperial). |
| 244 | function initialUnits() { |
| 245 | const q = new URLSearchParams(location.search).get("units"); |
| 246 | if (q === "metric" || q === "imperial") { try { localStorage.setItem("carbonUnits", q); } catch {} return q; } |
| 247 | try { |
| 248 | const saved = localStorage.getItem("carbonUnits"); |
| 249 | if (saved === "metric" || saved === "imperial") return saved; |
| 250 | } catch {} |
| 251 | const region = (new Intl.Locale(navigator.language || "en").region || "").toUpperCase(); |
| 252 | return ["US", "LR", "MM"].includes(region) ? "imperial" : "metric"; |
| 253 | } |
| 254 | let units = initialUnits(); |
| 255 | const U = () => UNITS[units]; |
| 256 | // Whole numbers once they're big enough to not need the decimal (used by the |
| 257 | // chart's kg-scale axis). |
| 258 | const nice = (v) => (Math.abs(v) >= 20 ? Math.round(v) : Math.round(v * 10) / 10); |
| 259 | const mass = (kg) => nice(kg * U().massF); |
| 260 | // Adaptive precision for a plain number: fewer decimals the bigger it is. |
| 261 | function fmtNum(v) { |
| 262 | const a = Math.abs(v); |
| 263 | const r = a >= 100 ? Math.round(v) : a >= 1 ? Math.round(v * 10) / 10 |
| 264 | : a >= 0.1 ? Math.round(v * 100) / 100 : Math.round(v * 1000) / 1000; |
| 265 | return r.toLocaleString(); |
| 266 | } |
| 267 | // Mass, adaptive across scales so tiny run figures don't collapse to "0 kg": |
| 268 | // grams under a kilo (metric), ounces under a pound (imperial). |
| 269 | function fmtMass(kg) { |
| 270 | if (units === "metric") return Math.abs(kg) < 1 ? fmtNum(kg * 1000) + " g" : fmtNum(kg) + " kg"; |
| 271 | const lb = kg * UNITS.imperial.massF; |
| 272 | return Math.abs(lb) < 1 ? fmtNum(lb * 16) + " oz" : fmtNum(lb) + " lb"; |
| 273 | } |
| 274 | // A low–high pair: collapse to one unit when both ends share it, else keep both. |
| 275 | function rangeStr(lo, hi) { |
| 276 | const a = fmtMass(lo), b = fmtMass(hi); |
| 277 | const unit = (s) => s.replace(/^[\d.,]+\s*/, ""); |
| 278 | return unit(a) === unit(b) ? a.replace(/\s*\S+$/, "") + "–" + b : a + " – " + b; |
| 279 | } |
| 280 | const plur = (n, s) => n + " " + (n === 1 ? s : s + "s"); |
| 281 | |
| 282 | let lastData = null; |
| 283 | |
| 284 | // ---- everyday anchors (shared by both sections) ------------------------------ |
| 285 | function flightPhrase(legs) { |
| 286 | if (legs >= 0.95) return "about " + plur(nice(legs), "Dublin–London economy flight"); |
| 287 | const pct = legs * 100; |
| 288 | return "about " + (pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10) + "% of one Dublin–London economy flight"; |
| 289 | } |
| 290 | function durationPhrase(hours) { |
| 291 | if (hours >= 1) return "for about " + plur(nice(hours), "hour"); |
| 292 | const mins = hours * 60; |
| 293 | if (mins >= 1) return "for about " + plur(Math.round(mins), "minute"); |
| 294 | return "for about " + plur(Math.max(1, Math.round(mins * 60)), "second"); |
| 295 | } |
| 296 | function treePhrase(years) { |
| 297 | if (years >= 1) return "about " + plur(nice(years), "tree-year") + " to soak it back up"; |
| 298 | const days = years * 365; |
| 299 | if (days >= 1) return "about " + plur(Math.round(days), "tree-day") + " to soak it back up"; |
| 300 | return "under a tree-day to soak it back up"; |
| 301 | } |
| 302 | // Build the anchor list for a given central-estimate kg. Central values only; |
| 303 | // the band is noted once beneath (in the *-band element). |
| 304 | function anchorList(ul, kg) { |
| 305 | ul.replaceChildren(); |
| 306 | const U0 = U(); |
| 307 | const items = [ |
| 308 | "driving a petrol car about " + fmtNum(kg * 1000 / CAR_G_PER_KM * U0.distF) + " " + U0.dist + |
| 309 | " (burning ~" + fmtNum(kg / PETROL_KG_PER_L * U0.volF) + " " + U0.vol + " of petrol)", |
| 310 | flightPhrase(kg / FLIGHT_LEG_KG), |
| 311 | "running a petrol leaf blower " + durationPhrase(kg / LEAFBLOWER_KG_PER_H), |
| 312 | "about " + fmtMass(kg / BEEF_KG_CO2E_PER_KG) + " of beef", |
| 313 | treePhrase(kg / TREE_KG_PER_YEAR), |
| 314 | ]; |
| 315 | for (const t of items) { |
| 316 | const li = document.createElement("li"); |
| 317 | li.textContent = t; |
| 318 | ul.append(li); |
| 319 | } |
| 320 | } |
| 321 | const bandNote = (lo, hi) => "Central estimates; the honest range is " + rangeStr(lo, hi) + |
| 322 | " CO₂e. Flights are economy short-haul, including the altitude (radiative-forcing) uplift."; |
| 323 | |
| 324 | // All rendering is textContent / createElement — never innerHTML of fetched |
| 325 | // data, even though the feed is our own. |
| 326 | function render(d) { |
| 327 | lastData = d; |
| 328 | $("u-metric").setAttribute("aria-pressed", String(units === "metric")); |
| 329 | $("u-imperial").setAttribute("aria-pressed", String(units === "imperial")); |
| 330 | |
| 331 | $("asof").dateTime = d.updated_at; |
| 332 | $("asof").textContent = new Date(d.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| 333 | |
| 334 | renderBuilt(d.built); |
| 335 | renderRun(d.run); |
| 336 | } |
| 337 | |
| 338 | function renderBuilt(b) { |
| 339 | const f = b.factors_mg_co2e_per_output_token; |
| 340 | $("factors").textContent = "we band public calibration points at " + f.low + "–" + f.high + |
| 341 | " mg CO₂e per output token, central " + f.central + " mg"; |
| 342 | |
| 343 | $("bt-central").textContent = "~" + fmtMass(b.totals.kg_central) + " CO₂e"; |
| 344 | $("bt-range").textContent = rangeStr(b.totals.kg_low, b.totals.kg_high); |
| 345 | $("bt-tokens").textContent = compact.format(b.totals.output_tokens); |
| 346 | |
| 347 | anchorList($("built-anchors"), b.totals.kg_central); |
| 348 | $("built-band").textContent = bandNote(b.totals.kg_low, b.totals.kg_high); |
| 349 | |
| 350 | $("chart-cap").textContent = "Cumulative " + U().mass + " CO₂e — the line is the central estimate, " + |
| 351 | "the wash is the low–high band. Exact figures are in the ledger below."; |
| 352 | builtChart(b); |
| 353 | |
| 354 | const list = $("built-entries"); |
| 355 | list.replaceChildren(); |
| 356 | // Ledger order = time order, even when contributor machines interleave. |
| 357 | const entries = (b.entries || []).slice().sort((a, c) => Date.parse(a.period_end) - Date.parse(c.period_end)); |
| 358 | const max = Math.max(1, ...entries.map((e) => e.kg_central)); |
| 359 | for (const e of entries) { |
| 360 | const li = document.createElement("li"); |
| 361 | const period = document.createElement("span"); |
| 362 | period.className = "period"; |
| 363 | period.textContent = day(e.period_start) + " – " + day(e.period_end); |
| 364 | const basis = document.createElement("span"); |
| 365 | basis.className = "basis"; |
| 366 | basis.textContent = e.source + " · " + e.basis + " · " + compact.format(e.output_tokens) + " output tokens"; |
| 367 | basis.title = e.note || ""; |
| 368 | period.append(basis); |
| 369 | const track = document.createElement("span"); |
| 370 | track.className = "track"; |
| 371 | track.setAttribute("aria-hidden", "true"); |
| 372 | const bar = document.createElement("span"); |
| 373 | bar.className = "bar"; |
| 374 | bar.style.width = (100 * e.kg_central / max) + "%"; |
| 375 | bar.style.display = "block"; |
| 376 | track.append(bar); |
| 377 | const kg = document.createElement("span"); |
| 378 | kg.className = "kg"; |
| 379 | kg.textContent = fmtMass(e.kg_central); |
| 380 | li.append(period, track, kg); |
| 381 | list.append(li); |
| 382 | } |
| 383 | |
| 384 | $("built-notcounted").textContent = "Known undercounts, on purpose: " + b.not_counted + "."; |
| 385 | } |
| 386 | |
| 387 | function renderRun(r) { |
| 388 | const body = $("run-body"), pending = $("run-pending"); |
| 389 | if (!r) { body.hidden = true; pending.hidden = false; return; } |
| 390 | body.hidden = false; pending.hidden = true; |
| 391 | |
| 392 | $("rt-daily").textContent = "~" + fmtMass(r.daily_kg.central); |
| 393 | $("rt-daily-range").textContent = "range " + rangeStr(r.daily_kg.low, r.daily_kg.high); |
| 394 | $("rt-cum-label").textContent = "Cumulative since " + day(r.since); |
| 395 | $("rt-cum").textContent = "~" + fmtMass(r.cumulative_kg.central); |
| 396 | $("rt-cum-range").textContent = "range " + rangeStr(r.cumulative_kg.low, r.cumulative_kg.high); |
| 397 | $("rt-space").textContent = "~" + fmtMass(r.per_space_daily_kg.central); |
| 398 | |
| 399 | // Anchors scale to the cumulative-to-date central — the run section's most |
| 400 | // legible magnitude as the fleet grows (a single day is grams). |
| 401 | $("run-anchors-lede").textContent = "Cumulatively since " + day(r.since) + |
| 402 | ", that’s about the same CO₂e as, centrally:"; |
| 403 | anchorList($("run-anchors"), r.cumulative_kg.central); |
| 404 | $("run-band").textContent = bandNote(r.cumulative_kg.low, r.cumulative_kg.high); |
| 405 | |
| 406 | // Service breakdown: central daily kg per service (rows text, bars aria-hidden). |
| 407 | const list = $("run-bars"); |
| 408 | list.replaceChildren(); |
| 409 | const max = Math.max(1e-9, ...r.services.map((s) => s.daily_kg_central)); |
| 410 | for (const s of r.services) { |
| 411 | const li = document.createElement("li"); |
| 412 | const period = document.createElement("span"); |
| 413 | period.className = "period"; |
| 414 | period.textContent = s.name; |
| 415 | const track = document.createElement("span"); |
| 416 | track.className = "track"; |
| 417 | track.setAttribute("aria-hidden", "true"); |
| 418 | const bar = document.createElement("span"); |
| 419 | bar.className = "bar"; |
| 420 | bar.style.width = (100 * s.daily_kg_central / max) + "%"; |
| 421 | bar.style.display = "block"; |
| 422 | track.append(bar); |
| 423 | const kg = document.createElement("span"); |
| 424 | kg.className = "kg"; |
| 425 | kg.textContent = fmtMass(s.daily_kg_central); |
| 426 | li.append(period, track, kg); |
| 427 | list.append(li); |
| 428 | } |
| 429 | |
| 430 | const asof = new Date(r.generated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| 431 | $("run-assumptions").textContent = "Estimated from ~" + r.vcpus.toLocaleString() + " vCPUs drawing ~" + |
| 432 | r.watts.central.toLocaleString() + " W (central), a ~" + r.grid_g_per_kwh.central.toLocaleString() + |
| 433 | " g CO₂e/kWh grid and PUE ~" + r.pue + ", plus small explicit terms for object storage and bot " + |
| 434 | "inference; normalised over " + plur(r.spaces, "space") + ". Recomputed live at each publish (as of " + |
| 435 | asof + ")."; |
| 436 | } |
| 437 | |
| 438 | // Cumulative line over time (Built): central line + low–high wash. Crosshair |
| 439 | // snaps to the nearest measurement; points are keyboard-focusable with the same |
| 440 | // tooltip. Values are cumulated in canonical kg, converted to the display unit |
| 441 | // at the last moment so labels, ticks, and tooltip always agree. |
| 442 | function builtChart(b) { |
| 443 | const svg = $("chart"); |
| 444 | svg.replaceChildren(); |
| 445 | const round1 = (v) => Math.round(v * 10) / 10; |
| 446 | const disp = (kg) => nice(kg * U().massF); |
| 447 | // Cumulate in period_end order across ALL sources; the curve starts at the |
| 448 | // earliest period_start any source measured from. |
| 449 | const ordered = b.entries.slice().sort((a, c) => Date.parse(a.period_end) - Date.parse(c.period_end)); |
| 450 | const t0 = Math.min(...b.entries.map((e) => Date.parse(e.period_start))); |
| 451 | const pts = [{ t: t0, c: 0, lo: 0, hi: 0 }]; |
| 452 | let c = 0, lo = 0, hi = 0; |
| 453 | for (const e of ordered) { |
| 454 | c = round1(c + e.kg_central); lo = round1(lo + e.kg_low); hi = round1(hi + e.kg_high); |
| 455 | pts.push({ t: Date.parse(e.period_end), c, lo, hi, e }); |
| 456 | } |
| 457 | const W = 720, H = 240, m = { l: 48, r: 86, t: 14, b: 26 }; |
| 458 | const span = Math.max(1, pts[pts.length - 1].t - pts[0].t); |
| 459 | const x = (t) => m.l + (W - m.l - m.r) * (t - pts[0].t) / span; |
| 460 | // Scale + ticks live in DISPLAY units so they stay clean after conversion. |
| 461 | const hiDisp = hi * U().massF; |
| 462 | let step = 1; |
| 463 | for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hiDisp / s <= 4) { step = s; break; } } |
| 464 | const yMax = Math.max(step, Math.ceil(hiDisp / step) * step); |
| 465 | const y = (kg) => H - m.b - (H - m.b - m.t) * (kg * U().massF) / yMax; |
| 466 | const yD = (v) => H - m.b - (H - m.b - m.t) * v / yMax; |
| 467 | const el = (name, attrs, text) => { |
| 468 | const n = document.createElementNS(NS, name); |
| 469 | for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); |
| 470 | if (text !== undefined) n.textContent = text; |
| 471 | svg.append(n); |
| 472 | return n; |
| 473 | }; |
| 474 | |
| 475 | svg.setAttribute("aria-label", "Cumulative carbon estimate from " + day(pts[0].t) + |
| 476 | " to " + day(pts[pts.length - 1].t) + ": central " + disp(c) + " " + U().mass + |
| 477 | " CO₂e, range " + disp(lo) + " to " + disp(hi) + " " + U().mass + "."); |
| 478 | |
| 479 | for (let v = 0; v <= yMax; v += step) { |
| 480 | el("line", { x1: m.l, x2: W - m.r, y1: yD(v), y2: yD(v), stroke: "var(--grid)", "stroke-width": 1 }); |
| 481 | el("text", { x: m.l - 8, y: yD(v) + 4, "text-anchor": "end", "font-size": 12, |
| 482 | fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, String(v)); |
| 483 | } |
| 484 | for (const p of pts) { |
| 485 | el("text", { x: x(p.t), y: H - 8, "text-anchor": "middle", "font-size": 12, fill: "var(--ink-2)" }, day(p.t)); |
| 486 | } |
| 487 | |
| 488 | const path = (pick) => pts.map((p, i) => (i ? "L" : "M") + x(p.t) + " " + y(pick(p))).join(" "); |
| 489 | el("path", { d: path((p) => p.hi) + " " + pts.slice().reverse().map((p) => "L" + x(p.t) + " " + y(p.lo)).join(" ") + " Z", |
| 490 | fill: "var(--series)", opacity: 0.1, stroke: "none" }); |
| 491 | el("path", { d: path((p) => p.c), fill: "none", stroke: "var(--series)", |
| 492 | "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }); |
| 493 | |
| 494 | // Selective direct labels at the right edge; ticks + tooltip carry the rest. |
| 495 | const end = pts[pts.length - 1]; |
| 496 | el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + disp(end.hi)); |
| 497 | el("text", { x: x(end.t) + 10, y: y(end.c) + 4, "font-size": 12, "font-weight": 650, fill: "var(--ink)" }, "~" + disp(end.c) + " " + U().mass); |
| 498 | el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + disp(end.lo)); |
| 499 | |
| 500 | const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" }); |
| 501 | const dots = pts.map((p) => el("circle", { cx: x(p.t), cy: y(p.c), r: 4.5, fill: "var(--series)", |
| 502 | stroke: "var(--surface)", "stroke-width": 2 })); |
| 503 | |
| 504 | const tip = $("tip"); |
| 505 | const show = (p, dot) => { |
| 506 | hair.setAttribute("x1", x(p.t)); hair.setAttribute("x2", x(p.t)); |
| 507 | hair.setAttribute("visibility", "visible"); |
| 508 | dots.forEach((d2, i) => d2.setAttribute("r", pts[i] === p ? 6 : 4.5)); |
| 509 | tip.replaceChildren(); |
| 510 | const big = document.createElement("div"); |
| 511 | big.className = "big"; |
| 512 | big.textContent = "~" + disp(p.c) + " " + U().mass + " CO₂e"; |
| 513 | const when = document.createElement("div"); |
| 514 | when.className = "sub"; |
| 515 | when.textContent = "by " + day(p.t) + " · range " + disp(p.lo) + "–" + disp(p.hi) + " " + U().mass; |
| 516 | tip.append(big, when); |
| 517 | if (p.e) { |
| 518 | const delta = document.createElement("div"); |
| 519 | delta.className = "sub"; |
| 520 | delta.textContent = "+" + disp(p.e.kg_central) + " " + U().mass + " (" + p.e.source + ", " + p.e.basis + ", " + |
| 521 | compact.format(p.e.output_tokens) + " output tokens)"; |
| 522 | tip.append(delta); |
| 523 | } |
| 524 | tip.style.display = "block"; |
| 525 | const box = svg.getBoundingClientRect(); |
| 526 | const px = (x(p.t) / W) * box.width; |
| 527 | tip.style.left = Math.min(Math.max(0, px - 60), box.width - tip.offsetWidth - 4) + "px"; |
| 528 | tip.style.top = Math.max(0, (y(p.c) / H) * box.height - tip.offsetHeight - 14) + "px"; |
| 529 | void dot; |
| 530 | }; |
| 531 | const hide = () => { |
| 532 | hair.setAttribute("visibility", "hidden"); |
| 533 | dots.forEach((d2) => d2.setAttribute("r", 4.5)); |
| 534 | tip.style.display = "none"; |
| 535 | }; |
| 536 | |
| 537 | // The crosshair finds the X: the whole plot is the hit area, snapping to the |
| 538 | // nearest measurement — nobody has to land on a 2px line. |
| 539 | svg.addEventListener("pointermove", (ev) => { |
| 540 | const box = svg.getBoundingClientRect(); |
| 541 | const mx = ((ev.clientX - box.left) / box.width) * W; |
| 542 | let best = pts[0]; |
| 543 | for (const p of pts) if (Math.abs(x(p.t) - mx) < Math.abs(x(best.t) - mx)) best = p; |
| 544 | show(best); |
| 545 | }); |
| 546 | svg.addEventListener("pointerleave", hide); |
| 547 | pts.forEach((p, i) => { |
| 548 | const dot = dots[i]; |
| 549 | dot.classList.add("pt"); |
| 550 | dot.setAttribute("tabindex", "0"); |
| 551 | dot.setAttribute("role", "img"); |
| 552 | dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + disp(p.c) + " " + U().mass + |
| 553 | " CO₂e, range " + disp(p.lo) + " to " + disp(p.hi)); |
| 554 | dot.addEventListener("focus", () => show(p, dot)); |
| 555 | dot.addEventListener("blur", hide); |
| 556 | }); |
| 557 | } |
| 558 | |
| 559 | function setUnits(next) { |
| 560 | if (next === units) return; |
| 561 | units = next; |
| 562 | try { localStorage.setItem("carbonUnits", units); } catch {} |
| 563 | if (lastData) render(lastData); |
| 564 | } |
| 565 | $("u-metric").addEventListener("click", () => setUnits("metric")); |
| 566 | $("u-imperial").addEventListener("click", () => setUnits("imperial")); |
| 567 | |
| 568 | async function refresh() { |
| 569 | try { |
| 570 | const res = await fetch("./carbon.json", { cache: "no-cache" }); |
| 571 | if (!res.ok) throw new Error("HTTP " + res.status); |
| 572 | render(await res.json()); |
| 573 | $("error").classList.remove("show"); |
| 574 | } catch (e) { |
| 575 | // Keep the previous render (no skeleton flash); surface the failure inline. |
| 576 | $("error").classList.add("show"); |
| 577 | } |
| 578 | } |
| 579 | refresh(); |
| 580 | setInterval(refresh, 60 * 1000); |
| 581 | void FIELDS; |
| 582 | </script> |
| 583 | </body> |
| 584 | </html> |
| 585 | |