eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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).
192const 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
211const $ = (id) => document.getElementById(id);
212const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
213const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
214const 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.
228const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3;
229const FLIGHT_KG_PER_PAX_KM = 0.145, FLIGHT_LEG_KM = 460;
230const FLIGHT_LEG_KG = FLIGHT_KG_PER_PAX_KM * FLIGHT_LEG_KM; // ≈ 67 kg / leg
231const LEAFBLOWER_KG_PER_H = 2.3;
232const BEEF_KG_CO2E_PER_KG = 60;
233const 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.
238const 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).
244function 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}
254let units = initialUnits();
255const 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).
258const nice = (v) => (Math.abs(v) >= 20 ? Math.round(v) : Math.round(v * 10) / 10);
259const mass = (kg) => nice(kg * U().massF);
260// Adaptive precision for a plain number: fewer decimals the bigger it is.
261function 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).
269function 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.
275function 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}
280const plur = (n, s) => n + " " + (n === 1 ? s : s + "s");
281
282let lastData = null;
283
284// ---- everyday anchors (shared by both sections) ------------------------------
285function 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}
290function 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}
296function 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).
304function 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}
321const 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.
326function 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
338function 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
387function 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.
442function 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
559function 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
568async 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}
579refresh();
580setInterval(refresh, 60 * 1000);
581void FIELDS;
582</script>
583</body>
584</html>
585