diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..491f2468d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: run: npm run check:boundaries - name: Run focused onboarding tests - run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs + run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs - name: Build production bundle run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f2481af..df6497222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +- Snow flakes sway within a fixed range instead of drifting sideways faster the + longer the app runs; the Wind setting still scales the sway. + +- The right rail ignores panels that other CSS hides, such as during an event + view, so they no longer force one-panel-at-a-time layout or collapse visible + panels. Panels the rail collapses itself are still restored. + +- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location. + +- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through + their ion-backed providers, retaining ion and per-tile attribution without + widening the Content Security Policy. + - Add New York City traffic cameras from the NYC DOT Traffic Management Center as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an @@ -11,6 +24,31 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Street Level counts unique tiles before ranking so overlapping dateline halves + in polar views do not waste slots in the 16-tile overview budget. + +- Directions no longer shows a truncation notice for a route with exactly 200 + turns, and a roundabout at the 200th turn keeps its exit distance. + +- Weather records keep missing conditions unknown and refuse summaries without a measured temperature. + +- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. + +- Director timeline seeks cross the dateline along the short longitude arc. + +- Map state updates stop delivering to subscribers removed during the same notification. + +- Director assets cancelled during the final stream read are discarded with reader cleanup. + +- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range. + +- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. + +- Frequently reused terrain batches retain their cached heights ahead of unused cells. + +- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or + boolean programmatic box edges instead of interpreting them as zero coordinates. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. @@ -123,6 +161,16 @@ over Paris hung the tab), decoded tiles are kept, and the cones' tiles have their own connection. +- Flights and Military Flights highlight emergency transponder broadcasts in + fleet billboards, 3D models and Cockpit contacts. Colors update when a status + starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV + reports minimum fuel and lifeguard without red tint. Cockpit names the code + or ADS-B status, and analyst/voice results retain broadcast provenance and + the latest known contact time. One omitted ADS-B status poll during a source + switch may retain the previous status if the squawk is unchanged; an explicit + clear takes effect immediately. Broadcasts are not confirmed incidents. + Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#856). + - Street Level: KartaView is the third provider, keyless. Light its chip (purple; off by default, one provider at a time) and zoom in below about 6 km: the lines are the streets KartaView has photos of (z13–14 tiles from @@ -206,6 +254,23 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- GNSS Integrity (Events, voice-addressable, share token `3`) draws navigation + accuracy scores from a keyless regional adsb.lol sample; aircraft-level analyst + queries are unavailable for these aggregate cells. Cells do not intercept + map clicks. Pan/zoom refreshes automatically; feed failures retain the display + and explain the next retry. Aircraft-reported quality includes MLAT-positioned + contacts after GPS loss, excludes TIS-B/MLAT-derived quality, and locates GPS-loss + evidence at the last good GNSS position when available. Scores discount one + bad aircraft per cell and retain 30 minutes of visited-view evidence; they + do not confirm jamming. See [method and limits](DATA_SOURCES.md#gnss-integrity). + Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#855). + +- Live HLS cameras play on a page opened over plain HTTP from another machine + (`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which + browsers only expose in secure contexts, so starting a live feed threw + before playback. The id now falls back to a v4 UUID built from + `crypto.getRandomValues`, which the server's lease check accepts. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63f83e4c2..cde8582f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,8 +79,8 @@ if its layer is later removed. New layers first use the remaining unreserved single-character digits (`0` through `9`), then the two-character base-36 namespace (`00` through `zz`). On the -current published ledger, `0`, `1` and `2` are already owned, so the next -choices are `3` through `9`, then `00`. After rebasing onto the latest `main`, run +current published ledger, `0`, `1`, `2` and `3` are already owned, so the next +choices are `4` through `9`, then `00`. After rebasing onto the latest `main`, run `npm run layer-token:next -- `, add that result to both the permanent JSON reservation ledger and registry entry, and keep the exact registered-layer count assertion up to date. The token is assigned against the merge-time base, not @@ -98,7 +98,7 @@ remove only your still-unpublished layer's registry entry and ledger row so the local registry is valid again, then rerun `npm run layer-token:next -- `. For example, if PR A publishes `0` while PR B still provisionally uses `0`, PR B removes only its own provisional entry, reruns the helper, and receives -`3`. Put `3` on PR B's registry entry and ledger row, update its pinned count +`4`. Put `4` on PR B's registry entry and ledger row, update its pinned count and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`. An invalid duplicate ledger can fail during module import before the helper prints anything; never fix that by renaming or deleting PR A's published token. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d083a5ea..dc4cff7f6 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -27,7 +27,7 @@ How to read this: | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | -| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | +| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | | **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) | | **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | @@ -81,6 +81,41 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. + + +- **GNSS integrity (adsb.lol).** A separate keyless point request samples a 250 nm + circle around the screen centre, snapped once to whole degrees. This does not + reuse the Flights fallback cache. Cache lifetime is 60 s; 429 backoff is capped + at one hour and other upstream failures cool down for 15 s. Future access may + require feeder keys under the [API terms](https://github.com/adsblol/api). + Airborne decoded ADS-B v1/v2 aircraft with a known 0–60 s position age qualify, + including MLAT-positioned aircraft still broadcasting their own quality. + TIS-B/MLAT-derived NIC and NACp are ignored. GPS-loss evidence uses + `gpsOkLat/gpsOkLon` when present, otherwise the current known position. + The proxy strips identity metadata and tags its response with classifier + `gev-nic-nacp-v2`; rows carry only integrity facts. + + The method has three separately attributed parts: + 1. **GEV classifier (unvalidated):** GPS-loss flag, aircraft-reported NACp < 8, + or aircraft-reported NIC < 7 except NIC 0 with NACp ≥ 10 (unknown containment). + The numerical minima come from US 14 CFR 91.227(c), not a published jamming + classifier. Readsb's [JSON contract](https://github.com/wiedehopf/readsb/blob/dev/README-json.md) + describes the quality field origins and GPS-loss flag. + 2. **GPSJAM aggregation:** score = `100 × max(0, bad − 1) / total`, with percent + bands < 2, 2–10, > 10. One bad aircraft is discounted. This is not the raw + share of bad aircraft. See the [GPSJAM FAQ](https://gpsjam.org/faq). + 3. **GEV window:** 30 minutes over visited views, at least three aircraft per + 0.5° cell. These bands were developed for daily totals; with fewer than ten + observations a cell can skip the middle band. Source timestamps date evidence + even through cache replays, and degraded evidence expires independently. + + Medium/high cells report navigation-integrity anomalies. Interference is one + possible explanation, not a detected or located emitter. Avionics faults and + manoeuvring military trainers can also lower reported accuracy. Blank areas + can be outside coverage or lack enough observations. Attribution: adsb.lol + contributors, ODbL 1.0; GPSJAM for aggregation only. + + - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. diff --git a/README.md b/README.md index c28ab74ac..ee19953ab 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ locked dependencies, finds a free local port, and opens the app. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). +**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +compatibility or minimum-hardware problem, copy the local server URL shown by +Pinokio and open the same URL in your normal hardware-accelerated browser. One +Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went +away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That points to the embedded browser rather than the app server or your +hardware; it does not establish a minimum GPU requirement. + ### Path 2 — Terminal / coding agent Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about @@ -295,7 +303,9 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | | 🗺️ **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 | 🟢 | +| 🎖️ **Military Flights** | ADS-B military traffic in amber, emergency broadcasts in red | adsb.lol | 🟢 | +| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 | +| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 | | 🚢 **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 | 🟢 | diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index deafe9168..c5a2642e6 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints. Voice controls accept an action runner and a controller factory through `createVoiceCommands` (`./voice/commands`). The default Realtime controller owns microphone tracks, playback, push-to-talk, tool cancellation and radio handoff. -Its backend supplies `requestToken({ tier, signal })` and +Its backend supplies `requestToken({ tier, authMode, signal })` and `negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter (`./voice/realtime-backend`) accepts separate token and connection transports and endpoints. Only the short-lived client secret reaches the SDP endpoint. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a0a2325ad..19a5ee372 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -65,11 +65,10 @@ 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. +Source-only factories use dedicated `layers//source` exports where +available, preserving the same factory implementation without loading rendering. +Record normalization and endpoint policies 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 @@ -630,9 +629,9 @@ 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 +`gods-eye-view/sources/reference` constructs fresh earthquake, GNSS integrity and bundled cable source instances independently of standalone setup. Individual sources remain -available through `layers/earthquakes/source` and `layers/submarine-cables/source`. +available through their `layers//source` exports, including `layers/gnss/source`. The latter retains the bundled dataset’s attribution and licensing requirements. ## Geocoding and feature-query providers diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 09a26fe8f..8e5bff676 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -73,6 +73,10 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). ## Tools and local MCP server — October 1, 2026 +Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. + +Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, @@ -394,11 +398,18 @@ and geographic placement remain distinct; Stop/replacement releases resources. Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene content, assets and credit remain unchanged. +The asset directory source rejects with the caller's abort reason if +cancellation arrives during any read, including the final one, or while the +reader is being released. + Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. +Seeking between ordinary camera poses uses the shortest longitude arc across the +dateline while preserving both authored endpoint longitudes, and turns heading +and roll the short way for any authored angles. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -814,6 +825,30 @@ use the supplied asset resolver, including the preload path. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. +## Transponder emergency highlighting + +`src/data/aircraftEmergency.js` derives one status per poll from the Mode A +squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency) +and the readsb ADS-B emergency/priority field where present. Higher severity +wins, with ADS-B taking a tie. GEV treats `minfuel` and `lifeguard` as priorities; +`general`, `unlawful`, `nordo` and `downed` are emergency statuses. + +Civil and military fleet billboards, 3D models and Cockpit dots turn red for +emergencies and repaint when the broadcast changes. Priorities retain normal +colors; tracked map colors keep their existing selection treatment. The Cockpit +meta line names the squawk or ADS-B status (only emergencies are red), replacing +COURSE ALIGNED while a status is present. + +Analyst and tracked voice results describe broadcasts, not confirmed incidents. +Fields include `squawk`, `emergency`, `emergencyKind`, `emergencySource` and +`emergencyBroadcast`. `lastContactAtMs` is the last known transponder contact, +not the arrival time of this status; missing contact times retain known contact. +Civil fallback preserves status alongside unchanged OpenSky vectors. One poll +omitting ADS-B status may retain the previous status when the squawk is unchanged; +explicit clears and changed squawks take effect immediately. The next omitted +poll expires that retention. `scripts/qa-emergency-squawk.mjs` checks billboard +and model transitions, Cockpit HUD, clearing and tracking release. + ## Browser live-source observations Flights, Military Flights and AIS Vessels obtain snapshots and optional history @@ -1013,6 +1048,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has no DOM dependency. Existing presets and the 500 ms transition remain unchanged. Stopping the controller revokes animation before asynchronous UI teardown; final destruction removes its stages and restores the borrowed bloom configuration. +Snow's sideways sway stays within a fixed range at any session age; Wind scales +its amplitude. ## Display control ownership @@ -1047,6 +1084,9 @@ Automatic collapse remains presentation only, prioritizes the latest explicit panel, and honors keyboard focus on the right. Mobile layout still releases desktop allocation styles. Stable Display allocation avoids unnecessary style writes. This extraction does not change panel positions or layout defaults. +The right rail skips panels hidden by other CSS (computed `display: none`), but +keeps the collapsed panels its own exclusive presentation hides, so automatic +collapse can still be undone. ## Surface keyboard lifecycle @@ -1188,11 +1228,11 @@ entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through visits to one place share a key but does not bound how many distinct places a session visits, and the cache previously had no eviction, so a long session retained every coordinate it ever resolved. Eviction is least-recently-used: -a consumer read promotes its entry, so the cell being watched is not dropped for -having been resolved early. The bound sits far above a city-scale session, so +a consumer read, including a warm hit in a batch lookup, promotes its entry, so +the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so normal use never evicts and issues no additional proxy requests. A batch's -results are assembled from what that call resolved, so eviction during a large -batch cannot report a just-resolved point as unresolved. Re:Earth and +results are assembled from what that call resolved or found warm, so eviction +during a large batch cannot report one of its points as unresolved. Re:Earth and geoid-fallback entry semantics, the fallback cooldown and the abort-time flush are unchanged. @@ -1419,6 +1459,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential. + The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential @@ -3100,8 +3142,34 @@ its criteria cannot be silently ignored. | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +GNSS Integrity samples a 250 nm circle around the Earth point under the screen +centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; +nearby views share whole-degree cache keys, and a minute poll refreshes data. +Counts and bands cover visited views over 30 minutes, including regions now off +screen. Non-pickable ground primitives preserve Street Level and perimeter +selection. The legend describes a low-accuracy score, not the raw aircraft share; +`getStats().levels` exposes nominal, medium and high counts separately. + +Transient source failures keep activation enabled and show unavailable/degraded +health with the next automatic retry. Upstream 429 deadlines are capped at one +hour, with the longest concurrent deadline retained; 5xx, timeout and malformed +responses impose a 15 s server cooldown. Cache hits and shared reads do not spend +upstream admission. Cache age and cooldown use a monotonic clock; observation +age comes from the upstream snapshot and is preserved in the row and voice stats. +Snapshots older than 30 minutes or without a source timestamp are refused. +Disable/destroy cancels pending requests, timers, subscriptions and primitives. + +The GEV classifier remains unvalidated; anomalies do not confirm interference. +See [method, provenance and limits](../DATA_SOURCES.md#gnss-integrity), including +MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception. +`scripts/qa-gnss.mjs` covers live freshness, bands, built primitives, camera/globe +anchors, recovery, keyboard and mobile states; emergency repainting is covered +by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. + + Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Street Level is a collapsible right-rail panel (`#street-level-panel`, layer @@ -3122,6 +3190,10 @@ sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. +Mapillary coverage ranks unique tile addresses before applying the 16-tile +overview budget. Overlapping dateline halves in a polar view therefore do not +waste overview slots on the same tile; loading already skips duplicate keys. + On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out above 1,800 m) the surface mode switches from `draped` to `terrain`: draped lines land on roofs and tree tops, so `groundCast.js` instead places lines, @@ -3446,9 +3518,10 @@ rate limit from the routing service is reported as one instead of as "no route found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at -200 maneuvers — a route past that cap is reported as cut off, in the row -summary and as the last line of the list, because the arrival step is among -the ones dropped. +200 maneuvers — a route with more is reported as cut off, in the row summary +and as the last line of the list, because the arrival step is among the ones +dropped. A route of exactly 200 is complete, and a roundabout exit after the +200th keeps folding into it. A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. @@ -4377,6 +4450,8 @@ and unreachable upstream (502/504) separately from road geometry. ### Place-search providers +Coordinate input requires exactly two nonempty components; extra comma or semicolon separators decline to geocoding rather than silently choosing a location. + Location search/fly-to, annotations and Radio location lookup receive one `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes two offline providers first — a strict decimal-degree coordinate reader and an @@ -4493,6 +4568,11 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +Programmatic boxes passed to the exported `setBox` and `searchHls` operations +reject missing, blank or boolean edges instead of interpreting them as zero +coordinates. Valid finite numeric edges remain accepted; the app’s drag tool, +pins and share links already supply complete boxes. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. @@ -4501,13 +4581,13 @@ are omitted rather than framing the wrong part of the globe. - **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. - **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`. - **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). -- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check. +- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples. ### 3D Aircraft + Tracking (June 2026) - **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. -- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, always flat amber). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. +- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. @@ -4682,6 +4762,7 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. @@ -5250,8 +5331,18 @@ falls back to OSM on construction failure or two active tile failures, and credits follow the source actually displayed. Terrain remains lazy while the photoreal globe is hidden. +Map-stack subscribers hear each settled activation once. A subscriber removed +during a delivery, or every subscriber once the controller is destroyed, gets +no further callback from it, and a destroyed controller accepts no new ones. + Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. +Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied +token, then construct the Bing provider with `tileProtocol: 'https'`. Both styles +request HTTPS tiles even on HTTP localhost, with the existing Content Security +Policy unchanged. Ion attribution and Bing's per-tile credits are preserved; +other ion imagery assets continue through the SDK's standard ion factory. + The controller accepts other source registries without adding provider branches. Each instance caches source construction, ignores superseded scene changes and releases imagery layers/listeners on replacement. Destroy invalidates pending @@ -5271,7 +5362,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback errors revert to the labeled frame fallback. Camera changes clear the old panel frame. hls.js is imported only when an HLS camera starts. Two live sessions may coexist with at most eight independent consumer leases each; no-access lease -expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail +expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease +ids use `crypto.randomUUID` where the page is a secure context and an equivalent +v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail closed. Performance and live agency reliability require recorded validation. ## Wind forecast models diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 34b207f79..65d0abae9 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application: 6. Record live object counts before attributing a difference to the client. 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. +8. Record whether the app is running in Pinokio's embedded view or in an + external browser. One Pinokio 8.2.0 user reported severe stutter in the + embedded view that went away when the same local URL was opened in a normal + browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Do not compare embedded-view and external-browser measurements directly; + compare the reported GPU renderer and matched scene settings first. ## What is not established yet diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index 4dac83a51..bb28eb3e9 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged. | Owner | State and responsibility | | --- | --- | | `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | +| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry | | `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | | `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | | `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | @@ -21,6 +22,15 @@ The session adapter, action runner and backend interfaces remain unchanged. | `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) | | `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) | +The backend is replaceable per deployment: `realtimeController.js` accepts it as a +`backend` option and falls back to `createRealtimeBackend()`. Its two method +signatures live with [application construction](APPLICATION.md); what belongs here +is the ownership. The connection is the backend's only consumer in the application. +The connection reads credential metadata for diagnostics and cost accounting, +then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection. +`realtimeOwners.test.mjs` drives whole sessions through a substitute that +implements only those two methods. + Pure input policy, preferences and protocol-response policy have separate modules. Owners receive named readers, operations or focused collaborators. Cross-owner cancellation uses operations; response handling does not own Radio's reservations diff --git a/package.json b/package.json index 4b2829313..d5c900cbd 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "./layers/recent-imagery": "./src/layers/recentImagery/index.js", "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", + "./layers/gnss": "./src/layers/gnss/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -238,6 +239,7 @@ "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", + "./layers/gnss/source": "./src/layers/gnss/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", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 215db0c72..1eb4f7014 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -18,6 +18,10 @@ "src/tooling/panelStorageDocs.test.mjs", "src/tooling/packageBoundaries.test.mjs", "src/data/infrastructure.js", + "src/data/aircraftEmergency.js", + "src/data/aircraftEmergency.test.mjs", + "src/data/aircraftEmergencyAnalyst.test.mjs", + "scripts/qa-emergency-squawk.mjs", "src/data/infrastructure.test.mjs", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", @@ -827,6 +831,16 @@ "server/providers/firePerimeters.js", "src/data/cyclonesProxy.test.mjs", "src/data/firePerimetersProxy.test.mjs", + "server/providers/gnssIntegrity.js", + "src/data/gnssIntegrityProxy.test.mjs", + "src/app/layers/gnss.js", + "src/layers/gnss/index.js", + "src/layers/gnss/records.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.test.mjs", + "src/layers/gnss/source.test.mjs", + "src/layers/gnss/ownership.test.mjs", + "scripts/qa-gnss.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", @@ -950,6 +964,8 @@ "scripts/qa-street-level.mjs", "src/tooling/streetLevelQa.test.mjs", "scripts/qa-browserEvidence.mjs", + "scripts/qa-chrome.mjs", + "src/qaChrome.test.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", "scripts/fixtures/street-level/kartaView.mjs", "scripts/fixtures/street-level/panoramax.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 493bae74a..98e8d17ec 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -781,6 +781,7 @@ "live-observations": { "exports": ["./sources/live"], "modules": [ + "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", "src/sources/live/index.js", @@ -794,6 +795,7 @@ "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -837,6 +839,7 @@ "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -1510,6 +1513,7 @@ "src/contextModePolicy.js", "src/creditKeyboard.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -2045,6 +2049,10 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/gnss.js", + "src/layers/gnss/index.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2308,6 +2316,7 @@ "src/celestialRing.js", "src/cockpitMath.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -2645,6 +2654,10 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/gnss.js", + "src/layers/gnss/index.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2945,7 +2958,8 @@ "src/layers/flights/recordPolicy.js", "src/data/aircraftMeta.js", "src/data/renderAltitude.js", - "src/data/aircraftClass.js" + "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js" ], "external": [] }, @@ -2964,7 +2978,8 @@ "src/layers/military/recordPolicy.js", "src/data/aircraftMeta.js", "src/data/renderAltitude.js", - "src/data/aircraftClass.js" + "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js" ], "external": [] }, @@ -3161,7 +3176,8 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/gnss/source.js" ], "external": [] }, @@ -3372,6 +3388,21 @@ ], "external": ["cesium"] }, + "gnss": { + "exports": ["./layers/gnss"], + "modules": [ + "src/layers/gnss/index.js", + "src/sources/httpBody.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.js" + ], + "external": ["cesium"] + }, + "gnss-source": { + "exports": ["./layers/gnss/source"], + "modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ @@ -3499,6 +3530,7 @@ "src/sources/featureGeometry.js", "src/sources/live/standalone.js", "src/sources/live/contract.js", + "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", "src/sources/live/vessels.js", "src/tools/places.js", diff --git a/scripts/precompute-cctv-heights.mjs b/scripts/precompute-cctv-heights.mjs index 24813b5e7..648768341 100644 --- a/scripts/precompute-cctv-heights.mjs +++ b/scripts/precompute-cctv-heights.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { planeSupportPoints, poseHash, @@ -389,8 +390,7 @@ async function main() { const { default: puppeteer } = await import('puppeteer'); browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), protocolTimeout: 600000, args: [ '--no-sandbox', diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 12af9beb8..3e3008e03 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -6,6 +6,7 @@ */ import puppeteer from 'puppeteer'; import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; @@ -65,7 +66,9 @@ try { requestAnimationFrame(tick); })); await new Promise((r) => setTimeout(r, 1_000)); - const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname; + const path = fileURLToPath( + new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), + ); await page.screenshot({ path }); console.log(`saved ${path}`); } diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs new file mode 100644 index 000000000..05c4dbff0 --- /dev/null +++ b/scripts/qa-chrome.mjs @@ -0,0 +1,20 @@ +/** + * The browser for gates that drive an installed Chrome. These gates were + * written against macOS Google Chrome, which Windows and Linux do not have. + */ +import { existsSync } from 'node:fs'; + +export const MACOS_CHROME = + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +/** + * PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is + * installed. Otherwise undefined, so Puppeteer launches the browser it manages. + */ +export function chromeExecutablePath({ + env = process.env, + exists = existsSync, +} = {}) { + if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; + return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; +} diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 9501690f9..ca271026f 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -10,6 +10,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); @@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => { const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index a5a16af1d..adb29f3af 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw'; @@ -22,8 +23,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs new file mode 100644 index 000000000..f877ea659 --- /dev/null +++ b/scripts/qa-emergency-squawk.mjs @@ -0,0 +1,554 @@ +#!/usr/bin/env node +/** + * qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * `/api/flights` and `/api/military` are stubbed in the page so the codes + * are deterministic; everything downstream (normalizers, records, rendering, + * Cockpit HUD) is production code. + * + * (i) TINT — a civil 7600 and a military ADS-B `general` emergency are + * drawn red; a normal civil flight stays white, a normal military + * flight stays amber, and a military `minfuel` priority stays amber. + * POLL/MODELS verify existing untracked billboards and 3D models + * repaint when emergency status starts and clears. + * (ii) HUD — tracking the military emergency and entering Cockpit names + * the broadcast code on the aircraft-meta line, in red. + * (iii) CLEAR — the next poll drops the code: the HUD line returns to its + * normal wording (the status is not sticky). + * (iv) RELEASE — the code returns and tracking stops: the restored fleet + * billboard is red, not the plain military amber. + * + * Screenshots saved to qa-shots/emergency-*.png (gitignored). + * + * Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot. + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000')); +const CENTER = { lon: 4.0, lat: 53.5 }; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** OpenSky state vector: squawk sits at index 14. */ +const civilRow = (icao24, callsign, lon, lat, squawk) => [ + icao24, + callsign, + 'Netherlands', + null, + null, + lon, + lat, + 10000, + false, + 230, + 90, + 0, + null, + 10050, + squawk, + false, + 0, +]; + +/** readsb row as adsb.lol /v2/mil returns it. */ +const milRow = (hex, flight, lon, lat, squawk, emergency) => ({ + hex, + flight, + lon, + lat, + alt_baro: 24000, + alt_geom: 24200, + gs: 380, + track: 270, + t: 'C17', + r: `QA-${hex}`, + squawk, + emergency, + seen: 1, + seen_pos: 1, +}); + +/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */ +function installStubs() { + const realFetch = window.fetch.bind(window); + window.__squawkQaPhase = 'emergency'; + const json = (body) => + Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + const now = Date.now(); + const { civil, mil } = window.__squawkQaFixtures; + if ( + url.includes('/api/flights/track') || + url.includes('/api/military/track') + ) + return json({ path: [] }); + if (url.includes('/api/flights')) { + const t = Math.floor(now / 1000); + return json({ + time: t, + states: civil.map((row) => { + const copy = [...row]; + copy[3] = t - 1; + copy[4] = t - 1; + return copy; + }), + }); + } + if (url.includes('/api/military')) { + const cleared = window.__squawkQaPhase === 'cleared'; + return json({ + msg: 'No error', + now, + ac: mil.map((row) => + cleared && row.hex === 'ae7700' + ? { ...row, squawk: '4521', emergency: 'none' } + : row, + ), + }); + } + return realFetch(input, init); + }; +} + +/** Colour of every shown fleet billboard whose id is one of `ids`. */ +function billboardColors(ids) { + const { viewer } = window.__godsEyeView; + const found = {}; + const seen = new Set(); + const visit = (primitive) => { + if (!primitive || seen.has(primitive)) return; + seen.add(primitive); + if ( + typeof primitive.length !== 'number' || + typeof primitive.get !== 'function' + ) + return; + for (let i = 0; i < primitive.length; i++) { + const item = primitive.get(i); + if (item?.color && ids.includes(item.id) && item.show !== false) { + found[item.id] = { + r: Math.round(item.color.red * 255), + g: Math.round(item.color.green * 255), + b: Math.round(item.color.blue * 255), + }; + } else visit(item); + } + }; + visit(viewer.scene.primitives); + return found; +} + +const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110; +const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220; +const isAmber = (c) => + Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c); + +async function shoot(page, name) { + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + await sleep(600); + await page.screenshot({ + path: path.join(SHOTS_DIR, `emergency-${name}.png`), + }); +} + +async function main() { + console.log('\nEmergency squawk proof (qa-emergency-squawk)'); + console.log(` App URL : ${APP_URL}\n`); + try { + const res = await fetch(APP_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); + process.exit(2); + } + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath().catch(() => null)) || + undefined; + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + protocolTimeout: 180000, + ...(executablePath && fs.existsSync(executablePath) + ? { executablePath } + : {}), + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', + ], + }); + + let exitCode = 0; + const errors = []; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + page.on('pageerror', (e) => errors.push(e.message)); + const { lon, lat } = CENTER; + await page.evaluateOnNewDocument( + (fixtures) => { + window.__squawkQaFixtures = fixtures; + }, + { + civil: [ + civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'), + civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'), + ], + mil: [ + milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'), + milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'), + milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'), + ], + }, + ); + // Stub in-page rather than with request interception: an intercepted page + // stalls Cesium's workers (see qa-gnss.mjs). + await page.evaluateOnNewDocument(installStubs); + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); + await page.evaluate( + (lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight active */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian( + { + longitude: lo * d2r, + latitude: la * d2r, + height: 260000, + }, + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, + lon, + lat, + ); + + // ── (i) TINT ────────────────────────────────────────────────────────── + console.log('(i) TINT — emergency codes through the real layers...'); + await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('flights', true, { origin: 'user' }); + await dm.setEnabled('military', true, { origin: 'user' }); + }); + const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001']; + const probe = `(${billboardColors})(${JSON.stringify(ids)})`; + await page + .waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, { + timeout: TIMEOUT_MS, + polling: 500, + }) + .catch(() => {}); + await sleep(1500); + const colors = await page.evaluate(probe); + const show = (id) => JSON.stringify(colors[id] ?? null); + record( + 'TINT: civil squawk 7600 is red', + isRed(colors['4847a1']), + show('4847a1'), + ); + record( + 'TINT: normal civil flight stays white', + isWhite(colors['4847a2']), + show('4847a2'), + ); + record( + 'TINT: military ADS-B general emergency is red', + isRed(colors.ae7700), + show('ae7700'), + ); + record( + 'TINT: military minimum-fuel priority stays amber', + isAmber(colors.ae0f01), + show('ae0f01'), + ); + record( + 'TINT: normal military flight stays amber', + isAmber(colors.ae0001), + show('ae0001'), + ); + await shoot(page, 'globe-tint'); + + // Existing untracked billboards and models must repaint in both directions. + await page.evaluate(async () => { + window.__squawkQaPhase = 'cleared'; + await window.__godsEyeView.dataManager.refreshLayer('military'); + }); + record( + 'POLL: existing military billboard clears to amber', + isAmber((await page.evaluate(probe)).ae7700), + ); + await page.evaluate(async () => { + window.__squawkQaPhase = 'emergency'; + await window.__godsEyeView.dataManager.refreshLayer('military'); + }); + record( + 'POLL: existing military billboard turns red again', + isRed((await page.evaluate(probe)).ae7700), + ); + const modelProbe = () => { + const found = {}; + const visit = (items) => { + for (let i = 0; i < items.length; i++) { + const item = items.get(i); + if (item.ready && item.color && typeof item.id === 'string') + found[item.id] = { + r: Math.round(item.color.red * 255), + g: Math.round(item.color.green * 255), + b: Math.round(item.color.blue * 255), + }; + else if (item.get) visit(item); + } + }; + visit(window.__godsEyeView.viewer.scene.primitives); + return found; + }; + await page.waitForFunction( + `(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const beforeModels = await page.evaluate(modelProbe); + record( + 'MODELS: civil and military emergency models are red', + isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700), + JSON.stringify(beforeModels), + ); + await page.evaluate(async () => { + window.__squawkQaFixtures.civil[0][14] = '2000'; + window.__squawkQaFixtures.civil[1][14] = '7600'; + window.__squawkQaPhase = 'cleared'; + const dm = window.__godsEyeView.dataManager; + await dm.refreshLayer('flights'); + await dm.refreshLayer('military'); + }); + await page.waitForFunction( + `(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const afterModels = await page.evaluate(modelProbe); + record( + 'MODELS: existing civil models start and clear, military clears', + isWhite(afterModels['4847a1']) && + isRed(afterModels['4847a2']) && + isAmber(afterModels.ae7700), + JSON.stringify(afterModels), + ); + await page.evaluate(async () => { + window.__squawkQaFixtures.civil[0][14] = '7600'; + window.__squawkQaFixtures.civil[1][14] = '2000'; + window.__squawkQaFixtures.mil[0].squawk = '7700'; + window.__squawkQaPhase = 'emergency'; + const dm = window.__godsEyeView.dataManager; + await dm.refreshLayer('flights'); + await dm.refreshLayer('military'); + }); + // ── (ii) HUD ────────────────────────────────────────────────────────── + console.log('\n(ii) HUD — track the emergency and enter Cockpit...'); + const tracked = await page.evaluate(() => + Boolean( + window.__godsEyeView.dataManager.layers + .get('military') + .module.trackById('ae7700', { origin: 'user' }), + ), + ); + record( + 'HUD: emergency aircraft can be tracked', + tracked, + `tracked=${tracked}`, + ); + // Cockpit entry is gated on the Contacts (flights) context mode. + await page.evaluate(() => + window.__godsEyeView.styleManager._contextControls._selectContextMode( + 'flights', + ), + ); + await page + .waitForFunction( + () => { + const entry = document.getElementById('cockpit-entry'); + return entry && !entry.hidden && !entry.disabled; + }, + { timeout: 20000 }, + ) + .catch(() => {}); + await page + .$eval('#cockpit-entry', (entry) => entry.click()) + .catch(() => {}); + const hudReady = await page + .waitForFunction( + () => + document.body.classList.contains('cockpit-mode') && + /SQUAWK 7700/.test( + document.getElementById('cockpit-aircraft-meta')?.textContent || '', + ), + { timeout: 30000 }, + ) + .then( + () => true, + () => false, + ); + const hud = await page.evaluate(() => { + const meta = document.getElementById('cockpit-aircraft-meta'); + return { + cockpit: document.body.classList.contains('cockpit-mode'), + text: meta?.textContent || '', + emergencyClass: meta?.classList.contains('emergency') || false, + color: meta ? getComputedStyle(meta).color : null, + }; + }); + record( + 'HUD: meta line names the broadcast code', + hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'), + JSON.stringify(hud), + ); + record( + 'HUD: meta line is styled as an emergency', + hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''), + `class=${hud.emergencyClass} color=${hud.color}`, + ); + const trackedBroadcast = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('military') + .module.getTrackedInfo().emergencyBroadcast, + ); + record( + 'TRACKED: voice-facing status quotes the broadcast', + trackedBroadcast === 'broadcasting squawk 7700 (general emergency)', + trackedBroadcast, + ); + await shoot(page, 'cockpit-hud'); + + // ── (iii) CLEAR ─────────────────────────────────────────────────────── + console.log('\n(iii) CLEAR — the next poll drops the code...'); + await page.evaluate(async () => { + window.__squawkQaPhase = 'cleared'; + await window.__godsEyeView.dataManager.refreshLayer('military'); + }); + const cleared = await page + .waitForFunction( + () => + !document + .getElementById('cockpit-aircraft-meta') + ?.classList.contains('emergency'), + { timeout: 20000 }, + ) + .then( + () => true, + () => false, + ); + const after = await page.evaluate( + () => document.getElementById('cockpit-aircraft-meta')?.textContent || '', + ); + record( + 'CLEAR: HUD returns to its normal wording', + cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'), + JSON.stringify(after), + ); + await shoot(page, 'cockpit-cleared'); + + // ── (iv) RELEASE ────────────────────────────────────────────────────── + console.log('\n(iv) RELEASE — the code returns, then tracking stops...'); + await page.evaluate(async () => { + window.__squawkQaPhase = 'emergency'; + const dm = window.__godsEyeView.dataManager; + await dm.refreshLayer('military'); + dm.layers.get('military').module.stopTracking({ origin: 'user' }); + }); + const releaseProbe = `(${billboardColors})(["ae7700"])`; + const released = await page + .waitForFunction(`Boolean((${releaseProbe}).ae7700)`, { + timeout: 20000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const releasedColor = (await page.evaluate(releaseProbe)).ae7700; + record( + 'RELEASE: untracked emergency billboard is red', + released && isRed(releasedColor), + JSON.stringify(releasedColor ?? null), + ); + record( + 'no uncaught browser errors', + errors.length === 0, + errors.slice(0, 3).join(' | '), + ); + } catch (err) { + console.error('\x1b[31mHarness error:\x1b[0m', err); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok).length; + const fail = results.length - pass; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index d32ede533..27c8663bf 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -230,16 +230,20 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null); if (!FIXTURES && !status?.hasKey) { console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index b8b7c9f5e..3ef331abd 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome. const FILES = { keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'), module: path.join(ROOT, 'src', 'firstRunExperience.js'), - html: path.join(ROOT, 'index.html'), - css: path.join(ROOT, 'style.css'), + html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'), + css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'), voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'), - voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'), - main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'), - ui: path.join(ROOT, 'src', 'ui.js'), + voiceDescriptions: path.join( + ROOT, + 'server/providers/openai/toolDescriptions.js', + ), + main: path.join(ROOT, 'src', 'app', 'startupChrome.js'), + ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'), + radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'), docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'), }; @@ -63,14 +67,14 @@ const MUTATIONS = [ { defect: 'the session flag is never read, so it re-nags on every reload', file: 'module', - from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;", - to: '// session check removed', + from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;", + to: ' // session check removed', }, { defect: 'the durable flag is never read, so the checkbox does nothing', file: 'module', - from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;", - to: '// durable check removed', + from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;", + to: ' // durable check removed', }, { defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it', @@ -105,7 +109,7 @@ const MUTATIONS = [ { defect: 'the guarded resolver stops catching, so a hostile getter escapes', file: 'module', - from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", + from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;", }, { @@ -131,8 +135,8 @@ const MUTATIONS = [ { defect: 'nothing watches for another surface taking the screen', file: 'module', - from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });", - to: '/* observer never attached */', + from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });", + to: ' /* observer never attached */', }, { defect: 'cockpit drops off the exclusive list and can stack with the launcher', @@ -155,8 +159,8 @@ const MUTATIONS = [ { defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again', file: 'module', - from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();', - to: ' && root.getClientRects().length > 0;', + from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();', + to: ' root.getClientRects().length > 0;', }, { defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working', @@ -174,9 +178,9 @@ const MUTATIONS = [ // Anchored past the call so it cannot land on the cockpit disclosure's own // stopImmediatePropagation() a few lines below and prove nothing about this. defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', - file: 'ui', - from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', - to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', + file: 'radio', + from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', + to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', }, { defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', @@ -234,7 +238,7 @@ const MUTATIONS = [ { defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump', file: 'module', - from: " environmental: Object.freeze({", + from: ' environmental: Object.freeze({', to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({", }, { @@ -326,8 +330,8 @@ const MUTATIONS = [ { defect: 'the Context panel is left collapsed, hiding the mode that just started', file: 'module', - from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });", - to: '// panel reveal removed', + from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });", + to: ' // panel reveal removed', }, { defect: 'the decision table is deleted, so the next editor re-litigates it blind', @@ -340,8 +344,8 @@ const MUTATIONS = [ { defect: 'disabling the focused tile drops the keyboard to mid-flight', file: 'module', - from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));", - to: 'for (const button of buttons) button.disabled = next;', + from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));", + to: ' for (const button of buttons) button.disabled = next;', }, { defect: 'Tab escapes into an app the visitor has not seen yet', @@ -370,8 +374,8 @@ const MUTATIONS = [ { defect: 'the card stays clickable through its fade-out (double-launch)', file: 'css', - from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', - to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', + from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', + to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', }, { defect: 'the launcher survives Clean-UI and recording mode', @@ -403,8 +407,8 @@ const MUTATIONS = [ // those instead and prove nothing about this feature. defect: "the launcher's reduced-motion opt-out is dropped", file: 'css', - from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }', - to: ' .first-run-nothing { transition: none; }', + from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }', + to: ' .first-run-nothing {\n transition: none;\n }', }, // ── Markup and startup ordering ─────────────────────────────────────────── @@ -442,21 +446,21 @@ const MUTATIONS = [ defect: 'the launcher is revealed before the loading cover yields', file: 'main', from: " loadingScreen.classList.add('hidden');", - to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');', + to: " revealFirstRun();\n loadingScreen.classList.add('hidden');", }, { defect: 'the globe missions lose their DataManager and cannot enable layers', file: 'main', - from: 'initFirstRunExperience({ styleManager, dataManager });', - to: 'initFirstRunExperience({ styleManager });', + from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });', + to: 'firstRun = initializeWelcome?.({ styleManager });', }, // ── Voice: schema must not drift ────────────────────────────────────────── { defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)', - file: 'voiceTools', - from: " 'earthquakes',\n 'satellites',", - to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',", + file: 'voiceDescriptions', + from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers", + to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers", }, { defect: 'the instruction mapping is dropped, so voice cannot reach the modes', diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 0e2ac9d62..b193ff169 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -26,7 +26,7 @@ const TESTS = [ ]; const FLIGHTS = 'src/layers/flights/motion.js'; -const FLOOR = 'src/data/groundFloor.js'; +const FLOOR = 'src/services/groundFloor.js'; const ALT = 'src/data/renderAltitude.js'; /** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */ @@ -133,8 +133,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' if (lowest == null || h < lowest) lowest = h;', - to: ' if (lowest == null || h > lowest) lowest = h;', + from: ' if (lowest == null || h < lowest) lowest = h;', + to: ' if (lowest == null || h > lowest) lowest = h;', }, ], }, @@ -143,8 +143,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', - to: ' return resolved >= 1 ? lowest : null;', + from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', + to: ' return resolved >= 1 ? lowest : null;', }, ], }, diff --git a/scripts/qa-flyroute-mutations.mjs b/scripts/qa-flyroute-mutations.mjs index dbece6dba..d7d3d3325 100644 --- a/scripts/qa-flyroute-mutations.mjs +++ b/scripts/qa-flyroute-mutations.mjs @@ -35,17 +35,17 @@ const MUTATIONS = [ }, { defect: 'heading lerps in Cartesian space and cannot cross a U-turn', - from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);', + from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );', to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);', }, { defect: 'the exact-180° bank direction becomes a floating-point coin toss', - from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;', + from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;', to: ' // branch removed', }, { defect: 'a cold cell is read as sea level (the 1.3 km underground bug)', - from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;', + from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;', to: ' state.floorM = carto.height;', }, { @@ -55,12 +55,12 @@ const MUTATIONS = [ }, { defect: 'the dolly races the fire-and-forget warm instead of arming for it', - from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {', + from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {', to: ' if (false) {', }, { defect: 'no mesh probe, so a permanently cold DEM is flown blind', - from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {', + from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {', to: ' if (false) {', }, { @@ -90,8 +90,8 @@ const MUTATIONS = [ }, { defect: 'the mesh probe fires every arming frame instead of once per route', - from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;', - to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;', + from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', + to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', }, { defect: 'one warm cell resolves a corridor whose other cells are unknown', @@ -105,13 +105,13 @@ const MUTATIONS = [ }, { defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample', - from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,', + from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,', to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,', }, { defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)', - from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', - to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', + from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });', + to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });', }, { defect: 'the OS reduced-motion preference is never consulted', diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs new file mode 100644 index 000000000..41933141e --- /dev/null +++ b/scripts/qa-gnss.mjs @@ -0,0 +1,988 @@ +#!/usr/bin/env node +/** + * qa-gnss.mjs — headless proof for the GNSS Integrity layer + * (id `gnss-integrity`): low navigation accuracy reported by ADS-B. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server + * cache, one upstream read per whole-degree anchor); the other sections + * stub that route in the page so they are deterministic. + * + * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, + * one geometry per cell and legend band counts that sum to the cell + * count; cells are required only when the returned rows, binned + * with the layer's own rules, reach the per-cell minimum (live + * traffic varies by hour). Requires a non-stale response no older than 60 s. + * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, + * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell + * with 1 degraded is low (first bad discounted), a clean cell at sea + * is low, a 2-aircraft cell is withheld; `stale: true` reaches + * getStats. Every band is drawn and low is shown over land and sea. + * The row reads as low navigation accuracy, and stats carry the + * machine-readable provenance (GEV classifier, gpsjam bands, window). + * PICK verifies actual perimeter and empty-ground clicks under GNSS. + * CAMERA/GLOBE verify automatic sampling and the screen-centre anchor. + * (iii) FAILURE — the route stubbed to 502: stats.error is set and the + * cells already on the globe are kept. + * (iv) DISABLE — owned ground primitives are hidden. + * (v) KEYBOARD — the row's toggle button, focused, turns the layer on + * with Enter and off with Space, keeps focus and names its state. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend + * fit the panel width with no horizontal overflow. + * (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same + * stale snapshot observed 31 minutes ago draws nothing and reports no + * error; observed 29 minutes ago it draws its cells. The replay keeps + * its observation time instead of restarting the 30-minute window. + * + * Screenshots wait until every cell's ground geometry is built and show the + * open Data Layers row with its legend. Visual proof saved to + * qa-shots/gnss-*.png (gitignored). + * + * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 + * (also against a built preview: npm run build && npm run preview, + * then pass the preview URL) + * Optional: --timeout-ms 180000 for a slow boot. + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + accumulateGnssObservations, + binGnssCells, +} from '../src/layers/gnss/records.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000')); +const LAYER_ID = 'gnss-integrity'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Boot the app, dismiss the intro and place the camera straight down. */ +async function boot(page, { lon, lat, height }) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); + await page.evaluate( + (lo, la, h) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + // The app's intro flyTo animation clobbers a setView issued mid-flight. + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight active */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: h, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, + lon, + lat, + height, + ); +} + +/** Enable (or refresh) the layer and poll until it settles. */ +async function enableAndSettle(page, { refresh = false } = {}) { + return page.evaluate( + async (id, again) => { + const dm = window.__godsEyeView.dataManager; + const layer = dm.layers.get(id).module; + if (again) await dm.refreshLayer(id); + else await dm.setEnabled(id, true); + const stats = layer.getStats(); + const rowControls = layer.getRowControls().legend; + const legend = rowControls.map(({ label, count }) => ({ label, count })); + const blurb = rowControls[0]?.blurb ?? ''; + const rowName = + document.querySelector(`[data-layer-id="${id}"] .data-name`) + ?.textContent ?? ''; + const all = window.__godsEyeView.viewer.scene.groundPrimitives; + const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + ); + return { + stats, + legend, + blurb, + rowName, + name: layer.name, + entities: ds?.get(0)?.geometryInstances.length ?? 0, + shown: ds?.show ?? null, + }; + }, + LAYER_ID, + refresh, + ); +} + +/** Open the Data Layers panel and bring the GNSS row into view. */ +async function showLayerRow(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]').click(); + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(800); +} + +/** Actual card pixels on the shared canvas, available in dev and production. */ +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function shoot(page, name) { + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: TIMEOUT_MS }, + ) + .catch(() => {}); + await showLayerRow(page); + await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) }); +} + +/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ +function cellStates(id) { + const { viewer } = window.__godsEyeView; + const all = viewer.scene.groundPrimitives; + const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + ); + if (!ds?.length) return []; + return [ds.get(0).ready ? 0 : 1]; +} + +/** Wait until every GNSS cell's ground rectangle has finished building. */ +async function waitForBuiltCells(page, timeoutMs = 60000) { + const built = await page + .waitForFunction( + `(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`, + { timeout: timeoutMs, polling: 250 }, + ) + .then( + () => true, + () => false, + ); + const states = await page.evaluate( + `(${cellStates})(${JSON.stringify(LAYER_ID)})`, + ); + return { built, detail: `states=${JSON.stringify(states)}` }; +} + +const fixtureRow = (hex, lat, lon, degraded) => ({ + hex, + lat, + lon, + nic: degraded ? 0 : 8, + nacp: degraded ? 0 : 10, + gpsLost: false, + degraded, +}); + +/** + * Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad), + * low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft). + */ +function fixturePayload() { + const rows = []; + const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`; + for (let i = 0; i < 10; i++) + rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); + for (let i = 0; i < 30; i++) + rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2)); + for (let i = 0; i < 3; i++) + rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); + for (let i = 0; i < 4; i++) + rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false)); + for (let i = 0; i < 2; i++) + rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); + return { + fetchedAt: Date.now(), + anchor: { lat: 55, lon: 19 }, + radiusNm: 250, + rows, + stale: true, + }; +} + +/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */ +async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) { + await page.evaluateOnNewDocument( + (payload, age, failures, staleOverride) => { + window.__gnssQaAgeMs = age; + window.__gnssQaRequests = []; + window.__gnssQaFailures = failures; + const realFetch = window.fetch.bind(window); + window.__gnssQaMode = 'fixture'; + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + window.__gnssQaRequests.push(url); + if (window.__gnssQaFailures > 0) { + window.__gnssQaFailures--; + return Promise.resolve( + Response.json( + { error: 'rate_limited' }, + { + status: 429, + headers: { 'Retry-After': '5' }, + }, + ), + ); + } + const ok = window.__gnssQaMode === 'fixture'; + const body = ok + ? { + ...payload, + fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0), + ageMs: window.__gnssQaAgeMs || 0, + stale: staleOverride ?? payload.stale, + } + : { error: 'gnss_integrity_unavailable' }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: ok ? 200 : 502, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + }, + fixturePayload(), + ageMs, + initialFailures, + stale, + ); +} + +/** Toggle button of the GNSS row, as the panel draws it. */ +function toggleState(id) { + const button = document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ); + const layer = window.__godsEyeView.dataManager.layers.get(id); + return { + focused: document.activeElement === button, + label: button?.getAttribute('aria-label') ?? '', + enabled: Boolean(layer?.enabled), + count: layer?.module.getStats().count ?? -1, + }; +} + +/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */ +async function pressToggle(page, key, wantEnabled) { + await page.evaluate((id) => { + document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus(); + }, LAYER_ID); + await page.keyboard.press(key); + await page + .waitForFunction( + (id, want) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + Boolean(layer?.enabled) === want && + (!want || layer.module.getStats().count > 0) + ); + }, + { timeout: 30000, polling: 250 }, + LAYER_ID, + wantEnabled, + ) + .catch(() => {}); + return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`); +} + +async function main() { + console.log('\nGNSS Integrity proof (qa-gnss)'); + console.log(` App URL : ${APP_URL}\n`); + try { + const res = await fetch(APP_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); + process.exit(2); + } + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath().catch(() => null)) || + undefined; + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(executablePath && fs.existsSync(executablePath) + ? { executablePath } + : {}), + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', + ], + }); + + let exitCode = 0; + const errors = []; + try { + // ── (i) LIVE ────────────────────────────────────────────────────────── + console.log( + '(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...', + ); + const live = await browser.newPage(); + await live.setViewport({ width: 1440, height: 900 }); + live.on('pageerror', (e) => errors.push(e.message)); + const liveBodies = []; + live.on('response', (res) => { + if (res.url().includes('/api/gnss-integrity') && res.ok()) + liveBodies.push(res.json()); + }); + await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 }); + const l = await enableAndSettle(live); + const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); + // Bin what adsb.lol actually returned with the layer's own rules: live + // traffic varies by hour, so cells are required only when some cell + // really reached the minimum aircraft count. + const liveSnapshots = (await Promise.allSettled(liveBodies)) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value); + record( + 'LIVE: a successful, dated integrity response was received', + liveSnapshots.length > 0 && + liveSnapshots.every( + (body) => + Array.isArray(body.rows) && + Number.isFinite(body.ageMs) && + body.ageMs >= 0 && + body.ageMs <= 60000 && + body.stale !== true && + body.classifier === 'gev-nic-nacp-v2', + ), + `successful responses=${liveSnapshots.length}`, + ); + const liveRows = liveSnapshots.reduce( + (sum, body) => sum + (body.rows?.length || 0), + 0, + ); + const expectedStore = new Map(); + for (const body of liveSnapshots) { + const at = Date.now(); + accumulateGnssObservations(expectedStore, body.rows, at, { + observedAt: at - body.ageMs, + }); + } + const expectedCells = binGnssCells(expectedStore).length; + record( + 'LIVE: no error, one geometry per cell', + !l.stats.error && l.entities === l.stats.count, + `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, + ); + if (expectedCells > 0) { + record( + 'LIVE: a snapshot with a qualifying cell yields cells', + l.stats.count > 0, + `rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`, + ); + } else { + console.log( + ` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`, + ); + } + record( + 'LIVE: legend band counts sum to the cell count', + legendTotal === l.stats.count, + JSON.stringify(l.legend), + ); + const liveBuilt = await waitForBuiltCells(live); + record( + 'LIVE: every cell rectangle is built on a parked camera', + liveBuilt.built, + liveBuilt.detail, + ); + await shoot(live, 'live-poland-baltic'); + await live.close(); + + // ── (ii) BANDS ──────────────────────────────────────────────────────── + console.log( + '\n(ii) BANDS — synthetic integrity rows through the real layer...', + ); + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + page.on('pageerror', (e) => errors.push(e.message)); + // Stub the route in-page rather than with request interception: an + // intercepted page stalls Cesium's geometry workers, so ground rectangles + // never finish building and the screenshot would not show real behaviour. + await stubRoute(page); + await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); + const f = await enableAndSettle(page); + const band = (prefix) => + f.legend.find(({ label }) => label.startsWith(prefix))?.count; + record( + 'BANDS: 4 cells shown, the 2-aircraft cell withheld', + f.stats.count === 4 && f.entities === 4, + `cells=${f.stats.count} entities=${f.entities}`, + ); + record( + 'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low', + band('Low-accuracy score >') === 1 && + band('Low-accuracy score 2') === 1 && + band('Low-accuracy score <') === 2, + JSON.stringify(f.legend), + ); + const prov = f.stats.provenance; + record( + 'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable', + prov?.classifier?.id === 'gev-nic-nacp-v2' && + prov.classifier.definedBy === 'GEV' && + prov.classifier.validated === false && + prov.aggregation?.source === 'https://gpsjam.org/faq' && + JSON.stringify(prov.aggregation.bands) === '[2,10]' && + prov.window?.minutes === 30 && + prov.window.scope === 'visited-view' && + prov.interpretation?.validated === false, + JSON.stringify(prov?.classifier), + ); + record( + 'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading', + f.name === 'GNSS Integrity' && + f.rowName.includes('GNSS Integrity') && + !/interference/i.test(f.rowName) && + f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) && + /one aircraft is discounted/.test(f.blurb) && + /not a detection/.test(f.blurb), + `name=${JSON.stringify(f.name)}`, + ); + record( + 'BANDS: stale snapshot is reported', + f.stats.stale === true, + `stale=${f.stats.stale}`, + ); + const bandsBuilt = await waitForBuiltCells(page); + record( + 'BANDS: every cell rectangle is built on a parked camera', + bandsBuilt.built, + bandsBuilt.detail, + ); + await shoot(page, 'synthetic-bands'); + + // GNSS fills must not own map input, including over a real perimeter. + await page.evaluate(async () => { + const original = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input.url; + if (url.includes('/api/fire-perimeters/inciweb')) + return Promise.resolve(Response.json({ rows: [] })); + if (url.includes('/api/fire-perimeters')) + return Promise.resolve( + Response.json({ + rows: [ + { + stableId: 'gnss-pick-qa', + name: 'GNSS PICK QA FIRE', + containedPct: 0, + acres: 100, + polygons: [ + [ + [ + [18.247, 54.247], + [18.253, 54.247], + [18.253, 54.253], + [18.247, 54.253], + [18.247, 54.247], + ], + ], + ], + }, + ], + }), + ); + return original(input, init); + }; + const { viewer, dataManager } = window.__godsEyeView; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.25 * Math.PI) / 180, + latitude: (54.25 * Math.PI) / 180, + height: 2500, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await dataManager.setEnabled('fire-perimeters', true); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const v = window.__godsEyeView.viewer, + p = v.scene.pick({ + x: v.scene.canvas.clientWidth / 2, + y: v.scene.canvas.clientHeight / 2, + }); + return String(p?.id?.id ?? p?.id ?? '').startsWith( + 'fire-perimeter:gnss-pick-qa:', + ); + }, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const pickProbe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer, + c = v.scene.canvas; + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const empty = { x: centre.x + 300, y: centre.y }; + const picked = v.scene.pick(empty), + box = c.getBoundingClientRect(); + return { + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + surface: !picked || picked.id === undefined, + height: v.camera.positionCartographic.height, + }; + }); + record('PICK: a perimeter remains selectable under GNSS', true); + record( + 'PICK: street ground remains a surface pick under GNSS', + pickProbe.surface, + ); + const beforeCardPixels = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y); + await page.waitForFunction( + `(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`, + { timeout: 10000, polling: 250 }, + ); + const selectedCardPixels = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: actual perimeter click paints its incident card', + selectedCardPixels > beforeCardPixels + 100, + `${beforeCardPixels} → ${selectedCardPixels} pixels`, + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'), + }); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y); + await page.waitForFunction( + `(${paintedOverlayPixels})() <= ${beforeCardPixels}`, + { timeout: 10000, polling: 250 }, + ); + const clearedCardPixels = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: empty ground clears the perimeter card', + clearedCardPixels <= beforeCardPixels, + `${selectedCardPixels} → ${clearedCardPixels} pixels`, + ); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, { + clickCount: 2, + }); + await sleep(1000); + record( + 'PICK: empty double-click does not zoom to a GNSS cell', + await page.evaluate( + (height) => + Math.abs( + window.__godsEyeView.viewer.camera.positionCartographic.height - + height, + ) < 200, + pickProbe.height, + ), + ); + await page.evaluate(async () => { + await window.__godsEyeView.dataManager.setEnabled( + 'fire-perimeters', + false, + ); + }); + console.log('\nCAMERA — automatic regional refresh without toggling...'); + const beforeMove = await page.evaluate( + () => window.__gnssQaRequests.length, + ); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (139.7 * Math.PI) / 180, + latitude: (35.7 * Math.PI) / 180, + height: 1_100_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 139.7, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + const moved = await page.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + coverage: window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats().coverage, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + )?.textContent, + }), + LAYER_ID, + ); + record( + 'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row', + moved.enabled && + moved.requests > beforeMove && + moved.requests <= beforeMove + 2 && + moved.coverage?.longitude === 140 && + /Regional sample.*250 nm radius/.test(moved.info), + JSON.stringify(moved), + ); + await shoot(page, 'camera-refresh'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (12.4 * Math.PI) / 180, + latitude: (41.9 * Math.PI) / 180, + height: 16_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + const params = url && new URL(url, location.origin).searchParams; + return ( + params && + Math.abs(Number(params.get('lon')) - 12.4) < 1 && + Math.abs(Number(params.get('lat')) - 41.9) < 1 + ); + }, + { timeout: 15000 }, + ); + const world = await page.evaluate((id) => { + const { viewer, dataManager } = window.__godsEyeView; + const bounds = viewer.camera.computeViewRectangle( + viewer.scene.globe.ellipsoid, + ); + return { + enabled: dataManager.isEnabled(id), + height: viewer.camera.positionCartographic.height, + boundsMidLongitude: + bounds && ((bounds.west + bounds.east) * 90) / Math.PI, + coverage: dataManager.layers.get(id).module.getStats().coverage, + }; + }, LAYER_ID); + record( + 'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius', + world.enabled && + world.height > 15_000_000 && + world.coverage?.longitude === 12 && + world.coverage?.latitude === 42 && + world.coverage?.radiusNm === 250, + JSON.stringify(world), + ); + await shoot(page, 'globe-regional'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.6 * Math.PI) / 180, + latitude: (54.6 * Math.PI) / 180, + height: 450_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 18.6, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + + // ── (iii) FAILURE ───────────────────────────────────────────────────── + console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); + await page.evaluate(() => { + window.__gnssQaMode = 'error'; + }); + const e = await enableAndSettle(page, { refresh: true }); + record( + 'FAILURE: error surfaced, cells kept', + Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4, + `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`, + ); + await shoot(page, 'failure'); + + // ── (iv) DISABLE ────────────────────────────────────────────────────── + console.log('\n(iv) DISABLE...'); + const shown = await page.evaluate(async (id) => { + await window.__godsEyeView.dataManager.setEnabled(id, false); + const all = window.__godsEyeView.viewer.scene.groundPrimitives; + return Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + )?.show; + }, LAYER_ID); + record('DISABLE: data source hidden', shown === false, `show=${shown}`); + await shoot(page, 'disabled'); + + // ── (v) KEYBOARD ────────────────────────────────────────────────────── + console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...'); + await page.evaluate(() => { + window.__gnssQaMode = 'fixture'; + }); + await showLayerRow(page); + const on = await pressToggle(page, 'Enter', true); + record( + 'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus', + on.enabled && + on.focused && + on.count === 4 && + /^GNSS Integrity: /.test(on.label) && + !/OFF$/.test(on.label), + JSON.stringify(on), + ); + const off = await pressToggle(page, ' ', false); + record( + 'KEYBOARD: Space on the focused toggle disables it and the label says OFF', + !off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label), + JSON.stringify(off), + ); + + // ── (vi) VIEWPORT ───────────────────────────────────────────────────── + console.log( + '\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...', + ); + await page.close(); + // A fresh page: switching an open page to isMobile reloads it, which + // would measure a half-booted app instead of the open row. + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + phone.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(phone); + await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(phone); + await pressToggle(phone, 'Enter', true); + await showLayerRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const legend = [...row.querySelectorAll('.data-toggle-legend-item')]; + const rect = row.getBoundingClientRect(); + return { + width: window.innerWidth, + rowLeft: Math.round(rect.left), + rowRight: Math.round(rect.right), + overflow: row.scrollWidth - row.clientWidth, + legendItems: legend.length, + coverageInfo: row.querySelector('.data-toggle-controls-info') + ?.textContent, + legendInside: legend.every((item) => { + const r = item.getBoundingClientRect(); + return ( + r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1 + ); + }), + }; + }, LAYER_ID); + record( + 'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow', + fit.rowLeft >= 0 && + fit.rowRight <= fit.width && + fit.overflow <= 0 && + fit.legendItems === 3 && + /Regional sample.*250 nm radius/.test(fit.coverageInfo) && + fit.legendInside, + JSON.stringify(fit), + ); + await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') }); + await phone.close(); + + console.log( + '\nSTARTUP — initial rate limit stays enabled and recovers automatically...', + ); + const retry = await browser.newPage(); + await retry.setViewport({ width: 1440, height: 900 }); + retry.on('pageerror', (e) => errors.push(e.message)); + // Pages share the disposable test context's storage. Start this one cold, + // so a previous fixture cannot auto-enable GNSS before the START test. + await retry.evaluateOnNewDocument(() => localStorage.clear()); + await stubRoute(retry, { initialFailures: 1, stale: false }); + await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(retry); + await retry.evaluate((id) => { + window.__gnssQaFailedStarts = 0; + window.__godsEyeView.dataManager.subscribe((change) => { + if (change.layerId === id && change.type === 'visibility-failed') + window.__gnssQaFailedStarts++; + }); + }, LAYER_ID); + const initial = await enableAndSettle(retry); + const limited = await retry.evaluate((id) => { + const dm = window.__godsEyeView.dataManager; + const row = document.querySelector(`[data-layer-id="${id}"]`); + return { + enabled: dm.isEnabled(id), + failedStarts: window.__gnssQaFailedStarts, + label: row.querySelector('.data-toggle-btn').textContent, + info: row.querySelector('.data-toggle-controls-info').textContent, + }; + }, LAYER_ID); + record( + 'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing', + limited.enabled && + limited.failedStarts === 0 && + limited.label === 'UNAVAILABLE' && + initial.stats.count === 0 && + initial.stats.retryInSec > 0 && + /rate limited.*Retrying automatically at/.test(limited.info), + JSON.stringify(limited), + ); + await retry.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'), + }); + await retry.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + layer.enabled && + layer.module.getStats().error === null && + layer.module.getStats().count === 4 + ); + }, + { timeout: 20000 }, + LAYER_ID, + ); + const recovered = await retry.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + label: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ).textContent, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + ).textContent, + }), + LAYER_ID, + ); + record( + 'STARTUP: one automatic retry recovers to ON without toggling and clears the warning', + recovered.enabled && + recovered.requests === 2 && + recovered.label === 'ON' && + !/Retrying/.test(recovered.info), + JSON.stringify(recovered), + ); + await shoot(retry, 'startup-recovered'); + await retry.close(); + + // ── (vii) AGE ───────────────────────────────────────────────────────── + console.log( + '\n(vii) AGE — a replayed stale snapshot keeps its observation time...', + ); + const aged = await browser.newPage(); + await aged.setViewport({ width: 1440, height: 900 }); + aged.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(aged, { ageMs: 31 * 60_000 }); + await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 }); + const old = await enableAndSettle(aged, {}); + record( + 'AGE: a snapshot observed 31 min ago adds no cells and is not an error', + old.stats.count === 0 && old.entities === 0 && !old.stats.error, + `cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`, + ); + await aged.evaluate(() => { + window.__gnssQaAgeMs = 29 * 60_000; + }); + const recent = await enableAndSettle(aged, { refresh: true }); + record( + 'AGE: the same snapshot observed 29 min ago is still inside the window', + recent.stats.count === 4 && recent.entities === 4, + `cells=${recent.stats.count} entities=${recent.entities}`, + ); + await aged.close(); + record( + 'no uncaught browser errors', + errors.length === 0, + errors.slice(0, 3).join(' | '), + ); + } catch (err) { + console.error('\x1b[31mHarness error:\x1b[0m', err); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok).length; + const fail = results.length - pass; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..89d60d96f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -12,16 +12,22 @@ * Run: start the app (`./scripts/dev-fresh.sh`) and * node scripts/qa-recent-imagery.mjs * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default - * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without - * network access to NASA the run skips with exit code 0. + * qa-shots/recent-imagery), QA_HEADFUL=1. + * QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a + * 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s). + * Every wait is bounded. Without NASA access the run skips with code 0. */ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; const HEADFUL = process.env.QA_HEADFUL === '1'; +const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1'; +const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000; +const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12; const STEP_TIMEOUT = 20_000; const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 }; const PANEL = '#recent-imagery-panel'; @@ -49,8 +55,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ @@ -308,15 +313,21 @@ const CONTROLS = { }; const measure = () => page.evaluate((controls) => { + const panel = document.getElementById('recent-imagery-panel'); const body = document.getElementById('recent-imagery-panel-body'); + const panelRect = panel?.getBoundingClientRect(); const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; for (const [name, selector] of Object.entries(controls)) { const rect = document.querySelector(selector)?.getBoundingClientRect(); - out.rects[name] = rect - ? [rect.left, rect.top, rect.width, rect.height].map( - (v) => Math.round(v * 10) / 10, - ) - : null; + out.rects[name] = + rect && panelRect + ? [ + rect.left - panelRect.left, + rect.top - panelRect.top, + rect.width, + rect.height, + ].map((v) => Math.round(v * 10) / 10) + : null; } return out; }, CONTROLS); @@ -372,6 +383,9 @@ try { const initialStack = await page.evaluate(() => window.__riQa.activeStack()); const photoreal = initialStack === 'photoreal'; console.log(`initial map stack: ${initialStack}`); + console.log( + `stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`, + ); const onPhotorealOnly = (name, passed, detail) => photoreal ? check(name, passed, detail) @@ -1042,13 +1056,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1272,8 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => + new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 73d28ca60..b236ab0b1 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -139,15 +139,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index 44319af0d..d1b4950fd 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -145,15 +145,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 2a04d5595..424f3486e 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -113,17 +113,21 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..c229be96c 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,11 +1,11 @@ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; /** Prove parked and moving headings in the real scene, without live feeds. */ export async function runTransitHeadingRegression(base, check) { const browser = await puppeteer.launch({ headless: true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: ['--no-sandbox', '--disable-background-timer-throttling'], }); try { diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index e5fadda49..5f6404b41 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index aac1d8f60..7d83d77a4 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,6 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true }); const browser = await puppeteer.launch({ headless: false, protocolTimeout: 150000, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9bd7e70e0..6cc6aaa94 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { return version; } +/** + * Directories this runner searches for `*.test.mjs`, in stable order. + * + * `server/` belongs here because provider modules own tests of their own: a + * suite placed beside the module it covers must run, and CI's unit-test job runs + * `npm test`, so a directory missing from this list is a suite that never runs + * there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs` + * outside dependencies, build output and dot-directories, so adding a tree + * without adding it here fails. + */ +export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); + /** Discover repository unit tests in stable path order. */ export function discoverUnitTestFiles(root = process.cwd()) { - const sourceRoot = path.join(root, 'src'); const files = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) { } } }; - visit(sourceRoot); + for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot)); return files.sort(); } diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js new file mode 100644 index 000000000..adf28cea5 --- /dev/null +++ b/server/providers/gnssIntegrity.js @@ -0,0 +1,238 @@ +import { + GNSS_CLASSIFIER, + GNSS_WINDOW_MS, + normalizeGnssAircraft, +} from '../../src/layers/gnss/records.js'; +import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; +import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the +// GNSS layer needs leave this proxy, so the browser never sees the full feed. +// This is a separate read from the flights fallback on purpose: that point +// cache keeps only the fields the flights layer renders (no nic / nac_p), is +// keyed on 0.25° anchors and lives for 12 s, while integrity cells need +// whole-degree anchors held for a minute. +const RADIUS_NM = 250; +const CACHE_MS = 60_000; +const CACHE_MAX = 64; +/** Default 429 cooldown when upstream sends no usable Retry-After. */ +const COOLDOWN_MS = 60_000; +/** Bound sane upstream deadlines to one hour; never shorten a deadline within that range. */ +const COOLDOWN_MIN_MS = 5_000; +const COOLDOWN_MAX_MS = 60 * 60_000; +const FAILURE_COOLDOWN_MS = 15_000; +const MAX_IN_FLIGHT = 8; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const TIMEOUT_MS = 10_000; + +/** Snap a view anchor to a whole degree so nearby views share one upstream read. */ +export function gnssIntegrityAnchor(url) { + const anchor = adsbLolFallbackAnchor({ url }); + if (!anchor) return null; + return { + lat: Math.round(anchor.latitude), + lon: Math.round(anchor.longitude), + }; +} + +/** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ +export function gnssRetryCooldownMs(raw, nowMs) { + if (raw) { + const seconds = /^\d+(?:\.\d+)?$/.test(raw.trim()) ? Number(raw) : NaN; + const ms = seconds * 1000; + if (Number.isFinite(seconds) && seconds > 0) + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); + const at = + Number.isNaN(seconds) && /[a-z]/i.test(raw) ? Date.parse(raw) : NaN; + if (Number.isFinite(at) && at > nowMs) + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, at - nowMs)); + } + return COOLDOWN_MS; +} + +/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */ +export function gnssIntegrityProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + monotonicNow = () => performance.now(), + warn = (...args) => console.warn(...args), +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 }); + let cooldownUntil = 0; + let cooldownStatus = 429; + + async function load({ lat, lon }) { + if (monotonicNow() < cooldownUntil) + throw Object.assign(new Error('cooling_down'), { + status: cooldownStatus, + }); + const signal = AbortSignal.timeout(TIMEOUT_MS); + try { + const response = await fetchImpl( + `https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`, + { + signal, + redirect: 'error', + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-gnss-integrity/1.0', + }, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + if (response.status === 429) { + const at = monotonicNow(); + const raw = response.headers.get('retry-after'); + const delay = gnssRetryCooldownMs(raw, now()); + if (delay === COOLDOWN_MAX_MS) + warn('[gnss-integrity] upstream cooldown capped at one hour'); + cooldownStatus = 429; + cooldownUntil = Math.max(cooldownUntil, at + delay); + throw Object.assign(new Error('upstream_rate_limited'), { + status: 429, + }); + } + throw new Error('upstream_unavailable'); + } + const payload = await readResponseJsonCapped( + response, + MAX_RESPONSE_BYTES, + signal, + ); + const rows = normalizeGnssAircraft(payload); + if (!rows) throw new Error('invalid_snapshot'); + const sourceNow = payload.now; + if (!Number.isFinite(sourceNow) || sourceNow <= 0) + throw new Error('undated_snapshot'); + const fetchedAt = Math.min( + now(), + sourceNow > 10_000_000_000 ? sourceNow : sourceNow * 1000, + ); + if (now() - fetchedAt > GNSS_WINDOW_MS) + throw new Error('expired_snapshot'); + return { + fetchedAt, + anchor: { lat, lon }, + radiusNm: RADIUS_NM, + // `degraded` on each row is this classifier's verdict, not gpsjam's. + classifier: GNSS_CLASSIFIER.id, + rows, + }; + } catch (error) { + if (error.status !== 429) { + const until = monotonicNow() + FAILURE_COOLDOWN_MS; + if (until > cooldownUntil) { + cooldownUntil = until; + cooldownStatus = 502; + } + } + throw error; + } + } + + function age(entry) { + if (!entry) return Infinity; + const elapsed = monotonicNow() - entry.savedAt; + // A backwards injected clock cannot rejuvenate a snapshot. + return elapsed < 0 ? Infinity : entry.ageAtReceipt + elapsed; + } + + async function acquire(anchor, peer) { + const key = `${anchor.lat},${anchor.lon}`; + const previous = cache.get(key); + if (age(previous) < CACHE_MS) + return { value: previous.value, ageMs: age(previous), stale: false }; + try { + if (!inFlight.has(key) && inFlight.size >= MAX_IN_FLIGHT) + throw Object.assign(new Error('busy'), { status: 429, local: true }); + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + if (monotonicNow() >= cooldownUntil && !allow(peer)) + throw Object.assign(new Error('rate_limited'), { + status: 429, + local: true, + }); + const value = await load(anchor); + const entry = { + value, + savedAt: monotonicNow(), + ageAtReceipt: Math.max(0, now() - value.fetchedAt), + }; + cache.delete(key); + cache.set(key, entry); + while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); + return entry; + }); + const entry = await promise; + const ageMs = age(entry); + return { value: entry.value, ageMs, stale: ageMs >= CACHE_MS }; + } catch (error) { + if (age(previous) <= GNSS_WINDOW_MS) + return { value: previous.value, ageMs: age(previous), stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false, retryAfterS = 60) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...([429, 502].includes(status) + ? { 'Retry-After': String(retryAfterS) } + : {}), + ...(stale ? { 'X-Data-Stale': 'true' } : {}), + }); + res.end(JSON.stringify(value)); + }; + if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + const anchor = gnssIntegrityAnchor(req.url); + if (!anchor) return json(400, { error: 'invalid_anchor' }); + try { + const { value, stale, ageMs } = await acquire(anchor, clientKey(req)); + // ageMs is measured on this server's clock, so the browser can date + // the rows without comparing its clock to ours. + json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale); + } catch (error) { + if (error.status === 429) { + // Tell the browser how long the shared upstream cooldown has left. + const retryAfterS = Math.max( + 1, + error.local ? 60 : Math.ceil((cooldownUntil - monotonicNow()) / 1000), + ); + return json( + 429, + { + error: error.local ? error.message : 'gnss_integrity_rate_limited', + }, + false, + retryAfterS, + ); + } + json( + 502, + { error: 'gnss_integrity_unavailable' }, + false, + Math.max(1, Math.ceil((cooldownUntil - monotonicNow()) / 1000)), + ); + } + } + + return { + name: 'gnss-integrity', + configureServer({ middlewares }) { + middlewares.use('/api/gnss-integrity', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/gnss-integrity', handler); + }, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 9acbade31..9fb6ae9c0 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; +import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) { cycloneProxy(), mapillaryProxy(), firePerimetersProxy(), + gnssIntegrityProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 450c9da81..18de877db 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -179,7 +179,7 @@ export const ACTION_DESCRIPTIONS = { }, 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.', + 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action. Tracked aircraft emergency status is a crowd-sourced transponder broadcast, not a confirmed incident: quote emergencyBroadcast and never infer a hijack or an incident ending from a code change.', $position: 1, }, set_hud: { @@ -543,7 +543,7 @@ export const ACTION_DESCRIPTIONS = { }, analyst_query: { description: - 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values).', + 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.', $position: 1, parameters: { properties: { diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 6ea9209f1..cb06c182a 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde const rows = Array.from({ length: 15000 }, (_, i) => row(now, `vehicle-${i}`), ); - const start = performance.now(); + // Main-thread CPU: excludes scheduler waits and parallel GC helper threads. + const start = process.threadCpuUsage(); h.ingest(feed, rows, now); - times.push(performance.now() - start); + const usage = process.threadCpuUsage(start); + times.push((usage.user + usage.system) / 1000); } assert.equal(h.diagnostics().keys, 15000); for (let i = 0; i < 15000; i++) { @@ -154,12 +156,12 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde } assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024); t.diagnostic( - `15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`, + `15000-vehicle ingestion CPU 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}`); + // CPU time measures ingestion work without counting scheduler delays on + // loaded runners. Keep the two-second ceiling as a guard against quadratic + // work, not a benchmark; the per-poll figures above show any slowdown. + assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`); }); test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => { diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index 09814247b..cd8ad6447 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () => test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { let adminSignal; let release; + let lookupStarted; const { resolveRegionRingForQuery } = createAnnotationResolver({ featureSource: featureSource({ getAdministrativeAreas(_point, { signal }) { + lookupStarted = Date.now(); adminSignal = signal; return new Promise((resolve) => { release = resolve; @@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th }, }), }); - const started = Date.now(); const region = await resolveRegionRingForQuery( 'Atlantis', undefined, @@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th ring: null, error: 'region-timeout', }); + // Timed from the boundary lookup, so the bundled pack scans before it do not + // count. Under the 3 s default budget, so an ignored budgetMs still fails. assert.ok( - Date.now() - started < 1000, + Date.now() - lookupStarted < 2_000, 'returns at the budget, not the lookup', ); // The lookup is left running so it can fill the boundary cache. diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 80315fe85..184b51f17 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; +import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -120,6 +121,9 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), + createApplicationGnssIntegrity({ + source: sources['gnss-integrity'], + }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f42e82c7c..f8ebb1e73 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 30); + assert.equal(first.layers.length, 31); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); + assert.ok(first.get('gnss-integrity')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 30); + assert.equal(catalog.layers.length, 31); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js new file mode 100644 index 000000000..46983953b --- /dev/null +++ b/src/app/layers/gnss.js @@ -0,0 +1,6 @@ +import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; + +/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ +export function createApplicationGnssIntegrity(options) { + return createGnssIntegrityLayer(options); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 14c864ac5..0b516f5b4 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 44a7c8fa0..cf8646e25 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 30); + assert.equal(actual.layers.length, 31); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/cockpitMath.js b/src/cockpitMath.js index a0bd83676..74e6d1451 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -83,6 +83,26 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) { return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null; } +/** + * HUD wording for a tracked aircraft's transponder emergency, or null. + * States what was broadcast, never a conclusion about the flight: the Mode A + * code when it names the status, else the ADS-B field it came from (an + * unrelated squawk is dropped). + * @param {{label?: string, squawk?: string|null, source?: string, + * severity?: string}|null|undefined} emergency + * @returns {string|null} + */ +export function formatCockpitEmergency(emergency) { + const label = String(emergency?.label || '') + .trim() + .toUpperCase(); + if (!label) return null; + if (emergency.source === 'squawk' && emergency.squawk) + return `SQUAWK ${emergency.squawk} · ${label}`; + const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY'; + return `ADS-B ${field} · ${label}`; +} + /** Format the cockpit Context scope without overstating installation coverage. */ export function formatCockpitContextScope( subjectLabel, diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index ef1e8382b..2189ca35e 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -13,6 +13,7 @@ import { compassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, + formatCockpitEmergency, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, @@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => { assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null); assert.equal(resolveTrackedAircraftInfo(), null); }); + +test('cockpit emergency wording states the broadcast code, not a conclusion', () => { + assert.equal( + formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }), + 'SQUAWK 7700 · GENERAL EMERGENCY', + ); + assert.equal( + formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }), + 'ADS-B EMERGENCY · DOWNED AIRCRAFT', + 'an unrelated squawk is not quoted beside an ADS-B status', + ); + assert.equal( + formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }), + 'ADS-B PRIORITY · MINIMUM FUEL', + ); + assert.equal(formatCockpitEmergency(null), null); + assert.equal(formatCockpitEmergency({ label: '' }), null); +}); diff --git a/src/codexOauthRealtime.test.mjs b/src/codexOauthRealtime.test.mjs index 07e1a7dcc..4a577ac70 100644 --- a/src/codexOauthRealtime.test.mjs +++ b/src/codexOauthRealtime.test.mjs @@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => { const checked = []; + // Built with path.join so the stub matches the resolver on Windows too, + // where the separators are backslashes. + const installed = path.join('/home/fixture', '.local', 'bin', 'codex'); assert.equal( resolveCodexExecutable({ environment: {}, home: '/home/fixture', access(candidate) { checked.push(candidate); - if (candidate.endsWith('/.local/bin/codex')) return; + if (candidate === installed) return; throw new Error('missing'); }, }), - path.join('/home/fixture', '.local', 'bin', 'codex'), + installed, ); assert.equal(checked.length, 1); diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 7cd887dc6..40dd2bbc0 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { .toLowerCase(); const latitude = finiteNumber(aircraft?.lat); const longitude = finiteNumber(aircraft?.lon); - if (!hex || latitude === null || longitude === null) return null; + if ( + !hex || + latitude === null || + longitude === null || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 + ) + return null; const seenPosition = Math.max( 0, - finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0, + finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN, ); const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition); const onGround = aircraft?.alt_baro === 'ground'; @@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { hex, String(aircraft?.flight || aircraft?.r || '').trim() || null, null, - Math.max(0, nowSeconds - seenPosition), - Math.max(0, nowSeconds - seen), + Number.isFinite(seenPosition) + ? Math.max(0, nowSeconds - seenPosition) + : null, + Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null, longitude, latitude, barometricFeet === null ? null : barometricFeet * FOOT_TO_M, @@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) { : Math.floor( responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow, ); - const states = (Array.isArray(payload?.ac) ? payload.ac : []) - .map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds)) - .filter(Boolean); - return { time: nowSeconds, states }; + const aircraft = Array.isArray(payload?.ac) ? payload.ac : []; + const states = []; + // Keep the OpenSky vectors unchanged. The regional fallback's extra readsb + // status travels alongside them, keyed only to admitted aircraft. + const admitted = new Set(); + const statuses = []; + for (const row of aircraft) { + const state = normalizeAdsbLolAircraftState(row, nowSeconds); + if (!state) continue; + states.push(state); + // The live contract admits the first valid vector for each ICAO. Its + // status must come from that same row, never an invalid or later duplicate. + const id = state[0]; + if (admitted.has(id)) continue; + admitted.add(id); + if (typeof row.emergency === 'string') + statuses.push([id, row.emergency.trim().toLowerCase()]); + } + const emergencyByIcao = Object.fromEntries(statuses); + return { + time: nowSeconds, + states, + ...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}), + }; } diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js new file mode 100644 index 000000000..78bd4308f --- /dev/null +++ b/src/data/aircraftEmergency.js @@ -0,0 +1,93 @@ +/** + * Transponder emergency status for one aircraft, from the Mode A squawk and + * the ADS-B emergency/priority field. + * + * Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference, + * 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports + * `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard, + * minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the + * squawk only. + * + * The status is what the transponder broadcast in THIS poll, never a finding: + * crews pass through 7x00 codes while dialling. Highlighting follows the latest + * broadcast, which does not confirm an incident or its resolution. + */ + +/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */ +export const EMERGENCY_TINT_CSS = '#FF4444'; + +const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' }; + +const KINDS = { + general: { label: 'General emergency', severity: 'emergency' }, + unlawful: { label: 'Unlawful interference', severity: 'emergency' }, + nordo: { label: 'Radio failure', severity: 'emergency' }, + downed: { label: 'Downed aircraft', severity: 'emergency' }, + minfuel: { label: 'Minimum fuel', severity: 'priority' }, + lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' }, +}; + +/** + * A Mode A code as its four octal digits, else null. + * @param {unknown} value + * @returns {string|null} + */ +export function normalizeSquawk(value) { + const code = String(value ?? '').trim(); + return /^[0-7]{4}$/.test(code) ? code : null; +} + +/** + * Emergency status from one observation. Higher severity wins; an ADS-B + * emergency wins a tie. A + * 7x00 squawk counts even when the ADS-B field says `none`. + * @param {{squawk?: unknown, emergency?: unknown}} [observation] + * @returns {{kind: string, label: string, severity: 'emergency'|'priority', + * squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is + * 'squawk' when the Mode A code itself names the status (so it can be + * quoted), else 'ads-b'. + */ +export function aircraftEmergency({ squawk, emergency } = {}) { + const code = normalizeSquawk(squawk); + const reported = String(emergency ?? '') + .trim() + .toLowerCase(); + const fromAdsb = Object.hasOwn(KINDS, reported); + const squawkKind = SQUAWK_KINDS[code] ?? null; + const kind = + fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind) + ? reported + : squawkKind; + if (!kind) return null; + return { + kind, + ...KINDS[kind], + squawk: code, + source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b', + }; +} + +/** + * How a summary may phrase the status without flattening it into an incident: + * quote the code when the Mode A squawk itself names the status, otherwise + * attribute it to the ADS-B emergency field. + * @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status + * @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)' + * or 'ADS-B reports general emergency'; null when there is no status. + */ +export function emergencyBroadcastPhrase(status) { + if (!status || !Object.hasOwn(KINDS, status.kind)) return null; + const label = KINDS[status.kind].label.toLowerCase(); + return status.source === 'squawk' && status.squawk + ? `broadcasting squawk ${status.squawk} (${label})` + : `ADS-B reports ${label}`; +} + +/** + * True when the aircraft is broadcasting an emergency (not a priority status). + * @param {{severity?: string}|null|undefined} status + * @returns {boolean} + */ +export function isEmergency(status) { + return status?.severity === 'emergency'; +} diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs new file mode 100644 index 000000000..6646aa6bd --- /dev/null +++ b/src/data/aircraftEmergency.test.mjs @@ -0,0 +1,95 @@ +// src/data/aircraftEmergency.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + aircraftEmergency, + emergencyBroadcastPhrase, + isEmergency, + normalizeSquawk, +} from './aircraftEmergency.js'; + +test('the three ICAO emergency squawks map to their kinds', () => { + assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful'); + assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo'); + const general = aircraftEmergency({ squawk: ' 7700 ' }); + assert.deepEqual(general, { + kind: 'general', + label: 'General emergency', + severity: 'emergency', + squawk: '7700', + source: 'squawk', + }); +}); + +test('ordinary codes, blanks and non-octal values are not emergencies', () => { + for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800']) + assert.equal(aircraftEmergency({ squawk }), null, String(squawk)); + assert.equal(aircraftEmergency(), null); + assert.equal(normalizeSquawk('7800'), null); + assert.equal(normalizeSquawk(7700), '7700'); + assert.equal(normalizeSquawk('770'), null); +}); + +test('the ADS-B emergency field wins, and `none` defers to the squawk', () => { + const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' }); + assert.equal(minfuel.kind, 'minfuel'); + assert.equal(minfuel.severity, 'priority'); + assert.equal(minfuel.source, 'ads-b'); + assert.equal(minfuel.squawk, '4521'); + assert.equal( + aircraftEmergency({ squawk: '7700', emergency: 'none' }).source, + 'squawk', + ); + assert.equal( + aircraftEmergency({ squawk: '7700', emergency: 'general' }).source, + 'squawk', + 'a code that names the status stays quotable', + ); + assert.equal( + aircraftEmergency({ squawk: '1200', emergency: 'general' }).source, + 'ads-b', + ); + assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed'); + assert.equal(aircraftEmergency({ emergency: 'none' }), null); + assert.equal(aircraftEmergency({ emergency: 'reserved' }), null); +}); + +test('priority statuses are reported but are not emergencies', () => { + assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true); + assert.equal( + isEmergency(aircraftEmergency({ emergency: 'lifeguard' })), + false, + ); + assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false); + assert.equal(isEmergency(null), false); +}); + +test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => { + assert.equal( + emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })), + 'broadcasting squawk 7700 (general emergency)', + ); + assert.equal( + emergencyBroadcastPhrase( + aircraftEmergency({ squawk: '1200', emergency: 'general' }), + ), + 'ADS-B reports general emergency', + ); + assert.equal( + emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })), + 'ADS-B reports lifeguard (medical priority)', + ); + assert.equal(emergencyBroadcastPhrase(null), null); + assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null); +}); + +test('an emergency squawk outranks an ADS-B priority', () => { + assert.equal( + aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind, + 'general', + ); + assert.equal( + aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind, + 'unlawful', + ); +}); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs new file mode 100644 index 000000000..27deb2b46 --- /dev/null +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -0,0 +1,524 @@ +import * as Cesium from 'cesium'; +import { TRACKED_ICON_COLOR } from '../layers/military/policy.js'; +import { createRendering as civilRendering } from '../layers/flights/rendering.js'; +import { createRendering as militaryRendering } from '../layers/military/rendering.js'; +import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js'; +import { aircraftEmergency } from './aircraftEmergency.js'; +// Source-to-voice provenance, status retention/clearing, contact-time contracts, +// and civil/military billboard, Cockpit and model tint regressions. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js'; +import { FlightRecords } from '../layers/flights/records.js'; +import { MilitaryFlightRecords } from '../layers/military/records.js'; +import { mapAnalystRecord as mapFlight } from './flights.js'; +import { mapAnalystRecord as mapMilitary } from './militaryFlights.js'; +import { createAnalystEngine } from './analystEngine.js'; +import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +const NOW_MS = 1800000000000; + +test('fallback status belongs to the first valid admitted aircraft row', () => { + const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' }; + for (const status of [undefined, 'general', 'none']) { + const payload = normalizeAdsbLolPointResponse({ + now: NOW_MS, + ac: [ + { ...valid, lat: 100, emergency: 'general' }, + { ...valid, emergency: status }, + { ...valid, lat: null, emergency: 'general' }, + { ...valid, emergency: status === 'general' ? 'none' : 'general' }, + ], + }); + const snapshot = openSkySnapshot(payload, { now: NOW_MS }); + assert.equal(snapshot.records.length, 1); + assert.equal(snapshot.records[0].emergency, status); + assert.equal(snapshot.records[0].latitude, 30); + } +}); + +function civilStore() { + return new FlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (value) => value, + approxDistanceKm: () => 0, + }); +} +const receiveContext = { + viewerLatDeg: null, + viewerLonDeg: null, + trackedId: null, + floorWarmPoints: [], +}; + +test('real civil fallback preserves ADS-B emergency and priority, then clears', () => { + const store = civilStore(); + const aircraft = { + hex: 'a1b2c3', + flight: 'TEST1', + lat: 30, + lon: -97, + alt_baro: 10000, + gs: 150, + track: 90, + squawk: '1200', + seen: 3, + seen_pos: 1, + }; + for (const [status, expectedKind, flagged] of [ + ['general', 'general', true], + ['minfuel', 'minfuel', false], + ['none', null, false], + ]) { + const payload = normalizeAdsbLolPointResponse({ + now: NOW_MS, + ac: [{ ...aircraft, emergency: status }], + }); + assert.equal( + payload.states[0].length, + 18, + 'fallback vectors retain the OpenSky shape', + ); + const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records; + const result = mapFlight( + aircraft.hex, + store.receive(observation, receiveContext).meta, + ); + assert.equal(result.emergency, flagged); + assert.equal(result.emergencyKind, expectedKind); + assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null); + assert.equal(result.lastContactAtMs, NOW_MS - 3000); + } +}); + +test('changed status without contact time retains general contact, including voice projection', async () => { + const store = civilStore(); + const [observation] = readsbSnapshot( + { + ac: [ + { + hex: 'a1b2c3', + flight: 'TEST1', + lat: 30, + lon: -97, + alt_baro: 10000, + gs: 150, + track: 90, + squawk: '1200', + emergency: 'general', + seen: 3, + seen_pos: 1, + }, + ], + }, + { observedAtMs: NOW_MS, now: NOW_MS }, + ).records; + store.receive(observation, receiveContext); + const meta = store.receive( + { ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' }, + receiveContext, + ).meta; + const record = mapFlight(observation.id, meta); + assert.equal(record.emergencyKind, 'nordo'); + assert.equal(record.lastContactAtMs, NOW_MS - 3000); + assert.equal(Object.hasOwn(record, 'observedAtMs'), false); + globalThis.window = globalThis.window || { + clearTimeout, + setTimeout, + requestIdleCallback: null, + }; + const flights = { + id: 'flights', + source: 'fixture', + getStats: () => ({ count: 1 }), + getAnalystRecords: () => [record], + }; + const runner = createGevActionRunner({ + viewer: { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { + moveEnd: { addEventListener() {} }, + positionCartographic: { + height: 300000, + latitude: 0.52, + longitude: -1.71, + }, + }, + }, + styleManager: { + getContextModeState: () => ({ mode: null, active: false }), + getCockpitState: () => ({ active: false }), + }, + dataManager: { + layers: new Map([['flights', { module: flights }]]), + isEnabled: (id) => id === 'flights', + getAll: () => [ + { id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } }, + ], + }, + }); + try { + const result = await runner('analyst_query', { + layers: ['flights'], + scope: { kind: 'anywhere' }, + filters: [{ field: 'emergency', op: 'eq', value: true }], + }); + assert.equal(result.ok, true); + assert.equal(result.items[0].emergency, true); + assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000); + assert.equal(result.items[0].emergencySource, 'squawk'); + assert.equal( + result.items[0].emergencyBroadcast, + 'broadcasting squawk 7600 (radio failure)', + ); + assert.equal( + Object.hasOwn(result.items[0], 'time'), + false, + 'general contact is not a generic incident timestamp', + ); + } finally { + runner.dispose?.(); + } +}); + +// OpenSky /states/all: [icao24, callsign, origin_country, time_position, +// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track, +// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category]. +const OPENSKY = { + time: NOW_MS / 1000, + states: [ + [ + 'a1b2c3', + 'SWA696 ', + 'United States', + NOW_MS / 1000 - 6, + NOW_MS / 1000 - 3, + -97.67, + 30.19, + 3000, + false, + 150, + 180, + -5, + null, + 3040, + '7700', + false, + 0, + 4, + ], + ], +}; + +// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds. +const ADSB_LOL = { + ac: [ + { + hex: 'ae01ce', + flight: 'RCH123 ', + t: 'C17', + lat: 31.1, + lon: -97.4, + alt_baro: 18000, + gs: 300, + track: 90, + squawk: '1200', + emergency: 'general', + seen: 1.5, + seen_pos: 2, + }, + ], +}; + +function civilAnalystRecord() { + const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records; + const store = new FlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + approxDistanceKm: () => 0, + }); + const { meta } = store.receive(row, { + viewerLatDeg: null, + viewerLonDeg: null, + trackedId: null, + floorWarmPoints: [], + }); + return mapFlight(row.id, meta); +} + +function militaryAnalystRecord() { + const [row] = readsbSnapshot(ADSB_LOL, { + observedAtMs: NOW_MS, + now: NOW_MS, + }).records; + const store = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + }); + const { meta } = store.receive(row, { + observedAtMs: NOW_MS, + floorWarmPoints: [], + modelOwnsVisual: false, + }); + return mapMilitary(row.id, meta); +} + +test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => { + const civil = civilAnalystRecord(); + const military = militaryAnalystRecord(); + + assert.equal(civil.emergency, true); + assert.equal(civil.emergencyKind, 'general'); + assert.equal(civil.squawk, '7700'); + assert.equal(civil.emergencySource, 'squawk'); + assert.equal( + civil.emergencyBroadcast, + 'broadcasting squawk 7700 (general emergency)', + ); + assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact'); + + assert.equal(military.emergency, true); + assert.equal(military.emergencyKind, 'general'); + assert.equal(military.squawk, '1200', 'the code is not an emergency code'); + assert.equal(military.emergencySource, 'ads-b'); + assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency'); + assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen'); +}); + +test('the analyst can filter emergencies by source', async () => { + const records = { + flights: [civilAnalystRecord()], + military: [militaryAnalystRecord()], + }; + const engine = createAnalystEngine({ + getRecords: (key) => records[key] || [], + resolveRegionRing: async () => null, + getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }), + }); + const query = (value) => + engine.query({ + layers: ['flights', 'military'], + scope: { kind: 'anywhere' }, + filters: [ + { field: 'emergency', op: 'eq', value: true }, + { field: 'emergencySource', op: 'eq', value }, + ], + }); + const bySquawk = await query('squawk'); + assert.deepEqual( + bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]), + [['a1b2c3', 'broadcasting squawk 7700 (general emergency)']], + ); + const byAdsb = await query('ads-b'); + assert.deepEqual( + byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]), + [['ae01ce', 'ADS-B reports general emergency']], + ); +}); + +test('military status changes retain only the latest known general contact', () => { + const store = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (v) => v, + }); + const [observation] = readsbSnapshot(ADSB_LOL, { + observedAtMs: NOW_MS, + now: NOW_MS, + }).records; + const context = { + observedAtMs: NOW_MS, + floorWarmPoints: [], + modelOwnsVisual: false, + }; + const unknown = mapMilitary( + observation.id, + store.receive({ ...observation, contactTimeMs: null }, context).meta, + ); + assert.equal(unknown.lastContactAtMs, null); + store.receive(observation, context); + const changed = mapMilitary( + observation.id, + store.receive( + { + ...observation, + contactTimeMs: null, + squawk: '7600', + emergency: 'none', + }, + context, + ).meta, + ); + assert.equal(changed.emergencyKind, 'nordo'); + assert.equal(changed.lastContactAtMs, NOW_MS - 1500); +}); + +test('fallback missing contact age retains known contact through the real converter', () => { + const store = civilStore(); + const row = { + hex: 'a1b2c3', + lat: 30, + lon: -97, + squawk: '1200', + emergency: 'general', + seen: 3, + seen_pos: 1, + }; + const receive = (row) => + store.receive( + openSkySnapshot( + normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }), + { now: NOW_MS }, + ).records[0], + receiveContext, + ).meta; + const first = receive(row); + const second = receive({ ...row, seen: undefined, seen_pos: undefined }); + assert.equal(second.lastContactEpochMs, first.lastContactEpochMs); +}); + +test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => { + const store = civilStore(); + const row = { + hex: 'a1b2c3', + lat: 30, + lon: -97, + squawk: '1200', + emergency: 'general', + seen: 3, + }; + const receive = (row) => + store.receive( + openSkySnapshot( + normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }), + { now: NOW_MS }, + ).records[0], + receiveContext, + ).meta; + assert.equal(receive(row).emergency.kind, 'general'); + assert.equal( + receive({ ...row, emergency: undefined }).emergency?.kind, + 'general', + ); + assert.equal(receive({ ...row, emergency: undefined }).emergency, null); + receive(row); + assert.equal(receive({ ...row, emergency: 'none' }).emergency, null); + receive(row); + assert.equal( + receive({ ...row, emergency: undefined, squawk: '2000' }).emergency, + null, + ); +}); + +function renderHarness(factory) { + const flightState = { records: { data: new Map() }, _trackedIcao: null }; + const rendering = factory({ + flightState, + services: { + militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' }, + aircraftPresentation: {}, + camera: {}, + focus: {}, + recession: {}, + }, + parts: {}, + }); + return { flightState, rendering }; +} + +test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => { + for (const factory of [civilRendering, militaryRendering]) { + const { flightState, rendering } = renderHarness(factory); + const normal = rendering._fleetBillboardColor('civil', {}); + assert.equal( + rendering._fleetBillboardColor('civil', { + emergency: aircraftEmergency({ emergency: 'minfuel' }), + }), + normal, + ); + const red = rendering._fleetBillboardColor('civil', { + emergency: aircraftEmergency({ squawk: '7700' }), + }); + assert.ok(red.red > 0.9 && red.green < 0.5); + flightState.records.data.set('civil', { + emergency: aircraftEmergency({ squawk: '7700' }), + }); + assert.equal(rendering._modelColor('civil'), red); + if (rendering._cockpitContactColor) + assert.equal(rendering._cockpitContactColor('civil'), red); + flightState._trackedIcao = 'civil'; + assert.equal( + rendering._modelColor('civil'), + factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR, + ); + flightState._trackedIcao = null; + flightState.records.data.set('civil', { emergency: null }); + assert.equal(rendering._modelColor('civil'), normal); + } +}); + +test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => { + const { flightState, rendering } = renderHarness(militaryRendering); + const bb = { + color: rendering._fleetBillboardColor('abc123').withAlpha(0.65), + }; + Object.assign(flightState, { + _billboards: new Map([['abc123', bb]]), + _models: new Map(), + _positionHistory: new Map(), + _cullPositions: new Map(), + _groundSnap: { forget() {} }, + _cockpitContactMode: false, + }); + const records = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (value) => value, + }); + flightState.records = records; + const apply = createMilitarySnapshotRenderer({ + flightState, + records, + groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null }, + meshFloor: { sampleMeshFloorCells() {} }, + militaryRegistry: { registerMilitaryIcaos() {} }, + rendering: { + ...rendering, + _syncModelToClass() {}, + _applyFleetBillboardPresentation() {}, + }, + motion: {}, + tracking: {}, + queries: {}, + }); + for (const [emergency, red] of [ + ['general', true], + ['none', false], + ['general', true], + ]) { + const snapshot = readsbSnapshot( + { + ac: [ + { + hex: 'abc123', + lat: 30, + lon: -97, + alt_baro: 10000, + squawk: '1200', + emergency, + seen_pos: 1, + }, + ], + }, + { observedAtMs: NOW_MS }, + ); + apply(snapshot, {}); + assert.equal(bb.color.green < 0.5, red); + assert.equal(bb.color.alpha, 0.65); + } +}); diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index b260c5f1b..04c41e604 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({ } /** - * Apply the already-composed aircraft alpha to an ambient glTF model without + * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without * disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. * @param {object} input * @param {object} input.model @@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({ if (!model || !baseColor || !Number.isFinite(alpha)) return 0; if ( Number.isFinite(model.color?.alpha) && - Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon + Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon && + model.color.red === baseColor.red && + model.color.green === baseColor.green && + model.color.blue === baseColor.blue ) return 0; model.color = baseColor.withAlpha(alpha); diff --git a/src/data/aircraftRecession.test.mjs b/src/data/aircraftRecession.test.mjs index 29bce05c5..23b8ceffb 100644 --- a/src/data/aircraftRecession.test.mjs +++ b/src/data/aircraftRecession.test.mjs @@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend assert.equal(model.colorBlendAmount, 0.9); assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0); }); + + +test('model hue changes at constant alpha, then clears without redundant writes', () => { + const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}}); + const model = {color:color(1,1,1).withAlpha(0.65)}; + assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1); + assert.equal(model.color.green,0.2); + assert.equal(model.color.alpha,0.65); + assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0); + assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1); + assert.equal(model.color.green,1); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index e68caddb4..9f0b8b2d1 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -111,7 +111,7 @@ export const DATA_CREDITS = [ { key: 'adsblol', html: - 'Military flights, aircraft traces & bounded regional flight fallback: ' + + 'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' + 'adsb.lol ' + '(ODbL 1.0)', }, diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs new file mode 100644 index 000000000..d60a7ee37 --- /dev/null +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseFirmsCsv } from './firmsCsv.js'; +import { adaptFirmsRecords } from './firmsAdapt.js'; +test('missing positions do not become fire detections at zero coordinates', () => { + const csv = + 'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1'; + assert.deepEqual( + parseFirmsCsv(csv).map((r) => [r.lat, r.lon]), + [ + [0, 0], + [-90, -180], + ], + ); + const records = [ + { lat: null, lon: 1 }, + { lat: '', lon: 1 }, + { lat: false, lon: 1 }, + { lat: 95, lon: 1 }, + { lat: 1, lon: 181 }, + { lat: '0', lon: 0 }, + { lat: -90, lon: -180 }, + ]; + assert.deepEqual( + adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]), + [ + [0, 0, 0], + [1, -90, -180], + ], + ); +}); + +test('both axes keep their exact bounds and drop values just past them', () => { + const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`; + const csv = [ + 'latitude,longitude,acq_date,acq_time,confidence,frp', + row(90, 180), + row(-90, -180), + row(90.000001, 0), + row(-90.000001, 0), + row(0, 180.000001), + row(0, -180.000001), + row(' ', 5), + row(5, '\t'), + ].join('\n'); + assert.deepEqual( + parseFirmsCsv(csv).map((r) => [r.lat, r.lon]), + [ + [90, 180], + [-90, -180], + ], + ); + const kept = adaptFirmsRecords([ + { lat: 90, lon: 180 }, + { lat: '-90', lon: '-180' }, + { lat: -90.000001, lon: 0 }, + { lat: 0, lon: -180.000001 }, + { lat: 5, lon: ' ' }, + { lat: 5, lon: undefined }, + { lat: [5], lon: 5 }, + ]); + assert.deepEqual( + kept.map((r) => [r.lat, r.lon]), + [ + [90, 180], + [-90, -180], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 2fc909bce..312838e80 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -7,8 +7,8 @@ */ /** - * Map proxy fire records into internal fire records. Records with - * non-finite coordinates are skipped; `index` is the post-skip position + * Map proxy fire records into internal fire records. Records with missing, + * non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position * (it keys pick ids and context-store ids, so it must stay sequential). * `contextEntity`/`position` start null and are lazily filled by the layer. * @param {?Array} records - /api/firms `fires` array. @@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) { for (const record of records) { const lat = Number(record?.lat); const lon = Number(record?.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if ( + !hasCoordinateValue(record?.lat) || + !hasCoordinateValue(record?.lon) || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; fires.push({ index: fires.length, lat, @@ -110,3 +118,11 @@ function finiteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } + +// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero. +function hasCoordinateValue(value) { + return ( + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== '') + ); +} diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..aafeac50b 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,7 +105,17 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + // Blank or out-of-range positions are dropped, never read as 0 (see + // hasCoordinateValue in firmsAdapt.js for the proxy-record side). + if ( + !parts[iLat].trim() || + !parts[iLon].trim() || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; records.push({ lat, diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index b09dc7014..c325ea744 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -79,6 +79,12 @@ test('flights analyst record: full record maps every contract field', () => { aircraftClass: 'airliner', originCountry: 'United States', operator: 'Southwest Airlines', + squawk: null, + emergency: false, + emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + lastContactAtMs: null, routeOrigin: 'AUS', routeDestination: 'LAX', }); diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs new file mode 100644 index 000000000..457878b18 --- /dev/null +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -0,0 +1,429 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + gnssIntegrityAnchor, + gnssIntegrityProxy, + gnssRetryCooldownMs, +} from '../../server/providers/gnssIntegrity.js'; + +const snapshot = { + now: 4_000_000_000_000, + ac: [ + { + hex: '4ca7b5', + type: 'adsb_icao', + version: 2, + alt_baro: 36000, + lat: 50.1, + lon: 19.9, + nic: 0, + nac_p: 0, + seen_pos: 1, + flight: 'RYR1AB ', + r: 'EI-ABC', + }, + { hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 }, + ], +}; + +function install(options = {}, hook = 'configureServer') { + let handler; + const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options }); + assert.equal(plugin.name, 'gnss-integrity'); + plugin[hook]({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/gnss-integrity'); + handler = callback; + }, + }, + }); + return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => { + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = JSON.parse(body); + }, + }; + await handler({ url, method, socket: { remoteAddress: peer } }, res); + return res; + }; +} + +test('anchors snap to whole degrees and reject missing or out-of-range values', () => { + assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), { + lat: 50, + lon: 20, + }); + assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null); + assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null); + assert.equal(gnssIntegrityAnchor('/?lat=0'), null); + assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null); +}); + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => { + const calls = []; + const request = install( + { + now: () => 1234, + fetchImpl: async (url, options) => { + calls.push(url); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.redirect, 'error'); + return Response.json(snapshot); + }, + }, + hook, + ); + const res = await request( + '/?lat=50.2&lon=19.8&url=https://invalid.example', + ); + assert.equal(res.status, 200); + assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']); + assert.deepEqual(res.body, { + fetchedAt: 1234, + ageMs: 0, + anchor: { lat: 50, lon: 20 }, + radiusNm: 250, + classifier: 'gev-nic-nacp-v2', + rows: [ + { + hex: '4ca7b5', + lat: 50.1, + lon: 19.9, + nic: 0, + nacp: 0, + gpsLost: false, + degraded: true, + }, + ], + }); + assert.equal(res.headers['Cache-Control'], 'no-store'); + }); +} + +test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => { + let clock = 0; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return Response.json(snapshot); + }, + }); + await Promise.all([request(), request('/?lat=50.3&lon=20.2')]); + assert.equal(calls, 1); + clock = 59_000; + const cached = await request(); + assert.equal(calls, 1); + assert.equal(cached.body.fetchedAt, 0); + assert.equal( + cached.body.ageMs, + 59_000, + 'a cached answer reports its age on the server clock', + ); + clock = 61_000; + await request(); + assert.equal(calls, 2); + await request('/?lat=10&lon=10'); + assert.equal(calls, 3); +}); + +test('an upstream failure serves the last snapshot as stale, else 502', async () => { + let clock = 0; + let fail = false; + const request = install({ + now: () => clock, + fetchImpl: async () => + fail ? new Response('down', { status: 503 }) : Response.json(snapshot), + }); + await request(); + fail = true; + clock = 120_000; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal( + stale.body.fetchedAt, + 0, + 'a stale answer keeps the time it was observed', + ); + assert.equal(stale.body.ageMs, 120_000); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + const missing = await request('/?lat=-10&lon=-10'); + assert.equal(missing.status, 502); + assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' }); +}); + +test('a stale snapshot older than the layer window is no longer served', async () => { + let clock = 0; + let fail = false; + const request = install({ + now: () => clock, + fetchImpl: async () => + fail ? new Response('down', { status: 503 }) : Response.json(snapshot), + }); + await request(); + fail = true; + clock = 30 * 60_000; + assert.equal((await request()).status, 200); + clock = 30 * 60_000 + 1; + const expired = await request(); + assert.equal(expired.status, 502); + assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' }); +}); + +test('an upstream 429 starts a cooldown with no further upstream calls', async () => { + let clock = 0; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return new Response('slow down', { + status: 429, + headers: { 'retry-after': '90' }, + }); + }, + }); + const first = await request(); + assert.equal(first.status, 429); + assert.equal(first.headers['Retry-After'], '90'); + clock = 30_500; + const cooling = await request('/?lat=1&lon=1'); + assert.equal(cooling.status, 429); + // The browser is told the remaining shared cooldown, rounded up. + assert.equal(cooling.headers['Retry-After'], '60'); + assert.equal(calls, 1); + clock = 91_000; + await request('/?lat=2&lon=2'); + assert.equal(calls, 2); +}); + +test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => { + const nowMs = Date.parse('2026-09-29T12:00:00Z'); + assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000); + assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000); + assert.equal(gnssRetryCooldownMs('3600', nowMs), 3_600_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 13:00:00 GMT', nowMs), + 3_600_000, + ); + assert.equal(gnssRetryCooldownMs('1e309', nowMs), 60_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs), + 45_000, + ); + // Garbage, a past date or no header fall back to the default minute. + assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs), + 60_000, + ); + assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000); +}); + +test('overlapping 429s preserve the longest shared deadline', async () => { + let clock = 0, + calls = 0; + const pending = []; + const request = install({ + now: () => clock, + fetchImpl: () => { + calls += 1; + return new Promise((resolve) => pending.push(resolve)); + }, + }); + const long = request('/?lat=1&lon=0'), + short = request('/?lat=2&lon=0'); + await new Promise((resolve) => setImmediate(resolve)); + pending[0]( + new Response('', { status: 429, headers: { 'Retry-After': '3600' } }), + ); + assert.equal((await long).headers['Retry-After'], '3600'); + clock = 1000; + pending[1]( + new Response('', { status: 429, headers: { 'Retry-After': '5' } }), + ); + assert.equal((await short).headers['Retry-After'], '3599'); + clock = 7000; + assert.equal((await request('/?lat=3&lon=0')).status, 429); + assert.equal(calls, 2); + clock = 3_600_001; + const resumed = request('/?lat=3&lon=0'); + await new Promise((resolve) => setImmediate(resolve)); + pending[2](Response.json(snapshot)); + assert.equal((await resumed).status, 200); + assert.equal(calls, 3); +}); + +test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => { + let clock = 0; + let limited = false; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return limited + ? new Response('slow down', { status: 429 }) + : Response.json(snapshot); + }, + }); + await request(); + limited = true; + clock = 61_000; + // Another anchor trips the shared cooldown… + assert.equal((await request('/?lat=5&lon=5')).status, 429); + clock = 62_000; + // …and the cached anchor is answered from cache without an upstream call. + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.rows.length, 1); + assert.equal(calls, 2); +}); + +test('an oversized upstream body is refused as 502 without being parsed', async () => { + const request = install({ + fetchImpl: async () => + new Response('{}', { + headers: { 'content-length': String(9 * 1024 * 1024) }, + }), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' }); +}); + +test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => { + const request = install({ + fetchImpl: async () => Response.json({ nope: true }), + }); + assert.equal((await request('/', 'POST')).status, 405); + assert.equal((await request('/other?lat=1&lon=1')).status, 404); + assert.equal((await request('/?lat=1')).status, 400); + assert.equal((await request('/?lat=1&lon=1')).status, 502); + const flood = install({ fetchImpl: async () => Response.json(snapshot) }); + let limited = 0; + for (let i = 0; i < 40; i += 1) + if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429) + limited += 1; + assert.ok(limited > 0); +}); + +test('GERMAN: pathological retry deadlines are bounded', () => { + const at = Date.parse('2026-10-14T12:00:00Z'); + assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000); + assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000); + assert.equal( + gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at), + 3600000, + ); + assert.equal(gnssRetryCooldownMs('0x10', at), 60000); +}); + +test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => { + for (const fail of [ + async () => new Response('', { status: 503 }), + async () => { + throw new DOMException('timeout', 'TimeoutError'); + }, + async () => new Response('bad JSON'), + ]) { + let clock = 0, + calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls++; + return fail(); + }, + }); + assert.equal((await request()).status, 502); + clock = 14000; + assert.equal((await request('/?lat=1&lon=1')).status, 502); + assert.equal(calls, 1); + clock = 15001; + await request('/?lat=2&lon=2'); + assert.equal(calls, 2); + } +}); + +test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => { + const now = 1800000000000; + const request = install({ + now: () => now, + fetchImpl: async () => + Response.json({ ...snapshot, now: (now - 120000) / 1000 }), + }); + const res = await request(); + assert.equal(res.body.ageMs, 120000); + assert.equal(res.body.stale, true); + for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) { + const missing = install({ + now: () => now, + fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }), + }); + assert.equal((await missing()).status, 502); + } +}); + +test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => { + let wall = 1800000000000, + mono = 0, + calls = 0; + const request = install({ + now: () => wall, + monotonicNow: () => mono, + fetchImpl: async () => { + calls++; + return calls === 1 + ? Response.json({ ...snapshot, now: wall / 1000 }) + : new Response('', { status: 429, headers: { 'Retry-After': '60' } }); + }, + }); + await request(); + wall -= 3600000; + mono = 61000; + const stale = await request(); + assert.equal(stale.body.ageMs, 61000); + assert.equal(stale.body.stale, true); + mono += 1000; + assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59'); + assert.equal(calls, 2); +}); + +test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => { + let calls = 0; + const request = install({ + now: () => 1800000000000, + fetchImpl: async () => { + calls++; + return Response.json(snapshot); + }, + }); + for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200); + assert.equal((await request('/?lat=1&lon=1')).status, 200); + assert.equal(calls, 2); +}); + +test('GERMAN: distinct concurrent upstream reads are bounded', async () => { + const pending = []; + const request = install({ + fetchImpl: () => new Promise((resolve) => pending.push(resolve)), + }); + const jobs = Array.from({ length: 30 }, (_, i) => + request(`/?lat=${i}&lon=1`), + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(pending.length <= 8); + pending.forEach((resolve) => resolve(Response.json(snapshot))); + const results = await Promise.all(jobs); + assert.ok(results.some((result) => result.status === 429)); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index 373eac20d..1db10494b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -607,6 +607,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled+options', optionOwner: 'flights', }), + Object.freeze({ + id: 'gnss-integrity', + token: '3', + disposition: 'enabled-only', + }), Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }), Object.freeze({ id: 'local-datacenters', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2b94d81d7..30f251b7b 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 29); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); + assert.equal(REGISTERED_LAYER_IDS.length, 30); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '3'); + assert.equal(nextLayerStateToken(), '4'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '3', - bravo: '4', + alpha: '4', + bravo: '5', }), - '5', + '6', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'3456789'].map((digit) => [`prior-${digit}`, digit]), + [...'456789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '3', - next: '4', + future: '4', + next: '5', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 3/, + /next free token 4/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' }, ), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s ); }); +test('GNSS interference uses digit 3 without colliding with other digit tokens', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1')); + assert.deepEqual(decoded.enabledLayerIds, [ + 'fire-perimeters', + 'gnss-integrity', + 'recent-imagery', + ]); + assert.equal( + LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token, + '3', + ); + assert.deepEqual( + decodeLayerStateParams(new URLSearchParams(encode(decoded))), + decoded, + ); +}); + test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1')); assert.deepEqual(decoded.enabledLayerIds, [ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 5a02d85cf..a38a43d51 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' }, + { + ...LEGACY_LAYER_STATE_TOKENS, + 'street-level': '0', + 'gnss-integrity': '3', + }, ); assert.throws( () => parseLayerStateTokenReservations(reservationRows.slice(1)), @@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '4']]) .future, - '3', + '4', ); assert.throws( () => @@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '3']] }, + { rows: [...reservationRows, ['retired-layer', '4']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); + assert.equal(nextLayerStateToken(published), '5'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '4', + newcomer: '5', }), true, ); @@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '3', + competing: '4', }), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '3', + newcomer: '4', }), /changed or removed: retired-layer/, ); @@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { +test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '3']], - [{ id: 'pr-b', token: '3' }], + [...reservationRows, ['pr-b', '4']], + [{ id: 'pr-b', token: '4' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '4']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '4' }, ], ); const staleRebase = runCheck(); @@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); - const validBaselineRows = [...reservationRows, ['pr-a', '3']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); + assert.equal(nextLayerStateToken(published), '5'); + const validBaselineRows = [...reservationRows, ['pr-a', '4']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 4/); + assert.match(helper.stdout, /pr-b: 5/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '5']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '4' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '5' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /30 published, 1 new/); + assert.match(corrected.stdout, /31 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'3456789'].map((digit) => [ + const priorDigits = [...'456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 69); + assert.equal(params.get('l').length, 71); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 30); + assert.equal(Object.keys(published).length, 31); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index eb49eed27..34ba725b5 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -9,6 +9,7 @@ ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], + ["gnss-integrity", "3"], ["local-dams", "q"], ["local-datacenters", "d"], ["local-firms", "w"], diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index fce64ae8f..23641c962 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi removeGuard(); // The newest intent must be able to abort the hung compensation and run. const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' }); + let giveUpTimer; const finalOn = await Promise.race([ pendingFinalOn, - new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)), - ]); + new Promise((resolve) => { + giveUpTimer = setTimeout(() => resolve('starved'), 2000); + }), + ]).finally(() => clearTimeout(giveUpTimer)); assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation'); assert.equal(compensationAborted, true, 'the hung compensation was aborted'); assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled'); @@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp releaseFirstDisable(); const blockedOn = await pendingBlockedOn; + let giveUpTimer; const reentrant = await Promise.race([ (async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(), - new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)), - ]); + new Promise((resolve) => { + giveUpTimer = setTimeout(() => resolve('starved'), 2000); + }), + ]).finally(() => clearTimeout(giveUpTimer)); await pendingOff; await mgr.waitForLayerSettled('flights'); unsubscribe(); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 597211bb7..0c518eb5f 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -71,6 +71,12 @@ test('military analyst record: full record maps every contract field', () => { aircraftClass: 'widebody', originCountry: null, operator: 'United States Air Force', + squawk: null, + emergency: false, + emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + lastContactAtMs: null, routeOrigin: null, routeDestination: null, }); diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..3a6d79f14 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -87,14 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { return articles; } +/** A reported number, or null when the observation is missing or not numeric. */ +function observedNumber(value) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; + return Number.isFinite(Number(value)) ? Number(value) : null; +} + /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if (!current || !Number.isFinite(Number(current.temperature_2m))) return null; - const numberOrNull = (value) => { - if (value === null || value === undefined || value === '') return null; - return Number.isFinite(Number(value)) ? Number(value) : null; - }; + if (!current || observedNumber(current.temperature_2m) == null) return null; + const numberOrNull = observedNumber; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. const observedRaw = @@ -119,8 +127,8 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { - const value = Number(code); - if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; + const value = observedNumber(code); + if (value == null) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; if ([1, 2].includes(value)) return 'PARTLY CLOUDY'; if (value === 3) return 'OVERCAST'; diff --git a/src/data/route-cap-truthfulness.test.mjs b/src/data/route-cap-truthfulness.test.mjs new file mode 100644 index 000000000..7d5d80153 --- /dev/null +++ b/src/data/route-cap-truthfulness.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js'; +const step = (type) => ({ + maneuver: { type, location: [10, 20] }, + distance: 10, + duration: 1, +}); +test('exact-cap routes remain complete and boundary exits retain their distance', () => { + const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn')); + assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false); + assert.equal( + normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] }) + .truncated, + true, + ); + steps[steps.length - 1] = step('roundabout'); + const result = normalizeOsrmSteps({ + legs: [{ steps: [...steps, step('exit roundabout')] }], + }); + assert.equal(result.truncated, false); + assert.equal(result.steps.length, ROUTE_STEPS_MAX); + assert.equal(result.steps.at(-1).distanceM, 20); +}); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index 5ee11602a..e3144bdf7 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) { } continue; } + if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true }; step.instruction = instructionFor(step); out.push(step); - if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true }; } } return { steps: out, truncated: false }; diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index a3c8cf227..6bf74dd08 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () = // would present a route that simply stops in the middle of a road. assert.equal(cut.truncated, true); const exact = normalizeOsrmSteps({ - legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }], + legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }], }); - assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1); + assert.equal(exact.steps.length, ROUTE_STEPS_MAX); assert.equal(exact.truncated, false, 'a route that fits is not cut'); }); diff --git a/src/data/weather-missing-observations.test.mjs b/src/data/weather-missing-observations.test.mjs new file mode 100644 index 000000000..4e89dcedd --- /dev/null +++ b/src/data/weather-missing-observations.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js'; +test('missing weather observations never assert clear conditions', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN'); + assert.equal( + normalizeRegionalWeather({ current: { temperature_2m: value } }), + null, + ); + } + assert.equal(weatherCodeLabel(0), 'CLEAR'); + assert.equal(weatherCodeLabel('0'), 'CLEAR'); + const record = normalizeRegionalWeather({ + current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false }, + }); + assert.equal(record.temperatureC, 0); + assert.equal(record.weatherCode, null); + assert.equal(record.windKph, null); +}); diff --git a/src/director/camera.js b/src/director/camera.js index 059c7a873..e009c2cba 100644 --- a/src/director/camera.js +++ b/src/director/camera.js @@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) { }; } +/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */ +export function normalizeCameraLongitude(longitude) { + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + /** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ export function sampleCameraMove(move, progress) { const t = Math.max(0, Math.min(1, Number(progress) || 0)); @@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) { const { from, to } = move; return { lat: lerp(from.lat, to.lat), - lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + lon: normalizeCameraLongitude(angle(from.lon, to.lon)), alt: lerp(from.alt, to.alt), heading: angle(from.heading, to.heading), pitch: lerp(from.pitch, to.pitch), diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs new file mode 100644 index 000000000..b0dd2b24b --- /dev/null +++ b/src/director/director-dateline-seek.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cameraAtProgress } from './timeline.js'; +const camera = (lon) => ({ + lat: 10, + lon, + alt: 1000, + heading: 0, + pitch: -40, + roll: 0, +}); +test('director seek follows the short dateline arc in both directions', () => { + for (const [a, b] of [ + [179, -179], + [-179, 179], + ]) { + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179); + } + assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); +}); + +test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => { + for (const [a, b] of [ + [180, -180], + [-180, 180], + ]) { + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + } + // 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east. + const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon; + assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`); +}); + +test('director seek turns heading and roll the short way for any authored angles', () => { + const pose = (heading, roll) => ({ ...camera(0), heading, roll }); + const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5); + // 350° and -350° are 20° apart through 0°/360°, not 340° through 180°. + for (const value of [mid.heading, mid.roll]) { + const wrapped = ((value % 360) + 360) % 360; + assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`); + } +}); diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs new file mode 100644 index 000000000..eb67244ff --- /dev/null +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAssetDirectorySource } from './source.js'; +test('cancellation during the terminating read cannot return a scene asset', async () => { + for (const cancel of [true, false]) { + const controller = new AbortController(); + let reads = 0, + cancelled = 0, + released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'application/octet-stream' }), + body: { + getReader() { + return { + async read() { + reads++; + if (reads === 1) + return { done: false, value: new Uint8Array([1, 2]) }; + if (cancel) controller.abort(); + return { done: true }; + }, + async cancel() { + cancelled++; + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + const pending = source({ path: 'scene.bin', signal: controller.signal }); + if (cancel) await assert.rejects(pending, { name: 'AbortError' }); + else assert.deepEqual([...(await pending).bytes], [1, 2]); + assert.equal(cancelled, 1); + assert.equal(released, 1); + } +}); + +test('cancellation during reader cleanup cannot return a scene asset', async () => { + const controller = new AbortController(); + const reason = new DOMException('scene changed', 'AbortError'); + let released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + body: { + getReader() { + let reads = 0; + return { + async read() { + reads++; + return reads === 1 + ? { done: false, value: new Uint8Array([1]) } + : { done: true }; + }, + async cancel() { + controller.abort(reason); + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + await assert.rejects( + source({ path: 'scene.bin', signal: controller.signal }), + (error) => error === reason, + ); + assert.equal(released, 1); +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 4828827af..0ad920670 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -41,6 +41,7 @@ export function createAssetDirectorySource({ for (;;) { signal?.throwIfAborted(); const { done, value } = await reader.read(); + signal?.throwIfAborted(); if (done) break; length += value.byteLength; if (length > maxBytes) throw new Error('Asset exceeds byte limit'); @@ -50,6 +51,8 @@ export function createAssetDirectorySource({ await reader.cancel().catch(() => {}); reader.releaseLock(); } + // Cleanup awaits too; a cancel during it must still discard the asset. + signal?.throwIfAborted(); const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { diff --git a/src/director/timeline.js b/src/director/timeline.js index 7fd4cbbae..f7cffcece 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -2,6 +2,7 @@ import { resolveCameraPose, resolveCameraMove, sampleCameraMove, + normalizeCameraLongitude, } from './camera.js'; const DEFAULT_SHOT_DURATION_SEC = 4; @@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased; const lerpAngle = (from, to) => { const start = Number(from) || 0; - const delta = ((Number(to) - start + 540) % 360) - 180; + // Double modulo keeps the delta in [-180, 180) for any authored angles, + // as sampleCameraMove does; a single % goes negative below -540. + const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180; return start + delta * eased; }; return { lat: lerp(source.lat, target.lat), - lon: lerp(source.lon, target.lon), + lon: + t === 0 + ? Number(source.lon) + : t === 1 + ? Number(target.lon) + : normalizeCameraLongitude(lerpAngle(source.lon, target.lon)), alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 49711163f..a75ac91fd 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -673,11 +673,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // referent-only track_entity alternative (kept out of the model-facing // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. - // Street Level adds toggle enum values and the generated alias hint. - assert.equal(block.length, 29802, 'serialized tool schema length drifted'); + // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. + assert.equal(block.length, 30511, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed', + '62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/cctv/videoPlayback.js b/src/layers/cctv/videoPlayback.js index d59143b5a..48988c17c 100644 --- a/src/layers/cctv/videoPlayback.js +++ b/src/layers/cctv/videoPlayback.js @@ -1,3 +1,28 @@ +/** + * A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure + * contexts, so a page opened over plain HTTP from another machine (the + * HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context + * and yields the same shape the server's lease check accepts. + */ +export function createLeaseId(cryptoImpl = globalThis.crypto) { + if (typeof cryptoImpl.randomUUID === 'function') { + return cryptoImpl.randomUUID(); + } + const bytes = cryptoImpl.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4), + hex.slice(4, 6), + hex.slice(6, 8), + hex.slice(8, 10), + hex.slice(10), + ] + .map((group) => group.join('')) + .join('-'); +} + /** One decoder per active camera; both surfaces consume this video element. */ export function attachCctvVideo( video, @@ -7,13 +32,14 @@ export function attachCctvVideo( loadHls = () => import('hls.js'), onFailure = () => {}, fetchImpl = globalThis.fetch, + cryptoImpl = globalThis.crypto, } = {}, ) { // Preserve replay for existing finite video feeds; live HLS must not loop. video.loop = feedType !== 'hls'; let disposed = false; let hls = null; - const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null; + const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null; const mediaUrl = feedType === 'hls' ? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}` diff --git a/src/layers/cctv/videoPlayback.test.mjs b/src/layers/cctv/videoPlayback.test.mjs index 1f8c1c0bb..d1ef02539 100644 --- a/src/layers/cctv/videoPlayback.test.mjs +++ b/src/layers/cctv/videoPlayback.test.mjs @@ -1,6 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { attachCctvVideo } from './videoPlayback.js'; +import { webcrypto } from 'node:crypto'; +import { attachCctvVideo, createLeaseId } from './videoPlayback.js'; + +const LEASE_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** What an insecure context exposes: getRandomValues but no randomUUID. */ +const insecureCrypto = { + getRandomValues: (array) => webcrypto.getRandomValues(array), +}; function video() { const v = new EventTarget(); Object.assign(v, { @@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn assert.equal(releases[0].init.method, 'DELETE'); }); +test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => { + assert.match(createLeaseId(), LEASE_V4); + const ids = new Set(); + for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto)); + assert.equal(ids.size, 64); + for (const id of ids) assert.match(id, LEASE_V4); +}); + +test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => { + const source = video(); + source.canPlayType = () => 'probably'; + const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', { + loadHls: async () => ({ default: { isSupported: () => false } }), + fetchImpl: async () => {}, + cryptoImpl: insecureCrypto, + }); + try { + await playback.ready; + const lease = new URL( + source.src, + 'http://192.168.1.10:4173/', + ).searchParams.get('lease'); + assert.match(lease, LEASE_V4); + } finally { + playback.dispose(); + } +}); + test('finite video feeds retain looping while live HLS does not loop', async () => { for (const feedType of ['mp4', 'webm', 'hls']) { const source = video(); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..80d77dddd 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -7,6 +7,7 @@ export { ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; import * as Cesium from 'cesium'; +import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; @@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800'); +/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */ + +export const EMERGENCY_TINT = + Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS); + // --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes // taxiing and landing") ----------------------------------------------------------- // Present-but-grounded planes are RENDERED instead of being skipped: same class diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..9c206a335 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -15,6 +15,7 @@ import { FOCUS_EVIDENCE_DEV, FLEET_DR_INTERVAL_MS, } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -167,6 +168,10 @@ export function createQueries({ flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, ), + // This poll's transponder broadcast (not sticky; see records.js). + squawk: info?.squawk ?? null, + emergency: info?.emergency ? { ...info.emergency } : null, + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), airline: info?.airline ?? null, // CLASS label follows the TR-3B conversion so every downstream card // (cockpit, Contacts, analyst) agrees with the triangle on screen. @@ -219,7 +224,10 @@ export function createQueries({ * lon: number|null, altitudeM: number|null, speedMps: number|null, * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: string|null, - * operator: string|null, routeOrigin: string|null, routeDestination: string|null}} + * operator: string|null, squawk: string|null, emergency: boolean, + * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, + * emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null, + * routeDestination: string|null}} */ function mapAnalystRecord( @@ -253,6 +261,20 @@ export function createQueries({ aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), originCountry: text(info?.originCountry), operator: text(info?.airline), + squawk: text(info?.squawk), + // True while this poll broadcast an emergency (7500/7600/7700 or the + // ADS-B status); priority statuses (lifeguard, minfuel) report the kind + // without the flag. + emergency: info?.emergency?.severity === 'emergency', + emergencyKind: text(info?.emergency?.kind), + // Provenance, so a summary can say "broadcasting squawk 7700" rather + // than "ADS-B reports general emergency" (or flatten both): 'squawk' + // when the Mode A code names the status, 'ads-b' for the readsb field. + emergencySource: text(info?.emergency?.source), + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), + // Latest known general aircraft-message contact, retained if a poll + // has no time. This is not the receipt time of the current status. + lastContactAtMs: num(info?.lastContactEpochMs), routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, routeDestination: routeOk ? text(info?.route?.destination?.code) : null, }; diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 43809f516..2b6f48dfb 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -4,6 +4,7 @@ import { pickRenderAltitudeM, } from '../../data/renderAltitude.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; +import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { approxDistanceKm, GROUND_FLOOR_CLAMP_RADIUS_KM, @@ -226,6 +227,22 @@ export class FlightRecords { // like callsign so a transient blank row doesn't blank the field. originCountry: stickyText(origin_country, prevMeta?.originCountry) || null, + // Per poll, NOT sticky: an emergency code clears when the crew resets + // it, so a blank squawk must not hold a stale 7700 on screen. + squawk: observation.squawk ?? null, + // One source-switch omission may retain an ADS-B status, while an + // explicit clear or changed squawk always takes effect immediately. + emergency: + observation.emergency === undefined && + observation.squawk === prevMeta?.squawk && + prevMeta?.emergency?.source === 'ads-b' && + (prevMeta?.missingEmergencyPolls ?? 0) < 1 + ? prevMeta.emergency + : aircraftEmergency(observation), + missingEmergencyPolls: + observation.emergency === undefined + ? (prevMeta?.missingEmergencyPolls ?? 0) + 1 + : 0, // OpenSky distinguishes the last position epoch from the last // transponder message. The fleet coast horizon uses this actual // contact time so a temporarily old position does not hard-freeze diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index d001c7811..fff25b8b9 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -106,3 +106,13 @@ test('ground transitions preserve last render height while a surface cell is col assert.equal(next.meta.renderAltitudeM, 1040); assert.equal(next.meta.wasAirborne, true); }); + +test('civil emergency status follows the latest poll instead of sticking', () => { + const store = records(); + const first = store.receive(observation({ squawk: '7700' }), view()); + assert.equal(first.meta.squawk, '7700'); + assert.equal(first.meta.emergency.kind, 'general'); + const next = store.receive(observation({ squawk: '2000' }), view()); + assert.equal(next.meta.squawk, '2000'); + assert.equal(next.meta.emergency, null); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 44e1b02c3..ae4b7c7ca 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { isEmergency } from '../../data/aircraftEmergency.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -13,6 +14,7 @@ import { import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { MIL_TINT, + EMERGENCY_TINT, GROUND_SCALE, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CIVILIAN_COLOR, @@ -54,13 +56,34 @@ export function createRendering({ const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = services.recession; - /** Fleet (untracked) billboard tint: amber for known-military, white otherwise. + /** True while this contact's latest poll broadcast an emergency code. */ + + function _isEmergency(icao24, info = flightState.records.data.get(icao24)) { + return isEmergency(info?.emergency); + } + + /** Fleet (untracked) billboard tint: red while broadcasting an emergency, + * amber for known-military, white otherwise. * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ - function _fleetBillboardColor(icao24) { + function _fleetBillboardColor( + icao24, + info = flightState.records.data.get(icao24), + ) { + if (_isEmergency(icao24, info)) return EMERGENCY_TINT; return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; } + /** Cockpit far-contact dot tint: the emergency red outranks the category tint. */ + + function _cockpitContactColor( + icao24, + info = flightState.records.data.get(icao24), + ) { + if (_isEmergency(icao24, info)) return EMERGENCY_TINT; + return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR; + } + /** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */ function _fleetBillboardScale(icao24, klass) { @@ -129,9 +152,7 @@ export function createRendering({ bb.height = COCKPIT_CONTACT_SIZE_PX; bb.scale = limbScale; bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = ( - isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR - ).withAlpha(freshnessAlpha); + bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha); bb.rotation = 0; return; } @@ -162,7 +183,7 @@ export function createRendering({ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; - return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + return _fleetBillboardColor(icao24); } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -1009,10 +1030,8 @@ export function createRendering({ flightState._cockpitNearContacts.has(icao24); const layerBaseColor = flightState._cockpitContactMode && !isCockpitNear - ? isMilitaryIcao(icao24) - ? MIL_TINT - : COCKPIT_CIVILIAN_COLOR - : _fleetBillboardColor(icao24); + ? _cockpitContactColor(icao24, info) + : _fleetBillboardColor(icao24, info); const baseColor = layerBaseColor; const treatment = applyAircraftBillboardTreatment({ billboard: bb, diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js new file mode 100644 index 000000000..bd63b600f --- /dev/null +++ b/src/layers/gnss/index.js @@ -0,0 +1,422 @@ +import * as Cesium from 'cesium'; +import { + GNSS_LEVEL_COLORS, + GNSS_MIN_AIRCRAFT, + GNSS_WINDOW_MS, + accumulateGnssObservations, + binGnssCells, + gnssProvenance, + pruneGnssObservations, +} from './records.js'; +export * from './records.js'; +export { createAdsbGnssSource } from './source.js'; + +/** + * GroundPrimitive allowPicking:false suppresses ids but still submits stencil + * commands in Cesium's pick pass, erasing picks of overlapping ground geometry. + * This fill participates only in rendering, so sibling polygons remain pickable. + */ +class GnssGroundFill extends Cesium.GroundPrimitive { + update(frameState) { + if (!frameState.passes.pick) super.update(frameState); + } +} + +/** Low stays visible over land so coverage reads apart from no data. */ +const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); +const LEVEL_LABELS = Object.freeze({ + low: 'Low-accuracy score < 2%', + medium: 'Low-accuracy score 2–10%', + high: 'Low-accuracy score > 10%', +}); + +/** Degrees at the centre of the current view, or null before the camera settles. */ +export function defaultGnssViewAnchor(viewer) { + const canvas = viewer?.scene?.canvas; + const ellipsoid = viewer?.scene?.globe?.ellipsoid; + // At globe scale the visible rectangle can be the whole world, whose + // midpoint is unrelated to the place under the screen centre. + const centre = + canvas?.clientWidth > 0 && + canvas.clientHeight > 0 && + viewer.camera?.pickEllipsoid?.( + new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), + ellipsoid, + ); + const rectangle = + !centre && viewer?.camera?.computeViewRectangle?.(ellipsoid); + const carto = centre + ? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre) + : rectangle + ? Cesium.Rectangle.center(rectangle) + : viewer?.camera?.positionCartographic; + if (!carto) return null; + const latitude = Cesium.Math.toDegrees(carto.latitude); + const longitude = Cesium.Math.toDegrees(carto.longitude); + return Number.isFinite(latitude) && Number.isFinite(longitude) + ? { latitude, longitude } + : null; +} + +/** + * Own one GNSS navigation-integrity display: rolling integrity observations + * around the views the user visits, binned into cells coloured by the + * gpsjam low-accuracy score. Share links retain token 3. + */ +export function createGnssIntegrityLayer({ + source, + viewAnchor = defaultGnssViewAnchor, + now = () => Date.now(), + monotonicNow = () => performance.now(), + windowMs = GNSS_WINDOW_MS, +} = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('GNSS integrity layer requires a snapshot source'); + let _viewer = null; + let _primitives = null; + let _request = null; + let _enabled = false; + let _cells = []; + let _signature = null; + let _lastUpdate = null; + let _lastError = null; + let _stale = false; + let _manager = null; + let _cameraTimer = null; + let _backgroundRefresh = null; + let _lastQueryKey = null; + let _lastRequestAt = -Infinity; + let _removeCameraEnd = null; + let _removeCameraChanged = null; + let _coverage = null; + let _retryTimer = null; + let _retryAt = 0; + let _failureCount = 0; + const _observations = new Map(); + const provenance = gnssProvenance({ windowMs }); + + const queryKey = (anchor) => + anchor + ? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}` + : null; + + function scheduleCameraRefresh() { + if (!_enabled || !_primitives) return; + if (now() < _retryAt) return; + const key = queryKey(viewAnchor(_viewer)); + if (!key || key === _lastQueryKey) return; + clearTimeout(_cameraTimer); + // Debounce drags and bound moving/follow cameras to at most 24 requests + // per minute. Nearby views share the proxy's whole-degree cache key. + _cameraTimer = setTimeout( + async () => { + _cameraTimer = null; + if (!_enabled || _backgroundRefresh || _request) return; + const key = queryKey(viewAnchor(_viewer)); + if (!key || key === _lastQueryKey) return; + await refreshInBackground(); + }, + Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)), + ); + } + + async function refreshInBackground() { + if (!_enabled || _backgroundRefresh) return; + const controller = new AbortController(); + _backgroundRefresh = controller; + try { + if (_manager) + await _manager.refreshLayer(layer.id, { signal: controller.signal }); + else await layer.update(undefined, { signal: controller.signal }); + } finally { + if (_backgroundRefresh === controller) { + _backgroundRefresh = null; + scheduleCameraRefresh(); + scheduleRetry(); + } + } + } + + function scheduleRetry() { + clearTimeout(_retryTimer); + _retryTimer = null; + if (!_enabled || !_retryAt) return; + // Long provider cooldowns keep their full deadline. Browser timers cannot + // exceed ~24 days, so wake in bounded chunks without fetching early. + _retryTimer = setTimeout( + () => { + _retryTimer = null; + void refreshInBackground(); + }, + Math.min(2147483647, Math.max(0, _retryAt - now())), + ); + _retryTimer?.unref?.(); + } + + /** Age the store without new evidence, so expired cells leave the globe. */ + function expire() { + pruneGnssObservations(_observations, now(), { windowMs }); + _cells = binGnssCells(_observations); + render(); + } + + function render() { + // Only the id and band change what is drawn; counts alone do not. + const signature = JSON.stringify( + _cells.map(({ id, level }) => [id, level]), + ); + if (signature === _signature) return; + _signature = signature; + _primitives.removeAll(); + if (_cells.length) { + _primitives.add( + new GnssGroundFill({ + geometryInstances: _cells.map( + (cell) => + new Cesium.GeometryInstance({ + id: `${layer.id}:${cell.id}`, + geometry: new Cesium.RectangleGeometry({ + rectangle: Cesium.Rectangle.fromDegrees( + cell.west, + cell.south, + cell.east, + cell.north, + ), + vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + Cesium.Color.fromCssColorString( + GNSS_LEVEL_COLORS[cell.level], + ).withAlpha(FILL_ALPHA[cell.level]), + ), + }, + }), + ), + appearance: new Cesium.PerInstanceColorAppearance({ + flat: true, + translucent: true, + }), + classificationType: Cesium.ClassificationType.BOTH, + allowPicking: false, + // Retain the compact rectangle descriptors for inspection, not Entity visualizers. + releaseGeometryInstances: false, + }), + ); + } + _viewer?.scene?.requestRender?.(); + } + + const layer = { + id: 'gnss-integrity', + name: 'GNSS Integrity', + icon: '📡', + source: 'adsb.lol', + /** Where each part of the method comes from; see records.js. */ + provenance, + updateInterval: 60000, + + attachDataManager(manager) { + _manager = manager; + }, + + init(viewer) { + if (_viewer) + throw new Error('GNSS integrity layer is already initialized'); + _viewer = viewer; + _primitives = new Cesium.PrimitiveCollection(); + _primitives.id = layer.id; + _primitives.show = false; + viewer.scene.groundPrimitives.add(_primitives); + _removeCameraEnd = viewer.camera?.moveEnd?.addEventListener( + scheduleCameraRefresh, + ); + _removeCameraChanged = viewer.camera?.changed?.addEventListener( + scheduleCameraRefresh, + ); + }, + + enable() { + _enabled = true; + // Cells left from before a long disable must not reappear past the window. + if (_primitives) { + expire(); + _primitives.show = true; + } + }, + + disable() { + clearTimeout(_cameraTimer); + _cameraTimer = null; + clearTimeout(_retryTimer); + _retryTimer = null; + _backgroundRefresh?.abort(); + _backgroundRefresh = null; + _request?.abort(); + _request = null; + _enabled = false; + if (_primitives) _primitives.show = false; + }, + + async update(_viewerArg, { signal } = {}) { + if (!_enabled || !_primitives || signal?.aborted) return false; + if (now() < _retryAt) { + expire(); + scheduleRetry(); + return true; + } + _retryAt = 0; + clearTimeout(_retryTimer); + _retryTimer = null; + const anchor = viewAnchor(_viewer); + // No anchor yet (camera still settling) is not a failure: returning + // false would make the manager reject the enable. The next interval + // retries; cells already drawn still age out of the window. + if (!anchor) { + expire(); + return true; + } + _request?.abort(); + const request = new AbortController(); + _request = request; + const onAbort = () => request.abort(signal.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + _lastQueryKey = queryKey(anchor); + _lastRequestAt = monotonicNow(); + _coverage = { + kind: 'regional', + latitude: Math.round(anchor.latitude), + longitude: Math.round(anchor.longitude), + radiusNm: 250, + }; + let received = false; + try { + const snapshot = await source.getSnapshot(anchor, { + signal: request.signal, + }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + received = true; + // Date the rows by when the proxy observed them: a cached or stale + // replay keeps its original age instead of restarting the window. + // The proxy reports that age, so client and server clocks never mix. + const at = now(); + accumulateGnssObservations(_observations, snapshot.rows, at, { + windowMs, + observedAt: at - snapshot.ageMs, + }); + _cells = binGnssCells(_observations); + render(); + _stale = snapshot.stale === true; + _lastUpdate = at - snapshot.ageMs; + _lastError = null; + _retryAt = 0; + _failureCount = 0; + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + // A renderer/programming failure remains a real lifecycle failure. + // Only acquisition failures are kept enabled for recovery. + if (received) throw e; + console.warn('[Data:GNSS] Fetch error:', e); + _lastError = e?.message || 'GNSS integrity source unavailable'; + // Keep the last cells only while their evidence is inside the window. + expire(); + if (e?.retryable === false) return false; + _failureCount = Math.min(3, _failureCount + 1); + const delay = + Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0 + ? e.retryAfterMs + : Math.min(120000, 30000 * 2 ** (_failureCount - 1)); + _retryAt = Math.max( + _retryAt, + Math.min(Number.MAX_VALUE, now() + delay), + ); + // Enabling the display succeeded. Stats still report an unavailable + // or degraded feed; the manager keeps its normal refresh failure lane. + return true; + } finally { + signal?.removeEventListener('abort', onAbort); + if (_request === request) _request = null; + scheduleCameraRefresh(); + scheduleRetry(); + } + }, + + destroy(viewer = _viewer) { + layer.disable(); + _removeCameraEnd?.(); + _removeCameraChanged?.(); + _removeCameraEnd = null; + _removeCameraChanged = null; + _observations.clear(); + _cells = []; + _signature = null; + if (_primitives) { + viewer.scene.groundPrimitives.remove(_primitives); + _primitives = null; + } + _viewer = null; + _lastUpdate = null; + _lastError = null; + _stale = false; + _coverage = null; + _retryAt = 0; + _failureCount = 0; + _lastQueryKey = null; + _manager = null; + }, + + getRowControls() { + return { + chips: [], + info: + 'Regional sample · 250 nm radius · cells from visited views over 30 min' + + (_lastError && _retryAt + ? ` · ${_lastError} · Retrying automatically${ + _retryAt <= 8640000000000000 + ? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC` + : ' after provider cooldown' + }` + : ''), + infoTitle: + 'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.', + legend: ['high', 'medium', 'low'].map((level, index) => ({ + label: LEVEL_LABELS[level], + color: GNSS_LEVEL_COLORS[level], + count: _cells.filter((cell) => cell.level === level).length, + ...(index === 0 + ? { + blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`, + } + : {}), + })), + }; + }, + + getStats() { + return { + count: _cells.length, + levels: Object.fromEntries( + ['low', 'medium', 'high'].map((level) => [ + level, + _cells.filter((cell) => cell.level === level).length, + ]), + ), + lastUpdate: _lastUpdate, + error: _lastError, + retryInSec: _retryAt + ? Math.max(0, Math.ceil((_retryAt - now()) / 1000)) + : null, + stale: _stale, + provenance, + coverage: _coverage, + // Load-bearing: without an explicit boolean, layerFeedState reads the + // adsb.lol source name as a flights fallback (src/data/feedState.js). + fallback: false, + }; + }, + }; + return layer; +} diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs new file mode 100644 index 000000000..713e44889 --- /dev/null +++ b/src/layers/gnss/ownership.test.mjs @@ -0,0 +1,700 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js'; +import { LayerLifecycle } from '../../data/lifecycle.js'; +import { layerFeedState } from '../../data/feedState.js'; + +const row = (hex, lat, degraded) => ({ + hex, + lat, + lon: 30.2, + nic: degraded ? 0 : 8, + nacp: degraded ? 0 : 10, + gpsLost: false, + degraded, +}); + +function harness( + getSnapshot, + { + anchor: initialAnchor = { latitude: 50, longitude: 30 }, + initialize = true, + } = {}, +) { + let anchor = initialAnchor; + const sources = []; + const viewer = { + camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, + scene: { + globe: { show: true }, + groundPrimitives: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + value.destroy(); + }, + }, + }, + }; + let clock = 1_000; + const anchors = []; + const layer = createGnssIntegrityLayer({ + // The real source refuses an undated snapshot; stubs default to a fresh one. + source: { + async getSnapshot(...args) { + const snapshot = await getSnapshot(...args); + return { ageMs: 0, ...snapshot }; + }, + }, + viewAnchor: () => { + anchors.push(anchor); + return anchor; + }, + now: () => clock, + monotonicNow: () => clock, + }); + if (initialize) layer.init(viewer); + return { + layer, + sources, + anchors, + viewer, + advance(ms) { + clock += ms; + }, + now: () => clock, + setAnchor(value) { + anchor = value; + }, + }; +} + +const MIN = 60_000; +const threeHealthy = () => [ + row('a1', 1.1, false), + row('a2', 1.2, false), + row('a3', 1.3, false), +]; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => { + const anchor = defaultGnssViewAnchor({ + scene: { + canvas: { clientWidth: 1000, clientHeight: 800 }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + camera: { + pickEllipsoid(pixel) { + assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400)); + return Cesium.Cartesian3.fromDegrees(-97, 30); + }, + computeViewRectangle: () => { + throw new Error('centre hit must not compute a rectangle'); + }, + }, + }); + assert.ok(Math.abs(anchor.latitude - 30) < 1e-8); + assert.ok(Math.abs(anchor.longitude + 97) < 1e-8); +}); + +test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let calls = 0; + const h = harness( + async () => { + calls++; + return { rows: threeHealthy() }; + }, + { initialize: false }, + ); + const manager = new LayerLifecycle(h.viewer); + manager.register(h.layer); + h.layer.attachDataManager(manager); + const changes = []; + manager.subscribe((change) => changes.push(change.type)); + await manager.setEnabled(h.layer.id, true); + h.setAnchor({ latitude: 54, longitude: 19 }); + h.viewer.camera.changed.raiseEvent(); + h.setAnchor({ latitude: 55, longitude: 20 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + assert.equal(calls, 2, 'one initial read and one read for the latest view'); + assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 }); + assert.ok(changes.includes('refresh-transition')); + assert.ok(changes.includes('refresh')); + assert.equal(manager.isEnabled(h.layer.id), true); + assert.deepEqual(h.layer.getStats().coverage, { + kind: 'regional', + latitude: 55, + longitude: 20, + radiusNm: 250, + }); + assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/); + h.setAnchor({ latitude: 55.1, longitude: 20.1 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(5000); + t.mock.timers.tick(5000); + await flush(); + assert.equal(calls, 2, 'nearby views reuse the same query region'); + await manager.destroyAll(); + assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0); + assert.equal(h.viewer.camera.changed.numberOfListeners, 0); +}); + +test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const calls = []; + let resolvePending; + const h = harness((anchor) => { + calls.push(anchor); + if (calls.length === 2) + return new Promise((resolve) => { + resolvePending = resolve; + }); + return { rows: threeHealthy() }; + }); + h.layer.enable(); + await h.layer.update(); + h.setAnchor({ latitude: 54, longitude: 19 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + h.setAnchor({ latitude: 35, longitude: 140 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + assert.equal(calls.length, 2, 'only one camera request in flight'); + resolvePending({ rows: threeHealthy() }); + await flush(); + h.advance(600); + t.mock.timers.tick(600); + await flush(); + assert.equal(calls.length, 3); + assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 }); + h.setAnchor({ latitude: 10, longitude: 10 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.layer.disable(); + h.advance(5000); + t.mock.timers.tick(5000); + await flush(); + assert.equal(calls.length, 3, 'disable drains the camera timer'); + h.layer.destroy(); +}); + +test('the layer requires a snapshot source and cannot be initialized twice', () => { + assert.throws(() => createGnssIntegrityLayer({}), TypeError); + const { layer } = harness(async () => ({ rows: [] })); + assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/); +}); + +test('an enabled refresh bins the snapshot into coloured ground cells', async () => { + const { layer, sources, anchors } = harness(async (anchor, { signal }) => { + assert.deepEqual(anchor, { latitude: 50, longitude: 30 }); + assert.ok(signal instanceof AbortSignal); + return { + rows: [ + row('a1', 50.1, true), + row('a2', 50.1, true), + row('a3', 50.2, false), + row('a4', 50.3, false), + row('b1', 55.1, false), + ], + stale: false, + }; + }); + assert.equal(await layer.update(), false, 'disabled layers do not fetch'); + assert.equal(anchors.length, 0); + layer.enable(); + assert.equal(sources[0].show, true); + assert.equal(await layer.update(), true); + const entities = sources[0].get(0).geometryInstances; + assert.equal(entities.length, 1, 'the one-aircraft cell is withheld'); + assert.equal(entities[0].id, 'gnss-integrity:280:420'); + assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry); + assert.equal(sources[0].get(0).allowPicking, false); + const stats = layer.getStats(); + assert.equal(stats.count, 1); + assert.equal(stats.error, null); + assert.equal(stats.fallback, false); + const { legend } = layer.getRowControls(); + assert.deepEqual( + legend.map(({ count }) => count), + [1, 0, 0], + ); + assert.match(legend[0].blurb, /not a detection/); + assert.match(legend[0].blurb, /need 3 aircraft/); +}); + +test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => { + const { layer } = harness(async () => ({ rows: [], stale: false })); + assert.equal( + layer.id, + 'gnss-integrity', + 'the unpublished id describes the measured state', + ); + assert.equal(layer.name, 'GNSS Integrity'); + assert.doesNotMatch(layer.name, /interference|jamming/i); + const { legend } = layer.getRowControls(); + assert.deepEqual( + legend.map(({ label }) => label), + [ + 'Low-accuracy score > 10%', + 'Low-accuracy score 2–10%', + 'Low-accuracy score < 2%', + ], + ); + const { blurb } = legend[0]; + assert.match(blurb, /one aircraft is discounted/); + assert.match(blurb, /not the raw share/); + assert.match(blurb, /not a detection/); + assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2'); + assert.equal(layer.provenance.classifier.definedBy, 'GEV'); + assert.equal(layer.provenance.classifier.validated, false); + assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq'); + assert.deepEqual(layer.provenance.window, { + minutes: 30, + scope: 'visited-view', + cellDeg: 0.5, + minAircraft: 3, + definedBy: 'GEV', + }); + assert.equal(layer.provenance.interpretation.validated, false); + assert.equal(layer.getStats().provenance, layer.provenance); +}); + +test('a failed refresh keeps the last cells and reports the error', async () => { + let fail = false; + const { layer, sources } = harness(async () => { + if (fail) throw new Error('adsb.lol HTTP 502'); + return { + rows: [ + row('a1', 1.1, false), + row('a2', 1.2, false), + row('a3', 1.3, false), + ], + stale: true, + }; + }); + layer.enable(); + await layer.update(); + assert.equal(layer.getStats().stale, true); + fail = true; + assert.equal(await layer.update(), true); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); + assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); + assert.equal(layer.getStats().count, 1); +}); + +test('observations expire with the window, and disable aborts the request in flight', async () => { + let pending; + const { layer, sources, advance } = harness( + (anchor, { signal }) => + new Promise((resolve, reject) => { + pending = { resolve }; + signal.addEventListener('abort', () => reject(signal.reason)); + }), + ); + layer.enable(); + let result = layer.update(); + pending.resolve({ + rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)], + }); + assert.equal(await result, true); + assert.equal(layer.getStats().count, 1); + + advance(31 * 60_000); + result = layer.update(); + pending.resolve({ rows: [] }); + assert.equal(await result, true); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + + result = layer.update(); + layer.disable(); + assert.equal(await result, false); + assert.equal(sources[0].show, false); +}); + +test('no view anchor means no request, and destroy releases the data source', async () => { + let calls = 0; + const { layer, sources } = harness( + async () => { + calls += 1; + return { rows: [] }; + }, + { anchor: null }, + ); + layer.enable(); + assert.equal( + await layer.update(), + true, + 'a settling camera is not a failure', + ); + assert.equal(calls, 0); + assert.equal(layer.getStats().error, null); + layer.destroy(); + assert.equal(sources.length, 0); + assert.equal(layer.getStats().count, 0); +}); + +test('a replayed stale snapshot keeps its observation age and expires on time', async () => { + // The proxy keeps answering with the snapshot it observed at t=1000. + const { layer, sources, advance, now } = harness(async () => ({ + rows: threeHealthy(), + ageMs: now() - 1_000, + stale: true, + })); + layer.enable(); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1); + advance(29 * MIN); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1); + advance(2 * MIN); + assert.equal( + await layer.update(), + true, + 'the replay is not an error, only no evidence', + ); + assert.equal( + layer.getStats().count, + 0, + 'the replay did not restart the window', + ); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); +}); + +test('cells expire during prolonged fetch failures', async () => { + let fail = false; + const { layer, sources, advance } = harness(async () => { + if (fail) throw new Error('adsb.lol HTTP 502'); + return { rows: threeHealthy() }; + }); + layer.enable(); + await layer.update(); + fail = true; + advance(20 * MIN); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1, 'still inside the window'); + advance(11 * MIN); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); +}); + +test('cells expire while no view anchor is available', async () => { + let calls = 0; + const { layer, sources, advance, setAnchor } = harness(async () => { + calls += 1; + return { rows: threeHealthy() }; + }); + layer.enable(); + await layer.update(); + setAnchor(null); + advance(20 * MIN); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1); + advance(11 * MIN); + assert.equal(await layer.update(), true); + assert.equal(calls, 1, 'no anchor means no request'); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(layer.getStats().error, null); +}); + +test('re-enabling after a long disable does not show cells past the window', async () => { + const { layer, sources, advance } = harness(async () => ({ + rows: threeHealthy(), + })); + layer.enable(); + await layer.update(); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); + layer.disable(); + advance(31 * MIN); + layer.enable(); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); +}); + +const threeCells = () => ({ + rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)], +}); + +test('an identical snapshot leaves the drawn entities untouched', async () => { + const { layer, sources } = harness(async () => threeCells()); + layer.enable(); + await layer.update(); + const first = sources[0].get(0); + await layer.update(); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); + assert.equal(sources[0].get(0), first); +}); + +test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let calls = 0; + const h = harness( + async () => { + calls++; + if (calls === 1) throw new Error('adsb.lol HTTP 502'); + return { rows: threeHealthy() }; + }, + { initialize: false }, + ); + const manager = new LayerLifecycle(h.viewer); + manager.register(h.layer); + h.layer.attachDataManager(manager); + const changes = []; + manager.subscribe((c) => changes.push(c.type)); + assert.equal(await manager.setEnabled(h.layer.id, true), true); + assert.equal(manager.isEnabled(h.layer.id), true); + assert.equal(layerFeedState(h.layer.getStats()), 'unavailable'); + assert.equal(h.layer.getStats().retryInSec, 30); + assert.match( + h.layer.getRowControls().info, + /HTTP 502.*Retrying automatically at/, + ); + assert.equal(changes.includes('visibility-failed'), false); + assert.equal( + await manager.refreshLayer(h.layer.id), + false, + 'feed remains honestly unhealthy during cooldown', + ); + assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown'); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + assert.equal(calls, 2); + assert.equal(manager.isEnabled(h.layer.id), true); + assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); + assert.equal(h.layer.getStats().count, 1); + assert.equal(h.layer.getStats().retryInSec, null); + assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/); + await manager.destroyAll(); +}); + +test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const h = harness(async () => { + calls++; + if (calls === 1) + throw Object.assign(new Error('Rate limited (HTTP 429)'), { + retryAfterMs: 3600000, + }); + return { rows: threeHealthy() }; + }); + h.layer.enable(); + await h.layer.update(); + h.setAnchor({ latitude: 10, longitude: 20 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + await h.layer.update(); + assert.equal(calls, 1); + assert.equal(h.layer.getStats().retryInSec, 3570); + h.layer.disable(); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + h.layer.enable(); + await h.layer.update(); + assert.equal(calls, 1, 'reenabling does not erase a provider deadline'); + h.advance(3540000); + t.mock.timers.tick(3540000); + await flush(); + assert.equal(calls, 2); + assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 }); + h.layer.destroy(); + + const stopped = harness(async () => { + throw new Error('network unavailable'); + }); + stopped.layer.enable(); + await stopped.layer.update(); + stopped.layer.disable(); + stopped.advance(30000); + t.mock.timers.tick(30000); + await flush(); + assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry'); + stopped.layer.destroy(); +}); + +test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const h = harness(async () => { + calls++; + throw new Error('network unavailable'); + }); + h.layer.enable(); + await h.layer.update(); + assert.equal(h.layer.getStats().retryInSec, 30); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + assert.equal(h.layer.getStats().retryInSec, 60); + h.advance(60000); + t.mock.timers.tick(60000); + await flush(); + assert.equal(h.layer.getStats().retryInSec, 120); + assert.equal(calls, 3); + h.layer.destroy(); + const fatal = harness(async () => { + throw Object.assign(new Error('invalid request (HTTP 400)'), { + retryable: false, + }); + }); + fatal.layer.enable(); + assert.equal(await fatal.layer.update(), false); + assert.equal(fatal.layer.getStats().retryInSec, null); + fatal.layer.destroy(); +}); + +test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => { + const h = harness(async () => ({ + rows: threeHealthy(), + ageMs: 120000, + stale: true, + })); + h.layer.enable(); + await h.layer.update(); + assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000); + h.layer.destroy(); +}); + +test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const h = harness(async () => { + calls++; + return { rows: [] }; + }); + h.layer.enable(); + await h.layer.update(); + h.setAnchor({ latitude: 51, longitude: 31 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(2499); + t.mock.timers.tick(2499); + await flush(); + assert.equal(calls, 1); + h.advance(1); + t.mock.timers.tick(1); + await flush(); + assert.equal(calls, 2); + h.advance(3000); + h.setAnchor({ latitude: 52, longitude: 32 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(599); + t.mock.timers.tick(599); + await flush(); + assert.equal(calls, 2); + h.advance(1); + t.mock.timers.tick(1); + await flush(); + assert.equal(calls, 3); + h.layer.destroy(); +}); + +test('camera movement during initial activation waits for acquisition before following up', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const calls = []; + let resolve; + const h = harness((anchor) => { + calls.push(anchor); + return calls.length === 1 + ? new Promise((r) => (resolve = r)) + : Promise.resolve({ rows: threeHealthy() }); + }); + h.layer.enable(); + const initial = h.layer.update(); + h.setAnchor({ latitude: 51, longitude: 31 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + assert.equal(calls.length, 1); + resolve({ rows: threeHealthy() }); + assert.equal(await initial, true); + h.advance(600); + t.mock.timers.tick(600); + await flush(); + assert.equal(calls.length, 2); + h.layer.destroy(); +}); + +test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(async () => ({ rows: [] })); + h.layer.destroy(); + let wall = 1800000000000, + mono = 0, + anchor = { latitude: 50, longitude: 30 }, + calls = 0; + const layer = createGnssIntegrityLayer({ + source: { + getSnapshot: async () => { + calls++; + return { rows: [], ageMs: 0 }; + }, + }, + viewAnchor: () => anchor, + now: () => wall, + monotonicNow: () => mono, + }); + layer.init(h.viewer); + layer.enable(); + await layer.update(); + wall -= 3600000; + mono = 3000; + anchor = { latitude: 51, longitude: 31 }; + h.viewer.camera.changed.raiseEvent(); + mono += 600; + t.mock.timers.tick(600); + await flush(); + assert.equal(calls, 2); + layer.destroy(); +}); + +test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => { + const h = harness(async () => ({ rows: threeHealthy() })); + h.layer.enable(); + await h.layer.update(); + const fill = h.sources[0].get(0); + let updates = 0; + t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++); + fill.update({ passes: { pick: true } }); + assert.equal(updates, 0); + fill.update({ passes: { render: true, pick: false } }); + assert.equal(updates, 1); + h.layer.destroy(); + assert.equal(fill.isDestroyed(), true); +}); + +test('view anchor falls back to rectangle, then nadir, then no camera', () => { + assert.equal(defaultGnssViewAnchor({}), null); + const viewer = { + camera: { + computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42), + }, + }; + const rectangleAnchor = defaultGnssViewAnchor(viewer); + assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10); + assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10); + viewer.camera = { + positionCartographic: Cesium.Cartographic.fromDegrees(21, 41), + }; + const nadirAnchor = defaultGnssViewAnchor(viewer); + assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10); + assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10); +}); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js new file mode 100644 index 000000000..ec425d5ee --- /dev/null +++ b/src/layers/gnss/records.js @@ -0,0 +1,318 @@ +/** + * Low navigation accuracy reported by ADS-B aircraft, binned into cells. + * + * Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust + * their own position: NIC (integrity containment radius) and NACp (position + * accuracy). A cluster of aircraft reporting low values in one area is a + * navigation-integrity anomaly. Jamming or spoofing is one common cause, so + * such clusters are a well-known public proxy for GNSS interference, but + * avionics faults and old transponders also report low values: suspected + * interference is a second, unvalidated interpretation, never the primary + * state. + * + * Three separately sourced parts make up the method (see `gnssProvenance`): + * - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7 + * (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed + * from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not + * a published interference classifier) or the readsb/tar1090 `gpsOkBefore` + * flag. gpsjam.org does not publish its per-aircraft threshold. + * - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands. + * - Window: a GEV-defined rolling 30 minutes over the views the user visits. + */ + +/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */ +const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']); +/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */ +const QUALIFYING_VERSIONS = new Set([1, 2]); +/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */ +export const GNSS_NIC_THRESHOLD = 7; +/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */ +export const GNSS_NACP_THRESHOLD = 8; +/** Positions older than this are not current evidence of the local sky. */ +const MAX_POSITION_AGE_S = 60; +/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */ +export const GNSS_CELL_DEG = 0.5; +/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */ +export const GNSS_MIN_AIRCRAFT = 3; +/** Rolling window over which aircraft seen in a cell are counted. */ +export const GNSS_WINDOW_MS = 30 * 60_000; +/** gpsjam.org band edges, in percent of aircraft (after discounting one). */ +export const GNSS_MEDIUM_PERCENT = 2; +export const GNSS_HIGH_PERCENT = 10; + +/** Primary cell state: what the data shows (low navigation accuracy). */ +export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly'; +export const GNSS_NOMINAL_STATE = 'nominal'; +/** Secondary, unvalidated reading of an anomaly cell. */ +export const GNSS_INTERPRETATION = 'suspected-gnss-interference'; + +/** The per-aircraft rule; GEV-defined and not validated against known events. */ +export const GNSS_CLASSIFIER = Object.freeze({ + id: 'gev-nic-nacp-v2', + rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`, + basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', + definedBy: 'GEV', + validated: false, +}); + +/** + * Machine-readable provenance of the cell output: which part of the method + * comes from where, and which parts are unvalidated. + * + * @param {{windowMs?: number}} [options] The window the layer actually uses. + */ +export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) { + return Object.freeze({ + classifier: GNSS_CLASSIFIER, + aggregation: Object.freeze({ + id: 'gpsjam-cell-bands', + formula: '100 * max(0, bad - 1) / total', + bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]), + source: 'https://gpsjam.org/faq', + definedBy: 'gpsjam.org', + }), + window: Object.freeze({ + minutes: windowMs / 60_000, + scope: 'visited-view', + cellDeg: GNSS_CELL_DEG, + minAircraft: GNSS_MIN_AIRCRAFT, + definedBy: 'GEV', + }), + state: Object.freeze({ + id: GNSS_ANOMALY_STATE, + meaning: 'low navigation accuracy reported by ADS-B aircraft', + }), + interpretation: Object.freeze({ + id: GNSS_INTERPRETATION, + validated: false, + note: 'one possible cause of an anomaly cell; avionics faults also report low values', + }), + }); +} + +/** Provenance at the default window. */ +export const GNSS_PROVENANCE = gnssProvenance(); + +const HEX_PATTERN = /^~?[0-9a-f]{6}$/; + +const finite = (value) => typeof value === 'number' && Number.isFinite(value); + +/** + * Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity + * facts the layer needs. Returns null when the payload has no aircraft array. + * + * @param {{ac?: object[], now?: number}} payload - readsb-style snapshot. + * @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null} + */ +export function normalizeGnssAircraft(payload) { + if (!Array.isArray(payload?.ac)) return null; + const rows = []; + const seen = new Set(); + for (const aircraft of payload.ac) { + if (!aircraft || typeof aircraft !== 'object') continue; + const hex = typeof aircraft.hex === 'string' ? aircraft.hex : ''; + if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue; + if (!QUALIFYING_TYPES.has(aircraft.type)) continue; + if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue; + if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue; + let { lat, lon } = aircraft; + const derived = (field) => + aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field); + const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null; + const nacp = + finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null; + if (!finite(lat) || Math.abs(lat) > 90) continue; + if (!finite(lon) || Math.abs(lon) > 180) continue; + // Only a position confirmed within the last minute is current evidence; a + // missing, invalid or negative age confirms nothing. + const age = aircraft.seen_pos; + if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue; + // readsb exposes the flag while GPS is lost (15 s–15 min after the + // last good position), and removes it on recovery. Locate that evidence + // at the last good GNSS position, rather than a later MLAT estimate. + const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0; + if (nic === null && nacp === null && !gpsLost) continue; + if ( + gpsLost && + finite(aircraft.gpsOkLat) && + Math.abs(aircraft.gpsOkLat) <= 90 && + finite(aircraft.gpsOkLon) && + Math.abs(aircraft.gpsOkLon) <= 180 + ) { + lat = aircraft.gpsOkLat; + lon = aircraft.gpsOkLon; + } + seen.add(hex); + rows.push({ + hex, + lat, + lon, + nic, + nacp, + gpsLost, + degraded: + gpsLost || + (nacp !== null && nacp < GNSS_NACP_THRESHOLD) || + (nic !== null && + nic < GNSS_NIC_THRESHOLD && + !(nic === 0 && nacp !== null && nacp >= 10)), + }); + } + return rows; +} + +/** Stable key of the cell containing a position. */ +export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) { + const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg); + const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg); + return `${row}:${col}`; +} + +/** + * Fold one snapshot into a rolling observation store. Each aircraft counts + * once per cell per window. Presence and degraded evidence age separately: a + * degraded report keeps the aircraft degraded in that cell for one window + * after the report itself, however many healthy reports follow, so an + * aircraft that loses GNSS mid-cell is not averaged away and a recovered one + * is not held degraded forever. + * + * Every row is dated by when the snapshot was observed, not when it arrived: + * replaying a cached or stale snapshot never makes its evidence younger, and + * a snapshot older than the window adds nothing. + * + * @param {Map} store + * @param {ReturnType} rows + * @param {number} nowMs + * @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options] + * `observedAt` is the snapshot's observation time (defaults to `nowMs`; + * a future time is clamped to `nowMs`, a non-finite one adds nothing). + * @returns {Map} The same store, pruned to the window. + */ +export function accumulateGnssObservations( + store, + rows, + nowMs, + { + windowMs = GNSS_WINDOW_MS, + cellDeg = GNSS_CELL_DEG, + observedAt = nowMs, + } = {}, +) { + const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN; + if (Number.isFinite(at) && nowMs - at <= windowMs) { + for (const row of rows || []) { + if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue; + const cell = gnssCellKey(row.lat, row.lon, cellDeg); + const key = `${row.hex}|${cell}`; + const previous = store.get(key); + const previousDegradedAt = previous?.degradedAt ?? null; + store.set(key, { + cell, + seenAt: Math.max(previous?.seenAt ?? at, at), + degradedAt: row.degraded + ? Math.max(previousDegradedAt ?? at, at) + : previousDegradedAt, + }); + } + } + return pruneGnssObservations(store, nowMs, { windowMs }); +} + +/** + * Drop observations older than the window and degraded evidence older than + * the window, without adding anything. Used when no fresh snapshot arrives. + * + * @param {Map} store + * @param {number} nowMs + * @param {{windowMs?: number}} [options] + * @returns {Map} The same store. + */ +export function pruneGnssObservations( + store, + nowMs, + { windowMs = GNSS_WINDOW_MS } = {}, +) { + for (const [key, entry] of store) { + if (nowMs - entry.seenAt > windowMs) store.delete(key); + else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs) + store.set(key, { ...entry, degradedAt: null }); + } + return store; +} + +/** + * Navigation-integrity level of a cell, using gpsjam.org's published cell + * formula and bands: the first degraded aircraft is discounted to limit false + * positives, then under 2% is low, 2–10% medium and over 10% high. Medium and + * high cells are navigation-integrity anomalies. + */ +export function gnssIntegrityLevel(total, degraded) { + const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0; + const level = + percent > GNSS_HIGH_PERCENT + ? 'high' + : percent >= GNSS_MEDIUM_PERCENT + ? 'medium' + : 'low'; + const anomaly = level !== 'low'; + return { + percent, + level, + state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE, + interpretation: anomaly ? GNSS_INTERPRETATION : null, + }; +} + +/** + * Aggregate the observation store into displayable cells. Expects a store + * already pruned to the window by `accumulateGnssObservations` or + * `pruneGnssObservations`. + * + * @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]} + */ +export function binGnssCells( + store, + { cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {}, +) { + const tallies = new Map(); + for (const { cell, degradedAt } of store.values()) { + const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 }; + tally.aircraft += 1; + if (degradedAt != null) tally.degraded += 1; + tallies.set(cell, tally); + } + const cells = []; + for (const [id, { aircraft, degraded }] of tallies) { + if (aircraft < minAircraft) continue; + const [row, col] = id.split(':').map(Number); + const south = row * cellDeg - 90; + const west = col * cellDeg - 180; + const { percent, level, state, interpretation } = gnssIntegrityLevel( + aircraft, + degraded, + ); + cells.push({ + id, + south, + west, + north: south + cellDeg, + east: west + cellDeg, + lat: south + cellDeg / 2, + lon: west + cellDeg / 2, + aircraft, + degraded, + percentDegraded: Math.round(percent * 10) / 10, + level, + state, + interpretation, + }); + } + return cells.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** Legend colors: gpsjam's green / amber / red convention. */ +export const GNSS_LEVEL_COLORS = Object.freeze({ + low: '#3fb950', + medium: '#e3b341', + high: '#f85149', +}); diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs new file mode 100644 index 000000000..2f1493544 --- /dev/null +++ b/src/layers/gnss/records.test.mjs @@ -0,0 +1,491 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + accumulateGnssObservations, + binGnssCells, + GNSS_CLASSIFIER, + GNSS_PROVENANCE, + GNSS_WINDOW_MS, + gnssCellKey, + gnssIntegrityLevel, + gnssProvenance, + normalizeGnssAircraft, + pruneGnssObservations, +} from './records.js'; + +const aircraft = (overrides = {}) => ({ + hex: '4ca7b5', + type: 'adsb_icao', + version: 2, + alt_baro: 36000, + lat: 50.1, + lon: 19.9, + nic: 8, + nac_p: 10, + seen_pos: 1.2, + ...overrides, +}); + +test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => { + const rows = normalizeGnssAircraft({ + ac: [ + aircraft(), + aircraft({ + hex: '000001', + type: 'mlat', + nic: undefined, + nac_p: undefined, + }), + aircraft({ hex: '000002', type: 'tisb_icao' }), + aircraft({ hex: '000003', version: 0 }), + aircraft({ hex: '000004', alt_baro: 'ground' }), + aircraft({ hex: '000005', seen_pos: 120 }), + aircraft({ hex: '000006', nic: undefined, nac_p: undefined }), + aircraft({ hex: '000007', lat: 95 }), + aircraft({ hex: '000008', version: undefined }), + // Only DO-260A/B (versions 1 and 2) define NIC and NACp. + aircraft({ hex: '000009', version: 3 }), + aircraft({ hex: '00000a', version: '2' }), + aircraft({ hex: '00000b', version: 1.5 }), + // A position is current only when its age is known, finite and 0..60 s. + aircraft({ hex: '00000c', seen_pos: undefined }), + aircraft({ hex: '00000d', seen_pos: null }), + aircraft({ hex: '00000e', seen_pos: -0.5 }), + aircraft({ hex: '00000f', seen_pos: '1.2' }), + aircraft({ hex: '000010', seen_pos: Number.NaN }), + aircraft({ hex: '000011', seen_pos: 60.1 }), + aircraft({ hex: 'not-hex' }), + aircraft(), + // A non-ICAO (`~`) address still self-reports over 1090ES ADS-B. + aircraft({ hex: '~a1b2c3' }), + // Version 1, and both ends of the position-age range, qualify. + aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }), + aircraft({ hex: 'b00002', seen_pos: 60 }), + null, + ], + }); + assert.deepEqual(rows, [ + { + hex: '4ca7b5', + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + }, + { + hex: '~a1b2c3', + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + }, + ...['b00001', 'b00002'].map((hex) => ({ + hex, + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + })), + ]); + assert.equal(normalizeGnssAircraft({}), null); + assert.equal(normalizeGnssAircraft(null), null); +}); + +test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => { + const rows = normalizeGnssAircraft({ + ac: [ + aircraft({ hex: 'a00001', nic: 6 }), + aircraft({ hex: 'a00002', nac_p: 7 }), + aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }), + aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }), + ], + }); + assert.deepEqual( + rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]), + [ + ['a00001', true, false], + ['a00002', true, false], + ['a00003', true, true], + ['a00004', false, false], + ], + ); +}); + +test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => { + assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6)); + assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9)); + assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9)); + assert.equal(gnssCellKey(-90, -180), '0:0'); + // Just south-west of the origin falls in the last cell before 0°, not cell 0. + assert.equal(gnssCellKey(-0.1, -0.1), '179:359'); +}); + +test('integrity level follows the gpsjam.org published cell formula and bands', () => { + const nominal = { + percent: 0, + level: 'low', + state: 'nominal', + interpretation: null, + }; + assert.deepEqual(gnssIntegrityLevel(10, 0), nominal); + // One degraded aircraft is always discounted. + assert.deepEqual(gnssIntegrityLevel(10, 1), nominal); + assert.equal(gnssIntegrityLevel(50, 2).level, 'medium'); + assert.equal(gnssIntegrityLevel(10, 2).level, 'medium'); + assert.equal(gnssIntegrityLevel(10, 3).level, 'high'); + assert.equal(gnssIntegrityLevel(0, 0).level, 'low'); +}); + +test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => { + for (const [total, bad] of [ + [50, 2], + [10, 3], + ]) { + const { state, interpretation } = gnssIntegrityLevel(total, bad); + assert.equal(state, 'navigation-integrity-anomaly'); + assert.equal(interpretation, 'suspected-gnss-interference'); + } + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'h1', lat: 10.1, lon: 10.1, degraded: true }, + { hex: 'h2', lat: 10.2, lon: 10.2, degraded: true }, + { hex: 'h3', lat: 10.3, lon: 10.3, degraded: false }, + ], + 0, + ); + const [cell] = binGnssCells(store); + assert.equal(cell.level, 'high'); + assert.equal(cell.state, 'navigation-integrity-anomaly'); + assert.equal(cell.interpretation, 'suspected-gnss-interference'); + for (const key of Object.keys(cell)) + assert.doesNotMatch(key, /interference|jamming|spoof/i); +}); + +test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => { + assert.deepEqual(GNSS_PROVENANCE.classifier, { + id: 'gev-nic-nacp-v2', + rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))', + basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', + definedBy: 'GEV', + validated: false, + }); + assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER); + assert.deepEqual(GNSS_PROVENANCE.aggregation, { + id: 'gpsjam-cell-bands', + formula: '100 * max(0, bad - 1) / total', + bands: [2, 10], + source: 'https://gpsjam.org/faq', + definedBy: 'gpsjam.org', + }); + assert.deepEqual(GNSS_PROVENANCE.window, { + minutes: 30, + scope: 'visited-view', + cellDeg: 0.5, + minAircraft: 3, + definedBy: 'GEV', + }); + assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly'); + assert.equal( + GNSS_PROVENANCE.interpretation.id, + 'suspected-gnss-interference', + ); + assert.equal(GNSS_PROVENANCE.interpretation.validated, false); + assert.ok(Object.isFrozen(GNSS_PROVENANCE)); + assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands)); + // The bands in provenance are the ones the level function applies. + const [medium, high] = GNSS_PROVENANCE.aggregation.bands; + assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high'); + assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10); +}); + +test('observations count each aircraft once per cell and expire after the window', () => { + const store = new Map(); + const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded }); + accumulateGnssObservations( + store, + [at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)], + 0, + { windowMs: 1000 }, + ); + // a1 recovers inside the same cell: it stays degraded for the window. + accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, { + windowMs: 1000, + }); + let [cell] = binGnssCells(store); + assert.equal(cell.aircraft, 3); + assert.equal(cell.degraded, 1); + assert.equal(cell.level, 'low'); + assert.deepEqual( + [cell.south, cell.west, cell.north, cell.east], + [50, 20, 50.5, 20.5], + ); + + accumulateGnssObservations(store, [], 1200, { windowMs: 1000 }); + assert.equal(store.size, 1); + assert.deepEqual(binGnssCells(store), []); +}); + +const MIN = 60_000; +const report = (hex, degraded, lat = 50.1) => ({ + hex, + lat, + lon: 20.1, + degraded, +}); +const entry = (store, hex) => + [...store].find(([key]) => key.startsWith(`${hex}|`))?.[1]; + +test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => { + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + // a1 keeps reporting healthy every minute in the same cell. + for (let minute = 1; minute <= 30; minute += 1) + accumulateGnssObservations(store, [report('a1', false)], minute * MIN); + assert.equal(entry(store, 'a1').seenAt, 30 * MIN); + assert.equal( + entry(store, 'a1').degradedAt, + 0, + 'healthy reports do not refresh the degraded evidence', + ); + accumulateGnssObservations(store, [report('a1', false)], 31 * MIN); + assert.deepEqual(entry(store, 'a1'), { + cell: gnssCellKey(50.1, 20.1), + seenAt: 31 * MIN, + degradedAt: null, + }); + // A new degraded report starts its own window. + accumulateGnssObservations(store, [report('a1', true)], 32 * MIN); + assert.equal(entry(store, 'a1').degradedAt, 32 * MIN); + accumulateGnssObservations(store, [report('a1', false)], 40 * MIN); + assert.equal(entry(store, 'a1').degradedAt, 32 * MIN); +}); + +test('a degraded cell turns healthy once its degraded evidence is older than the window', () => { + const store = new Map(); + const healthy = [report('a2', false), report('a3', false)]; + accumulateGnssObservations( + store, + [report('a1', true), report('b1', true), ...healthy], + 0, + ); + assert.equal(binGnssCells(store)[0].level, 'high'); + for (let minute = 10; minute <= 30; minute += 10) + accumulateGnssObservations( + store, + [report('a1', false), report('b1', false), ...healthy], + minute * MIN, + ); + assert.equal(binGnssCells(store)[0].degraded, 2); + accumulateGnssObservations( + store, + [report('a1', false), report('b1', false), ...healthy], + 31 * MIN, + ); + const [cell] = binGnssCells(store); + assert.equal(cell.aircraft, 4); + assert.equal(cell.degraded, 0); + assert.equal(cell.level, 'low'); +}); + +test('an aircraft returning after the window starts fresh', () => { + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + // a1 leaves; nothing is heard from it for longer than the window. + accumulateGnssObservations(store, [], 31 * MIN); + assert.equal(store.size, 0); + accumulateGnssObservations(store, [report('a1', false)], 32 * MIN); + assert.deepEqual(entry(store, 'a1'), { + cell: gnssCellKey(50.1, 20.1), + seenAt: 32 * MIN, + degradedAt: null, + }); + // Returning degraded dates the evidence from the new report. + const again = new Map(); + accumulateGnssObservations(again, [report('a9', true)], 0); + accumulateGnssObservations(again, [], 31 * MIN); + accumulateGnssObservations(again, [report('a9', true)], 33 * MIN); + assert.equal(entry(again, 'a9').degradedAt, 33 * MIN); +}); + +test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => { + // No prune runs in between: the old entry is still in the store when the + // healthy report arrives, so the new report alone must not carry it over. + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + accumulateGnssObservations(store, [report('a1', false)], 31 * MIN); + assert.deepEqual(entry(store, 'a1'), { + cell: gnssCellKey(50.1, 20.1), + seenAt: 31 * MIN, + degradedAt: null, + }); + // Exactly one window after the degraded report it still counts. + const edge = new Map(); + accumulateGnssObservations(edge, [report('a1', true)], 0); + accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN); + assert.equal(entry(edge, 'a1').degradedAt, 0); +}); + +test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => { + const store = new Map(); + const snapshot = [report('a1', true), report('a2', false)]; + accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 }); + // The same snapshot replayed (cached or stale) 29 minutes later. + accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 }); + assert.equal(entry(store, 'a1').seenAt, 0); + assert.equal(entry(store, 'a1').degradedAt, 0); + accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 }); + assert.equal(store.size, 0, 'a replay older than the window adds nothing'); + + // An older replay never moves an entry's age backwards either. + accumulateGnssObservations(store, snapshot, 40 * MIN); + accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, { + observedAt: 35 * MIN, + }); + assert.equal(entry(store, 'a2').seenAt, 40 * MIN); + assert.equal(entry(store, 'a2').degradedAt, 35 * MIN); + + // A future time is clamped to now; an undated snapshot adds nothing. + accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, { + observedAt: 99 * MIN, + }); + assert.equal(entry(store, 'f1').seenAt, 50 * MIN); + for (const observedAt of [null, Number.NaN]) + accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, { + observedAt, + }); + assert.equal(entry(store, 'u1'), undefined); +}); + +test('pruning without new rows drops expired observations and expired degraded evidence', () => { + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + accumulateGnssObservations(store, [report('a1', false)], 20 * MIN); + accumulateGnssObservations(store, [report('a2', false)], 0); + assert.equal(pruneGnssObservations(store, 31 * MIN), store); + assert.deepEqual( + [...store.keys()].map((key) => key.split('|')[0]), + ['a1'], + ); + assert.equal(entry(store, 'a1').degradedAt, null); + pruneGnssObservations(store, 51 * MIN); + assert.equal(store.size, 0); + assert.equal(GNSS_WINDOW_MS, 30 * MIN); +}); + +test('rows without finite coordinates never key a cell', () => { + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true }, + { hex: 'n2', lat: 10.1, lon: '10.1', degraded: true }, + null, + ], + 0, + ); + assert.equal(store.size, 0); +}); + +test('cells below the minimum aircraft count are withheld', () => { + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'b1', lat: 10.1, lon: 10.1, degraded: true }, + { hex: 'b2', lat: 10.2, lon: 10.2, degraded: true }, + ], + 0, + ); + assert.deepEqual(binGnssCells(store), []); + const [cell] = binGnssCells(store, { minAircraft: 2 }); + assert.equal(cell.level, 'high'); + assert.equal(cell.percentDegraded, 50); +}); + +test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => { + const [row] = normalizeGnssAircraft({ + ac: [ + aircraft({ + type: 'mlat', + nac_p: 0, + nic: 0, + mlat: ['lat', 'lon', 'nic'], + gpsOkBefore: 1800000000, + gpsOkLat: 54.2, + gpsOkLon: 19.3, + }), + ], + }); + assert.ok(row); + assert.equal(row.lat, 54.2); + assert.equal(row.lon, 19.3); + assert.equal(row.nic, null); + assert.equal(row.degraded, true); + const [healthy] = normalizeGnssAircraft({ + ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })], + }); + assert.equal(healthy.degraded, false); +}); + +test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => { + const rows = normalizeGnssAircraft({ + ac: [ + aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }), + aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }), + aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }), + aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }), + aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }), + ], + }); + assert.deepEqual( + rows.map((r) => r.degraded), + [false, false, false, true, true], + ); +}); + +// Quality-field subsets captured from public adsb.lol US point responses on +// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors. +// Only field provenance is reused; the airborne ADS-B envelopes are synthetic. +test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => { + const tisbQuality = { + nic: 0, + nac_p: 0, + tisb: [ + 'altitude', + 'alt_baro', + 'gs', + 'track', + 'geom_rate', + 'lat', + 'lon', + 'nic', + 'rc', + 'nac_p', + 'nac_v', + 'sil', + 'sil_type', + ], + mlat: [], + }; + assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []); + const unknownContainment = { + nic: 0, + nac_p: 10, + tisb: ['callsign'], + mlat: [], + }; + assert.equal( + normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded, + false, + ); +}); diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js new file mode 100644 index 000000000..af67aa18b --- /dev/null +++ b/src/layers/gnss/source.js @@ -0,0 +1,76 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +function retryDelay(raw, nowMs) { + if (!raw) return null; + const seconds = Number(raw); + const ms = seconds * 1000; + if (Number.isFinite(ms) && seconds > 0) + return Math.min(3600000, Math.max(5000, ms)); + const at = Date.parse(raw); + return Number.isFinite(at) && at > nowMs + ? Math.min(3600000, Math.max(5000, at - nowMs)) + : null; +} + +/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */ +export function createAdsbGnssSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + return { + async getSnapshot({ latitude, longitude } = {}, { signal } = {}) { + signal?.throwIfAborted(); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) + throw new Error('GNSS snapshot needs a view anchor'); + const params = new URLSearchParams({ + lat: String(Math.round(latitude)), + lon: String(Math.round(longitude)), + }); + const response = await fetchImpl(`/api/gnss-integrity?${params}`, { + signal, + }); + if (!response.ok) { + const limited = response.status === 429; + const retryAfterMs = retryDelay( + response.headers.get('retry-after'), + now(), + ); + throw Object.assign( + new Error( + limited + ? 'GNSS feed rate limited (HTTP 429)' + : `GNSS feed unavailable (HTTP ${response.status})`, + ), + { + status: response.status, + retryable: limited || response.status >= 500, + retryAfterMs: retryAfterMs ?? (limited ? 60000 : null), + }, + ); + } + const payload = await readResponseJsonCapped( + response, + 4 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed GNSS integrity snapshot'); + // The snapshot's age (measured on the proxy's clock) dates every row in + // the rolling window; an undated snapshot could only be treated as new, + // so it is refused. + if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0) + throw new Error('GNSS integrity snapshot has no observation time'); + return { + rows: payload.rows, + ageMs: payload.ageMs, + fetchedAt: Number.isFinite(payload.fetchedAt) + ? payload.fetchedAt + : null, + stale: payload.stale === true, + classifier: + typeof payload.classifier === 'string' ? payload.classifier : null, + }; + }, + }; +} diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs new file mode 100644 index 000000000..3d27d15c6 --- /dev/null +++ b/src/layers/gnss/source.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAdsbGnssSource } from './source.js'; + +test('the source asks the same-origin proxy for the view anchor', async () => { + const calls = []; + const source = createAdsbGnssSource({ + fetchImpl: async (url, options) => { + calls.push(url); + assert.ok(options.signal instanceof AbortSignal); + return Response.json({ + fetchedAt: 5, + ageMs: 0, + classifier: 'gev-nic-nacp-v2', + rows: [{ hex: 'abc123' }], + }); + }, + }); + const snapshot = await source.getSnapshot( + { latitude: 50.06143, longitude: 19.93658 }, + { signal: new AbortController().signal }, + ); + assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']); + assert.deepEqual(snapshot, { + rows: [{ hex: 'abc123' }], + ageMs: 0, + fetchedAt: 5, + stale: false, + classifier: 'gev-nic-nacp-v2', + }); +}); + +test('the source reports stale proxy data and rejects bad input or responses', async () => { + const reply = (body, init) => async () => Response.json(body, init); + const stale = await createAdsbGnssSource({ + fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }), + }).getSnapshot({ latitude: 1, longitude: 2 }); + assert.equal(stale.stale, true); + assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age'); + assert.equal(stale.fetchedAt, 7); + assert.equal(stale.classifier, null); + // Without an observation age the rows could only be dated as new. + for (const ageMs of [undefined, null, '7', -1, Number.NaN]) + await assert.rejects( + createAdsbGnssSource({ + fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }), + }).getSnapshot({ latitude: 1, longitude: 2 }), + /no observation time/, + ); + + await assert.rejects( + createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}), + /view anchor/, + ); + await assert.rejects( + createAdsbGnssSource({ + fetchImpl: reply({ error: 'x' }, { status: 502 }), + }).getSnapshot({ latitude: 1, longitude: 2 }), + /HTTP 502/, + ); + await assert.rejects( + createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({ + latitude: 1, + longitude: 2, + }), + /Malformed/, + ); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot( + { latitude: 1, longitude: 2 }, + { signal: controller.signal }, + ), + ); +}); + +test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => { + const at = Date.UTC(2026, 9, 13, 12); + for (const [status, header, delay, retryable] of [ + [429, '3600', 3600000, true], + [429, new Date(at + 3600000).toUTCString(), 3600000, true], + [429, null, 60000, true], + [429, 'Infinity', 60000, true], + [502, '5', 5000, true], + [502, null, null, true], + [400, null, null, false], + ]) { + const source = createAdsbGnssSource({ + now: () => at, + fetchImpl: async () => + Response.json( + {}, + { status, headers: header ? { 'Retry-After': header } : {} }, + ), + }); + await assert.rejects( + source.getSnapshot({ latitude: 50, longitude: 20 }), + (e) => { + assert.equal(e.status, status); + assert.equal(e.retryAfterMs, delay); + assert.equal(e.retryable, retryable); + assert.match( + e.message, + status === 429 ? /rate limited/ : /feed unavailable/, + ); + return true; + }, + ); + } +}); diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index 36d5ebde4..f375e5419 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -6,6 +6,7 @@ export { ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; import * as Cesium from 'cesium'; +import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; /** * @module militaryFlights @@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); +/** @constant {Cesium.Color} Red tint for aircraft broadcasting an emergency (mirror of flights) */ + +export const EMERGENCY_ICON_COLOR = + Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS); + // --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- // adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate // boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..ee11f811f 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -9,6 +9,7 @@ import { CONTACT_MATCH_TIER, } from '../../data/contactMatch.js'; import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -133,6 +134,10 @@ export function createQueries({ flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, ), + // This poll's transponder broadcast (not sticky; see records.js). + squawk: info?.squawk ?? null, + emergency: info?.emergency ? { ...info.emergency } : null, + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), }; } @@ -156,7 +161,9 @@ export function createQueries({ * lon: number|null, altitudeM: number|null, speedMps: number|null, * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: null, - * operator: string|null, routeOrigin: null, routeDestination: null}} + * operator: string|null, squawk: string|null, emergency: boolean, + * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, + * emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: null, routeDestination: null}} */ function mapAnalystRecord(icao24, info) { @@ -187,6 +194,20 @@ export function createQueries({ aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), originCountry: null, operator: text(info?.operator), + squawk: text(info?.squawk), + // True while this poll broadcast an emergency (7500/7600/7700 or the + // ADS-B status); priority statuses (lifeguard, minfuel) report the kind + // without the flag. + emergency: info?.emergency?.severity === 'emergency', + emergencyKind: text(info?.emergency?.kind), + // Provenance, so a summary can say "broadcasting squawk 7700" rather + // than "ADS-B reports general emergency" (or flatten both): 'squawk' + // when the Mode A code names the status, 'ads-b' for the readsb field. + emergencySource: text(info?.emergency?.source), + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), + // Latest known general aircraft-message contact, retained if a poll + // has no time. This is not the receipt time of the current status. + lastContactAtMs: num(info?.lastContactEpochMs), routeOrigin: null, routeDestination: null, }; diff --git a/src/layers/military/records.js b/src/layers/military/records.js index cbc62be79..9cd6754e6 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -1,6 +1,7 @@ import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; +import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { GROUND_FLOOR_WARM_MAX_ALT_M, LANDED_MISSING_POLL_LIMIT, @@ -161,6 +162,9 @@ export class MilitaryFlightRecords { null, ), turnRateDps: prevMeta?.turnRateDps || 0, + // Per poll, NOT sticky (mirror of flights): a cleared code clears here. + squawk: aircraft.squawk ?? null, + emergency: aircraftEmergency(aircraft), onGround, // Round 7: sticky airborne history (see _likelyLanded). wasAirborne: prevMeta?.wasAirborne === true || !onGround, diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index df42760ce..5054fb28a 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only 'remove', ); }); + +test('military emergency status follows the latest poll instead of sticking', () => { + const records = store(); + const first = records.receive( + observation({ squawk: '1234', emergency: 'minfuel' }), + context(), + ); + assert.equal(first.meta.squawk, '1234'); + assert.equal(first.meta.emergency.kind, 'minfuel'); + assert.equal(first.meta.emergency.severity, 'priority'); + const next = records.receive( + observation({ squawk: '1234', emergency: 'none' }), + context(), + ); + assert.equal(next.meta.emergency, null); +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 9ebd03180..089ae9ec8 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -18,6 +18,7 @@ import { screenProjectedRotation, } from '../../data/iconOrientation.js'; import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { isEmergency } from '../../data/aircraftEmergency.js'; import { PLANE_MODEL_SCALE, PLANE_MODEL_URL, @@ -33,6 +34,7 @@ import { COCKPIT_CONTACT_SIZE_PX, MIL_ICON_COLOR, TRACKED_ICON_COLOR, + EMERGENCY_ICON_COLOR, MODEL_ALT_CEIL_M, MODEL_MAX_ALL, MODEL_MAX, @@ -159,7 +161,7 @@ export function createRendering({ bb.height = COCKPIT_CONTACT_SIZE_PX; bb.scale = limbScale; bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); + bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha); bb.rotation = 0; return; } @@ -173,7 +175,9 @@ export function createRendering({ bb.height = icao24 === flightState._trackedIcao ? 24 : 20; bb.scale = _militaryBillboardScale(icao24) * limbScale; bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb)); + bb.color = _fleetBillboardColor(icao24, meta).withAlpha( + cyberSonarBaseAlpha(bb), + ); } /** Sprite kind for one contact's billboard. Identity for every aircraft except @@ -189,7 +193,17 @@ export function createRendering({ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; - return MIL_ICON_COLOR; + return _fleetBillboardColor(icao24); + } + + /** Fleet (untracked) tint: red while the latest poll broadcast an emergency, + * amber otherwise. Cockpit far-contact dots use it too. */ + + function _fleetBillboardColor( + icao24, + info = flightState.records.data.get(icao24), + ) { + return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR; } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -965,7 +979,7 @@ export function createRendering({ const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); - const baseColor = MIL_ICON_COLOR; + const baseColor = _fleetBillboardColor(icao24, info); const treatment = applyAircraftBillboardTreatment({ billboard: bb, baseScale: @@ -1082,6 +1096,7 @@ export function createRendering({ _applyFleetBillboardPresentation, _iconKind, _modelColor, + _fleetBillboardColor, _modelRegimeActive, _modelCap, _modelAddDistM, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 58eb57972..6d619bf72 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { turnRateFromFixHistory, @@ -7,12 +8,7 @@ import { } from '../../data/motionModel.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; -import { - BILLBOARD_SCALE, - GROUND_SCALE, - TRACKED_ICON_COLOR, - MIL_ICON_COLOR, -} from './policy.js'; +import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js'; /** Apply military record changes to Cesium resources and existing follow operations. */ export function createMilitarySnapshotRenderer({ @@ -125,6 +121,11 @@ export function createMilitarySnapshotRenderer({ if (flightState._billboards.has(icao24)) { const bb = flightState._billboards.get(icao24); + if (!isTracked) { + bb.color = rendering + ._fleetBillboardColor(icao24, meta) + .withAlpha(cyberSonarBaseAlpha(bb)); + } // Position AND rotation are owned by the fleet pass (_fleetTick); // course changes land on the next rotation pass (forced below). // Ground flips (landing/takeoff) restyle this SAME billboard in @@ -161,7 +162,9 @@ export function createMilitarySnapshotRenderer({ // Screen-projected rotation lands on the next fleet tick. rotation: 0, alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + color: isTracked + ? TRACKED_ICON_COLOR + : rendering._fleetBillboardColor(icao24, meta), sizeInMeters: false, scaleByDistance: rendering._normalBillboardScaleByDistance(), // Grounded/near-surface planes sit at/below the photoreal tile diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index b0cfb0368..5723ac39e 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -21,7 +21,6 @@ import { TRAIL_COLOR, BILLBOARD_SCALE, GROUND_SCALE, - MIL_ICON_COLOR, AMBER_TRANSPARENT, TRACKED_ICON_COLOR, } from './policy.js'; @@ -780,12 +779,16 @@ export function createTracking({ bb.width = 20; bb.height = 20; // Ground-aware restore (a plane untracked while taxiing comes back at - // ground scale; tint is full amber on the ground and in the air). + // ground scale; the fleet tint is amber, or red while broadcasting an + // emergency, on the ground and in the air). bb.scale = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); - bb.color = MIL_ICON_COLOR; + bb.color = parts.rendering._fleetBillboardColor( + flightState._trackedIcao, + meta, + ); bb.rotation = flightState._lastTrackedRotation; } flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs new file mode 100644 index 000000000..698bcaee5 --- /dev/null +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + boxCentre, + boxFromRectangle, + dequantizeBox, + validateBox, +} from './model.js'; +import { searchHls } from './catalog.js'; + +test('missing imagery edges do not turn into invented zero coordinates', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + const box = { west: value, south: 0, east: 1, north: 1 }; + assert.equal(validateBox(box).ok, false); + assert.equal(validateBox(box).reason, 'invalid'); + assert.equal(boxCentre(box), null); + } + assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true); + assert.equal( + validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok, + true, + ); +}); + +test('every edge and every box entry point refuses a missing edge', async () => { + const blanks = [null, undefined, '', ' ', false, [], {}]; + for (const edge of ['west', 'south', 'east', 'north']) { + for (const value of blanks) { + const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value }; + assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`); + // Share-link integers and Cesium radians are converted per edge. + assert.equal(dequantizeBox(box), null, `share ${edge}`); + assert.equal(boxFromRectangle(box), null, `radians ${edge}`); + } + } + let fetched = false; + await assert.rejects( + searchHls({ + box: { west: ' ', south: 1, east: 2, north: 2 }, + fetchImpl: async () => { + fetched = true; + }, + }), + TypeError, + ); + assert.equal(fetched, false); + assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 })); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 23f503e9e..24ef54fc6 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -75,6 +75,14 @@ const pad2 = (value) => String(value).padStart(2, '0'); * east are not swapped: west > east crosses the dateline and `validateBox` * rejects it. Null when any edge is nonfinite. */ +/** A box edge as a number, or null when it is missing, blank or not numeric. */ +function edgeNumber(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string' || !value.trim()) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + function normalizeBox(box) { if (!box || typeof box !== 'object') return null; const [west, south, east, north] = [ @@ -82,8 +90,8 @@ function normalizeBox(box) { box.south, box.east, box.north, - ].map(Number); - if (![west, south, east, north].every(Number.isFinite)) return null; + ].map(edgeNumber); + if ([west, south, east, north].some((edge) => edge == null)) return null; return { west, south: Math.min(south, north), @@ -211,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) { function mapBox(box, fn) { if (!box || typeof box !== 'object') return null; + // Converting first would turn a blank edge into 0 before normalizeBox sees it. + const edge = (value) => { + const number = edgeNumber(value); + return number == null ? null : fn(number); + }; return normalizeBox({ - west: fn(box.west), - south: fn(box.south), - east: fn(box.east), - north: fn(box.north), + west: edge(box.west), + south: edge(box.south), + east: edge(box.east), + north: edge(box.north), }); } diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs new file mode 100644 index 000000000..52e7b6aea --- /dev/null +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tilesForBbox } from './tileMath.js'; + +test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => { + // coverage uses the actual view bbox above zoom 1; polar views can wrap. + const bbox = [-10, 60, -20, 85]; + const result = tilesForBbox(bbox, 3); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(keys.length, 24); + assert.equal(new Set(keys).size, 24); + assert.equal(result.total, 24); + assert.equal(result.truncated, false); + const overview = tilesForBbox(bbox, 3, { limit: 16 }); + assert.equal(overview.total, 24); + assert.equal(overview.tiles.length, 16); + assert.equal( + new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size, + 16, + ); + assert.equal(overview.truncated, true); +}); + +test('a limit that fits every distinct tile is not spent on repeats', () => { + // Before de-duplication this box yielded 27 entries for 24 tiles, so a + // 24-slot overview held repeats and still reported itself truncated. + const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 }); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(new Set(keys).size, 24); + assert.equal(result.truncated, false); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index fe3c3ec18..350188200 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -146,8 +146,16 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { const dx = Math.abs(tile.x - centre.x); return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; - // Each tile's distance is computed once; ties keep their row order. - const ranked = tiles + // The date-line halves share a column when the gap between east and west is + // narrower than a tile; count that column once so a capped overview does not + // spend its slots on repeats. Each tile's distance is computed once; ties keep + // their row order. + const unique = [ + ...new Map( + tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), + ).values(), + ]; + const ranked = unique .map((tile) => ({ tile, d: distance2(tile) })) .sort((a, b) => a.d - b.d) .map(({ tile }) => tile); diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..2b2ea462f 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -131,7 +131,8 @@ export class MapSourceController { * @returns {() => void} Unsubscribe. */ subscribe(listener) { - if (typeof listener !== 'function') return () => {}; + // A destroyed controller never settles again, so it takes no listeners. + if (typeof listener !== 'function' || this._destroyed) return () => {}; this._subscribers.add(listener); return () => { this._subscribers.delete(listener); @@ -142,6 +143,8 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed) return; + if (!this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { diff --git a/src/maps/imagery.js b/src/maps/imagery.js index 32fd3f375..d151b6ce6 100644 --- a/src/maps/imagery.js +++ b/src/maps/imagery.js @@ -25,5 +25,42 @@ export function createEsriImagery() { export function createIonImagery(style, accessToken) { accessToken = String(accessToken || '').trim(); if (!accessToken) throw new Error('Ion imagery requires an explicit token'); + if ( + style === Cesium.IonWorldImageryStyle.AERIAL || + style === Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS + ) { + return createBingIonImagery(style, accessToken); + } return Cesium.IonImageryProvider.fromAssetId(style, { accessToken }); } + +async function createBingIonImagery(style, accessToken) { + const endpoint = await new Cesium.Resource({ + url: `https://api.cesium.com/v1/assets/${style}/endpoint`, + queryParameters: { access_token: accessToken }, + }).fetchJson(); + if ( + endpoint?.type !== 'IMAGERY' || + endpoint.externalType !== 'BING' || + typeof endpoint.options?.url !== 'string' + ) { + throw new Error('Ion world imagery did not return a Bing imagery endpoint'); + } + // Bing otherwise follows the page's HTTP scheme on localhost. Resolve its + // ion-issued credentials through public APIs so tiles use HTTPS without + // weakening CSP, changing SDK defaults or touching provider internals. + const { url, ...options } = endpoint.options; + const provider = await Cesium.BingMapsImageryProvider.fromUrl(url, { + ...options, + tileProtocol: 'https', + }); + const ionCredits = (endpoint.attributions ?? []).map( + ({ html, collapsible }) => new Cesium.Credit(html, collapsible === false), + ); + const getTileCredits = provider.getTileCredits.bind(provider); + provider.getTileCredits = (x, y, level) => [ + ...ionCredits, + ...(getTileCredits(x, y, level) ?? []), + ]; + return provider; +} diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs new file mode 100644 index 000000000..cf0708c09 --- /dev/null +++ b/src/maps/map-subscription-ownership.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapSourceController } from './controller.js'; +function fixture() { + return new MapSourceController( + { + scene: { + globe: { show: true }, + requestRender() {}, + frameState: { + creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} }, + }, + primitives: { add() {}, remove() {} }, + }, + imageryLayers: { add() {}, remove() {} }, + }, + { + registry: { + defaultId: 'fixture', + sources: [ + { + descriptor: { id: 'fixture', label: 'Fixture', kind: 'imagery' }, + imagery: async () => ({}), + }, + ], + }, + createImageryLayer: (provider) => ({ provider }), + }, + ); +} +test('an unsubscribed owner is skipped in the same map settlement delivery', async () => { + const controller = fixture(); + const calls = []; + let detach; + controller.subscribe(() => { + calls.push('first'); + detach(); + }); + detach = controller.subscribe(() => calls.push('detached')); + controller.subscribe(() => calls.push('last')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'last']); + controller.destroy(); +}); +test('destroying the controller during delivery stops remaining listeners', async () => { + const controller = fixture(); + const calls = []; + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + }); + controller.subscribe(() => calls.push('detached')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['destroy']); +}); + +test('a listener re-added after destroy during delivery is not called', async () => { + const controller = fixture(); + const calls = []; + const later = () => calls.push('later'); + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + controller.subscribe(later); + }); + controller.subscribe(later); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['destroy']); +}); + +test('delivery order: added during delivery waits, duplicates run once, re-added runs', async () => { + const controller = fixture(); + const calls = []; + const twice = () => calls.push('twice'); + const readded = () => calls.push('readded'); + let offReadded; + controller.subscribe(() => { + calls.push('first'); + controller.subscribe(() => calls.push('added-late')); + offReadded(); + controller.subscribe(readded); + }); + controller.subscribe(twice); + controller.subscribe(twice); + offReadded = controller.subscribe(readded); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'twice', 'readded']); + controller.destroy(); +}); diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 15571666d..d3aaa5068 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -89,7 +89,7 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul calls.push({ kind: 'terrain', resource, options }); return { id: 'terrain' }; }; - await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token'); + await createIonImagery(12345, 'imagery-token'); const result = await createWorldTerrain('terrain-token'); assert.equal(calls[0].options.accessToken, 'imagery-token'); assert.equal(calls[1].options.accessToken, 'terrain-token'); @@ -104,6 +104,162 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul } }); +test('both Bing ion stacks use HTTPS and retain ion and per-tile attribution', async (t) => { + const defaultToken = Cesium.Ion.defaultAccessToken; + const metadataCalls = []; + const bingCalls = []; + const perTileCalls = []; + const bingCredit = new Cesium.Credit('Bing tile credit', true); + const endpoint = { + type: 'IMAGERY', + externalType: 'BING', + options: Object.freeze({ + url: 'https://dev.virtualearth.net', + key: 'ion-issued-bing-fixture', + mapStyle: 'Aerial', + tileProtocol: 'http', + }), + attributions: [ + { html: 'Ion required credit', collapsible: false }, + { html: 'Ion optional credit', collapsible: true }, + ], + }; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async function () { + metadataCalls.push({ + url: this.url, + token: this.queryParameters.access_token, + }); + const mapStyle = this.url.includes( + `/assets/${Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS}/`, + ) + ? 'AerialWithLabels' + : 'Aerial'; + return { + ...endpoint, + options: Object.freeze({ ...endpoint.options, mapStyle }), + }; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing must not inherit the local HTTP page protocol'), + ); + t.mock.method( + Cesium.BingMapsImageryProvider, + 'fromUrl', + async (url, options) => { + bingCalls.push({ url, options }); + const provider = { + errorEvent: new Cesium.Event(), + getTileCredits(x, y, level) { + assert.equal(this, provider); + perTileCalls.push([x, y, level]); + return level === 0 ? undefined : [bingCredit]; + }, + }; + return provider; + }, + ); + for (const style of [ + Cesium.IonWorldImageryStyle.AERIAL, + Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS, + ]) { + const provider = await createIonImagery(style, ' own-ion-token '); + const credits = provider.getTileCredits(3, 4, 5); + assert.deepEqual( + credits.map(({ html, showOnScreen }) => ({ html, showOnScreen })), + [ + { html: 'Ion required credit', showOnScreen: true }, + { html: 'Ion optional credit', showOnScreen: false }, + { html: 'Bing tile credit', showOnScreen: true }, + ], + ); + assert.equal(credits[2], bingCredit); + assert.equal(provider.getTileCredits(0, 0, 0).length, 2); + assert.ok(provider.errorEvent instanceof Cesium.Event); + assert.equal(metadataCalls.at(-1).token, 'own-ion-token'); + const requested = new URL(metadataCalls.at(-1).url); + assert.equal(requested.origin, 'https://api.cesium.com'); + assert.equal(requested.pathname, `/v1/assets/${style}/endpoint`); + } + assert.deepEqual(perTileCalls, [ + [3, 4, 5], + [0, 0, 0], + [3, 4, 5], + [0, 0, 0], + ]); + assert.equal(bingCalls.length, 2); + for (const [index, { url, options }] of bingCalls.entries()) { + assert.equal(url, 'https://dev.virtualearth.net'); + assert.deepEqual(options, { + key: 'ion-issued-bing-fixture', + mapStyle: index === 0 ? 'Aerial' : 'AerialWithLabels', + tileProtocol: 'https', + }); + } + assert.equal(endpoint.options.tileProtocol, 'http'); + assert.equal(Cesium.Ion.defaultAccessToken, defaultToken); +}); + +test('Bing ion metadata and construction failures propagate without another source', async (t) => { + const style = Cesium.IonWorldImageryStyle.AERIAL; + const metadataError = new Error('ion metadata fixture failure'); + const providerError = new Error('Bing construction fixture failure'); + let metadata = () => { + throw metadataError; + }; + let bingCalls = 0; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async () => metadata()); + t.mock.method(Cesium.BingMapsImageryProvider, 'fromUrl', async () => { + bingCalls += 1; + throw providerError; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing failures must not silently use an alternate provider'), + ); + await assert.rejects(createIonImagery(style, 'test-token'), metadataError); + for (const endpoint of [ + undefined, + { + type: 'TERRAIN', + externalType: 'BING', + options: { url: 'https://example.test' }, + }, + { + type: 'IMAGERY', + externalType: 'OTHER', + options: { url: 'https://example.test' }, + }, + { type: 'IMAGERY', externalType: 'BING', options: {} }, + ]) { + metadata = () => endpoint; + await assert.rejects( + createIonImagery(style, 'test-token'), + /Bing imagery endpoint/, + ); + } + assert.equal(bingCalls, 0); + metadata = () => ({ + type: 'IMAGERY', + externalType: 'BING', + options: { url: 'https://dev.virtualearth.net', key: 'bing-fixture' }, + }); + await assert.rejects(createIonImagery(style, 'test-token'), providerError); + assert.equal(bingCalls, 1); +}); + +test('other ion imagery assets keep the standard SDK factory', async (t) => { + const expected = {}; + const calls = []; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', () => + assert.fail('generic assets must use the SDK ion factory'), + ); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', async (...args) => { + calls.push(args); + return expected; + }); + assert.equal(await createIonImagery(12345, ' custom-token '), expected); + assert.deepEqual(calls, [[12345, { accessToken: 'custom-token' }]]); +}); + test('cancellation after ion metadata prevents terrain construction', async () => { const originalResource = Cesium.IonResource.fromAssetId; const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl; diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs new file mode 100644 index 000000000..4414f3c93 --- /dev/null +++ b/src/qaChrome.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; + +test('gates keep macOS Chrome where it is installed', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }), + MACOS_CHROME, + ); +}); + +test('gates fall back to Puppeteer’s browser without macOS Chrome', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: () => false }), + undefined, + ); +}); + +test('an explicit executable path wins', () => { + assert.equal( + chromeExecutablePath({ + env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' }, + exists: () => true, + }), + 'C:\\Chrome\\chrome.exe', + ); +}); diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs index 95b1eb5f2..f8078dbe5 100644 --- a/src/sdr/controller.test.mjs +++ b/src/sdr/controller.test.mjs @@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn /** Resolve with the promise's value, or 'timed out' after `ms`. */ function within(promise, ms = 1_000) { - return Promise.race([ - promise, - new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)), - ]); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve('timed out'), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } /** diff --git a/src/search/coordinate-empty-components.test.mjs b/src/search/coordinate-empty-components.test.mjs new file mode 100644 index 000000000..48bca54ed --- /dev/null +++ b/src/search/coordinate-empty-components.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCoordinateQuery } from './coordinateParser.js'; +test('search refuses omitted components and repeated separators', () => { + for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97']) + assert.equal(parseCoordinateQuery(q), null, q); + for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97']) + assert.deepEqual( + [parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon], + [30, -97], + ); +}); diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 261e2394c..cb8c85524 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -49,8 +49,8 @@ function splitComponents(query) { const parts = /[,;]/.test(text) ? text.split(/[,;]/) : text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i); - const trimmed = parts.map((part) => part.trim()).filter(Boolean); - return trimmed.length === 2 ? trimmed : null; + const trimmed = parts.map((part) => part.trim()); + return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; } /** Present a parsed pair the way a coordinate is normally written. */ diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index a6502aeb5..f84a4413f 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -272,28 +272,26 @@ export function createTerrainHeights({ const uncached = []; const seenKeys = new Set(); const now = Date.now(); + // What this call resolved or found warm, so the assembly below can report a + // point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. + // Without it a batch big enough to evict its own entries would report + // points as unresolved that it had in fact resolved. + const resolvedThisCall = new Map(); for (const item of work) { - const entry = cache.get(item.key); + const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && now < entry.retryAt; - if ( - (entry && entry.source === 'reearth') || - fallbackCooling || - seenKeys.has(item.key) - ) + if ((entry && entry.source === 'reearth') || fallbackCooling) { + resolvedThisCall.set(item.key, entry); continue; + } + if (seenKeys.has(item.key)) continue; seenKeys.add(item.key); uncached.push(item); } - // What this call resolved, so the assembly below can report a point even - // if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a - // batch big enough to evict its own earlier chunks would report points as - // unresolved that it had in fact just resolved. - const resolvedThisCall = new Map(); - // Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's // failure is isolated to that chunk's points (geoid fallback), so a single // bad chunk doesn't lose results for the rest of a large batch. diff --git a/src/services/terrainHeights.test.mjs b/src/services/terrainHeights.test.mjs index c9884e89c..5e80b9be2 100644 --- a/src/services/terrainHeights.test.mjs +++ b/src/services/terrainHeights.test.mjs @@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved' assert.equal(warm.length, 4); }); +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); + +test('a batch still reports its warm points after its own fetches evict them', async () => { + const terrain = createTerrainHeights({ + source: stubSource(), + maxCacheEntries: 2, + }); + await terrain.resolveEllipsoidalGround([pointAt(0)]); + + const results = await terrain.resolveEllipsoidalGround([ + pointAt(0), + pointAt(1), + pointAt(2), + ]); + + results.forEach((result, i) => { + assert.equal(result.source, 'reearth', `point ${i} reported resolved`); + assert.equal(result.ellipsoid, heightAt(lonAt(i))); + }); +}); + test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => { for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) { const terrain = createTerrainHeights({ diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 9534a5c3c..ebc27f559 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -1,3 +1,4 @@ +import { normalizeSquawk } from '../../data/aircraftEmergency.js'; import { admitRecords, cleanText, @@ -30,6 +31,9 @@ export function normalizeOpenSkyAircraft(row) { typeCode: null, registration: null, operator: null, + squawk: normalizeSquawk(row[14]), + // State vectors carry no ADS-B emergency field; the squawk stands alone. + emergency: undefined, }; } @@ -68,6 +72,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { typeCode: cleanText(row.t), registration: cleanText(row.r), operator: cleanText(row.ownOp), + squawk: normalizeSquawk(row.squawk), + emergency: cleanText(row.emergency).toLowerCase() || null, }; } @@ -82,7 +88,13 @@ export function openSkySnapshot( ) { const admitted = admitRecords( payload?.states, - normalizeOpenSkyAircraft, + (row) => { + const record = normalizeOpenSkyAircraft(row); + const statuses = payload?.emergencyByIcao; + if (record && statuses && Object.hasOwn(statuses, record.id)) + record.emergency = cleanText(statuses[record.id]).toLowerCase() || null; + return record; + }, 'OpenSky', ); const observedAtMs = epoch(payload?.time, 1000); diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 391fe1e72..1bba6e28f 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation' name: 'AbortError', }); }); + +test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => { + const squawking = [...aircraft, '7700']; + const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0]; + assert.equal(civil.squawk, '7700'); + assert.equal(civil.emergency, undefined); + assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null); + const mil = normalizeReadsbAircraft( + { hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' }, + now, + ); + assert.equal(mil.squawk, '7600'); + assert.equal(mil.emergency, 'nordo'); + const quiet = normalizeReadsbAircraft( + { hex: 'abc123', lat: 30, lon: -97 }, + now, + ); + assert.equal(quiet.squawk, null); + assert.equal(quiet.emergency, null); +}); diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..8631d3ad2 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,6 +1,7 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; +import { createAdsbGnssSource } from '../layers/gnss/source.js'; /** Construct the existing reference feeds independently of application setup. */ export function createReferenceSources() { @@ -8,5 +9,6 @@ export function createReferenceSources() { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), + 'gnss-integrity': createAdsbGnssSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..9f9cc9d92 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -16,12 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s 'earthquakes', 'fire-perimeters', 'cables', + 'gnss-integrity', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); + assert.notEqual(first['gnss-integrity'], second['gnss-integrity']); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); + assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function'); assert.equal(requests, 0); }); diff --git a/src/styles/snow.js b/src/styles/snow.js index e2ada6ad7..b23f9bd71 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -29,11 +29,13 @@ export const snowShader = { float depth = 0.5 + layer * 0.5; float speed = 0.4 + layer * 0.3; float size = mix(0.01, 0.025, layer); - float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2); + float windForce = 0.05 + wind * 0.2; vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0); snowUV.y += time * speed; - snowUV.x += time * windForce; + // Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce, + // so the sway stays bounded however long the app has been running. + snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce; vec2 cell = floor(snowUV); vec2 f = fract(snowUV); diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index d6eb80e20..04c24b92d 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs'; test('data providers have both hooks; credential editing stays development-only', () => { const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); + assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); @@ -103,6 +104,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/adsbdb/type/invalid', 400], ['/api/firms/status', 200], ['/api/fire-perimeters/inciweb/publication/invalid', 400], + ['/api/gnss-integrity?lat=invalid', 400], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/overpass/status', 200], diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..f9af7af3d 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { - return Number.isFinite(ms) ? new Date(ms).toISOString() : null; + if (!Number.isFinite(ms)) return null; + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; } /** "1 earthquake", "3 earthquakes". */ diff --git a/src/tools/schema.js b/src/tools/schema.js index 7c40c3eb5..f857825a1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,10 +106,20 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - if (schema.minLength != null && value.length < schema.minLength) - fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && value.length > schema.maxLength) - fail(`must be at most ${schema.maxLength} characters`); + if (schema.minLength != null || schema.maxLength != null) { + // Code points, as JSON Schema counts. Counting stops once both bounds + // are decided, so a huge argument costs no more than its limit. + const limit = Math.max( + schema.minLength ?? 0, + schema.maxLength != null ? schema.maxLength + 1 : 0, + ); + let length = 0; + for (const _ of value) if (++length >= limit) break; + if (schema.minLength != null && length < schema.minLength) + fail(`must be at least ${schema.minLength} characters`); + if (schema.maxLength != null && length > schema.maxLength) + fail(`must be at most ${schema.maxLength} characters`); + } if ( schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value) diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs new file mode 100644 index 000000000..27af78fa3 --- /dev/null +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateValue } from './schema.js'; +test('tool string schemas count Unicode characters rather than UTF-16 units', () => { + const value = '𠮷野家😀'; + assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []); + assert.equal( + validateValue({ type: 'string', minLength: 5 }, value).length, + 1, + ); + assert.equal( + validateValue({ type: 'string', maxLength: 3 }, value).length, + 1, + ); + assert.deepEqual( + validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'), + [], + ); + assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); +}); + +test('tool string bounds hold at their edges and on huge arguments', () => { + const string = (bounds) => ({ type: 'string', ...bounds }); + assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []); + assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1); + assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []); + assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []); + // Counting all of it takes roughly 0.7 s; stopping at the limit takes none. + const huge = 'x'.repeat(1e8); + const started = performance.now(); + assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1); + assert.ok(performance.now() - started < 200, 'stops counting past the limit'); +}); diff --git a/src/tools/tool-timestamp-range.test.mjs b/src/tools/tool-timestamp-range.test.mjs new file mode 100644 index 000000000..7aeee737b --- /dev/null +++ b/src/tools/tool-timestamp-range.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isoTime } from './results.js'; +import { getBikeShare } from './queries/mobility.js'; +test('out-of-range finite timestamps are unknown rather than throwing', async () => { + for (const value of [1e30, -1e30, NaN, Infinity, null]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z'); + assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z'); + assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z'); + for (const value of [8640000000000001, -8640000000000001, -Infinity]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z'); + const station = { + station_id: 's1', + name: 'Fixture', + lat: 0, + lon: 0, + capacity: 4, + }; + const result = await getBikeShare.run( + { area: { bbox: [-1, -1, 1, 1] } }, + { + services: { + bikeshare: { + systems: [ + { + id: 'fixture', + city: 'Fixture', + centerLat: 0, + centerLon: 0, + loadRadiusKm: 30, + stationInformationUrl: 'information', + stationStatusUrl: 'status', + }, + ], + async getStations(url) { + return { + data: { + stations: + url === 'information' + ? [station] + : [ + { + station_id: 's1', + last_reported: 1e30, + num_bikes_available: 2, + }, + ], + }, + }; + }, + }, + }, + }, + ); + assert.equal(result.data.returned, 1); + assert.equal(result.data.rows[0].last_reported, null); +}); diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index e4814517d..437aa9b26 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -12,6 +12,7 @@ import { compassDivisions, formatAltitudeRulerTick, formatCompassDivision, + formatCockpitEmergency, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -133,7 +134,15 @@ export function updateHud( : info.stale ? 'STALE FEED' : 'LIVE TRACK'; - this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; + const fleet = info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'; + const emergency = formatCockpitEmergency(info.emergency); + this.aircraftMeta.textContent = emergency + ? `${fleet} · ${emergency} · ${feedState}` + : `${fleet} · ${feedState} · COURSE ALIGNED`; + this.aircraftMeta.classList.toggle( + 'emergency', + info.emergency?.severity === 'emergency', + ); } this.updateRoute(info); if ( diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 227cc41e1..6fe0fe8d5 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -49,7 +49,13 @@ const PANEL_GROUPS = [ }, { label: 'Events', - ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'], + ids: [ + 'rocket-launches', + 'earthquakes', + 'local-firms', + 'fire-perimeters', + 'gnss-integrity', + ], }, { label: 'Weather', diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..7015c89e2 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -704,6 +704,51 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', () } }); +test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => { + const f = fixture('right'); + const weather = element('weather-panel', { height: 300 }); + weather.computed.display = 'none'; + f.stack.children.push(weather); + weather.parentElement = f.stack; + + f.run(); + + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + assert.equal(f.stack.dataset.expandedCount, '0'); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.second.getAttribute('aria-hidden'), undefined); + assert.equal( + weather.style.getPropertyValue('--right-panel-allocated-height'), + '', + ); +}); + +test('right layout still restores panels that exclusive presentation hides', () => { + const f = fixture('right'); + // Model layers.css: an exclusive rail hides its collapsed panels. + f.options.windowRef.getComputedStyle = (node) => ({ + ...node.computed, + display: + f.stack.classList.contains('layout-exclusive') && + node.classList.contains('collapsed') + ? 'none' + : node.computed.display, + }); + f.expand(f.first, 250); + f.second.classList.add('layout-auto-collapsed'); + f.stack.classList.add('layout-exclusive'); + + f.run(); + assert.equal(f.stack.classList.contains('layout-exclusive'), true); + assert.equal(f.second.getAttribute('aria-hidden'), 'true'); + + f.options.hud.visible = false; + f.run(); + assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('layout-auto-collapsed'), false); + assert.deepEqual(f.collapsed, ['second']); +}); + test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { const f = fixture('right'); const imagery = element('recent-imagery-panel', { height: 0 }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3b85bb896..be49999fd 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -69,10 +69,17 @@ export function layoutRightPanelRail({ // A collapse may schedule one follow-up, which only measures and allocates. const isCollapseRetry = pendingCollapseRetries.delete(stack); - // A floating (lifted-out) panel is not laid out by the rail. + // A floating (lifted-out) panel is not laid out by the rail. Neither is one + // that other CSS hides, such as an event view. Exclusive presentation hides + // collapsed panels itself; those stay in the rail so they can be restored. + const hiddenByRail = (panel) => + stack.classList.contains('layout-exclusive') && + panel.classList.contains('collapsed'); const panels = [...stack.children].filter( (panel) => - panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden, + panel.matches('[data-panel-id]:not(.panel-floating)') && + !panel.hidden && + (hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index 8436c3a41..bed0c1772 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -43,6 +43,8 @@ #cockpit-hud { --cockpit-accent: #00dcff; + /* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */ + --cockpit-emergency: #ff4444; --cockpit-side-panel-width: min(340px, 28vw); /* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */ --cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px); @@ -496,6 +498,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) font-size: 7px; letter-spacing: 0.16em; } +.cockpit-aircraft-meta.emergency { + color: var(--cockpit-emergency); + font-weight: 600; +} .cockpit-mode-chip { margin-top: 2px; padding: 7px 11px; diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 5a25bfec8..c8f5b139f 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -2270,6 +2270,12 @@ color: rgba(205, 225, 226, 0.48); } +:root[data-ui-theme='cyber'] + body.cockpit-mode + .cockpit-aircraft-meta.emergency { + color: var(--cyber-red-bright); +} + :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls { margin-top: 0; border-color: rgba(105, 203, 209, 0.38); diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 002dcb796..80d9091e0 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -1,14 +1,40 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ALLOCATION_TEST_FILES, + UNIT_TEST_ROOTS, allocationTestArgs, assertNode24AllocationRuntime, buildUnitTestPlan, + discoverUnitTestFiles, isCalibratedAllocationRuntime, } from '../scripts/run-unit-tests.mjs'; +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + +/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */ +function testFilesOnDisk(root) { + // Build outputs and dependencies are not places a contributor writes a test, + // and walking them would be slow enough to matter. Everything else counts. + const skip = new Set(['node_modules', 'dist', 'build', 'public']); + const found = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || skip.has(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && entry.name.endsWith('.test.mjs')) { + found.push(path.relative(root, absolute).split(path.sep).join('/')); + } + } + }; + visit(root); + return found.sort(); +} + test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => { const ordinary = [ 'src/data/manager.test.mjs', @@ -37,6 +63,30 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = ); }); +test('the runner discovers every test file in the repository', () => { + // CI's unit-test job runs `npm test`, which runs exactly what this function + // returns. A `*.test.mjs` outside the searched roots therefore never runs + // there, while still reading — in review and to its author — like covered + // code. Compare the two sets rather than their sizes, so the failure names the + // files instead of only disagreeing about a count. The final equality also + // catches the reverse: files the runner finds that this walk skips. + const discovered = discoverUnitTestFiles(repositoryRoot); + const onDisk = testFilesOnDisk(repositoryRoot); + const undiscovered = onDisk.filter((file) => !discovered.includes(file)); + assert.deepEqual( + undiscovered, + [], + `test files the runner never executes: ${undiscovered.join(', ')}`, + ); + assert.deepEqual(discovered, onDisk); + for (const file of discovered) { + assert.ok( + UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)), + `${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`, + ); + } +}); + test('allocation runtime calibration is explicit and pinned to Node 24', () => { assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); assert.throws( diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d3b8e8a90..c57b4a1ab 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -36,8 +36,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. - // Street Level adds its toggle enum values and generated alias hint. - '9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f', + // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. + '9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b', ); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9e1e183d0..d78528af2 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4809,7 +4809,12 @@ function compactAnalystItem(r) { if (!present(r[key]) || key in compact) continue; if (spec.type === 'time') { const ms = Number(r[key]); - if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString(); + // A finite value can still be past Date's range, where toISOString throws. + const time = new Date(ms); + if (Number.isFinite(time.getTime())) { + if (key === 'lastContactAtMs') compact.lastContactAtMs = ms; + else compact.time = time.toISOString(); + } } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..6edc604ef 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3263,6 +3263,30 @@ test('ALPR common names toggle only the registered camera layer through the norm } }); +test('GNSS integrity and interference common names toggle only the GNSS layer through the normal voice action', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } } }; + const calls = []; + let enabled = false; + const dataManager = { + layers: new Map([['gnss-integrity', { module: {} }]]), + getAll: () => [{ id: 'gnss-integrity', name: 'GNSS Integrity' }], + isEnabled: () => enabled, + setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, + }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); + for (const alias of ['gnss-integrity', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + for (const value of [true, false]) { + const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); + assert.equal(result.ok, true); + assert.equal(result.layerId, 'gnss-integrity'); + assert.deepEqual(calls.at(-1), ['gnss-integrity', value]); + } + } +}); + test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, @@ -3459,6 +3483,26 @@ test('analyst_query refusals carry their code and the allowed values', async () assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/); }); +test('analyst_query keeps its answer when one record has an out-of-range time', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const now = Date.now(); + const earthquakes = { + getStats: () => ({ count: 2, lastUpdate: now }), + getAnalystRecords: () => [ + { id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now }, + { id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 }, + ], + }; + const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] }); + const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } }); + assert.equal(result.ok, true); + const q = result.items.find((item) => item.id === 'q'); + assert.match(q.time, /^\d{4}-\d\d-\d\dT/); + const r = result.items.find((item) => item.id === 'r'); + assert.ok(r, 'the record is still listed'); + assert.equal(r.time, undefined, 'an unrepresentable time is omitted'); +}); + test('spoken layer names from the manifest reach the newer layers', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const stub = () => ({ getStats: () => ({ count: 0 }) }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1e348ee41..286b8a5d8 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -34,9 +34,29 @@ const AIRCRAFT_FIELDS = { aircraftClass: ['t'], military: ['f'], onGround: ['f'], + squawk: ['t'], + emergency: ['f'], + emergencyKind: ['t'], + emergencySource: ['t'], + emergencyBroadcast: ['t'], + lastContactAtMs: ['ms'], }; const layers = [ + { + id: 'gnss-integrity', + aliases: [ + 'gnss integrity', + 'navigation integrity', + 'low navigation accuracy', + 'gnss interference', + 'gps interference', + 'gps jamming', + 'gnss', + ], + context: false, + noQuery: 'navigation-integrity cells; no countable analyst records', + }, { id: 'flights', track: true, diff --git a/src/weatherEffectsMath.js b/src/weatherEffectsMath.js index 0c1276bab..ec06741aa 100644 --- a/src/weatherEffectsMath.js +++ b/src/weatherEffectsMath.js @@ -13,7 +13,13 @@ function range01(value, min, max) { * Missing weather fails clear instead of inventing conditions. */ export function deriveWeatherEffectProfile(weather) { - if (!weather || !Number.isFinite(Number(weather.weatherCode))) { + const reported = weather?.weatherCode; + // Number(null) and Number('') are 0 (clear), so a missing code is checked first. + if ( + reported == null || + reported === '' || + !Number.isFinite(Number(reported)) + ) { return { available: false, cloud: 0, diff --git a/src/weatherEffectsMath.test.mjs b/src/weatherEffectsMath.test.mjs index 6ca4387bd..bed3d1cd4 100644 --- a/src/weatherEffectsMath.test.mjs +++ b/src/weatherEffectsMath.test.mjs @@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => { assert.equal(profile.storm, 0); }); +test('a missing weather code is unavailable, not clear', () => { + for (const weatherCode of [null, undefined, '']) { + const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 }); + assert.equal(profile.available, false, `weatherCode ${weatherCode}`); + assert.equal(profile.cloud, 0); + } + assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true); +}); + test('clear weather follows observed cloud cover without precipitation', () => { const profile = deriveWeatherEffectProfile({ weatherCode: 1,