From 213750887ba9bf8a0934a023f06476a1b61a0de7 Mon Sep 17 00:00:00 2001 From: aurph Date: Thu, 3 Sep 2026 11:50:03 -0400 Subject: [PATCH 1/3] deps: clear the browserslist + postcss-selector-parser advisories Two advisories published upstream since the last merge (browserslist unbounded memory growth, HIGH; postcss-selector-parser AST recursion, low) trip CI's npm-audit gate on every new push, blocking the whole open PR train. npm audit fix; 0 vulnerabilities after; tests/tsc/build green. Lockfile-only. --- package-lock.json | 104 +++++++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19dc994..af9d835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3117,6 +3117,19 @@ "node": ">= 0.4" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3205,9 +3218,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -3225,10 +3238,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -3294,9 +3308,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001753", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", - "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -4100,9 +4114,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.51", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.51.tgz", - "integrity": "sha512-kKeWV57KSS8jH4alKt/jKnvHPmJgBxXzGUSbMd4eQF+iOsVPl7bz2KUmu6eo80eMP8wVioTfTyTzdMgM15WXNg==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -4158,9 +4172,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4382,9 +4396,9 @@ } }, "node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", "license": "MIT" }, "node_modules/filename-reserved-regex": { @@ -5237,11 +5251,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -5561,9 +5578,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5629,12 +5646,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6256,14 +6274,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6275,13 +6293,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6862,9 +6880,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -6883,7 +6901,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" From dd837420db6c7147b4060c1bc1bd040959fcbb19 Mon Sep 17 00:00:00 2001 From: aurph Date: Tue, 8 Sep 2026 12:06:00 -0400 Subject: [PATCH 2/3] Compute Frontier: buildout replay - press play and watch it arrive The mission sentence, animated: an interactive time-scrubber on the cluster map. Facilities light up in the year they came online (solid, sized by running MW, brand-orange pop as each year's arrivals land); announced targets appear hollow and dashed at their promised year and STAY hollow until they actually energize, even after the target year passes - the hype gap, visible in motion. An instrument overlay on the map carries the big mono year and the running tally (live GW vs targeted GW, aria-live), with a play/pause + scrubber strip below. Honesty rules live in a pure, tested lib (client/src/lib/ buildout-timeline.ts): live = operational clusters at their online year using rated MW; targets = planned MW at announced year; "Late 2020s"-style undatable entries are excluded and DISCLOSED in the footer, never guessed. Real data only, no new deps, no keys, no tokens; CARTO tiles + status colors + sqrt-MW sizing all reuse the existing map idiom. prefers-reduced-motion drops the pop animation (discrete steps remain); no autoplay. Named BuildoutReplay because a BuildoutTimeline component already exists on the Power page (facilities-by-opening-year bar chart, left untouched); the two are complementary surfaces over different registries. 489 tests (5 new), tsc, build green; page serves 200 with 216/235 clusters datable. Visuals not browser-verified this session - eyeball /compute-frontier and press play. --- client/src/components/BuildoutReplay.tsx | 190 ++++++++++++++++++ client/src/index.css | 19 ++ .../lib/__tests__/buildout-timeline.test.ts | 84 ++++++++ client/src/lib/buildout-timeline.ts | 106 ++++++++++ client/src/pages/compute-frontier.tsx | 4 + 5 files changed, 403 insertions(+) create mode 100644 client/src/components/BuildoutReplay.tsx create mode 100644 client/src/lib/__tests__/buildout-timeline.test.ts create mode 100644 client/src/lib/buildout-timeline.ts diff --git a/client/src/components/BuildoutReplay.tsx b/client/src/components/BuildoutReplay.tsx new file mode 100644 index 0000000..174018f --- /dev/null +++ b/client/src/components/BuildoutReplay.tsx @@ -0,0 +1,190 @@ +/** + * Buildout over time: the cluster map, animated. Press play and facilities + * light up in the year they came online (solid = running, sized by rated + * MW); announced targets appear hollow at their promised year and stay + * hollow until reality catches up. The honesty rules live in + * lib/buildout-timeline.ts; this component is choreography. + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { MapContainer, TileLayer, CircleMarker, Tooltip as MapTooltip, ZoomControl } from "react-leaflet"; +import { Card } from "@/components/ui/card"; +import { BRAND, INK, STATUS_COLORS, SURFACE } from "@/lib/tokens"; +import { prefersReducedMotion } from "@/lib/chart-theme"; +import { + buildTimeline, + totalsAt, + type TimelineClusterInput, +} from "@/lib/buildout-timeline"; + +const STATUS_COLOR: Record = STATUS_COLORS; +const TICK_MS = 900; + +const gw = (mw: number) => (mw / 1000).toFixed(1); + +export default function BuildoutReplay({ clusters }: { clusters: TimelineClusterInput[] }) { + const timeline = useMemo(() => buildTimeline(clusters), [clusters]); + const { entries, minYear, maxYear, unknownCount } = timeline; + + const nowYear = new Date().getFullYear(); + const [year, setYear] = useState(() => Math.min(Math.max(nowYear, minYear), maxYear)); + const [playing, setPlaying] = useState(false); + const playRef = useRef(playing); + playRef.current = playing; + + useEffect(() => { + if (!playing) return; + const t = setInterval(() => { + setYear((y) => { + if (y >= maxYear) { + setPlaying(false); + return y; + } + return y + 1; + }); + }, TICK_MS); + return () => clearInterval(t); + }, [playing, maxYear]); + + const play = () => { + if (playing) { + setPlaying(false); + return; + } + if (year >= maxYear) setYear(minYear); + setPlaying(true); + }; + + const visible = useMemo(() => entries.filter((e) => e.year <= year), [entries, year]); + const totals = useMemo(() => totalsAt(entries, year), [entries, year]); + const animate = !prefersReducedMotion(); + + if (entries.length === 0) return null; + + return ( + +
+ Buildout over time +
+ + + came online + + + + announced target + +
+
+ +
+ + + {visible.map((e) => { + const live = e.kind === "live"; + const color = STATUS_COLOR[e.status] ?? INK.muted; + return ( + + +
+
{e.name}
+
+ {e.operator} · {e.mw.toLocaleString()} MW{" "} + {live ? `· online ${e.year}` : `· targets ${e.year}`} +
+
+
+
+ ); + })} + +
+ + {/* Instrument overlay: the year and the running tally, on the map */} +
+
+ {year} +
+
+
+ {gw(totals.liveMW)} GW{" "} + live · {totals.liveCount} clusters +
+
+ +{gw(totals.targetMW)} GW targeted ·{" "} + {totals.targetCount} announced +
+
+
+
+ +
+ + {minYear} + { + setPlaying(false); + setYear(Number(e.target.value)); + }} + aria-label="Buildout year" + className="w-full" + style={{ accentColor: BRAND.primary }} + data-testid="cf-timeline-scrub" + /> + {maxYear} +
+
+ Solid circles came online in the shown year or earlier (sized by running MW); hollow circles are + announced targets (planned MW) and stay hollow until they actually energize. + {unknownCount > 0 && ` ${unknownCount} clusters without a dated estimate aren't animated.`} +
+
+ ); +} diff --git a/client/src/index.css b/client/src/index.css index 3e9c15d..b291d97 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -723,3 +723,22 @@ html { .group:hover .feature-preview-img { opacity: 1; } + +/* Buildout timeline: newly-appeared cluster pop (SVG path on the Leaflet + pane). Gated to motion-tolerant users; reduced-motion gets the plain + discrete appearance. */ +@media (prefers-reduced-motion: no-preference) { + @keyframes gt-tl-pop { + 0% { + stroke-width: 14; + stroke-opacity: 0.85; + fill-opacity: 0; + } + 60% { + stroke-opacity: 0.35; + } + } + .gt-tl-pop { + animation: gt-tl-pop 0.9s ease-out; + } +} diff --git a/client/src/lib/__tests__/buildout-timeline.test.ts b/client/src/lib/__tests__/buildout-timeline.test.ts new file mode 100644 index 0000000..c2797bb --- /dev/null +++ b/client/src/lib/__tests__/buildout-timeline.test.ts @@ -0,0 +1,84 @@ +// Timeline semantics: live = operational at its online year (rated MW), +// target = everything else at its announced year (planned MW, still a +// promise after the year passes), unknown-dated clusters excluded but +// counted. The map animation is only as honest as these rules. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + parseOnlineYear, + buildTimeline, + totalsAt, + type TimelineClusterInput, +} from "../buildout-timeline"; + +test("parseOnlineYear handles the registry's free-form date styles", () => { + assert.equal(parseOnlineYear("2025"), 2025); + assert.equal(parseOnlineYear("2026-06-30"), 2026); + assert.equal(parseOnlineYear("2027-2030"), 2027); // first year: when it starts arriving + assert.equal(parseOnlineYear("Phased to 2030"), 2030); + assert.equal(parseOnlineYear("Full buildout ~Q4 2026"), 2026); + assert.equal(parseOnlineYear("Late 2020s"), null); // no fabricated precision + assert.equal(parseOnlineYear(""), null); + assert.equal(parseOnlineYear(undefined), null); + assert.equal(parseOnlineYear("est. 1999"), null); // outside the plausible window +}); + +const input = (over: Partial): TimelineClusterInput => ({ + id: "x", + name: "X", + operator: "Op", + status: "operational", + location: { lat: 30, lng: -97 }, + ratedPowerMW: 100, + plannedPowerMW: 500, + onlineDate: "2024", + ...over, +}); + +test("live entries use rated MW at their online year; targets use planned MW", () => { + const t = buildTimeline([ + input({ id: "a", status: "operational", ratedPowerMW: 100, plannedPowerMW: 500, onlineDate: "2024" }), + input({ id: "b", status: "construction", plannedPowerMW: 900, onlineDate: "2027" }), + ]); + assert.equal(t.entries.length, 2); + assert.deepEqual( + t.entries.map((e) => [e.id, e.kind, e.mw, e.year]), + [["a", "live", 100, 2024], ["b", "target", 900, 2027]], + ); + assert.equal(t.minYear, 2024); + assert.equal(t.maxYear, 2027); +}); + +test("unparseable years and zero-MW clusters are excluded and counted", () => { + const t = buildTimeline([ + input({ id: "a" }), + input({ id: "nodate", onlineDate: "TBD" }), + input({ id: "nomw", status: "announced", plannedPowerMW: 0 }), + ]); + assert.equal(t.entries.length, 1); + assert.equal(t.unknownCount, 2); +}); + +test("totalsAt is cumulative and keeps targets as targets after their year passes", () => { + const { entries } = buildTimeline([ + input({ id: "a", status: "operational", ratedPowerMW: 100, onlineDate: "2024" }), + input({ id: "b", status: "operational", ratedPowerMW: 50, onlineDate: "2026" }), + input({ id: "c", status: "construction", plannedPowerMW: 900, onlineDate: "2025" }), + ]); + assert.deepEqual(totalsAt(entries, 2024), { liveMW: 100, liveCount: 1, targetMW: 0, targetCount: 0 }); + // 2026: the 2025-target has NOT become live just because the year passed + assert.deepEqual(totalsAt(entries, 2026), { liveMW: 150, liveCount: 2, targetMW: 900, targetCount: 1 }); +}); + +test("the shipped registry animates: most clusters dated, sane year span", () => { + const root = JSON.parse( + readFileSync(join(process.cwd(), "server", "data", "clusters.json"), "utf-8"), + ); + const t = buildTimeline(root.clusters); + assert.ok(t.entries.length >= root.clusters.length * 0.6, `only ${t.entries.length}/${root.clusters.length} datable`); + assert.ok(t.minYear >= 2020 && t.maxYear <= 2040, `implausible span ${t.minYear}..${t.maxYear}`); + const now = totalsAt(t.entries, t.maxYear); + assert.ok(now.liveMW > 0 && now.targetMW > 0); +}); diff --git a/client/src/lib/buildout-timeline.ts b/client/src/lib/buildout-timeline.ts new file mode 100644 index 0000000..cb22f09 --- /dev/null +++ b/client/src/lib/buildout-timeline.ts @@ -0,0 +1,106 @@ +/** + * Buildout over time: turns the cluster registry into an animatable + * timeline. Semantics are deliberately honest: + * + * - "live" entries are operational clusters, placed at the year they came + * online, sized by rated (running) MW. + * - "target" entries are construction/announced clusters, placed at their + * announced online year, sized by planned MW. Targets are promises, not + * facts, and render as such (hollow), even after their year passes. + * - Clusters with no parseable year are excluded and counted, so the + * surface can disclose exactly what it is not showing. + */ + +export interface TimelineClusterInput { + id: string; + name: string; + operator: string; + status: string; // operational | construction | announced + location: { lat: number; lng: number }; + ratedPowerMW: number; + plannedPowerMW: number; + onlineDate?: string; +} + +export interface TimelineEntry { + id: string; + name: string; + operator: string; + kind: "live" | "target"; + status: string; + lat: number; + lng: number; + mw: number; + year: number; +} + +export interface BuildoutTimeline { + entries: TimelineEntry[]; // sorted by year asc + minYear: number; + maxYear: number; + unknownCount: number; // clusters excluded (no parseable year or no MW) +} + +/** First plausible 4-digit year in a free-form online-date string. */ +export function parseOnlineYear(onlineDate?: string | null): number | null { + const m = /\b(20[2-4]\d)\b/.exec(onlineDate ?? ""); + return m ? Number(m[1]) : null; +} + +export function buildTimeline(clusters: TimelineClusterInput[]): BuildoutTimeline { + const entries: TimelineEntry[] = []; + let unknownCount = 0; + + for (const c of clusters) { + const year = parseOnlineYear(c.onlineDate); + const kind: TimelineEntry["kind"] = c.status === "operational" ? "live" : "target"; + const mw = kind === "live" ? c.ratedPowerMW || c.plannedPowerMW : c.plannedPowerMW; + if (year == null || !(mw > 0)) { + unknownCount++; + continue; + } + entries.push({ + id: c.id, + name: c.name, + operator: c.operator, + kind, + status: c.status, + lat: c.location.lat, + lng: c.location.lng, + mw, + year, + }); + } + + entries.sort((a, b) => a.year - b.year || a.id.localeCompare(b.id)); + const years = entries.map((e) => e.year); + return { + entries, + minYear: years.length ? Math.min(...years) : new Date().getFullYear(), + maxYear: years.length ? Math.max(...years) : new Date().getFullYear(), + unknownCount, + }; +} + +export interface TimelineTotals { + liveMW: number; + liveCount: number; + targetMW: number; + targetCount: number; +} + +/** Cumulative state of the buildout as of the end of `year`. */ +export function totalsAt(entries: TimelineEntry[], year: number): TimelineTotals { + const t: TimelineTotals = { liveMW: 0, liveCount: 0, targetMW: 0, targetCount: 0 }; + for (const e of entries) { + if (e.year > year) continue; + if (e.kind === "live") { + t.liveMW += e.mw; + t.liveCount++; + } else { + t.targetMW += e.mw; + t.targetCount++; + } + } + return t; +} diff --git a/client/src/pages/compute-frontier.tsx b/client/src/pages/compute-frontier.tsx index 2f5d5f1..29637a3 100644 --- a/client/src/pages/compute-frontier.tsx +++ b/client/src/pages/compute-frontier.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "wouter"; import { MapContainer, TileLayer, CircleMarker, Tooltip as MapTooltip, ZoomControl } from "react-leaflet"; import "leaflet/dist/leaflet.css"; +import BuildoutReplay from "@/components/BuildoutReplay"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -449,6 +450,9 @@ export default function ComputeFrontier() { + {/* The buildout, replayed: press play and watch it arrive */} + {clusters && clusters.length > 0 && } + {/* Filters */}
({ value: o, label: o }))]} /> From 65aabc62ef0fe092576bdcee2ff843e23333005a Mon Sep 17 00:00:00 2001 From: aurph Date: Tue, 8 Sep 2026 16:16:35 -0400 Subject: [PATCH 3/3] replay: finish the instrument - synced curve, headliners, rolling tally The curve is now the scrubber: a cumulative GW area strip under the map (solid orange = live, dashed hollow = targeted) with a moving playhead; click anywhere on it to jump in time, or keep the slider. The overlay gains a per-year delta (+X.X GW arrived), a rolling count-up tally (rAF ease, jumps under reduced motion), and a headliner line naming each year's biggest arrival - live arrivals outrank same-year promises because the story each tick tells is what actually showed up (pure, tested in yearlySeries). Arrival pop upgraded to a two-phase radiating ring with an overshoot settle. Play slowed to 1.1s/year so the headliners are readable. Chart series use seriesMotion() per the repo's chart-motion guard (which correctly failed the first cut). 490 tests (6 lib tests), tsc, build green; page serves 200. --- client/src/components/BuildoutReplay.tsx | 160 +++++++++++++++--- client/src/index.css | 15 +- .../lib/__tests__/buildout-timeline.test.ts | 20 +++ client/src/lib/buildout-timeline.ts | 40 +++++ 4 files changed, 210 insertions(+), 25 deletions(-) diff --git a/client/src/components/BuildoutReplay.tsx b/client/src/components/BuildoutReplay.tsx index 174018f..48554b0 100644 --- a/client/src/components/BuildoutReplay.tsx +++ b/client/src/components/BuildoutReplay.tsx @@ -1,35 +1,74 @@ /** - * Buildout over time: the cluster map, animated. Press play and facilities + * Buildout replay: the cluster map, animated. Press play and facilities * light up in the year they came online (solid = running, sized by rated * MW); announced targets appear hollow at their promised year and stay - * hollow until reality catches up. The honesty rules live in - * lib/buildout-timeline.ts; this component is choreography. + * hollow until reality catches up. Below the map, a cumulative GW curve is + * the scrubber: the playhead tracks the year, clicking the curve jumps + * time, and the overlay names each year's biggest real arrival. The + * honesty rules live in lib/buildout-timeline.ts; this file is + * choreography. */ import { useEffect, useMemo, useRef, useState } from "react"; import { MapContainer, TileLayer, CircleMarker, Tooltip as MapTooltip, ZoomControl } from "react-leaflet"; +import { + ResponsiveContainer, + ComposedChart, + Area, + XAxis, + YAxis, + ReferenceLine, +} from "recharts"; import { Card } from "@/components/ui/card"; +import { SrChartTable } from "@/components/Freshness"; import { BRAND, INK, STATUS_COLORS, SURFACE } from "@/lib/tokens"; -import { prefersReducedMotion } from "@/lib/chart-theme"; +import { prefersReducedMotion, seriesMotion } from "@/lib/chart-theme"; import { buildTimeline, totalsAt, + yearlySeries, type TimelineClusterInput, } from "@/lib/buildout-timeline"; const STATUS_COLOR: Record = STATUS_COLORS; -const TICK_MS = 900; +const TICK_MS = 1100; + +const gwNum = (mw: number) => mw / 1000; -const gw = (mw: number) => (mw / 1000).toFixed(1); +/** Rolling number: eases toward `target` (~0.5s). Reduced motion jumps. */ +function useRollingNumber(target: number): number { + const [shown, setShown] = useState(target); + const shownRef = useRef(shown); + shownRef.current = shown; + useEffect(() => { + if (prefersReducedMotion()) { + setShown(target); + return; + } + const from = shownRef.current; + if (from === target) return; + const t0 = performance.now(); + const dur = 500; + let raf = 0; + const step = (t: number) => { + const p = Math.min(1, (t - t0) / dur); + const eased = 1 - Math.pow(1 - p, 3); + setShown(from + (target - from) * eased); + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [target]); + return shown; +} export default function BuildoutReplay({ clusters }: { clusters: TimelineClusterInput[] }) { const timeline = useMemo(() => buildTimeline(clusters), [clusters]); const { entries, minYear, maxYear, unknownCount } = timeline; + const frames = useMemo(() => yearlySeries(timeline), [timeline]); const nowYear = new Date().getFullYear(); const [year, setYear] = useState(() => Math.min(Math.max(nowYear, minYear), maxYear)); const [playing, setPlaying] = useState(false); - const playRef = useRef(playing); - playRef.current = playing; useEffect(() => { if (!playing) return; @@ -56,14 +95,18 @@ export default function BuildoutReplay({ clusters }: { clusters: TimelineCluster const visible = useMemo(() => entries.filter((e) => e.year <= year), [entries, year]); const totals = useMemo(() => totalsAt(entries, year), [entries, year]); + const frame = frames.find((f) => f.year === year) ?? null; const animate = !prefersReducedMotion(); + const liveGw = useRollingNumber(gwNum(totals.liveMW)); + const targetGw = useRollingNumber(gwNum(totals.targetMW)); + if (entries.length === 0) return null; return (
- Buildout over time + Buildout replay
@@ -125,30 +168,105 @@ export default function BuildoutReplay({ clusters }: { clusters: TimelineCluster - {/* Instrument overlay: the year and the running tally, on the map */} -
-
- {year} + {/* Instrument overlay: year, rolling tally, the year's headliner */} +
+
+ + {year} + + {frame && frame.arrivedMW > 0 && ( + + +{(frame.arrivedMW / 1000).toFixed(1)} GW + + )}
- {gw(totals.liveMW)} GW{" "} + {liveGw.toFixed(1)} GW{" "} live · {totals.liveCount} clusters
- +{gw(totals.targetMW)} GW targeted ·{" "} + +{targetGw.toFixed(1)} GW targeted ·{" "} {totals.targetCount} announced
+ {frame?.headliner && ( +
+ + {frame.headliner.kind === "live" ? "came online" : "biggest promise"} + +
+ {frame.headliner.name} + + {" "} + · {frame.headliner.mw.toLocaleString()} MW + +
+
+ )}
-
+ {/* The curve is the scrubber: cumulative GW with a moving playhead */} +
+ + { + if (s && s.activeLabel != null) { + setPlaying(false); + setYear(Number(s.activeLabel)); + } + }} + style={{ cursor: "pointer" }} + > + + + + + + + + [f.year, Math.round(f.liveMW).toLocaleString(), Math.round(f.targetMW).toLocaleString()])} + /> +
+ +
{minYear}
Solid circles came online in the shown year or earlier (sized by running MW); hollow circles are - announced targets (planned MW) and stay hollow until they actually energize. + announced targets (planned MW) and stay hollow until they actually energize. Click the curve or + drag the slider to jump in time. {unknownCount > 0 && ` ${unknownCount} clusters without a dated estimate aren't animated.`}
diff --git a/client/src/index.css b/client/src/index.css index b291d97..9d96dcd 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -730,15 +730,20 @@ html { @media (prefers-reduced-motion: no-preference) { @keyframes gt-tl-pop { 0% { - stroke-width: 14; - stroke-opacity: 0.85; + stroke-width: 22; + stroke-opacity: 0.9; fill-opacity: 0; } - 60% { - stroke-opacity: 0.35; + 45% { + stroke-width: 6; + stroke-opacity: 0.5; + fill-opacity: 0.8; /* overshoot: land bright, then settle */ + } + 100% { + stroke-opacity: 1; } } .gt-tl-pop { - animation: gt-tl-pop 0.9s ease-out; + animation: gt-tl-pop 1s cubic-bezier(0.22, 0.9, 0.35, 1); } } diff --git a/client/src/lib/__tests__/buildout-timeline.test.ts b/client/src/lib/__tests__/buildout-timeline.test.ts index c2797bb..f8122c2 100644 --- a/client/src/lib/__tests__/buildout-timeline.test.ts +++ b/client/src/lib/__tests__/buildout-timeline.test.ts @@ -10,6 +10,7 @@ import { parseOnlineYear, buildTimeline, totalsAt, + yearlySeries, type TimelineClusterInput, } from "../buildout-timeline"; @@ -72,6 +73,25 @@ test("totalsAt is cumulative and keeps targets as targets after their year passe assert.deepEqual(totalsAt(entries, 2026), { liveMW: 150, liveCount: 2, targetMW: 900, targetCount: 1 }); }); +test("yearlySeries: continuous years, cumulative areas, live headliner outranks a bigger same-year target", () => { + const t = buildTimeline([ + input({ id: "a", status: "operational", ratedPowerMW: 100, onlineDate: "2024" }), + // 2025 is quiet - must still get a flat frame + input({ id: "b", status: "operational", ratedPowerMW: 50, onlineDate: "2026" }), + input({ id: "c", name: "Big Promise", status: "announced", plannedPowerMW: 900, onlineDate: "2026" }), + ]); + const s = yearlySeries(t); + assert.deepEqual(s.map((f) => f.year), [2024, 2025, 2026]); + assert.deepEqual(s.map((f) => [f.liveMW, f.targetMW, f.arrivedMW]), [ + [100, 0, 100], + [100, 0, 0], + [150, 900, 50], + ]); + assert.equal(s[1].headliner, null); + // the 50 MW live arrival headlines over the 900 MW promise + assert.deepEqual(s[2].headliner, { name: "X", operator: "Op", mw: 50, kind: "live" }); +}); + test("the shipped registry animates: most clusters dated, sane year span", () => { const root = JSON.parse( readFileSync(join(process.cwd(), "server", "data", "clusters.json"), "utf-8"), diff --git a/client/src/lib/buildout-timeline.ts b/client/src/lib/buildout-timeline.ts index cb22f09..65c3b4f 100644 --- a/client/src/lib/buildout-timeline.ts +++ b/client/src/lib/buildout-timeline.ts @@ -89,6 +89,46 @@ export interface TimelineTotals { targetCount: number; } +export interface YearFrame { + year: number; + liveMW: number; // cumulative running MW through this year + targetMW: number; // cumulative announced-target MW through this year + arrivedMW: number; // running MW that came online IN this year + headliner: { name: string; operator: string; mw: number; kind: "live" | "target" } | null; +} + +/** + * One frame per year, continuous from minYear..maxYear (quiet years get a + * flat frame - dropping them would compress time). The headliner is the + * single biggest arrival of the year, live arrivals outranking same-year + * targets: the story each tick tells is what actually showed up. + */ +export function yearlySeries(t: BuildoutTimeline): YearFrame[] { + const frames: YearFrame[] = []; + let liveMW = 0; + let targetMW = 0; + for (let year = t.minYear; year <= t.maxYear; year++) { + const arrivals = t.entries.filter((e) => e.year === year); + let arrivedMW = 0; + let headliner: YearFrame["headliner"] = null; + for (const e of arrivals) { + if (e.kind === "live") { + liveMW += e.mw; + arrivedMW += e.mw; + } else { + targetMW += e.mw; + } + const beats = + headliner == null || + (e.kind === "live" && headliner.kind === "target") || + (e.kind === headliner.kind && e.mw > headliner.mw); + if (beats) headliner = { name: e.name, operator: e.operator, mw: e.mw, kind: e.kind }; + } + frames.push({ year, liveMW, targetMW, arrivedMW, headliner }); + } + return frames; +} + /** Cumulative state of the buildout as of the end of `year`. */ export function totalsAt(entries: TimelineEntry[], year: number): TimelineTotals { const t: TimelineTotals = { liveMW: 0, liveCount: 0, targetMW: 0, targetCount: 0 };