diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..27e776953 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ + +# Mechanical runtime formatting; executable syntax is unchanged. +2aca7aecbb38c8554d40ddbc415fec37b3a65ec4 +b7c2c47b4154c3904337fd86d71caca5d1c602bb diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2a8101a..829aee0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +- Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports. + +- Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution. + +- Cancel the Nepal Upper Valley locator's pending approach and orbit on scene Stop, replacement, seek, and teardown; late camera callbacks cannot take over a newer shot. + +- Keep completed flood history visible when later Nepal media-only shots arrive and reveal their source cards. +- Reconcile an already-playing Nepal video when the YouTube API attaches, so missed playback notifications cannot truncate the seven-second clip or replay an already-ended clip. + +- Clamp the Nepal flood trail and surge marker to the active terrain or photoreal surface so refined 3D tiles cannot bury the path. + +- Preserve Nepal shot camera and map ownership through the public layer lifecycle; passive restoration does not start standalone playback. + +- Separate transit snapshot/history acquisition from the layer and expose its bounded request service independently of Vite. Preserve feed selection, playback, cache policy and compatibility exports. + +- 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. + +- Isolate optional scene-card presentation callbacks from the shared overlay projection path, preserving ordinary-layer allocation budgets and recovering from failed animation callbacks. + +- Rename scene shots inline with a double-click; Enter or focus loss saves, and Escape cancels. +- Stop scene playback and delayed media when its event layer is explicitly closed; reject stale seek and replay completions. Ship only the Nepal incident default, without a separate reconstruction recipe. + +- Keep Nepal flood and locator components available through Scenes without separate entries in Data Layers. + +- Start the shared Nepal flood route at the Debris-Dammed Lake river reach, removing the earlier upstream section from overview paths. + +- Remove the redundant WITNESS panel shortcut; retain per-shot source media and Open Original links. + +- Follow the Mailung Bazzar, Dandaguan video's source clock through 0:07 and advance after its brief fade-out, including older saved scenes. Bound delayed or blocked playback and cancel late provider callbacks on Stop or replacement. + +- Keep the completed flood trail visible during the Debris-Dammed Lake to Second landslide camera handoff, without revealing the next reach early. + +- Preserve loaded base imagery when successive scene shots use the same provider, avoiding a bare-globe flash at shot handoffs. + +- Add the Nepal Flood Incident scene with geographic labels, synchronized event controls, linked witness sources, and Vantor comparison imagery over Esri. Nepal uses Google 3D when available and falls back to Esri with keyless terrain without rewriting saved shots. Bundled event imagery and derived data retain their separate non-commercial terms. + +- 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. + +- 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. + - Three proxy paths no longer relay upstream or JS error text to the client. The HUD summary passed OpenAI's own `error.message` through whenever upstream was not ok, carrying request ids and quota wording; the Realtime token route echoed the JS error, which for a network fault names the upstream host; and a failed CCTV media fetch stored the raw errno as the camera's health message, which reaches the screen through `GET /api/cctv/health` rather than through the sanitized response beside it. Logs now name the failure and the upstream status without the text. - Separate vessel records and feed acquisition from rendering while preserving selection, partial-feed retention, sea-surface placement and request cancellation. @@ -135,6 +193,46 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Bikeshare stations load again. The extracted station source addressed the + proxy as `/api/gbfs?url=`, but the proxy reads its upstream target from the + path, so every request answered 400 and the layer reported a fetch error for + every city (#441 — thanks @MiguelGFerreira). +- Overpass requests now carry a User-Agent that names the application, its + version and the project address, which is what the OpenStreetMap API usage + policy asks for; the previous string identified neither. A mirror may refuse + a client it cannot identify, and a refused mirror is one the fan-out has to + skip, so this affects every Overpass-backed layer: Mapped Installations, + traffic roads and annotation geometry. Mirror rotation, cooldown and cache + admission are unchanged (#420 — thanks @GladiatorrX9). +- Place search has a last resort. With no Google Maps key, and when Photon does + not answer, a named-place search now falls back to OpenStreetMap's Nominatim + through `/api/geocode`, so search and voice fly-to still work on a keyless + globe. The route keeps to the service's usage policy: an identifying + User-Agent and Referer, at most one request per second, answers cached, one + upstream call shared between identical searches in flight, a bounded queue so + a burst is refused rather than held, and a queued search dropped once its + caller has given up (#350 — thanks @sendmebits). + +- Regional upstream reads now hold their deadline through the response body. The + abort timer was cleared as soon as the headers arrived, so an upstream that + answered and then stalled mid-body had no deadline at all. Redirect policy is + now stated per call rather than inherited, and the fixed Nominatim endpoints + refuse to be redirected. + +- The location search box answers two kinds of query without a network request + or an API key. A decimal-degree coordinate — `43.1731, -79.0384`, or either + order when N/S/E/W say which is which — flies straight there; a bundled city + or landmark name typed exactly (`paris`, `sf`, `Golden Gate Bridge`) flies to + the bundled place. Anything else, including anything malformed, goes to the + existing geocoders unchanged. Degrees/minutes/seconds and grid references are + not parsed and fall through the same way (#388 — thanks @KuraPiee). +- A data-layer control a provider key is holding back now names that key. With + no FIRMS key the fire layer's control read KEY REQUIRED without saying which + key or where to put it; it now reads "Needs FIRMS_MAP_KEY — add it in Provider + Settings", on the control and in its accessible name. A layer that needs no + key, or already holds one, carries no such text, and an unrecognised key name + produces none rather than a guess (#296 — thanks @Matthew-Selvam). + - Draped annotation geometry — area fills and outlines, routes and arrows — classifies onto terrain as well as 3D tiles. On a keyless boot, where Cesium's own globe carries the imagery, marks previously rendered their labels and no @@ -153,6 +251,22 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Added +- Two map-orientation controls sit beside Share in the top-center globe + actions. Tilt Map swings between a straight-down map and a 35-degree oblique + around the point under the centre of the view, keeping that point and the + distance to it. North Up rotates around the same point until north is at the + top, keeping the pitch, and its needle shows the current bearing. Both decline + without moving the camera when nothing is under the centre of the view, and + both follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit + (#442 — thanks @yashveeeeeeer). +- The location search box answers two kinds of query without a network request + or an API key. A decimal-degree coordinate — `43.1731, -79.0384`, or either + order when N/S/E/W say which is which — flies straight there; a bundled city + or landmark name typed exactly (`paris`, `sf`, `Golden Gate Bridge`) flies to + the bundled place. Anything else, including anything malformed, goes to the + existing geocoders unchanged. Degrees/minutes/seconds and grid references are + not parsed and fall through the same way (#388 — thanks @KuraPiee). + - Add Open Calgary traffic cameras as a keyless CCTV source pack (thanks @rileygramlich): the public City of Calgary catalog, frames pinned to the city's own host and upgraded to HTTPS, with the Open Government Licence – @@ -161,6 +275,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/CONTRIBUTING.md b/CONTRIBUTING.md index b765948ea..a58227d8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,15 +42,16 @@ it is not a production server. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Each layer is one self-contained module in `src/data/.js` implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. -- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `server/providers/openai/tools.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened). +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **UI lives in `src/ui.js`** (panels, HUD, styles, the control facade). **Layer logic lives in `src/data/.js`.** Keep them separate. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. @@ -63,12 +64,13 @@ The highest-leverage places to jump in: ## Formatting and reusable components -Run `npm run format` before submitting changes to adopted modules, then -`npm run format:check` and `npm run check:boundaries`. Formatting uses the -explicit file list in `scripts/format-scope.json`; add newly extracted modules -and tests there in a separate mechanical commit. Keep unadopted files consistent -with their surrounding style. CI checks all adopted files and package boundaries -on Linux and Windows. +Run `npm run format` before submitting changes, then `npm run format:check` +and `npm run check:boundaries`. Runtime JavaScript under the owned roots in +`scripts/format-runtime.json` is discovered automatically, including new files. +Tests and other adopted files remain listed in `scripts/format-scope.json`. +Git-ignored files and `.prettierignore` exclusions are not automatically adopted. +Keep mechanical formatting separate from behavioral edits. CI checks formatting +and package boundaries on Linux and Windows. Reusable package exports own their state and receive application operations through explicit callbacks. They must not import the standalone bootstrap or diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9d546acfe..850d27e39 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -29,7 +29,7 @@ How to read this: | **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | | **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) | | **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" + "© OpenStreetMap contributors" | -| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" | +| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page, and last-resort forward place search via `/api/geocode` when Google and Photon do not answer | ODbL 1.0 + [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/) (maximum 1 request/second, results cached, identifying User-Agent) | "© OpenStreetMap contributors" | | **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data | | **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain | | **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain | @@ -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,21 +74,25 @@ 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. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts. +- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` shares the cockpit's Nominatim queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries. - **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit. - **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay. --- -## Bundled snapshots (committed under `src/data/local_data/`) +## Bundled snapshots Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README. +The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `public/events/`, contains Vantor imagery crops and a GeoPera-derived river centerline under **CC BY-NC 4.0**, separately from the MIT code. The derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` has the same non-commercial restriction and is compiled into the Nepal scene. Commercial users must obtain separate permission or exclude both the event pack and that source-derived dataset from their source and build. Deleting only `public/events/bhote-koshi-2026/` does not remove all restricted data; excluding the scene also requires removing its registrations and imports before building. Linked witness posts and geolocation-map records retain their owners' terms; no social-media clips or cached posters are bundled. Other snapshots below are under `src/data/local_data/`. + | Dataset | Folder | License | Commercial use? | Attribution | | --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | | **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" | diff --git a/LICENSE b/LICENSE index 8cff5265a..4f01699a2 100644 --- a/LICENSE +++ b/LICENSE @@ -38,6 +38,14 @@ remain subject to their own licenses and terms. Notably: Open Database License (ODbL) 1.0 — attribution + share-alike on the data. - NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation requested). + - Bhote Koshi event imagery and derived river centerline + (public/events/bhote-koshi-2026/ and the coordinate dataset in + src/data/bhoteKoshiFloodPath.js): Vantor / GeoPera, CC BY-NC 4.0. + NOT for commercial use without separate permission. Source-linked witness + posts retain their owners' terms; no witness clips are bundled. See the + folder README for attribution and modification notices. The derived + coordinates remain third-party data even though they are embedded in a + JavaScript source file; the MIT grant does not cover that dataset. Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS, OSM Overpass, City of Austin, GBFS) are fetched at runtime under each 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._ ![Orbital HUD, a tracked live globe, FLIR terrain — then OPEN SOURCED](docs/media/hero-open-source-reveal.gif) @@ -16,7 +16,7 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, The God's Eye View video series on YouTube -▶️ **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) [![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](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 ![Moving from a full airport overhead down to close taxiway inspection with 3D flight models](docs/media/start-here/airport-ground-traffic-google-3d.gif) -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. ![Diving into an Austin intersection with a live public camera projected into the 3D scene](docs/media/03-austin-cctv.gif) @@ -200,8 +200,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea ![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif) -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 ![A live military contact ridden through Normal, NVG, and Ironbow FLIR with dense detection](docs/media/start-here/military-cockpit-dense-google-3d.gif) -*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 ![A spoken distance measurement spanning an airport, inspected from orbit](docs/media/04-airport-distance.gif) **🔎 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?"_ ![The globe populating with the world's radio stations as another live layer](docs/media/15-global-radio-layer.gif) -*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 | ![A reconstructed Falcon 9 ascent climbing and curving into its projected orbit](docs/media/08-falcon9-replay.gif) -*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._ ![Resolving a selected aircraft's recent flight path into stacked 3D loops above the terrain](docs/media/07-helicopter-loops.gif) -*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._ ![Asking for a walking route and flying the generated path through the 3D city](docs/media/10-walking-route-flythrough.gif) -*"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._ ![Descending from regional context into dense rows of retired aircraft at the boneyard](docs/media/08-boneyard.gif) -*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 | ![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif) -*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/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index fb56d4d11..35409e1d4 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -1,58 +1,98 @@ # Formatting and component boundaries -Run `npm run format` to format the files in `scripts/format-scope.json` and +Run `npm run format` to format the files in `scripts/format-scope.json` plus +automatically discovered runtime JavaScript in `scripts/format-runtime.json`, and `npm run format:check` to check that same list without writing. CI checks the -entire adopted list on Linux and Windows. Prettier is pinned in the development +combined scope on Linux and Windows. Prettier is pinned in the development dependencies; use the installed version so local and CI output agree. The shared configuration specifies two spaces, single quotes, semicolons and LF endings. -Add new reusable modules and their tests to the list as they are extracted. +Owned runtime modules are discovered automatically; add their tests and other +non-runtime files to the explicit list as they are adopted. Keep mechanical formatting in its own commit after behavior is stable. Existing source-text regression assertions still apply; investigate failures and preserve their behavioral coverage when a move or line wrap changes a tested shape. -Files outside the list retain their surrounding style until deliberately adopted. +Files outside the runtime roots and explicit list retain their surrounding style +until deliberately adopted. Generated output, local configuration, browser evidence and bundled datasets are excluded. The formatter validates every entry before writing any file. ## Current component ownership -| Surface | Owns | Receives from its caller | -| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | -| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | -| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | -| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements | -| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services | -| `src/main.js` and `src/standalone/` | Standalone browser startup | Local configuration | +Package imports use `gods-eye-view`; `package.json` is the authoritative +export inventory. Use declared exports rather than reaching into internal files. -The application and infrastructure exports are browser source modules. Use their documented -exports instead of importing standalone startup or reaching into internal files. -The application owns the viewer, context store, overlay host and render scheduler; -layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). +| Owner | Responsibility and lifetime | +| ---------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/app/` | Construct supplied components, share scene/request services, cancel startup and dispose the application | +| `src/standalone/` | Select the default catalog, local sources and setup controls | +| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions | +| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades | +| `src/layers//` | Source acquisition, records and Cesium resources with explicit controller/renderer owners | +| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance | +| `src/services/` | Supplied application operations; scene construction owns caches and cancellation | +| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters | +| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup | +| `server/standalone/` | Environment, local settings writes and server composition | -`npm run check:boundaries` builds every declared package export, with app Vite -configuration disabled. `scripts/package-boundaries.json` lists each export's -component, owned modules and external runtime dependencies. A new export must be -classified. Imports outside the declared modules fail, including unused and -literal dynamic imports. Cesium stays external so the consuming application -supplies the same compatible instance as its viewer. Existing consumer tests -also check import-time inactivity and asset URLs under a non-root base. +See [application construction](APPLICATION.md) and the +[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces. -These checks cover the declared exports, not every import in the application. -They check build-time imports, not arbitrary runtime-generated module URLs. -Keep runtime module discovery out of these exports. When extracting another -component, add its ownership and consumer tests together. Node services must use -separate entry points and their own checks when they become reusable; importing -them into a browser component is not supported. +## Import direction gates -`gods-eye-view/application` owns construction order, startup state, cancellation -and disposal of caller-supplied components. Its only owned module is -`src/app/application.js`. `gods-eye-view/application/viewer` separately owns the -standard Cesium viewer configuration in `src/app/viewer.js`; Cesium stays external. -Neither export imports standalone UI, layers, tools or configuration. See -[application construction](APPLICATION.md) for the contracts and current limits. +`npm run check:boundaries` runs two complementary checks: -UI panels and individual source adapters remain future extractions. They should -become smaller modules with explicit lifecycle owners as their callers migrate. +1. `scripts/check-import-directions.mjs` parses every runtime JS/MJS/CJS file in + `src/` and `server/`, including files unused by the current bundle. Static, + literal dynamic and re-export edges are checked; computed module imports and + CommonJS `require` are rejected. Browser graphs cannot reach Node, server or + test modules through helpers. Reusable modules cannot select standalone setup, + and provider modules cannot import application/rendering modules. +2. `scripts/check-package-boundaries.mjs` builds every declared export without + app Vite configuration or environment files. `scripts/package-boundaries.json` + assigns each export exactly once and lists its owned modules and external + dependencies. Unused imports still count. Node exports have only a `node` + condition; browser groups cannot use build-only dependency exceptions. + +Portable source graphs cannot reach application/rendering, Node, Cesium or +browser globals. This includes `sources/*`, dedicated `layers/*/source` exports, +flight/military/vessel record and ingestion exports, action schemas, the session +interface, lifecycle and feed state. The browser-global rule reserves platform +names such as `document` and `window` in these modules; it is an architectural +check, not a JavaScript sandbox. Common voice controls cannot depend on a +Realtime protocol implementation. Negative fixtures cover indirect helpers, +self-package imports, symlinks and unreachable files. + +Source factories have dedicated exports for ALPR, bikeshare, CCTV, earthquakes, +FIRMS, installations, launches, radio, satellites and traffic. They preserve the +same factory implementations without loading layer rendering. ALPR/earthquake +record normalization and CCTV source endpoint policy have plain owners separate +from geometry/cards. Source exports do not start acquisition at import time. + +Other layer `ingestion.js` files may still coordinate Cesium resources; the +portable contract applies to the explicitly reviewed graphs above. Their layer +controller remains the owner of rendering/cleanup until a focused extraction +moves it. Do not label all ingestion modules platform-independent by filename. + +## Compatibility entries and owners + +| Retained entry | Owner and current reason | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` | +| `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | +| `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | +| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | +| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` | +| `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | +| `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | + +The two exact standalone-import exceptions are `src/ui.js` to +`src/standalone/catalog.js` and `server/providers/local.js` to +`server/standalone/key-setup.js`. They do not authorize new compatibility +back-edges. Voice actions use plain `src/data/feedState.js`, not the manager +facade. Settings filesystem hardening belongs in +`server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged. +Test modules and the Node-only allocation benchmark are outside browser runtime. ## Build and standalone server configuration @@ -503,3 +543,102 @@ retention. `layers/vessels/ingestion` owns source requests and feed state throug explicit operations. Both exports exclude Cesium, DOM and application assembly. The snapshot renderer applies record changes; rendering owns a weak map of geometry and billboard resources used by cards, picking and trails. + +`gods-eye-view/sources/reference` constructs fresh earthquake and bundled cable +source instances independently of standalone setup. Individual sources remain +available through `layers/earthquakes/source` and `layers/submarine-cables/source`. +The latter retains the bundled dataset’s attribution and licensing requirements. + +## Geocoding and feature-query providers + +`createDefaultPlaceSearch` accepts an explicit Nominatim selection: + +```js +const places = createDefaultPlaceSearch({ + geocoding: { + provider: 'nominatim', + searchEndpoint: '/places/search', + reverseEndpoint: '/places/reverse', + }, + fetchImpl, + signal, +}); +``` + +The endpoints must return Nominatim JSONv2. They are supplied by application +composition; no instance is selected automatically and this does not install +those routes. For a remote instance, configure its full URLs and transport +requirements. The caller owns request scheduling, identifying headers and any +instance-specific usage policy. The existing public fallback retains its shared +queue, pacing and cache. `endpoints.nominatim` still names the legacy +Google-shaped `/api/geocode` fallback; it is not a raw Nominatim endpoint. + +Coordinates and supplied presets remain offline. Explicit Nominatim selection +replaces the network forward-geocoder chain; it does not silently fall back to +another instance. Reverse geocoding is available only when a reverse endpoint +is configured. Other operations (Places text/nearby search and routing) retain +their independent providers. For custom compositions, `createNominatimProvider` +from `./search/nominatim` can be passed to `createPlaceSearch` and +`createGeospatialServices`; it exposes no route or nearby-search capability. +An empty search array is a definitive miss. HTTP failures, malformed results +and oversized responses remain retryable. Attribution identifies OpenStreetMap / +Nominatim. + +Overpass currently supplies traffic road geometry, ALPR camera records, military +installation footprints, and annotation geometry (administrative boundaries, +neighborhoods, streets, building/grounds outlines and monument candidates). +Nominatim forward/reverse lookup does not replace those queries. Layer source +interfaces select traffic, camera and installation ingestion separately; +annotation and camera-framing consumers use the feature operations documented +below. The Overpass adapter translates those operations and normalizes records; +an alternate backend implements that source interface. Rendering and geometry +selection remain consumers of those results. + +`./sources/nominatim` exports the lower-level JSONv2 client and normalizers for +server adapters without importing search composition. + +`./sources/http-body` exports bounded text/JSON readers using web primitives; +`./sources/overpass` exports the quoted-string/comment lexer. The lexer alone is +not a query validator: spatial bounds, timeouts and other policy remain in the +server sanitizer. Existing server imports keep their compatibility exports. + +Runtime formatting discovers tracked and new non-ignored `.js`, `.mjs` and `.cjs` +files under the configured owned roots. Tests retain explicit adoption. Git and +Prettier exclusions keep local/generated data out; resolved paths are validated +before any writes. New runtime modules do not need another scope-list entry. + +## Map-feature sources + +Application request services expose `features`. Callers may supply another source +with the same operations; the default is `createOverpassFeatureSource` from +`sources/map-features`. Legacy boundary-query configuration remains supported. +Search and annotation code request candidates and rank them without constructing +query-language strings or decoding backend tags and relation members. + +| Operation | Result used by the caller | +| --------------------------------------------------------------------- | ------------------------------------------------------- | +| `getAdministrativeAreas(point, options)` | Named administrative candidates and geometry references | +| `getAreaGeometry(id, options)` | Geometry for one selected administrative reference | +| `getNeighborhoodAreas(point, options)` | Neighborhood polygon candidates | +| `getStreetAreas(point, options)` / `getStreetLines(point, options)` | Named areas and road lines for street annotations | +| `getFootprints(point, options)` / `getEnclosingAreas(point, options)` | Building and surrounding-ground candidates | +| `getMonuments(point, options)` | Named point candidates | +| `getFocusFootprints(point, options)` | Building/landmark candidates for camera framing | + +Points use `{lat, lon}`. Options carry `signal`. A successful array, including an +empty array, is definitive; `null` means a transient failure; +`{rateLimited: true, retryAfterMs}` retains a provider's retry delay. Candidates +carry `id`, `names` (`primary`, `english`, `official`, `alternate`, `short`), +`coordinates` (objects with `lat` and `lon`), `building` and optional `heightM`, +`center` and `point`. Administrative candidates use `category: 'administrative'` +and a numeric `level`. Provenance may retain compatibility metadata, but ranking +does not use backend tags. The Overpass implementation retains its existing +operation-specific query radii and deadlines. + +Traffic's source response decodes to `{roads}`. Each road supplies longitude/latitude +`coordinates`, a road-class `type` and `oneway` (`-1`, `0` or `1`). Scene construction +still owns thinning, terrain sampling and Cartesian waypoints. Installation +sources supply `{records, droppedCount, status, saturated}`; viewport filtering, +exact-bound retry and rendering stay in the layer. Legacy cache saturation is +decoded in the source. Source and geometry modules remain independent of Cesium, +DOM and platform middleware. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ee5458a07..a11c53828 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,47 @@ # God's Eye View Current State +Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged. + +Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. + +Nepal flood lines classify the active terrain or photoreal surface, and the surge marker follows that surface. Geographic route coordinates are unchanged; dynamic lines do not rely on unsupported depth-failure materials. Later media-only shots keep their completed source-path history throughout arrival and card reveal. + +Scene Stop, replacement, seek, and teardown revoke the Upper Valley locator's delayed camera approach and orbit while preserving its visible callout. A revoked timer or camera callback cannot start another flight or interfere with a newer shot. + +Nepal scene ownership follows the parameters prepared by the public layer manager before activation, without depending on enable-time origin metadata. Enabling a scene beat preserves its selected map and camera; passive restoration does not schedule standalone playback. + +Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation. + +Nepal Flood Incident is accessed through Scenes. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched. + +Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project. + +The Nepal overview route and lake-shot trail share the river-centerline starting point nearest Debris-Dammed Lake. No earlier upstream section is included; evidence pins remain separate from the schematic river geometry. + +The event panel omits the WITNESS shortcut. Shot media, source attribution, Open Original links and corridor navigation remain available. + +The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. The authored estimate remains seven seconds plus the exit, replacing older saved holds at runtime without rewriting them. Stop, replacement and teardown revoke pending playback callbacks. + +Persistent media-led Nepal shots preserve their completed upstream flood path during camera travel. The next reach still reveals after arrival; direct loads reconstruct the prefix and backward replay trims downstream history. + +Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior. + +Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms. + +Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved. + +Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server. + +Reference feed construction is exported through `sources/reference`; the cable source also has a dedicated `layers/submarine-cables/source` entry. Standalone catalog compatibility remains available. Source choices, data and attribution are unchanged. + +Source factories have dedicated `layers//source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged. + +Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/result, interruption and completion events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner. + +Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds the same 28 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. + +Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged. + Vessels separate plain, stable MMSI records and source acquisition from scene resources. The renderer owns geometry and billboards; cards, picking and trails resolve those resources through it. Partial-feed retention, selected-vessel @@ -466,7 +508,9 @@ The existing declared media size ceiling remains 64 MiB. ## GBFS upstream bounds -GBFS refuses upstream redirects and enforces its 5 MiB response cap while +The station reader addresses the proxy as `/api/gbfs/`; the +feed address is read from the path, and a query-string form is refused with a +400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. @@ -717,7 +761,7 @@ Non-object or array-valued properties reject the response instead of being treat Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. -Updated: August 24, 2026 +Updated: September 15, 2026 ## Aircraft and vessel server modules @@ -785,7 +829,13 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > is present — everyone gets the same tile. > > Keyless, the honest surface is the **layer row**, which reads -> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`, and the earthquakes half +> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`. Its control also names the +> key: hovering it, and its accessible name, read +> `Needs FIRMS_MAP_KEY — add it in Provider Settings`. A layer declares which +> key it needs as a key-registry id and reports `stats.keyRequired` while that +> key is absent; the panel builds the text from the pair, and produces none for +> a layer that needs no key, already holds one, or names a key the registry does +> not recognise. The earthquakes half > still delivers in full. The shared loading reducer now treats an explicitly > declared missing optional key as a configured terminal state rather than a > failed multi-layer mission, so the global chip completes without showing @@ -1448,7 +1498,15 @@ This is the current runtime/source-of-truth snapshot for the project. > user intent. > Display orders 3D immediately above Celestial and Clean UI immediately > below it. The top-center action group places Clear Layers to the left of -> Share and Reset Globe to the right. Clear Layers turns off the currently +> Share, then Tilt Map and North Up, and Reset Globe to the right. Tilt Map +> swings the camera between a straight-down map and a 35-degree oblique around +> the point under the centre of the view, keeping that point and the distance +> to it; North Up rotates around the same point until north is at the top, +> keeping the pitch. Its needle shows the current bearing. Both decline +> without moving the camera when nothing is under the centre of the view (a +> camera facing the sky), and both are explicit camera actions, so either one +> releases a followed aircraft the way the other camera controls do. They +> follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit. Clear Layers turns off the currently > selected manager-owned data layers, including an active Context choice, > while retaining visual, HUD, map, and panel settings. A disabled layer may > still release camera work that it owns through its normal teardown. @@ -1494,8 +1552,9 @@ This is the current runtime/source-of-truth snapshot for the project. > the Cockpit HUD at 145), scrolling internally when the layer list is longer than > the corridor. Cesium's credit line is never passable and still bounds the > corridor. Layer toggles stay live from there, and collapsing returns the -> plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden -> for the duration of Cockpit, both as a group and as individual controls. +> plain launcher. The map-only Clear, Share, Tilt Map, North Up, and Reset +> Globe actions are hidden for the duration of Cockpit, both as a group and as +> individual controls. > It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row > places the single Cockpit entry before Search Nearby Sites. Cockpit removes > the duplicate floating map entry and topline exit; the bottom-center @@ -1605,9 +1664,9 @@ This is the current runtime/source-of-truth snapshot for the project. > tray inside the screen. > Adaptive remeasurement preserves the user's > scroll position across Tactical, Minimal, and HUD Off layouts. -> During an active Scene run, Clear Selected Layers and Reset Globe remain -> hidden until playback stops or completes because the Scene transport owns -> layer and camera sequencing for that interval. +> During an active Scene run, Clear Selected Layers, Tilt Map, North Up and +> Reset Globe remain hidden until playback stops or completes because the +> Scene transport owns layer and camera sequencing for that interval. > The Cockpit Contact summary exposes Previous and Next contact navigation > plus its collapse control; it does not offer a Focus action because the > first-person Cockpit camera remains owned by the tracked aircraft. @@ -2378,6 +2437,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 | @@ -2465,6 +2525,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 @@ -2882,6 +3099,7 @@ silently demoting every later lookup for the session. - `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache. - HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data. - A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`). +- Every mirror is asked with a User-Agent that names the application, its version and the project address (`OVERPASS_USER_AGENT` in `server/providers/overpass/constants.js`), which is what the OSM API usage policy asks for. Mirrors may refuse a client they cannot identify; such a refusal is never treated as data and costs the fan-out that mirror, so the header is what keeps the list at full strength. ### Share-link v2 layer state (August 2026) @@ -3056,8 +3274,28 @@ silently demoting every later lookup for the session. Location search/fly-to, annotations and Radio location lookup receive one `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes -Google first when configured and Photon/OpenStreetMap as fallback, including -Google transport failures or declined requests. The portable `./search` export +two offline providers first — a strict decimal-degree coordinate reader and an +exact-match reader over the bundled city and landmark names — then Google when +configured and Photon/OpenStreetMap as fallback, including Google transport +failures or declined requests. The offline pair answer with no request and no +key, and decline everything they are not certain of: a coordinate must be a +decimal-degree pair (either order when N/S/E/W fix the axes), and a bundled name +must match exactly, so `12junk, 34oops` and `austin, tx` both reach the +geocoders instead of being answered from the bundle. Degrees/minutes/seconds and +grid references are not parsed. An answer either provider gives is exact, so +nearby-landmark recovery stands aside for it — otherwise a typed coordinate +could be replaced by whatever Places finds near the current view. The portable `./search` export +Google first when configured, Photon/OpenStreetMap as fallback (including Google +transport failures or declined requests), and the local `/api/geocode` route +over Nominatim as a last resort when neither answers. That route keeps to the +public instance's usage policy: an identifying `User-Agent` and `Referer`, at +most one request per second shared with the cockpit's reverse lookups, answers +cached for five minutes, one upstream call shared between identical searches +already in flight, a queue bounded at four waiting searches (a burst past it is +refused with 429 and `Retry-After`, never held), and a queued search dropped +once its caller has given up rather than spending a slot on an answer nobody +will read. A hit whose coordinates or bounding box are missing or out of range +is discarded rather than coerced. The portable `./search` export provides the service and adapters; it reads no environment or application state. Existing browser/server key setup is unchanged. diff --git a/package.json b/package.json index 77e4b33eb..a919399a1 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-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", @@ -207,6 +208,38 @@ "./layers/military/records": "./src/layers/military/records.js", "./layers/military/ingestion": "./src/layers/military/ingestion.js", "./layers/vessels/records": "./src/layers/vessels/records.js", - "./layers/vessels/ingestion": "./src/layers/vessels/ingestion.js" + "./layers/vessels/ingestion": "./src/layers/vessels/ingestion.js", + "./sources/radio-browser": "./src/sources/radioBrowser.js", + "./sources/cctv-types": "./src/sources/cctvTypes.js", + "./sources/regional": "./src/data/regionalModel.js", + "./voice/action-schemas": "./src/voice/actionSchemas.js", + "./voice/session": "./src/voice/session.js", + "./voice/session-controls": "./src/voice/sessionCommands.js", + "./voice/realtime-session": "./src/voice/realtimeSession.js", + "./layers/alpr/source": "./src/layers/alpr/source.js", + "./layers/bikeshare/source": "./src/layers/bikeshare/source.js", + "./layers/cctv/source": "./src/layers/cctv/source.js", + "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", + "./layers/firms/source": "./src/layers/firms/source.js", + "./layers/installations/source": "./src/layers/installations/source.js", + "./layers/launches/source": "./src/layers/launches/source.js", + "./layers/radio/source": "./src/layers/radio/source.js", + "./layers/satellites/source": "./src/layers/satellites/source.js", + "./layers/traffic/source": "./src/layers/traffic/source.js", + "./sources/reference": "./src/sources/reference.js", + "./layers/submarine-cables/source": "./src/layers/submarineCables/bundledSource.js", + "./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", + "./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", + "./layers/transit/source": "./src/layers/transit/source.js", + "./sources/transit-service": "./src/sources/transitService.js", + "./sources/map-features": "./src/sources/overpassFeatures.js" } } diff --git a/public/events/bhote-koshi-2026/README.md b/public/events/bhote-koshi-2026/README.md new file mode 100644 index 000000000..f8d3657e7 --- /dev/null +++ b/public/events/bhote-koshi-2026/README.md @@ -0,0 +1,55 @@ +# Bhote Koshi 2026 event pack + +A non-commercial visualization of the 26 August 2026 Bhote Koshi outburst flood. +This is not an official hazard model. + +## Provenance and licenses + +| File | Source and modifications | License | +| --- | --- | --- | +| `pre.webp` | Vantor WorldView-2 scene `10300100C86CED00`, observed 2021-10-16; natural-color crop of 85.295–85.379 E, 28.135–28.292 N, resampled to 2048×4352 and WebP-compressed | CC BY-NC 4.0 | +| `post.webp` | Vantor WorldView-3 scene `B040001100881410`, observed 2026-08-27; same bounds and output geometry, with source clouds retained | CC BY-NC 4.0 | +| `event.json` | GeoPera reconstruction, commit `59ee5bd`; every fourth published centerline point inside the image bounds, plus sixteen curated evidence records linking public witness posts and the GeoGeorgeShadrach geolocation map | CC BY-NC 4.0 for the derived centerline; linked posts and map records retain their owners' terms | +| `src/data/bhoteKoshiFloodPath.js` (outside this directory) | Simplified GeoPera centerline from the Debris-Dammed Lake observation to Trishuli Bazaar, compiled into the scene and locator | CC BY-NC 4.0 for the coordinate dataset, separately from the executable code | + +These third-party assets are **not covered by the application's MIT license**. +Keep attribution and the [CC BY-NC 4.0 license](https://creativecommons.org/licenses/by-nc/4.0/). +For commercial use, obtain separate permission or exclude both this pack and the +derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source +and build. Deleting this directory alone does not remove the compiled river +data. Excluding the Nepal scene also requires removing its registrations and +imports before building. + +Sources: + +- [Vantor Open Data Program](https://vantor.com/company/open-data-program) +- [Historical source image](https://vantor-opendata.s3.amazonaws.com/events/Nepal-Flooding-Aug-2026/10300100C86CED00.tif) +- [Post-event source image](https://vantor-opendata.s3.amazonaws.com/events/Nepal-Flooding-Aug-2026/B040001100881410.tif) +- [GeoPera reconstruction](https://github.com/geo-pera/bhotekoshi-2026-reconstruction) +- [GeoGeorgeShadrach public geolocation-map introduction](https://x.com/geogeorgeology/status/2093632283442053371) + +Witness clips and cached posters are not bundled. Cards link to the original +source, or use the original platform's embed where available. An unavailable +embed leaves the source link accessible. Capture times remain unverified. + +Esri basemap imagery and runtime terrain are fetched separately, not bundled; +their provider attribution remains visible in the application. + +The Nepal sequence supports both keyed and keyless map setups. Google 3D is used +for authored 3D shots when available; otherwise those shots use Esri imagery. +Without a Cesium ion token, terrain comes from Re:Earth / Mapterhorn. With an +ion token, the configured Cesium terrain remains available. Vantor comparison +shots use Esri in either mode. Provider resolution does not rewrite saved shots. + +## Interpretation limits + +The 2021 image is historical context, not an immediate pre-event baseline. +Differences also span development, seasons, sensors, and the July 2025 +Rasuwagadhi flood. The amber corridor is a schematic progression along the +published river centerline, not modeled arrival time. Its width and timing +must not be used for emergency planning, exposure analysis, or casualty claims. + +Evidence cards identify observations outside the Vantor comparison rectangle. +The Rasuwagadhi witness focus uses the 2021 context image because the 2026 +scene does not provide useful coverage at that point. The Timure record groups +five source-map placements, not five independent timing confirmations. diff --git a/public/events/bhote-koshi-2026/event.json b/public/events/bhote-koshi-2026/event.json new file mode 100644 index 000000000..d00f0d3a7 --- /dev/null +++ b/public/events/bhote-koshi-2026/event.json @@ -0,0 +1,1633 @@ +{ + "schemaVersion": 1, + "id": "bhote-koshi-2026", + "title": "Bhote Koshi Outburst Flood", + "observedDate": "2026-08-26", + "geolocationMap": { + "credit": "GeoGeorgeShadrach public geolocation map", + "sourceUrl": "https://x.com/geogeorgeology/status/2093632283442053371" + }, + "imagery": { + "rectangle": { + "west": 85.295, + "south": 28.135, + "east": 85.379, + "north": 28.292 + }, + "before": { + "path": "pre.webp", + "observedAt": "2021-10-16", + "label": "2021 HISTORICAL REFERENCE · 16 OCT 2021", + "source": "Vantor WorldView-2 Open Data" + }, + "after": { + "path": "post.webp", + "observedAt": "2026-08-27", + "label": "2026 POST-EVENT · 27 AUG 2026", + "source": "Vantor WorldView-3 Open Data" + } + }, + "reconstruction": { + "elapsedSeconds": 4170, + "label": "SCHEMATIC DOWNSTREAM PROGRESSION, NOT MODELED ARRIVAL TIME", + "caveat": "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.", + "corridor": [ + { + "chainageM": 23705, + "lon": 85.37877, + "lat": 28.27962, + "elevationM": 1818.4 + }, + { + "chainageM": 23863, + "lon": 85.37756, + "lat": 28.27879, + "elevationM": 1811.7 + }, + { + "chainageM": 24020, + "lon": 85.37665, + "lat": 28.2777, + "elevationM": 1796.5 + }, + { + "chainageM": 24140, + "lon": 85.37667, + "lat": 28.27661, + "elevationM": 1783.5 + }, + { + "chainageM": 24260, + "lon": 85.37669, + "lat": 28.27553, + "elevationM": 1777.4 + }, + { + "chainageM": 24392, + "lon": 85.37701, + "lat": 28.27445, + "elevationM": 1777.6 + }, + { + "chainageM": 24537, + "lon": 85.37641, + "lat": 28.27336, + "elevationM": 1779.8 + }, + { + "chainageM": 24682, + "lon": 85.37643, + "lat": 28.27228, + "elevationM": 1781.3 + }, + { + "chainageM": 24814, + "lon": 85.37614, + "lat": 28.27119, + "elevationM": 1780.2 + }, + { + "chainageM": 24934, + "lon": 85.37616, + "lat": 28.27011, + "elevationM": 1780.1 + }, + { + "chainageM": 25054, + "lon": 85.37617, + "lat": 28.26903, + "elevationM": 1779.5 + }, + { + "chainageM": 25212, + "lon": 85.37589, + "lat": 28.26794, + "elevationM": 1775.7 + }, + { + "chainageM": 25357, + "lon": 85.37529, + "lat": 28.26685, + "elevationM": 1759.9 + }, + { + "chainageM": 25526, + "lon": 85.37408, + "lat": 28.26575, + "elevationM": 1739.7 + }, + { + "chainageM": 25684, + "lon": 85.37318, + "lat": 28.26466, + "elevationM": 1745.1 + }, + { + "chainageM": 25853, + "lon": 85.37198, + "lat": 28.26356, + "elevationM": 1734 + }, + { + "chainageM": 25973, + "lon": 85.37199, + "lat": 28.26248, + "elevationM": 1725.5 + }, + { + "chainageM": 26143, + "lon": 85.37079, + "lat": 28.26138, + "elevationM": 1729.2 + }, + { + "chainageM": 26313, + "lon": 85.36958, + "lat": 28.26029, + "elevationM": 1726.2 + }, + { + "chainageM": 26470, + "lon": 85.36836, + "lat": 28.26, + "elevationM": 1700.8 + }, + { + "chainageM": 26640, + "lon": 85.36715, + "lat": 28.2589, + "elevationM": 1714 + }, + { + "chainageM": 26809, + "lon": 85.36656, + "lat": 28.25781, + "elevationM": 1714 + }, + { + "chainageM": 26954, + "lon": 85.36596, + "lat": 28.25672, + "elevationM": 1706.8 + }, + { + "chainageM": 27111, + "lon": 85.36506, + "lat": 28.25563, + "elevationM": 1692.9 + }, + { + "chainageM": 27244, + "lon": 85.36477, + "lat": 28.25454, + "elevationM": 1691.4 + }, + { + "chainageM": 27376, + "lon": 85.36449, + "lat": 28.25346, + "elevationM": 1691.9 + }, + { + "chainageM": 27521, + "lon": 85.36511, + "lat": 28.25238, + "elevationM": 1689.4 + }, + { + "chainageM": 27666, + "lon": 85.36452, + "lat": 28.25129, + "elevationM": 1674.4 + }, + { + "chainageM": 27798, + "lon": 85.36423, + "lat": 28.2502, + "elevationM": 1669.5 + }, + { + "chainageM": 27931, + "lon": 85.36455, + "lat": 28.24912, + "elevationM": 1676.8 + }, + { + "chainageM": 28088, + "lon": 85.36365, + "lat": 28.24803, + "elevationM": 1673.2 + }, + { + "chainageM": 28245, + "lon": 85.36275, + "lat": 28.24694, + "elevationM": 1672.1 + }, + { + "chainageM": 28415, + "lon": 85.36154, + "lat": 28.24584, + "elevationM": 1672.1 + }, + { + "chainageM": 28585, + "lon": 85.36034, + "lat": 28.24474, + "elevationM": 1673.6 + }, + { + "chainageM": 28755, + "lon": 85.35912, + "lat": 28.24419, + "elevationM": 1680.3 + }, + { + "chainageM": 28912, + "lon": 85.35822, + "lat": 28.24309, + "elevationM": 1662.8 + }, + { + "chainageM": 29057, + "lon": 85.35763, + "lat": 28.242, + "elevationM": 1645.5 + }, + { + "chainageM": 29189, + "lon": 85.35734, + "lat": 28.24092, + "elevationM": 1636.4 + }, + { + "chainageM": 29334, + "lon": 85.35797, + "lat": 28.23984, + "elevationM": 1645.3 + }, + { + "chainageM": 29466, + "lon": 85.35768, + "lat": 28.23875, + "elevationM": 1645.1 + }, + { + "chainageM": 29611, + "lon": 85.35769, + "lat": 28.23767, + "elevationM": 1640.9 + }, + { + "chainageM": 29731, + "lon": 85.35771, + "lat": 28.23659, + "elevationM": 1643.8 + }, + { + "chainageM": 29876, + "lon": 85.35834, + "lat": 28.23551, + "elevationM": 1642.5 + }, + { + "chainageM": 30021, + "lon": 85.35897, + "lat": 28.23444, + "elevationM": 1645.7 + }, + { + "chainageM": 30166, + "lon": 85.3596, + "lat": 28.23336, + "elevationM": 1644.8 + }, + { + "chainageM": 30311, + "lon": 85.36022, + "lat": 28.23229, + "elevationM": 1640.9 + }, + { + "chainageM": 30455, + "lon": 85.36024, + "lat": 28.2312, + "elevationM": 1640.7 + }, + { + "chainageM": 30588, + "lon": 85.36056, + "lat": 28.23012, + "elevationM": 1617.5 + }, + { + "chainageM": 30720, + "lon": 85.36088, + "lat": 28.22905, + "elevationM": 1619.7 + }, + { + "chainageM": 30853, + "lon": 85.36059, + "lat": 28.22796, + "elevationM": 1606.7 + }, + { + "chainageM": 30985, + "lon": 85.36092, + "lat": 28.22688, + "elevationM": 1605 + }, + { + "chainageM": 31105, + "lon": 85.36093, + "lat": 28.2258, + "elevationM": 1591.3 + }, + { + "chainageM": 31238, + "lon": 85.36126, + "lat": 28.22472, + "elevationM": 1587.9 + }, + { + "chainageM": 31370, + "lon": 85.36097, + "lat": 28.22363, + "elevationM": 1588.5 + }, + { + "chainageM": 31515, + "lon": 85.36037, + "lat": 28.22254, + "elevationM": 1585.7 + }, + { + "chainageM": 31685, + "lon": 85.35917, + "lat": 28.22144, + "elevationM": 1573.5 + }, + { + "chainageM": 31854, + "lon": 85.35796, + "lat": 28.22035, + "elevationM": 1590.1 + }, + { + "chainageM": 32012, + "lon": 85.35706, + "lat": 28.21925, + "elevationM": 1580.1 + }, + { + "chainageM": 32156, + "lon": 85.35647, + "lat": 28.21816, + "elevationM": 1574.3 + }, + { + "chainageM": 32314, + "lon": 85.35557, + "lat": 28.21707, + "elevationM": 1561.1 + }, + { + "chainageM": 32459, + "lon": 85.35497, + "lat": 28.21598, + "elevationM": 1558.2 + }, + { + "chainageM": 32591, + "lon": 85.35468, + "lat": 28.21489, + "elevationM": 1554.7 + }, + { + "chainageM": 32723, + "lon": 85.355, + "lat": 28.21381, + "elevationM": 1552.4 + }, + { + "chainageM": 32856, + "lon": 85.35471, + "lat": 28.21273, + "elevationM": 1548.3 + }, + { + "chainageM": 32988, + "lon": 85.35443, + "lat": 28.21164, + "elevationM": 1546.4 + }, + { + "chainageM": 33121, + "lon": 85.35414, + "lat": 28.21055, + "elevationM": 1542.7 + }, + { + "chainageM": 33241, + "lon": 85.35415, + "lat": 28.20947, + "elevationM": 1541.1 + }, + { + "chainageM": 33373, + "lon": 85.35448, + "lat": 28.20839, + "elevationM": 1539.9 + }, + { + "chainageM": 33506, + "lon": 85.3548, + "lat": 28.20731, + "elevationM": 1536.1 + }, + { + "chainageM": 33626, + "lon": 85.35481, + "lat": 28.20623, + "elevationM": 1534.7 + }, + { + "chainageM": 33770, + "lon": 85.35422, + "lat": 28.20514, + "elevationM": 1530.3 + }, + { + "chainageM": 33915, + "lon": 85.35363, + "lat": 28.20405, + "elevationM": 1526.4 + }, + { + "chainageM": 34060, + "lon": 85.35303, + "lat": 28.20296, + "elevationM": 1526.2 + }, + { + "chainageM": 34193, + "lon": 85.35274, + "lat": 28.20187, + "elevationM": 1522.2 + }, + { + "chainageM": 34313, + "lon": 85.35276, + "lat": 28.20079, + "elevationM": 1523.5 + }, + { + "chainageM": 34445, + "lon": 85.35247, + "lat": 28.1997, + "elevationM": 1514.7 + }, + { + "chainageM": 34577, + "lon": 85.35218, + "lat": 28.19862, + "elevationM": 1508 + }, + { + "chainageM": 34735, + "lon": 85.35128, + "lat": 28.19752, + "elevationM": 1504.4 + }, + { + "chainageM": 34892, + "lon": 85.35038, + "lat": 28.19643, + "elevationM": 1496.7 + }, + { + "chainageM": 35024, + "lon": 85.35009, + "lat": 28.19534, + "elevationM": 1495.1 + }, + { + "chainageM": 35157, + "lon": 85.3498, + "lat": 28.19426, + "elevationM": 1493.6 + }, + { + "chainageM": 35302, + "lon": 85.34921, + "lat": 28.19317, + "elevationM": 1488.9 + }, + { + "chainageM": 35434, + "lon": 85.34892, + "lat": 28.19208, + "elevationM": 1484 + }, + { + "chainageM": 35579, + "lon": 85.34832, + "lat": 28.19099, + "elevationM": 1482.6 + }, + { + "chainageM": 35724, + "lon": 85.34773, + "lat": 28.1899, + "elevationM": 1481 + }, + { + "chainageM": 35893, + "lon": 85.34652, + "lat": 28.1888, + "elevationM": 1477.6 + }, + { + "chainageM": 36038, + "lon": 85.34593, + "lat": 28.18771, + "elevationM": 1475.5 + }, + { + "chainageM": 36158, + "lon": 85.34595, + "lat": 28.18663, + "elevationM": 1473.2 + }, + { + "chainageM": 36316, + "lon": 85.34505, + "lat": 28.18553, + "elevationM": 1467.4 + }, + { + "chainageM": 36473, + "lon": 85.34415, + "lat": 28.18444, + "elevationM": 1465.5 + }, + { + "chainageM": 36618, + "lon": 85.34355, + "lat": 28.18335, + "elevationM": 1466.3 + }, + { + "chainageM": 36763, + "lon": 85.34296, + "lat": 28.18226, + "elevationM": 1467.9 + }, + { + "chainageM": 36907, + "lon": 85.34236, + "lat": 28.18117, + "elevationM": 1464.2 + }, + { + "chainageM": 37027, + "lon": 85.34238, + "lat": 28.18009, + "elevationM": 1463.6 + }, + { + "chainageM": 37160, + "lon": 85.3427, + "lat": 28.17901, + "elevationM": 1474.1 + }, + { + "chainageM": 37280, + "lon": 85.34272, + "lat": 28.17793, + "elevationM": 1465.8 + }, + { + "chainageM": 37400, + "lon": 85.34274, + "lat": 28.17684, + "elevationM": 1456.3 + }, + { + "chainageM": 37532, + "lon": 85.34245, + "lat": 28.17576, + "elevationM": 1445.9 + }, + { + "chainageM": 37665, + "lon": 85.34216, + "lat": 28.17467, + "elevationM": 1431.7 + }, + { + "chainageM": 37797, + "lon": 85.34248, + "lat": 28.17359, + "elevationM": 1443.6 + }, + { + "chainageM": 37942, + "lon": 85.34189, + "lat": 28.1725, + "elevationM": 1446.6 + }, + { + "chainageM": 38062, + "lon": 85.3419, + "lat": 28.17142, + "elevationM": 1437.8 + }, + { + "chainageM": 38182, + "lon": 85.34192, + "lat": 28.17033, + "elevationM": 1444.8 + }, + { + "chainageM": 38327, + "lon": 85.34255, + "lat": 28.16926, + "elevationM": 1450.6 + }, + { + "chainageM": 38447, + "lon": 85.34256, + "lat": 28.16818, + "elevationM": 1444 + }, + { + "chainageM": 38604, + "lon": 85.34289, + "lat": 28.1671, + "elevationM": 1442.5 + }, + { + "chainageM": 38737, + "lon": 85.3426, + "lat": 28.16601, + "elevationM": 1436.6 + }, + { + "chainageM": 38881, + "lon": 85.342, + "lat": 28.16492, + "elevationM": 1428.4 + }, + { + "chainageM": 39026, + "lon": 85.34079, + "lat": 28.16436, + "elevationM": 1418.8 + }, + { + "chainageM": 39171, + "lon": 85.33958, + "lat": 28.16381, + "elevationM": 1417.9 + }, + { + "chainageM": 39328, + "lon": 85.33837, + "lat": 28.16298, + "elevationM": 1417 + }, + { + "chainageM": 39486, + "lon": 85.33747, + "lat": 28.16189, + "elevationM": 1418.6 + }, + { + "chainageM": 39606, + "lon": 85.33748, + "lat": 28.1608, + "elevationM": 1405.8 + }, + { + "chainageM": 39751, + "lon": 85.33658, + "lat": 28.15998, + "elevationM": 1408.6 + }, + { + "chainageM": 39908, + "lon": 85.33537, + "lat": 28.15915, + "elevationM": 1402.1 + }, + { + "chainageM": 40053, + "lon": 85.33416, + "lat": 28.1586, + "elevationM": 1398.3 + }, + { + "chainageM": 40198, + "lon": 85.33295, + "lat": 28.15804, + "elevationM": 1396.2 + }, + { + "chainageM": 40342, + "lon": 85.33204, + "lat": 28.15722, + "elevationM": 1389.7 + }, + { + "chainageM": 40500, + "lon": 85.33083, + "lat": 28.15639, + "elevationM": 1387.9 + }, + { + "chainageM": 40657, + "lon": 85.32962, + "lat": 28.15556, + "elevationM": 1384.2 + }, + { + "chainageM": 40814, + "lon": 85.32872, + "lat": 28.15447, + "elevationM": 1385.5 + }, + { + "chainageM": 40972, + "lon": 85.32751, + "lat": 28.15364, + "elevationM": 1379.1 + }, + { + "chainageM": 41129, + "lon": 85.32662, + "lat": 28.15255, + "elevationM": 1376.3 + }, + { + "chainageM": 41286, + "lon": 85.32541, + "lat": 28.15172, + "elevationM": 1368.8 + }, + { + "chainageM": 41431, + "lon": 85.32419, + "lat": 28.15117, + "elevationM": 1365.4 + }, + { + "chainageM": 41576, + "lon": 85.32298, + "lat": 28.15061, + "elevationM": 1365.1 + }, + { + "chainageM": 41721, + "lon": 85.32177, + "lat": 28.15005, + "elevationM": 1364.6 + }, + { + "chainageM": 41878, + "lon": 85.32056, + "lat": 28.14923, + "elevationM": 1357.3 + }, + { + "chainageM": 42035, + "lon": 85.31935, + "lat": 28.1484, + "elevationM": 1359.7 + }, + { + "chainageM": 42168, + "lon": 85.31813, + "lat": 28.14811, + "elevationM": 1354.2 + }, + { + "chainageM": 42337, + "lon": 85.31693, + "lat": 28.14702, + "elevationM": 1357 + }, + { + "chainageM": 42482, + "lon": 85.31571, + "lat": 28.14646, + "elevationM": 1357 + }, + { + "chainageM": 42639, + "lon": 85.3145, + "lat": 28.14563, + "elevationM": 1342.2 + }, + { + "chainageM": 42809, + "lon": 85.3133, + "lat": 28.14453, + "elevationM": 1344.9 + }, + { + "chainageM": 42966, + "lon": 85.3124, + "lat": 28.14344, + "elevationM": 1358.7 + }, + { + "chainageM": 43099, + "lon": 85.31211, + "lat": 28.14235, + "elevationM": 1358.4 + }, + { + "chainageM": 43244, + "lon": 85.31089, + "lat": 28.14234, + "elevationM": 1336.9 + }, + { + "chainageM": 43376, + "lon": 85.30966, + "lat": 28.14259, + "elevationM": 1343 + }, + { + "chainageM": 43546, + "lon": 85.30846, + "lat": 28.1415, + "elevationM": 1331.3 + }, + { + "chainageM": 43716, + "lon": 85.30726, + "lat": 28.1404, + "elevationM": 1331.4 + }, + { + "chainageM": 43885, + "lon": 85.30605, + "lat": 28.1393, + "elevationM": 1324.1 + }, + { + "chainageM": 44055, + "lon": 85.30485, + "lat": 28.1382, + "elevationM": 1341.6 + }, + { + "chainageM": 44187, + "lon": 85.30456, + "lat": 28.13712, + "elevationM": 1339 + }, + { + "chainageM": 44332, + "lon": 85.30396, + "lat": 28.13603, + "elevationM": 1326.1 + }, + { + "chainageM": 44447, + "lon": 85.30337, + "lat": 28.13521, + "elevationM": 1323 + } + ], + "storyFloodWindow": { + "start": 0.195, + "end": 0.39 + } + }, + "witnessAnchors": [ + { + "id": "rasuwagadhi-geoconfirmed", + "title": "RASUWAGADHI · SOURCE GEOCONFIRMED", + "details": [ + "28.279942 N · 85.377725 E", + "WITNESS VIDEO · OPEN ORIGINAL" + ], + "lat": 28.279942, + "lon": 85.377725, + "sourceUrl": "https://x.com/Indowatchosint/status/2092640554568007958", + "provenance": "Published as GeoConfirmed in the linked IndoWatchOSINT post; original video credited there.", + "elevationM": 1836.4 + } + ], + "fieldReports": [ + { + "label": "Rasuwagadhi witness video", + "status": "SOURCE GEOCONFIRMED · WORLD-PINNED", + "url": "https://x.com/Indowatchosint/status/2092640554568007958" + }, + { + "label": "Bhote Koshi valley footage · Nirmal Prasai", + "status": "SOURCE CREDITED · LOCATION UNRESOLVED", + "url": "https://x.com/GlobalUpdates24/status/2092602195812470980" + }, + { + "label": "Flood footage · Surajit", + "status": "SOURCE CREDITED · LOCATION UNRESOLVED", + "url": "https://x.com/BNODesk/status/2092568283098575057" + }, + { + "label": "Compilation and casualty claims", + "status": "REFERENCE ONLY · CLAIMS UNVERIFIED", + "url": "https://x.com/Emeka2026/status/2093196357242662947" + } + ], + "viewpoints": { + "corridor": { + "lat": 28.207, + "lon": 85.3548, + "targetElevationM": 1536, + "rangeM": 8600, + "headingDeg": 344, + "pitchDeg": -39 + }, + "witness": { + "lat": 28.2775, + "lon": 85.374, + "targetElevationM": 1795, + "rangeM": 1700, + "headingDeg": 0, + "pitchDeg": -60, + "imagery": "before" + } + }, + "evidenceSpine": [ + { + "id": "immediate-collapse-viewpoint", + "sequence": 1, + "title": "Immediate collapse viewpoint", + "shortTitle": "Collapse viewpoint", + "summary": "Geolocated viewpoint toward the upper-valley collapse zone.", + "phase": "initiation", + "lat": 28.3632333, + "lon": 85.4423639, + "fallbackElevationM": 2850, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "viewpoint geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=DbqRexFxv3k", + "posterUrl": "https://i.ytimg.com/vi/DbqRexFxv3k/hqdefault.jpg", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 422, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 8.6, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "debris-dammed-lake", + "sequence": 2, + "title": "Debris-dammed lake", + "shortTitle": "Dammed lake", + "summary": "Geolocated observation of impoundment above the border corridor.", + "phase": "damming", + "lat": 28.3346111, + "lon": 85.4861389, + "fallbackElevationM": 2750, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.065 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=HTwd724Hs2g", + "posterUrl": "https://i.ytimg.com/vi/HTwd724Hs2g/hqdefault.jpg", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 480, + "height": 360, + "orientation": "landscape", + "fit": "cover", + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "second-landslide", + "sequence": 3, + "title": "Second landslide", + "shortTitle": "Second landslide", + "summary": "A short source-linked excerpt of the second upper-valley landslide.", + "phase": "secondary failure", + "lat": 28.3379706, + "lon": 85.4688823, + "fallbackElevationM": 2750, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.13 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=hfCz1z0u4pk", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 9.3, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "gyirong-border-gate", + "sequence": 4, + "title": "Gyirong border gate", + "shortTitle": "Border gate", + "summary": "Public footage geolocated at the Nepal-China border crossing.", + "phase": "border arrival", + "lat": 28.2808338, + "lon": 85.3780248, + "fallbackElevationM": 1818, + "imageryCoverage": "within", + "trigger": { + "mode": "phase", + "storyProgress": 0.195 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=qlE51Eu0thk", + "posterUrl": "https://i.ytimg.com/vi/qlE51Eu0thk/hqdefault.jpg", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 480, + "height": 360, + "orientation": "landscape", + "fit": "cover", + "clipInSec": 26, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "timure-cluster", + "sequence": 5, + "title": "Timure evidence cluster", + "shortTitle": "Timure", + "summary": "Five geolocated public viewpoints document the flood passage.", + "phase": "propagation", + "lat": 28.2519361, + "lon": 85.3665833, + "fallbackElevationM": 1689, + "imageryCoverage": "within", + "trigger": { + "mode": "phase", + "storyProgress": 0.26 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "cluster", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=gb8cGELvskA", + "posterUrl": "https://i.ytimg.com/vi/gb8cGELvskA/hqdefault.jpg", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "cover", + "clipInSec": 0, + "clipOutSec": 10.1, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + }, + "corroboration": { + "kind": "multi-source geolocation cluster", + "sourceCount": 5, + "displayedSourceCount": 1, + "note": "Five distinct Timure placements are recorded in the public source map; this card opens one representative source." + } + }, + { + "id": "syabru-besi", + "sequence": 6, + "title": "Syabru Besi", + "shortTitle": "Syabru Besi", + "summary": "A Facebook-sourced downstream viewpoint along the reconstructed corridor.", + "phase": "downstream passage", + "lat": 28.1602583333333, + "lon": 85.3325666666667, + "fallbackElevationM": 1417, + "imageryCoverage": "within", + "trigger": { + "mode": "phase", + "storyProgress": 0.325 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "viewpoint geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.facebook.com/reel/1571491657757829", + "platform": "Facebook", + "publisher": "Facebook source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 10, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map, record 19", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "dhunche", + "sequence": 7, + "title": "Dhunche", + "shortTitle": "Dhunche", + "summary": "Flood surge visible from a Dhunche hillside viewpoint.", + "phase": "downstream passage", + "lat": 28.1131861111111, + "lon": 85.2953305555556, + "fallbackElevationM": 1300, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.39 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=r5OYhYnBX6E", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 14.43, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "mailung-upper-trishuli", + "sequence": 8, + "title": "Mailung / Upper Trishuli bridge", + "shortTitle": "Upper Trishuli", + "summary": "A bridge-side view of the surge between Dhunche and Mailung Bazzar.", + "phase": "downstream passage", + "lat": 28.090925, + "lon": 85.2322916666667, + "fallbackElevationM": 950, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.455 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.facebook.com/watch/?v=1291517609615680", + "platform": "Facebook", + "publisher": "Facebook source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 12, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "mailung-bazzar", + "sequence": 9, + "title": "Mailung Bazzar, Dandaguan", + "shortTitle": "Mailung", + "summary": "A wave races along the Mailung Bazzar road corridor.", + "phase": "downstream passage", + "lat": 28.0761666666667, + "lon": 85.2125277777778, + "fallbackElevationM": 800, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.52 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=TREmjWjppII", + "embedEndAtSec": 7, + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 10, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "dandagaun", + "sequence": 10, + "title": "Dandagaun, Rasuwa", + "shortTitle": "Dandagaun", + "summary": "A compact excerpt of the Dandagaun slope-collapse sequence.", + "phase": "downstream passage", + "lat": 28.0751944444444, + "lon": 85.2076944444444, + "fallbackElevationM": 780, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.585 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=neheV9j5SI0", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 13.37, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "dandagaun-viewpoint", + "sequence": 11, + "title": "Dandagaun viewpoint restaurant", + "shortTitle": "Dandagaun viewpoint", + "summary": "A wide valley view of the flash-flood surge from a restaurant.", + "phase": "downstream passage", + "lat": 28.0354, + "lon": 85.1894361111111, + "fallbackElevationM": 700, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.65 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=9mE8Zfj_o2I", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 12, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "betrawati-bazaar", + "sequence": 12, + "title": "Betrawati Bazaar", + "shortTitle": "Betrawati", + "summary": "A landscape view of the Betrawati flood surge.", + "phase": "downstream consequence", + "lat": 27.9758992572246, + "lon": 85.1835331689033, + "fallbackElevationM": 600, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.715 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=qjApXYPD2Ow", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 426, + "height": 240, + "orientation": "landscape", + "fit": "cover", + "clipInSec": 0, + "clipOutSec": 10.44, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "bhainse", + "sequence": 13, + "title": "Bhainse", + "shortTitle": "Bhainse", + "summary": "A source-linked clip placed at Bhainse in the public geolocation map.", + "phase": "downstream consequence", + "lat": 27.9623111111111, + "lon": 85.1739777777778, + "fallbackElevationM": 500, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.747 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated source-map placement", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=nBbDsvYiUg8", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "embedStartAtSec": 14, + "clipOutSec": 11.11, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map, record 44 source 1", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "bidur-trishuli-bridge", + "sequence": 14, + "title": "Bidur - Trishuli bridge", + "shortTitle": "Bidur bridge", + "summary": "Geolocated downstream consequence well beyond the image comparison.", + "phase": "consequence", + "lat": 27.920858, + "lon": 85.148029, + "fallbackElevationM": 510, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.78 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=FE5HRRFhDQg", + "posterUrl": "https://i.ytimg.com/vi/FE5HRRFhDQg/hqdefault.jpg", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 480, + "height": 360, + "orientation": "landscape", + "fit": "cover", + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "devighat-taadi-khola-bridge", + "sequence": 15, + "title": "Devighat / Taadi Khola Bridge", + "shortTitle": "Devighat bridge", + "summary": "A downstream river view at the Devighat bridge area.", + "phase": "downstream consequence", + "lat": 27.8585278, + "lon": 85.1090318, + "fallbackElevationM": 350, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.845 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=nG9um3ksbR4", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 11.11, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + }, + { + "id": "charaudi", + "sequence": 16, + "title": "Charaudi", + "shortTitle": "Charaudi", + "summary": "Floodwater surges through the Charaudi built area.", + "phase": "downstream consequence", + "lat": 27.8032944444444, + "lon": 84.749375, + "fallbackElevationM": 300, + "imageryCoverage": "outside", + "trigger": { + "mode": "phase", + "storyProgress": 0.91 + }, + "timing": { + "capturedAt": null, + "note": "capture time unverified" + }, + "confidence": { + "location": "geolocated", + "time": "unverified" + }, + "media": { + "sourceUrl": "https://www.youtube.com/watch?v=55SrkQilcO8", + "platform": "YouTube", + "publisher": "YouTube source", + "width": 240, + "height": 426, + "orientation": "portrait", + "fit": "contain", + "clipInSec": 0, + "clipOutSec": 10, + "rightsStatus": "Linked original source; media is not redistributed." + }, + "provenance": { + "via": "GeoGeorgeShadrach public geolocation map", + "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371", + "rights": "third-party link only; media not redistributed" + } + } + ] +} diff --git a/public/events/bhote-koshi-2026/post.webp b/public/events/bhote-koshi-2026/post.webp new file mode 100644 index 000000000..b6df1cdf0 Binary files /dev/null and b/public/events/bhote-koshi-2026/post.webp differ diff --git a/public/events/bhote-koshi-2026/pre.webp b/public/events/bhote-koshi-2026/pre.webp new file mode 100644 index 000000000..3bca10941 Binary files /dev/null and b/public/events/bhote-koshi-2026/pre.webp differ diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs new file mode 100644 index 000000000..d84bc18ac --- /dev/null +++ b/scripts/check-import-directions.mjs @@ -0,0 +1,254 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { analyzeModule } from './module-analysis.mjs'; + +const builtins = new Set( + builtinModules.flatMap((name) => [name, `node:${name}`]), +); +const code = /\.[mc]?js$/; +const entry = (file) => + file === 'src/main.js' || file.startsWith('src/standalone/'); +const tests = (file) => + file.endsWith('.test.mjs') || + file.startsWith('src/testSupport/') || + file.startsWith('src/tooling/') || + file === 'src/overlays/worldOverlayAllocation.worker.mjs'; +const source = (file) => + file.startsWith('src/sources/') || + /^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file); +const renderer = (file) => + /^src\/(?:ui|app|standalone|overlays)\//.test(file) || + /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( + file, + ); +// Two compatibility composition entries intentionally select standalone defaults. +const compatibilityEdge = (from, to) => + (from === 'server/providers/local.js' && + to === 'server/standalone/key-setup.js') || + (from === 'src/ui.js' && to === 'src/standalone/catalog.js'); +const portableExport = (key) => + key.startsWith('./sources/') || + /\/source$/.test(key) || + /^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test( + key, + ) || + ['./voice/action-schemas', './voice/session', './data/lifecycle'].includes( + key, + ); + +/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */ +export function checkImportDirections(root) { + root = realpathSync(root); + const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); + const files = [ + ...new Set( + execFileSync( + 'git', + [ + 'ls-files', + '-z', + '--cached', + '--others', + '--exclude-standard', + '--', + 'src', + 'server', + ], + { cwd: root, encoding: 'utf8' }, + ).split('\0'), + ), + ].filter( + (file) => + code.test(file) && !tests(file) && existsSync(path.join(root, file)), + ); + const errors = new Set(); + const modules = new Map(); + const relative = (file) => + path.relative(root, file).split(path.sep).join('/'); + const report = (file, message) => errors.add(`${file}: ${message}`); + function load(file) { + if (modules.has(file)) return modules.get(file); + const resolved = realpathSync(path.join(root, file)); + if (relative(resolved) !== file) { + report( + file, + 'Runtime modules must not alias other owners through symlinks', + ); + return null; + } + let analysis; + try { + analysis = analyzeModule(readFileSync(resolved, 'utf8')); + } catch (error) { + report(file, error.message); + return null; + } + const record = { ...analysis, edges: [], external: [] }; + modules.set(file, record); + for (const specifier of analysis.imports) { + let target; + if (specifier.startsWith('.')) + target = path.resolve(path.dirname(resolved), specifier.split('?')[0]); + else if (specifier === pkg.name || specifier.startsWith(`${pkg.name}/`)) { + const key = + specifier === pkg.name ? '.' : '.' + specifier.slice(pkg.name.length); + const exported = pkg.exports?.[key]; + if (typeof exported !== 'string') { + report(file, `Unsupported self import: ${specifier}`); + continue; + } + target = path.resolve(root, exported); + } else { + record.external.push(specifier); + if ( + file.startsWith('src/') && + (builtins.has(specifier) || specifier.startsWith('node:')) + ) + report(file, `Node builtin in browser module: ${specifier}`); + if ( + specifier.startsWith('/') || + (/^[a-z]+:/i.test(specifier) && !specifier.startsWith('node:')) + ) + report(file, `Use repository or package imports: ${specifier}`); + continue; + } + if (!existsSync(target)) { + report(file, `Missing import: ${specifier}`); + continue; + } + const to = relative(realpathSync(target)); + if (to.startsWith('../') || path.isAbsolute(to)) { + report(file, `Import escapes repository: ${specifier}`); + continue; + } + record.edges.push(to); + if (file.startsWith('src/') && to.startsWith('server/')) + report(file, `Browser imports server: ${to}`); + if ( + !compatibilityEdge(file, to) && + file.startsWith('server/providers/') && + (to.startsWith('server/standalone/') || renderer(to)) + ) + report(file, `Provider imports application/rendering: ${to}`); + if ( + !compatibilityEdge(file, to) && + file.startsWith('src/') && + !entry(file) && + entry(to) + ) + report(file, `Reusable module imports standalone setup: ${to}`); + if (source(file) && renderer(to)) + report(file, `Source imports rendering/application: ${to}`); + if (file === 'src/voice/gevActions.js' && to === 'src/data/manager.js') + report( + file, + 'Actions must consume feed state without the manager facade', + ); + if ( + file === 'src/app/constructCatalog.js' && + to === 'src/data/localGeojson.js' + ) + report( + file, + 'Catalog must use the services owner without compatibility layer construction', + ); + } + return record; + } + for (const file of files) load(file); + // A helper outside src must not smuggle Node or server code into a browser graph. + const browserSeen = new Set(); + function browser(file) { + if (browserSeen.has(file) || !code.test(file)) return; + browserSeen.add(file); + const info = load(file); + if (!info) return; + for (const specifier of info.external) { + if (builtins.has(specifier) || specifier.startsWith('node:')) + report(file, `Browser graph reaches Node builtin: ${specifier}`); + } + for (const to of info.edges) { + if (to.startsWith('server/') || tests(to)) + report(file, `Browser graph reaches non-runtime owner: ${to}`); + else browser(to); + } + } + for (const file of files.filter((file) => file.startsWith('src/'))) + browser(file); + const roots = new Map(files.filter(source).map((file) => [file, 'source'])); + for (const [key, value] of Object.entries(pkg.exports || {})) { + if (portableExport(key) && typeof value === 'string') + roots.set(value.replace(/^\.\//, ''), key); + } + roots.set('src/data/feedState.js', 'feed state'); + for (const [start, label] of roots) { + if (!existsSync(path.join(root, start))) { + report(start, `Missing portable entry: ${label}`); + continue; + } + const seen = new Set(); + function visit(file) { + if (seen.has(file) || !code.test(file)) return; + seen.add(file); + const info = load(file); + if (!info) return; + if (info.browser.length) + report( + file, + `${label} reaches browser globals: ${info.browser.join(', ')}`, + ); + for (const specifier of info.external) { + if ( + builtins.has(specifier) || + specifier.startsWith('node:') || + /^(?:cesium|vite)(?:\/|$)/.test(specifier) + ) + report( + file, + `${label} reaches platform/rendering dependency: ${specifier}`, + ); + } + for (const to of info.edges) { + if (renderer(to) || to.startsWith('server/')) + report(file, `${label} reaches application/rendering: ${to}`); + else visit(to); + } + } + visit(start); + } + // Common voice controls may use their DOM view, but never a protocol implementation. + const voiceSeen = new Set(); + function voice(file) { + if (voiceSeen.has(file)) return; + voiceSeen.add(file); + const info = load(file); + for (const to of info?.edges || []) { + if (/^src\/voice\/realtime/.test(to)) + report(file, `Common voice controls import protocol: ${to}`); + else if (code.test(to)) voice(to); + } + } + if (existsSync(path.join(root, 'src/voice/sessionCommands.js'))) + voice('src/voice/sessionCommands.js'); + if (errors.size) throw new Error([...errors].join('\n')); + return { modules: files.length, portableEntries: roots.size }; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + console.log( + JSON.stringify( + checkImportDirections(fileURLToPath(new URL('../', import.meta.url))), + ), + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/format-runtime.json b/scripts/format-runtime.json new file mode 100644 index 000000000..050b973f8 --- /dev/null +++ b/scripts/format-runtime.json @@ -0,0 +1,3 @@ +{ + "roots": ["src", "server"] +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1b6d251be..b86632108 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -123,6 +123,9 @@ "server/providers/regional/http.js", "server/providers/regional/news.js", "server/providers/regional/place.js", + "src/nominatimGeocode.js", + "src/nominatimGeocode.test.mjs", + "src/tooling/nominatimSearchRoute.test.mjs", "server/providers/regional/query.js", "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", @@ -131,6 +134,11 @@ "src/search/index.js", "src/search/placeSearch.js", "src/search/google.js", + "src/search/coordinateParser.js", + "src/search/coordinateParser.test.mjs", + "src/search/coordinateGeocoder.js", + "src/search/presetGeocoder.js", + "src/search/offlineGeocoders.test.mjs", "src/keylessGeocoder.js", "src/standalone/placeSearch.js", "src/ui/panelDisclosure.js", @@ -150,6 +158,8 @@ "scripts/qa-visual-input.mjs", "src/ui/displayControls.js", "src/ui/displayControls.test.mjs", + "src/ui/cameraOrientationControls.js", + "src/ui/cameraOrientationControls.test.mjs", "src/ui/effects.js", "src/ui/visualEffects.js", "src/ui/visualPresets.js", @@ -170,6 +180,7 @@ "scripts/qa-map-source-controls.mjs", "src/ui/layers.js", "src/ui/layerPanel.js", + "src/ui/layerKeyRequirement.test.mjs", "src/ui/clearLayersControl.js", "scripts/qa-layer-panel.mjs", "scripts/qa-directions.mjs", @@ -440,6 +451,7 @@ "src/data/flowTiles.js", "src/layers/traffic/source.test.mjs", "src/layers/bikeshare/source.test.mjs", + "src/layers/bikeshare/proxyRoute.test.mjs", "scripts/traffic-fixtures.mjs", "src/layers/traffic/navigation.test.mjs", "scripts/qa-traffic-navigation.mjs", @@ -636,5 +648,82 @@ "src/data/directions.js", "src/data/directions.test.mjs", "src/app/layers/directions.js", - "src/layers/directions/index.js" + "src/layers/directions/index.js", + "src/sources/radioBrowser.js", + "src/sources/cctvTypes.js", + "src/sources/protocols.test.mjs", + "src/voice/actionSchemas.js", + "src/voice/actionSchemas.test.mjs", + "server/providers/openai/toolDescriptions.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/realtimeSession.js", + "src/voice/realtimeEvents.js", + "src/voice/session.test.mjs", + "src/layers/alpr/records.js", + "src/layers/earthquakes/records.js", + "src/sources/sourceSlot.js", + "src/data/feedState.js", + "src/app/localGeojsonServices.js", + "src/layers/cctv/sourcePolicy.js", + "scripts/module-analysis.mjs", + "scripts/check-import-directions.mjs", + "src/tooling/importDirections.test.mjs", + "src/sources/reference.js", + "src/sources/reference.test.mjs", + "src/search/nominatim.js", + "src/search/nominatim.test.mjs", + "src/sources/httpBody.js", + "src/sources/overpass.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", + "scripts/format-runtime.json", + "src/tooling/transitService.test.mjs", + "src/sources/overpassFeatures.test.mjs" ] diff --git a/scripts/format.mjs b/scripts/format.mjs index b74dfbdcd..25311ba29 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -1,9 +1,58 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { readFile, writeFile, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import * as prettier from 'prettier'; -/** Check or format the explicitly adopted files, without scanning other source. */ +/** Discover owned runtime code; tests and other files retain explicit adoption. */ +export async function discoverRuntimeFormatFiles(root) { + const configFile = path.join(root, 'scripts/format-runtime.json'); + if (!existsSync(configFile)) return []; + const { roots } = JSON.parse(await readFile(configFile, 'utf8')); + if ( + !Array.isArray(roots) || + !roots.length || + new Set(roots).size !== roots.length || + roots.some( + (name) => + typeof name !== 'string' || + !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(name), + ) + ) + throw new Error( + 'Runtime formatting roots must be unique repository directory paths', + ); + const candidates = execFileSync( + 'git', + [ + 'ls-files', + '-z', + '--cached', + '--others', + '--exclude-standard', + '--', + ...roots, + ], + { cwd: root, encoding: 'utf8' }, + ).split('\0'); + const files = []; + for (const name of new Set(candidates)) { + if ( + !/\.[cm]?js$/.test(name) || + /\.test\.[cm]?js$/.test(name) || + !existsSync(path.join(root, name)) + ) + continue; + const info = await prettier.getFileInfo(path.join(root, name), { + ignorePath: path.join(root, '.prettierignore'), + }); + if (!info.ignored) files.push(name); + } + return files.sort(); +} + +/** Check or format explicit adoption plus automatically discovered runtime code. */ export async function formatAdoptedFiles(root, mode) { if (!['--check', '--write'].includes(mode)) { throw new Error('Usage: node scripts/format.mjs --check|--write'); @@ -22,9 +71,13 @@ export async function formatAdoptedFiles(root, mode) { ); } + const names = [ + ...new Set([...scope, ...(await discoverRuntimeFormatFiles(root))]), + ]; + // Validate the whole scope before any write, including resolved symlink paths. const files = []; - for (const name of scope) { + for (const name of names) { if ( typeof name !== 'string' || !name || @@ -91,7 +144,7 @@ if (import.meta.url === invoked) { process.exitCode = 1; } else { console.log( - `${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`, + `${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} source files.`, ); } } catch (error) { diff --git a/scripts/module-analysis.mjs b/scripts/module-analysis.mjs new file mode 100644 index 000000000..0976159f7 --- /dev/null +++ b/scripts/module-analysis.mjs @@ -0,0 +1,80 @@ +import { parsers } from 'prettier/plugins/babel.mjs'; + +const browserGlobals = new Set([ + 'window', + 'document', + 'navigator', + 'HTMLElement', + 'HTMLCanvasElement', + 'Image', + 'Audio', + 'localStorage', + 'sessionStorage', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'getComputedStyle', +]); + +/** Inspect literal module dependencies and browser-platform references without executing code. */ +export function analyzeModule(source) { + const imports = []; + const browser = new Set(); + function walk(node, parent, field) { + if (!node || typeof node !== 'object') return; + if ( + [ + 'ImportDeclaration', + 'ExportNamedDeclaration', + 'ExportAllDeclaration', + 'ImportExpression', + ].includes(node.type) && + node.source + ) { + if (node.source.type !== 'StringLiteral') + throw new Error( + 'Computed module imports are not allowed in runtime code', + ); + imports.push(node.source.value); + } + if (node.type === 'CallExpression' && node.callee?.name === 'require') + throw new Error('Runtime modules must use ES imports'); + if (node.type === 'Identifier' && browserGlobals.has(node.name)) { + const property = + (parent?.type === 'MemberExpression' || + parent?.type === 'OptionalMemberExpression') && + field === 'property' && + !parent.computed; + const key = + ['ObjectProperty', 'ObjectMethod', 'ClassMethod'].includes( + parent?.type, + ) && + field === 'key' && + !parent.computed; + const declaration = + field === 'id' || + field === 'params' || + parent?.type?.startsWith('Import'); + if (!property && !key && !declaration) browser.add(node.name); + } + if ( + ['MemberExpression', 'OptionalMemberExpression'].includes(node.type) && + ['globalThis', 'self'].includes(node.object?.name) + ) { + const name = node.computed ? node.property?.value : node.property?.name; + if (browserGlobals.has(name)) browser.add(name); + } + for (const [key, value] of Object.entries(node)) { + if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue; + if (Array.isArray(value)) + value.forEach((child) => walk(child, node, key)); + else if (value && typeof value === 'object') walk(value, node, key); + } + } + walk(parsers.babel.parse(source)); + return { imports, browser: [...browser] }; +} + +/** Return static imports, literal dynamic imports and re-exports. */ +export function moduleImports(source) { + return analyzeModule(source).imports; +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 23f7383d4..643edce10 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -44,7 +44,8 @@ "server/providers/vessels/ais-live.js", "src/data/adsbLolFallback.js", "src/data/aisStreamAdapter.js", - "src/data/aisWatchdog.js" + "src/data/aisWatchdog.js", + "src/sources/httpBody.js" ], "external": ["ws"] }, @@ -66,7 +67,9 @@ "server/providers/common/http.js", "scripts/google-server-key.mjs", "src/data/placeProviderPayloads.js", - "src/data/routeSteps.js" + "src/data/routeSteps.js", + "src/sources/httpBody.js", + "src/sources/rateLimit.js" ], "external": [] }, @@ -83,7 +86,8 @@ "server/providers/space/celestrak.js", "server/providers/space/launch-library.js", "server/providers/common/http.js", - "src/data/spaceProviderRequests.js" + "src/data/spaceProviderRequests.js", + "src/sources/httpBody.js" ], "external": [] }, @@ -134,7 +138,8 @@ "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", - "src/data/gbfsSource.js" + "src/data/gbfsSource.js", + "src/sources/httpBody.js" ], "external": [] }, @@ -161,7 +166,9 @@ "server/providers/common/geo.js", "server/providers/places/google-key.js", "scripts/google-server-key.mjs", - "src/data/cctvFootprint.js" + "src/data/cctvFootprint.js", + "src/sources/cctvTypes.js", + "src/sources/httpBody.js" ], "external": [] }, @@ -175,7 +182,9 @@ "server/providers/radio/stations.js", "server/providers/radio/transport.js", "src/data/radioCountry.js", - "server/providers/common/http.js" + "server/providers/common/http.js", + "src/sources/radioBrowser.js", + "src/sources/httpBody.js" ], "external": [] }, @@ -195,7 +204,10 @@ "server/providers/overpass/transport.js", "server/providers/places/routes.js", "src/data/placeProviderPayloads.js", - "src/data/routeSteps.js" + "src/data/routeSteps.js", + "src/sources/httpBody.js", + "src/sources/overpass.js", + "src/sources/rateLimit.js" ], "external": [] }, @@ -212,7 +224,9 @@ "server/providers/military-installations/query.js", "server/providers/overpass/constants.js", "server/providers/overpass/geometry.js", - "server/providers/overpass/transport.js" + "server/providers/overpass/transport.js", + "src/sources/httpBody.js", + "src/sources/rateLimit.js" ], "external": [] }, @@ -231,7 +245,10 @@ "server/providers/regional/query.js", "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", - "src/data/regionalModel.js" + "src/data/regionalModel.js", + "src/nominatimGeocode.js", + "src/sources/httpBody.js", + "src/sources/rateLimit.js" ], "external": [] }, @@ -251,7 +268,10 @@ "server/providers/openai/realtime.js", "server/providers/openai/tools.js", "src/hudSummaryResponse.js", - "src/voice/voiceCost.js" + "src/voice/voiceCost.js", + "src/voice/actionSchemas.js", + "server/providers/openai/toolDescriptions.js", + "src/sources/rateLimit.js" ], "external": [] }, @@ -263,7 +283,7 @@ "server/providers/common/source-root.js", "server/standalone/key-setup.js", "src/keySetupCore.mjs", - "src/keySetupHardening.mjs" + "server/standalone/key-setup-hardening.mjs" ], "external": [] }, @@ -276,7 +296,15 @@ "src/keylessGeocoder.js", "src/search/geospatial.js", "src/search/http.js", - "src/search/defaults.js" + "src/search/defaults.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", + "src/search/presetGeocoder.js", + "src/search/nominatim.js", + "src/nominatimGeocode.js", + "src/data/regionalModel.js", + "src/sources/httpBody.js", + "src/sources/nominatim.js" ], "external": [] }, @@ -349,7 +377,9 @@ "src/ui/layerPanel.js", "src/ui/clearLayersControl.js", "src/loadingFeedback.js", - "src/data/installationFeedback.js" + "src/data/installationFeedback.js", + "src/keySetupCore.mjs", + "src/data/feedState.js" ], "external": [] }, @@ -458,6 +488,7 @@ "src/data/installationFeedback.js", "src/data/layerState.js", "src/data/militaryAwarenessEngine.js", + "src/data/scenePick.js", "src/keySetupCore.mjs", "src/loadingFeedback.js", "src/locationStatus.js", @@ -473,6 +504,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -541,7 +573,8 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", - "src/worldFocus.js" + "src/worldFocus.js", + "src/data/feedState.js" ], "external": ["cesium"] }, @@ -560,6 +593,7 @@ "src/ui/styles/radio.css", "src/ui/styles/cctv.css", "src/ui/styles/scenes.css", + "src/ui/styles/bhote-koshi.css", "src/ui/styles/recording.css", "src/ui/styles/responsive.css", "src/ui/styles/command-dock.css", @@ -575,6 +609,11 @@ "scenes": { "exports": ["./scenes"], "modules": [ + "src/scenes/nepalEvidencePack.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiShotPaths.js", + "src/data/bhoteKoshiFloodPath.js", + "src/maps/catalog.js", "src/scenes/director.js", "src/scenes/recipes.js", "src/scenes/scenePolicy.js", @@ -724,7 +763,8 @@ "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", - "src/layers/earthquakes/source.js" + "src/layers/earthquakes/source.js", + "src/layers/earthquakes/records.js" ], "external": ["cesium"] }, @@ -849,6 +889,7 @@ "traffic-layer": { "exports": ["./layers/traffic"], "modules": [ + "src/data/cameraSensitivity.js", "src/data/flowMatch.js", "src/data/tomtomTiles.js", "src/data/trafficBounds.js", @@ -870,7 +911,8 @@ "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/timing.js", - "src/layers/traffic/viewport.js" + "src/layers/traffic/viewport.js", + "src/sources/overpassRoads.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -891,7 +933,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"] }, @@ -929,7 +972,8 @@ "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js", "src/data/cctvFootprint.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/layers/cctv/sourcePolicy.js" ], "external": ["cesium"] }, @@ -943,7 +987,8 @@ "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", "src/layers/alpr/source.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/layers/alpr/records.js" ], "external": ["cesium"] }, @@ -1047,7 +1092,8 @@ "./voice/control", "./voice/realtime-backend", "./voice/commands", - "./voice/cost" + "./voice/cost", + "./voice/realtime-session" ], "modules": [ "src/voice/realtimeController.js", @@ -1055,7 +1101,11 @@ "src/voice/control.js", "src/voice/layerSummary.js", "src/voice/voiceCost.js", - "src/voice/commands.js" + "src/voice/commands.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/realtimeSession.js", + "src/voice/realtimeEvents.js" ], "external": [] }, @@ -1079,6 +1129,13 @@ "./standalone/catalog" ], "modules": [ + "src/scenes/nepalEvidencePack.js", + "src/data/bhoteKoshiEvent.js", + "src/data/bhoteKoshiEmbeddedMedia.js", + "src/data/bhoteKoshiLocator.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiFloodPath.js", + "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationEngine.js", "src/annotations/annotationResolver.js", "src/annotations/hybridAnnotationRenderer.js", @@ -1110,7 +1167,7 @@ "src/app/layers/traffic.js", "src/app/operations.js", "src/app/scene.js", - "src/app/sourceSlot.js", + "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", "src/app/stateChannel.js", @@ -1490,6 +1547,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -1584,7 +1642,56 @@ "src/data/routeSteps.js", "src/layers/directions/index.js", "src/data/directions.js", - "src/app/layers/directions.js" + "src/app/layers/directions.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", + "src/search/presetGeocoder.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/realtimeSession.js", + "src/voice/realtimeEvents.js", + "src/app/localGeojsonServices.js", + "src/data/feedState.js", + "src/layers/alpr/records.js", + "src/layers/earthquakes/records.js", + "src/layers/cctv/sourcePolicy.js", + "src/sources/reference.js", + "src/search/nominatim.js", + "src/nominatimGeocode.js", + "src/sources/httpBody.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", + "src/layers/transit/source.js", + "src/sources/transitHistory.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js", + "src/sources/overpassRoads.js" ], "external": [ "@mapbox/vector-tile", @@ -1603,6 +1710,12 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/bhoteKoshiEvent.js", + "src/data/bhoteKoshiEmbeddedMedia.js", + "src/data/bhoteKoshiLocator.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiFloodPath.js", + "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationResolver.js", "src/annotations/resolver.js", "src/app/catalog.js", @@ -1623,7 +1736,7 @@ "src/app/layers/satellites.js", "src/app/layers/submarineCables.js", "src/app/layers/traffic.js", - "src/app/sourceSlot.js", + "src/sources/sourceSlot.js", "src/cctvFocusRequest.js", "src/celestialRing.js", "src/cockpitMath.js", @@ -1929,7 +2042,50 @@ "src/data/terrainHeights.js", "src/data/groundFloor.js", "src/data/directions.js", - "src/app/layers/directions.js" + "src/app/layers/directions.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", + "src/search/presetGeocoder.js", + "src/app/localGeojsonServices.js", + "src/layers/alpr/records.js", + "src/layers/earthquakes/records.js", + "src/layers/cctv/sourcePolicy.js", + "src/search/nominatim.js", + "src/nominatimGeocode.js", + "src/data/regionalModel.js", + "src/sources/httpBody.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", + "src/layers/transit/source.js", + "src/sources/transitHistory.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js", + "src/sources/overpassRoads.js" ], "external": [ "cesium", @@ -1946,7 +2102,7 @@ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", "src/app/operations.js", - "src/app/sourceSlot.js", + "src/sources/sourceSlot.js", "src/app/surfaceServices.js", "src/data/dataCredits.js", "src/data/geoid.js", @@ -1970,7 +2126,19 @@ "src/services/groundSnap.js", "src/services/meshFloorSampler.js", "src/services/requests.js", - "src/services/terrainHeights.js" + "src/services/terrainHeights.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", + "src/search/presetGeocoder.js", + "src/search/nominatim.js", + "src/nominatimGeocode.js", + "src/data/regionalModel.js", + "src/sources/httpBody.js", + "src/sources/nominatim.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js" ], "external": [ "cesium", @@ -2102,5 +2270,307 @@ "exports": ["./layers/directions"], "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], "external": ["cesium"] + }, + "radio-browser-source": { + "exports": ["./sources/radio-browser"], + "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], + "external": [] + }, + "cctv-types-source": { + "exports": ["./sources/cctv-types"], + "modules": ["src/sources/cctvTypes.js"], + "external": [] + }, + "regional-source": { + "exports": ["./sources/regional"], + "modules": ["src/data/regionalModel.js"], + "external": [] + }, + "voice-action-schemas": { + "exports": ["./voice/action-schemas"], + "modules": ["src/voice/actionSchemas.js"], + "external": [] + }, + "voice-session": { + "exports": ["./voice/session"], + "modules": ["src/voice/session.js"], + "external": [] + }, + "voice-session-controls": { + "exports": ["./voice/session-controls"], + "modules": [ + "src/voice/sessionCommands.js", + "src/voice/session.js", + "src/voice/control.js" + ], + "external": [] + }, + "alpr-source": { + "exports": ["./layers/alpr/source"], + "modules": [ + "src/layers/alpr/policy.js", + "src/layers/alpr/records.js", + "src/layers/alpr/source.js" + ], + "external": [] + }, + "bikeshare-source": { + "exports": ["./layers/bikeshare/source"], + "modules": ["src/layers/bikeshare/source.js"], + "external": [] + }, + "cctv-source": { + "exports": ["./layers/cctv/source"], + "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], + "external": [] + }, + "earthquakes-source": { + "exports": ["./layers/earthquakes/source"], + "modules": [ + "src/layers/earthquakes/records.js", + "src/layers/earthquakes/source.js" + ], + "external": [] + }, + "firms-source": { + "exports": ["./layers/firms/source"], + "modules": ["src/layers/firms/source.js"], + "external": [] + }, + "installations-source": { + "exports": ["./layers/installations/source"], + "modules": [ + "src/layers/installations/source.js", + "src/data/militaryInstallationData.js" + ], + "external": [] + }, + "launches-source": { + "exports": ["./layers/launches/source"], + "modules": ["src/layers/launches/source.js"], + "external": [] + }, + "radio-source": { + "exports": ["./layers/radio/source"], + "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], + "external": [] + }, + "satellites-source": { + "exports": ["./layers/satellites/source"], + "modules": ["src/layers/satellites/source.js"], + "external": [] + }, + "traffic-source-factory": { + "exports": ["./layers/traffic/source"], + "modules": [ + "src/data/tomtomTiles.js", + "src/layers/traffic/flowDecode.js", + "src/layers/traffic/flowSource.js", + "src/layers/traffic/source.js", + "src/sources/overpassRoads.js" + ], + "external": ["@mapbox/vector-tile", "pbf"] + }, + "reference-sources": { + "exports": ["./sources/reference", "./layers/submarine-cables/source"], + "modules": [ + "src/sources/reference.js", + "src/layers/earthquakes/source.js", + "src/layers/earthquakes/records.js", + "src/layers/submarineCables/bundledSource.js" + ], + "external": [] + }, + "nominatim-provider": { + "exports": ["./search/nominatim"], + "modules": [ + "src/search/nominatim.js", + "src/nominatimGeocode.js", + "src/search/google.js", + "src/search/geospatial.js", + "src/data/regionalModel.js", + "src/sources/httpBody.js", + "src/sources/nominatim.js" + ], + "external": [] + }, + "source-http-body": { + "exports": ["./sources/http-body"], + "modules": ["src/sources/httpBody.js"], + "external": [] + }, + "overpass-lexer": { + "exports": ["./sources/overpass"], + "modules": ["src/sources/overpass.js"], + "external": [] + }, + "nominatim-source": { + "exports": ["./sources/nominatim"], + "modules": [ + "src/sources/nominatim.js", + "src/nominatimGeocode.js", + "src/search/geospatial.js", + "src/data/regionalModel.js", + "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", + "src/sources/transitHistory.js", + "src/sources/transitService.js", + "src/sources/transitHistoryStore.js", + "src/sources/rateLimit.js" + ], + "external": ["pbf"] + }, + "transit-source": { + "exports": ["./sources/transit"], + "modules": [ + "src/data/gtfsRealtime.js", + "src/data/transitFeeds.js", + "src/data/transitProxy.js", + "src/sources/transitHistory.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", + "src/layers/transit/source.js", + "src/sources/transitHistory.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.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", + "src/layers/transit/source.js", + "src/sources/transitHistory.js" + ], + "external": ["cesium", "pbf"] + }, + "transit-source-factory": { + "exports": ["./layers/transit/source"], + "modules": [ + "src/layers/transit/source.js", + "src/sources/transitHistory.js" + ], + "external": [] + }, + "transit-service": { + "exports": ["./sources/transit-service"], + "modules": [ + "src/data/transitFeeds.js", + "src/data/transitProxy.js", + "src/data/gtfsRealtime.js", + "src/sources/httpBody.js", + "src/sources/transitHistory.js", + "src/sources/transitService.js", + "src/sources/transitHistoryStore.js", + "src/sources/rateLimit.js" + ], + "external": ["pbf"] + }, + "map-feature-source": { + "exports": ["./sources/map-features"], + "modules": [ + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js" + ], + "external": [] } } diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 38382f8c4..2a04d5595 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -85,6 +85,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { const dm = gev.dataManager; await dm.setEnabled('traffic', true); const mod = dm.layers.get('traffic').module; + const beforeUpdate = mod.getStats().lastUpdate; const ell = gev.viewer.scene.globe.ellipsoid; const d2r = Math.PI / 180; // The app's intro flyTo animation clobbers a setView issued mid-flight — @@ -98,7 +99,9 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { for (let i = 0; i < tS; i++) { await new Promise((r) => setTimeout(r, 1000)); s = mod.getStats(); - if (s.count >= minC && !s.loading) break; + // Retained roads from the previous city can satisfy count/loading before + // the move debounce starts its request. Require the destination render. + if (s.lastUpdate !== beforeUpdate && s.count >= minC && !s.loading) break; } return s; }, view, minCount, timeoutS); 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/scripts/track-regression.mjs b/scripts/track-regression.mjs index f6e6d1121..b5f6286e5 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -2860,6 +2860,55 @@ async function main() { tick(); setTimeout(() => { done = true; res(); }, ms); }); + // A timer can expire while the camera is new but the model/cache still + // belongs to the old frame. Observe the first completed exit frame, + // including a wrong result; never wait for visibility or height to pass. + window.__dfObserveTrackedExit = async ({ scene, trackedEntity, trackedModel, + getTrackedModel, getTrackedEntity, getTrackedId, getCameraHeight, + exitHeight, read, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) => { + const deadline = now() + 5000; + let removePre, removePost, removeError, timer; + let preFrame = null; + try { + return await new Promise((resolve) => { + let finished = false; + const finish = (result) => { if (!finished) { finished = true; resolve(result); } }; + const valid = () => { + try { + if (now() > deadline) return 'tracked exit observation exceeded its 5000 ms deadline'; + if (getTrackedEntity() !== trackedEntity || getTrackedId() !== 'aaa097') return 'tracked exit fixture identity changed'; + if (!trackedModel || getTrackedModel() !== trackedModel) return 'tracked exit model identity changed'; + if (!(getCameraHeight() > exitHeight)) return 'tracked exit camera left the exit regime'; + return null; + } catch { return 'tracked exit fixture validation failed'; } + }; + removePre = scene.preUpdate.addEventListener(() => { + if (finished) return; + const error = valid(); + if (error) { finish({ error }); return; } + if (preFrame == null) preFrame = scene.frameState.frameNumber; + }); + removePost = scene.postRender.addEventListener(() => { + if (finished || preFrame == null) return; + const error = valid(); + if (error) { finish({ error }); return; } + if (scene.frameState.frameNumber === preFrame) { + finish({ error: 'tracked exit observation has no new rendered frame' }); return; + } + try { + const snapshot = read(); + finish(snapshot ? { ...snapshot, observationFrame: scene.frameState.frameNumber } + : { error: 'tracked exit snapshot missing' }); + } catch { finish({ error: 'tracked exit snapshot failed' }); } + }); + removeError = scene.renderError.addEventListener(() => finish({ error: 'tracked exit render failed' })); + timer = setTimer(() => finish({ error: 'tracked exit update did not complete within 5000 ms' }), 5000); + scene.requestRender(); + }); + } finally { + clearTimer(timer); removePre?.(); removePost?.(); removeError?.(); + } + }; // Cesium Models in the scene, split by visibility (duck-typed the same // way the harness excludes them from height probes). // @@ -3378,10 +3427,30 @@ async function main() { // the billboard — which MUST be floored, or a tracked grounded contact // renders buried at any distance the operator pulls out to. This is the // coverage the toggle used to reach; the zoom regime reaches it now. + const findOwnModel = () => { + let found = null; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if (typeof item?.get === 'function' && typeof item.length === 'number') walk(item); + else if (item?.id === 'aaa097' && item.activeAnimations !== undefined + && item.minimumPixelSize !== undefined) found = item; + } + }; + walk(v.scene.primitives); + return found; + }; + const trackedEntity = v.trackedEntity; + const trackedModel = findOwnModel(); const achievedOut = await window.__dfZoomAbove(window.__dfRegime.exit + 5000); - await window.__dfSettle(1500); - const zoomedOut = readTracked(); - if (!zoomedOut) return { error: 'tracked entity has no position (zoomed out)' }; + const zoomedOut = await window.__dfObserveTrackedExit({ + scene: v.scene, trackedEntity, trackedModel, getTrackedModel: findOwnModel, + getTrackedEntity: () => v.trackedEntity, + getTrackedId: () => fl.getTrackedInfo()?.icao24, + getCameraHeight: window.__dfCamH, exitHeight: window.__dfRegime.exit, + read: readTracked, + }); + if (zoomedOut.error) return { error: zoomedOut.error }; const out = { rawH: d0.h, @@ -3643,11 +3712,10 @@ async function main() { } // ---- F3: the LOADING window is billboard-owned, not model-owned ------ - // A fleet model is registered with Cesium's default show=true the instant - // its glTF resolves, but the handoff waits for `ready`; and a tracked - // model is null for the whole load even though the regime is already - // active. Both windows leave the BILLBOARD as the visual, so both must - // stay floored — ownership means actually rendering. + // Stress the fleet ownership predicate with synthetic show=true/ready=false + // flags. Production admission keeps the model hidden; these shadows do + // not simulate Cesium's internal loading/draw state. The tracked check + // below separately samples the no-rendering-model window. const dfLoading = await evalPage(async () => { const v = window.__godsEyeView.viewer; const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js'); @@ -3663,13 +3731,8 @@ async function main() { }; const out = {}; - // (a) FLEET: reach the real admitted-but-not-ready window. A fleet model - // is registered in `_models` with Cesium's default show=true the moment - // its glTF resolves, while the billboard handoff waits for `ready` — so - // for a tick the model claims the visual it is not drawing. That window - // is ~one fleet tick wide, so hold the state open by shadowing the two - // flags on the instance (the same defineProperty trick the ground-3d - // group uses on `tilesLoaded`). + // (a) FLEET: retain the adversarial flags until this contact has passed + // through its installed fleet update and that frame has completed. const findFleetModel = (id) => { let found = null; const walk = (coll) => { @@ -3698,8 +3761,26 @@ async function main() { // assigns `model.show = false` for a not-ready model, and assigning to a // non-writable own property throws in strict mode — which lands inside // Cesium's render loop and stops rendering for the rest of the run. + const priorReady = Object.getOwnPropertyDescriptor(fleetModel, 'ready'); + const priorShow = Object.getOwnPropertyDescriptor(fleetModel, 'show'); + let armed = false; + let processedFrame = null; + let sample = null; + let removePostRender = null; + try { Object.defineProperty(fleetModel, 'ready', { get: () => false, set: () => {}, configurable: true }); - Object.defineProperty(fleetModel, 'show', { get: () => true, set: () => {}, configurable: true }); + Object.defineProperty(fleetModel, 'show', { + get: () => true, + set: (value) => { + // Both the not-ready handoff and horizon cull clear show AFTER + // computing this contact's display floor. Neither a timer nor an + // unrelated rendered frame proves that this aircraft was processed. + if (armed && value === false && processedFrame == null) { + processedFrame = v.scene.frameState.frameNumber; + } + }, + configurable: true, + }); const bb = window.__dfFindBB('aaa097'); if (!bb) return { error: 'aaa097 billboard missing' }; bb.show = true; // the handoff would not have hidden it: the model is not ready @@ -3713,21 +3794,45 @@ async function main() { fl._clearDisplayFloorStateForTest(); const seededFleet = d0.h + 45; floorAround(d0, seededFleet); - // reportMeshFloorCell is a direct test seam and does not schedule a - // Cesium frame. Force the CallbackProperty to re-evaluate before the - // assertion, matching the request-render fix in the retained-model - // scenario above. - v.scene.requestRender(); - await window.__dfSettle(900); - const bbAfter = window.__dfFindBB('aaa097'); - out.fleetModelReady = fleetModel.ready; - out.fleetModelShow = fleetModel.show; - out.fleetBillboardVisible = !!bbAfter?.show; - out.fleetSeeded = seededFleet; - out.fleetH = bbAfter ? window.__dfCarto(bbAfter.position).h : null; - delete fleetModel.ready; - delete fleetModel.show; - fl.setParams({ models3d: false }); + const tickDeadline = Date.now() + 5000; + removePostRender = v.scene.postRender.addEventListener(() => { + if (sample || processedFrame == null || v.scene.frameState.frameNumber < processedFrame) return; + if (Date.now() > tickDeadline) { + sample = { error: 'fleet loading fixture completed after its 5000 ms deadline' }; + return; + } + const currentBb = window.__dfFindBB('aaa097'); + const currentModel = findFleetModel('aaa097'); + sample = { + fleetTickCompleted: true, + fleetFrame: v.scene.frameState.frameNumber, + fleetModelReady: fleetModel.ready, + fleetModelShow: fleetModel.show, + fleetBillboardVisible: !!currentBb?.show, + fleetSeeded: seededFleet, + fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null, + }; + if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed() + || fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame'; + }); + armed = true; + // This is a setup-completion deadline, not a visual latency budget. + // Take the FIRST completed fleet result, even when its height is wrong; + // never poll the height until the assertion happens to pass. + do { + await window.__dfSettle(50); + } while (!sample && Date.now() < tickDeadline); + if (!sample) return { error: 'fleet loading fixture did not complete an aircraft update within 5000 ms' }; + Object.assign(out, sample); + } finally { + armed = false; + removePostRender?.(); + if (priorReady) Object.defineProperty(fleetModel, 'ready', priorReady); + else delete fleetModel.ready; + if (priorShow) Object.defineProperty(fleetModel, 'show', priorShow); + else delete fleetModel.show; + fl.setParams({ models3d: false }); + } await window.__dfSettle(400); // (b) TRACKED, regime ACTIVE but no model yet. Turning 3D on while @@ -3771,7 +3876,8 @@ async function main() { skip('display-floor/loading: fleet model loading window', dfLoading.skipped); } else { record('display-floor/loading: a fleet model that is shown-but-not-ready does not own the visual', - !dfLoading.error && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false + !dfLoading.error && dfLoading.fleetTickCompleted === true + && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false && dfLoading.fleetBillboardVisible === true && Number.isFinite(dfLoading.fleetH) && dfLoading.fleetH >= dfLoading.fleetSeeded + DISPLAY_FLOOR_LIFT_M - 0.5, @@ -3955,6 +4061,7 @@ async function main() { delete window.__dfPriorTilesLoadedDescriptor; delete window.__dfSkinSamples; delete window.__dfSkinM; + delete window.__dfObserveTrackedExit; await fl.update(v); }); diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index ffe025b30..b564ef440 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -1,3 +1,8 @@ +import { + normalizeFeedType, + isVideoFeedType, +} from '../../../src/sources/cctvTypes.js'; +export { normalizeFeedType, isVideoFeedType }; import { directionToHeading } from '../../../src/data/directionText.js'; import { haversineKm } from '../common/geo.js'; /** @@ -31,35 +36,6 @@ export function escapeXml(text) { .replace(/'/g, '''); } -/** - * Canonicalize a CCTV feed type string to one of: - * 'image', 'mjpeg', 'mp4', 'webm', 'hls', or pass-through. - * - * @param {string} value - Raw feed type (e.g. 'jpeg', 'mjpg', 'video', 'stream'). - * @returns {string} Normalized feed type. - */ -export function normalizeFeedType(value) { - const raw = String(value || '') - .trim() - .toLowerCase(); - if (!raw) return 'image'; - if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image'; - if (raw === 'mjpg') return 'mjpeg'; - if (raw === 'video') return 'mp4'; - if (raw === 'stream') return 'hls'; - return raw; -} - -/** - * Check whether a normalized feed type represents streaming video. - * - * @param {string} feedType - * @returns {boolean} - */ -export function isVideoFeedType(feedType) { - return feedType === 'mp4' || feedType === 'webm' || feedType === 'hls'; -} - /** * Coerce a value to a finite number, returning fallback if NaN/Infinity. * diff --git a/server/providers/common/http.js b/server/providers/common/http.js index 561d19cf8..c80c29115 100644 --- a/server/providers/common/http.js +++ b/server/providers/common/http.js @@ -1,74 +1,9 @@ -/** - * Read a fetch() Response body as text with a hard byte cap. Rejects early on an - * oversized Content-Length, then streams with a running cap so a chunked or - * length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }. - */ -export async function readResponseTextCapped(response, maxBytes, signal) { - const tooLarge = () => - Object.assign(new Error('Upstream response too large'), { - code: 'RESPONSE_TOO_LARGE', - }); - const declared = Number(response.headers.get('content-length')); - if (Number.isFinite(declared) && declared > maxBytes) { - void response.body?.cancel().catch(() => {}); - throw tooLarge(); - } - signal?.throwIfAborted(); - const reader = response.body?.getReader?.(); - if (!reader) { - const text = await response.text(); - signal?.throwIfAborted(); - if (Buffer.byteLength(text) > maxBytes) throw tooLarge(); - return text; - } - const cancel = () => { - void reader.cancel().catch(() => {}); - }; - signal?.addEventListener('abort', cancel, { once: true }); - const decoder = new TextDecoder(); - let out = ''; - let total = 0; - try { - for (;;) { - signal?.throwIfAborted(); - const { done, value } = await reader.read(); - signal?.throwIfAborted(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) throw tooLarge(); - out += decoder.decode(value, { stream: true }); - } - return out + decoder.decode(); - } catch (error) { - cancel(); - throw error; - } finally { - signal?.removeEventListener('abort', cancel); - reader.releaseLock(); - } -} - -/** Parse a fetch() JSON response only after enforcing a hard byte cap. */ -export async function readResponseJsonCapped(response, maxBytes) { - return JSON.parse(await readResponseTextCapped(response, maxBytes)); -} - -/** - * Return the existing promise for a cache key, or create one and remove it - * only when that exact promise settles. - */ -export function coalesceProxyRequest(inFlight, key, create) { - const existing = inFlight.get(key); - if (existing) return { promise: existing, shared: true }; - let promise; - promise = Promise.resolve() - .then(create) - .finally(() => { - if (inFlight.get(key) === promise) inFlight.delete(key); - }); - inFlight.set(key, promise); - return { promise, shared: false }; -} +export { + readResponseTextCapped, + readResponseJsonCapped, + readResponseBytesCapped, + coalesceProxyRequest, +} from '../../../src/sources/httpBody.js'; /** * Read a fetch Response body as text while enforcing a hard byte cap during diff --git a/server/providers/common/rate-limit.js b/server/providers/common/rate-limit.js index 3d7c0b9f8..4b5f967c3 100644 --- a/server/providers/common/rate-limit.js +++ b/server/providers/common/rate-limit.js @@ -1,38 +1,5 @@ -/** - * Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard - * security boundary (dev-only), just a backstop so a runaway client can't hammer - * the public Overpass / OSRM mirrors or exhaust this process. - */ -const RATE_LIMITER_MAX_KEYS = 2000; - -export function makeRateLimiter({ windowMs, max, globalMax }) { - const hits = new Map(); // key -> number[] (timestamps within window) - let globalTimes = []; // all hits in window, for the global backstop - return function allow(key) { - const now = Date.now(); - globalTimes = globalTimes.filter((t) => now - t < windowMs); - if (globalMax && globalTimes.length >= globalMax) return false; // global backstop - const recent = (hits.get(key) || []).filter((t) => now - t < windowMs); - if (recent.length >= max) { - hits.set(key, recent); - return false; - } - recent.push(now); - hits.set(key, recent); - globalTimes.push(now); - // Hard key cap so a key-rotating caller can't grow the map without bound. - if (hits.size > RATE_LIMITER_MAX_KEYS) { - const oldest = hits.keys().next().value; - if (oldest !== undefined) hits.delete(oldest); - } - if (hits.size > 256) { - for (const [k, v] of hits) { - if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k); - } - } - return true; - }; -} +import { makeRateLimiter } from '../../../src/sources/rateLimit.js'; +export { makeRateLimiter } from '../../../src/sources/rateLimit.js'; /** * Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google). diff --git a/server/providers/local.js b/server/providers/local.js index 52a48f6d8..68f61a3cb 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -7,11 +7,13 @@ import { adsbdbProxy } from './aircraft/enrichment.js'; import { overpassProxy } from './overpass.js'; import { militaryInstallationsProxy } from './military-installations.js'; import { regionalBriefProxy } from './regional/briefing.js'; +import { geocodeProxy } from './regional/place.js'; import { weatherEffectsProxy } from './regional/weather-effects.js'; 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'; @@ -32,10 +34,12 @@ function localProviderPlugins() { overpassProxy(), militaryInstallationsProxy(), regionalBriefProxy(), + geocodeProxy(), weatherEffectsProxy(), cctvProxy({ sourceRoot: defaultSourceRoot }), radioBrowserProxy(), gbfsProxy(), + transitProxy(), adsbLolProxy(), aisLiveProxy(), trackBackfillProxies(), diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js new file mode 100644 index 000000000..9a4d1ec9e --- /dev/null +++ b/server/providers/openai/toolDescriptions.js @@ -0,0 +1,574 @@ +export const ACTION_DESCRIPTIONS = { + fly_to_location: { + description: + "Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.", + $position: 1, + parameters: { + properties: { + locationId: { + description: + 'Known city preset ID. Use when the requested place matches one of these cities.', + $position: 2, + }, + query: { + description: + 'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".', + $position: 1, + }, + viewMode: { + description: + 'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.', + $position: 2, + }, + rangeM: { + description: + 'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.', + $position: 3, + }, + waitForArrival: { + description: + 'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.', + $position: 1, + }, + }, + }, + }, + select_nearest_aircraft: { + description: + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + $position: 1, + parameters: { + properties: { + layerId: { + description: + 'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.', + $position: 2, + }, + locationId: { + description: + 'Known city preset ID when the place matches one of these cities.', + $position: 2, + }, + locationQuery: { + description: 'Free-form destination when no locationId matches.', + $position: 2, + }, + }, + }, + }, + adjust_camera_zoom: { + description: + 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.', + $position: 1, + parameters: { + properties: { + amount: { + description: + 'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".', + $position: 2, + }, + }, + }, + }, + zoom_to_globe: { + description: + 'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.', + $position: 1, + }, + set_layer_visibility: { + description: "Enable or disable one registered God's Eye View data layer.", + $position: 1, + parameters: { + properties: { + layerId: { + description: + 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.', + $position: 1, + }, + }, + }, + }, + show_data_layers_menu: { + description: + 'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.', + $position: 1, + parameters: { + properties: { + layerId: { + description: 'Optional layer row to scroll into view and highlight.', + $position: 2, + }, + }, + }, + }, + set_panel_open: { + description: 'Open or close a GEV UI panel/dropdown.', + $position: 1, + }, + set_context_mode: { + description: + 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + $position: 1, + parameters: { + properties: { + mode: { + description: 'Use off to exit context mode.', + $position: 2, + }, + }, + }, + }, + control_cockpit: { + description: + 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + $position: 1, + parameters: { + properties: { + action: { + description: + 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + $position: 2, + }, + targetLayer: { + description: + 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + $position: 2, + }, + aircraftClass: { + description: + 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + $position: 1, + }, + }, + }, + }, + set_visual_style: { + description: "Set the active God's Eye View visual filter/style.", + $position: 1, + }, + get_entity_context: { + description: + 'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.', + $position: 1, + parameters: { + properties: { + scope: { + description: + 'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.', + $position: 2, + }, + layerId: { + description: 'Optional layer filter for visible entity context.', + $position: 2, + }, + }, + }, + }, + get_current_view_state: { + description: + 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', + $position: 1, + }, + set_hud: { + description: + 'Control the intelligence HUD overlay: visibility and/or layout variant.', + $position: 1, + parameters: { + properties: { + visible: { + description: 'auto restores style-driven show/hide.', + $position: 2, + }, + }, + }, + }, + set_detection: { + description: + 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.', + $position: 1, + parameters: { + properties: { + enabled: { + description: + 'false turns detection OFF; true restores the current density-derived profile.', + $position: 1, + }, + densityPct: { + description: + 'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.', + $position: 1, + }, + allocationStrategy: { + description: + 'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.', + $position: 2, + }, + }, + }, + }, + set_map_stack: { + description: + 'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).', + $position: 1, + parameters: { + properties: { + stack: { + description: + 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.', + $position: 2, + }, + }, + }, + }, + set_post_processing: { + description: + 'Control bloom and sharpen post-processing toggles and intensities.', + $position: 1, + parameters: { + properties: { + bloom: { + properties: { + intensityPct: { + description: '0-200 (UI percent).', + $position: 1, + }, + }, + }, + sharpen: { + properties: { + intensityPct: { + description: '0-100 (UI percent).', + $position: 1, + }, + }, + }, + }, + }, + }, + control_scene: { + description: + 'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.', + $position: 1, + parameters: { + properties: { + sceneId: { + description: 'Scene id or (partial) title for play.', + $position: 1, + }, + }, + }, + }, + control_cctv: { + description: + 'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.', + $position: 1, + parameters: { + properties: { + cameraQuery: { + description: 'Camera name or id for select.', + $position: 1, + }, + enabled: { + description: + 'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.', + $position: 1, + }, + }, + }, + }, + control_radio: { + description: + 'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.', + $position: 1, + parameters: { + properties: { + action: { + description: + 'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.', + $position: 2, + }, + volumePct: { + description: + 'Required for volume; sets the persistent Radio playback volume.', + $position: 3, + }, + category: { + description: + 'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.', + $position: 2, + }, + locationId: { + description: 'Known nearby-city anchor for select.', + $position: 2, + }, + locationQuery: { + description: + 'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.', + $position: 2, + }, + country: { + description: + 'Country code or name filter, for example US or United States.', + $position: 2, + }, + stationQuery: { + description: 'Optional station name/tag substring.', + $position: 2, + }, + }, + }, + }, + track_entity: { + description: + 'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.', + $position: 1, + parameters: { + properties: { + query: { + description: + 'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.', + $position: 1, + }, + layerId: { + description: + 'Optional layer hint: flights | military | ais-live-vessels | satellites.', + $position: 1, + }, + }, + }, + }, + stop_tracking: { + description: + 'Stop following the tracked aircraft/satellite and clear any selected vessel.', + $position: 1, + }, + frame_overhead: { + description: + 'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.', + $position: 1, + parameters: { + properties: { + radiusKm: { + description: + 'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.', + $position: 1, + }, + }, + }, + }, + annotate_map: { + description: + 'Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.', + $position: 1, + parameters: { + properties: { + annotations: { + description: + 'One or more things to mark. Mark multiple related places together when describing them as a group.', + $position: 1, + items: { + properties: { + type: { + description: + 'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.', + $position: 2, + }, + target: { + description: + 'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.', + $position: 2, + }, + points: { + description: + 'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).', + $position: 1, + items: { + properties: { + target: { + description: 'Waypoint place name.', + $position: 2, + }, + }, + }, + }, + mode: { + description: + 'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.', + $position: 2, + }, + latitude: { + description: + 'Explicit latitude (use only if no good place name exists).', + $position: 3, + }, + toTarget: { + description: 'For type=arrow: the destination place name.', + $position: 2, + }, + label: { + description: + 'Short caption shown on the map (a few words). Optional.', + $position: 2, + }, + color: { + description: + 'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.', + $position: 2, + }, + footprint: { + description: + 'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.', + $position: 1, + }, + intent: { + description: + 'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.', + $position: 2, + }, + entityKind: { + description: + 'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).', + $position: 2, + }, + screenX: { + description: + 'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.', + $position: 3, + }, + screenY: { + description: + 'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.', + $position: 3, + }, + toScreenX: { + description: + 'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).', + $position: 3, + }, + toScreenY: { + description: + 'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).', + $position: 3, + }, + }, + }, + }, + flyTo: { + description: + 'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.', + $position: 1, + }, + persist: { + description: + 'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).', + $position: 1, + }, + }, + }, + }, + clear_annotations: { + description: + 'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.', + $position: 1, + }, + move_camera: { + description: + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + $position: 1, + parameters: { + properties: { + direction: { + description: + 'Required except for orbit (defaults right/clockwise) and stop.', + $position: 2, + }, + mode: { + description: + 'once = bounded eased nudge (default); continuous = until stop/manual input.', + $position: 2, + }, + }, + }, + }, + fly_route: { + description: + 'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.', + $position: 1, + parameters: { + properties: { + label: { + description: 'Match an existing route mark by (partial) label.', + $position: 1, + }, + }, + }, + }, + analyst_query: { + description: + 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', + $position: 1, + parameters: { + properties: { + layers: { + description: + 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.', + $position: 2, + }, + scope: { + description: + 'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.', + $position: 2, + properties: { + name: { + description: + 'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").', + $position: 1, + }, + km: { + description: 'For kind=radius.', + $position: 1, + }, + }, + }, + filters: { + description: + 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).', + $position: 1, + }, + sortBy: { + description: 'Field to rank by, or "distance" for nearest-first.', + $position: 1, + }, + followUp: { + description: + 'true = re-query the PREVIOUS result set instead of fresh data.', + $position: 1, + }, + }, + }, + }, + next_iss_pass: { + description: + "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.", + $position: 1, + parameters: { + properties: { + latitude: { + description: + 'Optional observer latitude. Omit to use the current camera position.', + $position: 3, + }, + longitude: { + description: + 'Optional observer longitude. Omit to use the current camera position.', + $position: 3, + }, + minElevationDeg: { + description: + 'Minimum peak elevation (deg) to count as a pass. Default 10.', + $position: 3, + }, + }, + }, + }, +}; diff --git a/server/providers/openai/tools.js b/server/providers/openai/tools.js index 612bf3174..a1bbc4745 100644 --- a/server/providers/openai/tools.js +++ b/server/providers/openai/tools.js @@ -1,978 +1,4 @@ -const GEV_REALTIME_TOOLS = [ - { - type: 'function', - name: 'fly_to_location', - description: - "Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.", - parameters: { - type: 'object', - additionalProperties: false, - properties: { - locationId: { - type: 'string', - enum: [ - 'austin', - 'sf', - 'nyc', - 'tokyo', - 'london', - 'paris', - 'dubai', - 'dc', - ], - description: - 'Known city preset ID. Use when the requested place matches one of these cities.', - }, - query: { - type: 'string', - description: - 'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".', - }, - latitude: { type: 'number', minimum: -90, maximum: 90 }, - longitude: { type: 'number', minimum: -180, maximum: 180 }, - viewMode: { - type: 'string', - enum: ['close', 'overview'], - description: - 'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.', - }, - rangeM: { - type: 'number', - minimum: 100, - maximum: 20000000, - description: - 'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.', - }, - waitForArrival: { - type: 'boolean', - description: - 'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.', - }, - }, - }, - }, - { - type: 'function', - name: 'select_nearest_aircraft', - description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - layerId: { - type: 'string', - enum: ['flights', 'military'], - description: - 'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.', - }, - locationId: { - type: 'string', - enum: [ - 'austin', - 'sf', - 'nyc', - 'tokyo', - 'london', - 'paris', - 'dubai', - 'dc', - ], - description: - 'Known city preset ID when the place matches one of these cities.', - }, - locationQuery: { - type: 'string', - maxLength: 160, - description: 'Free-form destination when no locationId matches.', - }, - latitude: { type: 'number', minimum: -90, maximum: 90 }, - longitude: { type: 'number', minimum: -180, maximum: 180 }, - }, - required: ['layerId'], - }, - }, - { - type: 'function', - name: 'adjust_camera_zoom', - description: - 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - direction: { - type: 'string', - enum: ['in', 'out'], - }, - amount: { - type: 'string', - enum: ['little', 'medium', 'lot'], - description: - 'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".', - }, - }, - required: ['direction', 'amount'], - }, - }, - { - type: 'function', - name: 'zoom_to_globe', - description: - 'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.', - parameters: { - type: 'object', - additionalProperties: false, - properties: {}, - }, - }, - { - type: 'function', - name: 'set_layer_visibility', - description: "Enable or disable one registered God's Eye View data layer.", - parameters: { - type: 'object', - additionalProperties: false, - properties: { - layerId: { - type: 'string', - description: - 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.', - enum: [ - 'flights', - 'military', - 'earthquakes', - 'satellites', - 'rocket-launches', - 'traffic', - 'cctv', - 'radio', - 'bikeshare', - 'ais-live-vessels', - 'local-datacenters', - 'local-dams', - 'telegeography-submarine-cables', - 'local-firms', - 'alpr-cameras', - ], - }, - enabled: { type: 'boolean' }, - }, - required: ['layerId', 'enabled'], - }, - }, - { - type: 'function', - name: 'show_data_layers_menu', - description: - 'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - layerId: { - type: 'string', - enum: [ - 'flights', - 'military', - 'earthquakes', - 'satellites', - 'traffic', - 'cctv', - 'radio', - 'bikeshare', - 'ais-live-vessels', - 'local-datacenters', - 'local-dams', - 'telegeography-submarine-cables', - 'local-firms', - 'alpr-cameras', - ], - description: 'Optional layer row to scroll into view and highlight.', - }, - }, - }, - }, - { - type: 'function', - name: 'set_panel_open', - description: 'Open or close a GEV UI panel/dropdown.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - panelId: { - type: 'string', - enum: [ - 'data-panel', - 'location-bar', - 'control-panel', - 'cctv-panel', - 'radio-panel', - 'scene-panel', - 'pp-toggles', - 'global-context-panel', - ], - }, - open: { type: 'boolean' }, - }, - required: ['panelId', 'open'], - }, - }, - { - type: 'function', - name: 'set_context_mode', - description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - mode: { - type: 'string', - enum: ['off', 'contacts', 'flights', 'space-missions', 'missions'], - description: 'Use off to exit context mode.', - }, - }, - required: ['mode'], - }, - }, - { - type: 'function', - name: 'control_cockpit', - description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - action: { - type: 'string', - enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'], - description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', - }, - targetLayer: { - type: 'string', - enum: [ - 'flights', - 'military', - 'ais-live-vessels', - 'military-installations', - ], - description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', - }, - aircraftClass: { - type: 'string', - description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', - }, - }, - required: ['action'], - }, - }, - { - type: 'function', - name: 'set_visual_style', - description: "Set the active God's Eye View visual filter/style.", - parameters: { - type: 'object', - additionalProperties: false, - properties: { - style: { - type: 'string', - enum: [ - 'normal', - 'retro', - 'surveillance', - 'thermal', - 'anime', - 'noir', - 'snow', - ], - }, - }, - required: ['style'], - }, - }, - { - type: 'function', - name: 'get_entity_context', - description: - 'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - scope: { - type: 'string', - enum: ['auto', 'selected', 'in_view'], - description: - 'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.', - }, - layerId: { - type: 'string', - enum: [ - 'local-datacenters', - 'local-dams', - 'telegeography-submarine-cables', - 'local-firms', - ], - description: 'Optional layer filter for visible entity context.', - }, - limit: { - type: 'number', - minimum: 1, - maximum: 12, - }, - }, - }, - }, - { - type: 'function', - name: 'get_current_view_state', - description: - 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', - parameters: { - type: 'object', - additionalProperties: false, - properties: {}, - }, - }, - { - type: 'function', - name: 'set_hud', - description: - 'Control the intelligence HUD overlay: visibility and/or layout variant.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - visible: { - type: 'string', - enum: ['on', 'off', 'auto'], - description: 'auto restores style-driven show/hide.', - }, - layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] }, - }, - }, - }, - { - type: 'function', - name: 'set_detection', - description: - 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - enabled: { - type: 'boolean', - description: - 'false turns detection OFF; true restores the current density-derived profile.', - }, - mode: { type: 'string', enum: ['sparse', 'balanced', 'dense'] }, - densityPct: { - type: 'number', - description: - 'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.', - }, - allocationStrategy: { - type: 'string', - enum: ['elastic', 'weighted'], - description: - 'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.', - }, - }, - }, - }, - { - type: 'function', - name: 'set_map_stack', - description: - 'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - stack: { - type: 'string', - enum: [ - 'photoreal', - 'bing-aerial', - 'bing-labels', - 'esri-imagery', - 'osm', - ], - description: - 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.', - }, - }, - required: ['stack'], - }, - }, - { - type: 'function', - name: 'set_post_processing', - description: - 'Control bloom and sharpen post-processing toggles and intensities.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - bloom: { - type: 'object', - additionalProperties: false, - properties: { - enabled: { type: 'boolean' }, - intensityPct: { - type: 'number', - description: '0-200 (UI percent).', - }, - }, - }, - sharpen: { - type: 'object', - additionalProperties: false, - properties: { - enabled: { type: 'boolean' }, - intensityPct: { - type: 'number', - description: '0-100 (UI percent).', - }, - }, - }, - }, - }, - }, - { - type: 'function', - name: 'control_scene', - description: - 'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - action: { - type: 'string', - enum: ['list', 'play', 'stop', 'next', 'status'], - }, - sceneId: { - type: 'string', - description: 'Scene id or (partial) title for play.', - }, - }, - required: ['action'], - }, - }, - { - type: 'function', - name: 'control_cctv', - description: - 'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - action: { - type: 'string', - enum: [ - 'enable', - 'disable', - 'select', - 'next', - 'prev', - 'nearest', - 'focus', - 'coverage', - 'viewshed', - 'adjust', - 'projection', - 'autohop', - ], - }, - cameraQuery: { - type: 'string', - description: 'Camera name or id for select.', - }, - enabled: { - type: 'boolean', - description: - 'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.', - }, - }, - required: ['action'], - }, - }, - { - type: 'function', - name: 'control_radio', - description: - 'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - action: { - type: 'string', - enum: [ - 'enable', - 'disable', - 'play', - 'resume', - 'pause', - 'stop', - 'next', - 'previous', - 'volume', - 'select', - 'status', - ], - description: - 'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.', - }, - volumePct: { - type: 'number', - minimum: 0, - maximum: 100, - description: - 'Required for volume; sets the persistent Radio playback volume.', - }, - category: { - type: 'string', - enum: [ - 'all', - 'news', - 'talk', - 'weather', - 'public-safety', - 'aviation-marine', - 'traffic-transit', - 'music', - ], - description: - 'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.', - }, - locationId: { - type: 'string', - enum: [ - 'austin', - 'sf', - 'nyc', - 'tokyo', - 'london', - 'paris', - 'dubai', - 'dc', - ], - description: 'Known nearby-city anchor for select.', - }, - locationQuery: { - type: 'string', - maxLength: 120, - description: - 'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.', - }, - latitude: { type: 'number', minimum: -90, maximum: 90 }, - longitude: { type: 'number', minimum: -180, maximum: 180 }, - country: { - type: 'string', - maxLength: 80, - description: - 'Country code or name filter, for example US or United States.', - }, - stationQuery: { - type: 'string', - maxLength: 120, - description: 'Optional station name/tag substring.', - }, - }, - required: ['action'], - }, - }, - { - type: 'function', - name: 'track_entity', - description: - 'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - query: { - type: 'string', - description: - 'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.', - }, - layerId: { - type: 'string', - description: - 'Optional layer hint: flights | military | ais-live-vessels | satellites.', - }, - }, - required: ['query'], - }, - }, - { - type: 'function', - name: 'stop_tracking', - description: - 'Stop following the tracked aircraft/satellite and clear any selected vessel.', - parameters: { - type: 'object', - additionalProperties: false, - properties: {}, - }, - }, - { - type: 'function', - name: 'frame_overhead', - description: - 'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - target: { - type: 'string', - enum: ['flights', 'military', 'satellites', 'vessels'], - }, - radiusKm: { - type: 'number', - description: - 'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.', - }, - }, - required: ['target'], - }, - }, - { - type: 'function', - name: 'annotate_map', - description: - 'Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - annotations: { - type: 'array', - description: - 'One or more things to mark. Mark multiple related places together when describing them as a group.', - minItems: 1, - maxItems: 24, - items: { - type: 'object', - additionalProperties: false, - properties: { - type: { - type: 'string', - enum: ['pin', 'highlight', 'area', 'arrow', 'route', 'label'], - description: - 'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.', - }, - target: { - type: 'string', - maxLength: 200, - description: - 'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.', - }, - points: { - type: 'array', - description: - 'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).', - minItems: 2, - maxItems: 12, - items: { - type: 'object', - additionalProperties: false, - properties: { - target: { - type: 'string', - maxLength: 200, - description: 'Waypoint place name.', - }, - latitude: { type: 'number', minimum: -90, maximum: 90 }, - longitude: { type: 'number', minimum: -180, maximum: 180 }, - screenX: { type: 'number', minimum: 0, maximum: 1 }, - screenY: { type: 'number', minimum: 0, maximum: 1 }, - }, - }, - }, - mode: { - type: 'string', - enum: ['walking', 'driving', 'cycling'], - description: - 'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.', - }, - latitude: { - type: 'number', - minimum: -90, - maximum: 90, - description: - 'Explicit latitude (use only if no good place name exists).', - }, - longitude: { type: 'number', minimum: -180, maximum: 180 }, - toTarget: { - type: 'string', - maxLength: 200, - description: 'For type=arrow: the destination place name.', - }, - toLatitude: { type: 'number', minimum: -90, maximum: 90 }, - toLongitude: { type: 'number', minimum: -180, maximum: 180 }, - label: { - type: 'string', - maxLength: 120, - description: - 'Short caption shown on the map (a few words). Optional.', - }, - color: { - type: 'string', - enum: ['primary', 'amber', 'cyan', 'green', 'red'], - description: - 'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.', - }, - footprint: { - type: 'boolean', - description: - 'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.', - }, - intent: { - type: 'string', - enum: ['the_thing', 'around_the_thing'], - description: - 'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.', - }, - entityKind: { - type: 'string', - enum: [ - 'building', - 'compound', - 'district', - 'street', - 'point_feature', - ], - description: - 'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).', - }, - screenX: { - type: 'number', - minimum: 0, - maximum: 1, - description: - 'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.', - }, - screenY: { - type: 'number', - minimum: 0, - maximum: 1, - description: - 'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.', - }, - toScreenX: { - type: 'number', - minimum: 0, - maximum: 1, - description: - 'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).', - }, - toScreenY: { - type: 'number', - minimum: 0, - maximum: 1, - description: - 'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).', - }, - }, - required: ['type'], - }, - }, - flyTo: { - type: 'boolean', - description: - 'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.', - }, - persist: { - type: 'boolean', - description: - 'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).', - }, - }, - required: ['annotations'], - }, - }, - { - type: 'function', - name: 'clear_annotations', - description: - 'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.', - parameters: { - type: 'object', - additionalProperties: false, - properties: {}, - }, - }, - { - type: 'function', - name: 'move_camera', - description: - 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - motion: { - type: 'string', - enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], - }, - direction: { - type: 'string', - enum: ['left', 'right', 'up', 'down'], - description: - 'Required except for orbit (defaults right/clockwise) and stop.', - }, - speed: { type: 'string', enum: ['slow', 'normal', 'fast'] }, - mode: { - type: 'string', - enum: ['once', 'continuous'], - description: - 'once = bounded eased nudge (default); continuous = until stop/manual input.', - }, - }, - required: ['motion'], - }, - }, - { - type: 'function', - name: 'fly_route', - description: - 'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - label: { - type: 'string', - description: 'Match an existing route mark by (partial) label.', - }, - speed: { type: 'string', enum: ['slow', 'normal', 'fast'] }, - }, - }, - }, - { - type: 'function', - name: 'analyst_query', - description: - 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - layers: { - type: 'array', - items: { - type: 'string', - enum: [ - 'flights', - 'military', - 'ais-live-vessels', - 'local-firms', - 'earthquakes', - ], - }, - description: - 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.', - }, - scope: { - type: 'object', - additionalProperties: false, - description: - 'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.', - properties: { - kind: { - type: 'string', - enum: ['view', 'region', 'radius', 'anywhere'], - }, - name: { - type: 'string', - description: - 'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").', - }, - km: { type: 'number', description: 'For kind=radius.' }, - center: { - type: 'object', - additionalProperties: false, - properties: { lat: { type: 'number' }, lon: { type: 'number' } }, - }, - }, - }, - filters: { - type: 'array', - description: - 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).', - items: { - type: 'object', - additionalProperties: false, - properties: { - field: { type: 'string' }, - op: { - type: 'string', - enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'], - }, - value: {}, - }, - required: ['field', 'op', 'value'], - }, - }, - sortBy: { - type: 'string', - description: 'Field to rank by, or "distance" for nearest-first.', - }, - sortDir: { type: 'string', enum: ['asc', 'desc'] }, - limit: { type: 'number' }, - followUp: { - type: 'boolean', - description: - 'true = re-query the PREVIOUS result set instead of fresh data.', - }, - }, - }, - }, - { - type: 'function', - name: 'next_iss_pass', - description: - "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.", - parameters: { - type: 'object', - additionalProperties: false, - properties: { - latitude: { - type: 'number', - minimum: -90, - maximum: 90, - description: - 'Optional observer latitude. Omit to use the current camera position.', - }, - longitude: { - type: 'number', - minimum: -180, - maximum: 180, - description: - 'Optional observer longitude. Omit to use the current camera position.', - }, - minElevationDeg: { - type: 'number', - minimum: 5, - maximum: 60, - description: - 'Minimum peak elevation (deg) to count as a pass. Default 10.', - }, - }, - }, - }, -]; +import { createActionTools } from '../../../src/voice/actionSchemas.js'; +import { ACTION_DESCRIPTIONS } from './toolDescriptions.js'; -export { GEV_REALTIME_TOOLS }; +export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS); diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index aef0267eb..ddb53c01f 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -3,6 +3,19 @@ import path from 'node:path'; // --------------------------------------------------------------------------- // Overpass API proxy constants and cache state // --------------------------------------------------------------------------- +/** + * User-Agent sent to every Overpass mirror. + * + * The OSM API usage policy asks for a "Valid User-Agent identifying application + * and version"; a generic proxy label is not one. A mirror is free to refuse a + * client it cannot identify, and `src/overpassProxy.test.mjs` pins what that + * costs: a refusal is never data, so the query falls through to whatever + * mirrors are left. Keep this honest and stable — if it is ever refused, the + * answer is less query volume, not a new name. + */ +const OVERPASS_USER_AGENT = + 'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)'; + /** Ordered list of Overpass API mirrors; tried sequentially on failure/rate-limit. */ const OVERPASS_UPSTREAMS = [ 'https://overpass-api.de/api/interpreter', @@ -130,5 +143,6 @@ export { OVERPASS_SIMPLIFY_TOLERANCE_DEG, OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, + OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, }; diff --git a/server/providers/overpass/query.js b/server/providers/overpass/query.js index b40266e22..cee444d3a 100644 --- a/server/providers/overpass/query.js +++ b/server/providers/overpass/query.js @@ -1,3 +1,4 @@ +import { stripOverpassNoise } from '../../../src/sources/overpass.js'; import { OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, @@ -31,54 +32,6 @@ function isOverpassBoundaryQuery(cacheKey) { * * @returns {{ok:true, body:string} | {ok:false, error:string}} */ -/** - * Single-pass lexer: blank out quoted literals (→ empty quotes) and strip line - * and block comments — recognizing each in one walk so a comment marker INSIDE a - * quoted string is treated as string content, not a comment (and vice versa). - * Chained regex replaces get the ordering wrong (a quoted slash-slash would hide - * the rest of the line), which is exactly the bypass this avoids. - */ -function stripOverpassNoise(src) { - let out = ''; - let i = 0; - const n = src.length; - while (i < n) { - const c = src[i]; - if (c === '"' || c === "'") { - const quote = c; - i += 1; - while (i < n) { - if (src[i] === '\\') { - i += 2; - continue; - } // escaped char - if (src[i] === quote) { - i += 1; - break; - } // closing quote - i += 1; - } - out += quote + quote; // collapse the literal to empty quotes - continue; - } - if (c === '/' && src[i + 1] === '*') { - i += 2; - while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i += 1; - i += 2; - out += ' '; - continue; - } - if (c === '/' && src[i + 1] === '/') { - i += 2; - while (i < n && src[i] !== '\n') i += 1; - out += ' '; - continue; - } - out += c; - i += 1; - } - return out; -} function sanitizeOverpassBody(rawBody) { let params; diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index ff15ae0ff..28a608956 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -1,6 +1,7 @@ import { OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, + OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, } from './constants.js'; import { readResponseTextCapped } from '../common/http.js'; @@ -91,7 +92,7 @@ async function fetchOverpassPayload( method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'gods-eye-view-overpass-proxy/1.0', + 'User-Agent': OVERPASS_USER_AGENT, }, body, signal: controller.signal, @@ -125,12 +126,11 @@ async function fetchOverpassPayload( } // Anything but 2xx is this mirror declining, not an answer. Only 5xx used // to rotate, so a 4xx ended the fan-out and was returned — and cached — - // as data: overpass-api.de and its lz4 alias answer 406 to this proxy's - // User-Agent while kumi.systems and private.coffee answer 200 to the very - // same request, so every Overpass-backed layer failed on an Apache error - // page with two healthy mirrors untried. The first refusal is kept so a - // genuinely bad query still reports what upstream said, but only after - // every mirror has had the chance to answer it. + // as data: a mirror refusing this client answers 406 while the others + // answer 200 to the very same request, so every Overpass-backed layer + // failed on an error page with healthy mirrors untried. The first + // refusal is kept so a genuinely bad query still reports what upstream + // said, but only after every mirror has had the chance to answer it. if (status < 200 || status >= 300) { if (!lastRefusalPayload) lastRefusalPayload = payload; lastError = new Error( diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js index 996e2af06..0fee93ac6 100644 --- a/server/providers/radio/constants.js +++ b/server/providers/radio/constants.js @@ -10,8 +10,7 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( ); export const RADIO_USER_AGENT = 'GodsEyeView/1.0 (Radio Browser directory client)'; -export const RADIO_UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js'; export const RADIO_FALLBACK_MIRRORS = Object.freeze([ 'https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index 4d9bcf35c..b97b2d3c6 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,155 +1,7 @@ -import { RADIO_UUID_RE } from './constants.js'; -import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js'; -export function cleanRadioText(value, maxLength) { - return String(value ?? '') - .replace(/[\u0000-\u001f\u007f]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, maxLength) - .trim(); -} - -export function isNonGlobalIpv4(hostname) { - const pieces = hostname.split('.'); - if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) - return false; - const values = pieces.map(Number); - if (values.some((value) => value > 255)) return true; - const [a, b, c] = values; - return ( - a === 0 || - a === 10 || - a === 127 || - a >= 224 || - (a === 100 && b >= 64 && b <= 127) || - (a === 169 && b === 254) || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 0) || - (a === 192 && b === 88 && c === 99) || - (a === 192 && b === 168) || - (a === 198 && (b === 18 || b === 19)) || - (a === 198 && b === 51 && c === 100) || - (a === 203 && b === 0 && c === 113) - ); -} - -/** Return a normalized public HTTPS URL, or null for local/private targets. */ -export function publicRadioHttpsUrl(value) { - try { - const url = new URL(String(value ?? '')); - const hostname = url.hostname - .toLowerCase() - .replace(/^\[|\]$/g, '') - .replace(/\.$/, ''); - if (url.protocol !== 'https:' || url.username || url.password || !hostname) - return null; - if ( - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname.endsWith('.local') || - isNonGlobalIpv4(hostname) || - hostname.includes(':') - ) - return null; - url.hash = ''; - return url.href; - } catch { - return null; - } -} - -/** Normalize one Radio Browser station and omit favicons and unsafe streams. */ -export function normalizeRadioBrowserStation(raw) { - const id = cleanRadioText(raw?.stationuuid, 40).toLowerCase(); - const lat = - raw?.geo_lat === null || raw?.geo_lat === '' ? null : Number(raw?.geo_lat); - const lon = - raw?.geo_long === null || raw?.geo_long === '' - ? null - : Number(raw?.geo_long); - const codec = cleanRadioText(raw?.codec, 16).toUpperCase(); - const streamUrl = publicRadioHttpsUrl(raw?.url_resolved || raw?.url); - if ( - !RADIO_UUID_RE.test(id) || - Number(raw?.lastcheckok) !== 1 || - Number(raw?.hls) === 1 || - !Number.isFinite(lat) || - lat < -90 || - lat > 90 || - !Number.isFinite(lon) || - lon < -180 || - lon > 180 || - !/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(codec) || - !streamUrl - ) - return null; - - const name = cleanRadioText(raw?.name, 140); - if (!name) return null; - const tags = String(raw?.tags ?? '') - .split(',') - .map((tag) => - cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - ) - .filter(Boolean) - .filter((tag, index, all) => all.indexOf(tag) === index) - .slice(0, 24); - const languages = String(raw?.language ?? '') - .split(',') - .map((language) => cleanRadioText(language, 40)) - .filter(Boolean) - .slice(0, 8); - const rawCountryCode = cleanRadioText(raw?.countrycode, 2).toUpperCase(); - const normalizedCode = normalizeRadioCountryInput(rawCountryCode); - const normalizedCountry = - normalizedCode.valid && !normalizedCode.empty - ? normalizedCode - : normalizeRadioCountryInput(cleanRadioText(raw?.country, 80)); - const bitrate = Number(raw?.bitrate); - return { - id, - name, - lat, - lon, - streamUrl, - homepage: publicRadioHttpsUrl(raw?.homepage), - tags, - languages, - state: cleanRadioText(raw?.state, 80), - country: - normalizedCountry.valid && !normalizedCountry.empty - ? normalizedCountry.name - : cleanRadioText(raw?.country, 80), - countryCode: normalizedCountry.valid ? normalizedCountry.code : '', - metadataTrust: 'untrusted-community', - codec, - bitrate: - Number.isInteger(bitrate) && bitrate >= 8 && bitrate <= 1024 - ? bitrate - : null, - clickCount: Math.max(0, Math.min(10_000_000, Number(raw?.clickcount) || 0)), - }; -} - -export function publicRadioStation(station) { - return { - id: station.id, - name: station.name, - lat: station.lat, - lon: station.lon, - streamUrl: station.streamUrl, - homepage: station.homepage, - tags: station.tags, - languages: station.languages, - state: station.state, - country: station.country, - countryCode: station.countryCode, - metadataTrust: station.metadataTrust, - codec: station.codec, - bitrate: station.bitrate, - }; -} +export { + cleanRadioText, + isNonGlobalIpv4, + publicRadioHttpsUrl, + normalizeRadioBrowserStation, + publicRadioStation, +} from '../../../src/sources/radioBrowser.js'; diff --git a/server/providers/regional.js b/server/providers/regional.js index 3b097c8ef..e2c4fb671 100644 --- a/server/providers/regional.js +++ b/server/providers/regional.js @@ -4,4 +4,8 @@ export { } from './regional/briefing.js'; export { weatherEffectsProxy } from './regional/weather-effects.js'; export { validRegionalPoint } from './regional/query.js'; -export { createRegionalPlaceProvider } from './regional/place.js'; +export { + createRegionalPlaceProvider, + createNominatimSearchProvider, + geocodeProxy, +} from './regional/place.js'; diff --git a/server/providers/regional/http.js b/server/providers/regional/http.js index 9d8a7bc1e..b67808f98 100644 --- a/server/providers/regional/http.js +++ b/server/providers/regional/http.js @@ -5,42 +5,52 @@ import { const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; -async function fetchRegionalJson( - url, - { +/** + * One bounded upstream read for the regional providers. + * + * The deadline covers the BODY, not just the headers. `return`ing the reader's + * promise from inside a try/finally leaves the try block the moment the promise + * exists, so the abort timer used to be cleared before a single byte of body had + * arrived — an upstream that answered its headers and then stalled had no + * deadline at all. Awaiting inside the try keeps the timer alive until the body + * is in hand, and the same signal is handed to the reader so an abort stops the + * read rather than just the connect. + * + * `redirect` is stated rather than inherited: feeds that legitimately redirect + * (news RSS) follow, and a fixed API endpoint can be given 'error' so a + * redirect cannot steer the proxy at a destination this code never named. + * + * @param {string} url + * @param {{headers?: object, timeoutMs?: number, maxBytes?: number, redirect?: 'follow'|'error'|'manual'}} [options] + */ +async function fetchRegional(url, read, options = {}) { + const { headers = {}, timeoutMs = 9000, maxBytes = REGIONAL_MAX_RESPONSE_BYTES, - } = {}, -) { + redirect = 'follow', + } = options; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(url, { signal: controller.signal, headers }); + const response = await fetch(url, { + signal: controller.signal, + headers, + redirect, + }); if (!response.ok) throw new Error(`Upstream returned ${response.status}`); - return readResponseJsonCapped(response, maxBytes); + return await read(response, maxBytes, controller.signal); } finally { clearTimeout(timeout); } } -async function fetchRegionalText( - url, - { - headers = {}, - timeoutMs = 9000, - maxBytes = REGIONAL_MAX_RESPONSE_BYTES, - } = {}, -) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { signal: controller.signal, headers }); - if (!response.ok) throw new Error(`Upstream returned ${response.status}`); - return readResponseTextCapped(response, maxBytes); - } finally { - clearTimeout(timeout); - } +function fetchRegionalJson(url, options = {}) { + return fetchRegional(url, readResponseJsonCapped, options); +} + +function fetchRegionalText(url, options = {}) { + return fetchRegional(url, readResponseTextCapped, options); } export { fetchRegionalJson, fetchRegionalText }; diff --git a/server/providers/regional/place.js b/server/providers/regional/place.js index 0e9e1ac26..d1815a2fa 100644 --- a/server/providers/regional/place.js +++ b/server/providers/regional/place.js @@ -1,20 +1,123 @@ +import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; +import { coalesceProxyRequest } from '../common/http.js'; import { fetchRegionalJson } from './http.js'; import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js'; +import { + nominatimToGeocodeResult, + nominatimViewboxFromBounds, +} from '../../../src/nominatimGeocode.js'; -/** Construct the serialized Nominatim adapter with a trusted endpoint. */ +/** + * The usage policy for the public Nominatim instance asks for a User-Agent or + * Referer that identifies the application, and states that stock library + * agents will not do. Both are sent. + */ +const NOMINATIM_HEADERS = Object.freeze({ + 'User-Agent': + 'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)', + Referer: 'https://github.com/bilawalsidhu/gods-eye-view', +}); + +/** + * Minimum spacing between upstream calls. The policy states an absolute + * maximum of one request per second; 1.1 s keeps clock jitter from crossing it. + */ +const NOMINATIM_MIN_SPACING_MS = 1100; + +/** + * How many searches may be waiting for their turn. + * + * One request per second and an unbounded queue are incompatible: a burst of + * searches would keep the upstream busy long after everyone who asked has given + * up, which is exactly the load the policy asks callers not to create. Past + * this depth a search is refused at once instead of being promised a slot + * minutes away. + */ +const NOMINATIM_MAX_PENDING = 4; + +/** + * How long a queued search may wait before it is not worth sending. The + * browser gives up well before this, so anything reaching the front later than + * this is answering nobody. + */ +const NOMINATIM_MAX_WAIT_MS = 10_000; + +const NOMINATIM_SEARCH_CACHE_MS = 5 * 60_000; + +const NOMINATIM_SEARCH_MAX_CACHE = 80; + +const NOMINATIM_SEARCH_MAX_QUERY = 200; + +// The pacer is deliberately module state while everything else here is +// per-instance. One request per second is a budget for the whole application, +// not for each provider object: two instances each pacing themselves would +// send two requests a second between them. Reverse lookups and searches +// therefore queue together. +let _nominatimQueue = Promise.resolve(); + +let _nominatimLastRequestAt = 0; + +let _nominatimPending = 0; + +/** Raised when the queue is already as deep as it is allowed to get. */ +function queueFullError() { + return Object.assign(new Error('Place search queue is full'), { + code: 'NOMINATIM_QUEUE_FULL', + }); +} + +/** Raised when a queued search waited so long that nobody is left to answer. */ +function abandonedError() { + return Object.assign(new Error('Place search was abandoned'), { + code: 'NOMINATIM_ABANDONED', + }); +} + +/** + * Run one piece of upstream work, never closer than the policy spacing to the + * last one. + * + * `bounded` applies the queue-depth and staleness limits. The cockpit's reverse + * lookups are a slow background trickle and stay unbounded; the search route, + * which a person can fire as fast as they can type, does not. + * + * @param {() => Promise} work + * @param {{bounded?: boolean, signal?: AbortSignal}} [options] + */ +function enqueueNominatim(work, { bounded = false, signal } = {}) { + if (bounded && _nominatimPending >= NOMINATIM_MAX_PENDING) + return Promise.reject(queueFullError()); + if (bounded) _nominatimPending += 1; + const queuedAt = Date.now(); + const task = _nominatimQueue.then(async () => { + try { + const waitMs = Math.max( + 0, + NOMINATIM_MIN_SPACING_MS - (Date.now() - _nominatimLastRequestAt), + ); + if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); + // Nobody is waiting for this any more: sending it would spend the one + // request per second the policy allows on an answer with no reader. + if (bounded && Date.now() - queuedAt > NOMINATIM_MAX_WAIT_MS) + throw abandonedError(); + if (signal?.aborted) throw abandonedError(); + _nominatimLastRequestAt = Date.now(); + return await work(); + } finally { + if (bounded) _nominatimPending -= 1; + } + }); + _nominatimQueue = task.catch(() => null); + return task; +} + +/** Construct the serialized Nominatim reverse adapter with a trusted endpoint. */ export function createRegionalPlaceProvider({ endpoint = 'https://nominatim.openstreetmap.org/reverse', requestJson = fetchRegionalJson, } = {}) { - let _nominatimQueue = Promise.resolve(); - - let _nominatimLastRequestAt = 0; - function fetchRegionalPlace(point) { - const task = _nominatimQueue.then(async () => { - const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt)); - if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); - _nominatimLastRequestAt = Date.now(); + return enqueueNominatim(async () => { const params = new URLSearchParams({ format: 'jsonv2', lat: point.latitude.toFixed(5), @@ -24,19 +127,162 @@ export function createRegionalPlaceProvider({ 'accept-language': 'en', }); const payload = await requestJson(`${endpoint}?${params}`, { - headers: { - 'User-Agent': - 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)', - Referer: 'https://github.com/bilawalsidhu/gods-eye-view', - }, + headers: NOMINATIM_HEADERS, + redirect: 'error', }); return normalizeRegionalPlace(payload); }); - _nominatimQueue = task.catch(() => null); - return task; } return fetchRegionalPlace; } export const fetchRegionalPlace = createRegionalPlaceProvider(); + +/** + * Construct the forward search adapter with a trusted endpoint. + * + * The policy asks that results be cached, and warns that a client repeating the + * same query may be treated as faulty, so an answer is remembered and identical + * searches already in flight share one upstream call rather than queueing + * behind each other. + */ +export function createNominatimSearchProvider({ + endpoint = 'https://nominatim.openstreetmap.org/search', + requestJson = fetchRegionalJson, +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + + const trimCache = () => { + while (cache.size > NOMINATIM_SEARCH_MAX_CACHE) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } + }; + + return async function fetchNominatimSearch(query, bounds, { signal } = {}) { + const cacheKey = `${query.toLowerCase()}|${bounds || ''}`; + const cached = cache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt <= NOMINATIM_SEARCH_CACHE_MS) { + return { ...cached.payload, cached: true }; + } + const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => { + const params = new URLSearchParams({ + format: 'jsonv2', + q: query, + addressdetails: '1', + limit: '1', + 'accept-language': 'en', + }); + const viewbox = nominatimViewboxFromBounds(bounds); + if (viewbox) params.set('viewbox', viewbox); + const rows = await enqueueNominatim( + () => + requestJson(`${endpoint}?${params}`, { + headers: NOMINATIM_HEADERS, + redirect: 'error', + }), + { bounded: true, signal }, + ); + const result = nominatimToGeocodeResult( + Array.isArray(rows) ? rows[0] : null, + ); + const payload = result + ? { status: 'OK', results: [result] } + : { status: 'ZERO_RESULTS', results: [] }; + cache.set(cacheKey, { payload, cachedAt: Date.now() }); + trimCache(); + return payload; + }); + return await promise; + }; +} + +export const fetchNominatimSearch = createNominatimSearchProvider(); + +/** Vite plugin: last-resort place search over the public Nominatim instance. */ +export function geocodeProxy({ search = fetchNominatimSearch } = {}) { + const limiter = makeRateLimiter({ + windowMs: 60_000, + max: 30, + globalMax: 90, + }); + + function install(middlewares) { + middlewares.use('/api/geocode', async (req, res) => { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + if (!limiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '', 'http://localhost'); + const query = String(url.searchParams.get('q') || '').trim(); + if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'A place query of 1-200 characters is required', + }), + ); + return; + } + // A browser that gave up is no longer waiting; the queue reads this + // before spending its slot. + const abandoned = new AbortController(); + req.on?.('aborted', () => abandoned.abort()); + res.on?.('close', () => abandoned.abort()); + try { + const payload = await search(query, url.searchParams.get('bounds'), { + signal: abandoned.signal, + }); + if (res.writableEnded) return; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': payload.cached ? 'public, max-age=60' : 'no-store', + }); + res.end( + JSON.stringify({ status: payload.status, results: payload.results }), + ); + } catch (error) { + if (res.writableEnded) return; + const busy = + error?.code === 'NOMINATIM_QUEUE_FULL' || + error?.code === 'NOMINATIM_ABANDONED'; + res.writeHead(busy ? 429 : 503, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(busy ? { 'Retry-After': '5' } : {}), + }); + res.end( + JSON.stringify({ + error: busy + ? 'Place search is busy' + : 'Place search is temporarily unavailable', + }), + ); + } + }); + } + + return { + name: 'geocode-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { NOMINATIM_MAX_PENDING }; diff --git a/server/providers/transit.js b/server/providers/transit.js new file mode 100644 index 000000000..297352695 --- /dev/null +++ b/server/providers/transit.js @@ -0,0 +1,24 @@ +import { createTransitService } from '../../src/sources/transitService.js'; +export { fetchTransitFeed } from '../../src/sources/transitService.js'; + +/** Connect the reusable transit request service to development and preview. */ +export function transitProxy(options = {}) { + const service = createTransitService(options); + function install(server) { + server.middlewares.use('/api/transit', async (req, res) => { + const response = await service.handle({ + url: `http://localhost/api/transit${req.url || '/'}`, + method: req.method, + }); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(await response.text()); + }); + server.httpServer?.once('close', service.close); + } + return { + name: 'transit-proxy', + closeBundle: service.close, + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/providers/transitHistory.js b/server/providers/transitHistory.js new file mode 100644 index 000000000..f396f3d8f --- /dev/null +++ b/server/providers/transitHistory.js @@ -0,0 +1,5 @@ +export { + createTransitHistory, + TRANSIT_HISTORY_LIMITS, + validTransitIdentifier, +} from '../../src/sources/transitHistoryStore.js'; 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/keySetupHardening.mjs b/server/standalone/key-setup-hardening.mjs similarity index 80% rename from src/keySetupHardening.mjs rename to server/standalone/key-setup-hardening.mjs index 64d807292..002ccabdc 100644 --- a/src/keySetupHardening.mjs +++ b/server/standalone/key-setup-hardening.mjs @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { commandCompletedSuccessfully, parseWindowsUserSid, -} from './keySetupCore.mjs'; +} from '../../src/keySetupCore.mjs'; /** PowerShell verification for the exact owner-only Windows credential DACL. */ const WINDOWS_ACL_VERIFY_SCRIPT = [ @@ -51,11 +51,13 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) { if (configured.length === 0) return null; const roots = configured.map((value) => { - if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value)) return null; + if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value)) + return null; return value.endsWith('\\') ? value.slice(0, -1) : value; }); if (roots.some((root) => !root)) return null; - if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase())) return null; + if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase())) + return null; const systemRoot = roots[0]; const systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32'; @@ -80,14 +82,23 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) { for (const executable of Object.values(expected)) { const entry = fileSystem.lstatSync(executable); if (!entry.isFile() || entry.isSymbolicLink()) return null; - const canonicalExecutable = realpath.call(fileSystem.realpathSync, executable); + const canonicalExecutable = realpath.call( + fileSystem.realpathSync, + executable, + ); const canonicalCandidates = [executable]; if (architecture === 'ia32') { - canonicalCandidates.push(executable.replace('\\Sysnative\\', '\\System32\\')); + canonicalCandidates.push( + executable.replace('\\Sysnative\\', '\\System32\\'), + ); } - if (!canonicalCandidates.some( - (candidate) => candidate.toLowerCase() === canonicalExecutable.toLowerCase(), - )) return null; + if ( + !canonicalCandidates.some( + (candidate) => + candidate.toLowerCase() === canonicalExecutable.toLowerCase(), + ) + ) + return null; } } catch { return null; @@ -99,17 +110,22 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) { * Restrict a credential file before any secret is written to it. * Dependencies are injectable so every fail-closed branch is unit-testable. */ -export function hardenCredentialFile(filepath, { - platform = process.platform, - architecture = process.arch, - spawn = spawnSync, - fileSystem = fs, - environment = process.env, -} = {}) { +export function hardenCredentialFile( + filepath, + { + platform = process.platform, + architecture = process.arch, + spawn = spawnSync, + fileSystem = fs, + environment = process.env, + } = {}, +) { if (platform !== 'win32') { try { if (platform === 'darwin') { - const aclRemoval = spawn('chmod', ['-N', filepath], { stdio: 'ignore' }); + const aclRemoval = spawn('chmod', ['-N', filepath], { + stdio: 'ignore', + }); if (!commandCompletedSuccessfully(aclRemoval)) return false; } fileSystem.chmodSync(filepath, 0o600); @@ -119,7 +135,11 @@ export function hardenCredentialFile(filepath, { } } - const tools = resolveWindowsNativeTools(environment, fileSystem, architecture); + const tools = resolveWindowsNativeTools( + environment, + fileSystem, + architecture, + ); if (!tools) return false; try { @@ -135,14 +155,18 @@ export function hardenCredentialFile(filepath, { : null; if (!sid) return false; - const applied = spawn(tools.icacls, [ - filepath, - '/inheritance:r', - '/grant:r', - `*${sid}:F`, - '*S-1-5-18:F', - '*S-1-5-32-544:F', - ], { stdio: 'ignore', windowsHide: true }); + const applied = spawn( + tools.icacls, + [ + filepath, + '/inheritance:r', + '/grant:r', + `*${sid}:F`, + '*S-1-5-18:F', + '*S-1-5-32-544:F', + ], + { stdio: 'ignore', windowsHide: true }, + ); if (!commandCompletedSuccessfully(applied)) return false; // Command success is not proof of the resulting DACL. Query it back and @@ -173,20 +197,20 @@ export function hardenCredentialFile(filepath, { ([name]) => name.toLowerCase() !== 'psmodulepath', ), ); - const verified = spawn(tools.powershell, [ - '-NoProfile', - '-NonInteractive', - '-Command', WINDOWS_ACL_VERIFY_SCRIPT, - ], { - env: { - ...verifyEnvironment, - GEV_ACL_FILE: filepath, - GEV_ACL_USER_SID: sid, - PSModulePath: powershellModuleDirectory, + const verified = spawn( + tools.powershell, + ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_VERIFY_SCRIPT], + { + env: { + ...verifyEnvironment, + GEV_ACL_FILE: filepath, + GEV_ACL_USER_SID: sid, + PSModulePath: powershellModuleDirectory, + }, + stdio: 'ignore', + windowsHide: true, }, - stdio: 'ignore', - windowsHide: true, - }); + ); return commandCompletedSuccessfully(verified); } catch { return false; diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js index 37516cbe2..b8be4c036 100644 --- a/server/standalone/key-setup.js +++ b/server/standalone/key-setup.js @@ -12,7 +12,7 @@ import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scr import fs from 'node:fs'; import { parseEnv as parseDotenvText } from 'node:util'; import { randomUUID } from 'node:crypto'; -import { hardenCredentialFile } from '../../src/keySetupHardening.mjs'; +import { hardenCredentialFile } from './key-setup-hardening.mjs'; /** * Which launcher started this process, captured at MODULE LOAD — before the diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index e9f19c69f..ccf9cb06a 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,12 +1,20 @@ import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; -import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js'; +import { + holdContinuousRender, + releaseContinuousRender, +} from '../renderGovernor.js'; +import { + isRateLimitedOutcome, + resolveAnnotationTarget, + sampleGroundHeight, +} from './annotationResolver.js'; import { ringCentroid } from './drawMode.js'; // Dev convenience: expose the app's Cesium instance for console/preview probing // (single shared module instance — avoids dual-Cesium state bugs when testing). -if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium; +if (typeof window !== 'undefined' && !window.__CESIUM__) + window.__CESIUM__ = Cesium; /** * Annotation engine — the voice agent's "whiteboard" over the 3D world. @@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']); // Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) — // what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a // monument point-first). Unknown values are dropped, never guessed. -const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']); +const ENTITY_KINDS = new Set([ + 'building', + 'compound', + 'district', + 'street', + 'point_feature', +]); const DEFAULT_TTL_MS = 22_000; const FADE_MS = 1200; // Hard ceiling on simultaneously-live marks. Protects against a runaway voice @@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2; * retrying immediately. A thrown resolver counts as definitive — the pre-retry * catch→point behavior. Exported for tests. */ -export async function resolveOutlineWithRetry(resolveOutline, { - delaysMs = OUTLINE_RETRY_DELAYS_MS, - isStale = () => false, - waitFn = wait, -} = {}) { +export async function resolveOutlineWithRetry( + resolveOutline, + { + delaysMs = OUTLINE_RETRY_DELAYS_MS, + isStale = () => false, + waitFn = wait, + } = {}, +) { let retriedRateLimit = false; for (let attempt = 0; ; attempt += 1) { let fp; @@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, { // A throttle gets one deliberately spaced retry. If that retry is throttled // too, stop this mark's outline task instead of replaying the batch storm. if (retriedRateLimit || attempt >= delaysMs.length) return undefined; - const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0; + const retryAfterMs = Number.isFinite(fp.retryAfterMs) + ? fp.retryAfterMs + : 0; await waitFn(Math.max(retryAfterMs, delaysMs[attempt])); if (isStale()) return undefined; retriedRateLimit = true; @@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, { * geometry is unknown. Exported for tests. */ export function normalizeTargetKey(target) { - const raw = String(target ?? '').trim().toLowerCase(); + const raw = String(target ?? '') + .trim() + .toLowerCase(); if (!raw) return null; const head = raw.split(',')[0].trim(); return head || raw; @@ -123,12 +144,22 @@ export function createAnnotationEngine({ function emitOutlineEvent(evt) { for (const listener of outlineListeners) { - try { listener(evt); } catch { /* a listener error must never break the upgrade */ } + try { + listener(evt); + } catch { + /* a listener error must never break the upgrade */ + } } } function abortPending() { - for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } } + for (const c of activeControllers) { + try { + c.abort(); + } catch { + /* no-op */ + } + } activeControllers.clear(); } @@ -174,7 +205,9 @@ export function createAnnotationEngine({ function rollbackRendererState(anno) { try { renderer.remove(anno); - } catch { /* the renderer is already in a bad way; the original error wins */ } + } catch { + /* the renderer is already in a bad way; the original error wins */ + } } function ensureTicking() { @@ -226,7 +259,15 @@ export function createAnnotationEngine({ * @returns {Promise<{ok, drawn, failed, ids, results}>} */ async function annotate(requests, opts = {}) { - if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' }; + if (destroyed) + return { + ok: false, + drawn: 0, + failed: 0, + ids: [], + results: [], + error: 'destroyed', + }; const list = Array.isArray(requests) ? requests : [requests]; if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work @@ -247,7 +288,11 @@ export function createAnnotationEngine({ // allSettled gives per-item error isolation (one failed item never aborts the batch); the // mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output // order are all preserved exactly as the old serial loop had them. - const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true))); + const settled = await Promise.allSettled( + list.map((spec) => + resolveSpec(spec, controller.signal, opts.flyTo === true), + ), + ); try { for (let i = 0; i < list.length; i += 1) { @@ -270,8 +315,15 @@ export function createAnnotationEngine({ // stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full. const dup = findDuplicate(anno); if (dup) { - const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase(); - const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary'); + const labelChanged = + String(dup.label || '') + .trim() + .toLowerCase() !== + String(anno.label || '') + .trim() + .toLowerCase(); + const colorChanged = + (dup.color || 'primary') !== (anno.color || 'primary'); if (labelChanged || colorChanged) { // Same place + shape, but a NEW label and/or color ("Marina" → "Marina District", // or a recolor) → replace in place so the latest caption/color wins (count @@ -305,13 +357,26 @@ export function createAnnotationEngine({ ids.push(anno.id); if (!firstAnchor) firstAnchor = anno; if (typeof resolved.resolveOutline === 'function') { - startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller); + startOutlineUpgrade( + anno, + resolved.resolveOutline, + myGen, + controller, + ); } - results.push({ ...okResult(anno, resolved, anno.id), duplicate: true }); + results.push({ + ...okResult(anno, resolved, anno.id), + duplicate: true, + }); } else { // Identical re-narration → refresh the existing mark's lifecycle in place (keep // it visible whether persistent or TTL-based, and revive a fading-out one). - if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; } + if (persist) { + dup.ttlMs = null; + } else { + dup.createdAt = performance.now(); + dup.ttlMs = anno.ttlMs; + } dup.expiring = false; dup.fadeStart = null; ids.push(dup.id); @@ -319,11 +384,19 @@ export function createAnnotationEngine({ // A re-narration is also a RETRY: if the existing mark never got its outline // (transient Overpass miss), attach this call's deferred resolver to it. if (!dup.ring && typeof resolved.resolveOutline === 'function') { - startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller); + startOutlineUpgrade( + dup, + resolved.resolveOutline, + myGen, + controller, + ); } // Same result builder as the fresh path (full metadata) but the existing id — // route fallback / approximate honesty fields survive a re-narration. - results.push({ ...okResult(anno, resolved, dup.id), duplicate: true }); + results.push({ + ...okResult(anno, resolved, dup.id), + duplicate: true, + }); } continue; } @@ -350,12 +423,23 @@ export function createAnnotationEngine({ if (!firstAnchor) firstAnchor = anno; // Progressive: the point is on the board; trace the outline behind the narration. if (typeof resolved.resolveOutline === 'function') { - startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller); + startOutlineUpgrade( + anno, + resolved.resolveOutline, + myGen, + controller, + ); } results.push(okResult(anno, resolved, anno.id)); } catch (error) { if (superseded()) break; - results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null)); + results.push( + failResult( + spec, + error?.message || 'annotation failed', + Array.isArray(error?.failedTargets) ? error.failedTargets : null, + ), + ); } } } finally { @@ -365,7 +449,14 @@ export function createAnnotationEngine({ // A clear/new-topic landed mid-flight: drop everything this call produced and // do not touch the renderer or camera — the board now belongs to a newer call. if (superseded()) { - return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true }; + return { + ok: false, + drawn: 0, + failed: results.length, + ids: [], + results, + aborted: true, + }; } if (ids.length) { @@ -393,7 +484,8 @@ export function createAnnotationEngine({ if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer); if (type === 'route') { const points = Array.isArray(spec.points) ? spec.points : []; - if (points.length < 2) throw new Error('a route needs at least 2 waypoints'); + if (points.length < 2) + throw new Error('a route needs at least 2 waypoints'); const resolvedPts = []; const failed = []; for (const pt of points) { @@ -425,7 +517,12 @@ export function createAnnotationEngine({ } // Real street-following route (OSM/OSRM), mode-aware. const mode = normalizeMode(spec.mode); - const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch); + const routed = await fetchRoute( + resolvedPts.map((p) => [p.lon, p.lat]), + mode, + signal, + placeSearch, + ); if (routed) { return { path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })), @@ -439,8 +536,16 @@ export function createAnnotationEngine({ // Routing unavailable: do NOT pass off straight segments as a real route. Draw // a clearly-labeled direct line with great-circle distance and NO travel time. let straight = 0; - for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]); - return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true }; + for (let i = 1; i < resolvedPts.length; i += 1) + straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]); + return { + path: resolvedPts, + distanceM: straight, + durationS: null, + mode, + source: resolvedPts[0].source, + fallback: true, + }; } if (type === 'arrow') { const from = await resolveTarget({ @@ -479,9 +584,15 @@ export function createAnnotationEngine({ err.failedTargets = failed; throw err; } - return { from, to, distanceM: greatCircleM(from, to), source: from.source }; + return { + from, + to, + distanceM: greatCircleM(from, to), + source: from.source, + }; } - const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint); + const wantFootprint = + type === 'area' ? spec.footprint !== false : Boolean(spec.footprint); return resolveTarget({ placeSearch, viewer, @@ -491,7 +602,8 @@ export function createAnnotationEngine({ screenX: spec.screenX, screenY: spec.screenY, footprint: wantFootprint, - intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing', + intent: + spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing', entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null, // The label often carries the ask's true shape when the target omits it // ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only. @@ -525,11 +637,15 @@ export function createAnnotationEngine({ } function drainOutlineQueue() { - while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) { + while ( + activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && + outlineQueue.length + ) { const task = outlineQueue.shift(); - const stale = task.myGen !== generation - || task.controller.signal.aborted - || !annotations.has(task.anno.id); + const stale = + task.myGen !== generation || + task.controller.signal.aborted || + !annotations.has(task.anno.id); if (stale) { if (task.anno._outlineTask === task) task.anno._outlineTask = null; releaseController(task.controller); @@ -549,7 +665,10 @@ export function createAnnotationEngine({ // proxy caches the late completion, so the retry is usually a warm cache hit. fp = await resolveOutlineWithRetry(resolveOutline, { delaysMs: outlineRetryDelaysMs, - isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id), + isStale: () => + myGen !== generation || + controller.signal.aborted || + !annotations.has(anno.id), }); if (myGen !== generation || controller.signal.aborted) return; // board superseded if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving @@ -577,11 +696,22 @@ export function createAnnotationEngine({ // wins, exactly like the add-time replace path). Not an auto-clear — it removes // a second representation of the same mark, never a distinct one. for (const other of annotations.values()) { - if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue; - if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 - || Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue; - if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue; - if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue; + if ( + other.id === anno.id || + other.type !== 'area' || + other.pendingOutline + ) + continue; + if ( + !other.anchor || + Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 || + Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4 + ) + continue; + if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) + continue; + if ((other.footprintKind || null) !== (anno.footprintKind || null)) + continue; if (!ringsEqual(other.ring, anno.ring)) continue; annotations.delete(other.id); renderer.remove(other); @@ -629,7 +759,12 @@ export function createAnnotationEngine({ // Route-specific signal so the voice layer can be honest about an OSRM outage: // fallback=true means a straight direct line, not a real route. ...(anno.type === 'route' - ? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS } + ? { + fallback: Boolean(anno.fallback), + mode: anno.mode, + distanceM: anno.distanceM, + durationS: anno.durationS, + } : {}), }; } @@ -643,13 +778,18 @@ export function createAnnotationEngine({ * degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */ function findDuplicate(anno) { if (!anno.anchor) return null; - const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4; + const near = (a, b) => + a && + b && + Math.abs(a.lon - b.lon) < 5e-4 && + Math.abs(a.lat - b.lat) < 5e-4; // FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for // candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT // same-length rings/paths at the same anchor are never falsely merged. const ringSame = ringsEqual; const pathSame = (a, b) => { - const la = a ? a.length : 0; const lb = b ? b.length : 0; + const la = a ? a.length : 0; + const lb = b ? b.length : 0; if (la !== lb) return false; for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false; return true; @@ -674,10 +814,12 @@ export function createAnnotationEngine({ // the identity; the target is only the stand-in while geometry is unknown. if (!anno.targetKey || !ex.targetKey) continue; if (anno.targetKey !== ex.targetKey) continue; - if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue; + if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) + continue; } else { if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue; - if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue; + if ((ex.footprintKind || null) !== (anno.footprintKind || null)) + continue; if (!ringSame(ex.ring, anno.ring)) continue; } } else if (anno.type === 'arrow') { @@ -691,7 +833,13 @@ export function createAnnotationEngine({ // point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a // duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct // labels stay as separate marks (their callouts de-collide in the renderer). - const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase(); + const sameLabel = + String(ex.label || '') + .trim() + .toLowerCase() === + String(anno.label || '') + .trim() + .toLowerCase(); if (!sameLabel) continue; } return ex; @@ -703,7 +851,11 @@ export function createAnnotationEngine({ const type = normalizeType(spec?.type); const id = `anno-${++_seq}`; const color = COLORS.has(spec?.color) ? spec.color : 'primary'; - const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null; + const label = + cleanLabel(spec?.label) || + resolved?.label || + resolved?.from?.label || + null; const now = performance.now(); const base = { @@ -712,17 +864,27 @@ export function createAnnotationEngine({ color, label, createdAt: now, - ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS), + ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS, alpha: 0, // animate in bornAt: now, expiring: false, }; if (type === 'route') { - const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height })); + const path = resolved.path.map((p) => ({ + lon: p.lon, + lat: p.lat, + height: p.height, + })); return { ...base, - label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback), + label: composeRouteLabel( + base.label, + resolved.distanceM, + resolved.durationS, + resolved.mode, + resolved.fallback, + ), anchor: path[0], to: null, path, @@ -738,8 +900,16 @@ export function createAnnotationEngine({ return { ...base, label: appendDistance(base.label, resolved.distanceM), - anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height }, - to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height }, + anchor: { + lon: resolved.from.lon, + lat: resolved.from.lat, + height: resolved.from.height, + }, + to: { + lon: resolved.to.lon, + lat: resolved.to.lat, + height: resolved.to.height, + }, ring: null, }; } @@ -814,13 +984,17 @@ export function createAnnotationEngine({ if (m.to) points.push(m.to); } if (!points.length || points.some((p) => isPointOnScreen(p))) return; - console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`); + console.log( + `[Annotations] auto-framing ${marks.length} off-screen mark(s)`, + ); assistFlightUntil = performance.now() + 2600; // ~flight duration + settle if (marks.length === 1) { frameAnnotation(marks[0]); return; } - const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)); + const cart = points.map((p) => + Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), + ); const sphere = Cesium.BoundingSphere.fromPoints(cart); viewer.camera.flyToBoundingSphere(sphere, { offset: new Cesium.HeadingPitchRange( @@ -841,9 +1015,20 @@ export function createAnnotationEngine({ try { const camera = viewer.camera; const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0); - const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up); - if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false; - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position); + const cv = camera.frustum.computeCullingVolume( + camera.position, + camera.direction, + camera.up, + ); + if ( + cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === + Cesium.Intersect.OUTSIDE + ) + return false; + const occluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + camera.position, + ); return occluder.isPointVisible(pos); } catch { return true; @@ -853,15 +1038,24 @@ export function createAnnotationEngine({ function frameAnnotation(anno) { try { const target = anno.to - ? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 } + ? { + lon: (anno.anchor.lon + anno.to.lon) / 2, + lat: (anno.anchor.lat + anno.to.lat) / 2, + } : anno.anchor; // No ring yet (progressive outline still resolving) → size the flight from the // Places viewport box when we have one, so a big compound isn't framed at // building scale while its outline is traced. Never re-fly when the ring lands. - const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600); + const range = anno.ring + ? ringRange(anno.ring) + : viewportRange(anno.viewport) || 600; viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0), + Cesium.Cartesian3.fromDegrees( + target.lon, + target.lat, + anno.anchor.height || 0, + ), range, ), { @@ -884,7 +1078,9 @@ export function createAnnotationEngine({ destroy() { if (destroyed) return; destroyed = true; - try { clear(); } finally { + try { + clear(); + } finally { if (tickHandle != null) cancelAnimationFrame(tickHandle); tickHandle = null; outlineListeners.clear(); @@ -912,12 +1108,36 @@ export function createAnnotationEngine({ * Lays down a small San Francisco "tour" the way the voice agent would. */ async demo() { - return annotate([ - { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }, - { type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true }, - { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' }, - { type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' }, - ], { flyTo: true, clearPrevious: true, persist: true }); + return annotate( + [ + { + type: 'highlight', + target: 'Palace of Fine Arts, San Francisco', + label: 'Palace of Fine Arts', + color: 'amber', + }, + { + type: 'area', + target: 'Presidio of San Francisco', + label: 'The Presidio (former Army base)', + color: 'green', + footprint: true, + }, + { + type: 'pin', + target: 'Letterman Digital Arts Center, San Francisco', + label: 'ILM / Lucasfilm', + color: 'cyan', + }, + { + type: 'arrow', + target: 'Palace of Fine Arts, San Francisco', + toTarget: 'Marina District, San Francisco', + label: 'next to the Marina', + }, + ], + { flyTo: true, clearPrevious: true, persist: true }, + ); }, /** @@ -927,34 +1147,107 @@ export function createAnnotationEngine({ */ async tour() { clear(); - flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 }); + flyTo({ + lon: -122.4486, + lat: 37.796, + height: 520, + heading: 0, + pitch: -26, + duration: 3, + }); if (destroyed) return { ok: false }; await wait(3200); - await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true }); + await annotate( + [ + { + type: 'highlight', + target: 'Palace of Fine Arts, San Francisco', + label: 'Palace of Fine Arts', + color: 'amber', + }, + ], + { persist: true }, + ); if (destroyed) return { ok: false }; await wait(2600); - await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true }); + await annotate( + [ + { + type: 'arrow', + target: 'Palace of Fine Arts, San Francisco', + toTarget: 'Marina Green, San Francisco', + label: 'next to the Marina', + color: 'cyan', + }, + ], + { persist: true }, + ); if (destroyed) return { ok: false }; await wait(2600); - flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 }); + flyTo({ + lon: -122.4545, + lat: 37.788, + height: 1500, + heading: 18, + pitch: -32, + duration: 3, + }); if (destroyed) return { ok: false }; await wait(3200); - await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true }); + await annotate( + [ + { + type: 'area', + target: 'Presidio of San Francisco', + label: 'The Presidio — a former Army base', + color: 'green', + footprint: true, + }, + ], + { persist: true }, + ); if (destroyed) return { ok: false }; await wait(2800); - await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true }); + await annotate( + [ + { + type: 'pin', + target: 'Letterman Digital Arts Center, San Francisco', + label: 'ILM / Lucasfilm', + color: 'red', + }, + ], + { persist: true }, + ); if (destroyed) return { ok: false }; await wait(2600); - await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [ - { target: 'Palace of Fine Arts, San Francisco' }, - { target: 'Crissy Field, San Francisco' }, - { target: 'Fort Point, San Francisco' }, - ] }], { persist: true }); + await annotate( + [ + { + type: 'route', + color: 'amber', + label: 'Crissy Field shoreline', + points: [ + { target: 'Palace of Fine Arts, San Francisco' }, + { target: 'Crissy Field, San Francisco' }, + { target: 'Fort Point, San Francisco' }, + ], + }, + ], + { persist: true }, + ); return { ok: true, steps: 6 }; }, }; - function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) { + function flyTo({ + lon, + lat, + height, + heading = 0, + pitch = -30, + duration = 2.5, + }) { if (destroyed) return; try { viewer.camera.flyTo({ @@ -984,7 +1277,11 @@ function ringsEqual(a, b) { const lb = b ? b.length : 0; if (la !== lb) return false; for (let i = 0; i < la; i++) { - if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false; + if ( + Math.abs(a[i][0] - b[i][0]) >= 1e-5 || + Math.abs(a[i][1] - b[i][1]) >= 1e-5 + ) + return false; } return true; } @@ -1010,7 +1307,8 @@ function specTargets(spec) { * arrow endpoint) when known; otherwise it derives them from the spec. */ function failResult(spec, error, failedTargets) { - const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec); + const targets = + failedTargets && failedTargets.length ? failedTargets : specTargets(spec); return { ok: false, label: spec?.label || spec?.target || targets[0] || null, @@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) { function normalizeType(type) { const t = String(type || '').toLowerCase(); - if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area'; + if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') + return 'area'; if (t === 'route' || t === 'path' || t === 'line') return 'route'; if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow'; if (t === 'label' || t === 'callout' || t === 'text') return 'label'; @@ -1036,9 +1335,10 @@ function computeAlpha(anno, now) { if (anno.ttlMs == null && !anno.expiring) return fadeIn; - const fadeStart = anno.fadeStart != null - ? anno.fadeStart - : anno.createdAt + (anno.ttlMs || 0); + const fadeStart = + anno.fadeStart != null + ? anno.fadeStart + : anno.createdAt + (anno.ttlMs || 0); if (now < fadeStart) return fadeIn; const out = 1 - (now - fadeStart) / FADE_MS; if (out <= 0) anno.expiring = true; @@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) { if (now - anno.bornAt < 260) return true; // fading in if (anno.expiring) return true; // fading out if (anno.ttlMs != null) { - const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0); + const fadeStart = + anno.fadeStart != null + ? anno.fadeStart + : anno.createdAt + (anno.ttlMs || 0); return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress } return false; // persistent + faded in → stable @@ -1110,16 +1413,30 @@ function round5(n) { /** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */ function isManualSpec(spec, type) { if (!spec || spec.manual !== true) return false; - if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2; + if (type === 'route') + return Array.isArray(spec.path) && spec.path.length >= 2; if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3; - return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude)); + return ( + Number.isFinite(Number(spec.latitude)) && + Number.isFinite(Number(spec.longitude)) + ); } /** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */ function manualPairs(list) { return (Array.isArray(list) ? list : []) - .map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)])) - .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180); + .map((p) => + Array.isArray(p) + ? [Number(p[0]), Number(p[1])] + : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)], + ) + .filter( + ([lon, lat]) => + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180, + ); } /** @@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) { // height 0 is a placeholder, not a placement: the world renderer draws a // route with clampToGround + CESIUM_3D_TILE classification, so the line is // draped onto the photoreal surface whatever this number says. - const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 })); + const pts = manualPairs(spec.path).map(([lon, lat]) => ({ + lon, + lat, + height: 0, + })); if (pts.length < 2) throw new Error('a drawn line needs at least 2 points'); let distanceM = 0; - for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]); - return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false }; + for (let i = 1; i < pts.length; i += 1) + distanceM += greatCircleM(pts[i - 1], pts[i]); + return { + path: pts, + distanceM, + durationS: null, + mode: 'manual', + source: 'manual', + fallback: false, + }; } if (type === 'area') { const ring = manualPairs(spec.ring); - if (ring.length < 3) throw new Error('a drawn area needs at least 3 points'); + if (ring.length < 3) + throw new Error('a drawn area needs at least 3 points'); // Averaged through ringCentroid, which unwraps longitudes first: a ring // straddling the antimeridian has coordinates like [179.999, -179.999], and // a raw mean of those puts the anchor on the Greenwich meridian, half a @@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) { const lon = centre.lon; const lat = centre.lat; return { - lon, lat, height: sampleGroundHeight(viewer, lon, lat), - ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual', + lon, + lat, + height: sampleGroundHeight(viewer, lon, lat), + ring, + footprintKind: 'area', + buildingHeight: null, + synthesized: false, + source: 'manual', }; } const lon = Number(spec.longitude); const lat = Number(spec.latitude); - return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' }; + return { + lon, + lat, + height: sampleGroundHeight(viewer, lon, lat), + ring: null, + source: 'manual', + }; } /** The ring's distinct positions: a closing vertex that repeats the first is dropped. */ @@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) { if (ring.length < 2) return ring; const [firstLon, firstLat] = ring[0]; const [lastLon, lastLat] = ring[ring.length - 1]; - return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring; + return firstLon === lastLon && firstLat === lastLat + ? ring.slice(0, -1) + : ring; } function normalizeMode(m) { const t = String(m || '').toLowerCase(); if (t === 'car' || t === 'drive' || t === 'driving') return 'car'; - if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike'; + if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') + return 'bike'; return 'foot'; } /** Route failure remains an explicitly labelled direct line in the caller. */ async function fetchRoute(coordPairs, mode, signal, service) { - try { return await service.route?.(coordPairs, mode, { signal }) || null; } - catch { return null; } + try { + return (await service.route?.(coordPairs, mode, { signal })) || null; + } catch { + return null; + } } function greatCircleM(a, b) { @@ -1188,7 +1536,9 @@ function greatCircleM(a, b) { const dLon = toRad(b.lon - a.lon); const la1 = toRad(a.lat); const la2 = toRad(b.lat); - const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); } diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index cfdba190a..6e6a10264 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -1,4 +1,21 @@ +import { normalizeOverpassFeatures } from '../sources/overpassFeaturesRecords.js'; import { createAnnotationResolver } from './resolver.js'; import { applicationServices } from '../services/application.js'; -const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries }); -export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver; +const resolver = createAnnotationResolver({ + featureSource: applicationServices.features, +}); +export const { + resolveAnnotationTarget, + refineScope, + isRateLimitedOutcome, + isGroundsLikeAsk, + viewportBias, + placesNearViewRecovery, + resolveRegionRingForQuery, + pickWorldFromScreen, + sampleGroundHeight, +} = resolver; + +/** Compatibility adapter for callers supplying raw OSM fixtures. */ +export const selectFootprint = (elements, ...args) => + resolver.selectFootprint(normalizeOverpassFeatures(elements), ...args); diff --git a/src/annotations/hybridAnnotationRenderer.js b/src/annotations/hybridAnnotationRenderer.js index 1c271a721..3e90cf47e 100644 --- a/src/annotations/hybridAnnotationRenderer.js +++ b/src/annotations/hybridAnnotationRenderer.js @@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) { entry.screenProxy = liveProxy(anno, { type: 'label', ring: null }); screen.add(entry.screenProxy); } - } else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) { + } else if ( + anno.type === 'route' && + Array.isArray(anno.path) && + anno.path.length >= 2 + ) { // Drape the path on the tiles (world), caption it with a screen callout // at the path midpoint. entry.worldProxy = liveProxy(anno, { label: null }); @@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) { } else if (anno.label) { screen.add(screenProxy); } - entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null); + entry.screenProxy = entry.screenProxy + ? screenProxy + : anno.label + ? screenProxy + : null; } /** diff --git a/src/annotations/index.js b/src/annotations/index.js index f6e1c901f..51a0f3782 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; * footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver, * and voice tool wiring are identical to the other two branches. */ -export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) { +export function initAnnotations({ + viewer, + tileset = null, + placeSearch, + resolver, +}) { // World-space footprint draping; clamped marks can use the photoreal tiles. if (tileset) { - try { tileset.enableCollision = true; } catch { /* older tileset */ } + try { + tileset.enableCollision = true; + } catch { + /* older tileset */ + } } const renderer = createHybridAnnotationRenderer(viewer); - const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget }); + const engine = createAnnotationEngine({ + viewer, + renderer, + placeSearch, + resolveTarget: resolver?.resolveAnnotationTarget, + }); window.__gevAnnotations = engine; return engine; } diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 7f0807d83..3f757159e 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -1,3 +1,16 @@ +import { requireFeatureSource } from '../sources/featureSource.js'; +import { createOverpassFeatureSource } from '../sources/overpassFeatures.js'; +import { + ringAreaM2, + stitchLine, + bufferCorridor, + simplifyRing, + closeRing, + ringCentroid, + approximateAreaM2, + approximateDistanceM, + pointInPolygon, +} from '../sources/featureGeometry.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; @@ -12,13 +25,16 @@ import { import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; -/** Own annotation lookup caches and the supplied boundary query service. */ +/** Own annotation lookup caches and ranking of supplied feature candidates. */ export function createAnnotationResolver({ boundarySource, signal: lifetime, + featureSource = createOverpassFeatureSource({ + boundarySource, + signal: lifetime, + }), } = {}) { - if (typeof boundarySource?.query !== 'function') - throw new TypeError('Annotation boundaries require a query service'); + requireFeatureSource(featureSource); lifetime?.throwIfAborted(); /** @@ -764,20 +780,6 @@ export function createAnnotationResolver({ } /** Shoelace area (m²) of a [[lon,lat], ...] ring in a local equirectangular projection. */ - function ringAreaM2(ring) { - if (!Array.isArray(ring) || ring.length < 3) return 0; - const mLat = 111_320; - const mLon = mLat * Math.cos(Cesium.Math.toRadians(ring[0][1])); - let area = 0; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const xi = ring[i][0] * mLon; - const yi = ring[i][1] * mLat; - const xj = ring[j][0] * mLon; - const yj = ring[j][1] * mLat; - area += xj * yi - xi * yj; - } - return Math.abs(area) / 2; - } /** Resolve a name through the supplied service; geometry selection stays here. */ async function geocodePlace(query, biasRect, signal, placeSearch) { @@ -979,25 +981,6 @@ export function createAnnotationResolver({ return value?.rateLimited === true; } - /** POST an Overpass QL query and return elements, a transient null, or a throttle object. */ - async function overpassJson(query, timeoutMs = 14000, signal) { - const controller = new AbortController(); - const detach = linkAbort(controller, signal); - const timer = window.setTimeout(() => controller.abort(), timeoutMs); - try { - return await boundarySource.query(query, { - signal: lifetime - ? AbortSignal.any([lifetime, controller.signal]) - : controller.signal, - }); - } catch { - return null; - } finally { - window.clearTimeout(timer); - detach(); - } - } - /** * Resolve an administrative boundary (country/state/county/city/neighborhood). * `is_in(point)` returns every admin area containing the point at all levels; @@ -1014,10 +997,9 @@ export function createAnnotationResolver({ const cachedFp = cacheRead(footprintCache, cacheKey); if (cachedFp !== undefined) return cachedFp; - const candidates = await overpassJson( - `[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`, - 14000, - signal, + const candidates = await featureSource.getAdministrativeAreas( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(candidates)) return candidates; if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss) @@ -1025,17 +1007,17 @@ export function createAnnotationResolver({ const queryWords = normalizedWords(query); const scored = []; for (const el of candidates) { - if (el.type !== 'area' || !el.tags) continue; + if (el.category !== 'administrative') continue; // Match against the FULL name set (incl. official_name) so the query can still hit a // verbose official name — but score COMPLETENESS against the CORE name only. A long // official_name ("City and County of San Francisco") otherwise DILUTES the real // boundary's completeness and lets a less-specific duplicate ("San Francisco County") // win — which then has no backing relation and collapses the whole resolution to a dot. const coreWords = normalizedWords( - [el.tags.name, el.tags['name:en']].filter(Boolean).join(' '), + [el.names.primary, el.names.english].filter(Boolean).join(' '), ); const fullWords = normalizedWords( - [el.tags.name, el.tags['name:en'], el.tags.official_name] + [el.names.primary, el.names.english, el.names.official] .filter(Boolean) .join(' '), ); @@ -1053,7 +1035,7 @@ export function createAnnotationResolver({ // one (decisively outranks a " County" style duplicate). const exactName = coreWords.size === queryWords.size && coreOverlap === queryWords.size; - const level = Number(el.tags.admin_level) || 99; + const level = Number(el.level) || 99; // Bias toward the scope's specificity: city/neighborhood prefer the MORE specific // (higher admin_level) match; state/country prefer the broader one. let levelBias; @@ -1109,19 +1091,17 @@ export function createAnnotationResolver({ // 16 s turned finishable region pivots (Sicilia's dense coastline) into permanent // "transients" — every retry died the same death (field test 2026-07-23). The // outline is progressive, so a long budget blocks nothing; repeats are disk-cached. - const relEls = await overpassJson( - `[out:json][timeout:25];area(${cand.el.id})->.x;rel(pivot.x);out geom;`, - 28000, - signal, - ); + const relEls = await featureSource.getAreaGeometry(cand.el.id, { + signal: signal, + }); if (isRateLimitedOutcome(relEls)) return relEls; if (relEls === null) { transient = true; break; } // network blip — don't definitively fail - const relEl = relEls.find((e) => e.type === 'relation'); + const relEl = relEls[0]; if (!relEl) continue; // no relation backing this area — try the next candidate - const coords = elementCoordinates(relEl); + const coords = relEl.coordinates; if (coords.length < 3) continue; // incomplete geometry — try the next let ring = closeRing(coords.map((p) => [p.lon, p.lat])); @@ -1184,25 +1164,20 @@ export function createAnnotationResolver({ */ async function fetchPlaceArea(lat, lon, query, signal) { const queryWords = normalizedWords(query); - const els = await overpassJson( - `[out:json][timeout:20];(` + - `way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` + - `relation(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` + - `relation(around:1500,${lat},${lon})["boundary"="place"]["name"];` + - `);out tags geom;`, - 14000, - signal, + const els = await featureSource.getNeighborhoodAreas( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(els)) return els; if (els === null) return undefined; // transient upstream failure (vs [] = no match) let bestRing = null; let bestScore = 0; for (const el of els) { - const coords = elementCoordinates(el); + const coords = el.coordinates; if (coords.length < 3) continue; - const tags = el.tags || {}; + const names = el.names; const nameWords = normalizedWords( - [tags.name, tags['name:en']].filter(Boolean).join(' '), + [names.primary, names.english].filter(Boolean).join(' '), ); const overlap = wordOverlap(queryWords, nameWords); if (!overlap) continue; @@ -1238,26 +1213,20 @@ export function createAnnotationResolver({ const queryWords = normalizedWords(query); // Tier C — a same-named district / quarter / named commercial area. - const areaEls = await overpassJson( - `[out:json][timeout:20];(` + - `way(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb|city_block"];` + - `relation(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb"];` + - `way(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"];` + - `relation(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"]["type"="multipolygon"];` + - `);out tags geom;`, - 14000, - signal, + const areaEls = await featureSource.getStreetAreas( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(areaEls)) return areaEls; if (areaEls) { let bestRing = null; let bestScore = 0; for (const el of areaEls) { - const coords = elementCoordinates(el); + const coords = el.coordinates; if (coords.length < 3) continue; - const tags = el.tags || {}; + const names = el.names; const nameWords = normalizedWords( - [tags.name, tags['name:en']].filter(Boolean).join(' '), + [names.primary, names.english].filter(Boolean).join(' '), ); const overlap = wordOverlap(queryWords, nameWords); if (!overlap) continue; @@ -1277,23 +1246,20 @@ export function createAnnotationResolver({ } // Tier F — buffer the matching centerline into a corridor ribbon (workhorse). - const wayEls = await overpassJson( - `[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`, - 14000, - signal, + const wayEls = await featureSource.getStreetLines( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(wayEls)) return wayEls; if (wayEls) { const segments = []; for (const el of wayEls) { - const tags = el.tags || {}; + const names = el.names; const nameWords = normalizedWords( - [tags.name, tags['name:en']].filter(Boolean).join(' '), + [names.primary, names.english].filter(Boolean).join(' '), ); if (!wordOverlap(queryWords, nameWords)) continue; - const geom = (el.geometry || []).filter( - (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), - ); + const geom = el.coordinates; if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat])); } if (segments.length) { @@ -1317,119 +1283,6 @@ export function createAnnotationResolver({ return definitive ? null : undefined; } - /** Chain street way-segments into one contiguous polyline by endpoint matching. */ - function stitchLine(segments) { - const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2; - const remaining = segments.map((s) => s.slice()); - let line = remaining.shift(); - let advanced = true; - while (advanced && remaining.length) { - advanced = false; - for (let i = 0; i < remaining.length; i++) { - const s = remaining[i]; - if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1)); - else if (same(line[line.length - 1], s[s.length - 1])) - line = line.concat(s.slice(0, -1).reverse()); - else if (same(line[0], s[s.length - 1])) - line = s.slice(0, -1).concat(line); - else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line); - else continue; - remaining.splice(i, 1); - advanced = true; - break; - } - } - return line; - } - - /** Offset a centerline into a closed corridor ring (~2*halfWidthM wide). */ - function bufferCorridor(line, halfWidthM) { - const lat0 = line[0][1]; - const mLon = 111320 * Math.cos((lat0 * Math.PI) / 180); - const mLat = 111320; - const P = line.map(([lon, lat]) => [lon * mLon, lat * mLat]); - const left = []; - const right = []; - for (let i = 0; i < P.length; i++) { - const a = P[Math.max(0, i - 1)]; - const b = P[Math.min(P.length - 1, i + 1)]; - let dx = b[0] - a[0]; - let dy = b[1] - a[1]; - const len = Math.hypot(dx, dy) || 1; - dx /= len; - dy /= len; - const nx = -dy; - const ny = dx; - left.push([P[i][0] + nx * halfWidthM, P[i][1] + ny * halfWidthM]); - right.push([P[i][0] - nx * halfWidthM, P[i][1] - ny * halfWidthM]); - } - const ring = [...left, ...right.reverse(), left[0]]; - return ring.map(([x, y]) => [x / mLon, y / mLat]); - } - - /** Douglas–Peucker ring simplification with a metres tolerance. Pre-decimates - * very large rings (state/country) to keep the recursion shallow. */ - function simplifyRing(ring, tolM) { - if (ring.length <= 24) return ring; - let pts = ring; - // Hard-cap the input to Douglas-Peucker so a pathological 50k-point country - // boundary can't dominate a frame, even with the iterative implementation. - if (pts.length > 4000) { - const step = Math.ceil(pts.length / 4000); - pts = pts.filter((_, i) => i % step === 0 || i === ring.length - 1); - } - const lat0 = pts[0][1]; - const tol = tolM / (111320 * Math.cos((lat0 * Math.PI) / 180)); - const out = douglasPeucker(pts, tol); - return out.length >= 4 ? out : ring; - } - - function douglasPeucker(points, tol) { - const n = points.length; - if (n < 3) return points.slice(); - // Iterative, index-based (no per-call array slicing / recursion), so a large - // state/country boundary can't blow the call stack or thrash GC and freeze the - // main thread during a voice turn. - const keep = new Uint8Array(n); - keep[0] = 1; - keep[n - 1] = 1; - const stack = [[0, n - 1]]; - while (stack.length) { - const seg = stack.pop(); - const first = seg[0]; - const last = seg[1]; - let index = -1; - let maxD = 0; - const a = points[first]; - const b = points[last]; - for (let i = first + 1; i < last; i += 1) { - const dist = perpDistance(points[i], a, b); - if (dist > maxD) { - maxD = dist; - index = i; - } - } - if (maxD > tol && index > first) { - keep[index] = 1; - stack.push([first, index]); - stack.push([index, last]); - } - } - const out = []; - for (let i = 0; i < n; i += 1) if (keep[i]) out.push(points[i]); - return out; - } - - function perpDistance(p, a, b) { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const len2 = dx * dx + dy * dy; - if (len2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]); - let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; - t = Math.max(0, Math.min(1, t)); - return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); - } - /** * Fetch the best-matching OSM polygon near a point and classify it as a single * `building` (small footprint, gets extruded into a volume) or an `area` (a @@ -1466,30 +1319,10 @@ export function createAnnotationResolver({ const cachedFp = cacheRead(footprintCache, cacheKey); if (cachedFp !== undefined) return cachedFp; - // `out geom` (not `out tags geom`) so relation members come back WITH their - // way geometry — needed to trace multipolygon compounds like the Presidio. - const overpassQuery = ` - [out:json][timeout:25]; - ( - way(around:320,${lat},${lon})["building"]; - relation(around:320,${lat},${lon})["building"]; - way(around:1800,${lat},${lon})["landuse"]; - relation(around:1800,${lat},${lon})["landuse"]; - way(around:1800,${lat},${lon})["leisure"]; - relation(around:1800,${lat},${lon})["leisure"]; - way(around:1800,${lat},${lon})["aeroway"="aerodrome"]; - relation(around:1800,${lat},${lon})["aeroway"="aerodrome"]; - way(around:1800,${lat},${lon})["natural"="water"]["name"]; - relation(around:1800,${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; - way(around:1200,${lat},${lon})["shop"="mall"]; - relation(around:1200,${lat},${lon})["shop"="mall"]; - way(around:800,${lat},${lon})["amenity"]; - way(around:800,${lat},${lon})["tourism"]; + const elements = await featureSource.getFootprints( + { lat, lon }, + { signal: signal }, ); - out geom; - `; - - const elements = await overpassJson(overpassQuery, 12000, signal); if (isRateLimitedOutcome(elements)) return elements; if (elements === null || signal?.aborted) return undefined; const fp = selectFootprint(elements, lat, lon, query, mode); @@ -1501,8 +1334,6 @@ export function createAnnotationResolver({ return null; } - const ENCLOSING_RADIUS_M = 600; // sweep this far for an enclosing named non-building polygon - /** * Find the REAL enclosing "grounds / compound / campus" polygon containing a point * — the smallest NAMED, NON-building `leisure` / `landuse` / `boundary` / `amenity` @@ -1532,30 +1363,14 @@ export function createAnnotationResolver({ const cached = cacheRead(enclosingAreaCache, cacheKey); if (cached !== undefined) return cached; - // Named non-building polygons near the point. `out geom` returns inline geometry so it - // stitches with elementCoordinates/stitchRing (multipolygon outers). Buildings are EXCLUDED - // in JS below (a tag filter can't drop a relation whose members include a building). - const q = ` - [out:json][timeout:25]; - ( - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]["type"="multipolygon"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]["type"="multipolygon"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]["type"="multipolygon"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; - ); - out geom; - `; // Fail-fast budget: an opportunistic enrichment, not worth blocking on a hung Overpass — // but generous enough (12 s ≈ the footprint fetch budget) that an ordinary slow mirror - // doesn't strand a grounds mark as a point. overpassJson returns null on a transient + // doesn't strand a grounds mark as a point. The feature source returns null on a transient // failure (timeout/502). - const elements = await overpassJson(q, 12000, signal); + const elements = await featureSource.getEnclosingAreas( + { lat, lon }, + { signal: signal }, + ); if (isRateLimitedOutcome(elements)) return elements; if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match) @@ -1564,8 +1379,8 @@ export function createAnnotationResolver({ let bestArea = Infinity; let bestNameMatch = false; for (const el of elements) { - if (el.tags?.building) continue; // exclude buildings (the exact feature, never the grounds) - const coords = elementCoordinates(el); + if (el.building) continue; // exclude buildings (the exact feature, never the grounds) + const coords = el.coordinates; if (coords.length < 3) continue; if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment const ringLonLat = closeRing(coords.map((p) => [p.lon, p.lat])); @@ -1577,10 +1392,10 @@ export function createAnnotationResolver({ // makes the Capitol land on "Capitol Square" even though the user said "grounds". const nameWords = normalizedWords( [ - el.tags?.name, - el.tags?.['name:en'], - el.tags?.official_name, - el.tags?.alt_name, + el.names.primary, + el.names.english, + el.names.official, + el.names.alternate, ] .filter(Boolean) .join(' '), @@ -1606,7 +1421,6 @@ export function createAnnotationResolver({ return null; } - const MONUMENT_RADIUS_M = 2500; // search this far from the view centre for a named monument /** A target that reads like a fine-grained monument/marker (vs a building/district). */ function isMonumentLikeQuery(query) { return /\b(monument|memorial|statue|sculpture|fountain|cenotaph|obelisk|plaque|bust)\b/i.test( @@ -1675,18 +1489,10 @@ export function createAnnotationResolver({ // timeout, and one caller's clear() must not kill the others' snap). let pending = monumentInflight.get(centerKey); if (!pending) { - const q = ` - [out:json][timeout:20]; - ( - nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["historic"~"memorial|monument|statue|tomb"]; - nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["tourism"="artwork"]["name"]; - nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["memorial"]["name"]; - ); - out center tags; - `; // Short timeout + fail-fast: this is an opportunistic snap, not worth blocking narration - // on a slow/overloaded Overpass. overpassJson returns null on a transient failure. - pending = overpassJson(q, 6000) + // on a slow/overloaded Overpass. The feature source returns null on a transient failure. + pending = featureSource + .getMonuments({ lat, lon }, { signal: undefined }) .then((elements) => { if (elements === null || isRateLimitedOutcome(elements)) return null; // transient — NEVER cached (a poisoned bucket @@ -1694,16 +1500,12 @@ export function createAnnotationResolver({ const feats = []; for (const el of elements) { const name = - el.tags?.name || - el.tags?.['name:en'] || - el.tags?.official_name || - el.tags?.alt_name; + el.names.primary || + el.names.english || + el.names.official || + el.names.alternate; if (!name) continue; - const p = Number.isFinite(el.lat) - ? { lat: el.lat, lon: el.lon } - : el.center - ? { lat: el.center.lat, lon: el.center.lon } - : null; + const p = el.point; if (!p) continue; feats.push({ name, @@ -1767,20 +1569,20 @@ export function createAnnotationResolver({ let bestScore = -Infinity; for (const element of elements) { - const coords = elementCoordinates(element); + const coords = element.coordinates; if (coords.length < 3) continue; - const tags = element.tags || {}; - const isBuilding = Boolean(tags.building); + const names = element.names; + const isBuilding = element.building; const areaM2 = approximateAreaM2(coords); const nameWords = normalizedWords( [ - tags.name, - tags['name:en'], - tags.official_name, - tags.alt_name, - tags.short_name, + names.primary, + names.english, + names.official, + names.alternate, + names.short, ] .filter(Boolean) .join(' '), @@ -1848,7 +1650,10 @@ export function createAnnotationResolver({ best = { ring: closeRing(coords.map((p) => [p.lon, p.lat])), kind: isBuilding ? 'building' : 'area', - heightM: isBuilding ? buildingHeightFromTags(tags, areaM2) : null, + heightM: isBuilding + ? (element.heightM ?? + Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6))) + : null, }; } } @@ -1856,213 +1661,6 @@ export function createAnnotationResolver({ return best; } - /** Estimate a building's height (m) from OSM tags, falling back to footprint size. */ - function buildingHeightFromTags(tags, areaM2) { - const explicit = parseMeters(tags.height || tags['building:height']); - if (explicit) return explicit; - const levels = Number.parseFloat(tags['building:levels']); - const roof = parseMeters(tags['roof:height']) || 0; - if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roof; - const side = Math.sqrt(Math.max(1, areaM2)); - return Math.max(10, Math.min(70, side * 0.6)); - } - - function parseMeters(value) { - if (value == null) return 0; - const n = Number.parseFloat(String(value).replace(',', '.')); - if (!Number.isFinite(n) || n <= 0) return 0; - return /\b(ft|feet|foot)\b/i.test(String(value)) ? n * 0.3048 : n; - } - - // --- geometry helpers ------------------------------------------------------- - - function elementCoordinates(element) { - if (Array.isArray(element.geometry)) { - return element.geometry.filter( - (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), - ); - } - if (!Array.isArray(element.members)) return []; - // For a multipolygon relation the boundary is split across several outer - // ways (the Presidio has 8), so chain them into one ring by matching - // endpoints rather than taking a single segment. - const outerWays = element.members - .filter( - (m) => (m.role === 'outer' || !m.role) && Array.isArray(m.geometry), - ) - .map((m) => - m.geometry.filter( - (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), - ), - ) - .filter((w) => w.length >= 2); - return stitchRing(outerWays); - } - - /** - * Stitch relation outer ways into a footprint ring. Builds ALL connected - * components (a multipolygon outer boundary is split across several ways), then - * returns the largest component whose endpoints actually meet — a true closed - * ring. If no component closes cleanly, it accepts the largest only when the - * endpoint gap is small relative to its own span (a minor seam); otherwise the - * relation geometry is incomplete and it returns [] rather than fabricate a - * straight chord across the gap (the reported bay-spanning blob). - */ - function stitchRing(ways) { - if (!ways.length) return []; - const components = buildRingComponents(ways); - if (!components.length) return []; - - const CLOSE_TOL_M = 30; // endpoints within 30 m → a genuinely closed ring - const closed = components.filter((c) => endpointGapM(c) <= CLOSE_TOL_M); - if (closed.length) { - // Pick by projected AREA, not vertex count — a small, highly-detailed island - // must not beat the large simple mainland. (For a disjoint multipolygon this - // returns only the largest closed outer ring; full multi-ring rendering is a - // renderer change tracked separately.) - return largestByArea(closed); - } - - // Nothing closes exactly. Bridge a seam ONLY when the gap is BOTH a small - // fraction of the feature's own span (so half a compound's boundary missing is - // rejected) AND under a hard absolute ceiling (so a state/country can never get a - // kilometres-long chord — the bay blob). A complete relation closes at ~0; a - // genuine seam (e.g. the Presidio's ~few-hundred-m coastline gap) bridges; a - // truly incomplete relation is rejected → point fallback. - const largest = largestByArea(components); - const allow = Math.min(ringSpanM(largest) * 0.15, 1200); - return endpointGapM(largest) <= allow ? largest : []; - } - - /** Largest component by projected area (each component is an array of {lat,lon}). */ - function largestByArea(components) { - let best = components[0]; - let bestArea = approximateAreaM2(best); - for (let i = 1; i < components.length; i += 1) { - const area = approximateAreaM2(components[i]); - if (area > bestArea) { - bestArea = area; - best = components[i]; - } - } - return best; - } - - /** Rough diameter (m) of a chain's bounding box. */ - function ringSpanM(chain) { - let minLat = Infinity; - let maxLat = -Infinity; - let minLon = Infinity; - let maxLon = -Infinity; - for (const p of chain) { - if (p.lat < minLat) minLat = p.lat; - if (p.lat > maxLat) maxLat = p.lat; - if (p.lon < minLon) minLon = p.lon; - if (p.lon > maxLon) maxLon = p.lon; - } - return approximateDistanceM(minLat, minLon, maxLat, maxLon); - } - - /** Chain ways into maximal connected components by endpoint matching. */ - function buildRingComponents(ways) { - const same = (a, b) => - Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; - const remaining = ways.map((w) => w.slice()); - const components = []; - while (remaining.length) { - let chain = remaining.shift(); - let grew = true; - while (grew) { - grew = false; - const head = chain[0]; - const tail = chain[chain.length - 1]; - for (let i = 0; i < remaining.length; i += 1) { - const w = remaining[i]; - const ws = w[0]; - const we = w[w.length - 1]; - if (same(tail, ws)) chain = chain.concat(w.slice(1)); - else if (same(tail, we)) - chain = chain.concat(w.slice(0, -1).reverse()); - else if (same(head, we)) chain = w.slice(0, -1).concat(chain); - else if (same(head, ws)) chain = w.slice(1).reverse().concat(chain); - else continue; - remaining.splice(i, 1); - grew = true; - break; - } - } - components.push(chain); - } - return components; - } - - /** Great-circle distance (m) between a chain's first and last vertex. */ - function endpointGapM(chain) { - if (!chain || chain.length < 2) return Infinity; - const a = chain[0]; - const b = chain[chain.length - 1]; - return approximateDistanceM(a.lat, a.lon, b.lat, b.lon); - } - - function closeRing(ring) { - if (ring.length < 3) return ring; - const [fx, fy] = ring[0]; - const [lx, ly] = ring[ring.length - 1]; - if (fx !== lx || fy !== ly) ring.push([fx, fy]); - return ring; - } - - function ringCentroid(ring) { - if (!ring || ring.length < 3) return null; - let sumLat = 0; - let sumLon = 0; - for (const [lon, lat] of ring) { - sumLat += lat; - sumLon += lon; - } - return { lat: sumLat / ring.length, lon: sumLon / ring.length }; - } - - function approximateAreaM2(coords) { - // Shoelace in a local equirectangular projection (good enough for buildings). - if (coords.length < 3) return 0; - const lat0 = coords[0].lat; - const mPerDegLat = 111_320; - const mPerDegLon = mPerDegLat * Math.cos(Cesium.Math.toRadians(lat0)); - let area = 0; - for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { - const xi = coords[i].lon * mPerDegLon; - const yi = coords[i].lat * mPerDegLat; - const xj = coords[j].lon * mPerDegLon; - const yj = coords[j].lat * mPerDegLat; - area += xj * yi - xi * yj; - } - return Math.abs(area) / 2; - } - - function approximateDistanceM(latA, lonA, latB, lonB) { - const latScale = 111_320; - const lonScale = - latScale * Math.cos(Cesium.Math.toRadians((latA + latB) / 2)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); - } - - function pointInPolygon(lon, lat, coords) { - let inside = false; - for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { - const a = coords[i]; - const b = coords[j]; - const intersects = - a.lat > lat !== b.lat > lat && - lon < - ((b.lon - a.lon) * (lat - a.lat)) / - (b.lat - a.lat || Number.EPSILON) + - a.lon; - if (intersects) inside = !inside; - } - return inside; - } - function normalizedWords(value) { return new Set( String(value || '') diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 9ece701b0..c9c6c88b7 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -37,9 +37,9 @@ const PALETTE = { // Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling // back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no // longer collapse into an overlapping blob when their anchors project close together. -const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles -const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size -const MARK_SCALE_MIN = 0.32; // floor so dots never vanish +const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles +const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size +const MARK_SCALE_MIN = 0.32; // floor so dots never vanish const RING_OUTER_R = 34; const RING_INNER_R = 18; const DOT_R = 5; @@ -51,16 +51,22 @@ function markScale(h) { return 1 - t * (1 - MARK_SCALE_MIN); } -export function createScreenAnnotationRenderer(viewer, { - overlayPaintRect = getOverlayPaintRect, - activeTrackedReadoutId = getActiveTrackedReadoutId, -} = {}) { +export function createScreenAnnotationRenderer( + viewer, + { + overlayPaintRect = getOverlayPaintRect, + activeTrackedReadoutId = getActiveTrackedReadoutId, + } = {}, +) { injectStyles(); const { layer, svg, defs } = buildOverlay(); document.body.appendChild(layer); const scene = viewer.scene; - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC); + const occluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + scene.camera.positionWC, + ); const records = new Map(); // anno.id -> { anno, group, parts } const scratch = new Cesium.Cartesian2(); const scratchDir = new Cesium.Cartesian3(); @@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, { return cached.h; } try { - if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') { - const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0)); + if ( + scene.clampToHeightSupported && + typeof scene.clampToHeight === 'function' + ) { + const c = scene.clampToHeight( + Cesium.Cartesian3.fromDegrees(lon, lat, 0), + ); if (c) { const h = Cesium.Cartographic.fromCartesian(c).height; if (Number.isFinite(h) && h > -430 && h < 9000) { @@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, { } } } - } catch { /* tiles not ready */ } + } catch { + /* tiles not ready */ + } const fallback = cached ? cached.h : 0; heightCache.set(key, { h: fallback, settled: false, gen: projGen }); trimHeightCache(); @@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, { // "draw-on" gesture, so they never masquerade as an authoritative boundary. parts.poly = svgEl('polygon', { class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw', - fill: c, stroke: c, + fill: c, + stroke: c, 'fill-opacity': anno.synthesized ? '0.08' : '0.14', 'stroke-width': '2.5', ...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}), @@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, { if (parts.label) group.appendChild(parts.label.node); } else if (anno.type === 'arrow' && anno.to) { parts.path = svgEl('path', { - class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c, - 'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`, + class: 'gev-anno-arrow gev-draw', + fill: 'none', + stroke: c, + 'stroke-width': '3', + 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`, filter: 'url(#gev-sketch)', }); ensureArrowMarker(defs, anno.color || 'primary', c); group.appendChild(parts.path); parts.label = makeCallout(anno.label, c); if (parts.label) group.appendChild(parts.label.node); - } else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) { + } else if ( + anno.type === 'route' && + Array.isArray(anno.path) && + anno.path.length >= 2 + ) { // Multi-waypoint path: a drawn-on polyline with a dot at each waypoint. parts.poly = svgEl('polyline', { - class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c, - 'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`, + class: 'gev-anno-arrow gev-draw', + fill: 'none', + stroke: c, + 'stroke-width': '3', + 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`, filter: 'url(#gev-sketch)', }); ensureArrowMarker(defs, anno.color || 'primary', c); group.appendChild(parts.poly); parts.dots = anno.path.map(() => { - const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' }); + const dot = svgEl('circle', { + class: 'gev-anno-dot', + fill: c, + stroke: '#06121c', + 'stroke-width': '2', + r: '4', + }); group.appendChild(dot); return dot; }); @@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, { } else { // pin / highlight / label — pulsing target rings + a marker dot + callout if (anno.type !== 'label') { - parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' }); - parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' }); + parts.ringOuter = svgEl('circle', { + class: 'gev-anno-ring gev-pulse', + fill: 'none', + stroke: c, + 'stroke-width': '2', + r: '34', + }); + parts.ringInner = svgEl('circle', { + class: 'gev-anno-ring', + fill: c, + 'fill-opacity': '0.12', + stroke: c, + 'stroke-width': '2.5', + r: '18', + filter: 'url(#gev-sketch)', + }); group.appendChild(parts.ringOuter); group.appendChild(parts.ringInner); } - parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' }); + parts.dot = svgEl('circle', { + class: 'gev-anno-dot', + fill: c, + stroke: '#06121c', + 'stroke-width': '2', + r: anno.type === 'label' ? '4' : '5', + }); group.appendChild(parts.dot); - parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' }); + parts.leader = svgEl('line', { + class: 'gev-anno-leader', + stroke: c, + 'stroke-width': '1.5', + 'stroke-opacity': '0.7', + }); group.appendChild(parts.leader); parts.label = makeCallout(anno.label, c); if (parts.label) group.appendChild(parts.label.node); @@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, { // any length. Guarded on the specific property so an unrelated transition // (none here, but defensive) doesn't clear it early. for (const drawEl of group.querySelectorAll('.gev-draw')) { - drawEl.addEventListener('transitionend', (e) => { - if (e.propertyName === 'stroke-dashoffset') { - drawEl.style.strokeDasharray = 'none'; - } - }, { once: true }); + drawEl.addEventListener( + 'transitionend', + (e) => { + if (e.propertyName === 'stroke-dashoffset') { + drawEl.style.strokeDasharray = 'none'; + } + }, + { once: true }, + ); } projectAll(); } catch (error) { // Hard, immediate unwind — a mark that never finished has nothing to // fade out, and the caller (engine rollback) must find a clean board. records.delete(anno.id); - try { group.remove(); } catch { /* never inserted */ } + try { + group.remove(); + } catch { + /* never inserted */ + } if (records.size === 0) heightCache.clear(); throw error; } @@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, { // Anchor at the real surface height (cached + validated), so marks sit on // the ground/building instead of at sea level. The settle-once cache keeps // it stable (no per-frame jitter that earlier broke nearby projections). - const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat)); + const world = Cesium.Cartesian3.fromDegrees( + lon, + lat, + groundHeight(lon, lat), + ); // Reject points behind the camera (they produce wrapped/extreme coords). Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir); - if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null; + if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) + return null; // Reject points beyond the globe horizon. if (!occluder.isPointVisible(world)) return null; - const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch); + const win = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + world, + scratch, + ); if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null; // Reject absurd off-screen projections (anchor not in view). const w = scene.canvas.clientWidth || scene.canvas.width; @@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, { // tracked subject's screen FOOTPRINT is faded. The complete card footprint comes // from the host's ACTUAL painted rectangle after final layout; the billboard extent // is unioned for aircraft/satellites that also own a native tracked graphic. - const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint - const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1) - const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop + const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint + const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1) + const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop const _scratchTrackedWin = new Cesium.Cartesian2(); // Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance. @@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, { const ent = viewer.trackedEntity; const now = Cesium.JulianDate.now(); - const world = typeof ent?.gevDisplayPosition === 'function' - ? ent.gevDisplayPosition() - : null; + const world = + typeof ent?.gevDisplayPosition === 'function' + ? ent.gevDisplayPosition() + : null; const win = world - ? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin) + ? Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + world, + _scratchTrackedWin, + ) : null; // Billboard box — centered on the anchor, magnified by scaleByDistance. @@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, { const baseH = bb.height?.getValue?.(now) ?? 28; let scale = 1; const sbd = bb.scaleByDistance?.getValue?.(now); - if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world)); - const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2; + if (sbd) + scale = nearFarValue( + sbd, + Cesium.Cartesian3.distance(scene.camera.positionWC, world), + ); + const hw = (baseW * scale) / 2; + const hh = (baseH * scale) / 2; const bbLeft = win.x - hw; const bbRight = win.x + hw; const bbTop = win.y - hh; @@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, { if (![left, right, top, bottom].every(Number.isFinite)) return null; const m = TRACKED_BBOX_MARGIN; - return { left: left - m, right: right + m, top: top - m, bottom: bottom + m }; + return { + left: left - m, + right: right + m, + top: top - m, + bottom: bottom + m, + }; } function projectAll() { @@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, { const w = scene.canvas.clientWidth || scene.canvas.width; svg.setAttribute('viewBox', `0 0 ${w} ${h}`); // Shrink point-marker geometry as the camera pulls back (declutter the blob). - const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000); + const mScale = markScale( + viewer.camera.positionCartographic?.height ?? 1000, + ); for (const rec of records.values()) { const { anno, group, parts } = rec; @@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, { if (anno.type === 'area' && parts.poly) { const pts = []; - let cx = 0; let cy = 0; let n = 0; + let cx = 0; + let cy = 0; + let n = 0; for (const [lon, lat] of anno.ring) { const p = project(lon, lat); if (!p) continue; pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`); - cx += p.x; cy += p.y; n++; + cx += p.x; + cy += p.y; + n++; } if (pts.length >= 3) { parts.poly.setAttribute('points', pts.join(' ')); group.style.display = ''; - if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true); + if (parts.label) + positionCallout(parts.label, cx / n, cy / n - 6, true); } else { group.style.display = 'none'; } @@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, { const b = project(anno.to.lon, anno.to.lat); if (a && b) { // gentle arc so the connector reads as a drawn gesture, not a ruler line - const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; - const dx = b.x - a.x; const dy = b.y - a.y; + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + const dx = b.x - a.x; + const dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const bow = Math.min(60, len * 0.18); const ctrlX = mx - (dy / len) * bow; const ctrlY = my + (dx / len) * bow; - parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`); + parts.path.setAttribute( + 'd', + `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`, + ); group.style.display = ''; // Anchor the label to the (stable) endpoint midpoint, lifted toward the bow. - if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true); + if (parts.label) + positionCallout( + parts.label, + mx + (ctrlX - mx) * 0.5, + my + (ctrlY - my) * 0.5 - 8, + true, + ); } else { group.style.display = 'none'; } @@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, { // dots at each resolvable waypoint; hide the rest parts.dots.forEach((dot, i) => { const p = projected[i]; - if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; } - else { dot.style.display = 'none'; } + if (p) { + dot.setAttribute('cx', p.x.toFixed(1)); + dot.setAttribute('cy', p.y.toFixed(1)); + dot.style.display = ''; + } else { + dot.style.display = 'none'; + } }); group.style.display = ''; // label at the middle waypoint - const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)]; - if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true); + const mid = + projected.filter(Boolean)[ + Math.floor(projected.filter(Boolean).length / 2) + ]; + if (parts.label && mid) + positionCallout(parts.label, mid.x, mid.y - 12, true); } else { group.style.display = 'none'; } @@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, { if (p) { group.style.display = ''; const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R; - if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); } - if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); } - if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); } + if (parts.dot) { + parts.dot.setAttribute('cx', p.x.toFixed(1)); + parts.dot.setAttribute('cy', p.y.toFixed(1)); + parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); + } + if (parts.ringOuter) { + parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); + parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); + parts.ringOuter.setAttribute( + 'r', + (RING_OUTER_R * mScale).toFixed(1), + ); + } + if (parts.ringInner) { + parts.ringInner.setAttribute('cx', p.x.toFixed(1)); + parts.ringInner.setAttribute('cy', p.y.toFixed(1)); + parts.ringInner.setAttribute( + 'r', + (RING_INNER_R * mScale).toFixed(1), + ); + } if (parts.label) { // Keep the callout tucked near the (now smaller) reticle by scaling its offset. - const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale; + const lx = p.x + 18 * mScale; + const ly = p.y - 34 * mScale; positionCallout(parts.label, lx, ly, false); if (parts.leader) { - parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1)); - parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1)); + parts.leader.setAttribute('x1', p.x.toFixed(1)); + parts.leader.setAttribute('y1', p.y.toFixed(1)); + parts.leader.setAttribute('x2', lx.toFixed(1)); + parts.leader.setAttribute( + 'y2', + (ly + parts.label.height).toFixed(1), + ); parts.label._leader = parts.leader; // so de-collision can re-point it } } @@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, { const placed = []; for (const rec of records.values()) { const cal = rec.parts.label; - if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) { + if ( + cal && + cal.sized && + rec.group.style.display !== 'none' && + Number.isFinite(cal._x) + ) { placed.push(cal); } } @@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, { let overlapping = false; if (trackedRect) { let bb = null; - try { bb = group.getBBox(); } catch { bb = null; } + try { + bb = group.getBBox(); + } catch { + bb = null; + } if (bb && (bb.width !== 0 || bb.height !== 0)) { // HYSTERESIS: a mark that's already hidden must clear the rect by // TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades @@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, { // frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in // motion), which was the source of the flicker. const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0; - overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m - && bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m; + overlapping = + bb.x <= trackedRect.right + m && + bb.x + bb.width >= trackedRect.left - m && + bb.y <= trackedRect.bottom + m && + bb.y + bb.height >= trackedRect.top - m; } } const target = overlapping ? 0 : 1; @@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, { // (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease // is the ONLY easing — previously the two fought and produced an opacity oscillation.) if (rec._trackedFade !== 1) { - group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade)); + group.setAttribute( + 'opacity', + String((anno.alpha ?? 1) * rec._trackedFade), + ); } } } @@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, { rec.group.classList.remove('gev-in'); rec.group.classList.add('gev-out'); const node = rec.group; - window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360); + window.setTimeout(() => { + try { + node.remove(); + } catch { + /* gone */ + } + }, 360); records.delete(anno.id); // Board emptied → drop accumulated height samples (natural reset point). if (records.size === 0) heightCache.clear(); @@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, { } function destroy() { - try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ } - try { layer.remove(); } catch { /* gone */ } + try { + scene.postRender.removeEventListener(onPostRender); + } catch { + /* torn down */ + } + try { + layer.remove(); + } catch { + /* gone */ + } records.clear(); heightCache.clear(); } @@ -562,7 +729,15 @@ function makeCallout(text, c) { node.appendChild(rect); node.appendChild(accent); node.appendChild(t); - const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false }; + const callout = { + node, + rect, + text: t, + accent, + width: 0, + height: 26, + sized: false, + }; // size the card to the text once it is in the DOM requestAnimationFrame(() => sizeCallout(callout)); return callout; @@ -581,7 +756,9 @@ function sizeCallout(callout) { callout.width = w; callout.height = h; callout.sized = true; - } catch { /* not laid out yet */ } + } catch { + /* not laid out yet */ + } } function positionCallout(callout, x, y, center) { @@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) { const tx = center ? x - callout.width / 2 : x; callout._x = tx; callout._y = y; - callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`); + callout.node.setAttribute( + 'transform', + `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`, + ); } /** @@ -607,7 +787,10 @@ function decollideCallouts(callouts) { const overlapY = a._y < b._y + b.height && a._y + a.height > b._y; if (overlapX && overlapY) { a._y = b._y + b.height + 5; - a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`); + a.node.setAttribute( + 'transform', + `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`, + ); if (a._leader) { a._leader.setAttribute('x2', a._x.toFixed(1)); a._leader.setAttribute('y2', a._y.toFixed(1)); diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js index ef71ce342..bdfb4f1f7 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/annotations/worldAnnotationRenderer.js @@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) { ensureFlowFabricRegistered(); function colorFor(anno) { - return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary); + return Cesium.Color.fromCssColorString( + PALETTE[anno.color] || PALETTE.primary, + ); } // Target-ring radius (meters) scaled to camera height so it reads at any @@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) { const entities = []; anno._entities = entities; - if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') { + if ( + anno.ring && + anno.ring.length >= 3 && + anno.footprintKind === 'building' + ) { // Single building → the PRIMARY highlight is an extruded CLASSIFICATION // volume that tints the real photogrammetry mesh (dome, walls, roof — // everything inside the column) amber and pulses. A flat footprint @@ -94,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) { // actual tiles. A faint wireframe cage rides on top as a secondary cue. const buffered = bufferRing(anno.ring, 3); const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat()); - const groundH = sampleGroundOutside(viewer.scene, anno.ring) - ?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0); + const groundH = + sampleGroundOutside(viewer.scene, anno.ring) ?? + (Number.isFinite(anno.anchor.height) + ? anno.anchor.height - (anno.buildingHeight || 25) + : 0); const baseH = groundH - 3; // Tall headroom is free: classification only colors where tiles exist, so // empty air above the roof isn't tinted — this just guarantees we cover // under-tagged building heights. const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24; // 1) Classification volume — tints the mesh amber, pulsing. - entities.push(dataSource.entities.add({ - polygon: { - hierarchy: new Cesium.PolygonHierarchy(positions), - height: baseH, - extrudedHeight: topH, - perPositionHeight: false, - classificationType: CLASSIFY, - material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })), - }, - })); + entities.push( + dataSource.entities.add({ + polygon: { + hierarchy: new Cesium.PolygonHierarchy(positions), + height: baseH, + extrudedHeight: topH, + perPositionHeight: false, + classificationType: CLASSIFY, + material: new Cesium.ColorMaterialProperty( + liveColor(anno, base, { alpha: 0.45, pulse: true }), + ), + }, + }), + ); // 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate // the footprint so the extruded outline draws ~14 clean verticals, not one // per (often 100+) ring vertex. - const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat()); - entities.push(dataSource.entities.add({ - polygon: { - hierarchy: new Cesium.PolygonHierarchy(cagePositions), - height: baseH, - extrudedHeight: topH, - perPositionHeight: false, - fill: false, - outline: true, - outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }), - }, - })); + const cagePositions = Cesium.Cartesian3.fromDegreesArray( + decimateRing(buffered, 14).flat(), + ); + entities.push( + dataSource.entities.add({ + polygon: { + hierarchy: new Cesium.PolygonHierarchy(cagePositions), + height: baseH, + extrudedHeight: topH, + perPositionHeight: false, + fill: false, + outline: true, + outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }), + }, + }), + ); // 3) Crisp draped base outline so the footprint reads on the ground. - entities.push(dataSource.entities.add({ - polyline: { - positions, - width: 3, - material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }), - clampToGround: true, - classificationType: CLASSIFY, - }, - })); + entities.push( + dataSource.entities.add({ + polyline: { + positions, + width: 3, + material: new Cesium.PolylineGlowMaterialProperty({ + glowPower: 0.25, + color: liveColor(anno, base, { alpha: 0.7 }), + }), + clampToGround: true, + classificationType: CLASSIFY, + }, + }), + ); if (anno.label) entities.push(labelMarker(anno, base, { point: false })); } else if (anno.ring && anno.ring.length >= 3) { // Larger area (district / compound / park) → flat fill draped on the tiles // + a glowing outline. Draping is right here: you can't extrude a whole // neighbourhood, and the GIS overlay shows the boundary clearly. - const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat()); - entities.push(dataSource.entities.add({ - polygon: { - hierarchy: new Cesium.PolygonHierarchy(fillPositions), - // Synthesized (approximate) areas get a fainter fill so they don't read as solid. - material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })), - classificationType: CLASSIFY, - }, - })); - entities.push(dataSource.entities.add({ - polyline: { - positions: fillPositions, - width: 6, - // Synthesized → DASHED outline (signals "approximate, not an authoritative - // boundary", research §8.4/§8.6); real footprints → solid glow. - material: anno.synthesized - ? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 }) - : new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }), - clampToGround: true, - classificationType: CLASSIFY, - }, - })); + const fillPositions = Cesium.Cartesian3.fromDegreesArray( + anno.ring.flat(), + ); + entities.push( + dataSource.entities.add({ + polygon: { + hierarchy: new Cesium.PolygonHierarchy(fillPositions), + // Synthesized (approximate) areas get a fainter fill so they don't read as solid. + material: new Cesium.ColorMaterialProperty( + liveColor(anno, base, { + alpha: anno.synthesized ? 0.1 : 0.2, + pulse: true, + }), + ), + classificationType: CLASSIFY, + }, + }), + ); + entities.push( + dataSource.entities.add({ + polyline: { + positions: fillPositions, + width: 6, + // Synthesized → DASHED outline (signals "approximate, not an authoritative + // boundary", research §8.4/§8.6); real footprints → solid glow. + material: anno.synthesized + ? new Cesium.PolylineDashMaterialProperty({ + color: liveColor(anno, base, { alpha: 0.95 }), + dashLength: 24, + }) + : new Cesium.PolylineGlowMaterialProperty({ + glowPower: 0.35, + color: liveColor(anno, base, { alpha: 1 }), + }), + clampToGround: true, + classificationType: CLASSIFY, + }, + }), + ); if (anno.label) entities.push(labelMarker(anno, base, { point: false })); - } else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) { + } else if ( + anno.type === 'route' && + Array.isArray(anno.path) && + anno.path.length >= 2 + ) { // A real path (street-following) draped on the 3D tiles, with dashes that // FLOW toward the destination (custom st.s + time material; works on the // clamped/classified ground polyline). const positions = Cesium.Cartesian3.fromDegreesArray( anno.path.flatMap((p) => [p.lon, p.lat]), ); - entities.push(dataSource.entities.add({ - polyline: { - positions, - width: 9, - material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary), - clampToGround: true, - classificationType: CLASSIFY, - }, - })); + entities.push( + dataSource.entities.add({ + polyline: { + positions, + width: 9, + material: new FlowMaterialProperty( + PALETTE[anno.color] || PALETTE.primary, + ), + clampToGround: true, + classificationType: CLASSIFY, + }, + }), + ); if (anno.label) entities.push(labelMarker(anno, base, { point: false })); } else if (anno.type === 'arrow' && anno.to) { // Connector draped across the ground from origin to destination. @@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) { Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat), Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat), ]; - entities.push(dataSource.entities.add({ - polyline: { - positions, - width: 16, - material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })), - clampToGround: true, - classificationType: CLASSIFY, - }, - })); + entities.push( + dataSource.entities.add({ + polyline: { + positions, + width: 16, + material: new Cesium.PolylineArrowMaterialProperty( + liveColor(anno, base, { alpha: 0.95 }), + ), + clampToGround: true, + classificationType: CLASSIFY, + }, + }), + ); if (anno.label) { const mid = { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2, }; - entities.push(dataSource.entities.add({ - position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat), - label: labelGraphic(anno, base), - })); + entities.push( + dataSource.entities.add({ + position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat), + label: labelGraphic(anno, base), + }), + ); } } else { // pin / highlight / label — a camera-proportional target ring + a marker. @@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) { // Radius scales with camera height so the ring reads at any altitude. // semiMajor === semiMinor is required AND must hold every frame, so both // axes read the SAME ringRadius() (camera height is constant per frame). - entities.push(dataSource.entities.add({ - position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat), - ellipse: { - semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false), - semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false), - material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })), - outline: false, - classificationType: CLASSIFY, - }, - })); + entities.push( + dataSource.entities.add({ + position: Cesium.Cartesian3.fromDegrees( + anno.anchor.lon, + anno.anchor.lat, + ), + ellipse: { + semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false), + semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false), + material: new Cesium.ColorMaterialProperty( + liveColor(anno, base, { alpha: 0.38, pulse: true }), + ), + outline: false, + classificationType: CLASSIFY, + }, + }), + ); } entities.push(labelMarker(anno, base, { point: true })); } @@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) { function labelMarker(anno, base, { point }) { return dataSource.entities.add({ position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat), - point: point ? { - pixelSize: anno.type === 'label' ? 8 : 14, - color: liveColor(anno, base, { alpha: 1 }), - outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }), - outlineWidth: 3, - heightReference: CLAMP, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - } : undefined, + point: point + ? { + pixelSize: anno.type === 'label' ? 8 : 14, + color: liveColor(anno, base, { alpha: 1 }), + outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }), + outlineWidth: 3, + heightReference: CLAMP, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + } + : undefined, label: anno.label ? labelGraphic(anno, base) : undefined, }); } @@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) { pixelOffset: new Cesium.Cartesian2(0, -16), showBackground: true, backgroundColor: new Cesium.CallbackProperty( - () => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)), + () => + Cesium.Color.fromCssColorString('#0b1622').withAlpha( + 0.72 * (anno.alpha ?? 1), + ), false, ), backgroundPadding: new Cesium.Cartesian2(8, 5), @@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) { this._definitionChanged = new Cesium.Event(); } Object.defineProperties(FlowMaterialProperty.prototype, { - isConstant: { get() { return false; } }, // re-evaluated each frame → it animates - definitionChanged: { get() { return this._definitionChanged; } }, + isConstant: { + get() { + return false; + }, + }, // re-evaluated each frame → it animates + definitionChanged: { + get() { + return this._definitionChanged; + }, + }, }); FlowMaterialProperty.prototype.getType = function getType() { return 'GevRouteFlow'; @@ -394,10 +467,17 @@ function decimateRing(ring, n) { * tiles under those points aren't loaded yet. */ function sampleGroundOutside(scene, ring) { - if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null; + if ( + !scene?.clampToHeightSupported || + typeof scene.clampToHeight !== 'function' + ) + return null; let clon = 0; let clat = 0; - for (const [lon, lat] of ring) { clon += lon; clat += lat; } + for (const [lon, lat] of ring) { + clon += lon; + clat += lat; + } clon /= ring.length; clat /= ring.length; const latS = 111320; @@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) { maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS)); } const out = maxR * 1.5 + 12; - const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]]; + const dirs = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [0.7, 0.7], + [-0.7, -0.7], + [0.7, -0.7], + [-0.7, 0.7], + ]; const samples = []; for (const [dx, dy] of dirs) { const lon = clon + (dx * out) / lonS; @@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) { const h = Cesium.Cartographic.fromCartesian(c).height; if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h); } - } catch { /* tile not ready */ } + } catch { + /* tile not ready */ + } } if (!samples.length) return null; samples.sort((a, b) => a - b); @@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) { function bufferRing(ring, meters) { let clon = 0; let clat = 0; - for (const [lon, lat] of ring) { clon += lon; clat += lat; } + for (const [lon, lat] of ring) { + clon += lon; + clat += lat; + } clon /= ring.length; clat /= ring.length; const latScale = 111320; 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 cbc848bd5..47ffe5658 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'; @@ -18,7 +19,9 @@ import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; -import { localGeoJsonServices } from '../data/localGeojson.js'; +import { localGeoJsonServices } from './localGeojsonServices.js'; +import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js'; +import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js'; const SOURCE_METHODS = Object.freeze({ flights: ['getSnapshot'], @@ -54,6 +57,7 @@ export function createApplicationCatalog({ metadata = LAYER_STATE_REGISTRY, vesselOptions, resolveAsset, + nepalBoundaryResolver, }) { if (!signal?.addEventListener) throw new TypeError('An application lifetime signal is required'); @@ -100,6 +104,10 @@ export function createApplicationCatalog({ }); const catalog = createLayerCatalog( [ + createBhoteKoshiEventLayer(), + createBhoteKoshiLocatorLayer({ + boundaryResolver: nepalBoundaryResolver, + }), flights, military, createApplicationEarthquakes({ source: sources.earthquakes }), @@ -111,6 +119,7 @@ export function createApplicationCatalog({ createApplicationRadio({ surface, source: sources.radio }), createApplicationBikeshare({ source: sources.bikeshare }), createApplicationDirections(), + createApplicationTransit({ surface, source: sources.transit }), vessels, installations, createApplicationAwareness({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index d25ad45ee..9de176605 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { createApplicationCatalog } from './constructCatalog.js'; import { createStandaloneLayerSources } from '../standalone/layerSources.js'; import { catalogControlServices } from './catalog.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; function fixtureSources(ids, calls) { const sources = createStandaloneLayerSources(); @@ -38,7 +39,27 @@ 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, 21); + assert.ok(first.get('transit')); + assert.ok(first.get('bhote-koshi-2026')); + assert.ok(first.get('bhote-koshi-locator')); + const lifecycle = new LayerLifecycle({}); + for (const layer of first.layers) lifecycle.register(layer); + const rows = lifecycle.getAll(); + for (const id of ['bhote-koshi-2026', 'bhote-koshi-locator']) { + assert.equal( + rows.find((row) => row.id === id)?.showInTogglePanel, + false, + `${id} remains registered for Scenes but is absent from Data Layers`, + ); + assert.equal(typeof first.get(id).enable, 'function'); + assert.equal(typeof first.get(id).setParams, 'function'); + } + assert.equal( + rows.find((row) => row.id === 'flights')?.showInTogglePanel, + true, + 'ordinary data layer entries remain visible', + ); assert.deepEqual( first.layers.map(({ id }) => id), second.layers.map(({ id }) => id), diff --git a/src/app/data.js b/src/app/data.js index 257b14583..77975b84f 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -2,7 +2,7 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerPresentation } from './layerPresentation.js'; /** Register the application layer catalog before allowing state restoration. */ export function createApplicationData({ - scene: { viewer }, + scene: { viewer, mapStackController }, controls: { styleManager }, catalog, allowQaRegistration, @@ -27,6 +27,8 @@ export function createApplicationData({ throw new TypeError('An application layer catalog is required'); for (const layer of catalog.layers) dataManager.register(layer); for (const layer of catalog.layers) layer.attachDataManager?.(dataManager); + for (const layer of catalog.layers) + layer.attachMapStackController?.(mapStackController); // Restoration starts only after the caller's complete registry is sealed. dataManager.finalizeRegistrations(catalog.metadata); if (allowQaRegistration) { diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js new file mode 100644 index 000000000..367fc425e --- /dev/null +++ b/src/app/layers/transit.js @@ -0,0 +1,49 @@ +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, source }) { + if (!surface?.groundFloor || !surface?.meshFloor) + throw new TypeError('Transit needs the application surface services'); + const { groundFloor, meshFloor } = surface; + return createTransitLayer({ + source, + 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/app/localGeojsonServices.js b/src/app/localGeojsonServices.js new file mode 100644 index 000000000..83caad8cd --- /dev/null +++ b/src/app/localGeojsonServices.js @@ -0,0 +1,25 @@ +import { governorRequestRender } from '../renderGovernor.js'; +import { + clearSelectedEntityContextForLayer, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, +} from '../data/contextStore.js'; +import { + clearOverlaySource, + setOverlayEntries, + setOverlaySourceVisible, +} from '../overlays/worldOverlay.js'; +/** Existing standalone application operations, shared by its local layers. */ +export const localGeoJsonServices = Object.freeze({ + overlayHost: Object.freeze({ + clearSource: clearOverlaySource, + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + }), + clearSelectedEntityContextForLayer, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, + governorRequestRender, +}); diff --git a/src/app/operations.js b/src/app/operations.js index d284ff210..f5104e960 100644 --- a/src/app/operations.js +++ b/src/app/operations.js @@ -1,3 +1,5 @@ +import { requireFeatureSource } from '../sources/featureSource.js'; +import { createOverpassFeatureSource } from '../sources/overpassFeatures.js'; import { createSurfaceServices } from './surfaceServices.js'; import { createAnnotationResolver } from '../annotations/resolver.js'; import { searchAndFlyTo } from '../locations.js'; @@ -5,7 +7,6 @@ import { searchAndFlyTo } from '../locations.js'; /** Assemble application operations from the caller's request services. */ export function createApplicationOperations({ requests, signal, eventTarget }) { for (const [name, method] of Object.entries({ - boundaries: 'query', terrain: 'getHeights', regional: 'getBrief', weather: 'getConditions', @@ -14,13 +15,20 @@ export function createApplicationOperations({ requests, signal, eventTarget }) { if (typeof requests?.[name]?.[method] !== 'function') throw new TypeError(`Missing application request service: ${name}`); } + const features = requireFeatureSource( + requests.features ?? + createOverpassFeatureSource({ + boundarySource: requests.boundaries, + signal, + }), + ); const surface = createSurfaceServices({ terrainSource: requests.terrain, signal, eventTarget, }); const annotationResolver = createAnnotationResolver({ - boundarySource: requests.boundaries, + featureSource: features, signal, }); return Object.freeze({ @@ -30,7 +38,11 @@ export function createApplicationOperations({ requests, signal, eventTarget }) { searchAndFlyTo: (viewer, query, options = {}) => searchAndFlyTo(viewer, query, { ...options, - boundaries: requests.boundaries, + features, + signal: + signal && options.signal + ? AbortSignal.any([signal, options.signal]) + : signal || options.signal, recoverNearView: annotationResolver.placesNearViewRecovery, }), }); diff --git a/src/app/operations.test.mjs b/src/app/operations.test.mjs index fa1424750..6a8ca8966 100644 --- a/src/app/operations.test.mjs +++ b/src/app/operations.test.mjs @@ -163,3 +163,22 @@ test('annotation lookup and cached outlines use the supplied source independentl { name: 'AbortError' }, ); }); + +test('invalid feature adapters fail before surface listeners are installed', () => { + let installed = 0; + assert.throws( + () => + createApplicationOperations({ + signal: new AbortController().signal, + requests: { ...requests({ getHeights: async () => [] }), features: {} }, + eventTarget: { + addEventListener() { + installed++; + }, + removeEventListener() {}, + }, + }), + /Missing feature operation/, + ); + assert.equal(installed, 0); +}); diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js index 8883c0171..fe7590e3b 100644 --- a/src/app/sourceSlot.js +++ b/src/app/sourceSlot.js @@ -1,52 +1,2 @@ -/** Bind a source contract once for the application without replacing its layer instance. */ -export function createSourceSlot( - initial, - methods, - label = 'Source', - optional = {}, -) { - let active = initial; - let binding = null; - const source = Object.fromEntries( - [...methods, ...Object.keys(optional)].map((method) => [ - method, - (...args) => { - if (typeof active?.[method] !== 'function' && optional[method]) - return optional[method](...args); - if (typeof active?.[method] !== 'function') - throw new Error(`${label} is not configured`); - const owner = binding; - const provider = active; - const value = provider[method](...args); - if (value && typeof value.then === 'function') - return value.then((result) => { - if (binding !== owner || active !== provider) - throw new DOMException(`${label} was replaced`, 'AbortError'); - return result; - }); - return value; - }, - ]), - ); - for (const property of ['label', 'attribution']) { - Object.defineProperty(source, property, { - enumerable: true, - get: () => active?.[property], - }); - } - return { - source, - configure(next) { - if (methods.some((method) => typeof next?.[method] !== 'function')) - throw new TypeError(`Invalid ${label.toLowerCase()}`); - const owner = Symbol(label); - active = next; - binding = owner; - return () => { - if (binding !== owner) return; - active = null; - binding = null; - }; - }, - }; -} +// Compatibility entry; source contracts are owned by sources/. +export { createSourceSlot } from '../sources/sourceSlot.js'; diff --git a/src/app/tools.js b/src/app/tools.js index 956a26bb6..87b51a5f3 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -27,7 +27,13 @@ export function createApplicationTools({ const { viewer, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; - const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); + const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { + isMapStackAvailable: (id) => + mapStackController?.isStackAvailable(id) === true, + }); + dataManager.layers + .get('bhote-koshi-2026') + ?.module.attachSceneController(sceneDirector); defer(() => sceneDirector.destroy()); onSceneDirector?.(sceneDirector); const annotations = initAnnotations({ diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 9ba486f55..4551d830c 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -31,7 +31,8 @@ function body(source, pattern, label) { function ordered(source, needles, label) { let previous = -1; for (const needle of needles) { - const index = source.indexOf(needle, previous + 1); + const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null; + const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1); assert.ok(index >= 0, `${label}: missing ${needle}`); assert.ok(index > previous, `${label}: ${needle} is out of order`); previous = index; @@ -104,7 +105,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => 'Cockpit entry transaction', ); ordered(transaction, [ - 'if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {', + /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, 'if (!entryError) entered = Boolean(cockpitView.enter());', ], 'Cockpit entry transaction'); // No direct camera control: every mutation goes through a layer tracker or @@ -200,11 +201,11 @@ test('accepted navigation releases through PR15-aware ownership before flight', test('validated voice camera destinations share the UI navigation authority facade', () => { assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager, 'camera', 'move_camera', navigate, releaseOptions/); - assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'route', 'fly_route', navigate/); - assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'fire', 'track_entity'/); - assert.match(voice, /runManagedVoiceNavigation\(styleManager, family\.kind, 'track_entity'/); - assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'frame', 'frame_overhead'/); + assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/); + assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/); + assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/); + assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/); + assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/); const trackedVoice = voice.slice( voice.indexOf('async function trackEntity'), voice.indexOf('async function frameOverhead'), @@ -224,20 +225,20 @@ test('validated voice camera destinations share the UI navigation authority faca ordered(move, [ "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", 'const start = () => {', - "const preserveCameraFlight = motion === 'orbit'", + /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', ], 'move validation before handoff'); const route = body( cameraVerbs, - /export function flyRoute\(annoList, args = \{\}, floorFn = null, runNavigation = null, warmFn = null\) \{([\s\S]*?)\n\}/, + /export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/, 'fly route', ); ordered(route, [ 'if (!routes.length)', 'const pts = route.path.map', 'const start = () => {', - "return typeof runNavigation === 'function' ? runNavigation(start) : start();", + /return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/, ], 'route validation before handoff'); // The corridor warm is injected the same way the floor READ is — the dolly // never reaches into the data layer itself, and the voice dispatch is the one diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index dc5f174de..278a86206 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -16,7 +16,10 @@ */ import * as Cesium from 'cesium'; -import { holdContinuousRender, releaseContinuousRender } from './renderGovernor.js'; +import { + holdContinuousRender, + releaseContinuousRender, +} from './renderGovernor.js'; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 }; @@ -26,7 +29,13 @@ const PAN_VIEW_FRACTION_S = { slow: 0.08, normal: 0.2, fast: 0.45 }; * out around this mean, so a route still takes totalM / ROUTE_M_S seconds. */ const ROUTE_M_S = { slow: 20, normal: 40, fast: 90 }; /** Bounded `once` nudges (spec: tune by feel in the field test). */ -const ONCE = { orbitDeg: 30, tiltDeg: 15, rotateDeg: 15, panViewFraction: 0.25, durationS: 0.9 }; +const ONCE = { + orbitDeg: 30, + tiltDeg: 15, + rotateDeg: 15, + panViewFraction: 0.25, + durationS: 0.9, +}; const PITCH_MIN = Cesium.Math.toRadians(-89); const PITCH_MAX = Cesium.Math.toRadians(-5); @@ -134,9 +143,11 @@ const ROUTE_MAX_DESCENT_MPS = 10; /** Whether the operating system asked us to keep motion boring. */ export function prefersReducedMotion() { try { - return typeof window !== 'undefined' - && typeof window.matchMedia === 'function' - && window.matchMedia('(prefers-reduced-motion: reduce)').matches === true; + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches === true + ); } catch { return false; } @@ -185,8 +196,8 @@ export function routeSpeedProfile(u, r) { const t = Math.min(1, Math.max(0, Number.isFinite(u) ? u : 0)); if (!(r > 0)) return { distance: t, speed: 1 }; const area = 1 - r; // ∫₀¹ v du — both ramps together contribute r - const rampArea = (p) => r * ((p * p * p) - ((p * p * p * p) / 2)); - const smoothstep = (p) => p * p * (3 - (2 * p)); + const rampArea = (p) => r * (p * p * p - (p * p * p * p) / 2); + const smoothstep = (p) => p * p * (3 - 2 * p); if (t <= r) { const p = t / r; return { distance: rampArea(p) / area, speed: smoothstep(p) }; @@ -195,7 +206,7 @@ export function routeSpeedProfile(u, r) { const p = (1 - t) / r; return { distance: (area - rampArea(p)) / area, speed: smoothstep(p) }; } - return { distance: ((r / 2) + (t - r)) / area, speed: 1 }; + return { distance: (r / 2 + (t - r)) / area, speed: 1 }; } /** @@ -219,13 +230,22 @@ export function routeBankTargetDeg(turnRateDegS, reducedMotion = false) { * @param {boolean} [reducedMotion] Flatten to a constant altitude. * @returns {number} Metres to add to the mean AGL. */ -export function routeAltitudeOffsetM(arcM, bankFraction, reducedMotion = false) { +export function routeAltitudeOffsetM( + arcM, + bankFraction, + reducedMotion = false, +) { if (reducedMotion) return 0; - const bank = Number.isFinite(bankFraction) ? Math.max(-1, Math.min(1, bankFraction)) : 0; + const bank = Number.isFinite(bankFraction) + ? Math.max(-1, Math.min(1, bankFraction)) + : 0; const straight = 1 - Math.abs(bank); const s = Number.isFinite(arcM) ? arcM : 0; - const breath = Math.sin((2 * Math.PI * s) / ROUTE_BREATH_WAVELENGTH_M) * ROUTE_BREATH_M * straight; - return breath + (Math.abs(bank) * ROUTE_TURN_LIFT_M); + const breath = + Math.sin((2 * Math.PI * s) / ROUTE_BREATH_WAVELENGTH_M) * + ROUTE_BREATH_M * + straight; + return breath + Math.abs(bank) * ROUTE_TURN_LIFT_M; } /** @@ -237,7 +257,8 @@ export function routeAltitudeOffsetM(arcM, bankFraction, reducedMotion = false) */ export function routeEyeHeightM(floorM, offsetM) { const base = Number.isFinite(floorM) ? floorM : 0; - const shaped = base + ROUTE_CAMERA_HEIGHT_M + (Number.isFinite(offsetM) ? offsetM : 0); + const shaped = + base + ROUTE_CAMERA_HEIGHT_M + (Number.isFinite(offsetM) ? offsetM : 0); return Math.max(base + ROUTE_MIN_CLEARANCE_M, shaped); } @@ -309,10 +330,12 @@ function arcPoint(state, s, result) { let hi = cumM.length - 2; while (lo < hi) { const mid = (lo + hi + 1) >> 1; - if (cumM[mid] <= clamped) lo = mid; else hi = mid - 1; + if (cumM[mid] <= clamped) lo = mid; + else hi = mid - 1; } const segLen = cumM[lo + 1] - cumM[lo]; - const t = segLen > 1e-6 ? Math.min(1, Math.max(0, (clamped - cumM[lo]) / segLen)) : 0; + const t = + segLen > 1e-6 ? Math.min(1, Math.max(0, (clamped - cumM[lo]) / segLen)) : 0; return Cesium.Cartesian3.lerp(pts[lo], pts[lo + 1], t, result); } @@ -322,7 +345,9 @@ function horizontalChord(state, sA, sB, up, result) { const b = arcPoint(state, sB, _arcB); Cesium.Cartesian3.subtract(b, a, result); const vertical = Cesium.Cartesian3.multiplyByScalar( - up, Cesium.Cartesian3.dot(result, up), _chordVertical, + up, + Cesium.Cartesian3.dot(result, up), + _chordVertical, ); Cesium.Cartesian3.subtract(result, vertical, result); const len = Cesium.Cartesian3.magnitude(result); @@ -346,7 +371,8 @@ export function signedTurnRad(a, b, up) { const cross = Cesium.Cartesian3.cross(a, b, _turnCross); const sinLeft = Cesium.Cartesian3.dot(cross, up); const cosTurn = Cesium.Cartesian3.dot(a, b); - if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI; + if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) + return Math.PI; return -Math.atan2(sinLeft, cosTurn); } @@ -360,15 +386,28 @@ export function signedTurnRad(a, b, up) { * @param {number} [maxCells] Cap on the returned cells. * @returns {Array<{lat: number, lon: number}>} Cells to warm. */ -export function routeCorridorCells(state, fromM, spanM, maxCells = ROUTE_WARM_MAX_CELLS) { +export function routeCorridorCells( + state, + fromM, + spanM, + maxCells = ROUTE_WARM_MAX_CELLS, +) { const out = []; if (!(state?.totalM > 0) || !(spanM > 0) || !(maxCells > 0)) return out; const start = Math.max(0, Math.min(state.totalM, fromM)); const end = Math.max(0, Math.min(state.totalM, fromM + spanM)); const seen = new Set(); - for (let s = start; s <= end + 1e-6 && out.length < maxCells; s += ROUTE_WARM_STEP_M) { + for ( + let s = start; + s <= end + 1e-6 && out.length < maxCells; + s += ROUTE_WARM_STEP_M + ) { const point = arcPoint(state, s, _warmPos); - const carto = Cesium.Cartographic.fromCartesian(point, Cesium.Ellipsoid.WGS84, _warmCarto); + const carto = Cesium.Cartographic.fromCartesian( + point, + Cesium.Ellipsoid.WGS84, + _warmCarto, + ); const lat = Number(Cesium.Math.toDegrees(carto.latitude).toFixed(3)); const lon = Number(Cesium.Math.toDegrees(carto.longitude).toFixed(3)); const key = `${lat},${lon}`; @@ -440,7 +479,12 @@ export function routeColdSeedFloorM(pathHeightM, cameraHeightM) { * @returns {boolean} Whether the corridor's floor is now RESOLVED. */ function acquireCorridorFloor(state, takeWhole) { - const cells = routeCorridorCells(state, state.traveled, ROUTE_WARM_START_M, ROUTE_MESH_PROBE_CELLS); + const cells = routeCorridorCells( + state, + state.traveled, + ROUTE_WARM_START_M, + ROUTE_MESH_PROBE_CELLS, + ); if (!cells.length) return false; const known = []; const cold = []; @@ -449,15 +493,22 @@ function acquireCorridorFloor(state, takeWhole) { let floor = Number.NaN; try { floor = state.floorFn(cell.lat, cell.lon); - } catch { /* a hostile read is the same as a cold one */ } - if (Number.isFinite(floor)) known.push(floor); else cold.push(cell); + } catch { + /* a hostile read is the same as a cold one */ + } + if (Number.isFinite(floor)) known.push(floor); + else cold.push(cell); } } else { cold.push(...cells); } // The probe fires once, for exactly the cells the cache could not answer. - if (cold.length && !state.meshProbeSpent && typeof state.probeFn === 'function') { + if ( + cold.length && + !state.meshProbeSpent && + typeof state.probeFn === 'function' + ) { state.meshProbeSpent = true; // latched whatever it returns — one per flight try { const probe = state.probeFn(cold) || {}; @@ -467,7 +518,9 @@ function acquireCorridorFloor(state, takeWhole) { // Only a probe that reached EVERY cold cell resolves the corridor. state.meshProbeCoveredCold = probe.sampled >= cold.length; } - } catch { /* tiles not streamed, or a scene mid-teardown */ } + } catch { + /* tiles not streamed, or a scene mid-teardown */ + } } if (Number.isFinite(state.meshProbeFloorM)) known.push(state.meshProbeFloorM); if (!known.length) return false; @@ -476,9 +529,10 @@ function acquireCorridorFloor(state, takeWhole) { // A partial answer still RAISES the held floor — knowing something is better // than the seed — but it does not resolve the corridor, so the safe hold and // the arm stay in force. - state.floorM = takeWhole || !Number.isFinite(state.floorM) - ? floor - : Math.max(state.floorM, floor); + state.floorM = + takeWhole || !Number.isFinite(state.floorM) + ? floor + : Math.max(state.floorM, floor); if (cold.length && !state.meshProbeCoveredCold) return false; state.floorKnown = true; return true; @@ -495,17 +549,25 @@ function acquireCorridorFloor(state, takeWhole) { export function probeMeshFloorM(scene, cells) { const requested = Array.isArray(cells) ? cells.length : 0; const empty = { heightM: Number.NaN, sampled: 0, requested }; - if (!scene || typeof scene.sampleHeight !== 'function' || !requested) return empty; + if (!scene || typeof scene.sampleHeight !== 'function' || !requested) + return empty; let heightM = Number.NaN; let sampled = 0; for (const cell of cells) { try { - const carto = Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto); + const carto = Cesium.Cartographic.fromDegrees( + cell.lon, + cell.lat, + 0, + _probeCarto, + ); const height = scene.sampleHeight(carto); if (!Number.isFinite(height)) continue; sampled += 1; heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height; - } catch { /* tiles not ready for this cell */ } + } catch { + /* tiles not ready for this cell */ + } } return { heightM, sampled, requested }; } @@ -526,8 +588,14 @@ export function probeMeshFloorM(scene, cells) { * @returns {Object} Motion state for advanceRouteFlight(). */ export function createRouteFlight({ - pts, cumM, speed = 'normal', floorFn = null, warmFn = null, probeFn = null, - cameraHeightM = Number.NaN, reducedMotion = false, + pts, + cumM, + speed = 'normal', + floorFn = null, + warmFn = null, + probeFn = null, + cameraHeightM = Number.NaN, + reducedMotion = false, }) { const totalM = cumM[cumM.length - 1]; const cruiseMps = ROUTE_M_S[speed] || ROUTE_M_S.normal; @@ -537,7 +605,9 @@ export function createRouteFlight({ // than asked. Those are shorter than the camera is tall — nothing to see. const durationS = Math.max(0.5, totalM / cruiseMps); const pathHeightM = Cesium.Cartographic.fromCartesian( - pts[0], Cesium.Ellipsoid.WGS84, _warmCarto, + pts[0], + Cesium.Ellipsoid.WGS84, + _warmCarto, ).height; const state = { kind: 'route', @@ -562,7 +632,9 @@ export function createRouteFlight({ // already there, and the user is already looking at the world from it. It // is a NON-DESCENT guarantee, not a clearance guarantee — nothing can // promise clearance over ground that was never measured. - safeHoldHeightM: Number.isFinite(cameraHeightM) ? cameraHeightM : Number.NaN, + safeHoldHeightM: Number.isFinite(cameraHeightM) + ? cameraHeightM + : Number.NaN, coldFrames: 0, floorFromMeshProbe: false, meshProbeSpent: false, @@ -607,8 +679,14 @@ export function advanceRouteFlight(state, dt) { // altitude derived from nothing. While armed the camera is not touched at // all: no teleport onto the route, no descent. The wait is covered by the // model speaking its confirmation, and a warm cache skips it entirely. - const canAnswer = typeof state.floorFn === 'function' || typeof state.probeFn === 'function'; - if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) { + const canAnswer = + typeof state.floorFn === 'function' || typeof state.probeFn === 'function'; + if ( + canAnswer && + !state.floorKnown && + !state.departed && + state.armS < ROUTE_ARM_S + ) { state.armS += step; if (!acquireCorridorFloor(state, true)) { return { arming: true, finished: false, progress: 0, bankDeg: 0 }; @@ -616,7 +694,7 @@ export function advanceRouteFlight(state, dt) { } state.departed = true; - state.u = Math.min(1, (state.u || 0) + (step / state.durationS)); + state.u = Math.min(1, (state.u || 0) + step / state.durationS); const profile = routeSpeedProfile(state.u, state.ramp); const traveled = state.totalM * profile.distance; state.traveled = traveled; @@ -624,20 +702,51 @@ export function advanceRouteFlight(state, dt) { state.groundSpeedMps = cruiseMps * profile.speed; const pos = arcPoint(state, traveled, _framePos); - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, _frameCarto); - const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormalCartographic(carto, _frameUp); + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + _frameCarto, + ); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormalCartographic( + carto, + _frameUp, + ); // Planned turn: compare where we came from with where we are going, over a // window centred on the camera. Peaks AT the corner, tapers either side. const halfWindowM = Math.max(20, (ROUTE_BANK_WINDOW_S * cruiseMps) / 2); - const inbound = horizontalChord(state, traveled - halfWindowM, traveled, up, _frameIn); - const outbound = horizontalChord(state, traveled, traveled + halfWindowM, up, _frameOut); - const turnRateDegS = (inbound && outbound) - ? Cesium.Math.toDegrees(signedTurnRad(inbound, outbound, up)) / ROUTE_BANK_WINDOW_S - : 0; + const inbound = horizontalChord( + state, + traveled - halfWindowM, + traveled, + up, + _frameIn, + ); + const outbound = horizontalChord( + state, + traveled, + traveled + halfWindowM, + up, + _frameOut, + ); + const turnRateDegS = + inbound && outbound + ? Cesium.Math.toDegrees(signedTurnRad(inbound, outbound, up)) / + ROUTE_BANK_WINDOW_S + : 0; const bankTarget = routeBankTargetDeg(turnRateDegS, state.reducedMotion); - state.bankLeadDeg = approachValue(state.bankLeadDeg, bankTarget, ROUTE_BANK_LEAD_RATE, step); - state.bankDeg = approachValue(state.bankDeg, state.bankLeadDeg, ROUTE_BANK_SETTLE_RATE, step); + state.bankLeadDeg = approachValue( + state.bankLeadDeg, + bankTarget, + ROUTE_BANK_LEAD_RATE, + step, + ); + state.bankDeg = approachValue( + state.bankDeg, + state.bankLeadDeg, + ROUTE_BANK_SETTLE_RATE, + step, + ); // Wings level for takeoff and landing. The speed profile is already a // smoothstep envelope that is 1 across the cruise and exactly 0 at both ends, // so reusing it unwinds the roll as the dolly slows — and guarantees the LAST @@ -650,9 +759,12 @@ export function advanceRouteFlight(state, dt) { ROUTE_LOOKAHEAD_MAX_M, Math.max(ROUTE_LOOKAHEAD_MIN_M, ROUTE_LOOKAHEAD_S * cruiseMps), ); - const gaze = horizontalChord(state, traveled, traveled + lookaheadM, up, _frameGaze) - || outbound || inbound; - if (gaze && !state.headingDir) state.headingDir = Cesium.Cartesian3.clone(gaze); + const gaze = + horizontalChord(state, traveled, traveled + lookaheadM, up, _frameGaze) || + outbound || + inbound; + if (gaze && !state.headingDir) + state.headingDir = Cesium.Cartesian3.clone(gaze); else if (gaze) { // Rotate the heading TOWARD the gaze about the local up, rather than // lerping the two vectors. A Cartesian lerp cannot cross an antipodal pair @@ -664,11 +776,18 @@ export function advanceRouteFlight(state, dt) { // signedTurnRad is + for a RIGHT turn; a positive rotation about up is a // LEFT turn, so the step is negated. const rotation = Cesium.Matrix3.fromQuaternion( - Cesium.Quaternion.fromAxisAngle(up, -turnToGaze * k, _headingQuat), _headingMatrix, + Cesium.Quaternion.fromAxisAngle(up, -turnToGaze * k, _headingQuat), + _headingMatrix, + ); + const turned = Cesium.Matrix3.multiplyByVector( + rotation, + state.headingDir, + _frameDir, ); - const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir); const vertical = Cesium.Cartesian3.multiplyByScalar( - up, Cesium.Cartesian3.dot(turned, up), _chordVertical, + up, + Cesium.Cartesian3.dot(turned, up), + _chordVertical, ); Cesium.Cartesian3.subtract(turned, vertical, turned); if (Cesium.Cartesian3.magnitude(turned) > 1e-6) { @@ -679,9 +798,10 @@ export function advanceRouteFlight(state, dt) { // has no azimuth to fly: point east so the frame stays orthonormal. if (!state.headingDir) { const east = Cesium.Cartesian3.fromElements(-pos.y, pos.x, 0, _frameGaze); - state.headingDir = Cesium.Cartesian3.magnitude(east) > 1e-6 - ? Cesium.Cartesian3.normalize(east, new Cesium.Cartesian3()) - : Cesium.Cartesian3.fromElements(1, 0, 0, new Cesium.Cartesian3()); + state.headingDir = + Cesium.Cartesian3.magnitude(east) > 1e-6 + ? Cesium.Cartesian3.normalize(east, new Cesium.Cartesian3()) + : Cesium.Cartesian3.fromElements(1, 0, 0, new Cesium.Cartesian3()); } const heading = state.headingDir; @@ -698,17 +818,23 @@ export function advanceRouteFlight(state, dt) { // Orthonormal camera frame, then roll it about its own forward axis: a // positive rotation drops the right wing, which is what a right turn wants. const right = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(direction, up, _frameRight), _frameRight, + Cesium.Cartesian3.cross(direction, up, _frameRight), + _frameRight, ); const levelUp = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(right, direction, _frameLevelUp), _frameLevelUp, + Cesium.Cartesian3.cross(right, direction, _frameLevelUp), + _frameLevelUp, ); const bankRad = Cesium.Math.toRadians(appliedBankDeg); let bankedUp = levelUp; if (Math.abs(bankRad) > 1e-6) { const quat = Cesium.Quaternion.fromAxisAngle(direction, bankRad, _bankQuat); const rotation = Cesium.Matrix3.fromQuaternion(quat, _bankMatrix); - bankedUp = Cesium.Matrix3.multiplyByVector(rotation, levelUp, _frameBankedUp); + bankedUp = Cesium.Matrix3.multiplyByVector( + rotation, + levelUp, + _frameBankedUp, + ); } // Keep the corridor ahead of the camera warm. The batch dedupes to coarse @@ -723,16 +849,20 @@ export function advanceRouteFlight(state, dt) { // the camera reaches it. Both are cached reads — nothing samples the mesh. let sampledFloor = Number.NaN; if (typeof state.floorFn === 'function') { - const aheadPos = arcPoint(state, traveled + (lookaheadM / 2), _frameAheadPos); + const aheadPos = arcPoint(state, traveled + lookaheadM / 2, _frameAheadPos); const aheadCarto = Cesium.Cartographic.fromCartesian( - aheadPos, Cesium.Ellipsoid.WGS84, _frameAheadCarto, + aheadPos, + Cesium.Ellipsoid.WGS84, + _frameAheadCarto, ); try { const here = state.floorFn( - Cesium.Math.toDegrees(carto.latitude), Cesium.Math.toDegrees(carto.longitude), + Cesium.Math.toDegrees(carto.latitude), + Cesium.Math.toDegrees(carto.longitude), ); const ahead = state.floorFn( - Cesium.Math.toDegrees(aheadCarto.latitude), Cesium.Math.toDegrees(aheadCarto.longitude), + Cesium.Math.toDegrees(aheadCarto.latitude), + Cesium.Math.toDegrees(aheadCarto.longitude), ); const warm = [here, ahead].filter((value) => Number.isFinite(value)); if (warm.length) sampledFloor = Math.max(...warm); @@ -745,7 +875,9 @@ export function advanceRouteFlight(state, dt) { // route's own vertices carry height 0, so trusting them put the eye 1.3 km // UNDER Albuquerque. Hold the last floor we actually knew; before we have // ever known one, hold the conservative seed captured at flight start. - state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM; + state.floorM = Number.isFinite(state.floorM) + ? state.floorM + : state.coldSeedFloorM; state.coldFrames += 1; } else if (!state.floorKnown) { // Data arrived AFTER the arm gave up. Easing is the right move now: the @@ -758,7 +890,12 @@ export function advanceRouteFlight(state, dt) { state.floorM = routeFloorHoldM(state.floorM, sampledFloor, step); } - state.liftBankDeg = approachValue(state.liftBankDeg, appliedBankDeg, ROUTE_LIFT_TRACK_RATE, step); + state.liftBankDeg = approachValue( + state.liftBankDeg, + appliedBankDeg, + ROUTE_LIFT_TRACK_RATE, + step, + ); const bankFraction = state.liftBankDeg / ROUTE_BANK_MAX_DEG; state.offsetM = approachValue( state.offsetM, @@ -771,7 +908,8 @@ export function advanceRouteFlight(state, dt) { // real rise is cleared on the frame it is seen, smoothing or not. let heightM = Math.max( routeEyeHeightM(state.floorM, state.offsetM), - (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M, + (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + + ROUTE_MIN_CLEARANCE_M, ); // Still no terrain data (the arm timed out, or the warm never landed at all): // hold the launch altitude for as long as that lasts. It is not a clearance @@ -782,11 +920,18 @@ export function advanceRouteFlight(state, dt) { } // Releasing that hold onto late data is a controlled descent, never a drop. if (Number.isFinite(state.appliedHeightM)) { - heightM = Math.max(heightM, state.appliedHeightM - (ROUTE_MAX_DESCENT_MPS * step)); + heightM = Math.max( + heightM, + state.appliedHeightM - ROUTE_MAX_DESCENT_MPS * step, + ); } state.appliedHeightM = heightM; const eye = Cesium.Cartesian3.fromRadians( - carto.longitude, carto.latitude, heightM, Cesium.Ellipsoid.WGS84, _frameEye, + carto.longitude, + carto.latitude, + heightM, + Cesium.Ellipsoid.WGS84, + _frameEye, ); state.appliedBankDeg = appliedBankDeg; @@ -842,7 +987,10 @@ export function activeCameraMotionId() { * @param {string} [reason] Diagnostic label. * @returns {{wasActive: boolean, reason: string, leveled: boolean}} */ -export function interruptCameraMotionIfActive(motionId, reason = 'owner-cancel') { +export function interruptCameraMotionIfActive( + motionId, + reason = 'owner-cancel', +) { if (!motionId || _active?.motionId !== motionId) { return { wasActive: false, reason, leveled: false }; } @@ -850,7 +998,11 @@ export function interruptCameraMotionIfActive(motionId, reason = 'owner-cancel') } function clearLookAt() { - try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } catch { /* teardown race */ } + try { + _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } catch { + /* teardown race */ + } } /** @@ -869,7 +1021,9 @@ function levelCameraRoll() { if (!cam || typeof cam.setView !== 'function') return false; const roll = Cesium.Math.negativePiToPi(cam.roll || 0); if (Math.abs(roll) < 1e-5) return false; - cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } }); + cam.setView({ + orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 }, + }); return true; } catch { return false; // teardown race @@ -884,7 +1038,12 @@ export function interruptCameraMotion(reason = 'interrupt') { // finding). Leave the transform to the tracker; it re-asserts every frame. const tracking = Boolean(_viewer?.trackedEntity); const wasRoute = wasActive && _active.kind === 'route'; - if (wasActive && !tracking && (_active.kind === 'orbit' || _active.kind === 'route')) clearLookAt(); + if ( + wasActive && + !tracking && + (_active.kind === 'orbit' || _active.kind === 'route') + ) + clearLookAt(); // Instant, not eased: the user grabbed the camera, or the flight is over. // Either way the next thing they hold must be a level horizon. const leveled = wasRoute && !tracking ? levelCameraRoll() : false; @@ -897,7 +1056,9 @@ export function interruptCameraMotion(reason = 'interrupt') { export function getActiveCameraMotion() { if (!_active) return null; const info = { - kind: _active.kind, mode: _active.mode, speed: _active.speed, + kind: _active.kind, + mode: _active.mode, + speed: _active.speed, motionId: _active.motionId || 0, }; if (_active.kind === 'route') { @@ -920,7 +1081,10 @@ function onTick() { // Wall-clock dt: clock.currentTime FREEZES when the app clock isn't // animating, which starved motion to the 1 ms floor (harness catch). const nowMs = performance.now(); - const dt = Math.min(0.25, Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000)); + const dt = Math.min( + 0.25, + Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000), + ); _active.lastMs = nowMs; const cam = _viewer.camera; const m = _active; @@ -931,7 +1095,9 @@ function onTick() { m.elapsed = (m.elapsed || 0) + dt; const t = Math.min(1, m.elapsed / ONCE.durationS); scale = 1 - (1 - t) * (1 - t); // ease-out progress share - if (t >= 1) { interruptCameraMotion('once-complete'); } + if (t >= 1) { + interruptCameraMotion('once-complete'); + } } try { @@ -939,13 +1105,19 @@ function onTick() { // Armed but target-less (started mid-flight, e.g. "fly to X and orbit // it"): keep trying for a ground target until the camera settles. m.pendingS = (m.pendingS || 0) + dt; - if (m.pendingS > 30) { interruptCameraMotion('orbit-arm-timeout'); return; } + if (m.pendingS > 30) { + interruptCameraMotion('orbit-arm-timeout'); + return; + } // pickEllipsoid hits the globe from ANY altitude — target existence // alone is not arrival. Camera flights are scene tweens, and multi-stage // flights have GAPS between tweens: require the tween queue quiet for a // continuous 0.8 s before capturing the orbit framing at the REAL // destination (field finding: Eiffel chain orbited from mid-flight). - if (_viewer.scene.tweens.length > 0) { m.settledS = 0; return; } + if (_viewer.scene.tweens.length > 0) { + m.settledS = 0; + return; + } m.settledS = (m.settledS || 0) + dt; if (m.settledS < 0.8) return; const target = _getTarget?.(_viewer); @@ -956,16 +1128,24 @@ function onTick() { const tCarto2 = Cesium.Cartographic.fromCartesian(target); const dh2 = carto2.height - tCarto2.height; m.target = target; - m.hpr = new Cesium.HeadingPitchRange(cam2.heading, -Math.asin(Math.min(1, Math.max(-1, dh2 / Math.max(1, range)))), range); + m.hpr = new Cesium.HeadingPitchRange( + cam2.heading, + -Math.asin(Math.min(1, Math.max(-1, dh2 / Math.max(1, range)))), + range, + ); m.hprStartHeading = cam2.heading; m.pending = false; m.elapsed = 0; // once-mode budget starts when the orbit actually begins return; } if (m.kind === 'orbit') { - const rate = Cesium.Math.toRadians(ORBIT_DEG_S[m.speed]) * (m.direction === 'left' ? -1 : 1); + const rate = + Cesium.Math.toRadians(ORBIT_DEG_S[m.speed]) * + (m.direction === 'left' ? -1 : 1); if (m.mode === 'once') { - const total = Cesium.Math.toRadians(ONCE.orbitDeg) * (m.direction === 'left' ? -1 : 1); + const total = + Cesium.Math.toRadians(ONCE.orbitDeg) * + (m.direction === 'left' ? -1 : 1); m.hpr.heading = m.hprStartHeading + total * scale; } else { m.hpr.heading += rate * dt; @@ -973,9 +1153,10 @@ function onTick() { cam.lookAt(m.target, m.hpr); } else if (m.kind === 'pan') { const heightM = Math.max(50, cam.positionCartographic.height); - const step = (m.mode === 'once' - ? (heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))) - : heightM * PAN_VIEW_FRACTION_S[m.speed] * dt); + const step = + m.mode === 'once' + ? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0)) + : heightM * PAN_VIEW_FRACTION_S[m.speed] * dt; m.lastScale = scale; if (m.direction === 'left') cam.moveLeft(step); else if (m.direction === 'right') cam.moveRight(step); @@ -983,16 +1164,24 @@ function onTick() { else cam.moveDown(step); } else if (m.kind === 'tilt' || m.kind === 'rotate') { const degS = TILT_DEG_S[m.speed]; - const stepRad = (m.mode === 'once' - ? Cesium.Math.toRadians(m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg) * (scale - (m.lastScale || 0)) - : Cesium.Math.toRadians(degS) * dt); + const stepRad = + m.mode === 'once' + ? Cesium.Math.toRadians( + m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg, + ) * + (scale - (m.lastScale || 0)) + : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; if (m.kind === 'tilt') { const up = m.direction === 'up'; // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. const next = cam.pitch + (up ? stepRad : -stepRad); - if (next > PITCH_MAX || next < PITCH_MIN) { interruptCameraMotion('tilt-clamp'); return; } - if (up) cam.lookUp(stepRad); else cam.lookDown(stepRad); + if (next > PITCH_MAX || next < PITCH_MIN) { + interruptCameraMotion('tilt-clamp'); + return; + } + if (up) cam.lookUp(stepRad); + else cam.lookDown(stepRad); } else if (m.direction === 'left') cam.lookLeft(stepRad); else cam.lookRight(stepRad); } else if (m.kind === 'route') { @@ -1003,8 +1192,14 @@ function onTick() { // user left it rather than teleporting to an altitude derived from // nothing. An interrupt during the arm is still instant. if (frame.arming) return; - cam.setView({ destination: frame.eye, orientation: { direction: frame.direction, up: frame.up } }); - if (frame.finished) { interruptCameraMotion('route-complete'); return; } + cam.setView({ + destination: frame.eye, + orientation: { direction: frame.direction, up: frame.up }, + }); + if (frame.finished) { + interruptCameraMotion('route-complete'); + return; + } } } catch { interruptCameraMotion('tick-error'); @@ -1024,7 +1219,10 @@ function onTick() { * new one. */ export function initCameraVerbs(viewer, getViewTargetCartesian = null) { - const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; + const nextTarget = + typeof getViewTargetCartesian === 'function' + ? getViewTargetCartesian + : null; if (_viewer === viewer) { if (nextTarget) _getTarget = nextTarget; return; @@ -1052,8 +1250,12 @@ export function initCameraVerbs(viewer, getViewTargetCartesian = null) { */ export function moveCamera(args = {}, runNavigation = null) { const motion = String(args.motion || '').toLowerCase(); - const direction = args.direction ? String(args.direction).toLowerCase() : null; - const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; + const direction = args.direction + ? String(args.direction).toLowerCase() + : null; + const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] + ? String(args.speed || 'normal').toLowerCase() + : 'normal'; const mode = args.mode === 'continuous' ? 'continuous' : 'once'; if (motion === 'stop') { const wasActiveBeforeHandoff = Boolean(_active); @@ -1069,51 +1271,122 @@ export function moveCamera(args = {}, runNavigation = null) { return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) { - return { ok: false, action: 'move_camera', error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.` }; + return { + ok: false, + action: 'move_camera', + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`, + }; } - if (motion !== 'orbit' && !['left', 'right', 'up', 'down'].includes(direction)) { - return { ok: false, action: 'move_camera', error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).` }; + if ( + motion !== 'orbit' && + !['left', 'right', 'up', 'down'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`, + }; } - if ((motion === 'rotate') && !['left', 'right'].includes(direction)) { - return { ok: false, action: 'move_camera', error: 'rotate needs left or right.' }; + if (motion === 'rotate' && !['left', 'right'].includes(direction)) { + return { + ok: false, + action: 'move_camera', + error: 'rotate needs left or right.', + }; } if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). const pitch = _viewer.camera.pitch; if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) { - return { ok: false, action: 'move_camera', error: 'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.' }; + return { + ok: false, + action: 'move_camera', + error: + 'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.', + }; } - if (direction === 'down' && pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)) { - return { ok: false, action: 'move_camera', error: 'Already looking straight down — tilt up to raise the horizon.' }; + if ( + direction === 'down' && + pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5) + ) { + return { + ok: false, + action: 'move_camera', + error: 'Already looking straight down — tilt up to raise the horizon.', + }; } } - if (motion === 'orbit' && _viewer?.trackedEntity && typeof runNavigation !== 'function') { + if ( + motion === 'orbit' && + _viewer?.trackedEntity && + typeof runNavigation !== 'function' + ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal // until tracked-orbit rides the follow camera natively (roadmap). - const label = _viewer.trackedEntity?.name || _viewer.trackedEntity?.id || 'the tracked target'; - return { ok: false, action: 'move_camera', error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.` }; + const label = + _viewer.trackedEntity?.name || + _viewer.trackedEntity?.id || + 'the tracked target'; + return { + ok: false, + action: 'move_camera', + error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`, + }; } const start = () => { interruptCameraMotion('replaced'); - const state = { kind: motion, direction: direction || 'right', speed, mode }; + const state = { + kind: motion, + direction: direction || 'right', + speed, + mode, + }; if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm // and capture at the destination; a target existing right now is // meaningless (the globe is always under the crosshair). - setActiveMotion({ kind: 'orbit', direction: direction || 'right', speed, mode, pending: true }); + setActiveMotion({ + kind: 'orbit', + direction: direction || 'right', + speed, + mode, + pending: true, + }); holdContinuousRender('camera-verb'); - return { ok: true, action: 'move_camera', motion, direction: direction || 'right', speed, mode, armed: 'waiting-for-arrival' }; + return { + ok: true, + action: 'move_camera', + motion, + direction: direction || 'right', + speed, + mode, + armed: 'waiting-for-arrival', + }; } const target = _getTarget?.(_viewer); if (!target) { // Mid-flight chain ("fly to X and orbit it"): ARM the orbit — the tick // loop keeps trying for a ground target as the flight settles. - setActiveMotion({ kind: 'orbit', direction: direction || 'right', speed, mode, pending: true }); + setActiveMotion({ + kind: 'orbit', + direction: direction || 'right', + speed, + mode, + pending: true, + }); holdContinuousRender('camera-verb'); - return { ok: true, action: 'move_camera', motion, direction: direction || 'right', speed, mode, armed: 'waiting-for-arrival' }; + return { + ok: true, + action: 'move_camera', + motion, + direction: direction || 'right', + speed, + mode, + armed: 'waiting-for-arrival', + }; } const cam = _viewer.camera; const range = Cesium.Cartesian3.distance(cam.positionWC, target); @@ -1121,7 +1394,9 @@ export function moveCamera(args = {}, runNavigation = null) { const carto = Cesium.Cartographic.fromCartesian(cam.positionWC); const tCarto = Cesium.Cartographic.fromCartesian(target); const dh = carto.height - tCarto.height; - const pitch = -Math.asin(Math.min(1, Math.max(-1, dh / Math.max(1, range)))); + const pitch = -Math.asin( + Math.min(1, Math.max(-1, dh / Math.max(1, range))), + ); state.target = target; state.hpr = new Cesium.HeadingPitchRange(cam.heading, pitch, range); state.hprStartHeading = cam.heading; @@ -1130,11 +1405,19 @@ export function moveCamera(args = {}, runNavigation = null) { // Verb motion drives the camera per clock tick — hold until interrupted; // interruptCameraMotion is the single release path. (perf wave 2) holdContinuousRender('camera-verb'); - return { ok: true, action: 'move_camera', motion, direction: state.direction, speed, mode }; + return { + ok: true, + action: 'move_camera', + motion, + direction: state.direction, + speed, + mode, + }; }; - const preserveCameraFlight = motion === 'orbit' - && _viewer.scene.tweens.length > 0 - && !_viewer.trackedEntity; + const preserveCameraFlight = + motion === 'orbit' && + _viewer.scene.tweens.length > 0 && + !_viewer.trackedEntity; return typeof runNavigation === 'function' ? runNavigation(start, { preserveCameraFlight }) : start(); @@ -1148,7 +1431,10 @@ export function moveCamera(args = {}, runNavigation = null) { */ export function adjustOrbitRange(factor) { if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false; - _active.hpr.range = Math.max(80, Math.min(5_000_000, _active.hpr.range * factor)); + _active.hpr.range = Math.max( + 80, + Math.min(5_000_000, _active.hpr.range * factor), + ); return true; } @@ -1162,52 +1448,98 @@ export function adjustOrbitRange(factor) { * along the route corridor. Without it a cold cache has no protection: route * vertices carry height 0, so an unwarmed mountain corridor reads as sea level. */ -export function flyRoute(annoList, args = {}, floorFn = null, runNavigation = null, warmFn = null) { - const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; - const routes = (annoList || []).filter((a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2); +export function flyRoute( + annoList, + args = {}, + floorFn = null, + runNavigation = null, + warmFn = null, +) { + const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()] + ? String(args.speed || 'normal').toLowerCase() + : 'normal'; + const routes = (annoList || []).filter( + (a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2, + ); if (!routes.length) { - return { ok: false, action: 'fly_route', error: 'No route is drawn — draw a route first (e.g. "route from A to B"), then fly it.' }; + return { + ok: false, + action: 'fly_route', + error: + 'No route is drawn — draw a route first (e.g. "route from A to B"), then fly it.', + }; } let route = routes[routes.length - 1]; if (args.label) { const wanted = String(args.label).toLowerCase(); - const byLabel = routes.filter((a) => String(a.label || '').toLowerCase().includes(wanted)); + const byLabel = routes.filter((a) => + String(a.label || '') + .toLowerCase() + .includes(wanted), + ); if (!byLabel.length) { - return { ok: false, action: 'fly_route', error: `No route matches "${args.label}" — say fly the route without a name for the newest one.` }; + return { + ok: false, + action: 'fly_route', + error: `No route matches "${args.label}" — say fly the route without a name for the newest one.`, + }; } route = byLabel[byLabel.length - 1]; } - if (route.path.some((point) => !Number.isFinite(point?.lat) - || point.lat < -90 || point.lat > 90 - || !Number.isFinite(point?.lon) - || point.lon < -180 || point.lon > 180 - || (point.height !== undefined && !Number.isFinite(point.height)))) { - return { ok: false, action: 'fly_route', error: 'The selected route has an invalid waypoint.' }; + if ( + route.path.some( + (point) => + !Number.isFinite(point?.lat) || + point.lat < -90 || + point.lat > 90 || + !Number.isFinite(point?.lon) || + point.lon < -180 || + point.lon > 180 || + (point.height !== undefined && !Number.isFinite(point.height)), + ) + ) { + return { + ok: false, + action: 'fly_route', + error: 'The selected route has an invalid waypoint.', + }; } - const pts = route.path.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, (p.height || 0))); + const pts = route.path.map((p) => + Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), + ); const cumM = [0]; for (let i = 1; i < pts.length; i += 1) { cumM.push(cumM[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i])); } if (!Number.isFinite(cumM.at(-1)) || cumM.at(-1) <= 0) { - return { ok: false, action: 'fly_route', error: 'The selected route has no flyable distance.' }; + return { + ok: false, + action: 'fly_route', + error: 'The selected route has no flyable distance.', + }; } const start = () => { interruptCameraMotion('replaced'); - setActiveMotion(createRouteFlight({ - pts, - cumM, - speed, - floorFn, - warmFn, - probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells), - cameraHeightM: _viewer?.camera?.positionCartographic?.height, - reducedMotion: prefersReducedMotion(), - })); + setActiveMotion( + createRouteFlight({ + pts, + cumM, + speed, + floorFn, + warmFn, + probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells), + cameraHeightM: _viewer?.camera?.positionCartographic?.height, + reducedMotion: prefersReducedMotion(), + }), + ); holdContinuousRender('camera-verb'); return { - ok: true, action: 'fly_route', label: route.label || null, speed, - distanceM: Math.round(_active.totalM), durationS: Math.round(_active.durationS), + ok: true, + action: 'fly_route', + label: route.label || null, + speed, + distanceM: Math.round(_active.totalM), + durationS: Math.round(_active.durationS), waypoints: pts.length, // The caller keeps this so it can stop ITS flight later without stopping // whatever has replaced it (see interruptCameraMotionIfActive). diff --git a/src/cctvFocusRequest.js b/src/cctvFocusRequest.js index 33122a47b..380cfeb7c 100644 --- a/src/cctvFocusRequest.js +++ b/src/cctvFocusRequest.js @@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick( ) { if (!cameraId || typeof activate !== 'function') return false; if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false; - eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, { - detail: { cameraId }, - })); + eventTarget.dispatchEvent( + new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, { + detail: { cameraId }, + }), + ); return true; } @@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick( * @returns {() => void} Listener disposer. */ export function registerCctvFocusRequestListener(eventTarget, listener) { - if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener - || typeof listener !== 'function') return () => {}; + if ( + !eventTarget?.addEventListener || + !eventTarget?.removeEventListener || + typeof listener !== 'function' + ) + return () => {}; eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener); let disposed = false; return () => { @@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) { */ export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) { const cameraId = event?.detail?.cameraId; - if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') { + if ( + typeof cameraId !== 'string' || + !cameraId || + typeof runExplicitFocus !== 'function' + ) { return false; } return runExplicitFocus( () => cameraId, - (selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC), + (selectedId) => + focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC), ); } diff --git a/src/celestialRing.js b/src/celestialRing.js index 365357ca1..3e30e62ec 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) { * floor through a band derived from the live keyhole radius. */ export function keyholeLabelAlpha(labelX, labelY, width, height) { - return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height)); + return keyholeLabelAlphaFromGeometry( + labelX, + labelY, + getKeyholeGeometry(width, height), + ); } /** Compute keyhole opacity from geometry already cached by a hot render loop. */ export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) { - if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0; + if ( + !geometry || + !(geometry.radius > 0) || + !Number.isFinite(labelX) || + !Number.isFinite(labelY) + ) + return 0; const feather = geometry.featherPx; - const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY); + const distance = Math.hypot( + labelX - geometry.centerX, + labelY - geometry.centerY, + ); if (distance <= geometry.radius) return 1; - if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity; + if (!(feather > 0) || distance >= geometry.radius + feather) + return keyholeOutsideOpacity; const progress = clamp((distance - geometry.radius) / feather, 0, 1); return 1 - (1 - keyholeOutsideOpacity) * progress; } @@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) { * @param {number} lastAngle - Previous stable angle in radians. * @returns {{angle:number, opacity:number, stable:boolean}} */ -export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) { +export function celestialScreenAngle( + rightComponent, + upComponent, + lastAngle = 0, +) { const planeLength = Math.hypot(rightComponent, upComponent); if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) { return { @@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) { keyholeCenterY, keyholeRadius, } = geometry || {}; - const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius]; - if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false; - const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY); + const values = [ + earthCenterX, + earthCenterY, + earthRadius, + keyholeCenterX, + keyholeCenterY, + keyholeRadius, + ]; + if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) + return false; + const offset = Math.hypot( + earthCenterX - keyholeCenterX, + earthCenterY - keyholeCenterY, + ); const clearance = keyholeRadius - (offset + earthRadius); - return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX); + return ( + clearance >= + (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX) + ); } /** Return the Earth-disc radius in CSS pixels for a perspective camera. */ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius; if ( - !Number.isFinite(cameraDistance) - || cameraDistance <= earthRadiusM - || !(viewportHeight > 0) - || !Number.isFinite(fovy) - || fovy <= 0 - || fovy >= Math.PI - ) return null; + !Number.isFinite(cameraDistance) || + cameraDistance <= earthRadiusM || + !(viewportHeight > 0) || + !Number.isFinite(fovy) || + fovy <= 0 || + fovy >= Math.PI + ) + return null; const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1)); - const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5); + const radius = + (viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5); return Number.isFinite(radius) && radius > 0 ? radius : null; } @@ -216,7 +250,11 @@ export function projectEarthDiscToViewport( if (!camera || !scene || !(width > 0) || !(height > 0)) return null; const distance = Cesium.Cartesian3.magnitude(camera.positionWC); - if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null; + if ( + !Number.isFinite(distance) || + distance <= Cesium.Ellipsoid.WGS84.maximumRadius + ) + return null; const toCenter = Cesium.Cartesian3.negate( camera.positionWC, @@ -229,9 +267,14 @@ export function projectEarthDiscToViewport( Cesium.Cartesian3.ZERO, scratchCenter, ); - if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null; + if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) + return null; - const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy); + const earthRadius = earthDiscScreenRadius( + distance, + height, + camera.frustum?.fovy, + ); if (!earthRadius) return null; const keyhole = getKeyholeGeometry(width, height); @@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) { glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)'); glow.addColorStop(1, 'rgba(255, 242, 214, 0)'); ctx.fillStyle = glow; - ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2); + ctx.fillRect( + cx - radius - 4, + cy - radius - 4, + (radius + 4) * 2, + (radius + 4) * 2, + ); ctx.lineCap = 'round'; ctx.shadowColor = 'rgba(255, 232, 171, 0.11)'; @@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) { const ex = sx + Math.cos(rayAngle) * rayLength; const ey = sy + Math.sin(rayAngle) * rayLength; const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey); - lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`); + lineGradient.addColorStop( + 0, + `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`, + ); lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)'); ctx.strokeStyle = lineGradient; ctx.lineWidth = i === 0 ? 1.8 : 0.9; @@ -340,7 +391,8 @@ export class CelestialRing { this.viewer = viewer; this.enabled = !!enabled; this.visible = false; - this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null; + this._onAutoDisable = + typeof onAutoDisable === 'function' ? onAutoDisable : null; this._focusInProgress = false; this._ephemerisDirty = true; this._ephemerisUpdateCount = 0; @@ -364,7 +416,9 @@ export class CelestialRing { this._fixedMatrix = new Cesium.Matrix3(); this._buildDOM(); - this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw()); + this._removePostRender = viewer.scene.postRender.addEventListener(() => + this._draw(), + ); // Pre-existing staleness fix (perf wave 2 review): the ephemeris was // sampled once per visible-enable from the FROZEN app clock, so the // sun/moon markers aged with the app. Resample real wall time each @@ -394,18 +448,26 @@ export class CelestialRing { this._sunCanvas = document.createElement('canvas'); this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas'; - this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true }); + this._sunCtx = this._sunCanvas.getContext('2d', { + alpha: true, + desynchronized: true, + }); this._moonCanvas = document.createElement('canvas'); this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas'; - this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true }); + this._moonCtx = this._moonCanvas.getContext('2d', { + alpha: true, + desynchronized: true, + }); this._sunMarker = document.createElement('span'); - this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined'; + this._sunMarker.className = + 'celestial-marker celestial-sun material-symbols-outlined'; this._sunMarker.textContent = 'light_mode'; this._moonMarker = document.createElement('span'); - this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined'; + this._moonMarker.className = + 'celestial-marker celestial-moon material-symbols-outlined'; this._moonMarker.textContent = 'dark_mode'; this._root.append( @@ -413,7 +475,7 @@ export class CelestialRing { this._sunCanvas, this._moonCanvas, this._sunMarker, - this._moonMarker + this._moonMarker, ); this.viewer.container.appendChild(this._root); } @@ -458,15 +520,24 @@ export class CelestialRing { const height = canvas.clientHeight || canvas.height; const cartographic = this.viewer.camera.positionCartographic; const fovy = this.viewer.camera.frustum?.fovy; - if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) { + if ( + !(height > 0) || + !cartographic || + !Number.isFinite(fovy) || + fovy <= 0 || + fovy >= Math.PI + ) { return false; } const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; - const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius; + const keyholeRadius = getKeyholeGeometry( + canvas.clientWidth || canvas.width, + height, + ).radius; const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO; const angularRadius = Math.atan( - (targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5) + (targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5), ); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); @@ -480,7 +551,7 @@ export class CelestialRing { destination: Cesium.Cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, - altitude + altitude, ), orientation: { heading: this.viewer.camera.heading, @@ -500,7 +571,10 @@ export class CelestialRing { /** Clear the backing canvas. */ _clear() { - for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) { + for (const [canvas, ctx] of [ + [this._sunCanvas, this._sunCtx], + [this._moonCanvas, this._moonCtx], + ]) { if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); } this._ringOutline?.style.setProperty('display', 'none'); @@ -512,12 +586,23 @@ export class CelestialRing { /** Resize the canvas backing store while drawing in CSS pixels. */ _resize(width, height) { const nativeDpr = Math.max(1, window.devicePixelRatio || 1); - const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height)); - const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height); - const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale); + const pixelsScale = Math.sqrt( + CELESTIAL_MAX_BACKING_PIXELS / (width * height), + ); + const dimensionScale = + CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height); + const dpr = Math.min( + nativeDpr, + CELESTIAL_MAX_DEVICE_PIXEL_RATIO, + pixelsScale, + dimensionScale, + ); const bw = Math.max(1, Math.round(width * dpr)); const bh = Math.max(1, Math.round(height * dpr)); - for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) { + for (const [canvas, ctx] of [ + [this._sunCanvas, this._sunCtx], + [this._moonCanvas, this._moonCtx], + ]) { if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; @@ -536,14 +621,25 @@ export class CelestialRing { _updateEphemeris(time) { if (!this._ephemerisDirty) return true; - Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial); - Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial); - const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) - || Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix); + Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( + time, + this._sunInertial, + ); + Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame( + time, + this._moonInertial, + ); + const matrix = + Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) || + Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix); if (!matrix) return false; Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed); - Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed); + Cesium.Matrix3.multiplyByVector( + matrix, + this._moonInertial, + this._moonFixed, + ); Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed); Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed); this._ephemerisDirty = false; @@ -574,8 +670,14 @@ export class CelestialRing { const cos = Math.cos(angle); const sin = Math.sin(angle); const margin = 14; - const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY; - const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY; + const maxRadiusX = + Math.abs(cos) > 1e-4 + ? (cx - margin) / Math.abs(cos) + : Number.POSITIVE_INFINITY; + const maxRadiusY = + Math.abs(sin) > 1e-4 + ? (cy - margin) / Math.abs(sin) + : Number.POSITIVE_INFINITY; const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY); marker.style.left = `${cx + cos * markerRadius}px`; marker.style.top = `${cy + sin * markerRadius}px`; @@ -596,14 +698,24 @@ export class CelestialRing { const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`; if (sunKey !== this._sunRenderKey) { - this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height); + this._sunCtx.clearRect( + 0, + 0, + this._sunCanvas.width, + this._sunCanvas.height, + ); drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0); drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56); this._sunRenderKey = sunKey; } if (outlineKey !== this._moonRenderKey) { - this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height); + this._moonCtx.clearRect( + 0, + 0, + this._moonCanvas.width, + this._moonCanvas.height, + ); drawMoonHaze(this._moonCtx, cx, cy, radius, 0); drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42); this._moonRenderKey = outlineKey; @@ -624,7 +736,9 @@ export class CelestialRing { const disc = this._projectedEarthDisc(width, height); const wasVisible = this.visible; - const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false; + const nextVisible = disc + ? isFullGlobeInsideKeyhole(disc, wasVisible) + : false; this.visible = nextVisible; this._root.classList.toggle('visible', nextVisible); this._root.dataset.globeVisible = String(nextVisible); @@ -645,12 +759,12 @@ export class CelestialRing { const sunProjection = celestialScreenAngle( Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC), Cesium.Cartesian3.dot(this._sunFixed, camera.upWC), - this._sunAngle + this._sunAngle, ); const moonProjection = celestialScreenAngle( Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC), Cesium.Cartesian3.dot(this._moonFixed, camera.upWC), - this._moonAngle + this._moonAngle, ); if (sunProjection.stable) this._sunAngle = sunProjection.angle; if (moonProjection.stable) this._moonAngle = moonProjection.angle; @@ -660,7 +774,10 @@ export class CelestialRing { const cx = width * 0.5; const cy = height * 0.5; const radius = disc.keyholeRadius - RING_INSET_PX; - const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065)); + const rayInnerRadius = Math.min( + radius - 12, + disc.earthRadius + Math.max(38, radius * 0.065), + ); this._renderEffectLayers(cx, cy, radius, rayInnerRadius); this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`; this._sunCanvas.style.opacity = String(this._sunOpacity); @@ -671,16 +788,24 @@ export class CelestialRing { // stroke. Near conjunction (such as a new moon), keep the true bearings but // move the moon into a second radial lane so both bodies remain legible. const markerRadius = radius - MARKER_INSET_PX; - const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle) - < MARKER_COLLISION_ANGLE; - this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity); + const markersCollide = + circularAngleDistance(this._sunAngle, this._moonAngle) < + MARKER_COLLISION_ANGLE; + this._positionMarker( + this._sunMarker, + cx, + cy, + markerRadius, + this._sunAngle, + this._sunOpacity, + ); this._positionMarker( this._moonMarker, cx, cy, markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0), this._moonAngle, - this._moonOpacity + this._moonOpacity, ); this._debug = { enabled: true, @@ -698,7 +823,12 @@ export class CelestialRing { /** Read-only geometry snapshot for browser QA. */ getDebugState() { - return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null; + return this._debug + ? { + ...this._debug, + disc: this._debug.disc ? { ...this._debug.disc } : null, + } + : null; } /** Detach the render hook and remove all overlay DOM. */ diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 8c5a69e46..053217810 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -1,6 +1,9 @@ import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; -import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js'; +import { + deriveWeatherEffectProfile, + weatherAltitudeFactors, +} from './weatherEffectsMath.js'; const WEATHER_REFRESH_MS = 5 * 60_000; const CLOUD_FRAME_MS = 1000 / 12; @@ -117,28 +120,47 @@ function compileShader(gl, type, source) { gl.shaderSource(shader, source); gl.compileShader(shader); if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader; - const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error'; + const error = + gl.getShaderInfoLog(shader) || 'Unknown shader compilation error'; gl.deleteShader(shader); throw new Error(error); } function greatCircleM(a, b) { - if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity; + if ( + ![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every( + Number.isFinite, + ) + ) + return Infinity; const latitudeA = Cesium.Math.toRadians(a.latitude); const latitudeB = Cesium.Math.toRadians(b.latitude); const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude); const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude); - const haversine = Math.sin(latitudeDelta / 2) ** 2 - + Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2; - return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(latitudeA) * + Math.cos(latitudeB) * + Math.sin(longitudeDelta / 2) ** 2; + return ( + 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) + ); } /** Return whether cockpit weather must refresh for elapsed time or movement. */ -export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) { +export function cockpitWeatherRefreshDue({ + nowMs, + fetchedAt, + anchor, + point, + hasWeather, +}) { if (!hasWeather) return true; if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true; - return nowMs - fetchedAt >= WEATHER_REFRESH_MS - || greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M; + return ( + nowMs - fetchedAt >= WEATHER_REFRESH_MS || + greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M + ); } /** Returns the capped framebuffer size used by the cockpit cloud pass. */ @@ -192,18 +214,24 @@ export class CockpitCloudEffectsController { this.pending = null; this.abort = null; this.destroyed = false; - this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + this.reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; this.enabled = this.readEnabledPreference(); this.onResize = () => this.resize(); this.onCockpitMode = (event) => { - const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode'); + const active = + event?.detail?.active ?? + document.body.classList.contains('cockpit-mode'); if (active && this.enabled) this.start(); else this.stop(); }; this.onEnabledChange = (event) => { const requested = event?.detail?.enabled; - this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled); + this.setEnabled( + typeof requested === 'boolean' ? requested : !this.enabled, + ); }; window.addEventListener('resize', this.onResize); window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode); @@ -222,9 +250,11 @@ export class CockpitCloudEffectsController { } emitEnabledState() { - window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', { - detail: { enabled: this.enabled }, - })); + window.dispatchEvent( + new CustomEvent('gev:cockpit-weather-state', { + detail: { enabled: this.enabled }, + }), + ); } setEnabled(enabled) { @@ -232,7 +262,9 @@ export class CockpitCloudEffectsController { this.enabled = next; try { localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0'); - } catch { /* best effort */ } + } catch { + /* best effort */ + } if (!next) { this.stop(); } else if (document.body.classList.contains('cockpit-mode')) { @@ -255,7 +287,11 @@ export class CockpitCloudEffectsController { if (!gl) throw new Error('WebGL unavailable'); const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); - const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + FRAGMENT_SHADER, + ); const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); @@ -263,15 +299,18 @@ export class CockpitCloudEffectsController { gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed'); + throw new Error( + gl.getProgramInfoLog(program) || 'Cloud shader link failed', + ); } const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ - -1, -1, 1, -1, -1, 1, - -1, 1, 1, -1, 1, 1, - ]), gl.STATIC_DRAW); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), + gl.STATIC_DRAW, + ); const position = gl.getAttribLocation(program, 'position'); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); @@ -293,7 +332,8 @@ export class CockpitCloudEffectsController { resize() { if (!this.gl) return; const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight); - if (this.canvas.width === size.width && this.canvas.height === size.height) return; + if (this.canvas.width === size.width && this.canvas.height === size.height) + return; this.canvas.width = size.width; this.canvas.height = size.height; this.gl.viewport(0, 0, size.width, size.height); @@ -313,7 +353,13 @@ export class CockpitCloudEffectsController { } start() { - if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return; + if ( + this.destroyed || + !this.enabled || + this.frame !== null || + this.suspended + ) + return; if (!this.gl) { this.initializeRenderer(); this.resize(); @@ -353,16 +399,21 @@ export class CockpitCloudEffectsController { } async refresh() { - if (this.destroyed || !this.enabled || this.pending || this.suspended - || !document.body.classList.contains('cockpit-mode')) { + if ( + this.destroyed || + !this.enabled || + this.pending || + this.suspended || + !document.body.classList.contains('cockpit-mode') + ) { return this.pending; } const point = this.cameraPoint(); if (!point) return null; if ( - this.weather - && Date.now() - this.fetchedAt < WEATHER_REFRESH_MS - && greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M + this.weather && + Date.now() - this.fetchedAt < WEATHER_REFRESH_MS && + greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M ) { this.applyWeather(this.weather, point.altitudeM); return this.weather; @@ -370,11 +421,17 @@ export class CockpitCloudEffectsController { this.abort?.abort(); this.abort = new AbortController(); - const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal }); + const pending = this.weatherService.getConditions( + point.latitude, + point.longitude, + { signal: this.abort.signal }, + ); const request = this.abort; - this.pending = pending.then(async (payload) => { + this.pending = pending + .then(async (payload) => { if (this.abort !== request || request.signal.aborted) return null; - if (!payload?.weather) throw new Error('Cloud weather observation unavailable'); + if (!payload?.weather) + throw new Error('Cloud weather observation unavailable'); this.weather = payload.weather; this.fetchedAt = Date.now(); this.anchor = point; @@ -391,7 +448,10 @@ export class CockpitCloudEffectsController { return null; }) .finally(() => { - if (this.abort === request) { this.pending = null; this.abort = null; } + if (this.abort === request) { + this.pending = null; + this.abort = null; + } }); return this.pending; } @@ -399,12 +459,17 @@ export class CockpitCloudEffectsController { applyWeather(weather, altitudeM) { const profile = deriveWeatherEffectProfile(weather); const altitude = weatherAltitudeFactors(altitudeM); - this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0; + this.targetStrength = profile.available + ? profile.cloud * altitude.cloud + : 0; this.windDirectionDeg = profile.windDirectionDeg || 0; this.windStrength = profile.wind || 0; this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3); this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown'); - if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) { + if ( + this.reducedMotion && + document.body.classList.contains('cockpit-mode') + ) { this.strength = this.targetStrength; const visible = this.strength > 0.035; this.canvas.classList.toggle('active', visible); @@ -414,7 +479,11 @@ export class CockpitCloudEffectsController { } tick(timeMs) { - if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) { + if ( + this.destroyed || + !this.enabled || + !document.body.classList.contains('cockpit-mode') + ) { this.stop(); return; } @@ -431,20 +500,27 @@ export class CockpitCloudEffectsController { if (timeMs - this.lastRefreshCheckMs >= 1000) { this.lastRefreshCheckMs = timeMs; const point = this.cameraPoint(); - if (point && cockpitWeatherRefreshDue({ - nowMs: Date.now(), - fetchedAt: this.fetchedAt, - anchor: this.anchor, - point, - hasWeather: Boolean(this.weather), - })) void this.refresh(); + if ( + point && + cockpitWeatherRefreshDue({ + nowMs: Date.now(), + fetchedAt: this.fetchedAt, + anchor: this.anchor, + point, + hasWeather: Boolean(this.weather), + }) + ) + void this.refresh(); } const blend = this.reducedMotion ? 1 : 0.08; this.strength += (this.targetStrength - this.strength) * blend; const visible = this.strength > 0.035; this.canvas.classList.toggle('active', visible); - if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) { + if ( + visible && + (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS) + ) { this.lastFrameMs = timeMs; this.render(timeMs / 1000); } else if (!visible) { @@ -468,9 +544,16 @@ export class CockpitCloudEffectsController { const windRadians = Cesium.Math.toRadians(this.windDirectionDeg); const windScale = 0.008 + this.windStrength * 0.022; gl.useProgram(this.program); - gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height); + gl.uniform2f( + this.locations.resolution, + this.canvas.width, + this.canvas.height, + ); gl.uniform1f(this.locations.time, timeSec); - gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength))); + gl.uniform1f( + this.locations.strength, + Math.min(1, Math.max(0, this.strength)), + ); gl.uniform2f( this.locations.wind, Math.sin(windRadians) * windScale, @@ -517,7 +600,10 @@ export class CockpitCloudEffectsController { this.stop(); window.removeEventListener('resize', this.onResize); window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode); - window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange); + window.removeEventListener( + 'gev:cockpit-weather-toggle', + this.onEnabledChange, + ); if (this.program && this.gl) this.gl.deleteProgram(this.program); this.canvas.remove(); } diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 8acf28d16..20cb1ecf5 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -45,7 +45,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => { ); const topCenterActions = html.match(/