diff --git a/CHANGELOG.md b/CHANGELOG.md
index 515c20845..4efe85ba9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+- Widen opaque sensor halos to 3 display pixels with a 30 px fleet core while retaining at least 60% opaque core coverage, adding contrast margin through thermal blur and bloom on bright roofs.
+
+- Exclude Noir's intentionally vignetted outer field from transit core measurements and add separate Boston live oblique selection/readability coverage.
+
+- Judge NVG transit readability by phosphor peak and halo contrast, use opaque black sensor halos, reject overlapping pixel samples, and report each selected-trail acceptance condition. Simplify transit source credits and ground-placement wording.
+
+- Reserve numeric playback scratch before sampling, including resolved transit surface heights, and reuse the same output and segment objects on every frame.
+
+- Keep moving and stationary transit above CCTV/Bikeshare consistently, and share camera sensitivity across the actual Traffic, Bikeshare and Transit lifecycles.
+
+- Reject isolated transit history outliers without changing the active playback bracket; rejected live reports retain the last accepted route, mode and detection text.
+
- Add an optional Nominatim geocoding adapter with configurable search/reverse endpoints, cancellation, bounded responses and retryable upstream errors. Extract portable response-reading and Overpass lexical helpers while retaining existing server exports.
- Expose reference feed factories independently of standalone catalog construction; preserve source choices and asset attribution.
@@ -7,6 +19,7 @@
- Add source-only layer exports and enforce source, browser, standalone and voice import directions. Move plain record/feed helpers and settings filesystem hardening to their owners while preserving compatibility and behavior.
- Separate voice session lifetime and common controls from the default Realtime protocol adapter.
+- Add live public transit: vehicles from seven open GTFS-Realtime feeds played back a lag behind real time at their reported speed, a selected-vehicle trail with bounded MBTA history caching, mode-coloured detection brackets in every preset, and sprites that stay readable in night-vision and thermal views.
- Separate canonical voice action arguments from descriptive wording, preserving the existing Realtime tool inventory.
- Expose portable radio, camera-type and regional source helpers; keep HTTP transport separate from record normalization.
@@ -226,6 +239,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
address grid — so headings use the shared id-hash fallback at low confidence
and are corrected with the calibration gizmo. `CCTV_CALGARY_MAX_SOURCES` sets
the cap and `CCTV_CALGARY_ENABLED=0` turns the pack off.
+- **Transit layer** — keyless buses, trams, subways, trains and ferries in
+ Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway and
+ South East Queensland. Vehicles use delayed timestamp playback and explicit
+ waiting states. Selection shows available recent history, mode-coloured cards
+ and clear report ages; MBTA history can survive a browser reload while the
+ proxy remains running. Heights are aligned to work with Google 3D tiles.
+ Subways are projected to street level and their cards explain that choice.
+ Visible animation, detection membership, history storage and proxy requests
+ are bounded. Share links carry Transit as token `j`.
- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area
highway cameras, with server-registered still URLs and attribution.
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 9c3262770..1de1b9dc1 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -46,6 +46,13 @@ How to read this:
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
+| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
+| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
+| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
+| **OVapi / Stichting OpenGeo** | Transit layer live vehicles, Netherlands | OVapi / Stichting OpenGeo open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends a User-Agent and conditional requests. | Attribution to OVapi / Stichting OpenGeo (courtesy text) |
+| **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) |
+| **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) |
+| **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover |
@@ -67,6 +74,7 @@ How to read this:
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
+- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
diff --git a/README.md b/README.md
index 5b0622ba5..6558a10b5 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, and public cameras. Hands-free voice control powered by a realtime AI agent.
-*No place left behind.*
+_No place left behind._

