diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5481754..99089f5f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,28 +106,16 @@ ## [Unreleased] -- Preserve full GNSS upstream retry deadlines and concurrent cooldowns; keep - published Street Level token `0` and assign GNSS token `3`. Live QA requires - a successful dated snapshot even in empty airspace. -- Civil Flights fallback retains ADS-B-only emergency/priority status and clears - it on later polls. Analyst/voice results label the retained general aircraft - contact time as `lastContactAtMs`, not the time of the current status. - GNSS Integrity and aircraft emergency highlighting contributed by - [Leo Pechnicki](https://github.com/leopechnicki) (#855, #856). +- 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). -- Flights and Military Flights highlight aircraft broadcasting a transponder - emergency. Squawk 7500 (unlawful interference), 7600 (radio failure) and - 7700 (general emergency) — or adsb.lol's ADS-B `emergency` status, which - also reports downed aircraft — tint the aircraft red on the globe, in - Cockpit contacts and in snapshots, and name the code on the Cockpit HUD. - Minimum-fuel and lifeguard statuses are reported but not tinted. The status - follows the latest poll instead of sticking, so a cleared code stops - highlighting on the next refresh. The analyst and voice query engine can - filter by `squawk`, `emergency`, `emergencyKind` and `emergencySource` - (whether the squawk or the ADS-B field named the status), and each result - says when the transponder was last heard, so answers read "broadcasting - squawk 7700" or "ADS-B reports general emergency". No new source or - request: OpenSky already carries the squawk and adsb.lol carries both. - 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 @@ -211,35 +199,16 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. -- GNSS Integrity layer (Events group, share id `gnss-interference`): cells of - low navigation accuracy reported by ADS-B aircraft. The method is a GEV - threshold plus gpsjam.org aggregation, not "the gpsjam method": an aircraft - counts as low when it reports NIC < 7 or NACp < 8 (borrowed from the US - 14 CFR 91.227 ADS-B Out minima, not a published interference classifier) or - readsb's GPS-loss flag; aircraft are binned into 0.5° cells over a - GEV-defined rolling 30-minute window around visited views and scored with - gpsjam.org's published formula and 2% / 10% bands. Medium and high cells - are navigation-integrity anomalies; suspected GNSS interference is a - secondary, unvalidated interpretation. The layer and the proxy expose - machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). - Transient feed failures no longer reject GNSS activation: the layer stays - enabled, explains unavailable/degraded feed health and retries automatically. - Provider Retry-After deadlines survive movement and toggling; other failures - back off for 30/60/120 seconds. Disable/destroy cancels retry work. - Automatically refreshes after camera pan/zoom without toggling, coalesces - nearby views, and uses the Earth point under the screen centre at globe scale. - Keyless regional sampling wherever adsb.lol has receivers; the row labels - the 250 nm radius, which remains regional when zoomed out. Reads - a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, - which returns integrity fields only and serves the last snapshot (with its - original observation time and `ageMs`, for at most 30 minutes) when the - upstream fails. - Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A - degraded report keeps an aircraft degraded for 30 minutes after that report, - not after its latest healthy one; replayed snapshots keep their age, and - cells expire on schedule during fetch failures or without a view anchor. - -## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) +- 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). - Ship each bundled data pack once. The region, marine, admin-boundary, county, military-name and neighborhood packs were emitted twice by the 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 84c4901c3..d0897a04f 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -26,7 +26,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; GNSS Integrity layer (per-aircraft NIC/NACp integrity only, 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 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) | @@ -80,8 +80,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 integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (Earth point under the screen centre, anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). Pan/zoom triggers a debounced automatic refresh, bounded to one read per 2.5 s; same-region movement shares the cache. This is a regional public point feed, not a worldwide snapshot at globe zoom. The layer row labels the sampling radius. Transient acquisition failures keep the layer enabled for automatic recovery: the browser respects Retry-After, uses bounded 30/60/120 s backoff otherwise, and shows feed-health errors with the next retry time. Movement and toggling do not bypass cooldowns. The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. -- **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so genuine OpenSky highlighting relies on squawk 7500/7600/7700 alone. The civil adsb.lol fallback preserves its additional ADS-B status beside the OpenSky-compatible vectors, without another request. + + +- **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 4029db402..4c636151c 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,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/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 3b41f7e9a..70ac44705 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -817,31 +817,26 @@ 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; -ICAO-standard worldwide) and, where the feed carries it, the readsb ADS-B -`emergency` field (adsb.lol; OpenSky state vectors carry the squawk only). An -explicit ADS-B status wins over the squawk. `general`, `unlawful`, `nordo` and -`downed` are emergencies; `minfuel` and `lifeguard` are priorities. +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. -Both aircraft layers keep `squawk` and `emergency` on each record and replace -them on every poll, so the status is never sticky. Civil and military fleet -billboards and Cockpit far-contact dots are tinted `EMERGENCY_TINT_CSS` red -only for emergencies; priorities keep the normal tint. The military layer uses -the same tint when a billboard is first created and when a tracked contact is -released. While a broadcasting aircraft is tracked, the Cockpit aircraft-meta -line reads `SQUAWK 7700 · GENERAL EMERGENCY` when the code names the status, -else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst records and -the voice query fields expose `squawk`, `emergency` (flag), `emergencyKind` and -`emergencySource` (`squawk` when the Mode A code names the status, `ads-b` for -the readsb field), plus `emergencyBroadcast` (`broadcasting squawk 7700 (general -emergency)` vs `ADS-B reports general emergency`) and `lastContactAtMs`, the latest known general aircraft-message contact in epoch -ms. Missing contact times retain the preceding known contact, or stay null if -none is known. This does not timestamp the current emergency broadcast. Civil -adsb.lol fallback preserves the readsb status in `emergencyByIcao` alongside -unchanged OpenSky-compatible vectors, including clearing on subsequent polls. -Compact voice results retain the emergency flag, source, phrase and named -last-contact time; they do not label that time as the status receipt. -`scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing. +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 @@ -3129,10 +3124,33 @@ 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-interference`) | 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) | +| 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 display activation stays enabled through transient feed failures, including the first read. The row reports unavailable/degraded source health, a specific HTTP cause and the next automatic retry time; successful recovery clears the warning. Retry-After is honored in full for provider cooldowns, with 30/60/120 s bounded backoff otherwise. Camera movement, polling and toggling cannot bypass the deadline; disable/destroy cancels retries. Genuine non-retryable requests and renderer failures remain lifecycle failures. GNSS Integrity automatically refreshes after camera pan/zoom (600 ms debounce, minimum 2.5 s between reads, shared whole-degree cache keys) through the manager refresh lane, so counts and loading/error state update without toggling. The anchor is the Earth point under the screen centre, including at globe scale where the visible rectangle can cover the entire world. It fetches a capped 250-nautical-mile-radius adsb.lol snapshot around that anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band and visibly labels the regional 250 nm sampling radius. Zooming out does not turn this public point feed into worldwide coverage; blank areas can lie outside the sampled region or have fewer than three qualifying aircraft. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. +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. @@ -4482,7 +4500,7 @@ are omitted rather than framing the wrong part of the globe. - **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. @@ -5336,9 +5354,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - -GNSS integration reserves share token `3`, retaining published Street Level `0`. -It uses the catalog source contract and voice layer manifest. Upstream 429 -Retry-After seconds/HTTP dates retain their full deadlines; overlapping requests -keep the longest cooldown. Live GNSS QA requires a successful dated response -even when there are no qualifying cells. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 13ebe3836..ec8f56022 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", @@ -3505,6 +3506,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/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index 8b5e59bf1..f877ea659 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -10,6 +10,8 @@ * (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 @@ -20,6 +22,7 @@ * 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. */ @@ -39,6 +42,7 @@ const getOpt = (name, 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 = []; @@ -232,7 +236,7 @@ async function main() { civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'), ], mil: [ - milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '7700', 'general'), + 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'), ], @@ -243,11 +247,11 @@ async function main() { await page.evaluateOnNewDocument(installStubs); await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', - timeout: 60000, + timeout: TIMEOUT_MS, }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, - { timeout: 60000 }, + { timeout: TIMEOUT_MS }, ); await sleep(5000); await page.keyboard.press('Escape'); @@ -287,7 +291,7 @@ async function main() { const probe = `(${billboardColors})(${JSON.stringify(ids)})`; await page .waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, { - timeout: 60000, + timeout: TIMEOUT_MS, polling: 500, }) .catch(() => {}); @@ -321,6 +325,79 @@ async function main() { ); 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(() => @@ -385,6 +462,17 @@ async function main() { 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 ─────────────────────────────────────────────────────── diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 3387ac5da..629316ce8 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * qa-gnss.mjs — headless proof for the GNSS Integrity layer - * (id `gnss-interference`): low navigation accuracy reported by ADS-B. + * (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 @@ -9,10 +9,10 @@ * 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 entity per cell and legend band counts that sum to the cell + * 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). + * 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 @@ -20,9 +20,11 @@ * 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 — the layer's data source is hidden. + * (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 @@ -39,6 +41,7 @@ * 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. */ @@ -62,7 +65,8 @@ const getOpt = (name, dflt) => { }; const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); -const LAYER_ID = 'gnss-interference'; +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000')); +const LAYER_ID = 'gnss-integrity'; const results = []; function record(name, ok, detail) { @@ -76,11 +80,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function boot(page, { lon, lat, height }) { await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', - timeout: 60000, + timeout: TIMEOUT_MS, }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, - { timeout: 60000 }, + { timeout: TIMEOUT_MS }, ); await sleep(5000); await page.keyboard.press('Escape'); @@ -118,26 +122,24 @@ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { const layer = dm.layers.get(id).module; if (again) await dm.refreshLayer(id); else await dm.setEnabled(id, true); - let stats = null; - for (let i = 0; i < tS; i++) { - stats = layer.getStats(); - if (stats.count > 0 || stats.error) break; - await new Promise((r) => setTimeout(r, 1000)); - } + 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 ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; + 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?.entities.values.length ?? -1, + entities: ds?.get(0)?.geometryInstances.length ?? 0, shown: ds?.show ?? null, }; }, @@ -164,7 +166,7 @@ async function shoot(page, name) { await page .waitForFunction( () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 60000 }, + { timeout: TIMEOUT_MS }, ) .catch(() => {}); await showLayerRow(page); @@ -174,15 +176,12 @@ async function shoot(page, name) { /** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ function cellStates(id) { const { viewer } = window.__godsEyeView; - const ds = viewer.dataSources.getByName(id)[0]; - const sphere = {}; - return ds.entities.values.map((entity) => { - try { - return viewer.dataSourceDisplay.getBoundingSphere(entity, false, sphere); - } catch { - return 1; // not yet seen by the geometry visualizer - } - }); + 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. */ @@ -371,7 +370,7 @@ async function main() { if (res.url().includes('/api/gnss-integrity') && res.ok()) liveBodies.push(res.json()); }); - await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); + 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 @@ -388,7 +387,9 @@ async function main() { Array.isArray(body.rows) && Number.isFinite(body.ageMs) && body.ageMs >= 0 && - body.classifier === 'gev-nic-nacp-v1', + body.ageMs <= 60000 && + body.stale !== true && + body.classifier === 'gev-nic-nacp-v2', ), `successful responses=${liveSnapshots.length}`, ); @@ -405,7 +406,7 @@ async function main() { } const expectedCells = binGnssCells(expectedStore).length; record( - 'LIVE: no error, one entity per cell', + '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)}`, ); @@ -456,17 +457,19 @@ async function main() { ); record( 'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low', - band('Over') === 1 && band('2') === 1 && band('Under') === 2, + 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-v1' && + 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) === '[0.02,0.1]' && + JSON.stringify(prov.aggregation.bands) === '[2,10]' && prov.window?.minutes === 30 && prov.window.scope === 'visited-view' && prov.interpretation?.validated === false, @@ -477,12 +480,9 @@ async function main() { f.name === 'GNSS Integrity' && f.rowName.includes('GNSS Integrity') && !/interference/i.test(f.rowName) && - f.legend.every(({ label }) => label.endsWith('low accuracy')) && - f.blurb.startsWith( - 'Share of ADS-B aircraft reporting low navigation accuracy', - ) && - /GEV threshold/.test(f.blurb) && - /Suspected jamming or spoofing is one possible cause/.test(f.blurb), + 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( @@ -498,6 +498,128 @@ async function main() { ); 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, + ); + await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y); + await page.waitForFunction( + () => + (window.__gevWorldOverlay.getDiagnostics().paintedBySource[ + 'fire-perimeters' + ] ?? 0) > 0, + { timeout: 10000 }, + ); + record('PICK: actual perimeter click opens its incident card', true); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'), + }); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y); + await sleep(500); + record( + 'PICK: empty ground clears the perimeter card', + await page.evaluate( + () => + (window.__gevWorldOverlay.getDiagnostics().entriesBySource[ + 'fire-perimeters' + ] ?? 0) === 0, + ), + ); + 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, @@ -555,7 +677,7 @@ async function main() { const { viewer } = window.__godsEyeView; viewer.camera.setView({ destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (12.5 * Math.PI) / 180, + longitude: (12.4 * Math.PI) / 180, latitude: (41.9 * Math.PI) / 180, height: 16_000_000, }), @@ -569,7 +691,7 @@ async function main() { const params = url && new URL(url, location.origin).searchParams; return ( params && - Math.abs(Number(params.get('lon')) - 12.5) < 1 && + Math.abs(Number(params.get('lon')) - 12.4) < 1 && Math.abs(Number(params.get('lat')) - 41.9) < 1 ); }, @@ -592,7 +714,7 @@ async function main() { 'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius', world.enabled && world.height > 15_000_000 && - world.coverage?.longitude === 13 && + world.coverage?.longitude === 12 && world.coverage?.latitude === 42 && world.coverage?.radiusNm === 250, JSON.stringify(world), @@ -642,7 +764,10 @@ async function main() { console.log('\n(iv) DISABLE...'); const shown = await page.evaluate(async (id) => { await window.__godsEyeView.dataManager.setEnabled(id, false); - return window.__godsEyeView.viewer.dataSources.getByName(id)[0]?.show; + 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'); diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index 4b27bff4f..adf28cea5 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -16,10 +16,13 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; const RADIUS_NM = 250; const CACHE_MS = 60_000; const CACHE_MAX = 64; -/** Cooldown after a 429 or 5xx when upstream sends no usable Retry-After. */ +/** Default 429 cooldown when upstream sends no usable Retry-After. */ const COOLDOWN_MS = 60_000; -/** Minimum backoff; a valid longer upstream deadline is never shortened. */ +/** 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; @@ -36,13 +39,14 @@ export function gnssIntegrityAnchor(url) { /** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ export function gnssRetryCooldownMs(raw, nowMs) { if (raw) { - const seconds = Number(raw); + const seconds = /^\d+(?:\.\d+)?$/.test(raw.trim()) ? Number(raw) : NaN; const ms = seconds * 1000; - if (Number.isFinite(ms) && seconds > 0) - return Math.max(COOLDOWN_MIN_MS, ms); - const at = Date.parse(raw); + 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.max(COOLDOWN_MIN_MS, at - nowMs); + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, at - nowMs)); } return COOLDOWN_MS; } @@ -51,77 +55,123 @@ export function gnssRetryCooldownMs(raw, nowMs) { 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 (now() < cooldownUntil) - throw Object.assign(new Error('cooling_down'), { status: 429 }); + if (monotonicNow() < cooldownUntil) + throw Object.assign(new Error('cooling_down'), { + status: cooldownStatus, + }); const signal = AbortSignal.timeout(TIMEOUT_MS); - 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', + 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 = now(); - cooldownUntil = Math.max( - cooldownUntil, - at + gnssRetryCooldownMs(response.headers.get('retry-after'), at), - ); - throw Object.assign(new Error('upstream_rate_limited'), { - status: 429, - }); + ); + 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'); } - 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; } - const payload = await readResponseJsonCapped( - response, - MAX_RESPONSE_BYTES, - signal, - ); - const rows = normalizeGnssAircraft(payload); - if (!rows) throw new Error('invalid_snapshot'); - return { - fetchedAt: now(), - anchor: { lat, lon }, - radiusNm: RADIUS_NM, - // `degraded` on each row is this classifier's verdict, not gpsjam's. - classifier: GNSS_CLASSIFIER.id, - rows, - }; } - async function acquire(anchor) { + 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 (previous && now() - previous.fetchedAt < CACHE_MS) - return { value: previous, stale: false }; + 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, value); + cache.set(key, entry); while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); - return value; + return entry; }); - return { value: await promise, stale: false }; + const entry = await promise; + const ageMs = age(entry); + return { value: entry.value, ageMs, stale: ageMs >= CACHE_MS }; } catch (error) { - // A stale answer keeps its original fetchedAt so the layer can age it; - // past the layer's window it is no evidence at all, so it is not served. - if (previous && now() - previous.fetchedAt <= GNSS_WINDOW_MS) - return { value: previous, stale: true }; + if (age(previous) <= GNSS_WINDOW_MS) + return { value: previous.value, ageMs: age(previous), stale: true }; throw error; } } @@ -133,7 +183,9 @@ export function gnssIntegrityProxy({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(status === 405 ? { Allow: 'GET' } : {}), - ...(status === 429 ? { 'Retry-After': String(retryAfterS) } : {}), + ...([429, 502].includes(status) + ? { 'Retry-After': String(retryAfterS) } + : {}), ...(stale ? { 'X-Data-Stale': 'true' } : {}), }); res.end(JSON.stringify(value)); @@ -144,28 +196,33 @@ export function gnssIntegrityProxy({ return json(404, { error: 'unknown_route' }); const anchor = gnssIntegrityAnchor(req.url); if (!anchor) return json(400, { error: 'invalid_anchor' }); - if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); try { - const { value, stale } = await acquire(anchor); + 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. - const ageMs = Math.max(0, now() - value.fetchedAt); 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, - Math.ceil((cooldownUntil - now()) / 1000), + error.local ? 60 : Math.ceil((cooldownUntil - monotonicNow()) / 1000), ); return json( 429, - { error: 'gnss_integrity_rate_limited' }, + { + error: error.local ? error.message : 'gnss_integrity_rate_limited', + }, false, retryAfterS, ); } - json(502, { error: 'gnss_integrity_unavailable' }); + json( + 502, + { error: 'gnss_integrity_unavailable' }, + false, + Math.max(1, Math.ceil((cooldownUntil - monotonicNow()) / 1000)), + ); } } diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 3504be9a1..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). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. lastContactAtMs is retained general aircraft-message contact and never a status-specific receipt timestamp.', + '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/src/app/constructCatalog.js b/src/app/constructCatalog.js index f78d4d5eb..184b51f17 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -122,7 +122,7 @@ export function createApplicationCatalog({ source: sources['fire-perimeters'], }), createApplicationGnssIntegrity({ - source: sources['gnss-interference'], + source: sources['gnss-integrity'], }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index d5a1fff63..f8ebb1e73 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -60,7 +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-interference')); + assert.ok(first.get('gnss-integrity')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 826b90b1f..0b516f5b4 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,7 +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-interference': contract(['gnss-interference'], ['getSnapshot']), + 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index e83cf118b..40dd2bbc0 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -54,7 +54,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { 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'; @@ -69,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, diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js index 3a555399e..78bd4308f 100644 --- a/src/data/aircraftEmergency.js +++ b/src/data/aircraftEmergency.js @@ -1,4 +1,3 @@ -// src/data/aircraftEmergency.js /** * Transponder emergency status for one aircraft, from the Mode A squawk and * the ADS-B emergency/priority field. @@ -10,8 +9,8 @@ * squawk only. * * The status is what the transponder broadcast in THIS poll, never a finding: - * crews pass through 7x00 codes while dialling, and a real emergency clears - * when the code does. Callers keep it per poll (not sticky). + * 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). */ @@ -39,7 +38,8 @@ export function normalizeSquawk(value) { } /** - * Emergency status from one observation. An explicit ADS-B status wins; a + * 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', @@ -53,7 +53,11 @@ export function aircraftEmergency({ squawk, emergency } = {}) { .trim() .toLowerCase(); const fromAdsb = Object.hasOwn(KINDS, reported); - const kind = fromAdsb ? reported : (SQUAWK_KINDS[code] ?? null); + const squawkKind = SQUAWK_KINDS[code] ?? null; + const kind = + fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind) + ? reported + : squawkKind; if (!kind) return null; return { kind, diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs index 4304dcb14..6646aa6bd 100644 --- a/src/data/aircraftEmergency.test.mjs +++ b/src/data/aircraftEmergency.test.mjs @@ -82,3 +82,14 @@ test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => { 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 index 9e4a6e2fe..27deb2b46 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -1,8 +1,11 @@ -// src/data/aircraftEmergencyAnalyst.test.mjs -// Source-to-analyst fixture: an OpenSky state vector squawking 7700 and an -// adsb.lol (readsb) aircraft whose ADS-B `emergency` field says `general` -// on an ordinary code must reach the analyst with the same kind but distinct -// provenance, so a summary never flattens one into the other. +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'; @@ -30,7 +33,7 @@ test('fallback status belongs to the first valid admitted aircraft row', () => { }); const snapshot = openSkySnapshot(payload, { now: NOW_MS }); assert.equal(snapshot.records.length, 1); - assert.equal(snapshot.records[0].emergency, status ?? null); + assert.equal(snapshot.records[0].emergency, status); assert.equal(snapshot.records[0].latitude, 30); } }); @@ -354,3 +357,168 @@ test('military status changes retain only the latest known general contact', () 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/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 76849c8aa..457878b18 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -7,7 +7,7 @@ import { } from '../../server/providers/gnssIntegrity.js'; const snapshot = { - now: 1, + now: 4_000_000_000_000, ac: [ { hex: '4ca7b5', @@ -28,7 +28,7 @@ const snapshot = { function install(options = {}, hook = 'configureServer') { let handler; - const plugin = gnssIntegrityProxy(options); + const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options }); assert.equal(plugin.name, 'gnss-integrity'); plugin[hook]({ middlewares: { @@ -89,7 +89,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { ageMs: 0, anchor: { lat: 50, lon: 20 }, radiusNm: 250, - classifier: 'gev-nic-nacp-v1', + classifier: 'gev-nic-nacp-v2', rows: [ { hex: '4ca7b5', @@ -205,7 +205,7 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async ( assert.equal(calls, 2); }); -test('Retry-After accepts seconds or an HTTP date, with a 5 s minimum and no truncated deadline', () => { +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); @@ -309,9 +309,121 @@ test('the route rejects other methods, paths, bad anchors, a malformed feed and 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 request('/?lat=1&lon=1', 'GET', 'flood')).status === 429) + 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 72a3351b9..1db10494b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -608,7 +608,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ optionOwner: 'flights', }), Object.freeze({ - id: 'gnss-interference', + id: 'gnss-integrity', token: '3', disposition: 'enabled-only', }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index b782de5e2..30f251b7b 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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' }), @@ -2643,11 +2643,11 @@ 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-interference', + 'gnss-integrity', 'recent-imagery', ]); assert.equal( - LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token, + LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token, '3', ); assert.deepEqual( diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 9d7544109..a38a43d51 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -134,7 +134,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0', - 'gnss-interference': '3', + 'gnss-integrity': '3', }, ); assert.throws( diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 13a5d136b..34ba725b5 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -9,7 +9,7 @@ ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], - ["gnss-interference", "3"], + ["gnss-integrity", "3"], ["local-dams", "q"], ["local-datacenters", "d"], ["local-firms", "w"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 53524bada..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. - // GNSS and emergency/contact fields extend the manifest-generated contract. - assert.equal(block.length, 30208, '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'), - 'f226ed9a1d1867b107b39073f527add7f9cb083ef013dbb2111842fcbd343c35', + '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/flights/queries.js b/src/layers/flights/queries.js index c6a55f76c..9c206a335 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -171,6 +171,7 @@ export function createQueries({ // 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. diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 4b665d773..2b6f48dfb 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -230,7 +230,19 @@ export class FlightRecords { // 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, - emergency: aircraftEmergency(observation), + // 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/gnss/index.js b/src/layers/gnss/index.js index 85146d832..bd63b600f 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -2,8 +2,6 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS, GNSS_MIN_AIRCRAFT, - GNSS_NACP_THRESHOLD, - GNSS_NIC_THRESHOLD, GNSS_WINDOW_MS, accumulateGnssObservations, binGnssCells, @@ -13,12 +11,23 @@ import { 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: 'Under 2% low accuracy', - medium: '2–10% low accuracy', - high: 'Over 10% low accuracy', + 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. */ @@ -34,7 +43,8 @@ export function defaultGnssViewAnchor(viewer) { new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), ellipsoid, ); - const rectangle = viewer?.camera?.computeViewRectangle?.(ellipsoid); + const rectangle = + !centre && viewer?.camera?.computeViewRectangle?.(ellipsoid); const carto = centre ? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre) : rectangle @@ -50,20 +60,20 @@ export function defaultGnssViewAnchor(viewer) { /** * Own one GNSS navigation-integrity display: rolling integrity observations - * around the views the user visits, binned into cells coloured by the share - * of aircraft reporting low navigation accuracy. The layer id keeps its - * original `gnss-interference` spelling because share links depend on it. + * 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 _dataSource = null; + let _primitives = null; let _request = null; let _enabled = false; let _cells = []; @@ -91,7 +101,7 @@ export function createGnssIntegrityLayer({ : null; function scheduleCameraRefresh() { - if (!_enabled || !_dataSource) return; + if (!_enabled || !_primitives) return; if (now() < _retryAt) return; const key = queryKey(viewAnchor(_viewer)); if (!key || key === _lastQueryKey) return; @@ -101,12 +111,12 @@ export function createGnssIntegrityLayer({ _cameraTimer = setTimeout( async () => { _cameraTimer = null; - if (!_enabled || _backgroundRefresh) return; + if (!_enabled || _backgroundRefresh || _request) return; const key = queryKey(viewAnchor(_viewer)); if (!key || key === _lastQueryKey) return; await refreshInBackground(); }, - Math.max(600, 2500 - (now() - _lastRequestAt)), + Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)), ); } @@ -157,26 +167,40 @@ export function createGnssIntegrityLayer({ ); if (signature === _signature) return; _signature = signature; - _dataSource.entities.removeAll(); - for (const cell of _cells) { - const color = Cesium.Color.fromCssColorString( - GNSS_LEVEL_COLORS[cell.level], - ); - _dataSource.entities.add( - new Cesium.Entity({ - id: `gnss-interference:${cell.id}`, - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.west, - cell.south, - cell.east, - cell.north, - ), - material: new Cesium.ColorMaterialProperty( - color.withAlpha(FILL_ALPHA[cell.level]), - ), - classificationType: Cesium.ClassificationType.BOTH, - }, + _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, }), ); } @@ -184,7 +208,7 @@ export function createGnssIntegrityLayer({ } const layer = { - id: 'gnss-interference', + id: 'gnss-integrity', name: 'GNSS Integrity', icon: '📡', source: 'adsb.lol', @@ -200,9 +224,10 @@ export function createGnssIntegrityLayer({ if (_viewer) throw new Error('GNSS integrity layer is already initialized'); _viewer = viewer; - _dataSource = new Cesium.CustomDataSource('gnss-interference'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); + _primitives = new Cesium.PrimitiveCollection(); + _primitives.id = layer.id; + _primitives.show = false; + viewer.scene.groundPrimitives.add(_primitives); _removeCameraEnd = viewer.camera?.moveEnd?.addEventListener( scheduleCameraRefresh, ); @@ -214,9 +239,9 @@ export function createGnssIntegrityLayer({ enable() { _enabled = true; // Cells left from before a long disable must not reappear past the window. - if (_dataSource) { + if (_primitives) { expire(); - _dataSource.show = true; + _primitives.show = true; } }, @@ -230,11 +255,11 @@ export function createGnssIntegrityLayer({ _request?.abort(); _request = null; _enabled = false; - if (_dataSource) _dataSource.show = false; + if (_primitives) _primitives.show = false; }, async update(_viewerArg, { signal } = {}) { - if (!_enabled || !_dataSource || signal?.aborted) return false; + if (!_enabled || !_primitives || signal?.aborted) return false; if (now() < _retryAt) { expire(); scheduleRetry(); @@ -257,7 +282,7 @@ export function createGnssIntegrityLayer({ const onAbort = () => request.abort(signal.reason); signal?.addEventListener('abort', onAbort, { once: true }); _lastQueryKey = queryKey(anchor); - _lastRequestAt = now(); + _lastRequestAt = monotonicNow(); _coverage = { kind: 'regional', latitude: Math.round(anchor.latitude), @@ -283,7 +308,7 @@ export function createGnssIntegrityLayer({ _cells = binGnssCells(_observations); render(); _stale = snapshot.stale === true; - _lastUpdate = now(); + _lastUpdate = at - snapshot.ageMs; _lastError = null; _retryAt = 0; _failureCount = 0; @@ -325,15 +350,12 @@ export function createGnssIntegrityLayer({ _removeCameraChanged?.(); _removeCameraEnd = null; _removeCameraChanged = null; - _request?.abort(); - _request = null; - _enabled = false; _observations.clear(); _cells = []; _signature = null; - if (_dataSource) { - viewer.dataSources.remove(_dataSource, true); - _dataSource = null; + if (_primitives) { + viewer.scene.groundPrimitives.remove(_primitives); + _primitives = null; } _viewer = null; _lastUpdate = null; @@ -350,7 +372,7 @@ export function createGnssIntegrityLayer({ return { chips: [], info: - 'Regional sample · 250 nm radius · pan to sample another area' + + 'Regional sample · 250 nm radius · cells from visited views over 30 min' + (_lastError && _retryAt ? ` · ${_lastError} · Retrying automatically${ _retryAt <= 8640000000000000 @@ -359,14 +381,14 @@ export function createGnssIntegrityLayer({ }` : ''), 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 have insufficient observations.', + '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: `Share of ADS-B aircraft reporting low navigation accuracy over the last ${provenance.window.minutes} minutes, around the views you visit. An aircraft counts when it reports NIC < ${GNSS_NIC_THRESHOLD} or NACp < ${GNSS_NACP_THRESHOLD} or GPS loss: a GEV threshold borrowed from US ADS-B Out minima, not a published interference test. Cells use gpsjam.org's formula and 2% / 10% bands. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is one possible cause, not a detection: avionics faults also report low values.`, + 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.`, } : {}), })), @@ -376,6 +398,12 @@ export function createGnssIntegrityLayer({ 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 diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index a26afa1e6..713e44889 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -26,12 +26,16 @@ function harness( const sources = []; const viewer = { camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, - dataSources: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); + scene: { + globe: { show: true }, + groundPrimitives: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + value.destroy(); + }, }, }, }; @@ -50,6 +54,7 @@ function harness( return anchor; }, now: () => clock, + monotonicNow: () => clock, }); if (initialize) layer.init(viewer); return { @@ -87,7 +92,9 @@ test('a globe-wide rectangle uses the visible screen centre as the query anchor' assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400)); return Cesium.Cartesian3.fromDegrees(-97, 30); }, - computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE, + computeViewRectangle: () => { + throw new Error('centre hit must not compute a rectangle'); + }, }, }); assert.ok(Math.abs(anchor.latitude - 30) < 1e-8); @@ -208,10 +215,11 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () layer.enable(); assert.equal(sources[0].show, true); assert.equal(await layer.update(), true); - const entities = sources[0].entities.values; + const entities = sources[0].get(0).geometryInstances; assert.equal(entities.length, 1, 'the one-aircraft cell is withheld'); - assert.equal(entities[0].id, 'gnss-interference:280:420'); - assert.ok(entities[0].rectangle); + 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); @@ -222,35 +230,32 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () [1, 0, 0], ); assert.match(legend[0].blurb, /not a detection/); - assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/); + 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-interference', 'the share-link id is unchanged'); + 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), - ['Over 10% low accuracy', '2–10% low accuracy', 'Under 2% low accuracy'], + [ + 'Low-accuracy score > 10%', + 'Low-accuracy score 2–10%', + 'Low-accuracy score < 2%', + ], ); const { blurb } = legend[0]; - assert.match( - blurb, - /^Share of ADS-B aircraft reporting low navigation accuracy/, - ); - assert.match(blurb, /NIC < 7 or NACp < 8/); - assert.match(blurb, /GEV threshold/); - assert.match(blurb, /not a published interference test/); - assert.match(blurb, /gpsjam\.org's formula and 2% \/ 10% bands/); - assert.match(blurb, /navigation-integrity anomalies/); - // Interference appears only as the secondary, hedged reading. - assert.ok( - blurb.indexOf('navigation-integrity anomalies') < - blurb.indexOf('Suspected jamming or spoofing'), - ); - assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v1'); + 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'); @@ -283,7 +288,7 @@ test('a failed refresh keeps the last cells and reports the error', async () => assert.equal(layer.getStats().stale, true); fail = true; assert.equal(await layer.update(), true); - assert.equal(sources[0].entities.values.length, 1); + 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); }); @@ -310,7 +315,7 @@ test('observations expire with the window, and disable aborts the request in fli pending.resolve({ rows: [] }); assert.equal(await result, true); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); result = layer.update(); layer.disable(); @@ -364,7 +369,7 @@ test('a replayed stale snapshot keeps its observation age and expires on time', 0, 'the replay did not restart the window', ); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); }); test('cells expire during prolonged fetch failures', async () => { @@ -382,7 +387,7 @@ test('cells expire during prolonged fetch failures', async () => { advance(11 * MIN); assert.equal(await layer.update(), true); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); }); @@ -402,7 +407,7 @@ test('cells expire while no view anchor is available', async () => { 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].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); assert.equal(layer.getStats().error, null); }); @@ -412,12 +417,12 @@ test('re-enabling after a long disable does not show cells past the window', asy })); layer.enable(); await layer.update(); - assert.equal(sources[0].entities.values.length, 1); + 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].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); }); const threeCells = () => ({ @@ -428,10 +433,10 @@ 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].entities.values; + const first = sources[0].get(0); await layer.update(); - assert.equal(sources[0].entities.values.length, 1); - assert.equal(sources[0].entities.values[0], first); + 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) => { @@ -555,3 +560,141 @@ test('repeated acquisition failures back off, while genuine non-retryable failur 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 index b76f10e5a..ec425d5ee 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -11,7 +11,8 @@ * state. * * Three separately sourced parts make up the method (see `gnssProvenance`): - * - Per-aircraft classifier, defined by GEV: NIC < 7 or NACp < 8 (borrowed + * - 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. @@ -19,9 +20,9 @@ * - Window: a GEV-defined rolling 30 minutes over the views the user visits. */ -/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ -const QUALIFYING_TYPES = new Set(['adsb_icao']); -/** DO-260A (1) and DO-260B (2) define NIC and NACp; version 0 and unknown versions do not. */ +/** 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; @@ -31,7 +32,7 @@ export const GNSS_NACP_THRESHOLD = 8; 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; -/** Fewer aircraft than this in a cell cannot distinguish one faulty transponder from interference. */ +/** 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; @@ -47,8 +48,8 @@ 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-v1', - rule: `gpsOkBefore set || NIC < ${GNSS_NIC_THRESHOLD} || NACp < ${GNSS_NACP_THRESHOLD}`, + 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, @@ -66,10 +67,7 @@ export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) { aggregation: Object.freeze({ id: 'gpsjam-cell-bands', formula: '100 * max(0, bad - 1) / total', - bands: Object.freeze([ - GNSS_MEDIUM_PERCENT / 100, - GNSS_HIGH_PERCENT / 100, - ]), + bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]), source: 'https://gpsjam.org/faq', definedBy: 'gpsjam.org', }), @@ -104,7 +102,7 @@ const finite = (value) => typeof value === 'number' && Number.isFinite(value); * 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, nacp: number, gpsLost: boolean, degraded: boolean}[]|null} + * @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; @@ -117,18 +115,33 @@ export function normalizeGnssAircraft(payload) { if (!QUALIFYING_TYPES.has(aircraft.type)) continue; if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue; if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue; - const { lat, lon, nic, nac_p: nacp } = aircraft; + 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; - if (!finite(nic) || !finite(nacp)) 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 sets gpsOkBefore when an aircraft's integrity collapses and keeps - // it for roughly 15 minutes after GPS recovers, so it can outlast the - // current NIC/NACp; the rolling window treats that as recent evidence. - const gpsLost = aircraft.gpsOkBefore != null; + // 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, @@ -138,7 +151,11 @@ export function normalizeGnssAircraft(payload) { nacp, gpsLost, degraded: - gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD, + gpsLost || + (nacp !== null && nacp < GNSS_NACP_THRESHOLD) || + (nic !== null && + nic < GNSS_NIC_THRESHOLD && + !(nic === 0 && nacp !== null && nacp >= 10)), }); } return rows; diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 593f3e6f3..2f1493544 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -26,16 +26,21 @@ const aircraft = (overrides = {}) => ({ ...overrides, }); -test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => { +test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => { const rows = normalizeGnssAircraft({ ac: [ aircraft(), - aircraft({ hex: '000001', type: 'mlat', nic: 0 }), + 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 }), + 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. @@ -166,8 +171,8 @@ test('the primary state is a navigation-integrity anomaly; interference is only test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => { assert.deepEqual(GNSS_PROVENANCE.classifier, { - id: 'gev-nic-nacp-v1', - rule: 'gpsOkBefore set || NIC < 7 || NACp < 8', + 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, @@ -176,7 +181,7 @@ test('provenance separates the GEV classifier from the gpsjam aggregation and th assert.deepEqual(GNSS_PROVENANCE.aggregation, { id: 'gpsjam-cell-bands', formula: '100 * max(0, bad - 1) / total', - bands: [0.02, 0.1], + bands: [2, 10], source: 'https://gpsjam.org/faq', definedBy: 'gpsjam.org', }); @@ -197,9 +202,9 @@ test('provenance separates the GEV classifier from the gpsjam aggregation and th 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 + 100 * medium).level, 'medium'); - assert.equal(gnssIntegrityLevel(100, 1 + 100 * high).level, 'medium'); - assert.equal(gnssIntegrityLevel(100, 2 + 100 * high).level, 'high'); + 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); }); @@ -406,3 +411,81 @@ test('cells below the minimum aircraft count are withheld', () => { 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 index bfe7eaf17..af67aa18b 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -4,9 +4,12 @@ function retryDelay(raw, nowMs) { if (!raw) return null; const seconds = Number(raw); const ms = seconds * 1000; - if (Number.isFinite(ms) && seconds > 0) return Math.max(5000, ms); + 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.max(5000, at - nowMs) : null; + 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. */ @@ -20,8 +23,8 @@ export function createAdsbGnssSource({ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error('GNSS snapshot needs a view anchor'); const params = new URLSearchParams({ - lat: latitude.toFixed(2), - lon: longitude.toFixed(2), + lat: String(Math.round(latitude)), + lon: String(Math.round(longitude)), }); const response = await fetchImpl(`/api/gnss-integrity?${params}`, { signal, @@ -35,8 +38,8 @@ export function createAdsbGnssSource({ throw Object.assign( new Error( limited - ? 'adsb.lol rate limited (HTTP 429)' - : `adsb.lol feed unavailable (HTTP ${response.status})`, + ? 'GNSS feed rate limited (HTTP 429)' + : `GNSS feed unavailable (HTTP ${response.status})`, ), { status: response.status, diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index aba3f3e9a..3d27d15c6 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -11,7 +11,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => { return Response.json({ fetchedAt: 5, ageMs: 0, - classifier: 'gev-nic-nacp-v1', + classifier: 'gev-nic-nacp-v2', rows: [{ hex: 'abc123' }], }); }, @@ -20,13 +20,13 @@ test('the source asks the same-origin proxy for the view anchor', async () => { { latitude: 50.06143, longitude: 19.93658 }, { signal: new AbortController().signal }, ); - assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']); + 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-v1', + classifier: 'gev-nic-nacp-v2', }); }); diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index b3d1b8cf3..ee11f811f 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -137,6 +137,7 @@ export function createQueries({ // This poll's transponder broadcast (not sticky; see records.js). squawk: info?.squawk ?? null, emergency: info?.emergency ? { ...info.emergency } : null, + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), }; } diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index b9e54c173..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, @@ -120,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 diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index faa77d08a..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,9 +31,9 @@ export function normalizeOpenSkyAircraft(row) { typeCode: null, registration: null, operator: null, - squawk: cleanText(row[14]) || null, + squawk: normalizeSquawk(row[14]), // State vectors carry no ADS-B emergency field; the squawk stands alone. - emergency: null, + emergency: undefined, }; } @@ -71,7 +72,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { typeCode: cleanText(row.t), registration: cleanText(row.r), operator: cleanText(row.ownOp), - squawk: cleanText(row.squawk) || null, + squawk: normalizeSquawk(row.squawk), emergency: cleanText(row.emergency).toLowerCase() || null, }; } diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 36fa9b95b..1bba6e28f 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -314,11 +314,11 @@ test('identity lookup validates its response and honors body-parse cancellation' }); }); -test('transponder squawk and ADS-B emergency status are carried, blanks stay null', () => { +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, null); + 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' }, diff --git a/src/sources/reference.js b/src/sources/reference.js index 40870c1f7..8631d3ad2 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -9,6 +9,6 @@ export function createReferenceSources() { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), - 'gnss-interference': createAdsbGnssSource(), + 'gnss-integrity': createAdsbGnssSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index fea1cb4f2..9f9cc9d92 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -16,15 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s 'earthquakes', 'fire-perimeters', 'cables', - 'gnss-interference', + '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-interference'], second['gnss-interference']); + 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-interference'].getSnapshot, 'function'); + assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function'); assert.equal(requests, 0); }); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c91771dca..6fe0fe8d5 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -54,7 +54,7 @@ const PANEL_GROUPS = [ 'earthquakes', 'local-firms', 'fire-perimeters', - 'gnss-interference', + 'gnss-integrity', ], }, { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 4a8f130a6..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. - // GNSS and emergency/contact fields extend the manifest-generated contract. - '3f18ec8718981cfa42e1462a0f1a9f0102cd90f1f86e0cc25a9c0d0f3b291a53', + // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. + '9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b', ); }); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 55daa80e9..32b9ac8b2 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3271,18 +3271,18 @@ test('GNSS integrity and interference common names toggle only the GNSS layer th const calls = []; let enabled = false; const dataManager = { - layers: new Map([['gnss-interference', { module: {} }]]), - getAll: () => [{ id: 'gnss-interference', name: 'GNSS Integrity' }], + 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-interference', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + 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-interference'); - assert.deepEqual(calls.at(-1), ['gnss-interference', value]); + assert.equal(result.layerId, 'gnss-integrity'); + assert.deepEqual(calls.at(-1), ['gnss-integrity', value]); } } }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index c5b84f3dd..286b8a5d8 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -44,7 +44,7 @@ const AIRCRAFT_FIELDS = { const layers = [ { - id: 'gnss-interference', + id: 'gnss-integrity', aliases: [ 'gnss integrity', 'navigation integrity',