status: metric/imperial toggle on the carbon tally page (#741)
A labelled Metric | Imperial button group beside "Estimated so far" converts the whole page — tiles, the car/petrol equivalents line, the chart (axis ticks recomputed in display units so they stay clean, end-labels, tooltip, aria labels), and the ledger. kg→lb, km→miles, litres→US gallons. The JSON stays canonical metric; conversion is purely presentational. Default follows the reader's locale (US customary regions get imperial), the choice is remembered per browser, and ?units=metric|imperial makes a mode linkable. Selected state reads by weight + underline, not color alone.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+100
−28
web/status/carbon.html+100 −28
diff --git a/web/status/carbon.html b/web/status/carbon.html| index 6f78b35..e9e46b4 100644 |
| --- a/web/status/carbon.html |
| +++ b/web/status/carbon.html |
| @@ -2,7 +2,9 @@ |
| Fully self-contained: inline CSS/JS, no external requests except |
| ./carbon.json (relative). Unlike fleet/books, the JSON is COMMITTED data |
| (web/status/carbon.json, accrued by tools/carbon-tally.mjs), republished |
| - as-is by deploy/status-publish.sh — aggregate token counts and kg only. --> |
| + as-is by deploy/status-publish.sh — aggregate token counts and kg only. |
| + The JSON stays metric (canonical); the metric/imperial toggle is purely |
| + presentational (?units=imperial|metric, remembered per browser). --> |
| <!doctype html> |
| <html lang="en"> |
| <head> |
| @@ -49,6 +51,22 @@ |
| .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| .asof { font-weight: 600; margin: 10px 0 24px; } |
| + .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 0 0 10px; } |
| + .row h2 { margin: 0; } |
| + /* Metric/imperial: a labelled two-button group; the selected unit reads by |
| + weight + an underline mark, never color alone. */ |
| + .units { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } |
| + .units button { |
| + font: inherit; font-size: 13px; padding: 4px 12px; border: 0; cursor: pointer; |
| + background: var(--surface); color: var(--ink-2); |
| + } |
| + .units button + button { border-left: 1px solid var(--border); } |
| + .units button[aria-pressed="true"] { |
| + color: var(--ink); font-weight: 650; |
| + box-shadow: inset 0 -2px 0 var(--series); |
| + } |
| + .units button:focus-visible { outline: 2px solid var(--series); outline-offset: -2px; } |
| + |
| .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 12px; } |
| .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| .tile .label { font-size: 13px; color: var(--ink-2); } |
| @@ -73,7 +91,7 @@ |
| #tip .sub { color: var(--ink-2); } |
| .pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; } |
| - /* Measurement ledger: single-series horizontal bars (kg, central estimate); |
| + /* Measurement ledger: single-series horizontal bars (central estimate); |
| rows are real text, bars aria-hidden — same mark spec as books.html. */ |
| .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| .bars li { display: grid; grid-template-columns: minmax(120px, 200px) 1fr 5.5em; align-items: center; gap: 12px; padding: 10px 16px; } |
| @@ -114,7 +132,13 @@ |
| <div id="tally"> |
| <section aria-labelledby="h-total"> |
| - <h2 id="h-total">Estimated so far</h2> |
| + <div class="row"> |
| + <h2 id="h-total">Estimated so far</h2> |
| + <div class="units" role="group" aria-label="Measurement units"> |
| + <button type="button" id="u-metric" aria-pressed="false">Metric</button> |
| + <button type="button" id="u-imperial" aria-pressed="false">Imperial</button> |
| + </div> |
| + </div> |
| <div class="tiles"> |
| <div class="tile"><div class="label">Central estimate</div><div class="value" id="t-central">—</div></div> |
| <div class="tile"><div class="label">Honest range</div><div class="value" id="t-range">—</div></div> |
| @@ -128,13 +152,14 @@ |
| <figure> |
| <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg> |
| <div id="tip"></div> |
| - <figcaption>Cumulative kg CO₂e — the line is the central estimate, the |
| - wash is the low–high band. Exact figures are in the ledger below.</figcaption> |
| + <figcaption id="chart-cap">Cumulative CO₂e — the line is the central |
| + estimate, the wash is the low–high band. Exact figures are in the |
| + ledger below.</figcaption> |
| </figure> |
| </section> |
| <section aria-labelledby="h-entries"> |
| - <h2 id="h-entries">The ledger (central kg per period)</h2> |
| + <h2 id="h-entries">The ledger (central estimate per period)</h2> |
| <ul class="bars" id="entries"><li><span class="period">Loading…</span></li></ul> |
| <p class="equiv" id="notcounted"></p> |
| </section> |
| @@ -173,9 +198,38 @@ const NS = "http://www.w3.org/2000/svg"; |
| // ~2 g CO₂e per member per year. |
| const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3, FLEET_G_PER_MEMBER_YEAR = 2; |
| +// The JSON is canonical metric; imperial is a display conversion only. |
| +const UNITS = { |
| + metric: { mass: "kg", massF: 1, dist: "km", distF: 1, vol: "litres", volF: 1 }, |
| + imperial: { mass: "lb", massF: 2.20462, dist: "miles", distF: 0.621371, vol: "US gallons", volF: 0.264172 }, |
| +}; |
| +// Resolution: ?units= wins (and is remembered), then the saved choice, then |
| +// the reader's locale (US customary regions default imperial). |
| +function initialUnits() { |
| + const q = new URLSearchParams(location.search).get("units"); |
| + if (q === "metric" || q === "imperial") { try { localStorage.setItem("carbonUnits", q); } catch {} return q; } |
| + try { |
| + const saved = localStorage.getItem("carbonUnits"); |
| + if (saved === "metric" || saved === "imperial") return saved; |
| + } catch {} |
| + const region = (new Intl.Locale(navigator.language || "en").region || "").toUpperCase(); |
| + return ["US", "LR", "MM"].includes(region) ? "imperial" : "metric"; |
| +} |
| +let units = initialUnits(); |
| +const U = () => UNITS[units]; |
| +// Whole numbers once they're big enough to not need the decimal. |
| +const nice = (v) => (Math.abs(v) >= 20 ? Math.round(v) : Math.round(v * 10) / 10); |
| +const mass = (kg) => nice(kg * U().massF); |
| + |
| +let lastData = null; |
| + |
| // All rendering is textContent / createElement — never innerHTML of fetched |
| // data, even though the feed is our own. |
| function render(d) { |
| + lastData = d; |
| + $("u-metric").setAttribute("aria-pressed", String(units === "metric")); |
| + $("u-imperial").setAttribute("aria-pressed", String(units === "imperial")); |
| + |
| $("asof").dateTime = d.updated_at; |
| $("asof").textContent = new Date(d.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| @@ -183,17 +237,19 @@ function render(d) { |
| $("factors").textContent = "we band public calibration points at " + f.low + "–" + f.high + |
| " mg CO₂e per output token, central " + f.central + " mg"; |
| - $("t-central").textContent = "~" + d.totals.kg_central + " kg CO₂e"; |
| - $("t-range").textContent = d.totals.kg_low + "–" + d.totals.kg_high + " kg"; |
| + $("t-central").textContent = "~" + mass(d.totals.kg_central) + " " + U().mass + " CO₂e"; |
| + $("t-range").textContent = mass(d.totals.kg_low) + "–" + mass(d.totals.kg_high) + " " + U().mass; |
| $("t-tokens").textContent = compact.format(d.totals.output_tokens); |
| - const km = Math.round(d.totals.kg_central * 1000 / CAR_G_PER_KM); |
| - const litres = Math.round(d.totals.kg_central / PETROL_KG_PER_L); |
| + const dist = nice(d.totals.kg_central * 1000 / CAR_G_PER_KM * U().distF); |
| + const vol = nice(d.totals.kg_central / PETROL_KG_PER_L * U().volF); |
| const memberYears = Math.round(d.totals.kg_central * 1000 / FLEET_G_PER_MEMBER_YEAR); |
| - $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + km + |
| - " km (burning ~" + litres + " litres of petrol) — or roughly " + |
| + $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + dist + " " + U().dist + |
| + " (burning ~" + vol + " " + U().vol + " of petrol) — or roughly " + |
| memberYears.toLocaleString() + " member-years of actually running the service."; |
| + $("chart-cap").textContent = "Cumulative " + U().mass + " CO₂e — the line is the central estimate, " + |
| + "the wash is the low–high band. Exact figures are in the ledger below."; |
| chart(d); |
| const list = $("entries"); |
| @@ -221,7 +277,7 @@ function render(d) { |
| track.append(bar); |
| const kg = document.createElement("span"); |
| kg.className = "kg"; |
| - kg.textContent = e.kg_central + " kg"; |
| + kg.textContent = mass(e.kg_central) + " " + U().mass; |
| li.append(period, track, kg); |
| list.append(li); |
| } |
| @@ -231,10 +287,13 @@ function render(d) { |
| // Cumulative line over time: central line + low–high wash. Crosshair snaps to |
| // the nearest measurement; points are keyboard-focusable with the same tooltip. |
| +// Values are cumulated in canonical kg, converted to the display unit at the |
| +// last moment so labels, ticks, and tooltip always agree. |
| function chart(d) { |
| const svg = $("chart"); |
| svg.replaceChildren(); |
| const round1 = (v) => Math.round(v * 10) / 10; |
| + const disp = (kg) => nice(kg * U().massF); |
| // Cumulate in period_end order across ALL sources; the curve starts at the |
| // earliest period_start any source measured from. |
| const ordered = d.entries.slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end)); |
| @@ -245,14 +304,16 @@ function chart(d) { |
| c = round1(c + e.kg_central); lo = round1(lo + e.kg_low); hi = round1(hi + e.kg_high); |
| pts.push({ t: Date.parse(e.period_end), c, lo, hi, e }); |
| } |
| - const W = 720, H = 240, m = { l: 44, r: 86, t: 14, b: 26 }; |
| + const W = 720, H = 240, m = { l: 48, r: 86, t: 14, b: 26 }; |
| const span = Math.max(1, pts[pts.length - 1].t - pts[0].t); |
| const x = (t) => m.l + (W - m.l - m.r) * (t - pts[0].t) / span; |
| - // Clean y ticks: pick the smallest 1/2/5×10ⁿ step that needs ≤ 4 intervals. |
| + // Scale + ticks live in DISPLAY units so they stay clean after conversion. |
| + const hiDisp = hi * U().massF; |
| let step = 1; |
| - for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hi / s <= 4) { step = s; break; } } |
| - const yMax = Math.max(step, Math.ceil(hi / step) * step); |
| - const y = (v) => H - m.b - (H - m.b - m.t) * v / yMax; |
| + for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hiDisp / s <= 4) { step = s; break; } } |
| + const yMax = Math.max(step, Math.ceil(hiDisp / step) * step); |
| + const y = (kg) => H - m.b - (H - m.b - m.t) * (kg * U().massF) / yMax; |
| + const yD = (v) => H - m.b - (H - m.b - m.t) * v / yMax; |
| const el = (name, attrs, text) => { |
| const n = document.createElementNS(NS, name); |
| for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); |
| @@ -262,11 +323,12 @@ function chart(d) { |
| }; |
| svg.setAttribute("aria-label", "Cumulative carbon estimate from " + day(pts[0].t) + |
| - " to " + day(pts[pts.length - 1].t) + ": central " + c + " kg CO₂e, range " + lo + " to " + hi + " kg."); |
| + " to " + day(pts[pts.length - 1].t) + ": central " + disp(c) + " " + U().mass + |
| + " CO₂e, range " + disp(lo) + " to " + disp(hi) + " " + U().mass + "."); |
| for (let v = 0; v <= yMax; v += step) { |
| - el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 }); |
| - el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12, |
| + el("line", { x1: m.l, x2: W - m.r, y1: yD(v), y2: yD(v), stroke: "var(--grid)", "stroke-width": 1 }); |
| + el("text", { x: m.l - 8, y: yD(v) + 4, "text-anchor": "end", "font-size": 12, |
| fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, String(v)); |
| } |
| for (const p of pts) { |
| @@ -281,9 +343,9 @@ function chart(d) { |
| // Selective direct labels at the right edge; ticks + tooltip carry the rest. |
| const end = pts[pts.length - 1]; |
| - el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + end.hi); |
| - el("text", { x: x(end.t) + 10, y: y(end.c) + 4, "font-size": 12, "font-weight": 650, fill: "var(--ink)" }, "~" + end.c + " kg"); |
| - el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + end.lo); |
| + el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + disp(end.hi)); |
| + 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); |
| + el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + disp(end.lo)); |
| const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" }); |
| const dots = pts.map((p) => el("circle", { cx: x(p.t), cy: y(p.c), r: 4.5, fill: "var(--series)", |
| @@ -297,15 +359,15 @@ function chart(d) { |
| tip.replaceChildren(); |
| const big = document.createElement("div"); |
| big.className = "big"; |
| - big.textContent = "~" + p.c + " kg CO₂e"; |
| + big.textContent = "~" + disp(p.c) + " " + U().mass + " CO₂e"; |
| const when = document.createElement("div"); |
| when.className = "sub"; |
| - when.textContent = "by " + day(p.t) + " · range " + p.lo + "–" + p.hi + " kg"; |
| + when.textContent = "by " + day(p.t) + " · range " + disp(p.lo) + "–" + disp(p.hi) + " " + U().mass; |
| tip.append(big, when); |
| if (p.e) { |
| const delta = document.createElement("div"); |
| delta.className = "sub"; |
| - delta.textContent = "+" + p.e.kg_central + " kg (" + p.e.source + ", " + p.e.basis + ", " + |
| + delta.textContent = "+" + disp(p.e.kg_central) + " " + U().mass + " (" + p.e.source + ", " + p.e.basis + ", " + |
| compact.format(p.e.output_tokens) + " output tokens)"; |
| tip.append(delta); |
| } |
| @@ -337,12 +399,22 @@ function chart(d) { |
| dot.classList.add("pt"); |
| dot.setAttribute("tabindex", "0"); |
| dot.setAttribute("role", "img"); |
| - dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + p.c + " kg CO₂e, range " + p.lo + " to " + p.hi); |
| + dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + disp(p.c) + " " + U().mass + |
| + " CO₂e, range " + disp(p.lo) + " to " + disp(p.hi)); |
| dot.addEventListener("focus", () => show(p, dot)); |
| dot.addEventListener("blur", hide); |
| }); |
| } |
| +function setUnits(next) { |
| + if (next === units) return; |
| + units = next; |
| + try { localStorage.setItem("carbonUnits", units); } catch {} |
| + if (lastData) render(lastData); |
| +} |
| +$("u-metric").addEventListener("click", () => setUnits("metric")); |
| +$("u-imperial").addEventListener("click", () => setUnits("imperial")); |
| + |
| async function refresh() { |
| try { |
| const res = await fetch("./carbon.json", { cache: "no-cache" }); |