@@ -16,7 +16,7 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
-▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
+▶️ **From the project behind the viral God's Eye View series** _(formerly WorldView)_ — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
[](https://x.com/bilawalsidhu/status/2093798887815348521)
@@ -24,7 +24,7 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
**[#8 Product of the Day](https://www.producthunt.com/products/god-s-eye-view?launch=god-s-eye-view)** · Hunted by Chris Messina, creator of the hashtag
-*“pretty cool”* — [Brendan Eich](https://x.com/BrendanEich/status/2094592096401490266), creator of JavaScript and co-founder of Mozilla and Brave · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
+_“pretty cool”_ — [Brendan Eich](https://x.com/BrendanEich/status/2094592096401490266), creator of JavaScript and co-founder of Mozilla and Brave · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
⚡ **Start without API keys.** Install with [Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view) or run locally from the terminal. Add optional keys inside the app. **[→ Quick Start](#-quick-start)**
@@ -146,10 +146,10 @@ reopens the same panel.
- **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a
terminal clone → the repo-root `.env`. Either file is made owner-only
- *before* a secret is written into it. These are local plaintext files,
+ _before_ a secret is written into it. These are local plaintext files,
excluded from Git; the app uses your keys to contact the providers.
- **Keys you already have stay yours:** values from your shell or the macOS
- Keychain show as *configured externally* and are read-only to the panel.
+ Keychain show as _configured externally_ and are read-only to the panel.
- **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
(eligible personal, non-commercial use; current terms and quotas apply) for
photorealistic 3D and world terrain; a Google Maps key only for the
@@ -188,7 +188,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea

-4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
+4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project _into_ the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.

@@ -200,8 +200,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea

-7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."*
-8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."*
+7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
+8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
@@ -219,7 +219,7 @@ The cockpit even carries its own briefing strip: nearby live signals, regional h

-*Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through.*
+_Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through._
---
@@ -229,19 +229,21 @@ The cockpit even carries its own briefing strip: nearby live signals, regional h
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
-- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask *"what city is this?"* mid-flight and it knows.
-- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask *"what's this?"* It answers using the object's live telemetry.
+- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask _"what city is this?"_ mid-flight and it knows.
+- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask _"what's this?"_ It answers using the object's live telemetry.
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
-- **🎬 Cinematic framing.** *"Show me the planes overhead"* pulls the camera back, angles it, and frames the live traffic like a director.
+- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
**🎥 Direct it** — drone-operator camera verbs:
-> 🗣️ *"Take me to Tokyo."* · *"Orbit around this area slowly."* · *"Draw the walking route from the Capitol to Zilker Park."* → *"Fly the route we just drew."* · *"Zoom out to a globe view."*
+
+> 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_
**🖊️ Annotate it** — a whiteboard over the real world:
-> 🗣️ *"Outline the state of Texas."* · *"Annotate the Texas State Capitol and its grounds"* — it draws the **actual enclosing boundary**, not a circle. · *"How far is the Eiffel Tower from the Louvre?"* — a connector arrow appears and it speaks the distance. Everything persists until you say *"clear the map."*
+
+> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
@@ -250,52 +252,56 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod

**🔎 Interrogate it** — analyst queries against the live layers:
-> 🗣️ *"How many flights are over Texas right now?"* · *"Which ships are headed to Oakland?"* · *"What is the biggest fire near Los Angeles?"* · *"Is anything flying above forty thousand feet?"* · *"When does the ISS pass over next?"*
+
+> 🗣️ _"How many flights are over Texas right now?"_ · _"Which ships are headed to Oakland?"_ · _"What is the biggest fire near Los Angeles?"_ · _"Is anything flying above forty thousand feet?"_ · _"When does the ISS pass over next?"_
**🎛️ Operate it** — the whole console, hands-free:
-> 🗣️ *"Switch to night vision and turn on the flights layer."* · *"Turn on the camera viewsheds."* · *"Play a news radio station near Austin."* · *"Track that plane."* → *"Enter Cockpit."*
+
+> 🗣️ _"Switch to night vision and turn on the flights layer."_ · _"Turn on the camera viewsheds."_ · _"Play a news radio station near Austin."_ · _"Track that plane."_ → _"Enter Cockpit."_
**And the rapid-fire tier** — one sentence each:
-> 🗣️ *"Show me global infrastructure."* (stages the layers and pulls back to the globe) · *"Play Orbital Watch."* (a full cinematic scene) · *"Set detection density to fifty percent."* · *"Next contact — helicopters only."* (mid-cockpit) · *"Show me space missions."* · *"Switch to OSM."* · *"Sharpen the image a touch."* · *"Switch to the tactical layout."* · *"What's turned on right now?"*
+
+> 🗣️ _"Show me global infrastructure."_ (stages the layers and pulls back to the globe) · _"Play Orbital Watch."_ (a full cinematic scene) · _"Set detection density to fifty percent."_ · _"Next contact — helicopters only."_ (mid-cockpit) · _"Show me space missions."_ · _"Switch to OSM."_ · _"Sharpen the image a touch."_ · _"Switch to the tactical layout."_ · _"What's turned on right now?"_

-*Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to.*
+_Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to._
---
## 🛰️ What's on the Globe
-Fourteen layers and map sources. **Twelve have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
+Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
-| Layer | What you get | Source | Auth |
-|-------|--------------|--------|------|
-| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
-| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
-| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
-| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
-| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
-| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
-| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
-| 📹 **CCTV Mesh** | ~3,600 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
-| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
-| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
-| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
-| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
-| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
-| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
+| Layer | What you get | Source | Auth |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
+| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
+| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
+| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
+| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
+| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
+| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
+| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
+| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
+| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
+| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
+| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
+| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
+| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
+| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
**The basemap ladder — what each tier buys you:**
-| You have | The globe you get |
-|---|---|
-| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
-| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
-| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |
+| You have | The globe you get |
+| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
+| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
+| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |

-*The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×.*
+_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
@@ -309,33 +315,33 @@ Fourteen layers and map sources. **Twelve have a keyless path.** Some offer addi
Once the basics click, run these:
-| Mission | How |
-|---|---|
-| **🚁 Ask the planet** | *"Why are all these military helicopters flying in circles?"* Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
-| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
-| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
-| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
-| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
-| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
-| **🚶 Ask for a walking route** *🎙️* | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then *"fly it"*: banked turns, eased ends, a camera that leads the path like a drone shot. |
-| **📏 Measure LAX to DFW** *🎙️* | *"How far is LAX from DFW?"* — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
-| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
-| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
-| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
+| Mission | How |
+| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **🚁 Ask the planet** | _"Why are all these military helicopters flying in circles?"_ Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
+| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
+| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
+| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
+| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
+| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
+| **🚶 Ask for a walking route** _🎙️_ | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then _"fly it"_: banked turns, eased ends, a camera that leads the path like a drone shot. |
+| **📏 Measure LAX to DFW** _🎙️_ | _"How far is LAX from DFW?"_ — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
+| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
+| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
+| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
-*🎙️ = voice missions — they need an OpenAI key.*
+_🎙️ = voice missions — they need an OpenAI key._

-*Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops.*
+_Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops._

-*"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot.*
+_"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot._

-*Walk the boneyard: rows of retired airframes, fully resolved in 3D.*
+_Walk the boneyard: rows of retired airframes, fully resolved in 3D._
---
@@ -343,10 +349,10 @@ Once the basics click, run these:
How the globe handles live data:
-- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
+- **World-stable icons.** Aircraft and ships point along their _true real-world heading_ at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps.
- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker.
-- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
+- **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
@@ -382,25 +388,25 @@ and configuration details.
Six keys. Four have a free tier, and the two 🔴 ones are metered:
-| | Key | Why | Get it |
-|---|-----|-----|--------|
-| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
-| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
-| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
-| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
-| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
-| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
+| | Key | Why | Get it |
+| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
+| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
+| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
+| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
+| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
+| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |

-*What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it.*
+_What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it._
### Cherry on top
-| | Key | Why | Get it |
-|---|-----|-----|--------|
-| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
-| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
+| | Key | Why | Get it |
+| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
+| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
+| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
Add these if you need higher polling allowances.
@@ -437,12 +443,12 @@ OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth cred
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
-| | Cost reality |
-|---|---|
-| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
-| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
-| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
-| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
+| | Cost reality |
+| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
+| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
+| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
+| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
3D Tiles sessions each month are currently free, and one root request supports
@@ -500,9 +506,9 @@ First — thank you. To everyone who watched the God-view demos and went off to
So here it is. Step inside the spy-thriller cockpit — except the data is real — and let's turn this into our shared sandbox for making sense of the world, and have fun doing it. This repo is the baseline, it stays open, and the whole point is for you to break things and bolt on layers we haven't thought of yet.
-One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. That's the long game.
+One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing _what happened_ and _what changed_ at any real resolution — the data gets expensive and the compute gets brutal. That's the long game.
-**Update — a hosted version is coming.** We originally planned to keep this repository as the open-source client and build a separate professional product. Then the launch happened, and the loudest request wasn't another feature — it was *"just give me a link."* So we're building an official hosted God's Eye View at [Halfpixel](https://halfpixel.ai): no installation, just open it in your browser. The hosted version is the easiest way into this open-source project. More soon.
+**Update — a hosted version is coming.** We originally planned to keep this repository as the open-source client and build a separate professional product. Then the launch happened, and the loudest request wasn't another feature — it was _"just give me a link."_ So we're building an official hosted God's Eye View at [Halfpixel](https://halfpixel.ai): no installation, just open it in your browser. The hosted version is the easiest way into this open-source project. More soon.
---
diff --git a/SECURITY.md b/SECURITY.md
index 9bf718b74..a21ea99a4 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -64,6 +64,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
+- **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
- **Response-size caps and timeouts** on proxied responses.
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 5e533f4e5..36a838492 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -2407,6 +2407,7 @@ its criteria cannot be silently ignored.
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
+| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
@@ -2494,6 +2495,163 @@ What a card needs is a resolved ground anchor: a step whose ground cell has not
answered yet opens nothing rather than a card hanging at sea level. The cells
are re-read for up to 90 seconds, which outlasts a cold terrain round trip;
reroute, CLEAR and disable cancel that.
+Transit is off by default, and share links use token `j`. The layer polls
+registered feeds near the camera every 15 seconds, below a 3,000 km altitude
+gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul,
+Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes
+identify buses, trams, subways, trains, ferries and unclassified vehicles.
+
+Retained transit history uses the same outlier quarantine as live observations and
+preserves the active live bracket. Rejected reports do not publish route or mode
+changes to sprites, cards or detection text.
+
+**Timestamp playback.** Vehicles play between reports at 1× or hold while
+waiting for the next report. The display delay adapts within 25–120 seconds;
+late updates do not make a vehicle accelerate. A short reported crawl still
+plays between fixes. Stationary segments schedule a wake at the next motion
+boundary. Motion wording and orientation come from the displayed segment;
+underflow says “Waiting for update,” and stale reports override confident
+motion wording. Reported speed and stop status remain explicitly labelled as
+reports. Duplicate timestamps and implausible observations cannot create a
+zero-duration journey or immediately relocate a visible vehicle.
+
+**Selected history.** Selecting one vehicle draws its available recent path,
+clipped to the displayed time. The browser retains up to 15 minutes and 128
+fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
+shorten that history. Only the selected vehicle has a trail: completed segments
+are batched primitives, and a small moving head ends at the marker. Trail
+geometry is bounded to 2,048 prepared vertices; history changes rebuild the
+body, while ordinary playback updates the head and segment visibility.
+Both normal and depth-fail instance colours follow the age ramp once per display
+second or style change. Subdivision crossings update only changed visibility
+attributes. Deselecting releases selection geometry; marker paths retain only
+active and future corridors.
+
+MBTA history is also retained in the running proxy: up to 15 minutes and 128
+observations per vehicle, with a 16 MiB feed limit and 64 MiB process limit.
+`GET /api/transit/trail/mbta/` reads only observations already
+received by this process; it never contacts the operator. Selection reads it
+once and cancels the request on deselection or disable. Reload can recover
+available history while the same server stays running. Restart clears it, and
+there is no background collection or promise of complete coverage. Other feeds
+remain live-enabled with session-local trails; proxy history is opt-in per feed.
+The catalog and source tests pin that distinction and the MBTA / MassDOT credit.
+Epoch metadata grows as contexts arrive. Snapshot members are protected during
+ingestion, eviction uses receipt-ordered queues, and capacity loss is disclosed
+when history is recreated.
+
+**Surface and route limitations.** Heights are aligned to work with Google 3D
+tiles. Unknown historical surfaces leave gaps in a trail. Sparse reports can
+cut across corners; these paths are not surveyed route geometry. Subway markers
+are projected to street level because the feed does not provide depth, and the
+selected card explains that choice. Actual street-level views still require
+visual inspection.
+
+**Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection;
+NVG, thermal and noir frames are 30/36 px. Source rasters are 48/96 px before padding.
+External halos are 1/1.5 px in normal and 3/3 px in sensors, calculated from the
+final unpadded display size. Six modes, two sizes and three profiles cap the
+raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram
+`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
+`#D8DDE5`. Selection keeps the mode colour. Sensor bodies use solid rounded envelopes with at least 60% opaque white coverage
+of the padded display footprint, without interior panel lines. Sensor input is opaque white with a
+fully opaque black halo; black-hot reverses the contrast through the
+thermal effect. Creation, mode changes, selection, deselection, map presets and
+Cockpit vision use the same sprite styling function.
+
+Sensor signatures remain in-scene. The thresholds in `qaMetrics.js` require
+white-hot, Ironbow and noir centres ≥0.85 luma and halo minima ≤0.25;
+black-hot uses centre ≤0.15 and halo maximum ≥0.75. Surveillance uses ≥90%
+of its phosphor peak white (0.65992 from RGB 0.16/1/0.22), halo minimum ≤0.25
+with the centre minus sampled background reported without gating. Each preset needs at least
+six verified, unobscured sprites; insufficient coverage is UNEXERCISED.
+The sampler excludes overlapping footprints, including otherwise ineligible
+sprites, and verifies pick ownership across all nine sampled core pixels.
+Noir samples also exclude its deliberately vignetted outer field, using shader
+settings and screen position rather than observed brightness. Selected-trail
+diagnostics report every condition even when selection fails.
+
+Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
+stroke over 3.25 px dark backing. Other contact themes retain their existing
+behavior. The selected trail uses a 2 px core over 4 px backing, mode-coloured
+in normal and white input in sensors. Alpha falls linearly from 0.80 at the head
+to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes.
+
+**Cards and DETECT.** The card presents route, mode/operator, displayed motion,
+display delay and newest-report age, reported speed/stop, vehicle ID, and
+available trail duration. Its anchor follows the rendered marker; text is
+checked at 4 Hz and published when it changes. DETECT labels retain route,
+mode and motion, with the selected label suppressed beside its fuller card.
+Candidate membership changes on revisions, coalesced at 4 Hz; paints read
+cached contacts and strings. Marker migration explicitly updates cached
+position references. Freshness and delay use the shared sample’s anchored clock;
+newest bearings are explicitly labelled as reports. Rejected fixes cannot alter
+lag reservoirs or the target delay. Returning from a hidden tab explicitly
+synchronizes visible playback before its next frame.
+
+Playback reserves numeric scratch before the first sample, including unknown
+heights, and reuses caller-owned sample and segment storage.
+
+**Scene work.** Stationary and animated billboards use separate, adjacent
+collections above CCTV and Bikeshare. Traffic, Bikeshare and Transit share camera
+sensitivity claims; the original sensitivity returns after the last release.
+Visibility checks combine the camera frustum and ellipsoid occlusion at no more
+than 4 Hz; missing map bounds do not admit the globe's far side. Frame work
+visits moving visible vehicles, up to 64 pending height changes, and the selected
+head. Rotation visits visible membership at 5 Hz, with selected rotation per
+frame. The transit render hold exists only for visible positional or orientation
+animation. Polls, surface completions and trail fades request individual renders.
+Disable clears vehicles, detection references, requests, timers and holds;
+destruction also releases collections and registrations.
+
+**Acceptance harness.** Run against an already running development server:
+
+```sh
+QA_BASE_URL=http://localhost:4305 QA_TAG=keyless node scripts/qa-transit.mjs
+```
+
+The full run samples completed frames at 1920×1080 CSS pixels, records DPR, and
+measures moving fleets of 800 and 3,000 vehicles with 128 fixes each and DETECT
+on. Street/metro targets are respectively: transit frame CPU p95 ≤1/4 ms, frame
+interval p95 ≤20/25 ms, billboard uploads ≤256 KiB/1 MiB per frame, and retained
+heap growth ≤24/48 MiB. The 3,000-vehicle CPU budget is 4 ms (recorded browser
+measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms).
+Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed
+by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`,
+`src/data/contactTrailRenderer.js` and `src/data/transit*.js`. Whole-frame
+allocation and its three largest allocators are reported without gating:
+Cesium billboard buffer uploads and the shared DETECT painter are outside
+this layer's allocation budget. **Follow-up: pool shared DETECT placement and
+paint scratch** in `_buildLabelPlacements`, `_materializeCandidate`,
+`_drawOverlay` and their cohort/arbiter helpers. Heap growth is measured
+conservatively as GC-bracketed page growth; allocation sampling includes objects
+collected during the interval. These are acceptance budgets, not measured claims
+for every machine.
+
+Artifacts in `qa-shots/transit/` include per-budget metrics and allocation
+profiles, the Boston Red/741/742/Green-E matrix on measured bright and dark
+backgrounds across normal, NVG, white-hot, black-hot and Ironbow, selected and
+restored screenshots, reload recovery, and disable checks. Matrix routes are
+scripted fixtures; the separate live-feed/reload checks use actual observations.
+The separate Boston live pass uses actual MBTA observations at 120–600 m and
+20–45 degree oblique views, with live selection and sensor readback.
+Live visual cities are otherwise chosen for local daytime operation. Motion checks use
+injected playback independently of live fleet activity. Thermal checks verify
+style activation before reading generated controls; DETECT checks set density
+and exercise the real OFF/restore button. Cesium render errors fail selection
+acceptance.
+Required empty or unexercised checks fail acceptance. The passing final line is
+`HARNESS: PASS (0 unexercised)`. A `QA_SECTIONS` subset is diagnostic only and
+cannot claim full acceptance.
+
+**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
+prior state on exit; Space Missions refuses enablement while isolating replay
+data. Cockpit enters through Contacts and does not independently enable Transit.
+The proxy fetches registered URLs only, validates redirect origins, bounds
+responses and decoding, caches successful snapshots, and backs off after failed
+refreshes. A 304 confirms contact without appending history. Missing vehicles
+are removed after two missed polls; reports older than ten minutes and feeds
+silent for five minutes are removed. Credits appear when the feed is used.
`src/data/militaryAwareness.js` remains registered internally as the Contacts
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
diff --git a/package.json b/package.json
index 6f8a5b411..8e3e5556f 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,8 @@
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
- "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs"
+ "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
+ "qa:transit": "node scripts/qa-transit.mjs"
},
"dependencies": {
"@mapbox/vector-tile": "^3.0.0",
@@ -230,6 +231,12 @@
"./sources/http-body": "./src/sources/httpBody.js",
"./sources/overpass": "./src/sources/overpass.js",
"./search/nominatim": "./src/search/nominatim.js",
- "./sources/nominatim": "./src/sources/nominatim.js"
+ "./sources/nominatim": "./src/sources/nominatim.js",
+ "./server/providers/transit": {
+ "node": "./server/providers/transit.js"
+ },
+ "./sources/transit": "./src/data/transitProxy.js",
+ "./layers/transit": "./src/layers/transit/index.js",
+ "./layers/transit/standalone": "./src/data/transit.js"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 30b035a0e..ad13e1b96 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -675,5 +675,52 @@
"src/search/nominatim.test.mjs",
"src/sources/httpBody.js",
"src/sources/overpass.js",
- "src/sources/nominatim.js"
+ "src/sources/nominatim.js",
+ "server/providers/transit.js",
+ "server/providers/transitHistory.js",
+ "server/providers/transitHistory.test.mjs",
+ "src/data/transitFeeds.js",
+ "src/data/transitFeeds.test.mjs",
+ "src/data/transitProxy.js",
+ "src/data/transitProxy.test.mjs",
+ "src/data/gtfsRealtime.js",
+ "src/data/gtfsRealtime.test.mjs",
+ "src/data/transit.js",
+ "src/data/transit.test.mjs",
+ "src/layers/transit/controls.js",
+ "src/layers/transit/height.js",
+ "src/layers/transit/index.js",
+ "src/layers/transit/ingestion.js",
+ "src/layers/transit/lifecycle.js",
+ "src/layers/transit/policy.js",
+ "src/layers/transit/queries.js",
+ "src/layers/transit/rendering.js",
+ "src/layers/transit/selection.js",
+ "src/layers/transit/state.js",
+ "src/layers/transit/testing.js",
+ "src/layers/transit/viewport.js",
+ "src/layers/transit/lifecycle.test.mjs",
+ "scripts/qa-transit.mjs",
+ "src/tooling/transitProvider.test.mjs",
+ "src/app/layers/transit.js",
+ "src/data/transitIcons.js",
+ "src/data/cameraSensitivity.js",
+ "src/data/contactPlayback.js",
+ "src/data/contactTrailRenderer.js",
+ "src/data/contactTrailRenderer.test.mjs",
+ "src/layers/transit/trails.js",
+ "src/data/contactPlayback.test.mjs",
+ "src/layers/transit/movement.js",
+ "src/layers/transit/movement.test.mjs",
+ "src/data/transitPresetStyle.js",
+ "src/data/transitPresetStyle.test.mjs",
+ "src/data/transitIcons.test.mjs",
+ "src/overlays/worldOverlayTokens.test.mjs",
+ "src/app/layers/transit.test.mjs",
+ "src/layers/transit/qaMetrics.js",
+ "src/layers/transit/qaMetrics.test.mjs",
+ "scripts/qa-transit-scenes.mjs",
+ "scripts/qa-transit-controls.mjs",
+ "src/tooling/transitQa.test.mjs",
+ "scripts/qa-transit-browser.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index cdd9fe6bb..9eb44433c 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -878,6 +878,7 @@
"traffic-layer": {
"exports": ["./layers/traffic"],
"modules": [
+ "src/data/cameraSensitivity.js",
"src/data/flowMatch.js",
"src/data/tomtomTiles.js",
"src/data/trafficBounds.js",
@@ -920,7 +921,8 @@
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js",
- "src/data/inputOwnership.js"
+ "src/data/inputOwnership.js",
+ "src/data/cameraSensitivity.js"
],
"external": ["cesium"]
},
@@ -1638,7 +1640,32 @@
"src/search/nominatim.js",
"src/nominatimGeocode.js",
"src/sources/httpBody.js",
- "src/sources/nominatim.js"
+ "src/sources/nominatim.js",
+ "src/app/layers/transit.js",
+ "src/data/transit.js",
+ "src/data/transitFeeds.js",
+ "src/data/transitIcons.js",
+ "src/data/transitPresetStyle.js",
+ "src/layers/transit/controls.js",
+ "src/layers/transit/height.js",
+ "src/layers/transit/index.js",
+ "src/layers/transit/ingestion.js",
+ "src/layers/transit/lifecycle.js",
+ "src/layers/transit/policy.js",
+ "src/layers/transit/queries.js",
+ "src/layers/transit/rendering.js",
+ "src/layers/transit/selection.js",
+ "src/layers/transit/state.js",
+ "src/layers/transit/testing.js",
+ "src/layers/transit/viewport.js",
+ "src/data/cameraSensitivity.js",
+ "src/data/contactPlayback.js",
+ "src/data/contactTrailRenderer.js",
+ "src/data/transitProxy.js",
+ "src/data/gtfsRealtime.js",
+ "src/layers/transit/trails.js",
+ "src/layers/transit/movement.js",
+ "src/layers/transit/qaMetrics.js"
],
"external": [
"@mapbox/vector-tile",
@@ -1995,7 +2022,31 @@
"src/nominatimGeocode.js",
"src/data/regionalModel.js",
"src/sources/httpBody.js",
- "src/sources/nominatim.js"
+ "src/sources/nominatim.js",
+ "src/app/layers/transit.js",
+ "src/data/transitFeeds.js",
+ "src/data/transitIcons.js",
+ "src/data/transitPresetStyle.js",
+ "src/layers/transit/controls.js",
+ "src/layers/transit/height.js",
+ "src/layers/transit/index.js",
+ "src/layers/transit/ingestion.js",
+ "src/layers/transit/lifecycle.js",
+ "src/layers/transit/policy.js",
+ "src/layers/transit/queries.js",
+ "src/layers/transit/rendering.js",
+ "src/layers/transit/selection.js",
+ "src/layers/transit/state.js",
+ "src/layers/transit/testing.js",
+ "src/layers/transit/viewport.js",
+ "src/data/cameraSensitivity.js",
+ "src/data/contactPlayback.js",
+ "src/data/contactTrailRenderer.js",
+ "src/data/transitProxy.js",
+ "src/data/gtfsRealtime.js",
+ "src/layers/transit/trails.js",
+ "src/layers/transit/movement.js",
+ "src/layers/transit/qaMetrics.js"
],
"external": [
"cesium",
@@ -2316,5 +2367,118 @@
"src/sources/httpBody.js"
],
"external": []
+ },
+ "transit-provider": {
+ "runtime": "node",
+ "exports": ["./server/providers/transit"],
+ "modules": [
+ "server/providers/common/http.js",
+ "server/providers/common/rate-limit.js",
+ "server/providers/transit.js",
+ "server/providers/transitHistory.js",
+ "src/data/gtfsRealtime.js",
+ "src/data/transitFeeds.js",
+ "src/data/transitProxy.js",
+ "src/sources/httpBody.js"
+ ],
+ "external": ["pbf"]
+ },
+ "transit-source": {
+ "exports": ["./sources/transit"],
+ "modules": [
+ "src/data/gtfsRealtime.js",
+ "src/data/transitFeeds.js",
+ "src/data/transitProxy.js"
+ ],
+ "external": ["pbf"]
+ },
+ "transit-standalone": {
+ "exports": ["./layers/transit/standalone"],
+ "modules": [
+ "src/app/sourceSlot.js",
+ "src/celestialRing.js",
+ "src/data/cameraSensitivity.js",
+ "src/data/contactPlayback.js",
+ "src/data/contactTrailRenderer.js",
+ "src/data/dataCredits.js",
+ "src/data/detectionCohort.js",
+ "src/data/detectionPolicy.js",
+ "src/data/geoid.js",
+ "src/data/groundFloor.js",
+ "src/data/gtfsRealtime.js",
+ "src/data/iconOrientation.js",
+ "src/data/inputOwnership.js",
+ "src/data/labelArbiter.js",
+ "src/data/meshFloorSampler.js",
+ "src/data/pickRegistry.js",
+ "src/data/spriteOrder.js",
+ "src/data/terrainHeights.js",
+ "src/data/transit.js",
+ "src/data/transitFeeds.js",
+ "src/data/transitIcons.js",
+ "src/data/transitPresetStyle.js",
+ "src/data/transitProxy.js",
+ "src/layers/transit/controls.js",
+ "src/layers/transit/height.js",
+ "src/layers/transit/index.js",
+ "src/layers/transit/ingestion.js",
+ "src/layers/transit/lifecycle.js",
+ "src/layers/transit/movement.js",
+ "src/layers/transit/policy.js",
+ "src/layers/transit/qaMetrics.js",
+ "src/layers/transit/queries.js",
+ "src/layers/transit/rendering.js",
+ "src/layers/transit/selection.js",
+ "src/layers/transit/state.js",
+ "src/layers/transit/testing.js",
+ "src/layers/transit/trails.js",
+ "src/layers/transit/viewport.js",
+ "src/overlays/worldOverlay.js",
+ "src/overlays/worldOverlayDraw.js",
+ "src/overlays/worldOverlayTokens.js",
+ "src/renderGovernor.js",
+ "src/services/application.js",
+ "src/services/groundFloor.js",
+ "src/services/meshFloorSampler.js",
+ "src/services/requests.js",
+ "src/services/terrainHeights.js",
+ "src/sources/sourceSlot.js"
+ ],
+ "external": ["cesium", "egm96-universal", "pbf"]
+ },
+ "transit-layer": {
+ "exports": ["./layers/transit"],
+ "modules": [
+ "src/data/iconOrientation.js",
+ "src/data/inputOwnership.js",
+ "src/data/transitFeeds.js",
+ "src/data/transitIcons.js",
+ "src/data/transitPresetStyle.js",
+ "src/layers/transit/controls.js",
+ "src/layers/transit/height.js",
+ "src/layers/transit/index.js",
+ "src/layers/transit/ingestion.js",
+ "src/layers/transit/lifecycle.js",
+ "src/layers/transit/policy.js",
+ "src/layers/transit/queries.js",
+ "src/layers/transit/rendering.js",
+ "src/layers/transit/selection.js",
+ "src/layers/transit/state.js",
+ "src/layers/transit/testing.js",
+ "src/layers/transit/viewport.js",
+ "src/data/cameraSensitivity.js",
+ "src/data/contactPlayback.js",
+ "src/data/contactTrailRenderer.js",
+ "src/data/transitProxy.js",
+ "src/data/gtfsRealtime.js",
+ "src/layers/transit/trails.js",
+ "src/layers/transit/movement.js",
+ "src/layers/transit/qaMetrics.js",
+ "src/layers/transit/qaMetrics.test.mjs",
+ "scripts/qa-transit-scenes.mjs",
+ "scripts/qa-transit-controls.mjs",
+ "scripts/qa-transit-browser.mjs"
+ ],
+ "external": ["cesium", "pbf"]
}
}
diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs
new file mode 100644
index 000000000..1e71e484e
--- /dev/null
+++ b/scripts/qa-transit-browser.mjs
@@ -0,0 +1,595 @@
+/** Bound page promises as well as CDP itself; labels identify a stalled section. */
+export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
+ const evaluate = page.evaluate.bind(page);
+ page.evaluate = (fn, ...args) => {
+ // The two long trace collectors pass their duration explicitly.
+ const duration =
+ args.find((v) => typeof v === 'number' && v >= 6000 && v <= 120000) || 0;
+ const timeoutMs = Math.max(
+ defaultTimeoutMs,
+ duration ? duration + 5000 : 0,
+ );
+ const label = String(fn).slice(0, 120).replace(/\s+/g, ' ');
+ return evaluate(
+ async (source, values, timeoutMs, label) => {
+ let timer;
+ try {
+ return await Promise.race([
+ // Puppeteer callbacks are standalone functions, just as in evaluate.
+ Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)),
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Transit QA page wait timed out after ${timeoutMs} ms: ${label}`,
+ ),
+ ),
+ timeoutMs,
+ );
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ }
+ },
+ String(fn),
+ args,
+ timeoutMs,
+ label,
+ );
+ };
+}
+
+export async function reportTransitVisibility(page, label) {
+ const result = await page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ return {
+ total: state._vehicles.size,
+ visible: state._visible.size,
+ moving: state._moving.size,
+ held: state._renderHeld,
+ requestRenderMode: app.viewer.scene.requestRenderMode,
+ renderLoop: app.viewer.useDefaultRenderLoop,
+ renderErrors: window.__transitRenderErrors || [],
+ bounds: state._viewBounds,
+ hidden: layer._transitVisibilityForTest().filter((e) => !e.shown),
+ };
+ });
+ console.log(` ${label}: ${JSON.stringify(result)}`);
+ if (result.renderLoop === false || result.renderErrors.length)
+ throw new Error(
+ `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
+ );
+ return result;
+}
+
+/** Intersect primed histories with the new snapshot; disappearance is availability. */
+export async function restoreRetainedTransit(retained) {
+ if (!retained.length)
+ return {
+ error: 'No multi-fix history observed during the probe',
+ unexercised: true,
+ };
+ const app = window.__godsEyeView;
+ await app.dataManager.setEnabled('transit', true, { source: 'qa' });
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ // enable() schedules proximity discovery; an empty pre-discovery map is not
+ // evidence that every primed vehicle departed. Wait for this feed's poll.
+ const deadline = Date.now() + 12000;
+ while (state._activeFeeds && !state._vehicles.size && Date.now() < deadline)
+ await new Promise((r) => setTimeout(r, 100));
+ await layer.update();
+ if (
+ retained[0]?.primedAt &&
+ Math.floor(Date.now() / 60000) !== Math.floor(retained[0].primedAt / 60000)
+ )
+ return {
+ error: 'Reload crossed the priming minute; same-minute proof unavailable',
+ unexercised: true,
+ };
+ const history = retained.find((h) =>
+ state._vehicles.has(`${h.feedId || 'mbta'}:${h.vehicleId}`),
+ );
+ if (!history)
+ return {
+ error: `All ${retained.length} primed vehicles left the current snapshot`,
+ unexercised: true,
+ };
+ const entry = state._vehicles.get(
+ `${history.feedId || 'mbta'}:${history.vehicleId}`,
+ );
+ const camera = app.viewer.camera,
+ C = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: C.toCartesian(
+ C.fromDegrees(entry.record.lon, entry.record.lat, 600),
+ ),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ layer._transitPartsForTest().selection.selectVehicle(entry.key);
+ return { key: entry.key, oldest: history.oldestT };
+}
+
+export async function sampleTransitPixels(page, palette) {
+ return page.evaluate(async (palette) => {
+ const app = window.__godsEyeView;
+ const state = app.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ // Decode the actual billboard rasters before the paint being judged.
+ const overlay = window.location
+ ? await import('/src/overlays/worldOverlay.js')
+ : null;
+ const rasters = new Map();
+ await Promise.all(
+ [
+ ...new Set(
+ [...state._vehicles.values()]
+ .map((e) => e.marker?.image)
+ .filter(Boolean),
+ ),
+ ].map(async (url) => {
+ const img = new Image();
+ img.src = url;
+ await img.decode();
+ const c = document.createElement('canvas');
+ c.width = img.width;
+ c.height = img.height;
+ const ctx = c.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ rasters.set(url, {
+ width: c.width,
+ height: c.height,
+ data: ctx.getImageData(0, 0, c.width, c.height).data,
+ });
+ }),
+ );
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ remove();
+ reject(
+ new Error('Transit pixel readback: no postRender within 5000 ms'),
+ );
+ }, 5000);
+ const remove =
+ window.__godsEyeView.viewer.scene.postRender.addEventListener(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const scene = app.viewer.scene;
+ const canvas = scene.canvas;
+ const view = scene.camera.viewMatrix;
+ const proj = scene.camera.frustum.projectionMatrix;
+ const mul = (m, v) => [
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
+ m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
+ ];
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ const sprites = [];
+ const footprints = [];
+ const intersects = (a, b) =>
+ a.x < b.x + b.w &&
+ a.x + a.w > b.x &&
+ a.y < b.y + b.h &&
+ a.y + a.h > b.y;
+ const plates =
+ app.styleManager
+ ?.getDetectionDiagnostics?.()
+ ?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
+ if (state._selectedKey) {
+ const card = overlay?.getOverlayPaintRect(
+ 'transit-selected',
+ state._selectedKey,
+ );
+ if (card) plates.push(card);
+ }
+ const panels = (overlay?.WORLD_OVERLAY_OCCLUDER_SELECTORS || [])
+ .flatMap((selector) => [...document.querySelectorAll(selector)])
+ .filter(
+ (el) =>
+ !el.hidden &&
+ el.checkVisibility({
+ checkOpacity: true,
+ checkVisibilityCSS: true,
+ }),
+ )
+ .map((el) => {
+ const r = el.getBoundingClientRect();
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
+ });
+ const skipped = [];
+ const style = state._stylePreset;
+ const uniforms = app.styleManager?.stages?.[style]?.uniforms || {};
+ const intensity = uniforms.intensity ?? 1;
+ const pixelation =
+ uniforms.pixelation ?? (style === 'surveillance' ? 2.5 : 1.5);
+ const sensor = style === 'surveillance' || style === 'thermal';
+ // Invert NVG's barrel transform; thermal only quantizes the source.
+ const screenPoint = (sx, sy) => {
+ let x = sx,
+ y = sy;
+ if (style === 'surveillance') {
+ const tx = (sx / width) * 2 - 1,
+ ty = (sy / height) * 2 - 1;
+ let cx = tx,
+ cy = ty;
+ for (let i = 0; i < 16; i++) {
+ const r2 = cx * cx + cy * cy;
+ const d =
+ 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
+ cx = tx / d;
+ cy = ty / d;
+ }
+ x = ((cx + 1) * width) / 2;
+ y = ((cy + 1) * height) / 2;
+ }
+ const offset = sensor
+ ? ((1 + (pixelation - 1) * intensity) * intensity) /
+ 2 /
+ (canvas.width / width)
+ : 0;
+ return { x: x + offset, y: y - offset };
+ };
+ const uiBlocked = (x, y) => {
+ const el = document.elementFromPoint?.(x, y);
+ return (
+ el &&
+ el !== canvas &&
+ !el.closest?.(
+ '#world-overlay-root, #world-overlay-detection-surface',
+ ) &&
+ el.tagName !== 'BODY' &&
+ el.tagName !== 'HTML'
+ );
+ };
+ for (const entry of state._vehicles.values()) {
+ if (!entry.marker || entry.marker.show === false) continue;
+ const p = entry.marker.position;
+ const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
+ if (!(clip[3] > 0)) continue;
+ const screen = entry.marker.computeScreenSpacePosition?.(scene) || {
+ x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width,
+ y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height,
+ };
+ const rect = entry.marker.constructor.getScreenSpaceBoundingBox?.(
+ entry.marker,
+ screen,
+ ) || { x: screen.x - 16, y: screen.y - 16, width: 32, height: 32 };
+ const { x, y } = screenPoint(screen.x, screen.y);
+ const c = Math.abs(Math.cos(entry.marker.rotation || 0)),
+ sn = Math.abs(Math.sin(entry.marker.rotation || 0));
+ const w = rect.width * c + rect.height * sn,
+ h = rect.height * c + rect.width * sn;
+ const footprint = { x: x - w / 2, y: y - h / 2, w, h };
+ footprints.push({ key: entry.key, ...footprint });
+ let reason = null;
+ if (
+ rect.x < 0 ||
+ rect.y < 0 ||
+ rect.x + rect.width > width ||
+ rect.y + rect.height > height
+ )
+ reason = 'billboard rectangle outside frame';
+ if (
+ entry.marker.color?.alpha < 0.95 ||
+ entry.markerCollection?.show === false
+ )
+ reason = 'billboard not opaque/collection hidden';
+ const raster = rasters.get(entry.marker.image);
+ if (!raster) reason = 'billboard alpha unavailable';
+ else if (
+ raster.data[
+ (Math.floor(raster.height / 2) * raster.width +
+ Math.floor(raster.width / 2)) *
+ 4 +
+ 3
+ ] < 242
+ )
+ reason = 'billboard centre is transparent';
+ if (
+ sensor &&
+ Math.hypot(
+ (((x / width) * 2 - 1) * width) / height,
+ (y / height) * 2 - 1,
+ ) > 0.6
+ )
+ reason = 'outside unmasked sensor lens';
+ if (style === 'noir') {
+ // Noir deliberately darkens the frame edges. Judge the core in
+ // its unmasked field, independently of the measured brightness.
+ const u = x / width,
+ v = y / height;
+ const transmission = Math.pow(
+ 16 * u * (1 - u) * v * (1 - v),
+ 0.3 + 0.4 * (uniforms.vignetteAmt ?? 0.5) * intensity,
+ );
+ if (1 - intensity + transmission * intensity < 0.9)
+ reason = 'outside unmasked noir field';
+ }
+
+ if (
+ [
+ [x, y],
+ [rect.x, rect.y],
+ [rect.x + rect.width, rect.y + rect.height],
+ [rect.x + rect.width, rect.y],
+ [rect.x, rect.y + rect.height],
+ ].some(([px, py]) => uiBlocked(px, py))
+ )
+ reason = 'UI panel overlaps billboard';
+ if (panels.some((r) => intersects(footprint, r)))
+ reason = 'UI panel rectangle overlaps billboard';
+ if (plates.some((r) => intersects(footprint, r)))
+ reason = 'DETECT label/card rectangle overlaps billboard';
+ if (reason) {
+ skipped.push({ key: entry.key, reason });
+ continue;
+ }
+ if (x < 40 || y < 40 || x > width - 40 || y > height - 40) continue;
+ sprites.push({
+ key: entry.key,
+ mode: entry.mode,
+ x,
+ y,
+ selected: state._selectedKey === entry.key,
+ screenX: screen.x,
+ screenY: screen.y,
+ rect,
+ footprint,
+ bracketX: entry.detectContact?._candidateScreenX ?? screen.x,
+ bracketY: entry.detectContact?._candidateScreenY ?? screen.y,
+ raster,
+ rotation: entry.marker.rotation || 0,
+ halfW: Number.isFinite(entry.marker.width)
+ ? Math.ceil(entry.marker.width / 2) + 2
+ : 11,
+ halfH: Number.isFinite(entry.marker.height)
+ ? Math.ceil(entry.marker.height / 2) + 2
+ : 7,
+ });
+ }
+ const isolated = sprites.filter(
+ (a) =>
+ !a.selected &&
+ footprints.every(
+ (b) => b.key === a.key || !intersects(a.footprint, b),
+ ),
+ );
+ const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
+ const scale = canvas.width / canvas.clientWidth;
+ const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ const readLuma = (cx, cy) => {
+ const px = Math.floor(cx * scale);
+ const py = Math.floor(canvas.height - 1 - cy * scale);
+ const buf = new Uint8Array(4);
+ gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
+ return luma(buf[0], buf[1], buf[2]);
+ };
+ const surface = document.getElementById('world-overlay-canvas');
+ const ctx = surface?.getContext('2d');
+ // A hidden surface has no client size; nothing to read there.
+ const sscale =
+ surface && surface.clientWidth > 0
+ ? surface.width / surface.clientWidth
+ : 0;
+ const out = [];
+ for (const s of isolated) {
+ if (out.length >= 12) break;
+ // The normal canvas includes DETECT plates and selected cards.
+ // A plate over the body invalidates this contact, even if WebGL below is hot.
+ if (ctx && sscale > 0) {
+ const body = ctx.getImageData(
+ Math.floor((s.x - 3) * sscale),
+ Math.floor((s.y - 3) * sscale),
+ Math.max(1, Math.ceil(6 * sscale)),
+ Math.max(1, Math.ceil(6 * sscale)),
+ ).data;
+ if (body.some((v, i) => i % 4 === 3 && v > 0)) {
+ skipped.push({
+ key: s.key,
+ reason: 'DETECT label/card overlaps core',
+ });
+ continue;
+ }
+ }
+ // Picking is independent of rendered brightness: an overlapping
+ // label or sprite must not masquerade as a dim sensor signature.
+ // Check the whole sampled core, not just its centre pick.
+ let coreOwned = true;
+ if (scene.pick) {
+ const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
+ for (let dx = -1; dx <= 1; dx++)
+ for (let dy = -1; dy <= 1; dy++) {
+ let u = (s.x + dx) / width,
+ v = 1 - (s.y + dy) / height;
+ if (style === 'surveillance') {
+ const x = u * 2 - 1,
+ y = v * 2 - 1,
+ r2 = x * x + y * y;
+ const d =
+ 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
+ u = (x * d + 1) / 2;
+ v = (y * d + 1) / 2;
+ }
+ if (sensor) {
+ const grid = 1 + (pixelation - 1) * intensity;
+ u +=
+ ((Math.floor((u * canvas.width) / grid) * grid) /
+ canvas.width -
+ u) *
+ intensity;
+ v +=
+ ((Math.floor((v * canvas.height) / grid) * grid) /
+ canvas.height -
+ v) *
+ intensity;
+ }
+ const picked = scene.pick(
+ { x: u * width, y: (1 - v) * height },
+ 1,
+ 1,
+ );
+ if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
+ coreOwned = false;
+ }
+ if (gl.bindFramebuffer)
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
+ }
+ if (!coreOwned) {
+ skipped.push({
+ key: s.key,
+ reason:
+ 'sampled core is partially obscured or outside the sprite',
+ });
+ continue;
+ }
+ let centre = 0;
+ for (let dx = -1; dx <= 1; dx += 1)
+ for (let dy = -1; dy <= 1; dy += 1)
+ centre += readLuma(s.x + dx, s.y + dy) / 9;
+ let ringMin = 1;
+ let ringMax = 0;
+ // Sample the actual rotated raster halo, not a fixed-radius circle
+ // that can hit the hot body or terrain after a display-size change.
+ const raster = s.raster;
+ const cos = Math.cos(s.rotation),
+ sin = Math.sin(s.rotation);
+ for (let ry = 1; ry < raster.height - 1; ry++)
+ for (let rx = 1; rx < raster.width - 1; rx++) {
+ const i = (ry * raster.width + rx) * 4;
+ if (
+ raster.data[i + 3] < 200 ||
+ raster.data[i] > 30 ||
+ raster.data[i + 1] > 30 ||
+ raster.data[i + 2] > 30
+ )
+ continue;
+ const dx = ((rx + 0.5) / raster.width - 0.5) * s.rect.width;
+ const dy = ((ry + 0.5) / raster.height - 0.5) * s.rect.height;
+ const q = screenPoint(
+ s.screenX + dx * cos + dy * sin,
+ s.screenY - dx * sin + dy * cos,
+ );
+ const v = readLuma(q.x, q.y);
+ ringMin = Math.min(ringMin, v);
+ ringMax = Math.max(ringMax, v);
+ }
+ let bracket = null;
+ if (ctx && sscale > 0) {
+ // Transit brackets follow the padded sprite extent and snapped
+ // stroke centres. Sample the middle of each straight segment,
+ // never a colour histogram containing antialiased edges/callouts.
+ const cx = Math.floor(s.bracketX) + 0.5,
+ cy = Math.floor(s.bracketY) + 0.5;
+ const hw = s.halfW,
+ hh = s.halfH,
+ mid = Math.max(4, Math.floor(Math.min(hw, hh) * 0.55)) / 2;
+ const points = [
+ [cx - hw + mid, cy - hh],
+ [cx + hw - mid, cy - hh],
+ [cx - hw + mid, cy + hh],
+ [cx + hw - mid, cy + hh],
+ [cx - hw, cy - hh + mid],
+ [cx + hw, cy - hh + mid],
+ [cx - hw, cy + hh - mid],
+ [cx + hw, cy + hh - mid],
+ ];
+ let bestAlpha = 0;
+ for (const [index, [x, y]] of points.entries()) {
+ if (
+ uiBlocked(x, y) ||
+ plates.some((r) =>
+ intersects({ x: x - 1, y: y - 1, w: 2, h: 2 }, r),
+ )
+ )
+ continue;
+ // A label plate fills both sides of a stroke. Require empty
+ // pixels beyond its dark backing on the perpendicular axis.
+ const normal = index < 4 ? [0, 3] : [3, 0];
+ if (
+ [-1, 1].some(
+ (sign) =>
+ ctx.getImageData(
+ Math.floor((x + sign * normal[0]) * sscale),
+ Math.floor((y + sign * normal[1]) * sscale),
+ 1,
+ 1,
+ ).data[3] > 0,
+ )
+ )
+ continue;
+ const data = ctx.getImageData(
+ Math.floor(x * sscale),
+ Math.floor(y * sscale),
+ 1,
+ 1,
+ ).data;
+ if (data[3] > bestAlpha) {
+ bestAlpha = data[3];
+ bracket = [data[0], data[1], data[2]];
+ }
+ }
+ }
+ out.push({
+ key: s.key,
+ mode: s.mode,
+ verified: true,
+ rectangle: s.rect,
+ centre,
+ background:
+ [
+ readLuma(s.x + 25, s.y),
+ readLuma(s.x - 25, s.y),
+ readLuma(s.x, s.y + 25),
+ readLuma(s.x, s.y - 25),
+ ].reduce((a, b) => a + b, 0) / 4,
+ ringMin,
+ ringMax,
+ bracket,
+ bracketTier: `transit_${s.mode}`,
+ bracketExpected: palette[s.mode]
+ ? [1, 3, 5].map((i) =>
+ parseInt(palette[s.mode].slice(i, i + 2), 16),
+ )
+ : null,
+ x: s.x,
+ y: s.y,
+ });
+ }
+ clearTimeout(timer);
+ remove();
+ console.log(
+ `TRANSIT_SAMPLER ${style}: ${out.length} verified; skipped=${JSON.stringify(skipped)}`,
+ );
+ resolve(out);
+ });
+ window.__godsEyeView.viewer.scene.requestRender();
+ });
+ }, palette);
+}
+
+/** A fleet-to-fleet comparison must not inherit the city's selected marker. */
+export function readTransitFleetStyle({ key, resetSelection = false } = {}) {
+ const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ if (resetSelection) layer._transitPartsForTest().selection.clearSelection();
+ const entry = key
+ ? state._vehicles.get(key)
+ : [...state._vehicles.values()].find((e) => e.marker?.show);
+ return entry
+ ? {
+ key: entry.key,
+ selected: state._selectedKey === entry.key,
+ color: entry.marker.color.toCssColorString(),
+ width: entry.marker.width,
+ image: entry.marker.image,
+ }
+ : null;
+}
diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs
new file mode 100644
index 000000000..b76e14706
--- /dev/null
+++ b/scripts/qa-transit-controls.mjs
@@ -0,0 +1,204 @@
+const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+export const BOSTON = {
+ id: 'boston',
+ label: 'Boston (MBTA)',
+ lat: 42.3554,
+ lon: -71.0605,
+ heading: 25,
+ zone: 'America/New_York',
+};
+const LIVE_CITIES = [
+ BOSTON,
+ {
+ id: 'austin',
+ label: 'Austin (CapMetro)',
+ lat: 30.2672,
+ lon: -97.7431,
+ heading: 340,
+ zone: 'America/Chicago',
+ },
+ {
+ id: 'minneapolis',
+ label: 'Minneapolis (Metro Transit)',
+ lat: 44.9778,
+ lon: -93.265,
+ heading: 0,
+ zone: 'America/Chicago',
+ },
+ {
+ id: 'helsinki',
+ label: 'Helsinki (HSL)',
+ lat: 60.1699,
+ lon: 24.9384,
+ heading: 0,
+ zone: 'Europe/Helsinki',
+ },
+ {
+ id: 'oslo',
+ label: 'Oslo (Entur)',
+ lat: 59.9139,
+ lon: 10.7522,
+ heading: 0,
+ zone: 'Europe/Oslo',
+ },
+];
+export function chooseLiveCities(now = new Date()) {
+ return LIVE_CITIES.filter((city) => {
+ const hour = Number(
+ new Intl.DateTimeFormat('en-US', {
+ timeZone: city.zone,
+ hour: 'numeric',
+ hourCycle: 'h23',
+ }).format(now),
+ );
+ return hour >= 6 && hour < 22;
+ }).slice(0, 2);
+}
+
+/**
+ * Switch the post-FX style the way a user does — the preset button — and
+ * fall back to the style manager only when the tray is not clickable, saying
+ * so. Then set any style parameters through their sliders.
+ * @param {import('puppeteer').Page} page
+ * @param {string} name
+ * @param {Record} [params] aria-label → slider value
+ * @returns {Promise} how the style was set
+ */
+export async function selectStyle(page, name, params = {}, wait = pause) {
+ let how = 'button';
+ // The presets live in the bottom control panel, which auto-dismisses when
+ // the pointer leaves it: pin it open the way a user does, once.
+ await page.evaluate(() => {
+ const panel = document.getElementById('control-panel');
+ if (panel && !panel.classList.contains('dock-pinned')) {
+ document
+ .querySelector('.dock-pin-btn[data-pin-target="control-panel"]')
+ ?.click();
+ }
+ window.__godsEyeView.styleManager.setPanelCollapsed?.(
+ 'control-panel',
+ false,
+ { explicit: true },
+ );
+ });
+ await wait(400);
+ try {
+ await page.$eval(`.style-btn[data-style="${name}"]`, (el) =>
+ el.scrollIntoView({ block: 'center' }),
+ );
+ await page.click(`.style-btn[data-style="${name}"]`);
+ } catch {
+ how = 'styleManager.setStyle (button not clickable)';
+ await page.evaluate(
+ (n) => window.__godsEyeView.styleManager.setStyle(n),
+ name,
+ );
+ }
+ // A click can be swallowed by a loading cover or a collapsed tray without
+ // Puppeteer throwing. Verify the active style before querying generated rows.
+ if (
+ !(await page.evaluate(
+ (n) => document.documentElement.dataset.gevStyle === n,
+ name,
+ ))
+ ) {
+ how = 'styleManager.setStyle (button did not activate style)';
+ await page.evaluate(
+ (n) => window.__godsEyeView.styleManager.setStyle(n),
+ name,
+ );
+ }
+ await page.waitForFunction(
+ (n) => document.documentElement.dataset.gevStyle === n,
+ { timeout: 10000 },
+ name,
+ );
+ await wait(1_800);
+ for (const [label, value] of Object.entries(params)) {
+ const selector = `#param-sliders input.param-slider[aria-label="${label}"]`;
+ await page.waitForSelector(selector, { timeout: 10000 });
+ await page.$eval(
+ selector,
+ (el, v) => {
+ el.value = String(v);
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ },
+ value,
+ );
+ await wait(700);
+ }
+ return how;
+}
+
+/**
+ * Turn DETECT on through its own button, cycling until the dense profile is
+ * reported on the detection surface.
+ * @param {import('puppeteer').Page} page
+ * @returns {Promise} the profile reached
+ */
+export async function detectOn(page, wait = pause) {
+ // The real button toggles OFF/restore. Density determines its on-profile.
+ const mode = () =>
+ page.evaluate(() => {
+ const button = document.getElementById('detection-toggle');
+ const label = button?.getAttribute('aria-label') || '';
+ return {
+ on: button?.getAttribute('aria-pressed') === 'true',
+ mode: (label.split(':')[1] || 'off').trim().toUpperCase(),
+ };
+ });
+ // The loading screen swallows the first click of a fresh page.
+ await page
+ .waitForFunction(() => !document.getElementById('loading-screen'), {
+ timeout: 30_000,
+ })
+ .catch(() => {});
+ // The DISPLAY rail starts collapsed on a first run: open it the way a user
+ // does, then press DETECT until it reports on.
+ await page.evaluate(() => {
+ const rail = document.getElementById('pp-toggles');
+ if (rail?.classList.contains('collapsed'))
+ document
+ .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
+ ?.click();
+ });
+ await page.waitForFunction(
+ () =>
+ document.getElementById('detection-toggle')?.getBoundingClientRect()
+ .width > 0,
+ { timeout: 10_000 },
+ );
+ if (!(await mode()).on) {
+ await page.click('#detection-toggle');
+ await wait(300);
+ }
+ await page.$eval('#detection-density-slider', (el) => {
+ el.value = '100';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await page.waitForFunction(
+ () =>
+ document
+ .getElementById('detection-toggle')
+ ?.getAttribute('aria-label') === 'Detection overlay: dense',
+ { timeout: 10000 },
+ );
+ // Prove the button's actual OFF/restore cycle preserves DENSE.
+ await page.click('#detection-toggle');
+ await page.waitForFunction(
+ () =>
+ document
+ .getElementById('detection-toggle')
+ ?.getAttribute('aria-pressed') === 'false',
+ { timeout: 10000 },
+ );
+ await page.click('#detection-toggle');
+ await page.waitForFunction(
+ () =>
+ document
+ .getElementById('detection-toggle')
+ ?.getAttribute('aria-label') === 'Detection overlay: dense',
+ { timeout: 10000 },
+ );
+ return (await mode()).mode;
+}
diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs
new file mode 100644
index 000000000..94ec134bb
--- /dev/null
+++ b/scripts/qa-transit-scenes.mjs
@@ -0,0 +1,583 @@
+import { reportTransitVisibility } from './qa-transit-browser.mjs';
+import { writeFile } from 'node:fs/promises';
+import {
+ reduceFleet,
+ reduceMotion,
+ reduceBackground,
+ reduceSensorContrast,
+} from '../src/layers/transit/qaMetrics.js';
+
+/** CDP includes objects collected during the sample, not just surviving objects. */
+export function allocationTotals(profile) {
+ let frameBytes = 0,
+ layerFrameBytes = 0,
+ sharedOverlayBytes = 0,
+ qaBytes = 0;
+ const allocators = new Map();
+ function visit(node, inFrame = false, inQa = false, inOverlay = false) {
+ const frame = node.callFrame || {},
+ name = frame.functionName || '',
+ url = frame.url || '';
+ const nested =
+ inFrame ||
+ name === 'onPreRender' ||
+ /^render\d*$/.test(name) ||
+ /^Scene\d*\.render$/.test(name);
+ // QA wraps the real frame callback: re-enter production attribution at its
+ // onPreRender boundary, but exclude QA's own Math/Cesium helper descendants.
+ const qa =
+ name === 'onPreRender'
+ ? false
+ : inQa || /\/transit\/testing\.js(?:\?|$)/.test(url);
+ const overlay = inOverlay || name === '_drawOverlay';
+ const bytes = node.selfSize || 0;
+ if (nested) {
+ if (qa) qaBytes += bytes;
+ else {
+ frameBytes += bytes;
+ const allocator = `${name || '(anonymous)'} ${url.split('?')[0]}:${(frame.lineNumber ?? -1) + 1}`;
+ allocators.set(allocator, (allocators.get(allocator) || 0) + bytes);
+ if (overlay) sharedOverlayBytes += bytes;
+ if (
+ /\/layers\/transit\/|\/data\/(?:contactPlayback|contactTrailRenderer|transit[^/]*)\.js/.test(
+ url,
+ )
+ )
+ layerFrameBytes += bytes;
+ }
+ }
+ for (const child of node.children || []) visit(child, nested, qa, overlay);
+ }
+ visit(profile.head);
+ return {
+ frameBytes,
+ layerFrameBytes,
+ sharedOverlayBytes,
+ qaBytes,
+ topAllocators: [...allocators]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 3)
+ .map(([allocator, bytes]) => ({ allocator, bytes })),
+ };
+}
+
+export async function runFleetBudgets({
+ page,
+ check,
+ wait,
+ shots,
+ tag,
+ detectOn,
+ selectStyle,
+}) {
+ console.log(
+ '\n== moving fleet budgets: 1920×1080 CSS, warmed view, DETECT dense ==',
+ );
+ await selectStyle(page, 'normal');
+ await detectOn(page);
+ const cdp = await page.createCDPSession();
+ await cdp.send('HeapProfiler.enable');
+ for (const count of [800, 3000]) {
+ await page.evaluate(async (count) => {
+ const app = window.__godsEyeView;
+ await app.dataManager.setEnabled('transit', true, { source: 'qa' });
+ const camera = app.viewer.camera,
+ C = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: C.toCartesian(
+ C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000),
+ ),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ const layer = app.dataManager.layers.get('transit').module;
+ layer._transitPartsForTest().ingestion.abortAllInFlight();
+ layer._transitStateForTest()._activeFeeds.clear();
+ for (const key of layer._transitStateForTest()._vehicles.keys())
+ layer._transitPartsForTest().ingestion.removeVehicle(key);
+ }, count);
+ await wait(8000);
+ // Camera proximity can finish a live poll during tile warm-up. Remove it
+ // before the heap baseline, or its memory would discount the fixture fleet.
+ await page.evaluate(() => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest(),
+ parts = layer._transitPartsForTest();
+ parts.ingestion.abortAllInFlight();
+ state._activeFeeds.clear();
+ clearTimeout(state._cameraDebounceTimer);
+ state._cameraDebounceTimer = null;
+ for (const key of state._vehicles.keys())
+ parts.ingestion.removeVehicle(key);
+ parts.rendering.syncRenderHold();
+ window.__godsEyeView.viewer.scene.requestRender();
+ });
+ await wait(500);
+ await cdp.send('HeapProfiler.collectGarbage');
+ const before = await cdp.send('Runtime.getHeapUsage');
+ const loaded = await page.evaluate(
+ (count) =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._loadTransitFleetForTest(
+ count,
+ { lat: 42.3554, lon: -71.0605 },
+ count === 800 ? 5 : 120,
+ ),
+ count,
+ );
+ await reportTransitVisibility(
+ page,
+ `${count} budget visibility after load`,
+ );
+ await wait(2000);
+ await cdp.send('HeapProfiler.collectGarbage');
+ // The retained delta is conservative: it includes concurrent page growth.
+ const retained = await cdp.send('Runtime.getHeapUsage');
+ // Timing/upload acceptance runs without the allocation profiler attached.
+ const trace = await page.evaluate(() =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._measureTransitFramesForTest(8000),
+ );
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._resetTransitFixtureClockForTest(),
+ );
+ await wait(1500);
+ await cdp.send('HeapProfiler.startSampling', {
+ samplingInterval: 128,
+ includeObjectsCollectedByMajorGC: true,
+ includeObjectsCollectedByMinorGC: true,
+ });
+ await reportTransitVisibility(
+ page,
+ `${count} budget visibility before allocation trace`,
+ );
+ const allocationTrace = await page.evaluate(() =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._measureTransitFramesForTest(6000),
+ );
+ const { profile } = await cdp.send('HeapProfiler.stopSampling');
+ const allocations = allocationTotals(profile);
+ const retainedBytes = retained.usedSize - before.usedSize;
+ const evidence = reduceFleet(
+ trace.rows,
+ count,
+ allocations.layerFrameBytes / allocationTrace.rows.length,
+ retainedBytes,
+ loaded.fixBytes,
+ );
+ const details = {
+ ...evidence,
+ ...allocations,
+ wholeFrameBytesPerFrame:
+ allocations.frameBytes / allocationTrace.rows.length,
+ topAllocatorsPerFrame: allocations.topAllocators.map((a) => ({
+ ...a,
+ bytesPerFrame: a.bytes / allocationTrace.rows.length,
+ })),
+ allocationFrames: allocationTrace.rows.length,
+ loaded,
+ dpr: trace.dpr,
+ viewport: [trace.width, trace.height],
+ heapMethod:
+ 'GC-bracketed whole-page growth after fleet load (conservative)',
+ allocationMethod:
+ 'CDP 128-byte sampling including collected objects; transit source self allocation gates, whole render stacks reported, QA scaffolding excluded',
+ };
+ await writeFile(
+ `${shots}/${tag}-fleet-${count}.json`,
+ JSON.stringify(details, null, 2),
+ );
+ await writeFile(
+ `${shots}/${tag}-fleet-${count}-allocations.json`,
+ JSON.stringify(profile),
+ );
+ check(
+ `${count} moving vehicles meet CPU, frame interval, billboard upload, heap and allocation budgets`,
+ evidence.pass && trace.width === 1920 && trace.height === 1080,
+ JSON.stringify(details),
+ );
+ check(
+ `${count}: transit-authored frame allocation <= 8 KiB`,
+ allocationTrace.rows.length >= 100 &&
+ reduceMotion(allocationTrace.rows).pass &&
+ allocationTrace.rows.every((r) => r.moving === count) &&
+ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
+ `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
+ );
+ await page.screenshot({
+ path: `${shots}/${tag}-fleet-${count}.jpg`,
+ type: 'jpeg',
+ quality: 85,
+ });
+ const settled = await page.evaluate(async () => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const { seek } = await import('/src/data/contactPlayback.js');
+ const { updatePlayback } =
+ await import('/src/layers/transit/movement.js');
+ for (const entry of state._vehicles.values()) {
+ entry.clocks.wallNowMs = Date.now();
+ entry.clocks.monoNowMs = performance.now();
+ seek(entry.track, entry.fixes.at(-1).t, entry.clocks);
+ updatePlayback(entry, Date.now(), performance.now());
+ layer._transitPartsForTest().rendering.schedulePlayback(entry);
+ }
+ layer._transitPartsForTest().rendering.syncRenderHold();
+ return { moving: state._moving.size, held: state._renderHeld };
+ });
+ check(
+ `${count}: settling releases the transit hold`,
+ settled.moving === 0 && !settled.held,
+ JSON.stringify(settled),
+ );
+ }
+ await cdp.detach();
+}
+
+export async function runBostonMatrix({
+ page,
+ check,
+ wait,
+ shots,
+ tag,
+ selectStyle,
+ sampleRendered,
+ detectOn,
+}) {
+ console.log(
+ '\n== Boston matrix: scripted Red, 741, 742, Green-E; actual map backgrounds ==',
+ );
+ await detectOn(page);
+ const routes = ['Red', '741', '742', 'Green-E'];
+ const looks = [
+ { name: 'normal', style: 'normal', params: {} },
+ { name: 'nvg', style: 'surveillance', params: {} },
+ { name: 'noir', style: 'noir', params: {} },
+ {
+ name: 'white-hot',
+ style: 'thermal',
+ params: { 'WHOT/BHOT': 0, Ironbow: 0 },
+ },
+ {
+ name: 'black-hot',
+ style: 'thermal',
+ params: { 'WHOT/BHOT': 1, Ironbow: 0 },
+ },
+ {
+ name: 'ironbow',
+ style: 'thermal',
+ params: { 'WHOT/BHOT': 0, Ironbow: 1 },
+ },
+ ];
+ // Actual Boston views. Acceptance checks the measured background; a tile/key
+ // failure or an unsuitable view is explicitly unexercised, never assumed bright/dark.
+ const backgrounds = [
+ {
+ name: 'bright',
+ candidates: [
+ { lat: 42.3458, lon: -71.0464 },
+ { lat: 42.3659, lon: -71.018 },
+ { lat: 42.3526, lon: -71.0461 },
+ { lat: 42.3486, lon: -71.0825 },
+ ],
+ },
+ {
+ name: 'dark',
+ candidates: [
+ { lat: 42.3574, lon: -71.0375 },
+ { lat: 42.3554, lon: -71.0655 },
+ { lat: 42.3588, lon: -71.0738 },
+ { lat: 42.348, lon: -71.032 },
+ ],
+ },
+ ];
+ const evidence = [];
+ for (const group of backgrounds) {
+ let background, backgroundResult;
+ const attempts = [];
+ await selectStyle(page, 'normal');
+ for (const candidate of group.candidates) {
+ background = { ...candidate, name: group.name };
+ await page.evaluate((b) => {
+ const camera = window.__godsEyeView.viewer.camera,
+ C = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ }, background);
+ await wait(8000);
+ await page.evaluate(
+ ({ background, routes }) => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ layer._loadTransitFleetForTest(8, background, 45, routes);
+ for (const entry of layer._transitStateForTest()._vehicles.values()) {
+ entry.track.rate = 0;
+ layer._transitPartsForTest().rendering.schedulePlayback(entry);
+ }
+ },
+ { background, routes },
+ );
+ await wait(1000);
+ backgroundResult = reduceBackground(
+ group.name,
+ await sampleRendered(page),
+ );
+ attempts.push({ background, ...backgroundResult });
+ if (backgroundResult.pass) break;
+ }
+ check(
+ `Boston ${background.name}: all four routes over measured ${background.name} background`,
+ backgroundResult.pass,
+ JSON.stringify({ attempts, chosen: background }),
+ { unexercised: !backgroundResult.pass },
+ );
+ for (const look of looks) {
+ await selectStyle(page, look.style, look.params);
+ await wait(1200);
+ await reportTransitVisibility(
+ page,
+ `Boston ${background.name}/${look.name} visibility`,
+ );
+ const pixels = await sampleRendered(page);
+ const modes = await page.evaluate(() => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ return [...layer._transitStateForTest()._vehicles.values()].map(
+ (e) => ({
+ key: e.key,
+ route: e.record.routeId,
+ image: e.marker.image,
+ width: e.marker.width,
+ color: e.marker.color.toCssHexString(),
+ }),
+ );
+ });
+ const contrast =
+ look.name === 'normal'
+ ? null
+ : reduceSensorContrast(
+ pixels,
+ look.name === 'black-hot' ? 'black' : 'white',
+ look.style,
+ );
+ check(
+ `Boston ${background.name}/${look.name}: all four silhouettes and mode brackets readable`,
+ pixels.length >= 6 &&
+ pixels.every(
+ (p) =>
+ p.bracket &&
+ p.bracketExpected &&
+ Math.hypot(...p.bracket.map((v, i) => v - p.bracketExpected[i])) <
+ 48,
+ ) &&
+ (!contrast || contrast.pass),
+ JSON.stringify({ pixels, contrast }),
+ { unexercised: pixels.length < 6 },
+ );
+ await page.screenshot({
+ path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
+ type: 'jpeg',
+ quality: 90,
+ });
+ for (const target of modes) {
+ await page.evaluate(
+ (key) =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitPartsForTest()
+ .selection.selectVehicle(key),
+ target.key,
+ );
+ await wait(500);
+ await page.screenshot({
+ path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
+ type: 'jpeg',
+ quality: 90,
+ });
+ const restored = await page.evaluate((key) => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ layer._transitPartsForTest().selection.clearSelection();
+ const e = layer._transitStateForTest()._vehicles.get(key);
+ return {
+ image: e.marker.image,
+ width: e.marker.width,
+ color: e.marker.color.toCssHexString(),
+ };
+ }, target.key);
+ check(
+ `Boston ${background.name}/${look.name}/${target.route}: deselect restores fleet styling`,
+ restored.image === target.image &&
+ restored.width === target.width &&
+ restored.color === target.color,
+ );
+ }
+ await wait(300);
+ await page.screenshot({
+ path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
+ type: 'jpeg',
+ quality: 90,
+ });
+ evidence.push({
+ background,
+ look,
+ pixels,
+ routes: modes.map((m) => m.route),
+ });
+ }
+ }
+ await selectStyle(page, 'normal');
+ await writeFile(
+ `${shots}/${tag}-boston-matrix.json`,
+ JSON.stringify(evidence, null, 2),
+ );
+}
+
+/** Real MBTA observations at oblique street views, separate from matrix fixtures. */
+export async function runBostonLive({
+ page,
+ check,
+ wait,
+ shots,
+ tag,
+ detectOn,
+ selectStyle,
+ sampleRendered,
+}) {
+ console.log('\n== Boston live oblique streets ==');
+ await page.evaluate(async () => {
+ const app = window.__godsEyeView;
+ await app.dataManager.setEnabled('transit', false, { source: 'qa' });
+ const camera = app.viewer.camera,
+ C = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: C.toCartesian(C.fromDegrees(-71.0605, 42.3554, 600)),
+ orientation: { heading: 0, pitch: -Math.PI / 4, roll: 0 },
+ });
+ await app.dataManager.setEnabled('transit', true, { source: 'qa' });
+ const layer = app.dataManager.layers.get('transit').module;
+ layer._setTransitFixtureFloorsForTest([], 0);
+ await layer.update();
+ });
+ await wait(20000);
+ await detectOn(page);
+ const evidence = [];
+ for (const view of [
+ {
+ name: 'white-hot',
+ style: 'thermal',
+ altitude: 120,
+ heading: 0,
+ pitch: -20,
+ params: { 'WHOT/BHOT': 0, Ironbow: 0 },
+ },
+ {
+ name: 'nvg',
+ style: 'surveillance',
+ altitude: 600,
+ heading: 30,
+ pitch: -45,
+ params: {},
+ },
+ {
+ name: 'noir',
+ style: 'noir',
+ altitude: 400,
+ heading: 60,
+ pitch: -35,
+ params: {},
+ },
+ ]) {
+ await page.evaluate((view) => {
+ const app = window.__godsEyeView,
+ camera = app.viewer.camera,
+ C = camera.positionCartographic.constructor;
+ app.dataManager.layers
+ .get('transit')
+ .module._transitPartsForTest()
+ .selection.clearSelection();
+ camera.setView({
+ destination: C.toCartesian(
+ C.fromDegrees(-71.0605, 42.3554, view.altitude),
+ ),
+ orientation: {
+ heading: (view.heading * Math.PI) / 180,
+ pitch: (view.pitch * Math.PI) / 180,
+ roll: 0,
+ },
+ });
+ }, view);
+ await selectStyle(page, view.style, view.params);
+ await wait(8000);
+ const fleet = await page.evaluate(() => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ const entries = [...state._vehicles.values()];
+ return {
+ total: entries.length,
+ visible: entries.filter((e) => e.marker?.show).length,
+ fixtures: entries.filter((e) => e.qaFixture).length,
+ routes: [
+ ...new Set(
+ entries.filter((e) => e.marker?.show).map((e) => e.record.routeId),
+ ),
+ ],
+ };
+ });
+ check(
+ `Boston live/${view.name}: observed vehicles in the oblique street view`,
+ fleet.total > 0 && fleet.visible > 0 && fleet.fixtures === 0,
+ JSON.stringify({ view, fleet }),
+ { unexercised: fleet.visible === 0 },
+ );
+ const pixels = await sampleRendered(page);
+ const contrast = reduceSensorContrast(pixels, 'white', view.style);
+ check(
+ `Boston live/${view.name}: unobscured sensor contrast`,
+ contrast.pass,
+ JSON.stringify(contrast),
+ { unexercised: contrast.unexercised },
+ );
+ const target = pixels[0];
+ let selection = null;
+ if (target) {
+ await page.mouse.click(target.x, target.y);
+ await wait(250);
+ selection = await page.evaluate((key) => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ return {
+ expected: key,
+ actual: state._selectedKey,
+ pick: state._lastPickForTest ?? null,
+ };
+ }, target.key);
+ }
+ check(
+ `Boston live/${view.name}: real mouse selects an observed vehicle`,
+ selection?.actual === target?.key && !!target,
+ JSON.stringify(selection),
+ { unexercised: !target },
+ );
+ await page.screenshot({
+ path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
+ type: 'jpeg',
+ quality: 90,
+ });
+ evidence.push({ view, fleet, pixels, contrast, selection });
+ }
+ await writeFile(
+ `${shots}/${tag}-boston-live.json`,
+ JSON.stringify(evidence, null, 2),
+ );
+}
diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs
new file mode 100644
index 000000000..de09a6222
--- /dev/null
+++ b/scripts/qa-transit.mjs
@@ -0,0 +1,1951 @@
+#!/usr/bin/env node
+import {
+ boundPageEvaluations,
+ reportTransitVisibility,
+ restoreRetainedTransit,
+ sampleTransitPixels,
+ readTransitFleetStyle,
+} from './qa-transit-browser.mjs';
+import {
+ BOSTON,
+ chooseLiveCities,
+ selectStyle,
+ detectOn,
+} from './qa-transit-controls.mjs';
+import {
+ runFleetBudgets,
+ runBostonMatrix,
+ runBostonLive,
+} from './qa-transit-scenes.mjs';
+/**
+ * Browser proof for the Transit layer against a running app.
+ *
+ * npm run qa:transit (defaults to http://localhost:4173)
+ * QA_BASE_URL=http://localhost:4305 node scripts/qa-transit.mjs
+ *
+ * Drives the real layer toggle and the real CONTEXT chooser, watches the real
+ * /api/transit traffic, proves vehicles move between polls in the SCENE (not
+ * just in the JSON), captures street-level shots in two cities, and proves that
+ * turning the layer off stops every request and leaves nothing rendered.
+ *
+ * Screenshots land in the gitignored qa-shots/ directory unless QA_SHOTS says
+ * otherwise. Needs a running server; the feeds are keyless, so a keyless boot
+ * exercises the same paths.
+ */
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import puppeteer from 'puppeteer';
+import { createTransitHistory } from '../server/providers/transitHistory.js';
+import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
+import { transitModeTier } from '../src/data/transitPresetStyle.js';
+
+import {
+ reduceAnchor,
+ reduceTrailHead,
+ reduceScenario,
+ reduceSensorContrast,
+ reduceScriptedMotion,
+ reduceMotion,
+ reduceFleet,
+} from '../src/layers/transit/qaMetrics.js';
+
+const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
+const SHOTS = process.env.QA_SHOTS || 'qa-shots/transit';
+const TAG = process.env.QA_TAG || 'keyed';
+/** Which sections to run: all, or a comma list of cities,playback,disable,context. */
+const SECTIONS = new Set((process.env.QA_SECTIONS || 'all').split(','));
+const runs = (section) => SECTIONS.has('all') || SECTIONS.has(section);
+const CITIES = chooseLiveCities();
+
+let failures = 0;
+let unexercised = 0;
+/**
+ * A check has three outcomes. PASS and FAIL are what they say. UNEXERCISED
+ * means the run could not put the claim to the test — a parked fleet has no
+ * motion to judge — and it is reported as its own word rather than as a
+ * pass, because a pass that proved nothing is how a broken interpolation
+ * sailed through this harness once.
+ */
+const check = (
+ name,
+ passed,
+ detail = '',
+ { unexercised: idle = false } = {},
+) => {
+ const word = idle ? 'UNEXERCISED' : passed ? 'PASS' : 'FAIL';
+ console.log(`[${word}] ${name}${detail ? ` — ${detail}` : ''}`);
+ if (idle) {
+ unexercised += 1;
+ failures += 1;
+ } else if (!passed) failures += 1;
+};
+const renderingSource = await readFile(
+ new URL('../src/layers/transit/rendering.js', import.meta.url),
+ 'utf8',
+);
+const frameSource = renderingSource
+ .split('function onPreRender()')[1]
+ .split('const occluder')[0];
+check(
+ 'frame pass does not scan the fleet or copy the moving set',
+ !/_vehicles\.values|\[\.\.\.state\._moving\]/.test(frameSource),
+);
+const palette = {
+ bus: '#5EF08A',
+ tram: '#FFC24A',
+ subway: '#FF4538',
+ rail: '#D9A6FF',
+ ferry: '#5FD6FF',
+ unknown: '#D8DDE5',
+};
+check(
+ 'all preset bracket themes preserve the six mode colours',
+ Object.values(DETECTION_THEME_MAP).every((theme) =>
+ Object.entries(palette).every(
+ ([mode, color]) =>
+ theme.tiers[transitModeTier(mode)].toUpperCase() === color,
+ ),
+ ),
+);
+check(
+ 'all required sections enabled',
+ SECTIONS.has('all'),
+ 'Partial runs are diagnostic and cannot pass acceptance.',
+);
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/** Hex → [r,g,b]. */
+const rgbOf = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
+const rgbDistance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
+
+/**
+ * Rendered pixels of the scene and of the detection surface around each
+ * visible transit sprite: centre and darkest/brightest ring luma from the
+ * WebGL canvas (the app preserves its drawing buffer), and the dominant
+ * opaque colour painted on the detection surface in a bracket-sized window.
+ * Sprites closer than 44 px to another sprite are skipped so a ring is not
+ * read off a neighbour.
+ * @param {import('puppeteer').Page} page
+ * @returns {Promise>}
+ */
+const sampleRendered = (page) => sampleTransitPixels(page, palette);
+
+/**
+ * The rendered position of every VISIBLE transit billboard, read back through
+ * Cesium. Visible only: off-screen vehicles take their newest fix directly, so
+ * including them would let hidden vehicles snapping stand in for motion a
+ * person can see.
+ * @param {import('puppeteer').Page} page
+ * @returns {Promise>}
+ */
+/**
+ * Window-space position and billboard rotation of every visible transit
+ * sprite. Orientation is a screen-space claim, so it has to be checked in
+ * screen space.
+ * @param {import('puppeteer').Page} page
+ * @returns {Promise>}
+ */
+async function sampleScreen(page) {
+ return page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const scene = app.viewer.scene;
+ const canvas = scene.canvas;
+ // The app exposes no `window.Cesium`, so the projection is done here in
+ // plain numbers: Cesium matrices are column-major arrays of 16 doubles,
+ // and window coordinates are the standard clip -> NDC -> viewport walk.
+ const view = scene.camera.viewMatrix;
+ const proj = scene.camera.frustum.projectionMatrix;
+ const mul = (m, v) => [
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
+ m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
+ ];
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ const out = [];
+ for (const entry of state._vehicles.values()) {
+ if (!entry.marker || entry.marker.show === false) continue;
+ const p = entry.marker.position;
+ const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
+ if (!(clip[3] > 0)) continue;
+ const ndcX = clip[0] / clip[3];
+ const ndcY = clip[1] / clip[3];
+ out.push({
+ entityCount: app.viewer.entities.values.length,
+ key: entry.key,
+ x: (ndcX * 0.5 + 0.5) * width,
+ y: (1 - (ndcY * 0.5 + 0.5)) * height,
+ rotation: entry.marker.rotation,
+ pollSeq: entry.pollSeq,
+ segment:
+ entry.from && entry.to ? `${entry.from.t}-${entry.to.t}` : null,
+ });
+ if (out.length >= 200) break;
+ }
+ return out;
+ });
+}
+
+async function sampleVisible(page) {
+ return page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const Cartographic = app.viewer.camera.positionCartographic.constructor;
+ return [...state._vehicles.values()]
+ .filter((entry) => entry.marker && entry.marker.show !== false)
+ .slice(0, 200)
+ .map((entry) => {
+ const carto = Cartographic.fromCartesian(entry.marker.position);
+ return {
+ key: entry.key,
+ lat: (carto.latitude * 180) / Math.PI,
+ lon: (carto.longitude * 180) / Math.PI,
+ gliding: state._moving.has(entry),
+ // Which poll and which displayed segment this reading belongs to:
+ // two readings that straddle a poll or a segment change are not
+ // evidence about interpolation, whatever the elapsed time says.
+ pollSeq: entry.pollSeq,
+ segment:
+ entry.from && entry.to ? `${entry.from.t}-${entry.to.t}` : null,
+ };
+ });
+ });
+}
+
+// Accelerate the storage clock without collecting a live feed in the background.
+const historyProbe = createTransitHistory({
+ sweep: false,
+ storage: { bytes: 0, keys: 0, stores: new Set() },
+ limits: { feedBytes: 128000, processBytes: 128000 },
+});
+const retainedSizes = [];
+for (let now = 0; now <= 7200000; now += 15000) {
+ historyProbe.ingest(
+ { id: 'probe', historyRetention: true, defaultMode: 'bus' },
+ Array.from({ length: 12 }, (_, i) => ({
+ id: String(i),
+ timestamp: now / 1000,
+ lat: 42,
+ lon: -71,
+ })),
+ now,
+ );
+ if (now >= 900000)
+ retainedSizes.push(historyProbe.diagnostics().allocatedBytes);
+}
+check(
+ 'two-hour proxy history allocation plateaus',
+ Math.max(...retainedSizes) === Math.min(...retainedSizes) &&
+ Math.max(...retainedSizes) <= 128000,
+ `${retainedSizes.length} warmed samples, ${Math.max(...retainedSizes)} allocated bytes`,
+);
+historyProbe.clear();
+await mkdir(SHOTS, { recursive: true });
+
+const browser = await puppeteer.launch({
+ headless: false,
+ protocolTimeout: 150000,
+ executablePath:
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ args: [
+ '--no-sandbox',
+ '--disable-background-timer-throttling',
+ '--disable-renderer-backgrounding',
+ '--window-size=1920,1080',
+ '--enable-precise-memory-info',
+ ],
+});
+const page = await browser.newPage();
+boundPageEvaluations(page);
+await page.setViewport({ width: 1920, height: 1080 });
+const pageErrors = [];
+page.on('pageerror', (error) => pageErrors.push(error.message));
+page.on('console', (message) => {
+ if (message.text().startsWith('TRANSIT_SAMPLER')) console.log(message.text());
+});
+const transitRequests = [];
+page.on('request', (request) => {
+ if (request.url().includes('/api/transit')) {
+ transitRequests.push({ at: Date.now(), url: request.url() });
+ }
+});
+
+const shot = (name, caption) =>
+ page
+ .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
+ .then(() => {
+ console.log(` shot ${name} — ${caption}`);
+ });
+
+const view = (city) =>
+ `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
+
+try {
+ await page.goto(view(BOSTON), { waitUntil: 'domcontentloaded' });
+ // Ready when the catalog is registered and its rows exist. (The manager's
+ // private panel handle moved into a presentation facade; waiting on it here
+ // was waiting for something that no longer exists.)
+ await page.waitForFunction(
+ () =>
+ Boolean(window.__godsEyeView?.dataManager?.layers?.size) &&
+ Boolean(document.querySelector('[data-layer-id="transit"]')),
+ { timeout: 90_000 },
+ );
+ await wait(6_000);
+
+ // The Data Layers panel starts collapsed; a user opens it before toggling.
+ const expanded = await page.$eval(
+ '#data-panel .panel-collapse-btn',
+ (button) => {
+ if (button.getAttribute('aria-expanded') === 'true') return true;
+ button.click();
+ return false;
+ },
+ );
+ if (!expanded) await wait(800);
+ check(
+ 'the Data Layers panel opens from its own header button',
+ await page.$eval(
+ '#data-panel .panel-collapse-btn',
+ (b) => b.getAttribute('aria-expanded') === 'true',
+ ),
+ );
+
+ // Record what the layer hands the shared overlay host, so the card check can
+ // quote the operator's own words rather than assert an internal key was set.
+ await page.evaluate(() => {
+ window.__gevTransitCards = new Map();
+ const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const host = state.DEFAULT_OVERLAY_HOST;
+ layer._setTransitOverlayHostForTest({
+ setEntries(sourceId, entries, options) {
+ for (const entry of entries || []) {
+ window.__gevTransitCards.set(entry.id, {
+ title: entry.title,
+ details: entry.details,
+ });
+ }
+ return host.setEntries(sourceId, entries, options);
+ },
+ setVisible: host.setVisible,
+ clearSource(sourceId) {
+ window.__gevTransitCards.clear();
+ return host.clearSource(sourceId);
+ },
+ });
+ });
+
+ const row = '[data-layer-id="transit"]';
+ const hasRow = await page.$(row);
+ check('the Transit layer has a row in the layer panel', Boolean(hasRow));
+ await page.$eval(row, (element) =>
+ element.scrollIntoView({ block: 'center' }),
+ );
+ await wait(400);
+ const before = transitRequests.length;
+ await page.click(`${row} .data-toggle-btn`);
+ await page.waitForFunction(
+ () => window.__godsEyeView.dataManager.isEnabled('transit'),
+ { timeout: 20_000 },
+ );
+ check(
+ 'clicking the row button enables the layer and starts real traffic',
+ transitRequests.length > before,
+ `${transitRequests.length - before} request(s)`,
+ );
+
+ await page.evaluate(() => {
+ window.__transitRenderErrors = [];
+ window.__godsEyeView.viewer.scene.renderError.addEventListener(
+ (scene, error) => window.__transitRenderErrors.push(error.message),
+ );
+ });
+ for (const city of runs('cities') ? CITIES : []) {
+ console.log(`\n== ${city.label} ==`);
+ // City-wide first: enough altitude to see the fleet as a fleet.
+ await page.evaluate((c) => {
+ const camera = window.__godsEyeView.viewer.camera;
+ const Cartographic = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: Cartographic.toCartesian(
+ Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500),
+ ),
+ orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
+ });
+ }, city);
+ await page.waitForFunction(
+ () =>
+ (window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module.getStats().count || 0) > 0,
+ { timeout: 90_000 },
+ );
+ await wait(9_000);
+ const fleet = await page.evaluate(() => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ return {
+ count: layer._transitStateForTest()._vehicles.size,
+ stats: layer.getStats(),
+ };
+ });
+ check(
+ `${city.label}: vehicles are rendered`,
+ fleet.count >= 20,
+ `${fleet.count} vehicles, coverage "${fleet.stats.coverage}"`,
+ );
+ await shot(
+ `${TAG}-${city.id}-00-fleet.jpg`,
+ `${city.label} from 3.5 km — ${fleet.count} vehicles across the metro`,
+ );
+
+ // Street level: 600 m up, 45 degrees down, framed on a real vehicle so the
+ // shot shows what a user sees when they drop in on one.
+ const framed = await page.evaluate((c) => {
+ const app = window.__godsEyeView;
+ const camera = app.viewer.camera;
+ const Cartographic = camera.positionCartographic.constructor;
+ const state = app.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ let best = null;
+ for (const entry of state._vehicles.values()) {
+ const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
+ if (!best || d < best.d)
+ best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
+ }
+ if (!best) return null;
+ // Camera 600 m back along the heading, 600 m up, looking 45 degrees down:
+ // the vehicle lands in the middle of the frame.
+ const heading = (c.heading * Math.PI) / 180;
+ const lat = best.lat - (600 * Math.cos(heading)) / 111_320;
+ const lon =
+ best.lon -
+ (600 * Math.sin(heading)) /
+ (111_320 * Math.cos((best.lat * Math.PI) / 180));
+ camera.setView({
+ destination: Cartographic.toCartesian(
+ Cartographic.fromDegrees(lon, lat, 600),
+ ),
+ orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 },
+ });
+ return best;
+ }, city);
+ check(
+ `${city.label}: a vehicle to fly to`,
+ Boolean(framed),
+ framed?.key || 'none',
+ );
+ await wait(9_000);
+
+ const first = await page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const Cartographic = app.viewer.camera.positionCartographic.constructor;
+ return {
+ count: state._vehicles.size,
+ stats: layer.getStats(),
+ // The RENDERED primitive position, read back through Cesium — not the
+ // fix the layer is aiming at. A layer that never moved a point would
+ // still show two different `entry.to` values, so comparing those would
+ // prove the data changed, not that anything on screen did.
+ // VISIBLE markers only. Off-screen vehicles take their newest fix
+ // directly — that is the whole point of the view gate — so sampling
+ // them would let a layer whose visible interpolation is broken pass
+ // this check on the strength of hidden vehicles snapping.
+ sample: [...state._vehicles.values()]
+ .filter((entry) => entry.marker && entry.marker.show !== false)
+ .slice(0, 200)
+ .map((entry) => {
+ const carto = Cartographic.fromCartesian(entry.marker.position);
+ return {
+ key: entry.key,
+ lat: (carto.latitude * 180) / Math.PI,
+ lon: (carto.longitude * 180) / Math.PI,
+ };
+ }),
+ };
+ });
+
+ // Click a vehicle that is actually on screen, recomputing its position
+ // immediately before each attempt — these dots are gliding.
+ let selected = null;
+ for (let attempt = 0; attempt < 6 && !selected; attempt += 1) {
+ const target = await page.evaluate((skip) => {
+ const app = window.__godsEyeView;
+ const state = app.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ const scene = app.viewer.scene;
+ const seen = [];
+ for (const entry of state._vehicles.values()) {
+ const screen = scene.cartesianToCanvasCoordinates(
+ entry.marker.position,
+ );
+ if (
+ screen &&
+ screen.x > 360 &&
+ screen.x < scene.canvas.clientWidth - 120 &&
+ screen.y > 120 &&
+ screen.y < scene.canvas.clientHeight - 160
+ ) {
+ seen.push({
+ key: entry.key,
+ x: Math.round(screen.x),
+ y: Math.round(screen.y),
+ });
+ }
+ }
+ return seen[skip] || null;
+ }, attempt);
+ if (!target) break;
+ await page.mouse.click(target.x, target.y);
+ await wait(900);
+ selected = await page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const key = app.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest()._selectedKey;
+ if (!key) return null;
+ // A selected key is bookkeeping. The claim is that the user SEES a
+ // card, and these cards are painted onto the shared overlay CANVAS
+ // rather than built from DOM nodes — so the proof is the operator's own
+ // words the layer published, plus pixels that actually landed there.
+ const published = window.__gevTransitCards?.get(key) || null;
+ const canvas = document.getElementById('world-overlay-canvas');
+ let painted = 0;
+ if (canvas?.width) {
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ const pixels = context.getImageData(
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ ).data;
+ for (let i = 3; i < pixels.length; i += 64) {
+ if (pixels[i] > 8) painted += 1;
+ }
+ }
+ return { key, published, painted };
+ });
+ if (selected && (!selected.published || selected.painted === 0)) {
+ selected = null;
+ }
+ }
+ const cardLines = selected
+ ? [selected.published.title, ...(selected.published.details || [])]
+ : [];
+ check(
+ `${city.label}: clicking a vehicle raises a card with its details on screen`,
+ cardLines.length >= 3 &&
+ /Route |Vehicle /.test(cardLines[0] || '') &&
+ cardLines.some((line) => /Reported /.test(line)) &&
+ selected.painted > 0,
+ selected
+ ? `${selected.key} — "${cardLines.slice(0, 4).join(' / ')}" over ${selected.painted} painted overlay samples`
+ : 'no vehicle card became visible',
+ );
+ // DETECT is on by default at DENSE. Prove the overlay actually boxes and
+ // labels transit contacts rather than ignoring them, which is what the
+ // owner found: the layer drew dots and the sensor overlay looked past them.
+ const detect = await page.evaluate(() => {
+ const gev = window.__godsEyeView;
+ const layer = gev.dataManager.layers.get('transit').module;
+ const objects = layer.getDetectableObjects({ maxCount: 200 });
+ const button = document.getElementById('detection-toggle');
+ return {
+ mode: (button?.textContent || '').replace(/\s+/g, ' ').trim(),
+ count: objects.length,
+ sample: objects.slice(0, 3).map((o) => `${o.id}/${o.metric || ''}`),
+ types: [...new Set(objects.map((o) => o.type))],
+ labelled: objects.filter((o) => o.id && o.metric).length,
+ skipped: objects.filter((o) => o.skipLabel).length,
+ };
+ });
+ // What the host DOES with these is pinned by the detection-host unit test,
+ // which drives the real painter and reads the route and mode back off the
+ // canvas. Here the claim is narrower and fully attributable: the layer
+ // offers DETECT properly formed contacts, which is precisely what it used
+ // to offer none of.
+ check(
+ `${city.label}: the layer offers DETECT real contacts, not bare dots`,
+ detect.count > 0 &&
+ detect.types.length === 1 &&
+ detect.types[0] === 'VEH' &&
+ detect.labelled === detect.count,
+ `${detect.count} contacts, all labelled, e.g. ${detect.sample.join(', ')} (DETECT ${detect.mode})`,
+ );
+
+ // The glyphs themselves: modes must not all be one shape.
+ const sprites = await page.evaluate(() => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ const images = new Map();
+ for (const entry of state._vehicles.values()) {
+ if (!entry.marker?.show) continue;
+ // Compare the WHOLE data URI: every glyph shares a long base64 prefix,
+ // so a truncated comparison says they are all the same picture.
+ images.set(entry.mode, String(entry.marker.image || ''));
+ }
+ return {
+ modes: [...images.keys()],
+ distinct: new Set(images.values()).size,
+ glyphBytes: [...images.values()].map((uri) => uri.length),
+ billboards: [...state._vehicles.values()].filter((e) => e.marker)
+ .length,
+ shown: state._shownCount,
+ };
+ });
+ check(
+ `${city.label}: vehicles draw as mode sprites, not plain dots`,
+ sprites.distinct === sprites.modes.length && sprites.modes.length > 0,
+ `${sprites.modes.join(', ')} → ${sprites.distinct} distinct glyph(s) of sizes ${sprites.glyphBytes.join('/')}; ${sprites.shown} shown of ${sprites.billboards}`,
+ );
+
+ await shot(
+ `${TAG}-${city.id}-01-vehicles.jpg`,
+ `${city.label} at 600 m, 45° pitch — ${first.count} vehicles in the layer, selected card on the one in frame`,
+ );
+
+ // ---- Rendered appearance under the sensor presets (first active live city) --------
+ // In-scene sprites pass through the post-FX chain; what matters is what
+ // reaches the screen. DETECT on, then each preset the owner tested:
+ // pixels of the sprite core and its ring from the WebGL canvas, the
+ // colour painted for its bracket on the detection surface, and one
+ // screenshot per preset for the diary.
+ if (city.id === CITIES[0].id) {
+ console.log('\n== rendered appearance ==');
+ const profile = await detectOn(page);
+ check(
+ 'DETECT reaches the dense profile through its own button',
+ profile === 'DENSE',
+ profile,
+ );
+ const themeFor = (style) =>
+ DETECTION_THEME_MAP[style] || DETECTION_THEME_MAP._default;
+ const presets = [
+ { name: 'normal', style: 'normal', params: {}, hot: null },
+ {
+ name: 'thermal-whot',
+ style: 'thermal',
+ params: { 'WHOT/BHOT': 0, Ironbow: 0 },
+ hot: 'white',
+ },
+ {
+ name: 'thermal-bhot',
+ style: 'thermal',
+ params: { 'WHOT/BHOT': 1, Ironbow: 0 },
+ hot: 'black',
+ },
+ {
+ name: 'thermal-ironbow',
+ style: 'thermal',
+ params: { 'WHOT/BHOT': 0, Ironbow: 1 },
+ hot: 'white',
+ },
+ {
+ name: 'surveillance',
+ style: 'surveillance',
+ params: {},
+ hot: 'white',
+ },
+ { name: 'noir', style: 'noir', params: {}, hot: 'white' },
+ { name: 'retro', style: 'retro', params: {}, hot: null },
+ ];
+ await selectStyle(page, 'normal');
+ const baseline = await page.evaluate(readTransitFleetStyle, {
+ resetSelection: true,
+ });
+ for (const preset of presets) {
+ const how = await selectStyle(page, preset.style, preset.params);
+ await wait(1_200);
+ const styling = await page.evaluate(() =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStylingForTest(),
+ );
+ const rendered = await sampleRendered(page);
+ await shot(
+ `${TAG}-boston-style-${preset.name}.jpg`,
+ `${city.label} at street level under ${preset.name}, DETECT on — ${rendered.length} isolated sprites sampled (style set via ${how})`,
+ );
+ const modes = new Set(rendered.map((r) => r.mode));
+ if (preset.hot) {
+ const contrast = reduceSensorContrast(
+ rendered,
+ preset.hot,
+ preset.style,
+ );
+ check(
+ `${preset.name}: sprite cores have the expected sensor contrast`,
+ contrast.pass,
+ JSON.stringify(contrast),
+ { unexercised: rendered.length < 6 },
+ );
+ check(
+ `${preset.name}: the layer styled its sprites for ${preset.style}`,
+ styling.stylePreset === preset.style,
+ JSON.stringify(styling),
+ );
+ }
+ // Brackets: the detection surface paints each contact in its tier
+ // colour, which must be the mode's colour for this theme.
+ const theme = themeFor(preset.style);
+ const matched = rendered.filter(
+ (r) =>
+ r.bracket &&
+ rgbDistance(
+ r.bracket,
+ rgbOf(theme.tiers[transitModeTier(r.mode)]),
+ ) < 48,
+ );
+ const byMode = new Map();
+ for (const r of rendered) if (r.bracket) byMode.set(r.mode, r.bracket);
+ const modeColours = [...byMode.values()];
+ let distinct = true;
+ for (let i = 0; i < modeColours.length; i += 1)
+ for (let j = i + 1; j < modeColours.length; j += 1)
+ if (rgbDistance(modeColours[i], modeColours[j]) < 40)
+ distinct = false;
+ check(
+ `${preset.name}: brackets carry the mode colour (${[...modes].join(', ') || 'no modes'})`,
+ rendered.length >= 6 &&
+ matched.length >= Math.ceil(rendered.length * 0.75) &&
+ (byMode.size < 2 || distinct),
+ rendered.length === 0
+ ? 'no isolated sprite in frame to sample'
+ : `${matched.length} of ${rendered.length} within tolerance of their tier; ${byMode.size} mode(s) painted${byMode.size >= 2 ? (distinct ? ', distinguishable' : ', NOT distinguishable') : ''}; misses=${JSON.stringify(
+ rendered
+ .filter((r) => !matched.includes(r))
+ .map((r) => ({
+ key: r.key,
+ sampled: r.bracket,
+ tier: transitModeTier(r.mode),
+ expected: rgbOf(theme.tiers[transitModeTier(r.mode)]),
+ })),
+ )}`,
+ { unexercised: rendered.length < 6 },
+ );
+ const clickTarget = rendered[0];
+ if (clickTarget) {
+ await page.mouse.click(clickTarget.x, clickTarget.y);
+ await wait(100);
+ const picked = await page.evaluate((key) => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ return {
+ selected: state._selectedKey === key,
+ pickOwner: state._lastPickForTest,
+ };
+ }, clickTarget.key);
+ check(
+ `${preset.name}: mouse click selects the displayed sprite`,
+ picked.selected,
+ JSON.stringify(picked),
+ );
+ await page.keyboard.press('Escape');
+ } else
+ check(
+ `${preset.name}: mouse click selects the displayed sprite`,
+ false,
+ 'No verified unobscured sprite',
+ { unexercised: true },
+ );
+ check(
+ `${preset.name}: the icon raster cache stays bounded`,
+ styling.iconCacheSize <= 6 * 2 * 3,
+ `${styling.iconCacheSize} rasters`,
+ );
+ }
+ // Selection under FLIR keeps the sprite white; leaving FLIR restores it.
+ await selectStyle(page, 'thermal', { 'WHOT/BHOT': 0, Ironbow: 0 });
+ const target = (await sampleRendered(page))[0];
+ if (target) {
+ await page.mouse.click(target.x, target.y);
+ await wait(800);
+ const underFlir = await page.evaluate((key) => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const entry = state._vehicles.get(key);
+ return {
+ selected: state._selectedKey === key,
+ color: entry?.marker.color.toCssColorString(),
+ width: entry?.marker.width,
+ pickOwner: (() => {
+ const p = window.__godsEyeView.viewer.scene.pick(
+ entry.marker.computeScreenSpacePosition(
+ window.__godsEyeView.viewer.scene,
+ ),
+ );
+ return {
+ id: typeof p?.id === 'string' ? p.id : null,
+ primitiveId:
+ typeof p?.primitive?.id === 'string' ? p.primitive.id : null,
+ collection:
+ entry.markerCollection === state._animatedMarkers
+ ? 'animated'
+ : 'stationary',
+ };
+ })(),
+ };
+ }, target.key);
+ check(
+ 'clicking a sprite under FLIR selects it and keeps it white-hot',
+ underFlir.selected && underFlir.color === 'rgb(255,255,255)',
+ JSON.stringify(underFlir),
+ );
+ await page.keyboard.press('Escape');
+ await wait(500);
+ } else {
+ check(
+ 'clicking a sprite under FLIR selects it and keeps it white-hot',
+ false,
+ 'no isolated sprite to click',
+ { unexercised: true },
+ );
+ }
+ await selectStyle(page, 'normal');
+ const restored = await page.evaluate(readTransitFleetStyle, {
+ key: baseline?.key,
+ });
+ check(
+ 'returning to normal restores the shipped sprite exactly',
+ Boolean(restored && baseline) &&
+ !restored.selected &&
+ !baseline.selected &&
+ restored.color === baseline.color &&
+ restored.width === baseline.width &&
+ restored.image === baseline.image,
+ restored && baseline
+ ? `${baseline.key}: selected ${baseline.selected}→${restored.selected}, ${baseline.color}/${baseline.width}px → ${restored.color}/${restored.width}px; raster equal ${baseline.image === restored.image}`
+ : 'the baseline vehicle left the fleet',
+ { unexercised: !(restored && baseline) },
+ );
+ }
+
+ // Motion acceptance uses known report-time playback even when live fleets park.
+ await selectStyle(page, 'normal');
+ await page.evaluate((city) => {
+ const app = window.__godsEyeView,
+ camera = app.viewer.camera;
+ const C = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ }, city);
+ await wait(2000);
+ await page.evaluate(
+ (city) =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._loadTransitFleetForTest(16, city, 25),
+ city,
+ );
+ await wait(1000);
+ const motionFirst = await sampleVisible(page);
+ await wait(30_000);
+ const second = await page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const Cartographic = app.viewer.camera.positionCartographic.constructor;
+ return {
+ count: state._vehicles.size,
+ // The RENDERED primitive position, read back through Cesium — not the
+ // fix the layer is aiming at. A layer that never moved a point would
+ // still show two different `entry.to` values, so comparing those would
+ // prove the data changed, not that anything on screen did.
+ // VISIBLE markers only. Off-screen vehicles take their newest fix
+ // directly — that is the whole point of the view gate — so sampling
+ // them would let a layer whose visible interpolation is broken pass
+ // this check on the strength of hidden vehicles snapping.
+ sample: [...state._vehicles.values()]
+ .filter((entry) => entry.marker && entry.marker.show !== false)
+ .slice(0, 200)
+ .map((entry) => {
+ const carto = Cartographic.fromCartesian(entry.marker.position);
+ return {
+ key: entry.key,
+ lat: (carto.latitude * 180) / Math.PI,
+ lon: (carto.longitude * 180) / Math.PI,
+ };
+ }),
+ };
+ });
+ const beforeById = new Map(motionFirst.map((v) => [v.key, v]));
+ let moved = 0;
+ let matched = 0;
+ for (const v of second.sample) {
+ const was = beforeById.get(v.key);
+ if (!was) continue;
+ matched += 1;
+ const dLat = (v.lat - was.lat) * 111_320;
+ const dLon =
+ (v.lon - was.lon) * 111_320 * Math.cos((v.lat * Math.PI) / 180);
+ if (Math.hypot(dLat, dLon) > 5) moved += 1;
+ }
+ check(
+ `${city.label} deterministic fixture: the same vehicles are still drawn 30 s later, in new places`,
+ matched >= 5 && moved >= 3,
+ `${matched} same visible ids, ${moved} whose RENDERED point moved more than 5 m`,
+ );
+
+ // Between polls, not across them. Positions changing over thirty seconds
+ // only proves new data arrived; a layer that snapped every fifteen seconds
+ // and drew nothing in between would pass that. Two reads two seconds apart
+ // fall inside one poll interval, so anything that moves is being
+ // INTERPOLATED on screen.
+ const glideA = await sampleVisible(page);
+ await wait(2_000);
+ const glideB = await sampleVisible(page);
+ const glideBefore = new Map(glideA.map((v) => [v.key, v]));
+ let glided = 0;
+ let glideMatched = 0;
+ let straddled = 0;
+ // Only vehicles the LAYER says are mid-segment can testify here, and only
+ // readings from the SAME poll and the same displayed segment: a pair that
+ // straddles a poll could have snapped, and would pass as motion.
+ let claimed = 0;
+ for (const v of glideB) {
+ const was = glideBefore.get(v.key);
+ if (!was) continue;
+ glideMatched += 1;
+ if (!(was.gliding || v.gliding)) continue;
+ if (was.pollSeq !== v.pollSeq || was.segment !== v.segment) {
+ straddled += 1;
+ continue;
+ }
+ claimed += 1;
+ const dLat = (v.lat - was.lat) * 111_320;
+ const dLon =
+ (v.lon - was.lon) * 111_320 * Math.cos((v.lat * Math.PI) / 180);
+ if (Math.hypot(dLat, dLon) > 1) glided += 1;
+ }
+ check(
+ `${city.label} deterministic fixture: a vehicle the layer says is mid-segment moves on screen between polls`,
+ claimed > 0 && glided >= 1,
+ claimed === 0
+ ? `nothing was mid-segment inside one poll across ${glideMatched} visible vehicles (${straddled} pairs straddled a poll)`
+ : `${glided} of ${claimed} mid-segment vehicles moved within one poll and one segment (${straddled} straddling pairs discarded)`,
+ { unexercised: claimed === 0 },
+ );
+
+ // Orientation, measured the only way that settles it: where the sprite
+ // points against where the sprite actually travelled on screen. The
+ // projection is perspective-exact, so an off-centre vehicle at an oblique
+ // pitch must agree too — that is precisely the case the orthographic
+ // approximation got wrong by twenty-odd degrees.
+ //
+ // Only sprites that visibly moved can testify, and a keyless Austin view
+ // holds a handful of them; the floor is what that view can honestly
+ // supply, and the accuracy bar does the work.
+ const headingA = await sampleScreen(page);
+ await wait(6_000);
+ const headingB = await sampleScreen(page);
+ const wasById = new Map(headingA.map((v) => [v.key, v]));
+ let aimed = 0;
+ let checked = 0;
+ let worstDeg = 0;
+ for (const now of headingB) {
+ const was = wasById.get(now.key);
+ if (!was) continue;
+ // A poll in between could have placed it; that is not travel.
+ if (was.pollSeq !== now.pollSeq) continue;
+ const dx = now.x - was.x;
+ const dy = now.y - was.y;
+ // Only vehicles that visibly travelled can testify about direction.
+ if (Math.hypot(dx, dy) < 6) continue;
+ checked += 1;
+ // The sprite is authored nose-up and rotated CCW, so at rotation r its
+ // nose points along (-sin r, -cos r) in window coordinates.
+ const noseX = -Math.sin(now.rotation);
+ const noseY = -Math.cos(now.rotation);
+ const travel = Math.hypot(dx, dy);
+ const cosErr = (noseX * dx + noseY * dy) / travel;
+ const errDeg =
+ (Math.acos(Math.max(-1, Math.min(1, cosErr))) * 180) / Math.PI;
+ if (errDeg <= 35) aimed += 1;
+ else worstDeg = Math.max(worstDeg, errDeg);
+ }
+ check(
+ `${city.label} deterministic fixture: sprites point the way they are travelling on screen`,
+ checked > 0 && aimed >= Math.ceil(checked * 0.8),
+ checked === 0
+ ? 'no sprite travelled far enough on screen inside one poll to testify'
+ : `${aimed} of ${checked} moving sprites within 35° of their screen travel` +
+ (worstDeg ? `, worst miss ${Math.round(worstDeg)}°` : ''),
+ { unexercised: checked === 0 },
+ );
+
+ await shot(
+ `${TAG}-${city.id}-02-thirty-seconds-later.jpg`,
+ `${city.label} deterministic fixture 30 s later — ${second.count} vehicles, ${moved}/${matched} moved`,
+ );
+ }
+
+ check(
+ 'vehicle selection never raises a Cesium render error',
+ (await page.evaluate(() => window.__transitRenderErrors || [])).length ===
+ 0,
+ JSON.stringify(
+ await page.evaluate(() => window.__transitRenderErrors || []),
+ ),
+ );
+
+ if (runs('history')) {
+ console.log('\n== retained history reload ==');
+ const recovery = await browser.newPage();
+ boundPageEvaluations(recovery);
+ try {
+ await recovery.goto(view(BOSTON), { waitUntil: 'domcontentloaded' });
+ await recovery.waitForFunction(
+ () => window.__godsEyeView?.dataManager?.layers?.has('transit'),
+ { timeout: 90000 },
+ );
+ // Leave enough time for two snapshots, priming and reload in one minute.
+ if (Date.now() % 60000 > 20000)
+ await wait(60000 - (Date.now() % 60000) + 50);
+ const first = await recovery.evaluate(async () =>
+ (
+ await fetch('/api/transit/vehicles/mbta', {
+ signal: AbortSignal.timeout(10000),
+ })
+ ).json(),
+ );
+ await wait(16000);
+ const retained = await recovery.evaluate(async (prior) => {
+ const current = await (
+ await fetch('/api/transit/vehicles/mbta', {
+ signal: AbortSignal.timeout(10000),
+ })
+ ).json();
+ const candidates = current.vehicles.filter((v) =>
+ prior.vehicles.some((p) => p.id === v.id),
+ );
+ // Prime the newest fixes from this exact feed, not evenly spaced stale IDs.
+ const primedAt = Date.now();
+ const picked = candidates
+ .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
+ .slice(0, 12);
+ const histories = await Promise.all(
+ picked.map(async (vehicle) => {
+ const response = await fetch(
+ `/api/transit/trail/mbta/${encodeURIComponent(vehicle.id)}`,
+ { signal: AbortSignal.timeout(10000) },
+ );
+ if (!response.ok)
+ throw new Error(`Trail prime HTTP ${response.status}`);
+ return response.json();
+ }),
+ );
+ return histories
+ .filter((history) => history.fixes?.length >= 2)
+ .map((history) => ({ ...history, feedId: 'mbta', primedAt }));
+ }, first);
+ await recovery.reload({ waitUntil: 'domcontentloaded' });
+ await recovery.waitForFunction(
+ () => window.__godsEyeView?.dataManager?.layers?.has('transit'),
+ { timeout: 90000 },
+ );
+ await recovery.evaluate(() => {
+ window.__transitRenderErrors = [];
+ window.__godsEyeView.viewer.scene.renderError.addEventListener(
+ (scene, error) => window.__transitRenderErrors.push(error.message),
+ );
+ });
+ const restored = await recovery.evaluate(
+ restoreRetainedTransit,
+ retained,
+ );
+ if (restored.key) {
+ await recovery.waitForFunction(
+ () =>
+ !window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitPartsForTest()
+ .trails.requestDiagnostics().pending,
+ { timeout: 20000 },
+ );
+ await wait(4000);
+ const proof = await recovery.evaluate(
+ ({ key, oldest }) => {
+ const layer =
+ window.__godsEyeView.dataManager.layers.get('transit').module;
+ const entry = layer._transitStateForTest()._vehicles.get(key),
+ parts = layer._transitPartsForTest();
+ if (!entry) return { gone: true };
+ const segments = entry.trailSegments?.length || 0;
+ const renderErrors = window.__transitRenderErrors || [];
+ const count = entry.track.count,
+ restoredOldest = entry.fixes[0].t;
+ const before = parts.trails.requestDiagnostics().abortCount;
+ parts.selection.selectVehicle(key);
+ parts.selection.clearSelection();
+ return {
+ count,
+ segments,
+ renderErrors,
+ restoredOldest,
+ oldest,
+ aborted: parts.trails.requestDiagnostics().abortCount > before,
+ };
+ },
+ { key: restored.key, oldest: restored.oldest },
+ );
+ check(
+ 'reload restores observed history and selection churn aborts its request',
+ proof.count >= 2 &&
+ proof.restoredOldest <= proof.oldest &&
+ proof.aborted,
+ proof.gone
+ ? 'Selected primed vehicle left during recovery'
+ : JSON.stringify(proof),
+ { unexercised: proof.gone === true },
+ );
+ if (!proof.gone)
+ check(
+ 'reloaded MBTA trail prepares geometry without a Cesium render error',
+ proof.segments > 0 && proof.renderErrors.length === 0,
+ JSON.stringify(proof),
+ {
+ unexercised:
+ proof.segments === 0 && proof.renderErrors.length === 0,
+ },
+ );
+ } else
+ check('reload restores observed history', false, restored.error, {
+ unexercised: restored.unexercised === true,
+ });
+ } finally {
+ await recovery.close();
+ await page.bringToFront();
+ }
+ }
+
+ // ---- Deterministic playback and card anchor (synthetic feed) -----------
+ // The live feeds prove the layer against reality; they cannot prove it
+ // against a script. For the next two minutes the MBTA response is replaced
+ // at the network layer by six scripted vehicles near the Boston view —
+ // straight travel, a corner, a genuine stop, a ninety-second silence, a
+ // late packet and a feed that only refreshes every thirty seconds — and
+ // every rendered frame is sampled.
+ if (runs('playback')) {
+ console.log('\n== deterministic playback ==');
+ await page.evaluate((c) => {
+ const camera = window.__godsEyeView.viewer.camera;
+ const Cartographic = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: Cartographic.toCartesian(
+ Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400),
+ ),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ }, BOSTON);
+ await page.evaluate(() => {
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._setTransitFixtureFloorsForTest(
+ ['straight', 'corner', 'stop', 'gap', 'late', 'dup'].map(
+ (id) => `mbta:sim-${id}`,
+ ),
+ 20,
+ );
+ });
+ const ORIGIN = { lat: BOSTON.lat, lon: BOSTON.lon };
+ const M_LAT = 1 / 111_320;
+ const M_LON = 1 / (111_320 * Math.cos((ORIGIN.lat * Math.PI) / 180));
+ const at = (eastM, northM) => ({
+ lat: ORIGIN.lat + northM * M_LAT,
+ lon: ORIGIN.lon + eastM * M_LON,
+ });
+ const simStart = Date.now();
+ const reportTime = (elapsedS, cadenceS) =>
+ Math.floor(elapsedS / cadenceS) * cadenceS;
+ const SIM = {
+ straight: { speed: 10 },
+ corner: { speed: 8, turnAt: 60 },
+ stop: { speed: 8, stopFrom: 30, stopTo: 75 },
+ gap: { speed: 6, silentFrom: 40, silentTo: 130 },
+ late: { speed: 8, lateFrom: 50, lateTo: 60 },
+ dup: { speed: 8, cadence: 30 },
+ };
+ const synthetic = (nowMs) => {
+ const elapsed = (nowMs - simStart) / 1000;
+ const vehicles = [];
+ const push = (id, pos, tS) =>
+ vehicles.push({
+ id,
+ lat: pos.lat,
+ lon: pos.lon,
+ bearing: null,
+ speedMps: null,
+ timestamp: Math.round(simStart / 1000 + tS),
+ timestampSource: 'vehicle',
+ routeId: id.toUpperCase(),
+ tripId: null,
+ directionId: null,
+ label: null,
+ stopId: null,
+ status: null,
+ occupancy: null,
+ });
+ {
+ const t = reportTime(elapsed, 10);
+ push('sim-straight', at(-220, SIM.straight.speed * t), t);
+ }
+ {
+ const t = reportTime(elapsed, 10);
+ const n = Math.min(t, SIM.corner.turnAt) * SIM.corner.speed;
+ const e = Math.max(0, t - SIM.corner.turnAt) * SIM.corner.speed;
+ push('sim-corner', at(110 + e, n), t);
+ }
+ {
+ const t = reportTime(elapsed, 10);
+ const moved =
+ t <= SIM.stop.stopFrom
+ ? t
+ : t <= SIM.stop.stopTo
+ ? SIM.stop.stopFrom
+ : SIM.stop.stopFrom + (t - SIM.stop.stopTo);
+ push('sim-stop', at(-110, moved * SIM.stop.speed), t);
+ }
+ {
+ const t0 = reportTime(elapsed, 10);
+ const t =
+ t0 >= SIM.gap.silentFrom && t0 < SIM.gap.silentTo
+ ? SIM.gap.silentFrom
+ : t0;
+ push('sim-gap', at(220, t * SIM.gap.speed), t);
+ }
+ {
+ const t0 = reportTime(elapsed, 10);
+ const t =
+ elapsed >= SIM.late.lateFrom && elapsed < SIM.late.lateTo
+ ? t0 - 30
+ : t0;
+ push('sim-late', at(330, t * SIM.late.speed), t);
+ }
+ {
+ const t = reportTime(elapsed, SIM.dup.cadence);
+ push('sim-dup', at(-330, t * SIM.dup.speed), t);
+ }
+ return {
+ feedId: 'mbta',
+ name: 'MBTA',
+ fetchedAt: nowMs,
+ feedTimestamp: null,
+ version: '2.0',
+ entityCount: vehicles.length,
+ truncated: false,
+ count: vehicles.length,
+ vehicles,
+ };
+ };
+ let intercepting = true;
+ await page.setRequestInterception(true);
+ const onRequest = (request) => {
+ if (
+ intercepting &&
+ request.url().includes('/api/transit/vehicles/mbta')
+ ) {
+ const now = Date.now();
+ request.respond({
+ status: 200,
+ contentType: 'application/json',
+ headers: { 'x-transit-contact': String(now) },
+ body: JSON.stringify(synthetic(now)),
+ });
+ return;
+ }
+ request.continue();
+ };
+ page.on('request', onRequest);
+ // Wait for the scripted fleet to replace the live one.
+ await page.waitForFunction(
+ () => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ return state._vehicles.has('mbta:sim-straight');
+ },
+ { timeout: 60_000 },
+ );
+ const simState = () =>
+ page.evaluate(() => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ const now = Date.now();
+ return ['straight', 'corner', 'stop', 'gap', 'late', 'dup']
+ .map((k) => {
+ const e = state._vehicles.get(`mbta:sim-${k}`);
+ if (!e) return `${k}:absent`;
+ return `${k}:${e.marker ? (e.marker.show === false ? 'hidden' : 'shown') : 'nomarker'}/lag${((now - e.playT) / 1000).toFixed(0)}/${state._moving.has(e) ? 'mv' : 'idle'}/fx${e.fixes.length}/r${e.resets || 0}`;
+ })
+ .join(' ');
+ });
+ console.log(` scripted fleet before trace: ${await simState()}`);
+ await reportTransitVisibility(page, 'scripted visibility before trace');
+ // Sample every rendered frame for two minutes.
+ const trace = await page.evaluate(
+ (durationMs) =>
+ new Promise((resolve) => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const Cartographic =
+ app.viewer.camera.positionCartographic.constructor;
+ const keys = [
+ 'sim-straight',
+ 'sim-corner',
+ 'sim-stop',
+ 'sim-gap',
+ 'sim-late',
+ 'sim-dup',
+ ].map((k) => `mbta:${k}`);
+ const out = Object.fromEntries(keys.map((k) => [k, []]));
+ const words = Object.fromEntries(keys.map((k) => [k, []]));
+ const t0 = performance.now();
+ let lastWordAt = -1e9;
+ let remove;
+ const timer = setTimeout(() => {
+ remove?.();
+ resolve({ out, words });
+ }, durationMs + 1000);
+ const tick = () => {
+ const now = performance.now();
+ const wall = Date.now();
+ for (const key of keys) {
+ const entry = state._vehicles.get(key);
+ if (!entry?.marker?.show) continue;
+ const carto = Cartographic.fromCartesian(entry.marker.position);
+ out[key].push([
+ now - t0,
+ (carto.latitude * 180) / Math.PI,
+ (carto.longitude * 180) / Math.PI,
+ entry.playT,
+ entry.resets || 0,
+ state._moving.has(entry) ? 1 : 0,
+ entry.courseDeg,
+ wall,
+ entry.track.count,
+ entry.sample.segmentSpeedMps,
+ entry.sample.fromSeq,
+ entry.marker.position.x,
+ entry.marker.position.y,
+ entry.marker.position.z,
+ entry.sample.toSeq,
+ entry.sample.phase,
+ entry.clocks.monoNowMs,
+ ]);
+ }
+ if (now - lastWordAt > 1_000) {
+ lastWordAt = now;
+ for (const contact of layer.getDetectableObjects()) {
+ if (words[contact.sourceId])
+ words[contact.sourceId].push([now - t0, contact.metric]);
+ }
+ }
+ if (now - t0 >= durationMs) {
+ clearTimeout(timer);
+ remove();
+ resolve({ out, words });
+ }
+ };
+ remove = app.viewer.scene.postRender.addEventListener(tick);
+ }),
+ 120_000,
+ );
+ console.log(` scripted fleet after trace: ${await simState()}`);
+ await reportTransitVisibility(page, 'scripted visibility after trace');
+ const metres = (a, b) =>
+ Math.hypot(
+ (b[1] - a[1]) * 111_320,
+ (b[2] - a[2]) * 111_320 * Math.cos((a[1] * Math.PI) / 180),
+ );
+ const judge = (key) => reduceScenario(trace, key);
+ // Preserve every frame and both bracket ends; never join moving rows across a hold.
+ const speedEvidence = Object.entries(trace.out).map(([key, rows]) => ({
+ key,
+ ...reduceScriptedMotion(key, rows),
+ }));
+ await writeFile(
+ `${SHOTS}/${TAG}-scripted-frames.json`,
+ JSON.stringify(trace),
+ );
+ for (const evidence of speedEvidence)
+ check(
+ `${evidence.key}: ordinary postRender frames follow the displayed segment within 2%`,
+ evidence.pass,
+ JSON.stringify(evidence),
+ );
+ const scenarioCheck = (name, conditions, detail) =>
+ check(
+ name,
+ Object.values(conditions).every(Boolean),
+ `${detail}; conditions=${JSON.stringify(conditions)}; failed=${JSON.stringify(Object.keys(conditions).filter((k) => !conditions[k]))}`,
+ );
+ const straight = judge('straight');
+ scenarioCheck(
+ 'straight travel is played back continuously at its own speed',
+ {
+ 'straight.rows > 3_000': straight.rows > 3_000,
+ 'straight.total > 300': straight.total > 300,
+ 'straight.moved > straight.rows * 0.4':
+ straight.moved > straight.rows * 0.4,
+ 'straight.worstJump <= SIM.straight.speed * 1.02 + 0.1':
+ straight.worstJump <= SIM.straight.speed * 1.02 + 0.1,
+ 'straight.speedWorst >= SIM.straight.speed * 0.98':
+ straight.speedWorst >= SIM.straight.speed * 0.98,
+ 'straight.speedWorst <= SIM.straight.speed * 1.02':
+ straight.speedWorst <= SIM.straight.speed * 1.02,
+ 'straight.wallFaster === 0': straight.wallFaster === 0,
+ 'straight.resets === 0': straight.resets === 0,
+ },
+ `${straight.rows} frames (${straight.keys}), ${straight.total.toFixed(0)} m drawn, ${straight.moved} moving frames, worst per-frame ${straight.worstJump.toFixed(1)} m/s, peak segment speed ${straight.speedWorst.toFixed(2)} m/s vs ${SIM.straight.speed}, ${straight.wallFaster} frames faster than real time, ${straight.resets} resets`,
+ );
+ scenarioCheck(
+ 'the display lag is bounded and never below the feed floor',
+ {
+ 'straight.lagValid': straight.lagValid,
+ 'straight.lagMin >= 24': straight.lagMin >= 24,
+ 'straight.lagMax <= 215': straight.lagMax <= 215,
+ },
+ `lag ${straight.lagMin.toFixed(1)}–${straight.lagMax.toFixed(1)} s`,
+ );
+ const corner = judge('corner');
+ const cornerRows = trace.out['mbta:sim-corner'] || [];
+ const courseLate = cornerRows
+ .slice(-300)
+ .map((r) => r[6])
+ .filter(Number.isFinite);
+ const courseEarly = cornerRows
+ .filter((r) => r[5] === 1)
+ .slice(0, 300)
+ .map((r) => r[6])
+ .filter(Number.isFinite);
+ const mean = (xs) =>
+ xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : NaN;
+ scenarioCheck(
+ 'a corner is turned on the displayed segment: north first, east once the turn is played',
+ {
+ 'courseEarly.length > 0': courseEarly.length > 0,
+ 'courseLate.length > 0': courseLate.length > 0,
+ 'Math.abs(mean(courseEarly)) < 12': Math.abs(mean(courseEarly)) < 12,
+ 'Math.abs(mean(courseLate) - 90) < 15':
+ Math.abs(mean(courseLate) - 90) < 15,
+ 'corner.worstJump <= SIM.corner.speed * 1.5 + 2':
+ corner.worstJump <= SIM.corner.speed * 1.5 + 2,
+ 'corner.resets === 0': corner.resets === 0,
+ },
+ `early course ${mean(courseEarly).toFixed(1)}°, late course ${mean(courseLate).toFixed(1)}°, worst per-frame ${corner.worstJump.toFixed(1)} m/s`,
+ );
+ const stopWords = (trace.words['mbta:sim-stop'] || []).map((w) => w[1]);
+ const stopRows = trace.out['mbta:sim-stop'] || [];
+ const stopStill = stopRows.filter(
+ (r, i) => i > 0 && metres(stopRows[i - 1], r) < 0.01,
+ ).length;
+ scenarioCheck(
+ 'a genuine stop reads STOPPED while the marker stands still, then moves on',
+ {
+ 'stopWords.some((w) => /STOPPED/.test(w))': stopWords.some((w) =>
+ /STOPPED/.test(w),
+ ),
+ 'stopStill > 600': stopStill > 600,
+ "judge('stop').total > 200": judge('stop').total > 200,
+ "judge('stop').resets === 0": judge('stop').resets === 0,
+ },
+ `words seen: ${[...new Set(stopWords)].join(' | ')}; ${stopStill} still frames; ${judge('stop').total.toFixed(0)} m drawn`,
+ );
+ const gap = judge('gap');
+ scenarioCheck(
+ 'a ninety-second silence holds at the newest fix — no extrapolation, no reset — and resumes',
+ {
+ 'gap.resets === 0': gap.resets === 0,
+ 'gap.worstJump <= SIM.gap.speed * 1.5 + 2':
+ gap.worstJump <= SIM.gap.speed * 1.5 + 2,
+ 'gap.total > 100': gap.total > 100,
+ },
+ `${gap.total.toFixed(0)} m drawn, worst per-frame ${gap.worstJump.toFixed(1)} m/s, ${gap.resets} resets`,
+ );
+ const late = judge('late');
+ scenarioCheck(
+ 'a late packet is refused: no backward motion, no reset',
+ {
+ 'late.backwards === 0': late.backwards === 0,
+ 'late.resets === 0': late.resets === 0,
+ 'late.total > 200': late.total > 200,
+ },
+ `${late.backwards} backward frames, ${late.resets} resets, ${late.total.toFixed(0)} m drawn`,
+ );
+ const dup = judge('dup');
+ scenarioCheck(
+ 'a feed that refreshes every thirty seconds plays at the fix speed, not in bursts',
+ {
+ 'dup.speedWorst <= SIM.dup.speed * 1.06':
+ dup.speedWorst <= SIM.dup.speed * 1.06,
+ 'dup.worstJump <= SIM.dup.speed * 1.5 + 2':
+ dup.worstJump <= SIM.dup.speed * 1.5 + 2,
+ 'dup.total > 200': dup.total > 200,
+ 'dup.resets === 0': dup.resets === 0,
+ },
+ `peak segment speed ${dup.speedWorst.toFixed(2)} m/s vs ${SIM.dup.speed}, worst per-frame ${dup.worstJump.toFixed(1)} m/s, ${dup.total.toFixed(0)} m drawn`,
+ );
+
+ // ---- Card anchor synchronisation ---------------------------------------
+ console.log('\n== card anchor ==');
+ await page.evaluate(() => {
+ const app = window.__godsEyeView,
+ camera = app.viewer.camera;
+ const entry = app.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest()
+ ._vehicles.get('mbta:sim-straight');
+ if (!entry) return;
+ const C = camera.positionCartographic.constructor;
+ const at = C.fromCartesian(entry.marker.position);
+ at.height = 600;
+ camera.setView({
+ destination: C.toCartesian(at),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ });
+ await wait(1500);
+ await reportTransitVisibility(page, 'scripted visibility before click');
+ const target = await page.evaluate(() => {
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ const entry = state._vehicles.get('mbta:sim-straight');
+ if (!entry?.marker?.show) return null;
+ const scene = app.viewer.scene;
+ const canvas = scene.canvas;
+ const view = scene.camera.viewMatrix;
+ const proj = scene.camera.frustum.projectionMatrix;
+ const mul = (m, v) => [
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
+ m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
+ ];
+ const p = entry.marker.position;
+ const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
+ const x = ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth;
+ const y = (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * canvas.clientHeight;
+ if (
+ !(clip[3] > 0) ||
+ x < 0 ||
+ y < 0 ||
+ x >= canvas.clientWidth ||
+ y >= canvas.clientHeight
+ )
+ return null;
+ const picked = scene.pick({ x, y });
+ return {
+ entityCount: app.viewer.entities.values.length,
+ key: entry.key,
+ x,
+ y,
+ pickKey: picked?.id ?? picked?.primitive?.id ?? null,
+ };
+ });
+ let anchor = null;
+ if (target) {
+ check(
+ 'the scripted sprite owns its screen-space pick',
+ target.pickKey === target.key,
+ JSON.stringify(target),
+ );
+ await page.mouse.click(target.x, target.y);
+ await wait(600);
+ anchor = await page.evaluate(
+ async (key, durationMs) => {
+ const overlay = await import('/src/overlays/worldOverlay.js');
+ const app = window.__godsEyeView;
+ const layer = app.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ if (state._selectedKey !== key)
+ return {
+ selected: false,
+ selection: {
+ expected: key,
+ actual: state._selectedKey,
+ handler: !!state._clickHandler,
+ lastPick: state._lastPickForTest ?? null,
+ pointerOwner: (
+ await import('/src/data/inputOwnership.js')
+ ).pointerOwner(),
+ },
+ };
+ const scene = app.viewer.scene;
+ const canvas = scene.canvas;
+ const mul = (m, v) => [
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
+ m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
+ ];
+ const frames = [];
+ const publicationsBefore = state._cardPublications || 0;
+ let headSamples = 0,
+ headErrorPx = 0,
+ bodyMutation = 0,
+ previousTrail = null;
+ const entityCount = app.viewer.entities.values.length;
+ return new Promise((resolve) => {
+ const t0 = performance.now();
+ const tick = () => {
+ const entry = state._vehicles.get(key);
+ const rect = overlay.getOverlayPaintRect('transit-selected', key);
+ if (entry?.marker) {
+ const view = scene.camera.viewMatrix;
+ const proj = scene.camera.frustum.projectionMatrix;
+ const p = entry.marker.position;
+ const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
+ frames.push({
+ t: performance.now() - t0,
+ mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
+ my:
+ (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) *
+ canvas.clientHeight,
+ markerVisible: entry.marker.show !== false,
+ worldFinite: [p.x, p.y, p.z].every(Number.isFinite),
+ cardPresent: !!rect,
+ surfaceReady: entry.surfaceReady,
+ ax: rect?.anchorX ?? null,
+ ay: rect?.anchorY ?? null,
+ poll: entry.pollSeq,
+ moving: state._moving.has(entry) ? 1 : 0,
+ });
+ }
+ const trail = layer._transitPartsForTest().trails.diagnostics();
+ if (trail && entry?.marker) {
+ if (
+ previousTrail &&
+ previousTrail.revision === trail.revision &&
+ previousTrail.body !== trail.body
+ )
+ bodyMutation++;
+ previousTrail = trail;
+ if (trail.head.show && trail.head.positions.length) {
+ const project = (p) => {
+ const clip = mul(
+ scene.camera.frustum.projectionMatrix,
+ mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
+ );
+ return [
+ ((clip[0] / clip[3]) * canvas.clientWidth) / 2,
+ ((clip[1] / clip[3]) * canvas.clientHeight) / 2,
+ ];
+ };
+ const h = project(trail.head.positions.at(-1)),
+ m = project(entry.marker.position);
+ headErrorPx = Math.max(
+ headErrorPx,
+ Math.hypot(h[0] - m[0], h[1] - m[1]),
+ );
+ headSamples++;
+ }
+ }
+ if (performance.now() - t0 >= durationMs) finish();
+ };
+ let remove;
+ const finish = () => {
+ clearTimeout(timer);
+ remove?.();
+ resolve({
+ selected: true,
+ frames,
+ textPublishes:
+ (state._cardPublications || 0) - publicationsBefore,
+ headSamples,
+ headErrorPx,
+ bodyMutation,
+ entityCount,
+ });
+ };
+ const timer = setTimeout(finish, durationMs + 1000);
+ remove = scene.postRender.addEventListener(tick);
+ });
+ },
+ target.key,
+ 20_000,
+ );
+ }
+ check(
+ 'selected trail head stays within 1 CSS px of the marker, without entity additions or body rebuilds between revisions',
+ reduceTrailHead(anchor, target).pass,
+ JSON.stringify(reduceTrailHead(anchor, target)),
+ );
+ if (anchor?.selected) {
+ await writeFile(
+ `${SHOTS}/${TAG}-anchor-frames.json`,
+ JSON.stringify(anchor.frames),
+ );
+ const evidence = reduceAnchor(anchor.frames);
+ check(
+ 'actual painted host anchor stays within 1 CSS px of the marker',
+ evidence.pass,
+ JSON.stringify(evidence),
+ );
+ check(
+ 'card text publication stays throttled while the anchor runs at frame rate',
+ anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
+ `${anchor.textPublishes} text publications in 20 s`,
+ );
+ } else {
+ check(
+ 'the card anchor moves in lockstep with the sprite on every frame, across polls and a settle',
+ false,
+ target
+ ? 'the click did not select the scripted vehicle'
+ : 'no scripted vehicle on screen',
+ { unexercised: true },
+ );
+ }
+ // Removal: the scripted vehicle leaves the feed; after two missed polls its
+ // card must go with it.
+ intercepting = false;
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._setTransitFixtureFloorsForTest([], null),
+ );
+ page.off('request', onRequest);
+ await page.setRequestInterception(false);
+ await page
+ .waitForFunction(
+ () => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ return !state._vehicles.has('mbta:sim-straight');
+ },
+ { timeout: 60_000 },
+ )
+ .catch(() => {});
+ const afterRemoval = await page.evaluate(() => {
+ const state = window.__godsEyeView.dataManager.layers
+ .get('transit')
+ .module._transitStateForTest();
+ return {
+ gone: !state._vehicles.has('mbta:sim-straight'),
+ selected: state._selectedKey,
+ };
+ });
+ check(
+ 'a removed vehicle takes its card with it',
+ afterRemoval.gone && afterRemoval.selected !== 'mbta:sim-straight',
+ JSON.stringify(afterRemoval),
+ );
+ await page.evaluate((c) => {
+ const camera = window.__godsEyeView.viewer.camera;
+ const Cartographic = camera.positionCartographic.constructor;
+ camera.setView({
+ destination: Cartographic.toCartesian(
+ Cartographic.fromDegrees(c.lon, c.lat, 600),
+ ),
+ orientation: {
+ heading: (c.heading * Math.PI) / 180,
+ pitch: (-45 * Math.PI) / 180,
+ roll: 0,
+ },
+ });
+ }, CITIES.at(-1));
+ await wait(4_000);
+ }
+
+ const sceneQA = {
+ page,
+ check,
+ wait,
+ shots: SHOTS,
+ tag: TAG,
+ detectOn,
+ selectStyle,
+ sampleRendered,
+ };
+ if (runs('budgets')) await runFleetBudgets(sceneQA);
+ if (runs('matrix')) await runBostonMatrix(sceneQA);
+ if (runs('boston-live')) await runBostonLive(sceneQA);
+
+ // Turning it off must stop the traffic and leave nothing behind.
+ console.log('\n== disable ==');
+ await page.$eval(row, (element) =>
+ element.scrollIntoView({ block: 'center' }),
+ );
+ await page.click(`${row} .data-toggle-btn`);
+ await page.waitForFunction(
+ () => !window.__godsEyeView.dataManager.isEnabled('transit'),
+ { timeout: 20_000 },
+ );
+ const afterDisable = transitRequests.length;
+ const teardown = await page.evaluate(() => {
+ const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
+ const state = layer._transitStateForTest();
+ return {
+ vehicles: state._vehicles.size,
+ primitives:
+ (state._markers?.length || 0) + (state._animatedMarkers?.length || 0),
+ visible: state._visible.size,
+ moving: state._moving.size,
+ detect: state._detectCache,
+ timers: [
+ state._visibilityTimer,
+ state._maintenanceTimer,
+ state._floorTimer,
+ state._cameraDebounceTimer,
+ ],
+ trailPending: layer._transitPartsForTest().trails.requestDiagnostics()
+ .pending,
+ shown: state._markers ? state._markers.show : null,
+ inFlight: state._inFlight.size,
+ activeFeeds: state._activeFeeds.size,
+ holds: window.__godsEyeView.getRenderGovernorDiagnostics().holds,
+ };
+ });
+ check(
+ 'disable leaves no vehicles and no point primitives',
+ teardown.vehicles === 0 &&
+ teardown.primitives === 0 &&
+ teardown.visible === 0 &&
+ teardown.moving === 0 &&
+ teardown.detect === null &&
+ teardown.timers.every((t) => t === null) &&
+ !teardown.trailPending &&
+ !teardown.holds.includes('transit'),
+ JSON.stringify(teardown),
+ );
+ await shot(
+ `${TAG}-disabled.jpg`,
+ 'Transit off — same view, nothing rendered',
+ );
+ await wait(25_000);
+ check(
+ 'no further transit requests after disable',
+ transitRequests.length === afterDisable,
+ `${transitRequests.length - afterDisable} request(s) in 25 s`,
+ );
+
+ // ---- Context modes -------------------------------------------------------
+ console.log('\n== context modes ==');
+ // The CONTEXT panel starts collapsed; a user expands it before choosing a mode.
+ await page.$eval(
+ '[data-collapse-target="global-context-panel"]',
+ (button) => {
+ if (button.getAttribute('aria-expanded') !== 'true') button.click();
+ },
+ );
+ await wait(1_200);
+ check(
+ 'the CONTEXT panel opens from its own header button',
+ await page.$eval(
+ '[data-collapse-target="global-context-panel"]',
+ (b) => b.getAttribute('aria-expanded') === 'true',
+ ),
+ );
+
+ const isOn = () =>
+ page.evaluate(() => window.__godsEyeView.dataManager.isEnabled('transit'));
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.setEnabled('transit', true, {
+ origin: 'user',
+ }),
+ );
+ await wait(6_000);
+ check('transit is on before entering a Context mode', await isOn());
+
+ // CONTACTS
+ await page.click('#global-context-flights-btn');
+ await wait(18_000);
+ const inContacts = {
+ transit: await isOn(),
+ vehicles: await page.evaluate(
+ () =>
+ window.__godsEyeView.dataManager.layers.get('transit').module.getStats()
+ .count,
+ ),
+ };
+ // Contacts is not a passive overlay: it puts every non-dependency layer away
+ // for the duration, Transit included, and gives it back on exit.
+ check(
+ 'Contacts puts Transit away for the duration',
+ inContacts.transit === false && inContacts.vehicles === 0,
+ JSON.stringify(inContacts),
+ );
+ await page.click('#global-context-flights-btn');
+ await wait(12_000);
+ check('leaving Contacts restores Transit', await isOn());
+
+ // SPACE MISSIONS
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.setEnabled('transit', false, {
+ origin: 'user',
+ }),
+ );
+ await wait(2_000);
+ await page.click('#global-context-missions-btn');
+ await wait(20_000);
+ const blocked = await page.evaluate(async () => {
+ const settled = await window.__godsEyeView.dataManager.setEnabled(
+ 'transit',
+ true,
+ { origin: 'user' },
+ );
+ return {
+ settled,
+ enabled: window.__godsEyeView.dataManager.isEnabled('transit'),
+ };
+ });
+ check(
+ 'Space Missions refuses to enable Transit while it isolates replay data',
+ blocked.enabled === false,
+ JSON.stringify(blocked),
+ );
+ await page.click('#global-context-missions-btn');
+ await wait(12_000);
+ check(
+ 'leaving Space Missions leaves Transit off, as it was on entry',
+ (await isOn()) === false,
+ );
+
+ check(
+ 'no uncaught page errors',
+ pageErrors.length === 0,
+ pageErrors.join(' | '),
+ );
+ console.log(
+ `\ntotal /api/transit requests this run: ${transitRequests.length}`,
+ );
+ check(
+ 'scripted selections never raise a Cesium render error',
+ (await page.evaluate(() => window.__transitRenderErrors || [])).length ===
+ 0,
+ JSON.stringify(
+ await page.evaluate(() => window.__transitRenderErrors || []),
+ ),
+ );
+} catch (error) {
+ check(
+ 'browser harness completed within its bounded waits',
+ false,
+ error.stack || error.message,
+ );
+} finally {
+ await browser.close();
+}
+console.log(
+ failures
+ ? `HARNESS: FAIL (${failures} failed, ${unexercised} unexercised)`
+ : `HARNESS: PASS (${unexercised} unexercised)`,
+);
+process.exit(failures || unexercised ? 1 : 0);
diff --git a/server/providers/common/http.js b/server/providers/common/http.js
index 0e530bb29..654d8e3d7 100644
--- a/server/providers/common/http.js
+++ b/server/providers/common/http.js
@@ -3,6 +3,50 @@ export {
readResponseJsonCapped,
} from '../../../src/sources/httpBody.js';
+/**
+ * Read a fetch() Response body as bytes with the same hard cap as
+ * readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
+ * Throws { code:'RESPONSE_TOO_LARGE' }.
+ */
+export async function readResponseBytesCapped(response, maxBytes) {
+ const tooLarge = () => {
+ const err = new Error('Upstream response too large');
+ err.code = 'RESPONSE_TOO_LARGE';
+ return err;
+ };
+ const declared = Number(response.headers.get('content-length'));
+ if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge();
+ const reader = response.body?.getReader?.();
+ if (!reader) {
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ if (bytes.byteLength > maxBytes) throw tooLarge();
+ return bytes;
+ }
+ const chunks = [];
+ let total = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > maxBytes) {
+ try {
+ await reader.cancel();
+ } catch {
+ /* no-op */
+ }
+ throw tooLarge();
+ }
+ chunks.push(value);
+ }
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return out;
+}
+
/**
* Return the existing promise for a cache key, or create one and remove it
* only when that exact promise settles.
diff --git a/server/providers/local.js b/server/providers/local.js
index eb9059bd5..68f61a3cb 100644
--- a/server/providers/local.js
+++ b/server/providers/local.js
@@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js';
import { defaultSourceRoot } from './common/source-root.js';
import { radioBrowserProxy } from './radio.js';
import { gbfsProxy } from './gbfs.js';
+import { transitProxy } from './transit.js';
import { adsbLolProxy } from './aircraft/adsb-lol.js';
import { aisLiveProxy } from './vessels/ais-live.js';
import { trackBackfillProxies } from './aircraft/tracks.js';
@@ -38,6 +39,7 @@ function localProviderPlugins() {
cctvProxy({ sourceRoot: defaultSourceRoot }),
radioBrowserProxy(),
gbfsProxy(),
+ transitProxy(),
adsbLolProxy(),
aisLiveProxy(),
trackBackfillProxies(),
diff --git a/server/providers/transit.js b/server/providers/transit.js
new file mode 100644
index 000000000..14c2de52d
--- /dev/null
+++ b/server/providers/transit.js
@@ -0,0 +1,427 @@
+import { createTransitHistory } from './transitHistory.js';
+/**
+ * GTFS-Realtime transit proxy for the Transit data layer.
+ *
+ * Only URLs registered in `src/data/transitFeeds.js` are ever fetched — the
+ * browser names a registered id, never a URL. Pure request/cache mechanics
+ * live in `src/data/transitProxy.js` so the offline suite covers them.
+ */
+
+import {
+ coalesceProxyRequest,
+ readResponseBytesCapped,
+} from './common/http.js';
+import { makeRateLimiter } from './common/rate-limit.js';
+import { publicTransitCatalog } from '../../src/data/transitFeeds.js';
+import {
+ TRANSIT_ADMISSION_MAX_GLOBAL,
+ TRANSIT_ADMISSION_MAX_PER_FEED,
+ TRANSIT_ADMISSION_WINDOW_MS,
+ TRANSIT_MAX_REDIRECTS,
+ TRANSIT_PROXY_MAX_BODY_BYTES,
+ TRANSIT_PROXY_TIMEOUT_MS,
+ TRANSIT_PROXY_TTL_MS,
+ TransitFeedShapeError,
+ buildTransitSnapshot,
+ isAcceptableTransitUpstreamUrl,
+ isTransitRedirectStatus,
+ nextTransitBackoffMs,
+ resolveTransitRoute,
+ transitCacheState,
+ transitRedirectDecision,
+ transitResponseHeaders,
+ transitUpstreamHeaders,
+} from '../../src/data/transitProxy.js';
+
+/**
+ * Fetch a registered feed, resolving each redirect hop BEFORE following it.
+ *
+ * `redirect: 'follow'` hands the hop to the runtime and only lets us inspect
+ * where it landed — by which point a disallowed host has already been
+ * contacted and has already answered. Each hop is therefore requested with
+ * `redirect: 'manual'` and its `Location` checked against the feed's own https
+ * origin first, the rule the CCTV frame proxy applies to camera images.
+ *
+ * @param {object} feed Registry entry.
+ * @param {AbortSignal} signal Abort signal for the whole hop chain.
+ * @param {typeof fetch} [fetchImpl] Injected for tests.
+ * @param {{etag?: string|null, lastModified?: string|null}} [validators] Cached validators.
+ * @returns {Promise<{response: Response, finalUrl: string}>}
+ */
+export async function fetchTransitFeed(
+ feed,
+ signal,
+ fetchImpl = fetch,
+ validators = null,
+) {
+ let current = feed.url;
+ for (let hop = 0; hop <= TRANSIT_MAX_REDIRECTS; hop += 1) {
+ const response = await fetchImpl(current, {
+ signal,
+ headers: transitUpstreamHeaders(feed, validators),
+ redirect: 'manual',
+ });
+ // Only a real redirect is followed. A 304 is the successful answer to our
+ // own conditional request and has no Location; sending it round this loop
+ // fails a feed that is simply unchanged.
+ if (!isTransitRedirectStatus(response?.status)) {
+ return { response, finalUrl: current };
+ }
+ const decision = transitRedirectDecision(
+ feed.url,
+ current,
+ response.headers.get('location'),
+ );
+ try {
+ await response.body?.cancel();
+ } catch {
+ /* no-op */
+ }
+ if (!decision.ok) throw new Error(`upstream ${decision.reason}`);
+ if (hop === TRANSIT_MAX_REDIRECTS) {
+ throw new Error('upstream redirected too many times');
+ }
+ current = decision.url;
+ }
+ throw new Error('upstream redirected too many times');
+}
+
+/**
+ * Vite plugin: GTFS-Realtime VehiclePositions proxy for the Transit layer.
+ *
+ * GET /api/transit/feeds → public catalog (coverage + credit)
+ * GET /api/transit/vehicles/ → decoded snapshot as JSON
+ *
+ * Only URLs in `src/data/transitFeeds.js` are ever fetched — the browser
+ * names a registered id, never a URL (SECURITY.md). Redirects are resolved hop
+ * by hop and must stay on the feed's own https origin. Bytes are capped at
+ * TRANSIT_PROXY_MAX_BODY_BYTES and decoded server-side, so the browser never
+ * parses protobuf. Per feed: 15 s memory cache, single-flight refresh, and
+ * serve-stale-on-failure for up to 10 minutes (the launch-library pattern).
+ * No disk cache — transit positions are worthless after a few minutes.
+ *
+ * Two backstops sit in front of every upstream request. A fixed-window
+ * admission limiter bounds how often this process may ask any one operator,
+ * and all of them together, no matter how many browser tabs are open. A
+ * per-feed cooldown ladder then keeps a feed that is genuinely down from being
+ * re-asked once per poll for a whole session: consecutive failures push the
+ * next permitted attempt out to five minutes, and one success resets it.
+ *
+ * @param {{fetchImpl?: typeof fetch}} [options]
+ * @returns {object} Vite plugin.
+ */
+export function transitProxy({ fetchImpl = fetch } = {}) {
+ /** @type {Map} feedId → snapshot */
+ const cache = new Map();
+ const history = createTransitHistory();
+ const admitHistory = makeRateLimiter({
+ windowMs: 60000,
+ max: 30,
+ globalMax: 120,
+ });
+ let closed = false;
+ const inFlight = new Map();
+ const controllers = new Set();
+ /** @type {Map} */
+ const cooldown = new Map();
+ const admit = makeRateLimiter({
+ windowMs: TRANSIT_ADMISSION_WINDOW_MS,
+ max: TRANSIT_ADMISSION_MAX_PER_FEED,
+ globalMax: TRANSIT_ADMISSION_MAX_GLOBAL,
+ });
+ const ttlSeconds = Math.ceil(TRANSIT_PROXY_TTL_MS / 1000);
+
+ function send(res, status, body, headers) {
+ res.writeHead(status, headers);
+ res.end(body);
+ }
+
+ function noteFailure(feedId, reason, now) {
+ const previous = cooldown.get(feedId);
+ const failures = (previous?.failures || 0) + 1;
+ const wait = nextTransitBackoffMs(failures);
+ cooldown.set(feedId, { failures, nextAttemptAt: now + wait, reason });
+ return wait;
+ }
+
+ async function refresh(feed, now) {
+ const controller = new AbortController();
+ controllers.add(controller);
+ const timeout = setTimeout(
+ () => controller.abort(),
+ TRANSIT_PROXY_TIMEOUT_MS,
+ );
+ const previous = cache.get(feed.id);
+ try {
+ const { response, finalUrl } = await fetchTransitFeed(
+ feed,
+ controller.signal,
+ fetchImpl,
+ previous
+ ? { etag: previous.etag, lastModified: previous.lastModified }
+ : null,
+ );
+ if (closed) throw new Error('Transit provider closed');
+ if (!isAcceptableTransitUpstreamUrl(finalUrl)) {
+ throw new Error('upstream is not https');
+ }
+ // Nothing changed upstream: keep the snapshot we have and restart its
+ // freshness window rather than asking for a body we already hold.
+ if (response.status === 304 && previous) {
+ try {
+ await response.body?.cancel();
+ } catch {
+ /* no-op */
+ }
+ // `at` restarts the freshness window; `contactedAt` records that the
+ // operator answered just now. They are different facts: the body still
+ // carries the fetch time of the payload it holds, and reading that as
+ // "when did we last hear from them" would age a feed that is answering
+ // every single request into silence.
+ const revalidated = { ...previous, at: now, contactedAt: now };
+ cache.set(feed.id, revalidated);
+ cooldown.delete(feed.id);
+ return revalidated;
+ }
+ if (!response.ok) {
+ const error = new Error(`upstream HTTP ${response.status}`);
+ error.upstreamStatus = response.status;
+ throw error;
+ }
+ const bytes = await readResponseBytesCapped(
+ response,
+ TRANSIT_PROXY_MAX_BODY_BYTES,
+ );
+ const snapshot = buildTransitSnapshot(feed, bytes, now);
+ if (closed) throw new Error('Transit provider closed');
+ history.ingest(feed, snapshot.vehicles, Date.now());
+ const entry = {
+ at: snapshot.fetchedAt,
+ contactedAt: snapshot.fetchedAt,
+ body: JSON.stringify(snapshot),
+ host: new URL(finalUrl).hostname,
+ etag: response.headers.get('etag'),
+ lastModified: response.headers.get('last-modified'),
+ };
+ cache.set(feed.id, entry);
+ cooldown.delete(feed.id);
+ return entry;
+ } finally {
+ clearTimeout(timeout);
+ controller.abort();
+ controllers.delete(controller);
+ }
+ }
+
+ function install(middlewares) {
+ middlewares.use('/api/transit', async (req, res) => {
+ if (req.method !== 'GET') {
+ send(
+ res,
+ 405,
+ JSON.stringify({ error: 'Method Not Allowed' }),
+ transitResponseHeaders('NONE'),
+ );
+ return;
+ }
+ const route = resolveTransitRoute(req.url);
+ if (!route) {
+ send(res, 404, JSON.stringify({ error: 'Unknown transit feed' }), {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ });
+ return;
+ }
+ if (route.route === 'feeds') {
+ send(res, 200, JSON.stringify({ feeds: publicTransitCatalog() }), {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600',
+ });
+ return;
+ }
+ const { feed } = route;
+ const now = Date.now();
+ if (route.route === 'trail') {
+ if (!admitHistory(feed.id)) {
+ send(
+ res,
+ 429,
+ JSON.stringify({ error: 'Transit history read limit reached' }),
+ {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'Retry-After': '60',
+ },
+ );
+ return;
+ }
+ send(
+ res,
+ 200,
+ JSON.stringify(history.get(feed.id, route.vehicleId, now)),
+ { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
+ );
+ return;
+ }
+ const cached = cache.get(feed.id);
+ const state = transitCacheState(cached, now);
+ if (state === 'fresh') {
+ send(
+ res,
+ 200,
+ cached.body,
+ transitResponseHeaders('HIT', cached.host, cached.contactedAt),
+ );
+ return;
+ }
+
+ // Answer without contacting the operator: the cached snapshot when there
+ // is one, otherwise an honest "unavailable, come back in N seconds".
+ const answerWithoutUpstream = (reason, retryAfterS) => {
+ if (state === 'stale' && cached) {
+ // The contact time stays as old as it is: a feed being served from
+ // cache during an outage has NOT answered, and saying otherwise
+ // would keep a dead feed on the globe.
+ send(
+ res,
+ 200,
+ cached.body,
+ transitResponseHeaders(
+ 'STALE-ERROR',
+ cached.host,
+ cached.contactedAt,
+ ),
+ );
+ return;
+ }
+ send(
+ res,
+ 503,
+ JSON.stringify({
+ error: 'Transit feed unavailable',
+ feedId: feed.id,
+ retryInSec: retryAfterS,
+ }),
+ {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'X-GEV-Cache': 'NONE',
+ 'Retry-After': String(retryAfterS),
+ 'X-Transit-Backoff': reason,
+ },
+ );
+ };
+
+ const waiting = cooldown.get(feed.id);
+ if (waiting && waiting.nextAttemptAt > now && !inFlight.has(feed.id)) {
+ answerWithoutUpstream(
+ 'cooldown',
+ Math.max(1, Math.ceil((waiting.nextAttemptAt - now) / 1000)),
+ );
+ return;
+ }
+ if (!inFlight.has(feed.id) && !admit(feed.id)) {
+ answerWithoutUpstream('rate-limited', ttlSeconds);
+ return;
+ }
+
+ const request = coalesceProxyRequest(inFlight, feed.id, () =>
+ refresh(feed, now),
+ );
+ try {
+ const fresh = await request.promise;
+ send(
+ res,
+ 200,
+ fresh.body,
+ transitResponseHeaders(
+ request.shared ? 'INFLIGHT' : 'MISS',
+ fresh.host,
+ fresh.contactedAt,
+ ),
+ );
+ } catch (error) {
+ const differential = error instanceof TransitFeedShapeError;
+ // A shape fault condemns what we already hold. The cached copy was
+ // decoded under the assumption this feed is a full snapshot, and that
+ // assumption has just been shown false — leaving it in place would let
+ // the very next request serve it back as a 200.
+ if (differential) cache.delete(feed.id);
+ if (!request.shared) {
+ const wait = noteFailure(
+ feed.id,
+ error?.message || String(error),
+ Date.now(),
+ );
+ console.warn(
+ `[transit-proxy] ${feed.id} unavailable: ${error?.message || error} — next attempt in ${Math.round(wait / 1000)}s`,
+ );
+ }
+ // A differential feed is a shape fault, not an outage: serving the old
+ // snapshot would hide a feed this decoder must not render at all.
+ if (state === 'stale' && cached && !differential) {
+ // The contact time is the last SUCCESSFUL one. This request failed,
+ // so it does not advance — otherwise a dead feed would look like one
+ // answering on time.
+ send(
+ res,
+ 200,
+ cached.body,
+ transitResponseHeaders(
+ 'STALE-ERROR',
+ cached.host,
+ cached.contactedAt,
+ ),
+ );
+ return;
+ }
+ const pending = cooldown.get(feed.id);
+ const retryAfterS = pending
+ ? Math.max(1, Math.ceil((pending.nextAttemptAt - Date.now()) / 1000))
+ : ttlSeconds;
+ send(
+ res,
+ differential ||
+ error?.code === 'RESPONSE_TOO_LARGE' ||
+ Number.isInteger(error?.upstreamStatus)
+ ? 502
+ : 504,
+ JSON.stringify({
+ error: differential
+ ? 'Transit feed is differential and is not supported'
+ : 'Transit feed unavailable',
+ feedId: feed.id,
+ retryInSec: retryAfterS,
+ }),
+ {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'X-GEV-Cache': 'NONE',
+ 'Retry-After': String(retryAfterS),
+ },
+ );
+ }
+ });
+ }
+
+ function close() {
+ closed = true;
+ for (const controller of controllers) controller.abort();
+ controllers.clear();
+ history.clear();
+ cache.clear();
+ cooldown.clear();
+ }
+ return {
+ closeBundle: close,
+ name: 'transit-proxy',
+ configureServer(server) {
+ install(server.middlewares);
+ server.httpServer?.once('close', close);
+ },
+ configurePreviewServer(server) {
+ install(server.middlewares);
+ server.httpServer?.once('close', close);
+ },
+ };
+}
diff --git a/server/providers/transitHistory.js b/server/providers/transitHistory.js
new file mode 100644
index 000000000..1cdb387e1
--- /dev/null
+++ b/server/providers/transitHistory.js
@@ -0,0 +1,493 @@
+import { validTransitIdentifier } from '../../src/data/transitProxy.js';
+export { validTransitIdentifier } from '../../src/data/transitProxy.js';
+/** Bounded, process-local operator position history. No upstream or scene access. */
+export const TRANSIT_HISTORY_LIMITS = Object.freeze({
+ retentionMs: 900000,
+ fixes: 128,
+ feedBytes: 16 * 1024 * 1024,
+ processBytes: 64 * 1024 * 1024,
+ keys: 15000,
+ identifierBytes: 256,
+ responseBytes: 32768,
+});
+const FIX_BYTES = 32,
+ CHUNK = 8,
+ EPOCH_SLOTS = 8,
+ EPOCH_BYTES = 544;
+const META_BYTES = 64;
+const encoder = new TextEncoder(),
+ decoder = new TextDecoder();
+// All stores in this process share the hard process allocation ceiling.
+const processStorage = { bytes: 0, keys: 0, stores: new Set() };
+
+function contextOf(record, mode) {
+ const trip = typeof record.tripId === 'string' ? record.tripId : '';
+ const route = typeof record.routeId === 'string' ? record.routeId : '';
+ if (encoder.encode(trip).length > 256 || encoder.encode(route).length > 256)
+ return null;
+ return { trip, route, mode: mode || 'unknown' };
+}
+function contextAt(entry, slot) {
+ const offset = 0,
+ view = entry.contexts[slot];
+ const bytes = new Uint8Array(view.buffer);
+ const length = view.getUint16(offset + 4),
+ routeLength = view.getUint16(offset + 6);
+ return {
+ id: view.getUint32(offset),
+ trip: decoder.decode(bytes.subarray(offset + 16, offset + 16 + length)),
+ route: decoder.decode(
+ bytes.subarray(offset + 272, offset + 272 + routeLength),
+ ),
+ mode: decoder.decode(
+ bytes.subarray(offset + 528, offset + 528 + view.getUint8(offset + 8)),
+ ),
+ };
+}
+function storeContext(entry, context, id) {
+ const slot = (id - 1) % EPOCH_SLOTS,
+ offset = 0;
+ const trip = encoder.encode(context.trip),
+ route = encoder.encode(context.route),
+ mode = encoder.encode(context.mode).subarray(0, 16);
+ const view = entry.contexts[slot],
+ bytes = new Uint8Array(view.buffer);
+ view.setUint32(offset, id);
+ view.setUint16(offset + 4, trip.length);
+ view.setUint16(offset + 6, route.length);
+ view.setUint8(offset + 8, mode.length);
+ bytes.set(trip, offset + 16);
+ bytes.set(route, offset + 272);
+ bytes.set(mode, offset + 528);
+}
+function offset(entry, index) {
+ return ((entry.head + index) % entry.capacity) * FIX_BYTES;
+}
+function read(entry, index) {
+ const at = offset(entry, index),
+ v = entry.fixes;
+ return [
+ v.getFloat64(at),
+ v.getFloat64(at + 8),
+ v.getFloat64(at + 16),
+ v.getUint32(at + 24),
+ v.getUint32(at + 28),
+ ];
+}
+function firstTime(entry) {
+ return entry.count ? entry.fixes.getFloat64(offset(entry, 0)) : Infinity;
+}
+function drop(entry) {
+ entry.head = (entry.head + 1) % entry.capacity;
+ entry.count--;
+ entry.truncated = true;
+}
+
+// Indexed eviction queues keep removal and receipt-order updates logarithmic.
+function evictionQueue() {
+ const items = [],
+ positions = new Map();
+ const less = (a, b) =>
+ a.observedAt < b.observedAt ||
+ (a.observedAt === b.observedAt && a.key < b.key);
+ function swap(a, b) {
+ [items[a], items[b]] = [items[b], items[a]];
+ positions.set(items[a], a);
+ positions.set(items[b], b);
+ }
+ function repair(index) {
+ while (index > 0) {
+ const parent = (index - 1) >>> 1;
+ if (!less(items[index], items[parent])) break;
+ swap(index, parent);
+ index = parent;
+ }
+ for (;;) {
+ let child = index * 2 + 1;
+ if (child >= items.length) break;
+ if (child + 1 < items.length && less(items[child + 1], items[child]))
+ child++;
+ if (!less(items[child], items[index])) break;
+ swap(index, child);
+ index = child;
+ }
+ }
+ function remove(entry) {
+ const index = positions.get(entry);
+ if (index === undefined) return;
+ positions.delete(entry);
+ const last = items.pop();
+ if (index < items.length) {
+ items[index] = last;
+ positions.set(last, index);
+ repair(index);
+ }
+ }
+ return {
+ remove,
+ add(entry) {
+ remove(entry);
+ positions.set(entry, items.length);
+ items.push(entry);
+ repair(items.length - 1);
+ },
+ oldest(except) {
+ if (items[0] !== except) return items[0] || null;
+ if (!items[1]) return null;
+ return items[2] && less(items[2], items[1]) ? items[2] : items[1];
+ },
+ get size() {
+ return items.length;
+ },
+ };
+}
+
+export function createTransitHistory({
+ limits = {},
+ storage = processStorage,
+ sweep = true,
+} = {}) {
+ const policy = { ...TRANSIT_HISTORY_LIMITS };
+ for (const [key, limit] of Object.entries(limits))
+ if (key in policy && Number.isFinite(limit) && limit > 0)
+ policy[key] = Math.min(policy[key], Math.floor(limit));
+ const entries = new Map(),
+ feedBytes = new Map(),
+ eviction = evictionQueue(),
+ feedEviction = new Map();
+ storage.feedBytes ||= new Map();
+ storage.lossUntil ||= new Map();
+ let protectedEntries = null;
+ function queue(entry) {
+ if (!feedEviction.has(entry.feed))
+ feedEviction.set(entry.feed, evictionQueue());
+ eviction.add(entry);
+ feedEviction.get(entry.feed).add(entry);
+ }
+ function unqueue(entry) {
+ eviction.remove(entry);
+ const feedQueue = feedEviction.get(entry.feed);
+ feedQueue?.remove(entry);
+ if (feedQueue?.size === 0) feedEviction.delete(entry.feed);
+ }
+ function recordLoss(feed, until) {
+ storage.lossUntil.set(
+ feed,
+ Math.max(storage.lossUntil.get(feed) || 0, until),
+ );
+ }
+ let prunedAt = -Infinity;
+ let allocatedBytes = 0,
+ timer = null,
+ closed = false;
+ function account(feed, delta) {
+ allocatedBytes += delta;
+ storage.bytes += delta;
+ storage.feedBytes.set(feed, (storage.feedBytes.get(feed) || 0) + delta);
+ if (storage.feedBytes.get(feed) === 0) storage.feedBytes.delete(feed);
+ feedBytes.set(feed, (feedBytes.get(feed) || 0) + delta);
+ if (feedBytes.get(feed) === 0) feedBytes.delete(feed);
+ }
+ function remove(entry) {
+ unqueue(entry);
+ entries.delete(entry.key);
+ storage.keys--;
+ account(entry.feed, -entry.bytes);
+ }
+ function canAllocate(feed, bytes) {
+ return (
+ storage.bytes + bytes <= policy.processBytes &&
+ (storage.feedBytes.get(feed) || 0) + bytes <= policy.feedBytes
+ );
+ }
+ function resize(entry, capacity) {
+ const next = new Uint8Array(capacity * FIX_BYTES),
+ before = new Uint8Array(entry.fixes.buffer);
+ for (let i = 0; i < entry.count; i++)
+ next.set(
+ before.subarray(offset(entry, i), offset(entry, i) + FIX_BYTES),
+ i * FIX_BYTES,
+ );
+ const delta = next.byteLength - entry.fixes.byteLength;
+ entry.fixes = new DataView(next.buffer);
+ entry.head = 0;
+ entry.capacity = capacity;
+ entry.bytes += delta;
+ account(entry.feed, delta);
+ }
+ function prune(now) {
+ if (now === prunedAt) return;
+ prunedAt = now;
+ for (const [feed, until] of storage.lossUntil)
+ if (until <= now) storage.lossUntil.delete(feed);
+ for (const entry of entries.values()) {
+ while (entry.count && firstTime(entry) < now - policy.retentionMs)
+ drop(entry);
+ if (!entry.count || now - entry.observedAt >= policy.retentionMs)
+ remove(entry);
+ else if (
+ entry.capacity > CHUNK &&
+ entry.count <= entry.capacity / 2 &&
+ canAllocate(
+ entry.feed,
+ Math.max(CHUNK, Math.ceil(entry.count / CHUNK) * CHUNK) * FIX_BYTES,
+ )
+ )
+ resize(entry, Math.max(CHUNK, Math.ceil(entry.count / CHUNK) * CHUNK));
+ }
+ }
+ function oldest(feed = null, except = null) {
+ return (feed ? feedEviction.get(feed) : eviction)?.oldest(except) || null;
+ }
+ function evictOne(feed = null, except = null) {
+ const entry = oldest(feed, except);
+ if (!entry) return false;
+ recordLoss(entry.feed, entry.observedAt + policy.retentionMs);
+ if (entry.count > CHUNK) {
+ const target = Math.max(CHUNK, entry.capacity - CHUNK);
+ if (!canAllocate(entry.feed, target * FIX_BYTES)) {
+ remove(entry);
+ return true;
+ }
+ while (entry.count > target) drop(entry);
+ resize(entry, target);
+ } else remove(entry);
+ return true;
+ }
+ const api = {
+ prune,
+ evictOne,
+ oldest,
+ get allocatedBytes() {
+ return allocatedBytes;
+ },
+ };
+ storage.stores.add(api);
+ function reserve(feed, bytes, except = null, newKey = false) {
+ while ((storage.feedBytes.get(feed) || 0) + bytes > policy.feedBytes) {
+ let owner = null,
+ candidate = null;
+ for (const store of storage.stores) {
+ const entry = store.oldest(feed, except);
+ if (entry && (!candidate || entry.observedAt < candidate.observedAt)) {
+ owner = store;
+ candidate = entry;
+ }
+ }
+ if (!owner || !owner.evictOne(feed, except)) return false;
+ }
+ while (
+ storage.bytes + bytes > policy.processBytes ||
+ (newKey && storage.keys >= policy.keys)
+ ) {
+ let owner = null,
+ candidate = null;
+ for (const store of storage.stores) {
+ const entry = store.oldest(null, except);
+ if (entry && (!candidate || entry.observedAt < candidate.observedAt)) {
+ owner = store;
+ candidate = entry;
+ }
+ }
+ if (!owner || !owner.evictOne(null, except)) return false;
+ }
+ return true;
+ }
+ function ingest(feed, records, now) {
+ if (
+ closed ||
+ feed.historyRetention !== true ||
+ !validTransitIdentifier(feed.id)
+ )
+ return;
+ prune(now);
+ // Pin all existing snapshot members before admitting any new key. A later
+ // row must not lose its retained path to an earlier row in the same feed.
+ protectedEntries = new Set();
+ for (const record of records) {
+ const entry = entries.get(`${feed.id}\u0000${record.id}`);
+ if (entry) {
+ protectedEntries.add(entry);
+ unqueue(entry);
+ }
+ }
+ try {
+ for (const record of records) {
+ if (
+ !validTransitIdentifier(record.id) ||
+ !Number.isFinite(record.lat) ||
+ Math.abs(record.lat) > 90 ||
+ !Number.isFinite(record.lon) ||
+ Math.abs(record.lon) > 180
+ )
+ continue;
+ const t = record.timestamp * 1000;
+ if (
+ !Number.isFinite(t) ||
+ t < now - policy.retentionMs ||
+ t > now + 10000
+ )
+ continue;
+ const mode =
+ feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown';
+ const context = contextOf(record, mode);
+ if (!context) continue;
+ const key = `${feed.id}\u0000${record.id}`;
+ let entry = entries.get(key);
+ if (entry) {
+ const last = read(entry, entry.count - 1);
+ if (t <= last[0]) continue;
+ } else {
+ const encodedKey = encoder.encode(key),
+ bytes =
+ META_BYTES +
+ EPOCH_BYTES +
+ encodedKey.byteLength +
+ CHUNK * FIX_BYTES;
+ if (!reserve(feed.id, bytes, null, true)) {
+ recordLoss(feed.id, now + policy.retentionMs);
+ continue;
+ }
+ entry = {
+ key,
+ encodedKey,
+ feed: feed.id,
+ vehicle: record.id,
+ metadata: new DataView(new ArrayBuffer(META_BYTES)),
+ contexts: [new DataView(new ArrayBuffer(EPOCH_BYTES))],
+ fixes: new DataView(new ArrayBuffer(CHUNK * FIX_BYTES)),
+ capacity: CHUNK,
+ head: 0,
+ count: 0,
+ epoch: 0,
+ observedAt: now,
+ truncated: (storage.lossUntil.get(feed.id) || 0) > now,
+ bytes,
+ };
+ entries.set(key, entry);
+ protectedEntries.add(entry);
+ storage.keys++;
+ account(feed.id, bytes);
+ }
+ let flags =
+ record.timestampSource === 'vehicle'
+ ? 1
+ : record.timestampSource === 'header'
+ ? 2
+ : 4;
+ const previous = entry.epoch
+ ? contextAt(entry, (entry.epoch - 1) % EPOCH_SLOTS)
+ : null;
+ if (
+ !previous ||
+ previous.trip !== context.trip ||
+ previous.route !== context.route ||
+ previous.mode !== context.mode
+ ) {
+ const slot = entry.epoch % EPOCH_SLOTS;
+ if (!entry.contexts[slot]) {
+ if (!reserve(feed.id, EPOCH_BYTES, entry)) {
+ entry.truncated = true;
+ recordLoss(feed.id, now + policy.retentionMs);
+ continue;
+ }
+ entry.contexts[slot] = new DataView(new ArrayBuffer(EPOCH_BYTES));
+ entry.bytes += EPOCH_BYTES;
+ account(feed.id, EPOCH_BYTES);
+ }
+ entry.epoch++;
+ // Expiring epoch metadata also expires its positions; never label a
+ // retained point with a reused context slot.
+ const expiredEpoch = entry.epoch - EPOCH_SLOTS;
+ while (entry.count && read(entry, 0)[4] <= expiredEpoch) drop(entry);
+ storeContext(entry, context, entry.epoch);
+ flags |= 16;
+ }
+ if (entry.count === entry.capacity) {
+ const next = Math.min(policy.fixes, entry.capacity + CHUNK);
+ if (
+ next > entry.capacity &&
+ reserve(feed.id, next * FIX_BYTES, entry)
+ )
+ resize(entry, next);
+ else drop(entry);
+ }
+ const at = offset(entry, entry.count++),
+ v = entry.fixes;
+ v.setFloat64(at, t);
+ v.setFloat64(at + 8, record.lat);
+ v.setFloat64(at + 16, record.lon);
+ v.setUint32(at + 24, flags);
+ v.setUint32(at + 28, entry.epoch);
+ entry.observedAt = now;
+ }
+ } finally {
+ for (const entry of protectedEntries) queue(entry);
+ protectedEntries = null;
+ }
+ }
+ function get(feedId, vehicleId, now) {
+ prune(now);
+ const entry = entries.get(`${feedId}\u0000${vehicleId}`);
+ const fixes = [],
+ epochs = [];
+ if (entry) {
+ const used = new Set();
+ for (let i = 0; i < entry.count; i++) {
+ const fix = read(entry, i);
+ fixes.push(fix);
+ used.add(fix[4]);
+ }
+ for (const id of used)
+ epochs.push(contextAt(entry, (id - 1) % EPOCH_SLOTS));
+ }
+ const payload = {
+ version: 1,
+ feedId,
+ vehicleId,
+ oldestT: fixes[0]?.[0] ?? null,
+ newestT: fixes.at(-1)?.[0] ?? null,
+ truncated:
+ entry?.truncated ||
+ (!entry && (storage.lossUntil.get(feedId) || 0) > now),
+ epochs,
+ fixes,
+ };
+ // The normal bound is smaller; keep a byte guard for worst-case Unicode.
+ while (
+ encoder.encode(JSON.stringify(payload)).length > policy.responseBytes &&
+ payload.fixes.length
+ ) {
+ payload.fixes.shift();
+ payload.oldestT = payload.fixes[0]?.[0] ?? null;
+ payload.truncated = true;
+ const used = new Set(payload.fixes.map((f) => f[4]));
+ payload.epochs = payload.epochs.filter((e) => used.has(e.id));
+ }
+ return payload;
+ }
+ function clear() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ closed = true;
+ for (const entry of entries.values()) remove(entry);
+ storage.stores.delete(api);
+ }
+ if (sweep) {
+ timer = setInterval(() => prune(Date.now()), 60000);
+ timer.unref?.();
+ }
+ return {
+ ingest,
+ get,
+ prune,
+ clear,
+ diagnostics: () => ({
+ allocatedBytes,
+ processBytes: storage.bytes,
+ keys: entries.size,
+ feedBytes: Object.fromEntries(feedBytes),
+ sweepActive: timer !== null,
+ }),
+ };
+}
diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs
new file mode 100644
index 000000000..6ea9209f1
--- /dev/null
+++ b/server/providers/transitHistory.test.mjs
@@ -0,0 +1,179 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ createTransitHistory,
+ validTransitIdentifier,
+} from './transitHistory.js';
+const feed = { id: 'mbta', historyRetention: true, defaultMode: 'bus' };
+const row = (t, id = 'bus', extra = {}) => ({
+ id,
+ timestamp: t / 1000,
+ lat: 42,
+ lon: -71,
+ tripId: 'trip',
+ routeId: '1',
+ timestampSource: 'vehicle',
+ ...extra,
+});
+const storage = () => ({ bytes: 0, keys: 0, stores: new Set() });
+function history(t, options = {}) {
+ const h = createTransitHistory({
+ sweep: false,
+ storage: storage(),
+ ...options,
+ });
+ t.after(() => h.clear());
+ return h;
+}
+test('TTL ages reports and reads do not extend it; duplicate/conflicting times never append', (t) => {
+ const h = history(t);
+ h.ingest(feed, [row(100000)], 100000);
+ h.ingest(feed, [row(100000), row(100000, 'bus', { lat: 43 })], 500000);
+ assert.equal(h.get('mbta', 'bus', 500000).fixes.length, 1);
+ assert.equal(h.get('mbta', 'bus', 999999).fixes.length, 1);
+ assert.equal(h.get('mbta', 'bus', 1000000).fixes.length, 0);
+ assert.equal(h.diagnostics().allocatedBytes, 0);
+});
+test('128-fix ring and bounded epoch contexts preserve breaks, never scene heights', (t) => {
+ const h = history(t);
+ for (let i = 0; i < 200; i++)
+ h.ingest(
+ feed,
+ [
+ row(i * 1000, 'bus', {
+ tripId: i < 100 ? 'first' : 'second',
+ heightM: 999,
+ }),
+ ],
+ i * 1000,
+ );
+ const result = h.get('mbta', 'bus', 199000);
+ assert.equal(result.fixes.length, 128);
+ assert.equal(result.truncated, true);
+ assert.equal(result.epochs.length, 2);
+ assert.ok(result.fixes.find((f) => f[0] === 100000)[3] & 16);
+ assert.ok(result.fixes.every((f) => f.length === 5));
+ assert.ok(Buffer.byteLength(JSON.stringify(result)) < 32768);
+ for (let i = 200; i < 220; i++)
+ h.ingest(feed, [row(i * 1000, 'bus', { tripId: `trip-${i}` })], i * 1000);
+ const churn = h.get('mbta', 'bus', 219000);
+ assert.ok(churn.epochs.length <= 8);
+ assert.ok(churn.fixes.every((f) => churn.epochs.some((e) => e.id === f[4])));
+});
+test('allocated key, metadata and chunk bytes obey feed/process ceilings across stores', (t) => {
+ const shared = storage(),
+ limits = { feedBytes: 16000, processBytes: 24000, keys: 4 };
+ const a = history(t, { storage: shared, limits }),
+ b = history(t, { storage: shared, limits });
+ for (let i = 0; i < 60; i++) {
+ a.ingest(feed, [row(i * 1000, `a${i}`)], i * 1000);
+ b.ingest(
+ { ...feed, id: i % 2 ? 'mbta' : 'other' },
+ [row(i * 1000, `b${i}`)],
+ i * 1000,
+ );
+ assert.ok(shared.bytes <= limits.processBytes);
+ assert.ok(shared.keys <= limits.keys);
+ assert.ok(
+ [...shared.feedBytes.values()].every(
+ (bytes) => bytes <= limits.feedBytes,
+ ),
+ );
+ assert.equal(
+ a.diagnostics().allocatedBytes + b.diagnostics().allocatedBytes,
+ shared.bytes,
+ );
+ }
+ a.clear();
+ b.clear();
+ assert.equal(shared.bytes, 0);
+ assert.equal(shared.keys, 0);
+});
+test('accelerated two-hour ingestion plateaus and expires idle keys', (t) => {
+ const h = history(t, { limits: { feedBytes: 128000, processBytes: 128000 } });
+ const samples = [];
+ for (let now = 0; now <= 7200000; now += 15000) {
+ h.ingest(
+ feed,
+ Array.from({ length: 12 }, (_, i) => row(now, `v${i}`)),
+ now,
+ );
+ if (now >= 900000) samples.push(h.diagnostics().allocatedBytes);
+ }
+ assert.ok(Math.max(...samples) <= 128000);
+ assert.equal(Math.max(...samples), Math.min(...samples));
+ h.prune(8100001);
+ assert.equal(h.diagnostics().keys, 0);
+});
+test('retention is opt-in and entire decoded identifiers are bounded', (t) => {
+ const h = history(t);
+ h.ingest({ ...feed, historyRetention: false }, [row(1000)], 1000);
+ assert.equal(h.diagnostics().keys, 0);
+ for (const id of [
+ '',
+ '.',
+ '..',
+ 'abc/def',
+ 'abc\\def',
+ 'abc\0def',
+ 'é'.repeat(129),
+ '\ud800',
+ ]) {
+ assert.equal(validTransitIdentifier(id), false);
+ h.ingest(feed, [row(1000, id)], 1000);
+ }
+ assert.equal(h.diagnostics().keys, 0);
+ assert.equal(validTransitIdentifier('🚋 vehicle 1'), true);
+});
+test('unref sweep is owned and clear closes the cache', (t) => {
+ const h = history(t, { sweep: true });
+ assert.equal(h.diagnostics().sweepActive, true);
+ h.clear();
+ h.ingest(feed, [row(1000)], 1000);
+ assert.equal(h.diagnostics().sweepActive, false);
+ assert.equal(h.diagnostics().keys, 0);
+});
+
+test('three identical-order 15000-vehicle snapshots retain paths within a bounded ingestion time', (t) => {
+ const h = history(t);
+ const times = [];
+ for (let poll = 0; poll < 3; poll++) {
+ const now = 100000 + poll * 15000;
+ const rows = Array.from({ length: 15000 }, (_, i) =>
+ row(now, `vehicle-${i}`),
+ );
+ const start = performance.now();
+ h.ingest(feed, rows, now);
+ times.push(performance.now() - start);
+ }
+ assert.equal(h.diagnostics().keys, 15000);
+ for (let i = 0; i < 15000; i++) {
+ const result = h.get('mbta', `vehicle-${i}`, 130000);
+ assert.equal(result.fixes.length, 3);
+ assert.equal(result.truncated, false);
+ }
+ assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024);
+ t.diagnostic(
+ `15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`,
+ );
+ // A ceiling that says "not quadratic", not a benchmark: 15,000 rows in
+ // well under two seconds on a loaded laptop or a slow CI runner. The
+ // per-run figure above is the diagnostic to read.
+ assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`);
+});
+
+test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => {
+ const h = history(t, { limits: { keys: 2 } });
+ h.ingest(feed, [row(1000, 'a'), row(1000, 'b')], 1000);
+ // Newcomers precede the retained members in every snapshot.
+ for (const now of [2000, 3000])
+ h.ingest(feed, [row(now, 'c'), row(now, 'a'), row(now, 'b')], now);
+ assert.equal(h.get('mbta', 'a', 3000).fixes.length, 3);
+ assert.equal(h.get('mbta', 'b', 3000).fixes.length, 3);
+ assert.equal(h.get('mbta', 'c', 3000).truncated, true);
+ h.ingest(feed, [row(4000, 'c')], 4000);
+ h.ingest(feed, [row(5000, 'a')], 5000);
+ const recreated = h.get('mbta', 'a', 5000);
+ assert.equal(recreated.fixes.length, 1);
+ assert.equal(recreated.truncated, true);
+});
diff --git a/src/app/catalog.js b/src/app/catalog.js
index aa75899d3..0d22ec9a1 100644
--- a/src/app/catalog.js
+++ b/src/app/catalog.js
@@ -6,6 +6,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
cctvLayer: 'cctv',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
+ transitLayer: 'transit',
aisLiveVesselsLayer: 'ais-live-vessels',
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs
index 5cee846ba..820943049 100644
--- a/src/app/catalog.test.mjs
+++ b/src/app/catalog.test.mjs
@@ -88,6 +88,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'cctv',
'radio',
'bikeshare',
+ 'transit',
'ais-live-vessels',
'military-awareness',
'military-installations',
@@ -97,7 +98,8 @@ test('controls bind catalog instances rather than similarly named defaults', ()
const catalog = createLayerCatalog(layers, metadata(layers));
const services = catalogControlServices(catalog);
assert.equal(services.flightsLayer, layers[1]);
- assert.equal(services.aisLiveVesselsLayer, layers[7]);
+ assert.equal(services.transitLayer, layers[7]);
+ assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index 18d872209..64a4c9843 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -9,6 +9,7 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
+import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
@@ -111,6 +112,7 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
+ createApplicationTransit({ surface }),
vessels,
installations,
createApplicationAwareness({
diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs
index d25ad45ee..ba434ef61 100644
--- a/src/app/constructCatalog.test.mjs
+++ b/src/app/constructCatalog.test.mjs
@@ -38,7 +38,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
- assert.equal(first.layers.length, 18);
+ assert.equal(first.layers.length, 19);
assert.deepEqual(
first.layers.map(({ id }) => id),
second.layers.map(({ id }) => id),
diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js
new file mode 100644
index 000000000..34724d6f0
--- /dev/null
+++ b/src/app/layers/transit.js
@@ -0,0 +1,48 @@
+import { createTransitLayer } from '../../layers/transit/index.js';
+import * as render from '../../renderGovernor.js';
+import * as sprites from '../../data/spriteOrder.js';
+import * as picking from '../../data/pickRegistry.js';
+import * as overlays from '../../overlays/worldOverlay.js';
+import {
+ registerDynamicCredit,
+ transitFeedCredit,
+} from '../../data/dataCredits.js';
+
+/**
+ * Construct one layer using the application scene owners and the
+ * application's surface services.
+ *
+ * The floor comes from `surface.groundFloor` — the ONE instance aircraft,
+ * trails, fires and camera geometry stand on — and the rendered-mesh sampler
+ * from `surface.meshFloor`, so a mesh floor the application resolves for a
+ * cell is the floor transit draws on, and a transit-only session warms its
+ * own mesh floors. An earlier cut imported the legacy module-level singleton
+ * instead and could not see either.
+ *
+ * @param {{surface: object}} options
+ * @returns {object}
+ */
+export function createApplicationTransit({ surface }) {
+ if (!surface?.groundFloor || !surface?.meshFloor)
+ throw new TypeError('Transit needs the application surface services');
+ const { groundFloor, meshFloor } = surface;
+ return createTransitLayer({
+ services: {
+ render,
+ sprites,
+ picking,
+ overlays,
+ credits: { registerDynamicCredit, transitFeedCredit },
+ ground: {
+ GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
+ cachedGroundFloor: groundFloor.cachedGroundFloor,
+ coarseFloorCoord: groundFloor.coarseFloorCoord,
+ neighborFloorM: groundFloor.neighborFloorM,
+ warmGroundFloor: groundFloor.warmGroundFloor,
+ corridorFloorCells: groundFloor.corridorFloorCells,
+ allocateCorridorCells: groundFloor.allocateCorridorCells,
+ },
+ mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells },
+ },
+ });
+}
diff --git a/src/app/layers/transit.test.mjs b/src/app/layers/transit.test.mjs
new file mode 100644
index 000000000..3f585ded5
--- /dev/null
+++ b/src/app/layers/transit.test.mjs
@@ -0,0 +1,93 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createSurfaceServices } from '../surfaceServices.js';
+import { createApplicationTransit } from './transit.js';
+import { createApplicationCatalog } from '../constructCatalog.js';
+import { createStandaloneLayerSources } from '../../standalone/layerSources.js';
+
+/** A terrain source that answers every point with one ellipsoidal height. */
+function fixtureSurface(signal, demM) {
+ return createSurfaceServices({
+ terrainSource: {
+ getHeights: async (chunk) => chunk.map(() => ({ ellipsoid: demM })),
+ },
+ signal,
+ eventTarget: null,
+ });
+}
+
+test('transit reads the mesh floor the application resolved, through the application surface', async (t) => {
+ // The floor transit draws on is the ONE the application constructs — the
+ // instance aircraft and camera geometry stand on — not a module-level copy.
+ // An earlier cut imported the legacy singleton and could not see a mesh
+ // floor the app had already resolved for a cell; it drew on bare earth.
+ const lifetime = new AbortController();
+ t.after(() => lifetime.abort());
+ const surface = fixtureSurface(lifetime.signal, 20);
+ const layer = createApplicationTransit({ surface });
+ const height = layer._transitPartsForTest().height;
+ const lat = 42.3601;
+ const lon = -71.0589;
+
+ // Nothing warm yet: the layer has no floor and no prior to borrow.
+ assert.equal(height.requestHeight(lat, lon).height, null);
+
+ // The application resolves the DEM for that cell...
+ await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
+ const lift = surface.groundFloor.GROUND_FLOOR_LIFT_M;
+ assert.equal(
+ height.requestHeight(lat, lon).height,
+ 20 + lift,
+ 'the DEM floor, lifted',
+ );
+
+ // ...and then a rendered-mesh sample lands on the same cell, seventeen
+ // metres above bare earth, the way a photogrammetric street sits.
+ assert.equal(
+ surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 37),
+ true,
+ );
+ assert.equal(
+ surface.groundFloor.cachedGroundFloor(lat, lon),
+ 37,
+ 'the app now floors on the mesh',
+ );
+ assert.equal(
+ height.requestHeight(lat, lon).height,
+ 37 + lift,
+ 'and so does transit — the same number',
+ );
+
+ // Leaving the photoreal regime drops back to the DEM for everyone at once.
+ surface.groundFloor.setMeshFloorPreferred(false);
+ assert.equal(height.requestHeight(lat, lon).height, 20 + lift);
+});
+
+test('the catalog hands transit the same surface it hands everything else', async (t) => {
+ const lifetime = new AbortController();
+ t.after(() => lifetime.abort());
+ const surface = fixtureSurface(lifetime.signal, 5);
+ const catalog = createApplicationCatalog({
+ sources: createStandaloneLayerSources(),
+ signal: lifetime.signal,
+ surface,
+ });
+ const transit = catalog.get('transit');
+ assert.ok(transit, 'transit is in the catalog');
+ const lat = 30.2672;
+ const lon = -97.7431;
+ await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
+ surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 22);
+ assert.equal(
+ transit._transitPartsForTest().height.requestHeight(lat, lon).height,
+ 22 + surface.groundFloor.GROUND_FLOOR_LIFT_M,
+ );
+});
+
+test('transit refuses to build without the application surface', () => {
+ assert.throws(() => createApplicationTransit({}), /surface/);
+ assert.throws(
+ () => createApplicationTransit({ surface: { groundFloor: {} } }),
+ /surface/,
+ );
+});
diff --git a/src/data/cameraSensitivity.js b/src/data/cameraSensitivity.js
new file mode 100644
index 000000000..87933dc9e
--- /dev/null
+++ b/src/data/cameraSensitivity.js
@@ -0,0 +1,93 @@
+// src/data/cameraSensitivity.js
+/**
+ * Shared claims on `camera.percentageChanged`.
+ *
+ * `camera.changed` fires once the view has moved by this fraction, and it is a
+ * single number on the camera that every listener in the app shares. Several
+ * layers want it finer than the default — traffic, bikeshare and transit all
+ * ask for 0.05 so they notice they have entered a coverage area — and each of
+ * them used to write the number directly and hand back whatever it remembered.
+ *
+ * That cannot work, because a number is not an owner. Two layers asking for the
+ * same 0.05 write the same value, so when the first one leaves it sees its own
+ * number still in place, concludes nothing else has claimed it, and restores
+ * the coarse default out from under a layer that is still running. The symptom
+ * is a camera-driven layer that quietly stops noticing the camera: Transit is
+ * turned off and Bikeshare stops loading new cities until something else nudges
+ * it.
+ *
+ * So ownership is tracked explicitly. Claims are named, the camera gets the
+ * finest value anyone has asked for, and the original value returns only when
+ * the last claim is gone. Claiming twice under one name replaces that claim.
+ *
+ * This is bookkeeping, not policy: it never decides what a layer should ask
+ * for, only that nobody's answer is taken away while they still need it.
+ */
+
+/** @type {WeakMap