Merge upstream/main: cockpit emergency, GNSS integrity and GeoJSON import meet i18n

Upstream advanced past the KartaView merge: transponder-emergency HUD
wording (formatCockpitEmergency in cockpitMath), the GNSS integrity
layer joined the Events group, and a GeoJSON/KML/KMZ/GPX file-import
control landed - all in regions this branch localizes.

- cockpitMath: upstream's formatCockpitEmergency kept alongside the
  branch's formatter-injected Context scope (module stays i18n-free;
  squawk/ADS-B codes and the broadcast label are data).
- cockpitInstruments: the emergency HUD line composed with t()
  placeholders; fleet/feedState stay translated, the 'emergency' class
  toggle kept.
- layerPanel: upstream's gnss-integrity id with msg('Events').
- package-boundaries: both sides' entries unioned.
- New upstream UI strings marked and translated, as the branch's
  unmarked-string detector requires: the geo-import control's markup
  (data-i18n-title/-aria-label/text), its list titles, hints and
  summarize() lines (t/tn with placeholders), the aircraft emergency
  labels (msg data), and the GNSS legend blurb (t with placeholders).
- es/ru catalogs: 26 new keys each (2019 messages).

npm run i18n: complete, no unmarked strings. npm test: 6739/6741 (the
one failure is the pre-existing environmental OPENAI_API_KEY leak in
devFreshDotenv). Build passes.
This commit is contained in:
Meir Michanie committed 2026-10-15 19:37:26 +02:00
commit 0211e6e877
120 files changed
+12921 -254

No files matched your search

+46 -11
View File
@@ -1,5 +1,10 @@
# Changelog
- A Radio Browser mirror answering 204, 205 or 304, or a status outside
200–599, no longer crashes the local dev/preview server. Null-body statuses
get an empty body; anything else that cannot become a response fails that
mirror so the next one is tried.
- Snow flakes sway within a fixed range instead of drifting sideways faster the
longer the app runs; the Wind setting still scales the sway.
@@ -49,18 +54,9 @@
- Director timeline seeks cross the dateline along the short longitude arc.
- Map state updates stop delivering to subscribers removed during the same notification.
- Recent Imagery continues past malformed records in full catalog pages, respects the requested record limit, and reports results as truncated when that limit drops valid records.
- Director assets cancelled during the final stream read are discarded with reader cleanup.
- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range.
- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid.
- Frequently reused terrain batches retain their cached heights ahead of unused cells.
- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or
boolean programmatic box edges instead of interpreting them as zero coordinates.
- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
@@ -157,6 +153,16 @@
## [Unreleased]
- Flights and Military Flights highlight emergency transponder broadcasts in
fleet billboards, 3D models and Cockpit contacts. Colors update when a status
starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV
reports minimum fuel and lifeguard without red tint. Cockpit names the code
or ADS-B status, and analyst/voice results retain broadcast provenance and
the latest known contact time. One omitted ADS-B status poll during a source
switch may retain the previous status if the squawk is unchanged; an explicit
clear takes effect immediately. Broadcasts are not confirmed incidents.
Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#856).
- Street Level: KartaView is the third provider, keyless. Light its chip
(purple; off by default, one provider at a time) and zoom in below about
6 km: the lines are the streets KartaView has photos of (z13–14 tiles from
@@ -240,12 +246,41 @@
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
under NLOD 2.0.
- Import your own GeoJSON, KML, KMZ and GPX files from DISPLAY or by dropping
them onto the app. Show, hide, fly to or remove each file for the session.
GeoJSON/GPX draw on the ground; KML retains its styles and explicit heights.
Large repeated-style exports use shared local pins, while recorded track
vertices and segment gaps stay intact. Imports respect newer camera gestures,
leave the shared clock alone, and report malformed or oversized files.
File bytes stay on the device; rendered overlays may appear in shared map
screenshots. Thanks to [Chase Mills](https://github.com/kapturner) (#925).
- GNSS Integrity (Events, voice-addressable, share token `3`) draws navigation
accuracy scores from a keyless regional adsb.lol sample; aircraft-level analyst
queries are unavailable for these aggregate cells. Cells do not intercept
map clicks. Pan/zoom refreshes automatically; feed failures retain the display
and explain the next retry. Aircraft-reported quality includes MLAT-positioned
contacts after GPS loss, excludes TIS-B/MLAT-derived quality, and locates GPS-loss
evidence at the last good GNSS position when available. Scores discount one
bad aircraft per cell and retain 30 minutes of visited-view evidence; they
do not confirm jamming. See [method and limits](DATA_SOURCES.md#gnss-integrity).
Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#855).
- Live HLS cameras play on a page opened over plain HTTP from another machine
(`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which
browsers only expose in secure contexts, so starting a live feed threw
before playback. The id now falls back to a v4 UUID built from
`crypto.getRandomValues`, which the server's lease check accepts.
- Move Google geocoding behind the local server. Forward and reverse lookups now
go through `/api/google/geocode` and `/api/google/reverse-geocode` with the
server key, joining Places and Street View. Google's Geocoding web service
refuses referrer-restricted keys, so the previous browser call left
`SECURITY.md`'s advice and working search mutually exclusive: the bundled
browser key had to be unrestricted for search to work at all. That key now
needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim
fallback order are unchanged.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
+3 -3
View File
@@ -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 -- <layer-id>`, 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 -- <layer-id>`.
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.
+36 -1
View File
@@ -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 | 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,6 +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.
<a id="gnss-integrity"></a>
- **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.
+3 -1
View File
@@ -303,7 +303,9 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
| 🎖️ **Military Flights** | ADS-B military traffic in amber, emergency broadcasts in red | adsb.lol | 🟢 |
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
+3 -3
View File
@@ -21,19 +21,19 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
| ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
### Three deliberately client-side keys — restrict them
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use.
**Places, Geocoding and the Street View Static fallback never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles + Maps JavaScript API, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks.
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
+6 -7
View File
@@ -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/<family>/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/<family>/source` exports, including `layers/gnss/source`.
The latter retains the bundled dataset’s attribution and licensing requirements.
## Geocoding and feature-query providers
+111 -8
View File
@@ -101,6 +101,10 @@ lists at 25 rows by default. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
Catalog calls check their cancellation signal immediately before executing a
tool. Already-cancelled queries do not start; cancellation after execution starts
continues to follow the existing tool and caller behavior.
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -825,6 +829,30 @@ use the supplied asset resolver, including the preload path. Enrichment receives
an abort signal and cannot update a later lifecycle after destruction. Existing
camera, terrain floor, trail, selection and measured model-size policies remain.
## Transponder emergency highlighting
`src/data/aircraftEmergency.js` derives one status per poll from the Mode A
squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency)
and the readsb ADS-B emergency/priority field where present. Higher severity
wins, with ADS-B taking a tie. GEV treats `minfuel` and `lifeguard` as priorities;
`general`, `unlawful`, `nordo` and `downed` are emergency statuses.
Civil and military fleet billboards, 3D models and Cockpit dots turn red for
emergencies and repaint when the broadcast changes. Priorities retain normal
colors; tracked map colors keep their existing selection treatment. The Cockpit
meta line names the squawk or ADS-B status (only emergencies are red), replacing
COURSE ALIGNED while a status is present.
Analyst and tracked voice results describe broadcasts, not confirmed incidents.
Fields include `squawk`, `emergency`, `emergencyKind`, `emergencySource` and
`emergencyBroadcast`. `lastContactAtMs` is the last known transponder contact,
not the arrival time of this status; missing contact times retain known contact.
Civil fallback preserves status alongside unchanged OpenSky vectors. One poll
omitting ADS-B status may retain the previous status when the squawk is unchanged;
explicit clears and changed squawks take effect immediately. The next omitted
poll expires that retention. `scripts/qa-emergency-squawk.mjs` checks billboard
and model transitions, Cockpit HUD, clearing and tracking release.
## Browser live-source observations
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
@@ -1066,6 +1094,9 @@ collapse can still be undone.
## Surface keyboard lifecycle
Surface Tab cycling excludes controls with a negative tabindex, native disabled
state or inert ancestry; ordinary visible enabled controls retain their order.
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
focus shared by the first-run launcher and Provider Settings. Each caller
activates it while open and deactivates it on dismissal; destruction releases
@@ -1149,7 +1180,9 @@ stalled downloads are cancelled. Development and preview use the same handler.
Overpass query validation, geometry simplification, disk caching and upstream
transport now have separate modules; military-installation search reuses that
transport. Regional briefing combines separate place, news and weather sources,
while weather effects uses only the weather source. Existing process-scoped
while weather effects uses only the weather source.
GDELT compact dates that do not name a real calendar instant keep the article
with an unknown publication time. Existing process-scoped
caches, rate limits, stale fallbacks and route ordering are preserved.
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
@@ -1178,13 +1211,21 @@ a browser key. Preview is for local build verification, not a production server.
## CCTV and radio provider modules
CCTV direction text resolves travel tokens first, then intercardinals, then
bare cardinals when enabled. Only conflicting headings within the highest
matching tier remain unknown; street quadrants and lower-tier words do not
cancel an explicit travel heading.
CCTV catalog acquisition, source normalization and frame/media delivery now live
in separate modules. Each CCTV factory owns its catalog and health state; its
`sourceRoot` option resolves relative source files against the application root.
Radio Browser station normalization, restricted outbound transport and directory
caching are separate modules. Node-only package entries expose both provider
factories. Routes, payloads, fallback behavior and existing dev/preview hook
registration are preserved; browser rendering is unchanged.
caching are separate modules. The pinned transport gives a mirror's 204, 205 or
304 reply an empty body and fails any reply outside 200–599 without throwing
outside its promise, so an unusual mirror status cannot crash the server.
Node-only package entries expose both provider factories. Routes, payloads,
fallback behavior and existing dev/preview hook registration are preserved;
browser rendering is unchanged.
## Terrain, traffic, fire and bike-share provider modules
@@ -1198,6 +1239,10 @@ The browser layers and their rendering remain in their existing modules.
## Terrain height cache bound
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
latitudes outside [-90, 90] for every client before upstream resolution. One
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
The client terrain-height resolver's in-memory cache is bounded at 20 000
entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through
`createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated
@@ -1435,7 +1480,7 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status
FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential.
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry.
@@ -3145,8 +3190,34 @@ its criteria cannot be silently ignored.
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
nearby views share whole-degree cache keys, and a minute poll refreshes data.
Counts and bands cover visited views over 30 minutes, including regions now off
screen. Non-pickable ground primitives preserve Street Level and perimeter
selection. The legend describes a low-accuracy score, not the raw aircraft share;
`getStats().levels` exposes nominal, medium and high counts separately.
Transient source failures keep activation enabled and show unavailable/degraded
health with the next automatic retry. Upstream 429 deadlines are capped at one
hour, with the longest concurrent deadline retained; 5xx, timeout and malformed
responses impose a 15 s server cooldown. Cache hits and shared reads do not spend
upstream admission. Cache age and cooldown use a monotonic clock; observation
age comes from the upstream snapshot and is preserved in the row and voice stats.
Snapshots older than 30 minutes or without a source timestamp are refused.
Disable/destroy cancels pending requests, timers, subscriptions and primitives.
The GEV classifier remains unvalidated; anomalies do not confirm interference.
See [method, provenance and limits](../DATA_SOURCES.md#gnss-integrity), including
MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception.
`scripts/qa-gnss.mjs` covers live freshness, bands, built primitives, camera/globe
anchors, recovery, keyboard and mobile states; emergency repainting is covered
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
@@ -4105,7 +4176,8 @@ when its own signal aborts.
Configured endpoints have per-instance cooldowns for 406/429, honoring
Retry-After; absent delays back off from 30 seconds to five minutes. Empty
`elements` is a valid answer. Existing byte, query, concurrency and cache caps
remain. Consumer capability misses never schedule automatic retries.
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
Consumer capability misses never schedule automatic retries.
Operator-configured Overpass area and focus-footprint queries use body-level
`out geom` (with `center` for focus), preserving relation member outlines.
@@ -4403,7 +4475,24 @@ Providers normalize coordinates, canonical name, label, place types and optional
bounds. Camera framing, nearby landmark recovery and footprint selection remain
in their consumers, including the Capitol identity/containment safeguards.
Radio keeps localized country names in labels rather than station filters.
Reverse geocoding and nearby/text-search endpoints retain their existing behavior.
Nearby/text-search endpoints retain their existing behavior.
**Google geocoding is a server route, not a browser call** (#363). Forward and
reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`,
which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`)
and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle.
Google's Geocoding **web service** refuses referrer-restricted keys, so the
previous browser call forced the bundled browser key — visible in devtools by
design — to be left unrestricted; it now needs only the Map Tiles API and the
Maps JavaScript API (Street View). Both routes answer in Google's own
`status`/`results` shape rather than the Places routes' `places: []` contract,
because the browser adapters normalize that shape directly. The browser key still gates the provider client-side, so a keyless
session spends no request to learn it has no Google: the route's own keyless
answer reads as an unanswered provider, which would stop the chain caching the
negative Photon or Nominatim then produces. Its query is capped at 200
characters, its reverse coordinates are validated against WGS84 bounds before
the request is paid for, and a malformed viewport bias is dropped rather than
refused, since a bias only ranks results.
Only valid answers and definitive misses enter bounded caches; malformed replies,
HTTP refusals and outages remain retryable. Searches share a 12-second total
@@ -4486,8 +4575,22 @@ are omitted rather than framing the wrong part of the globe.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
### Import Your Own Files (October 2026)
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas.
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
### Recent Imagery (September 2026)
CMR pagination counts raw rows when checking page completion and the record budget. A catalog is truncated when upstream hits exceed the rows examined or the record cap drops valid granules from the last page; discarded malformed rows do not imply unseen imagery.
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
reject missing, blank or boolean edges instead of interpreting them as zero
coordinates. Valid finite numeric edges remain accepted; the app’s drag tool,
@@ -4507,7 +4610,7 @@ pins and share links already supply complete boxes.
- **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.
+1 -1
View File
@@ -13,7 +13,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,view_in_ar" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,upload_file,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<!-- gev:template scene-chrome -->
+2
View File
@@ -166,6 +166,7 @@
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
"./layers/perimeters": "./src/layers/perimeters/index.js",
"./layers/gnss": "./src/layers/gnss/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
@@ -239,6 +240,7 @@
"./layers/cctv/source": "./src/layers/cctv/source.js",
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
"./layers/gnss/source": "./src/layers/gnss/source.js",
"./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js",
+22 -1
View File
@@ -18,6 +18,10 @@
"src/tooling/panelStorageDocs.test.mjs",
"src/tooling/packageBoundaries.test.mjs",
"src/data/infrastructure.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftEmergency.test.mjs",
"src/data/aircraftEmergencyAnalyst.test.mjs",
"scripts/qa-emergency-squawk.mjs",
"src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
@@ -26,6 +30,8 @@
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
"src/annotations/drawTool.test.mjs",
"src/data/geoFileImport.test.mjs",
"src/ui/geoFileImport.test.mjs",
"src/annotations/adminOutline.test.mjs",
"src/annotations/regionRing.test.mjs",
"src/data/adminBoundaries.test.mjs",
@@ -199,6 +205,9 @@
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs",
"scripts/qa-geo-import-regressions.mjs",
"scripts/qa-geo-import-large.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
@@ -827,6 +836,16 @@
"server/providers/firePerimeters.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
"server/providers/gnssIntegrity.js",
"src/data/gnssIntegrityProxy.test.mjs",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/records.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.test.mjs",
"src/layers/gnss/source.test.mjs",
"src/layers/gnss/ownership.test.mjs",
"scripts/qa-gnss.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
@@ -1001,5 +1020,7 @@
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs"
"scripts/qa-gbfs-names.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs"
]
+46 -6
View File
@@ -837,6 +837,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",
@@ -850,6 +851,7 @@
"modules": [
"src/cyberSonar.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -898,6 +900,7 @@
"modules": [
"src/cyberSonar.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -1635,6 +1638,7 @@
"src/contextModePolicy.js",
"src/creditKeyboard.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -2147,6 +2151,9 @@
"src/data/inputOwnership.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
"src/data/geoFileImport.js",
"src/ui/flyToDataSource.js",
"src/ui/geoFileImport.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/layers/directions/source.js",
@@ -2170,6 +2177,10 @@
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/layers/cctv/sourcePolicy.js",
"src/sources/reference.js",
"src/search/nominatim.js",
@@ -2366,7 +2377,10 @@
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js",
"src/layers/installations/classLabel.js"
"src/layers/installations/classLabel.js",
"src/data/cameraFraming.js",
"src/data/geoImportPins.js",
"src/data/geoImportImages.js"
],
"external": [
"hls.js",
@@ -2433,6 +2447,7 @@
"src/celestialRing.js",
"src/cockpitMath.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -2770,6 +2785,10 @@
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
@@ -2908,7 +2927,8 @@
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js"
"src/layers/installations/classLabel.js",
"src/data/cameraFraming.js"
],
"external": [
"hls.js",
@@ -2973,7 +2993,8 @@
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/capability.js",
"src/data/cameraArrival.js"
"src/data/cameraArrival.js",
"src/data/cameraFraming.js"
],
"external": [
"cesium",
@@ -3081,7 +3102,8 @@
"src/layers/flights/recordPolicy.js",
"src/data/aircraftMeta.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js"
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
],
"external": []
},
@@ -3100,7 +3122,8 @@
"src/layers/military/recordPolicy.js",
"src/data/aircraftMeta.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js"
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
],
"external": []
},
@@ -3306,7 +3329,8 @@
"src/sources/httpBody.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/submarineCables/bundledSource.js"
"src/layers/submarineCables/bundledSource.js",
"src/layers/gnss/source.js"
],
"external": []
},
@@ -3525,6 +3549,21 @@
],
"external": ["cesium"]
},
"gnss": {
"exports": ["./layers/gnss"],
"modules": [
"src/layers/gnss/index.js",
"src/sources/httpBody.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js"
],
"external": ["cesium"]
},
"gnss-source": {
"exports": ["./layers/gnss/source"],
"modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"],
"external": []
},
"perimeters-source": {
"exports": ["./layers/perimeters/source"],
"modules": [
@@ -3652,6 +3691,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",
+554
View File
@@ -0,0 +1,554 @@
#!/usr/bin/env node
/**
* qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* `/api/flights` and `/api/military` are stubbed in the page so the codes
* are deterministic; everything downstream (normalizers, records, rendering,
* Cockpit HUD) is production code.
*
* (i) TINT — a civil 7600 and a military ADS-B `general` emergency are
* drawn red; a normal civil flight stays white, a normal military
* flight stays amber, and a military `minfuel` priority stays amber.
* POLL/MODELS verify existing untracked billboards and 3D models
* repaint when emergency status starts and clears.
* (ii) HUD — tracking the military emergency and entering Cockpit names
* the broadcast code on the aircraft-meta line, in red.
* (iii) CLEAR — the next poll drops the code: the HUD line returns to its
* normal wording (the status is not sticky).
* (iv) RELEASE — the code returns and tracking stops: the restored fleet
* billboard is red, not the plain military amber.
*
* Screenshots saved to qa-shots/emergency-*.png (gitignored).
*
* Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const CENTER = { lon: 4.0, lat: 53.5 };
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** OpenSky state vector: squawk sits at index 14. */
const civilRow = (icao24, callsign, lon, lat, squawk) => [
icao24,
callsign,
'Netherlands',
null,
null,
lon,
lat,
10000,
false,
230,
90,
0,
null,
10050,
squawk,
false,
0,
];
/** readsb row as adsb.lol /v2/mil returns it. */
const milRow = (hex, flight, lon, lat, squawk, emergency) => ({
hex,
flight,
lon,
lat,
alt_baro: 24000,
alt_geom: 24200,
gs: 380,
track: 270,
t: 'C17',
r: `QA-${hex}`,
squawk,
emergency,
seen: 1,
seen_pos: 1,
});
/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */
function installStubs() {
const realFetch = window.fetch.bind(window);
window.__squawkQaPhase = 'emergency';
const json = (body) =>
Promise.resolve(
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
const now = Date.now();
const { civil, mil } = window.__squawkQaFixtures;
if (
url.includes('/api/flights/track') ||
url.includes('/api/military/track')
)
return json({ path: [] });
if (url.includes('/api/flights')) {
const t = Math.floor(now / 1000);
return json({
time: t,
states: civil.map((row) => {
const copy = [...row];
copy[3] = t - 1;
copy[4] = t - 1;
return copy;
}),
});
}
if (url.includes('/api/military')) {
const cleared = window.__squawkQaPhase === 'cleared';
return json({
msg: 'No error',
now,
ac: mil.map((row) =>
cleared && row.hex === 'ae7700'
? { ...row, squawk: '4521', emergency: 'none' }
: row,
),
});
}
return realFetch(input, init);
};
}
/** Colour of every shown fleet billboard whose id is one of `ids`. */
function billboardColors(ids) {
const { viewer } = window.__godsEyeView;
const found = {};
const seen = new Set();
const visit = (primitive) => {
if (!primitive || seen.has(primitive)) return;
seen.add(primitive);
if (
typeof primitive.length !== 'number' ||
typeof primitive.get !== 'function'
)
return;
for (let i = 0; i < primitive.length; i++) {
const item = primitive.get(i);
if (item?.color && ids.includes(item.id) && item.show !== false) {
found[item.id] = {
r: Math.round(item.color.red * 255),
g: Math.round(item.color.green * 255),
b: Math.round(item.color.blue * 255),
};
} else visit(item);
}
};
visit(viewer.scene.primitives);
return found;
}
const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110;
const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220;
const isAmber = (c) =>
Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c);
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 30000 },
)
.catch(() => {});
await sleep(600);
await page.screenshot({
path: path.join(SHOTS_DIR, `emergency-${name}.png`),
});
}
async function main() {
console.log('\nEmergency squawk proof (qa-emergency-squawk)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
protocolTimeout: 180000,
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
const { lon, lat } = CENTER;
await page.evaluateOnNewDocument(
(fixtures) => {
window.__squawkQaFixtures = fixtures;
},
{
civil: [
civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'),
civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'),
],
mil: [
milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'),
milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'),
milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'),
],
},
);
// Stub in-page rather than with request interception: an intercepted page
// stalls Cesium's workers (see qa-gnss.mjs).
await page.evaluateOnNewDocument(installStubs);
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian(
{
longitude: lo * d2r,
latitude: la * d2r,
height: 260000,
},
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
);
// ── (i) TINT ──────────────────────────────────────────────────────────
console.log('(i) TINT — emergency codes through the real layers...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('flights', true, { origin: 'user' });
await dm.setEnabled('military', true, { origin: 'user' });
});
const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001'];
const probe = `(${billboardColors})(${JSON.stringify(ids)})`;
await page
.waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, {
timeout: TIMEOUT_MS,
polling: 500,
})
.catch(() => {});
await sleep(1500);
const colors = await page.evaluate(probe);
const show = (id) => JSON.stringify(colors[id] ?? null);
record(
'TINT: civil squawk 7600 is red',
isRed(colors['4847a1']),
show('4847a1'),
);
record(
'TINT: normal civil flight stays white',
isWhite(colors['4847a2']),
show('4847a2'),
);
record(
'TINT: military ADS-B general emergency is red',
isRed(colors.ae7700),
show('ae7700'),
);
record(
'TINT: military minimum-fuel priority stays amber',
isAmber(colors.ae0f01),
show('ae0f01'),
);
record(
'TINT: normal military flight stays amber',
isAmber(colors.ae0001),
show('ae0001'),
);
await shoot(page, 'globe-tint');
// Existing untracked billboards and models must repaint in both directions.
await page.evaluate(async () => {
window.__squawkQaPhase = 'cleared';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
record(
'POLL: existing military billboard clears to amber',
isAmber((await page.evaluate(probe)).ae7700),
);
await page.evaluate(async () => {
window.__squawkQaPhase = 'emergency';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
record(
'POLL: existing military billboard turns red again',
isRed((await page.evaluate(probe)).ae7700),
);
const modelProbe = () => {
const found = {};
const visit = (items) => {
for (let i = 0; i < items.length; i++) {
const item = items.get(i);
if (item.ready && item.color && typeof item.id === 'string')
found[item.id] = {
r: Math.round(item.color.red * 255),
g: Math.round(item.color.green * 255),
b: Math.round(item.color.blue * 255),
};
else if (item.get) visit(item);
}
};
visit(window.__godsEyeView.viewer.scene.primitives);
return found;
};
await page.waitForFunction(
`(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`,
{ timeout: TIMEOUT_MS, polling: 250 },
);
const beforeModels = await page.evaluate(modelProbe);
record(
'MODELS: civil and military emergency models are red',
isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700),
JSON.stringify(beforeModels),
);
await page.evaluate(async () => {
window.__squawkQaFixtures.civil[0][14] = '2000';
window.__squawkQaFixtures.civil[1][14] = '7600';
window.__squawkQaPhase = 'cleared';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('flights');
await dm.refreshLayer('military');
});
await page.waitForFunction(
`(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`,
{ timeout: TIMEOUT_MS, polling: 250 },
);
const afterModels = await page.evaluate(modelProbe);
record(
'MODELS: existing civil models start and clear, military clears',
isWhite(afterModels['4847a1']) &&
isRed(afterModels['4847a2']) &&
isAmber(afterModels.ae7700),
JSON.stringify(afterModels),
);
await page.evaluate(async () => {
window.__squawkQaFixtures.civil[0][14] = '7600';
window.__squawkQaFixtures.civil[1][14] = '2000';
window.__squawkQaFixtures.mil[0].squawk = '7700';
window.__squawkQaPhase = 'emergency';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('flights');
await dm.refreshLayer('military');
});
// ── (ii) HUD ──────────────────────────────────────────────────────────
console.log('\n(ii) HUD — track the emergency and enter Cockpit...');
const tracked = await page.evaluate(() =>
Boolean(
window.__godsEyeView.dataManager.layers
.get('military')
.module.trackById('ae7700', { origin: 'user' }),
),
);
record(
'HUD: emergency aircraft can be tracked',
tracked,
`tracked=${tracked}`,
);
// Cockpit entry is gated on the Contacts (flights) context mode.
await page.evaluate(() =>
window.__godsEyeView.styleManager._contextControls._selectContextMode(
'flights',
),
);
await page
.waitForFunction(
() => {
const entry = document.getElementById('cockpit-entry');
return entry && !entry.hidden && !entry.disabled;
},
{ timeout: 20000 },
)
.catch(() => {});
await page
.$eval('#cockpit-entry', (entry) => entry.click())
.catch(() => {});
const hudReady = await page
.waitForFunction(
() =>
document.body.classList.contains('cockpit-mode') &&
/SQUAWK 7700/.test(
document.getElementById('cockpit-aircraft-meta')?.textContent || '',
),
{ timeout: 30000 },
)
.then(
() => true,
() => false,
);
const hud = await page.evaluate(() => {
const meta = document.getElementById('cockpit-aircraft-meta');
return {
cockpit: document.body.classList.contains('cockpit-mode'),
text: meta?.textContent || '',
emergencyClass: meta?.classList.contains('emergency') || false,
color: meta ? getComputedStyle(meta).color : null,
};
});
record(
'HUD: meta line names the broadcast code',
hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'),
JSON.stringify(hud),
);
record(
'HUD: meta line is styled as an emergency',
hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''),
`class=${hud.emergencyClass} color=${hud.color}`,
);
const trackedBroadcast = await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('military')
.module.getTrackedInfo().emergencyBroadcast,
);
record(
'TRACKED: voice-facing status quotes the broadcast',
trackedBroadcast === 'broadcasting squawk 7700 (general emergency)',
trackedBroadcast,
);
await shoot(page, 'cockpit-hud');
// ── (iii) CLEAR ───────────────────────────────────────────────────────
console.log('\n(iii) CLEAR — the next poll drops the code...');
await page.evaluate(async () => {
window.__squawkQaPhase = 'cleared';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
const cleared = await page
.waitForFunction(
() =>
!document
.getElementById('cockpit-aircraft-meta')
?.classList.contains('emergency'),
{ timeout: 20000 },
)
.then(
() => true,
() => false,
);
const after = await page.evaluate(
() => document.getElementById('cockpit-aircraft-meta')?.textContent || '',
);
record(
'CLEAR: HUD returns to its normal wording',
cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'),
JSON.stringify(after),
);
await shoot(page, 'cockpit-cleared');
// ── (iv) RELEASE ──────────────────────────────────────────────────────
console.log('\n(iv) RELEASE — the code returns, then tracking stops...');
await page.evaluate(async () => {
window.__squawkQaPhase = 'emergency';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('military');
dm.layers.get('military').module.stopTracking({ origin: 'user' });
});
const releaseProbe = `(${billboardColors})(["ae7700"])`;
const released = await page
.waitForFunction(`Boolean((${releaseProbe}).ae7700)`, {
timeout: 20000,
polling: 250,
})
.then(
() => true,
() => false,
);
const releasedColor = (await page.evaluate(releaseProbe)).ae7700;
record(
'RELEASE: untracked emergency billboard is red',
released && isRed(releasedColor),
JSON.stringify(releasedColor ?? null),
);
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env node
/** Large KML acceptance: style equivalence, 27.6 MB import and optional real file.
* QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml.
* For a USGS fixture with blocked icons, add --expect-local-pins to check paint.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
const supplied = process.argv.includes('--file')
? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
: null;
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-large-kml-'));
const file = path.join(directory, 'metadata-rich-27mb.kml');
const style =
'<Style><IconStyle><scale>0.6</scale><color>ff00dd66</color></IconStyle></Style>';
const notes = 'A local observation with supporting detail. '.repeat(120);
const placemarks = Array.from({ length: 5000 }, (_, i) => {
const lon = -122.6 + (i % 100) * 0.002;
const lat = 37.65 + Math.floor(i / 100) * 0.003;
return `<Placemark><name>Point ${i}</name><styleUrl>#base</styleUrl>${style}<description>${notes}</description><Point><coordinates>${lon},${lat},0</coordinates></Point></Placemark>`;
});
const wrap = (body) =>
`<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Style id="base"><IconStyle><heading>25</heading><scale>1.2</scale></IconStyle><LineStyle><color>ffdd6600</color><width>7</width></LineStyle><PolyStyle><color>6600ddff</color><fill>1</fill><outline>0</outline></PolyStyle></Style>${body}</Document></kml>`;
const large = wrap(placemarks.join(''));
fs.writeFileSync(file, large);
const bytes = fs.statSync(file).size;
if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024)
throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`);
const small = wrap(
Array.from({ length: 6 }, (_, i) => {
const kind = i % 3;
const geometry =
kind === 0
? '<Point><coordinates>-122.49,37.77,0</coordinates></Point>'
: kind === 1
? '<LineString><coordinates>-122.50,37.77,0 -122.49,37.77,0</coordinates></LineString>'
: '<Polygon><outerBoundaryIs><LinearRing><coordinates>-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0</coordinates></LinearRing></outerBoundaryIs></Polygon>';
const override =
kind === 0
? style
: kind === 1
? '<Style><LineStyle><width>3</width></LineStyle></Style>'
: '<Style><PolyStyle><outline>1</outline></PolyStyle></Style>';
return `<Placemark><name>Small ${i}</name><styleUrl>#base</styleUrl>${override}${geometry}</Placemark>`;
}).join('') +
'<Style id="named-a"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<Style id="named-b"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<StyleMap id="paired"><Pair><key>normal</key><Style><IconStyle><scale>0.8</scale></IconStyle></Style></Pair></StyleMap>' +
['named-a', 'named-b', 'paired']
.map(
(id) =>
`<Placemark><name>${id}</name><styleUrl>#${id}</styleUrl><Point><coordinates>-122.49,37.77,0</coordinates></Point></Placemark>`,
)
.join(''),
);
const browser = await puppeteer.launch({
headless: true,
args:
process.platform === 'darwin'
? ['--use-gl=angle', '--use-angle=metal']
: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const fileRequests = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (new URL(request.url()).hostname === 'earthquake.usgs.gov') {
fileRequests.push(request.url());
void request.abort();
} else void request.continue();
});
let failures = 0;
const check = (name, ok, detail) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? '');
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView;
const C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.clock.shouldAnimate = false;
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
window.__qaStyleSnapshot = (source) => {
const now = viewer.clock.currentTime;
const images = new Map();
const imageValue = (p) => {
const value = p?.getValue(now);
if (value?.tagName !== 'CANVAS') return value;
if (!images.has(value)) images.set(value, value.toDataURL());
return images.get(value);
};
const color = (p) => p?.getValue(now)?.toCssColorString();
const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
const renderedPins = new Map();
const collection = source.importedBillboards;
if (collection)
for (let i = 0; i < collection.length; i++) {
const pin = collection.get(i);
renderedPins.set(pin.id, pin);
}
const actual = (e, key, fallback) => {
const pin = renderedPins.get(e);
return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
};
return (source.importedEntities || source.entities.values)
.map((e) => ({
name: e.name,
point: renderedPins.get(e)?.position || e.position?.getValue(now),
scale: e.billboard ? actual(e, 'scale', 1) : undefined,
rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
color: e.billboard
? actual(e, 'color', C.Color.WHITE).toCssColorString()
: undefined,
width: e.polyline?.width?.getValue(now),
lineColor: color(e.polyline?.material?.color),
line: positions(e.polyline?.positions),
fill: e.polygon?.fill?.getValue(now),
outline: e.polygon?.outline?.getValue(now),
polygonColor: color(e.polygon?.material?.color),
polygon: e.polygon?.hierarchy
?.getValue(now)
?.positions?.map((v) => [v.x, v.y, v.z]),
}))
.sort((a, b) => a.name.localeCompare(b.name));
};
window.__qaUnsharedKml = async (text) => {
const doc = new DOMParser().parseFromString(text, 'application/xml');
for (const el of [...doc.getElementsByTagName('*')])
if (
['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName)
)
el.remove();
const source = await C.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
});
for (const e of source.importedEntities || source.entities.values)
e.label = undefined;
return source;
};
});
const equivalent = await page.evaluate(async (text) => {
const { viewer } = window.__godsEyeView;
const control = await window.__qaUnsharedKml(text);
const outcomes = await window.__gevGeoImport.importFiles([
new File([text], 'style-equivalence.kml'),
]);
const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
const result = {
ok: outcomes[0]?.ok,
same:
JSON.stringify(window.__qaStyleSnapshot(control)) ===
JSON.stringify(window.__qaStyleSnapshot(candidate)),
entities: (candidate.importedEntities || candidate.entities.values)
.length,
};
await window.__gevGeoImport.remove(outcomes[0]?.id);
return result;
}, small);
check(
'shared inline and named styles preserve point, line and polygon overrides/inheritance',
equivalent.ok && equivalent.same && equivalent.entities === 9,
JSON.stringify(equivalent),
);
// The control bypasses the former style-count refusal solely in this test,
// to compare native unshared rendering against normalized rendering.
await page.evaluate(async (text) => {
const source = await window.__qaUnsharedKml(text);
window.__qaUnsharedSnapshot = JSON.stringify(
window.__qaStyleSnapshot(source),
);
window.__qaControl = source;
await window.__godsEyeView.viewer.dataSources.add(source);
}, large);
const measure = async () =>
page.evaluate(async () => {
const C = window.__CESIUM__,
{ viewer } = window.__godsEyeView;
const scene = viewer.scene;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const started = performance.now(),
samples = [];
let last,
frames = 0;
const remove = scene.postRender.addEventListener(() => {
const t = performance.now();
if (t - started < 2000) return;
frames++;
if (last !== undefined) samples.push(t - last);
last = t;
});
await new Promise((resolve) => {
let previous = performance.now();
function tick(t) {
viewer.camera.rotate(
C.Cartesian3.UNIT_Z,
Math.min(t - previous, 100) * 0.0000002,
);
previous = t;
scene.requestRender();
if (t - started < 7000) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
remove();
samples.sort((a, b) => a - b);
const gl = scene.context._gl,
debug = gl.getExtension('WEBGL_debug_renderer_info');
return {
fps: frames / 5,
p50Ms: samples[Math.floor(samples.length * 0.5)],
p95Ms: samples[Math.floor(samples.length * 0.95)],
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null,
};
});
console.log('UNSHARED CONTROL', JSON.stringify(await measure()));
await page.evaluate(() =>
window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true),
);
const loadPicked = async (filename, expected) => {
const before = await page.evaluate(
() => window.__godsEyeView.viewer.dataSources.length,
);
const priorIds = await page.evaluate(() =>
window.__gevGeoImport.list().map((f) => f.id),
);
const started = performance.now();
await (await page.$('#geo-import-input')).uploadFile(filename);
const name = path.basename(filename);
const loaded = await page
.waitForFunction(
({ name, priorIds }) =>
window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) ||
document
.getElementById('geo-import-hint')
.textContent.startsWith(`${name}:`),
{ timeout: 120000 },
{ name, priorIds },
)
.then(
() => true,
() => false,
);
const state = await page.evaluate(() => ({
files: window.__gevGeoImport.list(),
hint: document.getElementById('geo-import-hint').textContent,
sources: window.__godsEyeView.viewer.dataSources.length,
}));
const item = state.files.find((f) => !priorIds.includes(f.id));
check(
`picked ${name} loads`,
loaded &&
(expected === null
? item?.entityCount > 0
: item?.entityCount === expected) &&
state.sources === before + 1,
JSON.stringify({
bytes: fs.statSync(filename).size,
loadMs: Math.round(performance.now() - started),
...state,
}),
);
return item;
};
const item = await loadPicked(file, 5000);
if (item) {
const same = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
return (
JSON.stringify(window.__qaStyleSnapshot(source)) ===
window.__qaUnsharedSnapshot
);
});
check('all 5,000 normalized graphics match their unshared control', same);
console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure()));
await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id);
}
if (supplied) {
const clockBefore = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
const suppliedItem = await loadPicked(supplied, null);
const clockAfter = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
check(
'real dated KML leaves the shared clock unchanged',
JSON.stringify(clockBefore) === JSON.stringify(clockAfter),
);
if (suppliedItem && process.argv.includes('--expect-local-pins')) {
const markers = await page.evaluate(() => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
const points = (
source.importedEntities || source.entities.values
).filter((e) => e.position && e.billboard);
const images = points.filter((e) =>
e.billboard.image?.getValue(viewer.clock.currentTime),
);
if (images.length) {
const at = C.Cartographic.fromCartesian(
images[0].position.getValue(viewer.clock.currentTime),
);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromRadians(
at.longitude,
at.latitude,
100000,
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}
return { points: points.length, images: images.length };
});
check(
'supplied point markers have drawable images',
markers.points > 0 && markers.images === markers.points,
JSON.stringify(markers),
);
if (markers.points) {
const yellowPixels = async () => {
const shot = await page.screenshot({
clip: { x: 620, y: 350, width: 200, height: 200 },
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height),
context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const data = context.getImageData(
0,
0,
bitmap.width,
bitmap.height,
).data;
let count = 0;
for (let i = 0; i < data.length; i += 4)
if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100)
count++;
return count;
}, shot);
};
const setVisible = (visible) =>
page.evaluate(
({ id, visible }) => window.__gevGeoImport.setVisible(id, visible),
{ id: suppliedItem.id, visible },
);
await setVisible(false);
await new Promise((resolve) => setTimeout(resolve, 1000));
const hidden = await yellowPixels();
await setVisible(true);
let shown = hidden;
const deadline = performance.now() + 10000;
while (performance.now() < deadline && shown <= hidden + 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
shown = await yellowPixels();
}
check(
'real USGS markers paint when shown and disappear when hidden',
shown > hidden + 10,
JSON.stringify({ hidden, shown }),
);
await page.screenshot({
path: path.join(directory, 'real-kml-visible.png'),
});
}
}
if (suppliedItem)
await page.evaluate(
(id) => window.__gevGeoImport.remove(id),
suppliedItem.id,
);
}
check(
'file-directed USGS icon/metadata requests never leave the page',
fileRequests.length === 0,
fileRequests.length,
);
errors.push(...(await readRenderErrors(page)));
check(
'no uncaught page or render errors',
errors.length === 0,
JSON.stringify(errors),
);
check(
'all imported sources are removed',
await page.evaluate(() => window.__gevGeoImport.list().length === 0),
);
console.log('Fixture retained at', file);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env node
/** Import regressions through the real UI controller and Cesium loaders.
* Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
*/
import puppeteer from 'puppeteer';
import * as Cesium from 'cesium';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
const meshBytes = Buffer.alloc(60);
[
-2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000,
].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4));
[0, 1, 2, 2, 1, 3].forEach((value, i) =>
meshBytes.writeUInt16LE(value, 48 + i * 2),
);
const fixture = {
'/__qa_import_surface/tileset.json': {
asset: { version: '1.1' },
geometricError: 0,
root: {
transform: Cesium.Matrix4.toArray(
Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-122.5, 37.7),
),
),
boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] },
geometricError: 0,
content: { uri: 'surface.gltf' },
},
},
'/__qa_import_surface/surface.gltf': {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [
{
primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
},
],
materials: [{ doubleSided: true }],
buffers: [
{
byteLength: 60,
uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`,
},
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 },
{ buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 },
],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 4,
type: 'VEC3',
min: [-2000, 2000, -2000],
max: [2000, 2000, 2000],
},
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
],
},
};
await page.setRequestInterception(true);
page.on('request', (request) => {
const body = fixture[new URL(request.url()).pathname];
if (body)
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
else void request.continue();
});
const errors = [],
requests = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => {
if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text());
});
await hookRenderErrors(page);
let failed = 0;
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView,
C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.clock.shouldAnimate = false;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
});
page.on('request', (r) => {
if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
});
const results = await page.evaluate(async () => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__,
tool = window.__gevGeoImport;
const results = [],
now = C.JulianDate.clone(viewer.clock.currentTime);
const check = (name, pass, detail = '') =>
results.push({ name, pass: Boolean(pass), detail });
const wrap = (body, attrs = '') =>
`<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" ${attrs}><Document>${body}</Document></kml>`;
const point = '<Point><coordinates>-122.5,37.7,0</coordinates></Point>';
const load = async (value, name) => {
const content =
typeof value === 'object' && !ArrayBuffer.isView(value)
? JSON.stringify(value)
: value;
const [outcome] = await tool.importFiles([new File([content], name)]);
return {
outcome,
source: outcome.ok
? viewer.dataSources.get(viewer.dataSources.length - 1)
: null,
};
};
const remove = (r) => {
if (r.outcome.ok) tool.remove(r.outcome.id);
};
let r = await load(
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'kartaview-owned',
properties: {
name: 'XYZ',
addProperty: 1,
'marker-symbol': 'rail',
},
geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-122.5, 37.7, 10],
[-122.4, 37.7, 10],
[-122.4, 37.8, 10],
[-122.5, 37.7, 10],
],
],
},
},
],
},
'xyz.geojson',
);
const pin = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.billboard,
),
polygon = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.polygon,
);
check(
'XYZ ground footprint and isolated entity IDs',
r.outcome.ok &&
pin.id !== 'kartaview-owned' &&
Math.abs(
C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
) < 0.01 &&
polygon.polygon.perPositionHeight?.getValue(now) !== true,
);
check(
'ground pins have no persistent clamping callbacks',
pin?.billboard.heightReference?.getValue(now) ===
C.HeightReference.NONE &&
pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
);
remove(r);
r = await load(
wrap(
'<Placemark><name>ground</name><gx:Track><gx:coord>-122.5 37.7 0</gx:coord><gx:coord>-122.4 37.8 0</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>absolute</name><gx:Track><altitudeMode>absolute</altitudeMode><gx:coord>-122.5 37.7 10000</gx:coord><gx:coord>-122.4 37.8 10000</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>line</name><LineString><coordinates>-122.5,37.7 -122.4,37.8</coordinates></LineString></Placemark>',
),
'tracks.kml',
);
const find = (name) =>
(r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.name === name,
);
check(
'ground tracks and untessellated authored lines clamp',
find('ground')?.polyline.clampToGround?.getValue(now) &&
find('line')?.polyline.clampToGround?.getValue(now),
);
check(
'explicit track altitude and original vertices survive',
find('absolute')?.polyline.positions.getValue(now).length === 2 &&
find('absolute')
.polyline.positions.getValue(now)
.every(
(p) =>
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
),
);
remove(r);
r = await load(
wrap(
`<Style id="s"><IconStyle><scale>0.5</scale></IconStyle></Style><Placemark><styleUrl> #s </styleUrl>${point}</Placemark>`,
),
'local-style.kml',
);
check('whitespace around a local style remains local', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark><StyleMap><Pair><key>normal</key><styleUrl>https://gev-qa-trap.invalid/style</styleUrl></Pair></StyleMap>${point}</Placemark>`,
),
'incomplete-style.kml',
);
check('removing external style does not hang the queue', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Folder><visibility>0</visibility><Placemark>${point}</Placemark></Folder>`,
),
'hidden-folder.kml',
);
check(
'import show controls own previously hidden KML content',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).every(
(e) => e.isShowing,
),
);
remove(r);
r = await load(
wrap(
'<GroundOverlay><color>ff00ff00</color><LatLonBox><north>37.8</north><south>37.7</south><east>-122.4</east><west>-122.5</west></LatLonBox></GroundOverlay>',
),
'colored.kml',
);
check(
'a colored overlay without an icon is drawable',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).some(
(e) => e.rectangle,
),
);
remove(r);
const xml = wrap(`<Placemark>${point}</Placemark>`);
const utf16 = new Uint8Array(2 + xml.length * 2);
utf16.set([255, 254]);
for (let i = 0; i < xml.length; i++) {
utf16[2 + i * 2] = xml.charCodeAt(i) & 255;
utf16[3 + i * 2] = xml.charCodeAt(i) >> 8;
}
r = await load(utf16, 'utf16.kml');
check('UTF-16 BOM is decoded', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark>${point}</Placemark>`,
'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"',
),
'namespaces.kml',
);
check(
'ordinary duplicate and missing export namespaces are repaired',
r.outcome.ok,
);
remove(r);
for (const [name, value] of [
['null geometry', { type: 'Feature', geometry: null }],
['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }],
[
'missing feature type',
{
type: 'FeatureCollection',
features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }],
},
],
[
'nested unsupported geometry',
{ type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
],
]) {
r = await load(value, 'refused.geojson');
check(`${name} is refused without stopping rendering`, !r.outcome.ok);
remove(r);
}
r = await load(
wrap(
'<Placemark><Point><coordinates>NaN,37,0</coordinates></Point></Placemark>',
),
'invalid.kml',
);
check('non-finite KML coordinates are refused', !r.outcome.ok);
remove(r);
r = await load(
{
type: 'LineString',
coordinates: [
[-122.6, 37.7],
[-122.4, 37.7],
],
},
'portrait.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const positions = (r.source?.importedEntities ||
r.source?.entities.values)[0].polyline.positions.getValue(now);
const screen = positions?.map((p) =>
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
);
check(
'portrait flight includes both route endpoints',
screen?.every(
(p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight,
),
screen?.map((p) => p && [p.x, p.y]),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[-74, 40.7],
[139.7, 35.7],
],
},
'intercontinental.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const intercontinental = (
r.source?.importedEntities || r.source?.entities.values
)
.filter((e) => e.position)
.map((e) => e.position.getValue(now));
const occluder = new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
check(
'intercontinental points remain in frame and in front of the horizon',
intercontinental?.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
return (
occluder.isPointVisible(p) &&
s &&
s.x > 0 &&
s.x < innerWidth &&
s.y > 0 &&
s.y < innerHeight
);
}),
);
remove(r);
r = await load(
wrap(
'<Placemark><LineString><altitudeMode>absolute</altitudeMode><extrude>1</extrude><coordinates>4.9,52.37,3000 5.12,52.09,3000</coordinates></LineString></Placemark><Placemark><Point><coordinates>5.12,52.09</coordinates></Point></Placemark>',
),
'wall.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
check(
'a wall contributes both endpoints to the flight',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values)
.find((e) => e.wall)
?.wall.positions.getValue(now)
.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
p,
);
return (
s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
);
}),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[0, 0],
[180, 0],
],
},
'antipodal.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 1800));
check(
'symmetric global extents keep a finite camera',
r.outcome.ok &&
Number.isFinite(viewer.camera.positionCartographic.height),
);
remove(r);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const mesh = await C.Cesium3DTileset.fromUrl(
`${location.origin}/__qa_import_surface/tileset.json`,
);
viewer.scene.primitives.add(mesh);
// This fixture installs a mesh outside the app's map-source lifecycle.
// Let the new camera/tiles reach a rendered frame before testing sampling.
await new Promise((resolve) => {
let frames = 0;
const remove = viewer.scene.postRender.addEventListener(() => {
if (++frames >= 2) {
remove();
resolve();
} else viewer.scene.requestRender();
});
viewer.scene.requestRender();
});
const fixtureDeadline = performance.now() + 5000;
while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) {
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 50));
}
const globeShown = viewer.scene.globe.show;
viewer.scene.globe.show = false;
r = await load(
{ type: 'Point', coordinates: [-122.5, 37.7, 0] },
'mesh-point.geojson',
);
const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
.find((e) => e.billboard)
?.position.getValue(now);
const meshHeight =
meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
check(
'native 3D Tiles ground sampling keeps pins on the rendered mesh',
r.outcome.ok && Math.abs(meshHeight - 2000) < 2,
{
meshHeight,
tilesLoaded: mesh.tilesLoaded,
globeShown: viewer.scene.globe.show,
},
);
tool.flyTo(r.outcome.id);
await new Promise((resolve) => setTimeout(resolve, 2300));
const meshScreen =
meshPoint &&
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
check(
'a high-surface arrival frames the elevated pin',
meshScreen &&
Math.abs(meshScreen.x - innerWidth / 2) < 20 &&
Math.abs(meshScreen.y - innerHeight / 2) < 20,
{
screen: meshScreen && [meshScreen.x, meshScreen.y],
camera: {
height: viewer.camera.positionCartographic.height,
pitch: viewer.camera.pitch,
},
meshHeight,
},
);
const groundSource = r.source;
const elevated = await load(
wrap(
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
),
'elevated-pin.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2600));
check(
'a higher imported pin never becomes the sampled ground',
elevated.outcome.ok &&
Math.abs(
C.Cartographic.fromCartesian(
groundSource.importedBillboards.get(0).position,
).height - 2000,
) < 2,
);
remove(elevated);
remove(r);
viewer.scene.primitives.remove(mesh);
viewer.scene.globe.show = globeShown;
const before = tool.list().length;
const transfer = new DataTransfer();
transfer.items.add(
new File(
[JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })],
'panel-drop.geojson',
),
);
const event = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
});
document.getElementById('geo-import-button').dispatchEvent(event);
for (let i = 0; i < 100 && tool.list().length === before; i++)
await new Promise((resolve) => setTimeout(resolve, 20));
check(
'a panel drop imports and prevents the browser default',
event.defaultPrevented && tool.list().length === before + 1,
);
for (const file of tool.list()) tool.remove(file.id);
check(
'the shared clock stays exactly unchanged',
C.JulianDate.equals(now, viewer.clock.currentTime),
);
check('no file sources remain', tool.list().length === 0);
return results;
});
for (const r of results) {
console.log(
`[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`,
JSON.stringify(r.detail),
);
if (!r.pass) failed++;
}
const renderErrors = await readRenderErrors(page);
if (errors.length || renderErrors.length) {
failed++;
console.log(
'[FAIL] browser/render errors',
JSON.stringify({ errors, renderErrors }),
);
}
// Terrain belongs to the base map; imported files may sample that provider.
const unexpected = requests.filter(
(url) =>
new URL(url).pathname !== '/logo.svg' &&
!new URL(url).pathname.startsWith('/__qa_import_surface/') &&
!/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test(
url,
),
);
console.log(
`[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`,
JSON.stringify(unexpected),
);
if (unexpected.length) failed++;
} finally {
await browser.close();
}
process.exitCode = failed ? 1 : 0;
+594
View File
@@ -0,0 +1,594 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
* each become one row and one data source; the imported shape actually paints
* on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
* it) and stops painting when hidden; no import makes the page request
* anything named in it (network link, remote icon, remote style, palette
* icon, balloon or extended-data HTML, maki marker) or anything outside the
* page for it; no entity keeps a description, label or properties; dated KML
* and GPX leave the app's clock alone and still draw now; a GPX track keeps
* its two segments apart; keyboard focus survives hide/show; a dropped file
* imports like a picked one; a file that is not geodata is refused with a
* reason and leaves nothing behind; Remove gives every data source back; no
* page errors.
*
* Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
* QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
const HEADFUL = process.env.QA_HEADFUL === '1';
// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 };
const TRAP_HOST = 'gev-qa-trap.invalid';
/* ── fixtures ──────────────────────────────────────────────────────────── */
const ring = [
[-122.511, 37.7645],
[-122.4545, 37.7645],
[-122.4545, 37.7745],
[-122.511, 37.7745],
[-122.511, 37.7645],
];
const geojson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
// A file's own simplestyle and HTML must not survive the import.
properties: {
name: 'QA park',
stroke: '#ff0000',
fill: '#ff0000',
'fill-opacity': 1,
description: `<img src="https://${TRAP_HOST}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] },
},
{
type: 'Feature',
properties: { name: 'QA pin', 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] },
},
],
});
const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP_HOST}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP_HOST}/balloon.png">]]></description>
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
<ExtendedData><Data name="note"><value><![CDATA[<iframe src="https://${TRAP_HOST}/extended.html"></iframe>]]></value></Data></ExtendedData>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
<Placemark><name>QA moving</name>
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
<Placemark><name>QA split tracks</name><gx:MultiTrack>
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
</gx:MultiTrack></Placemark>
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><desc>&lt;img src="https://${TRAP_HOST}/gpx.png"&gt;</desc><trkseg>
<trkpt lat="37.766" lon="-122.505"><time>2018-06-01T10:00:00Z</time></trkpt><trkpt lat="37.768" lon="-122.49"><time>2018-06-01T10:05:00Z</time></trkpt>
</trkseg><trkseg>
<trkpt lat="37.770" lon="-122.48"><time>2018-06-01T11:00:00Z</time></trkpt><trkpt lat="37.772" lon="-122.47"><time>2018-06-01T11:05:00Z</time></trkpt>
</trkseg></trk>
</gpx>`;
// 1×1 PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
/** Stored (uncompressed) zip — enough for a KMZ fixture. */
function storedZip(files) {
const parts = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(n.length, 26);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt32LE(data.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(n.length, 28);
c.writeUInt32LE(offset, 42);
parts.push(local, n, data);
central.push(c, n);
offset += 30 + n.length + data.length;
}
const dir = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(dir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, dir, end]);
}
const kmzDoc = kml('files/pin.png').replace(
`<href>https://${TRAP_HOST}/overlay.png</href>`,
'<href>files/overlay.png</href>',
);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-'));
const fixture = (name, data) => {
const file = path.join(dir, name);
fs.writeFileSync(file, data);
return file;
};
const FILES = [
fixture('park.geojson', geojson),
fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)),
fixture(
'packed.kmz',
storedZip([
['doc.kml', Buffer.from(kmzDoc)],
['files/pin.png', PNG],
['files/overlay.png', PNG],
]),
),
fixture('walk.gpx', gpx),
];
/* ── harness ───────────────────────────────────────────────────────────── */
const browser = await puppeteer.launch({
headless: !HEADFUL,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const trapped = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
page.on('console', (message) => {
if (/Ignoring clampToGround/.test(message.text()))
errors.push(message.text());
});
// Anything a file names, and the icon assets Cesium would load on a file's
// behalf (KML palette icons from maps.google.com, GeoJSON maki markers).
const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/'];
page.on('request', (request) => {
if (FILE_FETCH.some((needle) => request.url().includes(needle)))
trapped.push(request.url());
});
let failures = 0;
function check(name, passed, detail = '') {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures++;
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const shot = (name) =>
page.screenshot({
path: path.join(SHOT_DIR, `${name}.jpg`),
type: 'jpeg',
quality: 82,
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const CLIP = { x: 380, y: 240, width: 480, height: 320 };
/** Pixels in the clip close to the first import color (#39d0ff). */
async function cyanPixels() {
await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender());
await wait(600);
const base64 = await page.screenshot({
clip: CLIP,
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (b64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${b64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
let n = 0;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1;
}
return n;
}, base64);
}
const state = () =>
page.evaluate(() => ({
files: window.__gevGeoImport.list(),
dataSources: window.__godsEyeView.viewer.dataSources.length,
rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
}));
try {
await page.setViewport({ width: 1280, height: 860 });
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
window.__godsEyeView?.viewer &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
await page.evaluate(async (view) => {
const viewer = window.__godsEyeView.viewer;
for (const id of window.__godsEyeView.dataManager.layers.keys())
await window.__godsEyeView.dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider();
viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK;
viewer.scene.globe.enableLighting = false;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('geo-import-button')?.getBoundingClientRect()
.width > 0,
{ timeout: 10_000 },
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
.getElementById('geo-import-list')
.classList.contains('visible'),
)),
);
const cyanBefore = await cyanPixels();
await page.evaluate(() => {
window.__godsEyeView.viewer.clock.shouldAnimate = false;
});
const clock = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.clock;
const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0);
return {
start: iso(c.startTime),
stop: iso(c.stopTime),
range: c.clockRange,
step: c.clockStep,
multiplier: c.multiplier,
animate: c.shouldAnimate,
// Wall-clock drift aside, the current time must not jump to 2018/2019.
currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime),
};
});
const clockBefore = await clock();
const sourcesBefore = before.dataSources;
// Picked through the real <input type=file>.
const input = await page.$('#geo-import-input');
await input.uploadFile(...FILES);
const imported = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 4, {
timeout: 60_000,
})
.then(
() => true,
() => false,
);
const after = await state();
check(
'four picked files become four rows and four data sources',
imported &&
after.rows === 4 &&
after.dataSources === before.dataSources + 4,
JSON.stringify(
after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
),
);
check(
'formats are recognized',
after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx',
);
check(
'the KML and KMZ had their outside references removed',
after.files[1].removed > 0 && after.files[2].removed > 0,
`removed ${after.files[1].removed}/${after.files[2].removed}`,
);
check(
'the hint says so',
/Added 4 files\..*left out/.test(after.hint),
after.hint,
);
const clockAfter = await clock();
check(
"dated KML and GPX leave the app's clock alone",
JSON.stringify(clockAfter) === JSON.stringify(clockBefore) &&
(await page.evaluate(
() =>
window.__godsEyeView.viewer.clockTrackedDataSource?.clock ===
undefined,
)),
`${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
);
const inside = await page.evaluate((from) => {
const viewer = window.__godsEyeView.viewer;
const now = viewer.clock.currentTime;
const sources = [];
for (let i = from; i < viewer.dataSources.length; i++)
sources.push(viewer.dataSources.get(i));
const entities = sources.flatMap(
(ds) => ds.importedEntities || ds.entities.values,
);
const named = (name) => entities.find((e) => e.name === name);
const gpx = sources[3].importedEntities || sources[3].entities.values;
const kmz = sources[2].importedEntities || sources[2].entities.values;
return {
sources: sources.length,
clocks: sources.filter((ds) => ds.clock !== undefined).length,
described: entities.filter((e) => e.description !== undefined).length,
labelled: entities.filter((e) => e.label !== undefined).length,
withProperties: entities.filter((e) => e.properties !== undefined).length,
withKml: entities.filter((e) => e.kml !== undefined).length,
hidden: entities.filter((e) => !e.isAvailable(now)).length,
datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
named(name)?.billboard?.image?.getValue(now),
).length,
movingIsLine: Boolean(
named('QA moving')?.polyline && !named('QA moving')?.position,
),
movingVertices:
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
splitTracks: (sources[1].importedEntities || sources[1].entities.values)
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
.map((e) => e.polyline.positions.getValue(now).length),
gpxLines: gpx.filter((e) => e.polyline).length,
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
kmzLocalImage: kmz.some((e) =>
String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
),
kmzOverlay: kmz.some((e) => e.rectangle),
groundedLines: sources
.slice(1, 3)
.every((ds) =>
(ds.importedEntities || ds.entities.values)
.filter((e) => e.polyline)
.every((e) => e.polyline.clampToGround?.getValue(now)),
),
xyzPolygon:
(sources[0].importedEntities || sources[0].entities.values)
.find((e) => e.polygon)
?.polygon.perPositionHeight?.getValue(now) !== true,
};
}, sourcesBefore);
check(
'no imported entity keeps a description, label, properties or KML data',
inside.sources === 4 &&
inside.described +
inside.labelled +
inside.withProperties +
inside.withKml ===
0,
JSON.stringify(inside),
);
check(
'no imported source has a clock, and dated features draw now',
inside.clocks === 0 &&
inside.hidden === 0 &&
inside.datedPinNow &&
inside.movingIsLine &&
inside.movingVertices === 2,
JSON.stringify(inside),
);
check(
'blocked KML icon sources use drawable local fallback pins',
inside.fallbackPins === 2,
JSON.stringify({ fallbackPins: inside.fallbackPins }),
);
check(
'KML MultiTrack keeps its segments and original vertex count',
inside.splitTracks.join() === '2,2',
JSON.stringify(inside.splitTracks),
);
check(
'the GPX track keeps its two segments apart',
inside.gpxLines === 2 && inside.gpxPoints === 1,
JSON.stringify(inside),
);
check(
'KMZ retains its own packed image and overlay',
inside.kmzLocalImage && inside.kmzOverlay,
);
check(
'XYZ polygons and KML/KMZ lines use ground geometry',
inside.xyzPolygon && inside.groundedLines,
);
await page.evaluate((from) => {
for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
.entities.values)
if (entity.billboard) entity.show = false;
}, sourcesBefore);
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
}, VIEW);
await wait(2500);
const cyanShown = await cyanPixels();
await shot('imported');
check(
'the imported polygon paints on the globe',
cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000,
`${cyanBefore} → ${cyanShown}`,
);
const parkId = after.files[0].id;
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
const cyanHidden = await cyanPixels();
check(
'hiding it stops it painting',
cyanHidden < cyanShown / 3,
`${cyanShown} → ${cyanHidden}`,
);
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
check(
'showing it again paints it again',
(await cyanPixels()) > cyanHidden * 4,
);
// Keyboard: focus stays on the same control when the list is rebuilt.
const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`;
await page.focus(toggleSelector);
await page.keyboard.press('Enter');
const focusAfterHide = await page.evaluate(
(sel) => document.activeElement === document.querySelector(sel),
toggleSelector,
);
await page.keyboard.press('Enter');
const focusAfterShow = await page.evaluate(
(sel) =>
document.activeElement === document.querySelector(sel) &&
document.activeElement.getAttribute('aria-pressed') === 'true',
toggleSelector,
);
check(
'keyboard focus stays on the toggle through hide and show',
focusAfterHide && focusAfterShow,
);
// Dropped onto the globe, as a person drags from their file manager.
await page.evaluate((text) => {
const container = window.__godsEyeView.viewer.container;
const transfer = new DataTransfer();
transfer.items.add(
new File([text], 'dropped.geojson', { type: 'application/geo+json' }),
);
for (const type of ['dragenter', 'dragover', 'drop'])
container.dispatchEvent(
new DragEvent(type, {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
}, geojson);
const dropped = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 5, {
timeout: 20_000,
})
.then(
() => true,
() => false,
);
check('a file dropped on the globe imports', dropped);
// Not geodata: refused with a reason, nothing added.
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(['just some notes'], 'notes.txt', { type: 'text/plain' }),
);
window.__godsEyeView.viewer.container.dispatchEvent(
new DragEvent('drop', {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
});
await wait(800);
const refused = await state();
check(
'a file that is not geodata is refused with a reason',
refused.files.length === 5 &&
/notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint),
refused.hint,
);
// Remove every file through its row button.
for (const { id } of refused.files)
await page.click(
`#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`,
);
const cleared = await state();
check(
'Remove gives every data source back',
cleared.rows === 0 && cleared.dataSources === before.dataSources,
`${cleared.dataSources} vs ${before.dataSources}`,
);
await wait(1500);
check(
'nothing named inside a file, and no icon asset for one, was ever requested',
trapped.length === 0,
trapped.join(' '),
);
errors.push(...(await readRenderErrors(page)));
check('no page or rendering errors', errors.length === 0, errors.join(' | '));
} catch (error) {
check('harness ran to completion', false, error?.stack || String(error));
} finally {
await browser.close();
fs.rmSync(dir, { recursive: true, force: true });
}
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);
+988
View File
@@ -0,0 +1,988 @@
#!/usr/bin/env node
/**
* qa-gnss.mjs — headless proof for the GNSS Integrity layer
* (id `gnss-integrity`): low navigation accuracy reported by ADS-B.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* The live section reads adsb.lol through `/api/gnss-integrity` (60 s server
* cache, one upstream read per whole-degree anchor); the other sections
* stub that route in the page so they are deterministic.
*
* (i) LIVE — over Poland and the southern Baltic the layer settles with no error,
* one geometry per cell and legend band counts that sum to the cell
* count; cells are required only when the returned rows, binned
* with the layer's own rules, reach the per-cell minimum (live
* traffic varies by hour). Requires a non-stale response no older than 60 s.
* (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high,
* a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell
* with 1 degraded is low (first bad discounted), a clean cell at sea
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
* getStats. Every band is drawn and low is shown over land and sea.
* The row reads as low navigation accuracy, and stats carry the
* machine-readable provenance (GEV classifier, gpsjam bands, window).
* PICK verifies actual perimeter and empty-ground clicks under GNSS.
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
* cells already on the globe are kept.
* (iv) DISABLE — owned ground primitives are hidden.
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
* with Enter and off with Space, keeps focus and names its state.
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
* fit the panel width with no horizontal overflow.
* (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same
* stale snapshot observed 31 minutes ago draws nothing and reports no
* error; observed 29 minutes ago it draws its cells. The replay keeps
* its observation time instead of restarting the 30-minute window.
*
* Screenshots wait until every cell's ground geometry is built and show the
* open Data Layers row with its legend. Visual proof saved to
* qa-shots/gnss-*.png (gitignored).
*
* Run: node scripts/qa-gnss.mjs --url http://localhost:4173
* (also against a built preview: npm run build && npm run preview,
* then pass the preview URL)
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
accumulateGnssObservations,
binGnssCells,
} from '../src/layers/gnss/records.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const LAYER_ID = 'gnss-integrity';
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Boot the app, dismiss the intro and place the camera straight down. */
async function boot(page, { lon, lat, height }) {
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la, h) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight.
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: lo * d2r,
latitude: la * d2r,
height: h,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
height,
);
}
/** Enable (or refresh) the layer and poll until it settles. */
async function enableAndSettle(page, { refresh = false } = {}) {
return page.evaluate(
async (id, again) => {
const dm = window.__godsEyeView.dataManager;
const layer = dm.layers.get(id).module;
if (again) await dm.refreshLayer(id);
else await dm.setEnabled(id, true);
const stats = layer.getStats();
const rowControls = layer.getRowControls().legend;
const legend = rowControls.map(({ label, count }) => ({ label, count }));
const blurb = rowControls[0]?.blurb ?? '';
const rowName =
document.querySelector(`[data-layer-id="${id}"] .data-name`)
?.textContent ?? '';
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
return {
stats,
legend,
blurb,
rowName,
name: layer.name,
entities: ds?.get(0)?.geometryInstances.length ?? 0,
shown: ds?.show ?? null,
};
},
LAYER_ID,
refresh,
);
}
/** Open the Data Layers panel and bring the GNSS row into view. */
async function showLayerRow(page) {
await page.evaluate((id) => {
const panel = document.getElementById('data-panel');
if (panel.classList.contains('collapsed'))
panel.querySelector('[data-collapse-target="data-panel"]').click();
document
.querySelector(`[data-layer-id="${id}"]`)
?.scrollIntoView({ block: 'center' });
}, LAYER_ID);
await sleep(800);
}
/** Actual card pixels on the shared canvas, available in dev and production. */
function paintedOverlayPixels() {
const canvas = document.getElementById('world-overlay-canvas');
if (!canvas) return -1;
const pixels = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
return count;
}
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: TIMEOUT_MS },
)
.catch(() => {});
await showLayerRow(page);
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
}
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
function cellStates(id) {
const { viewer } = window.__godsEyeView;
const all = viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
if (!ds?.length) return [];
return [ds.get(0).ready ? 0 : 1];
}
/** Wait until every GNSS cell's ground rectangle has finished building. */
async function waitForBuiltCells(page, timeoutMs = 60000) {
const built = await page
.waitForFunction(
`(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`,
{ timeout: timeoutMs, polling: 250 },
)
.then(
() => true,
() => false,
);
const states = await page.evaluate(
`(${cellStates})(${JSON.stringify(LAYER_ID)})`,
);
return { built, detail: `states=${JSON.stringify(states)}` };
}
const fixtureRow = (hex, lat, lon, degraded) => ({
hex,
lat,
lon,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
/**
* Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad),
* low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft).
*/
function fixturePayload() {
const rows = [];
const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`;
for (let i = 0; i < 10; i++)
rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5));
for (let i = 0; i < 30; i++)
rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2));
for (let i = 0; i < 3; i++)
rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0));
for (let i = 0; i < 4; i++)
rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false));
for (let i = 0; i < 2; i++)
rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true));
return {
fetchedAt: Date.now(),
anchor: { lat: 55, lon: 19 },
radiusNm: 250,
rows,
stale: true,
};
}
/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */
async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) {
await page.evaluateOnNewDocument(
(payload, age, failures, staleOverride) => {
window.__gnssQaAgeMs = age;
window.__gnssQaRequests = [];
window.__gnssQaFailures = failures;
const realFetch = window.fetch.bind(window);
window.__gnssQaMode = 'fixture';
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
window.__gnssQaRequests.push(url);
if (window.__gnssQaFailures > 0) {
window.__gnssQaFailures--;
return Promise.resolve(
Response.json(
{ error: 'rate_limited' },
{
status: 429,
headers: { 'Retry-After': '5' },
},
),
);
}
const ok = window.__gnssQaMode === 'fixture';
const body = ok
? {
...payload,
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
ageMs: window.__gnssQaAgeMs || 0,
stale: staleOverride ?? payload.stale,
}
: { error: 'gnss_integrity_unavailable' };
return Promise.resolve(
new Response(JSON.stringify(body), {
status: ok ? 200 : 502,
headers: { 'Content-Type': 'application/json' },
}),
);
};
},
fixturePayload(),
ageMs,
initialFailures,
stale,
);
}
/** Toggle button of the GNSS row, as the panel draws it. */
function toggleState(id) {
const button = document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
);
const layer = window.__godsEyeView.dataManager.layers.get(id);
return {
focused: document.activeElement === button,
label: button?.getAttribute('aria-label') ?? '',
enabled: Boolean(layer?.enabled),
count: layer?.module.getStats().count ?? -1,
};
}
/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */
async function pressToggle(page, key, wantEnabled) {
await page.evaluate((id) => {
document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus();
}, LAYER_ID);
await page.keyboard.press(key);
await page
.waitForFunction(
(id, want) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
Boolean(layer?.enabled) === want &&
(!want || layer.module.getStats().count > 0)
);
},
{ timeout: 30000, polling: 250 },
LAYER_ID,
wantEnabled,
)
.catch(() => {});
return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`);
}
async function main() {
console.log('\nGNSS Integrity proof (qa-gnss)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
// ── (i) LIVE ──────────────────────────────────────────────────────────
console.log(
'(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...',
);
const live = await browser.newPage();
await live.setViewport({ width: 1440, height: 900 });
live.on('pageerror', (e) => errors.push(e.message));
const liveBodies = [];
live.on('response', (res) => {
if (res.url().includes('/api/gnss-integrity') && res.ok())
liveBodies.push(res.json());
});
await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 });
const l = await enableAndSettle(live);
const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0);
// Bin what adsb.lol actually returned with the layer's own rules: live
// traffic varies by hour, so cells are required only when some cell
// really reached the minimum aircraft count.
const liveSnapshots = (await Promise.allSettled(liveBodies))
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value);
record(
'LIVE: a successful, dated integrity response was received',
liveSnapshots.length > 0 &&
liveSnapshots.every(
(body) =>
Array.isArray(body.rows) &&
Number.isFinite(body.ageMs) &&
body.ageMs >= 0 &&
body.ageMs <= 60000 &&
body.stale !== true &&
body.classifier === 'gev-nic-nacp-v2',
),
`successful responses=${liveSnapshots.length}`,
);
const liveRows = liveSnapshots.reduce(
(sum, body) => sum + (body.rows?.length || 0),
0,
);
const expectedStore = new Map();
for (const body of liveSnapshots) {
const at = Date.now();
accumulateGnssObservations(expectedStore, body.rows, at, {
observedAt: at - body.ageMs,
});
}
const expectedCells = binGnssCells(expectedStore).length;
record(
'LIVE: no error, one geometry per cell',
!l.stats.error && l.entities === l.stats.count,
`cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`,
);
if (expectedCells > 0) {
record(
'LIVE: a snapshot with a qualifying cell yields cells',
l.stats.count > 0,
`rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`,
);
} else {
console.log(
` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`,
);
}
record(
'LIVE: legend band counts sum to the cell count',
legendTotal === l.stats.count,
JSON.stringify(l.legend),
);
const liveBuilt = await waitForBuiltCells(live);
record(
'LIVE: every cell rectangle is built on a parked camera',
liveBuilt.built,
liveBuilt.detail,
);
await shoot(live, 'live-poland-baltic');
await live.close();
// ── (ii) BANDS ────────────────────────────────────────────────────────
console.log(
'\n(ii) BANDS — synthetic integrity rows through the real layer...',
);
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
// Stub the route in-page rather than with request interception: an
// intercepted page stalls Cesium's geometry workers, so ground rectangles
// never finish building and the screenshot would not show real behaviour.
await stubRoute(page);
await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 });
const f = await enableAndSettle(page);
const band = (prefix) =>
f.legend.find(({ label }) => label.startsWith(prefix))?.count;
record(
'BANDS: 4 cells shown, the 2-aircraft cell withheld',
f.stats.count === 4 && f.entities === 4,
`cells=${f.stats.count} entities=${f.entities}`,
);
record(
'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low',
band('Low-accuracy score >') === 1 &&
band('Low-accuracy score 2') === 1 &&
band('Low-accuracy score <') === 2,
JSON.stringify(f.legend),
);
const prov = f.stats.provenance;
record(
'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable',
prov?.classifier?.id === 'gev-nic-nacp-v2' &&
prov.classifier.definedBy === 'GEV' &&
prov.classifier.validated === false &&
prov.aggregation?.source === 'https://gpsjam.org/faq' &&
JSON.stringify(prov.aggregation.bands) === '[2,10]' &&
prov.window?.minutes === 30 &&
prov.window.scope === 'visited-view' &&
prov.interpretation?.validated === false,
JSON.stringify(prov?.classifier),
);
record(
'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading',
f.name === 'GNSS Integrity' &&
f.rowName.includes('GNSS Integrity') &&
!/interference/i.test(f.rowName) &&
f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) &&
/one aircraft is discounted/.test(f.blurb) &&
/not a detection/.test(f.blurb),
`name=${JSON.stringify(f.name)}`,
);
record(
'BANDS: stale snapshot is reported',
f.stats.stale === true,
`stale=${f.stats.stale}`,
);
const bandsBuilt = await waitForBuiltCells(page);
record(
'BANDS: every cell rectangle is built on a parked camera',
bandsBuilt.built,
bandsBuilt.detail,
);
await shoot(page, 'synthetic-bands');
// GNSS fills must not own map input, including over a real perimeter.
await page.evaluate(async () => {
const original = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input.url;
if (url.includes('/api/fire-perimeters/inciweb'))
return Promise.resolve(Response.json({ rows: [] }));
if (url.includes('/api/fire-perimeters'))
return Promise.resolve(
Response.json({
rows: [
{
stableId: 'gnss-pick-qa',
name: 'GNSS PICK QA FIRE',
containedPct: 0,
acres: 100,
polygons: [
[
[
[18.247, 54.247],
[18.253, 54.247],
[18.253, 54.253],
[18.247, 54.253],
[18.247, 54.247],
],
],
],
},
],
}),
);
return original(input, init);
};
const { viewer, dataManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.25 * Math.PI) / 180,
latitude: (54.25 * Math.PI) / 180,
height: 2500,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('fire-perimeters', true);
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const v = window.__godsEyeView.viewer,
p = v.scene.pick({
x: v.scene.canvas.clientWidth / 2,
y: v.scene.canvas.clientHeight / 2,
});
return String(p?.id?.id ?? p?.id ?? '').startsWith(
'fire-perimeter:gnss-pick-qa:',
);
},
{ timeout: TIMEOUT_MS, polling: 250 },
);
const pickProbe = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
c = v.scene.canvas;
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
const empty = { x: centre.x + 300, y: centre.y };
const picked = v.scene.pick(empty),
box = c.getBoundingClientRect();
return {
centre: { x: centre.x + box.left, y: centre.y + box.top },
empty: { x: empty.x + box.left, y: empty.y + box.top },
surface: !picked || picked.id === undefined,
height: v.camera.positionCartographic.height,
};
});
record('PICK: a perimeter remains selectable under GNSS', true);
record(
'PICK: street ground remains a surface pick under GNSS',
pickProbe.surface,
);
const beforeCardPixels = await page.evaluate(paintedOverlayPixels);
await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`,
{ timeout: 10000, polling: 250 },
);
const selectedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: actual perimeter click paints its incident card',
selectedCardPixels > beforeCardPixels + 100,
`${beforeCardPixels} → ${selectedCardPixels} pixels`,
);
await page.screenshot({
path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'),
});
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() <= ${beforeCardPixels}`,
{ timeout: 10000, polling: 250 },
);
const clearedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: empty ground clears the perimeter card',
clearedCardPixels <= beforeCardPixels,
`${selectedCardPixels} → ${clearedCardPixels} pixels`,
);
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, {
clickCount: 2,
});
await sleep(1000);
record(
'PICK: empty double-click does not zoom to a GNSS cell',
await page.evaluate(
(height) =>
Math.abs(
window.__godsEyeView.viewer.camera.positionCartographic.height -
height,
) < 200,
pickProbe.height,
),
);
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled(
'fire-perimeters',
false,
);
});
console.log('\nCAMERA — automatic regional refresh without toggling...');
const beforeMove = await page.evaluate(
() => window.__gnssQaRequests.length,
);
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (139.7 * Math.PI) / 180,
latitude: (35.7 * Math.PI) / 180,
height: 1_100_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
139.7,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
const moved = await page.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
coverage: window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats().coverage,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
)?.textContent,
}),
LAYER_ID,
);
record(
'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row',
moved.enabled &&
moved.requests > beforeMove &&
moved.requests <= beforeMove + 2 &&
moved.coverage?.longitude === 140 &&
/Regional sample.*250 nm radius/.test(moved.info),
JSON.stringify(moved),
);
await shoot(page, 'camera-refresh');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (12.4 * Math.PI) / 180,
latitude: (41.9 * Math.PI) / 180,
height: 16_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
const params = url && new URL(url, location.origin).searchParams;
return (
params &&
Math.abs(Number(params.get('lon')) - 12.4) < 1 &&
Math.abs(Number(params.get('lat')) - 41.9) < 1
);
},
{ timeout: 15000 },
);
const world = await page.evaluate((id) => {
const { viewer, dataManager } = window.__godsEyeView;
const bounds = viewer.camera.computeViewRectangle(
viewer.scene.globe.ellipsoid,
);
return {
enabled: dataManager.isEnabled(id),
height: viewer.camera.positionCartographic.height,
boundsMidLongitude:
bounds && ((bounds.west + bounds.east) * 90) / Math.PI,
coverage: dataManager.layers.get(id).module.getStats().coverage,
};
}, LAYER_ID);
record(
'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius',
world.enabled &&
world.height > 15_000_000 &&
world.coverage?.longitude === 12 &&
world.coverage?.latitude === 42 &&
world.coverage?.radiusNm === 250,
JSON.stringify(world),
);
await shoot(page, 'globe-regional');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.6 * Math.PI) / 180,
latitude: (54.6 * Math.PI) / 180,
height: 450_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
18.6,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
// ── (iii) FAILURE ─────────────────────────────────────────────────────
console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...');
await page.evaluate(() => {
window.__gnssQaMode = 'error';
});
const e = await enableAndSettle(page, { refresh: true });
record(
'FAILURE: error surfaced, cells kept',
Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4,
`error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`,
);
await shoot(page, 'failure');
// ── (iv) DISABLE ──────────────────────────────────────────────────────
console.log('\n(iv) DISABLE...');
const shown = await page.evaluate(async (id) => {
await window.__godsEyeView.dataManager.setEnabled(id, false);
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
)?.show;
}, LAYER_ID);
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
await shoot(page, 'disabled');
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...');
await page.evaluate(() => {
window.__gnssQaMode = 'fixture';
});
await showLayerRow(page);
const on = await pressToggle(page, 'Enter', true);
record(
'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus',
on.enabled &&
on.focused &&
on.count === 4 &&
/^GNSS Integrity: /.test(on.label) &&
!/OFF$/.test(on.label),
JSON.stringify(on),
);
const off = await pressToggle(page, ' ', false);
record(
'KEYBOARD: Space on the focused toggle disables it and the label says OFF',
!off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label),
JSON.stringify(off),
);
// ── (vi) VIEWPORT ─────────────────────────────────────────────────────
console.log(
'\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...',
);
await page.close();
// A fresh page: switching an open page to isMobile reloads it, which
// would measure a half-booted app instead of the open row.
const phone = await browser.newPage();
await phone.setViewport({ width: 390, height: 844, isMobile: true });
phone.on('pageerror', (e) => errors.push(e.message));
await stubRoute(phone);
await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(phone);
await pressToggle(phone, 'Enter', true);
await showLayerRow(phone);
const fit = await phone.evaluate((id) => {
const row = document.querySelector(`[data-layer-id="${id}"]`);
const legend = [...row.querySelectorAll('.data-toggle-legend-item')];
const rect = row.getBoundingClientRect();
return {
width: window.innerWidth,
rowLeft: Math.round(rect.left),
rowRight: Math.round(rect.right),
overflow: row.scrollWidth - row.clientWidth,
legendItems: legend.length,
coverageInfo: row.querySelector('.data-toggle-controls-info')
?.textContent,
legendInside: legend.every((item) => {
const r = item.getBoundingClientRect();
return (
r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1
);
}),
};
}, LAYER_ID);
record(
'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow',
fit.rowLeft >= 0 &&
fit.rowRight <= fit.width &&
fit.overflow <= 0 &&
fit.legendItems === 3 &&
/Regional sample.*250 nm radius/.test(fit.coverageInfo) &&
fit.legendInside,
JSON.stringify(fit),
);
await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') });
await phone.close();
console.log(
'\nSTARTUP — initial rate limit stays enabled and recovers automatically...',
);
const retry = await browser.newPage();
await retry.setViewport({ width: 1440, height: 900 });
retry.on('pageerror', (e) => errors.push(e.message));
// Pages share the disposable test context's storage. Start this one cold,
// so a previous fixture cannot auto-enable GNSS before the START test.
await retry.evaluateOnNewDocument(() => localStorage.clear());
await stubRoute(retry, { initialFailures: 1, stale: false });
await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(retry);
await retry.evaluate((id) => {
window.__gnssQaFailedStarts = 0;
window.__godsEyeView.dataManager.subscribe((change) => {
if (change.layerId === id && change.type === 'visibility-failed')
window.__gnssQaFailedStarts++;
});
}, LAYER_ID);
const initial = await enableAndSettle(retry);
const limited = await retry.evaluate((id) => {
const dm = window.__godsEyeView.dataManager;
const row = document.querySelector(`[data-layer-id="${id}"]`);
return {
enabled: dm.isEnabled(id),
failedStarts: window.__gnssQaFailedStarts,
label: row.querySelector('.data-toggle-btn').textContent,
info: row.querySelector('.data-toggle-controls-info').textContent,
};
}, LAYER_ID);
record(
'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing',
limited.enabled &&
limited.failedStarts === 0 &&
limited.label === 'UNAVAILABLE' &&
initial.stats.count === 0 &&
initial.stats.retryInSec > 0 &&
/rate limited.*Retrying automatically at/.test(limited.info),
JSON.stringify(limited),
);
await retry.screenshot({
path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'),
});
await retry.waitForFunction(
(id) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
layer.enabled &&
layer.module.getStats().error === null &&
layer.module.getStats().count === 4
);
},
{ timeout: 20000 },
LAYER_ID,
);
const recovered = await retry.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
label: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
).textContent,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
).textContent,
}),
LAYER_ID,
);
record(
'STARTUP: one automatic retry recovers to ON without toggling and clears the warning',
recovered.enabled &&
recovered.requests === 2 &&
recovered.label === 'ON' &&
!/Retrying/.test(recovered.info),
JSON.stringify(recovered),
);
await shoot(retry, 'startup-recovered');
await retry.close();
// ── (vii) AGE ─────────────────────────────────────────────────────────
console.log(
'\n(vii) AGE — a replayed stale snapshot keeps its observation time...',
);
const aged = await browser.newPage();
await aged.setViewport({ width: 1440, height: 900 });
aged.on('pageerror', (e) => errors.push(e.message));
await stubRoute(aged, { ageMs: 31 * 60_000 });
await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 });
const old = await enableAndSettle(aged, {});
record(
'AGE: a snapshot observed 31 min ago adds no cells and is not an error',
old.stats.count === 0 && old.entities === 0 && !old.stats.error,
`cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`,
);
await aged.evaluate(() => {
window.__gnssQaAgeMs = 29 * 60_000;
});
const recent = await enableAndSettle(aged, { refresh: true });
record(
'AGE: the same snapshot observed 29 min ago is still inside the window',
recent.stats.count === 4 && recent.entities === 4,
`cells=${recent.stats.count} entities=${recent.entities}`,
);
await aged.close();
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+4 -1
View File
@@ -443,7 +443,10 @@ async function main() {
// for nearby names. Answer both with empty results: the entity-context
// assertions are about the SELECTED contact, and a live lookup would
// make the run non-hermetic (and bill the owner's Google quota).
if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) {
if (
isAppRequest &&
(url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode')
) {
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
}
if (isAppRequest && url.pathname === '/api/google/nearby-places') {
+238
View File
@@ -0,0 +1,238 @@
import {
GNSS_CLASSIFIER,
GNSS_WINDOW_MS,
normalizeGnssAircraft,
} from '../../src/layers/gnss/records.js';
import { adsbLolFallbackAnchor } from './aircraft/opensky.js';
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
// adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the
// GNSS layer needs leave this proxy, so the browser never sees the full feed.
// This is a separate read from the flights fallback on purpose: that point
// cache keeps only the fields the flights layer renders (no nic / nac_p), is
// keyed on 0.25° anchors and lives for 12 s, while integrity cells need
// whole-degree anchors held for a minute.
const RADIUS_NM = 250;
const CACHE_MS = 60_000;
const CACHE_MAX = 64;
/** Default 429 cooldown when upstream sends no usable Retry-After. */
const COOLDOWN_MS = 60_000;
/** Bound sane upstream deadlines to one hour; never shorten a deadline within that range. */
const COOLDOWN_MIN_MS = 5_000;
const COOLDOWN_MAX_MS = 60 * 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
const MAX_IN_FLIGHT = 8;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const TIMEOUT_MS = 10_000;
/** Snap a view anchor to a whole degree so nearby views share one upstream read. */
export function gnssIntegrityAnchor(url) {
const anchor = adsbLolFallbackAnchor({ url });
if (!anchor) return null;
return {
lat: Math.round(anchor.latitude),
lon: Math.round(anchor.longitude),
};
}
/** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */
export function gnssRetryCooldownMs(raw, nowMs) {
if (raw) {
const seconds = /^\d+(?:\.\d+)?$/.test(raw.trim()) ? Number(raw) : NaN;
const ms = seconds * 1000;
if (Number.isFinite(seconds) && seconds > 0)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms));
const at =
Number.isNaN(seconds) && /[a-z]/i.test(raw) ? Date.parse(raw) : NaN;
if (Number.isFinite(at) && at > nowMs)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, at - nowMs));
}
return COOLDOWN_MS;
}
/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */
export function gnssIntegrityProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
monotonicNow = () => performance.now(),
warn = (...args) => console.warn(...args),
} = {}) {
const cache = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 });
let cooldownUntil = 0;
let cooldownStatus = 429;
async function load({ lat, lon }) {
if (monotonicNow() < cooldownUntil)
throw Object.assign(new Error('cooling_down'), {
status: cooldownStatus,
});
const signal = AbortSignal.timeout(TIMEOUT_MS);
try {
const response = await fetchImpl(
`https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`,
{
signal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-gnss-integrity/1.0',
},
},
);
if (!response.ok) {
await response.body?.cancel();
if (response.status === 429) {
const at = monotonicNow();
const raw = response.headers.get('retry-after');
const delay = gnssRetryCooldownMs(raw, now());
if (delay === COOLDOWN_MAX_MS)
warn('[gnss-integrity] upstream cooldown capped at one hour');
cooldownStatus = 429;
cooldownUntil = Math.max(cooldownUntil, at + delay);
throw Object.assign(new Error('upstream_rate_limited'), {
status: 429,
});
}
throw new Error('upstream_unavailable');
}
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
signal,
);
const rows = normalizeGnssAircraft(payload);
if (!rows) throw new Error('invalid_snapshot');
const sourceNow = payload.now;
if (!Number.isFinite(sourceNow) || sourceNow <= 0)
throw new Error('undated_snapshot');
const fetchedAt = Math.min(
now(),
sourceNow > 10_000_000_000 ? sourceNow : sourceNow * 1000,
);
if (now() - fetchedAt > GNSS_WINDOW_MS)
throw new Error('expired_snapshot');
return {
fetchedAt,
anchor: { lat, lon },
radiusNm: RADIUS_NM,
// `degraded` on each row is this classifier's verdict, not gpsjam's.
classifier: GNSS_CLASSIFIER.id,
rows,
};
} catch (error) {
if (error.status !== 429) {
const until = monotonicNow() + FAILURE_COOLDOWN_MS;
if (until > cooldownUntil) {
cooldownUntil = until;
cooldownStatus = 502;
}
}
throw error;
}
}
function age(entry) {
if (!entry) return Infinity;
const elapsed = monotonicNow() - entry.savedAt;
// A backwards injected clock cannot rejuvenate a snapshot.
return elapsed < 0 ? Infinity : entry.ageAtReceipt + elapsed;
}
async function acquire(anchor, peer) {
const key = `${anchor.lat},${anchor.lon}`;
const previous = cache.get(key);
if (age(previous) < CACHE_MS)
return { value: previous.value, ageMs: age(previous), stale: false };
try {
if (!inFlight.has(key) && inFlight.size >= MAX_IN_FLIGHT)
throw Object.assign(new Error('busy'), { status: 429, local: true });
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
if (monotonicNow() >= cooldownUntil && !allow(peer))
throw Object.assign(new Error('rate_limited'), {
status: 429,
local: true,
});
const value = await load(anchor);
const entry = {
value,
savedAt: monotonicNow(),
ageAtReceipt: Math.max(0, now() - value.fetchedAt),
};
cache.delete(key);
cache.set(key, entry);
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return entry;
});
const entry = await promise;
const ageMs = age(entry);
return { value: entry.value, ageMs, stale: ageMs >= CACHE_MS };
} catch (error) {
if (age(previous) <= GNSS_WINDOW_MS)
return { value: previous.value, ageMs: age(previous), stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false, retryAfterS = 60) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...([429, 502].includes(status)
? { 'Retry-After': String(retryAfterS) }
: {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
if (path !== '/' && path !== '')
return json(404, { error: 'unknown_route' });
const anchor = gnssIntegrityAnchor(req.url);
if (!anchor) return json(400, { error: 'invalid_anchor' });
try {
const { value, stale, ageMs } = await acquire(anchor, clientKey(req));
// ageMs is measured on this server's clock, so the browser can date
// the rows without comparing its clock to ours.
json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale);
} catch (error) {
if (error.status === 429) {
// Tell the browser how long the shared upstream cooldown has left.
const retryAfterS = Math.max(
1,
error.local ? 60 : Math.ceil((cooldownUntil - monotonicNow()) / 1000),
);
return json(
429,
{
error: error.local ? error.message : 'gnss_integrity_rate_limited',
},
false,
retryAfterS,
);
}
json(
502,
{ error: 'gnss_integrity_unavailable' },
false,
Math.max(1, Math.ceil((cooldownUntil - monotonicNow()) / 1000)),
);
}
}
return {
name: 'gnss-integrity',
configureServer({ middlewares }) {
middlewares.use('/api/gnss-integrity', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/gnss-integrity', handler);
},
};
}
+5
View File
@@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) {
cycloneProxy(),
mapillaryProxy(),
firePerimetersProxy(),
gnssIntegrityProxy(),
keySetupEndpoint(),
];
}
@@ -81,6 +83,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
export { googlePlacesContextProxy } from './places.js';
export { googleServerApiKey } from './places.js';
export { keylessGooglePlacesResponse } from './places.js';
export { keylessGoogleGeocodeResponse } from './places.js';
export { validateGeocodeAddress } from './places.js';
export { geocodeBounds } from './places.js';
export { adsbLolFallbackAnchor } from './aircraft/opensky.js';
export { readResponseTextCapped } from './common/http.js';
export { readResponseJsonCapped } from './common/http.js';
+2 -2
View File
@@ -179,7 +179,7 @@ export const ACTION_DESCRIPTIONS = {
},
get_current_view_state: {
description:
'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action. Tracked aircraft emergency status is a crowd-sourced transponder broadcast, not a confirmed incident: quote emergencyBroadcast and never infer a hijack or an incident ending from a code change.',
$position: 1,
},
set_hud: {
@@ -543,7 +543,7 @@ export const ACTION_DESCRIPTIONS = {
},
analyst_query: {
description:
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values).',
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
$position: 1,
parameters: {
properties: {
+6 -1
View File
@@ -1,8 +1,13 @@
/** Place-search and route middleware for Node development servers. */
export { googlePlacesContextProxy } from './places/google.js';
export {
googlePlacesContextProxy,
validateGeocodeAddress,
geocodeBounds,
} from './places/google.js';
export {
googleServerApiKey,
keylessGooglePlacesResponse,
keylessGoogleGeocodeResponse,
} from './places/google-key.js';
export { installRouteMiddleware } from './places/routes.js';
export {
+28 -6
View File
@@ -14,12 +14,34 @@ export function keylessGooglePlacesResponse(apiKey) {
}
/**
* Google API key for the SERVER-SIDE calls (Places nearby/text search, the
* CCTV Street View fallback). These never reach the browser, so this key can
* be restricted by server IP and scoped to Places API + Street View Static
* API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles +
* Geocoding for the browser (#33). Splitting them is opt-in: unset, this
* falls back to the shared browser key and nothing changes.
* Geocoding speaks Google's own response shape rather than the `places: []`
* contract its two siblings use, because the browser adapters read `status`
* and `results` directly. The keyless capability reads the same way: a status
* that is not `OK` and no results, which the adapters already treat as "this
* provider did not answer" and pass to the next geocoder in the chain.
*/
export function keylessGoogleGeocodeResponse(apiKey) {
if (String(apiKey ?? '').trim()) return null;
return {
statusCode: 200,
payload: {
configured: false,
status: 'REQUEST_DENIED',
results: [],
error: null,
},
};
}
/**
* Google API key for the SERVER-SIDE calls (Places nearby/text search,
* geocoding, the CCTV Street View fallback). These never reach the browser, so
* this key can be restricted by server IP and scoped to Places API + Geocoding
* API + Street View Static API — while GOOGLE_MAPS_API_KEY stays
* referrer-restricted to Map Tiles + Maps JavaScript API for the browser
* (#33, #363). Splitting them is opt-in: unset, this falls back to the shared
* browser key, which then has to carry every API both sides use and cannot
* take a referrer restriction.
*/
export function googleServerApiKey() {
return resolveGoogleServerKey(process.env);
+191 -1
View File
@@ -1,9 +1,11 @@
import {
googleServerApiKey,
keylessGooglePlacesResponse,
keylessGoogleGeocodeResponse,
} from './google-key.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js';
import { readResponseJsonCapped } from '../common/http.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
@@ -25,7 +27,7 @@ let _googleRateLimiter;
*/
export const GOOGLE_DEFAULT_PER_MIN = 120;
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */
function googleRateLimiter() {
if (_googleRateLimiter === undefined)
_googleRateLimiter = makeCostRateLimiter(
@@ -56,6 +58,51 @@ export function validatePlacesCoordinates(searchParams) {
return { ok: true, latitude, longitude };
}
/** Longest accepted geocoder query. Real place names are far shorter. */
const GEOCODE_MAX_QUERY = 200;
/**
* Upstream deadline and body ceiling for the geocode routes. The deadline stays
* armed through body consumption, so a stalled upstream cannot hold a handler
* open, and the cap bounds a chunked or length-omitted reply. A geocode answer
* is a handful of results; 256 KiB is far above any honest one.
*/
const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000;
const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024;
/** Validate a free-text geocoder query before consuming request quota. */
export function validateGeocodeAddress(searchParams) {
const address = String(searchParams.get('address') || '').trim();
if (!address) return { ok: false, error: 'address is required' };
if (address.length > GEOCODE_MAX_QUERY)
return { ok: false, error: 'address is too long' };
return { ok: true, address };
}
/**
* Validate the optional viewport bias, which Google reads as
* `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than
* refused: the bias only ranks results, so a malformed one costs relevance,
* not an answer.
*/
export function geocodeBounds(searchParams) {
const raw = String(searchParams.get('bounds') || '').trim();
if (!raw) return null;
const corners = raw.split('|');
if (corners.length !== 2) return null;
const parts = corners.flatMap((corner) => corner.split(','));
// A blank component must be refused before Number() turns it into 0 and a
// half-written corner like `30.4,` reads as the equator.
if (parts.length !== 4 || parts.some((part) => !part.trim())) return null;
const numbers = parts.map(Number);
if (!numbers.every(Number.isFinite)) return null;
const [swLat, swLon, neLat, neLon] = numbers;
if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null;
if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null;
return raw;
}
/** Nearby place labels and view-biased text search, with request-time key resolution. */
export function googlePlacesContextProxy({
resolveApiKey = googleServerApiKey,
@@ -293,6 +340,149 @@ export function googlePlacesContextProxy({
);
}
});
/**
* Geocoding belongs on the server, not in the page. Google's Geocoding web
* service refuses referrer-restricted keys, so calling it from the browser
* forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be
* left unrestricted (#363). Here the request carries the server key, and
* the browser key no longer needs the Geocoding API at all.
*
* Unlike the two Places routes above, these answer in Google's own shape
* (`status` + `results`) rather than the `places: []` contract: the browser
* normalizers read that shape directly, and keeping it means the proxy adds
* a hop without adding a translation nobody asked for.
*/
function installGeocodeRoute(path, readQuery) {
middlewares.use(path, async (req, res) => {
const refuse = (statusCode, error) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }),
);
};
// Gate first, like the Places routes above: a cross-site caller learns
// nothing about this endpoint's method surface.
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') {
refuse(405, 'Method not allowed');
return;
}
// A keyless geocode has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the
// Places routes above).
const apiKey = resolveApiKey();
const keyless = keylessGoogleGeocodeResponse(apiKey);
if (keyless) {
res.statusCode = keyless.statusCode;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(keyless.payload));
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const query = readQuery(requestUrl.searchParams);
if (!query.ok) {
refuse(400, query.error);
return;
}
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with the
// Places routes because both spend the same Google budget.
const _grl = googleRateLimiter();
if (_grl && !_grl(clientKey(req))) {
res.statusCode = 429;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Retry-After', '5');
res.end(
JSON.stringify({
status: 'OVER_QUERY_LIMIT',
results: [],
error: 'Rate limit exceeded',
}),
);
return;
}
try {
const upstream = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
);
for (const [name, value] of Object.entries(query.params))
upstream.searchParams.set(name, value);
upstream.searchParams.set('key', apiKey);
const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS);
const response = await fetchImpl(upstream.toString(), {
redirect: 'error',
signal: deadline,
});
// An unreadable, oversized or timed-out body is not an answer: it
// falls through to the same shaped failure as a refusal below.
const data = await readResponseJsonCapped(
response,
GEOCODE_MAX_RESPONSE_BYTES,
deadline,
).catch(() => null);
// A body we could not read is an upstream failure, not an answer with
// a status of its own, so it does not borrow the upstream's code.
if (!data) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
}),
);
return;
}
res.statusCode = response.ok ? 200 : response.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'private, max-age=300');
res.end(JSON.stringify(data));
} catch {
// Fixed public text: a transport, URL or runtime message would
// describe the server to the browser (SECURITY.md, sanitized errors).
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
}),
);
}
});
}
installGeocodeRoute('/api/google/geocode', (searchParams) => {
const address = validateGeocodeAddress(searchParams);
if (!address.ok) return address;
const bounds = geocodeBounds(searchParams);
return {
ok: true,
params: {
address: address.address,
...(bounds ? { bounds } : {}),
},
};
});
installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => {
const coordinates = validatePlacesCoordinates(searchParams);
if (!coordinates.ok) return coordinates;
const { latitude, longitude } = coordinates;
return { ok: true, params: { latlng: `${latitude},${longitude}` } };
});
}
return {
+29 -12
View File
@@ -1,6 +1,9 @@
import https from 'node:https';
import { Readable } from 'node:stream';
import { isNonGlobalIpv4 } from './stations.js';
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
export function radioMirrorOrigin(value) {
const hostname = String(value ?? '')
.toLowerCase()
@@ -124,19 +127,33 @@ export function fetchPinnedRadioResponse(url, options, addresses) {
},
},
(response) => {
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
// Nothing thrown here may escape: this callback runs outside the
// promise, so an exception would be uncaught and crash the server.
try {
const status = response.statusCode || 500;
// A `Response` only represents statuses up to 599; Node never hands
// this callback a 1xx.
if (status > 599)
throw new Error(
`Radio Browser returned unsupported status ${status}`,
);
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
}
const nullBody = NULL_BODY_STATUSES.has(status);
const upstream = new Response(
nullBody ? null : Readable.toWeb(response),
{ status, statusText: response.statusMessage || '', headers },
);
if (nullBody) response.resume();
resolve(upstream);
} catch (error) {
response.destroy();
reject(error);
}
resolve(
new Response(Readable.toWeb(response), {
status: response.statusCode || 500,
statusText: response.statusMessage || '',
headers,
}),
);
},
);
request.on('error', reject);
+4 -2
View File
@@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
const requestTimes = [];
globalThis.fetch = async () => {
globalThis.fetch = async (url) => {
if (String(url).endsWith('/status'))
return Response.json({ configured: true });
requestTimes.push(Date.now());
return httpFailure(500);
};
@@ -402,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
};
let overpassCall = 0;
globalThis.fetch = async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({
status: 'OK',
results: [{
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+4
View File
@@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -120,6 +121,9 @@ export function createApplicationCatalog({
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 30);
assert.equal(first.layers.length, 31);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 30);
assert.equal(catalog.layers.length, 31);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+6
View File
@@ -0,0 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
}
+1
View File
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 30);
assert.equal(actual.layers.length, 31);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+11
View File
@@ -1,6 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
@@ -56,6 +57,16 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
viewer,
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
+21
View File
@@ -83,6 +83,27 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/**
/**
* HUD wording for a tracked aircraft's transponder emergency, or null.
* States what was broadcast, never a conclusion about the flight: the Mode A
* code when it names the status, else the ADS-B field it came from (an
* unrelated squawk is dropped).
* @param {{label?: string, squawk?: string|null, source?: string,
* severity?: string}|null|undefined} emergency
* @returns {string|null}
*/
export function formatCockpitEmergency(emergency) {
const label = String(emergency?.label || '')
.trim()
.toUpperCase();
if (!label) return null;
if (emergency.source === 'squawk' && emergency.squawk)
return `SQUAWK ${emergency.squawk} · ${label}`;
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
return `ADS-B ${field} · ${label}`;
}
/**
* The English Context scope line. The cockpit passes its own formatter so the
* page shows it in the active language; this module stays i18n-free.
+19
View File
@@ -13,6 +13,7 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatSpeedRulerTick,
normalizeHeading,
@@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
+37 -8
View File
@@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
.toLowerCase();
const latitude = finiteNumber(aircraft?.lat);
const longitude = finiteNumber(aircraft?.lon);
if (!hex || latitude === null || longitude === null) return null;
if (
!hex ||
latitude === null ||
longitude === null ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
return null;
const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground';
@@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
hex,
String(aircraft?.flight || aircraft?.r || '').trim() || null,
null,
Math.max(0, nowSeconds - seenPosition),
Math.max(0, nowSeconds - seen),
Number.isFinite(seenPosition)
? Math.max(0, nowSeconds - seenPosition)
: null,
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
longitude,
latitude,
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
@@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) {
: Math.floor(
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
);
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
.filter(Boolean);
return { time: nowSeconds, states };
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
const states = [];
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
// status travels alongside them, keyed only to admitted aircraft.
const admitted = new Set();
const statuses = [];
for (const row of aircraft) {
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
if (!state) continue;
states.push(state);
// The live contract admits the first valid vector for each ICAO. Its
// status must come from that same row, never an invalid or later duplicate.
const id = state[0];
if (admitted.has(id)) continue;
admitted.add(id);
if (typeof row.emergency === 'string')
statuses.push([id, row.emergency.trim().toLowerCase()]);
}
const emergencyByIcao = Object.fromEntries(statuses);
return {
time: nowSeconds,
states,
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Transponder emergency status for one aircraft, from the Mode A squawk and
* the ADS-B emergency/priority field.
*
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
* squawk only.
*
* The status is what the transponder broadcast in THIS poll, never a finding:
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
* broadcast, which does not confirm an incident or its resolution.
*/
import { msg } from '../i18n/index.js';
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444';
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = {
general: { label: msg('General emergency'), severity: 'emergency' },
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
nordo: { label: msg('Radio failure'), severity: 'emergency' },
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
lifeguard: { label: msg('Lifeguard (medical priority)'), severity: 'priority' },
};
/**
* A Mode A code as its four octal digits, else null.
* @param {unknown} value
* @returns {string|null}
*/
export function normalizeSquawk(value) {
const code = String(value ?? '').trim();
return /^[0-7]{4}$/.test(code) ? code : null;
}
/**
* Emergency status from one observation. Higher severity wins; an ADS-B
* emergency wins a tie. A
* 7x00 squawk counts even when the ADS-B field says `none`.
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
* 'squawk' when the Mode A code itself names the status (so it can be
* quoted), else 'ads-b'.
*/
export function aircraftEmergency({ squawk, emergency } = {}) {
const code = normalizeSquawk(squawk);
const reported = String(emergency ?? '')
.trim()
.toLowerCase();
const fromAdsb = Object.hasOwn(KINDS, reported);
const squawkKind = SQUAWK_KINDS[code] ?? null;
const kind =
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
? reported
: squawkKind;
if (!kind) return null;
return {
kind,
...KINDS[kind],
squawk: code,
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
};
}
/**
* How a summary may phrase the status without flattening it into an incident:
* quote the code when the Mode A squawk itself names the status, otherwise
* attribute it to the ADS-B emergency field.
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
* or 'ADS-B reports general emergency'; null when there is no status.
*/
export function emergencyBroadcastPhrase(status) {
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
const label = KINDS[status.kind].label.toLowerCase();
return status.source === 'squawk' && status.squawk
? `broadcasting squawk ${status.squawk} (${label})`
: `ADS-B reports ${label}`;
}
/**
* True when the aircraft is broadcasting an emergency (not a priority status).
* @param {{severity?: string}|null|undefined} status
* @returns {boolean}
*/
export function isEmergency(status) {
return status?.severity === 'emergency';
}
+95
View File
@@ -0,0 +1,95 @@
// src/data/aircraftEmergency.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
aircraftEmergency,
emergencyBroadcastPhrase,
isEmergency,
normalizeSquawk,
} from './aircraftEmergency.js';
test('the three ICAO emergency squawks map to their kinds', () => {
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
const general = aircraftEmergency({ squawk: ' 7700 ' });
assert.deepEqual(general, {
kind: 'general',
label: 'General emergency',
severity: 'emergency',
squawk: '7700',
source: 'squawk',
});
});
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
assert.equal(aircraftEmergency(), null);
assert.equal(normalizeSquawk('7800'), null);
assert.equal(normalizeSquawk(7700), '7700');
assert.equal(normalizeSquawk('770'), null);
});
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
assert.equal(minfuel.kind, 'minfuel');
assert.equal(minfuel.severity, 'priority');
assert.equal(minfuel.source, 'ads-b');
assert.equal(minfuel.squawk, '4521');
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
'squawk',
);
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
'squawk',
'a code that names the status stays quotable',
);
assert.equal(
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
'ads-b',
);
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
});
test('priority statuses are reported but are not emergencies', () => {
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
assert.equal(
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
false,
);
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
assert.equal(isEmergency(null), false);
});
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(
emergencyBroadcastPhrase(
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
),
'ADS-B reports general emergency',
);
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
'ADS-B reports lifeguard (medical priority)',
);
assert.equal(emergencyBroadcastPhrase(null), null);
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
});
test('an emergency squawk outranks an ADS-B priority', () => {
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
'general',
);
assert.equal(
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
'unlawful',
);
});
+524
View File
@@ -0,0 +1,524 @@
import * as Cesium from 'cesium';
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
import { FlightRecords } from '../layers/flights/records.js';
import { MilitaryFlightRecords } from '../layers/military/records.js';
import { mapAnalystRecord as mapFlight } from './flights.js';
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
import { createAnalystEngine } from './analystEngine.js';
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
import { createGevActionRunner } from '../voice/gevActions.js';
const NOW_MS = 1800000000000;
test('fallback status belongs to the first valid admitted aircraft row', () => {
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
for (const status of [undefined, 'general', 'none']) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [
{ ...valid, lat: 100, emergency: 'general' },
{ ...valid, emergency: status },
{ ...valid, lat: null, emergency: 'general' },
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
],
});
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.records[0].emergency, status);
assert.equal(snapshot.records[0].latitude, 30);
}
});
function civilStore() {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
approxDistanceKm: () => 0,
});
}
const receiveContext = {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
};
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
const store = civilStore();
const aircraft = {
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
seen: 3,
seen_pos: 1,
};
for (const [status, expectedKind, flagged] of [
['general', 'general', true],
['minfuel', 'minfuel', false],
['none', null, false],
]) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [{ ...aircraft, emergency: status }],
});
assert.equal(
payload.states[0].length,
18,
'fallback vectors retain the OpenSky shape',
);
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
const result = mapFlight(
aircraft.hex,
store.receive(observation, receiveContext).meta,
);
assert.equal(result.emergency, flagged);
assert.equal(result.emergencyKind, expectedKind);
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
}
});
test('changed status without contact time retains general contact, including voice projection', async () => {
const store = civilStore();
const [observation] = readsbSnapshot(
{
ac: [
{
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS, now: NOW_MS },
).records;
store.receive(observation, receiveContext);
const meta = store.receive(
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
receiveContext,
).meta;
const record = mapFlight(observation.id, meta);
assert.equal(record.emergencyKind, 'nordo');
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const flights = {
id: 'flights',
source: 'fixture',
getStats: () => ({ count: 1 }),
getAnalystRecords: () => [record],
};
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionCartographic: {
height: 300000,
latitude: 0.52,
longitude: -1.71,
},
},
},
styleManager: {
getContextModeState: () => ({ mode: null, active: false }),
getCockpitState: () => ({ active: false }),
},
dataManager: {
layers: new Map([['flights', { module: flights }]]),
isEnabled: (id) => id === 'flights',
getAll: () => [
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
],
},
});
try {
const result = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'emergency', op: 'eq', value: true }],
});
assert.equal(result.ok, true);
assert.equal(result.items[0].emergency, true);
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
assert.equal(result.items[0].emergencySource, 'squawk');
assert.equal(
result.items[0].emergencyBroadcast,
'broadcasting squawk 7600 (radio failure)',
);
assert.equal(
Object.hasOwn(result.items[0], 'time'),
false,
'general contact is not a generic incident timestamp',
);
} finally {
runner.dispose?.();
}
});
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
const OPENSKY = {
time: NOW_MS / 1000,
states: [
[
'a1b2c3',
'SWA696 ',
'United States',
NOW_MS / 1000 - 6,
NOW_MS / 1000 - 3,
-97.67,
30.19,
3000,
false,
150,
180,
-5,
null,
3040,
'7700',
false,
0,
4,
],
],
};
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
const ADSB_LOL = {
ac: [
{
hex: 'ae01ce',
flight: 'RCH123 ',
t: 'C17',
lat: 31.1,
lon: -97.4,
alt_baro: 18000,
gs: 300,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 1.5,
seen_pos: 2,
},
],
};
function civilAnalystRecord() {
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
const store = new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0,
});
const { meta } = store.receive(row, {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
return mapFlight(row.id, meta);
}
function militaryAnalystRecord() {
const [row] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
});
const { meta } = store.receive(row, {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
});
return mapMilitary(row.id, meta);
}
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
const civil = civilAnalystRecord();
const military = militaryAnalystRecord();
assert.equal(civil.emergency, true);
assert.equal(civil.emergencyKind, 'general');
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergencySource, 'squawk');
assert.equal(
civil.emergencyBroadcast,
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
assert.equal(military.emergency, true);
assert.equal(military.emergencyKind, 'general');
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
assert.equal(military.emergencySource, 'ads-b');
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
});
test('the analyst can filter emergencies by source', async () => {
const records = {
flights: [civilAnalystRecord()],
military: [militaryAnalystRecord()],
};
const engine = createAnalystEngine({
getRecords: (key) => records[key] || [],
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
});
const query = (value) =>
engine.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
filters: [
{ field: 'emergency', op: 'eq', value: true },
{ field: 'emergencySource', op: 'eq', value },
],
});
const bySquawk = await query('squawk');
assert.deepEqual(
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
);
const byAdsb = await query('ads-b');
assert.deepEqual(
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['ae01ce', 'ADS-B reports general emergency']],
);
});
test('military status changes retain only the latest known general contact', () => {
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (v) => v,
});
const [observation] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const context = {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
};
const unknown = mapMilitary(
observation.id,
store.receive({ ...observation, contactTimeMs: null }, context).meta,
);
assert.equal(unknown.lastContactAtMs, null);
store.receive(observation, context);
const changed = mapMilitary(
observation.id,
store.receive(
{
...observation,
contactTimeMs: null,
squawk: '7600',
emergency: 'none',
},
context,
).meta,
);
assert.equal(changed.emergencyKind, 'nordo');
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
});
test('fallback missing contact age retains known contact through the real converter', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
const first = receive(row);
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
});
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
assert.equal(receive(row).emergency.kind, 'general');
assert.equal(
receive({ ...row, emergency: undefined }).emergency?.kind,
'general',
);
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
receive(row);
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
receive(row);
assert.equal(
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
null,
);
});
function renderHarness(factory) {
const flightState = { records: { data: new Map() }, _trackedIcao: null };
const rendering = factory({
flightState,
services: {
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
aircraftPresentation: {},
camera: {},
focus: {},
recession: {},
},
parts: {},
});
return { flightState, rendering };
}
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
for (const factory of [civilRendering, militaryRendering]) {
const { flightState, rendering } = renderHarness(factory);
const normal = rendering._fleetBillboardColor('civil', {});
assert.equal(
rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ emergency: 'minfuel' }),
}),
normal,
);
const red = rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.ok(red.red > 0.9 && red.green < 0.5);
flightState.records.data.set('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.equal(rendering._modelColor('civil'), red);
if (rendering._cockpitContactColor)
assert.equal(rendering._cockpitContactColor('civil'), red);
flightState._trackedIcao = 'civil';
assert.equal(
rendering._modelColor('civil'),
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal);
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
});
flightState.records = records;
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {},
queries: {},
});
for (const [emergency, red] of [
['general', true],
['none', false],
['general', true],
]) {
const snapshot = readsbSnapshot(
{
ac: [
{
hex: 'abc123',
lat: 30,
lon: -97,
alt_baro: 10000,
squawk: '1200',
emergency,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
}
});
+5 -2
View File
@@ -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);
+12
View File
@@ -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);
});
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { directionToHeading } from './directionText.js';
test('conflicting facing directions cannot manufacture one heading', () => {
for (const bare of [false, true]) {
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
assert.equal(directionToHeading('NB northbound', bare), 0);
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
}
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
assert.equal(directionToHeading('West / SR-24', true), 270);
});
test('travel tokens retain precedence over quadrant streets and bare words', () => {
for (const [text, heading] of [
['I-5 NB at NE 45th St', 0],
['SR 520 EB at NE 148th', 90],
['US 13 SB @ SW Front St', 180],
]) {
for (const bare of [false, true])
assert.equal(directionToHeading(text, bare), heading);
}
assert.equal(
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
270,
);
assert.equal(directionToHeading('NE near West St', true), 45);
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
});
+15
View File
@@ -0,0 +1,15 @@
import * as Cesium from 'cesium';
/** Camera range with breathing room in both viewport dimensions. */
export function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera?.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
+1 -1
View File
@@ -111,7 +111,7 @@ export const DATA_CREDITS = [
{
key: 'adsblol',
html:
'Military flights, aircraft traces &amp; bounded regional flight fallback: ' +
'Military flights, aircraft traces, bounded regional flight fallback &amp; GNSS navigation integrity: ' +
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
+28 -16
View File
@@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) {
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45;
if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315;
if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135;
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
// Bare cardinals — dedicated direction fields only.
if (allowBare) {
if (/\bNORTH\b/.test(text)) return 0;
if (/\bSOUTH\b/.test(text)) return 180;
if (/\bEAST\b/.test(text)) return 90;
if (/\bWEST\b/.test(text)) return 270;
const tiers = [
[
[/\bNORTHBOUND\b|\bNB\b/, 0],
[/\bSOUTHBOUND\b|\bSB\b/, 180],
[/\bEASTBOUND\b|\bEB\b/, 90],
[/\bWESTBOUND\b|\bWB\b/, 270],
],
[
[/\bNORTHEAST\b|\bNE\b/, 45],
[/\bNORTHWEST\b|\bNW\b/, 315],
[/\bSOUTHEAST\b|\bSE\b/, 135],
[/\bSOUTHWEST\b|\bSW\b/, 225],
],
];
if (allowBare)
tiers.push([
[/\bNORTH\b/, 0],
[/\bSOUTH\b/, 180],
[/\bEAST\b/, 90],
[/\bWEST\b/, 270],
]);
for (const forms of tiers) {
const headings = new Set();
for (const [pattern, heading] of forms) {
if (pattern.test(text)) headings.add(heading);
}
if (headings.size)
return headings.size === 1 ? headings.values().next().value : NaN;
}
return NaN;
}
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { acquisitionMsUtc } from './firmsCsv.js';
test('FIRMS acquisition dates cannot roll into a different calendar day', () => {
for (const [date, time] of [
['2026-02-30', '1200'],
['2026-04-31', '1200'],
['2026-13-01', '1200'],
['2026-07-16junk', '1200'],
['2026-07-16', '2400'],
['2026-07-16', '1260'],
['2026-07-16', null],
['2026-07-16', ''],
]) {
assert.ok(Number.isNaN(acquisitionMsUtc(date, time)), `${date}:${time}`);
}
for (const [date, time, expected] of [
['2024-02-29', '45', Date.UTC(2024, 1, 29, 0, 45)],
['2026-07-16', '0', Date.UTC(2026, 6, 16)],
]) {
assert.equal(acquisitionMsUtc(date, time), expected);
}
});
+7 -1
View File
@@ -163,7 +163,13 @@ export function acquisitionMsUtc(acqDate, acqTime) {
minutes > 59
)
return NaN;
return Date.UTC(year, month - 1, day, hours, minutes);
const ms = Date.UTC(year, month - 1, day, hours, minutes);
const parsed = new Date(ms);
return parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() === month - 1 &&
parsed.getUTCDate() === day
? ms
: NaN;
}
/**
+6
View File
@@ -79,6 +79,12 @@ test('flights analyst record: full record maps every contract field', () => {
aircraftClass: 'airliner',
originCountry: 'United States',
operator: 'Southwest Airlines',
squawk: null,
emergency: false,
emergencyKind: null,
emergencySource: null,
emergencyBroadcast: null,
lastContactAtMs: null,
routeOrigin: 'AUS',
routeDestination: 'LAX',
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
// Inspect encoded headers before allowing the browser to decode local images.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
/** Width and height from a supported raster header; no image is decoded. */
export function importImageSize(bytes) {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
const u24 = (at) => bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
if (
bytes.length >= 24 &&
v.getUint32(0) === 0x89504e47 &&
ascii(12, 4) === 'IHDR'
)
return [v.getUint32(16), v.getUint32(20)];
if (bytes.length >= 10 && /^GIF8[79]a$/.test(ascii(0, 6)))
return [v.getUint16(6, true), v.getUint16(8, true)];
if (bytes.length >= 26 && ascii(0, 2) === 'BM') {
if (v.getUint32(14, true) === 12)
return [v.getUint16(18, true), v.getUint16(20, true)];
return [Math.abs(v.getInt32(18, true)), Math.abs(v.getInt32(22, true))];
}
if (bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
const kind = ascii(12, 4);
if (kind === 'VP8X') return [1 + u24(24), 1 + u24(27)];
if (kind === 'VP8 ' && u24(23) === 0x2a019d)
return [v.getUint16(26, true) & 0x3fff, v.getUint16(28, true) & 0x3fff];
if (kind === 'VP8L' && bytes[20] === 0x2f) {
const bits = v.getUint32(21, true);
return [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1];
}
}
if (bytes.length >= 4 && v.getUint16(0) === 0xffd8) {
for (let at = 2; at + 4 <= bytes.length;) {
if (bytes[at++] !== 0xff) break;
while (bytes[at] === 0xff) at++;
const marker = bytes[at++];
if (marker === 0xd9 || marker === 0xda) break;
if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue;
const length = v.getUint16(at);
if (length < 2 || at + length > bytes.length) break;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd,
0xce, 0xcf,
].includes(marker) &&
length >= 8
)
return [v.getUint16(at + 5), v.getUint16(at + 3)];
at += length;
}
}
return null;
}
/** Charge each distinct encoded image once against the file's decoded budget. */
export function createImportImageBudget() {
const seen = new Set();
let pixels = 0;
return (key, bytes) => {
if (seen.has(key)) return;
const size = importImageSize(bytes);
if (
!size ||
size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_SIDE) ||
pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
)
throw new Error(
'Local images exceed the decoded image limit or have an unreadable header.',
);
pixels += size[0] * size[1];
seen.add(key);
};
}
+128
View File
@@ -0,0 +1,128 @@
import * as Cesium from 'cesium';
const STYLE = [
'image',
'scale',
'rotation',
'alignedAxis',
'horizontalOrigin',
'verticalOrigin',
'color',
'eyeOffset',
'pixelOffset',
'width',
'height',
'scaleByDistance',
'translucencyByDistance',
'pixelOffsetScaleByDistance',
'distanceDisplayCondition',
'sizeInMeters',
'disableDepthTestDistance',
'imageSubRegion',
];
class ImportedBillboards extends Cesium.BillboardCollection {
update(frameState) {
// Height queries should find the map, never another imported marker.
// Ordinary click picks and rendered screenshots still include the pins.
if (frameState.passes.pick && frameState.passes.offscreen) return;
super.update(frameState);
}
}
/** Own static imported pins without a per-frame Entity visualizer walk. */
export function createImportedPins(dataSource, viewer) {
const all = dataSource.entities.values.slice();
const now = Cesium.JulianDate.now();
const pins = all.filter((entity) => entity.billboard && entity.position);
if (!pins.length) return { mount() {}, sync() {}, destroy() {} };
const collection = new ImportedBillboards({ scene: viewer.scene });
const billboards = [];
try {
for (const entity of pins) {
const position = entity.position.getValue(now);
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
throw new Error('Invalid imported point position.');
const options = {
id: entity,
position,
show:
entity.isShowing && entity.billboard.show?.getValue(now) !== false,
heightReference: Cesium.HeightReference.NONE,
};
for (const key of STYLE) {
const value = entity.billboard[key]?.getValue(now);
if (value !== undefined) options[key] = value;
}
billboards.push(collection.add(options));
}
} catch (error) {
collection.destroy();
throw error;
}
// Rebuild once before the source is added to DataSourceDisplay. Removing
// one entity at a time would itself make this transition quadratic.
dataSource.entities.suspendEvents();
try {
dataSource.entities.removeAll();
for (const entity of all)
if (!entity.billboard || !entity.position)
dataSource.entities.add(entity);
} finally {
dataSource.entities.resumeEvents();
}
Object.defineProperty(dataSource, 'importedEntities', {
value: all,
configurable: true,
});
Object.defineProperty(dataSource, 'importedBillboards', {
value: collection,
configurable: true,
});
let show = dataSource.show,
mounted = false,
destroyed = false;
Object.defineProperty(dataSource, 'show', {
configurable: true,
get: () => show,
set(value) {
show = Boolean(value);
dataSource.entities.show = show;
collection.show = show;
},
});
collection.show = show;
return {
mount() {
if (destroyed || mounted) return;
viewer.scene.primitives?.add(collection);
mounted = true;
},
sync() {
if (destroyed) return;
for (let i = 0; i < pins.length; i++) {
billboards[i].position = pins[i].position.getValue(now);
billboards[i].show =
pins[i].isShowing && pins[i].billboard.show?.getValue(now) !== false;
}
viewer.scene.requestRender?.();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (viewer.trackedEntity && pins.includes(viewer.trackedEntity))
viewer.trackedEntity = undefined;
if (viewer.selectedEntity && pins.includes(viewer.selectedEntity))
viewer.selectedEntity = undefined;
if (mounted && !viewer.scene.primitives?.isDestroyed?.())
viewer.scene.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
all.length = 0;
pins.length = 0;
billboards.length = 0;
},
};
}
+429
View File
@@ -0,0 +1,429 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gnssIntegrityAnchor,
gnssIntegrityProxy,
gnssRetryCooldownMs,
} from '../../server/providers/gnssIntegrity.js';
const snapshot = {
now: 4_000_000_000_000,
ac: [
{
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 0,
nac_p: 0,
seen_pos: 1,
flight: 'RYR1AB ',
r: 'EI-ABC',
},
{ hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 },
],
};
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options });
assert.equal(plugin.name, 'gnss-integrity');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/gnss-integrity');
handler = callback;
},
},
});
return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
test('anchors snap to whole degrees and reject missing or out-of-range values', () => {
assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), {
lat: 50,
lon: 20,
});
assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null);
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json(snapshot);
},
},
hook,
);
const res = await request(
'/?lat=50.2&lon=19.8&url=https://invalid.example',
);
assert.equal(res.status, 200);
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
assert.deepEqual(res.body, {
fetchedAt: 1234,
ageMs: 0,
anchor: { lat: 50, lon: 20 },
radiusNm: 250,
classifier: 'gev-nic-nacp-v2',
rows: [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 0,
nacp: 0,
gpsLost: false,
degraded: true,
},
],
});
assert.equal(res.headers['Cache-Control'], 'no-store');
});
}
test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return Response.json(snapshot);
},
});
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
assert.equal(calls, 1);
clock = 59_000;
const cached = await request();
assert.equal(calls, 1);
assert.equal(cached.body.fetchedAt, 0);
assert.equal(
cached.body.ageMs,
59_000,
'a cached answer reports its age on the server clock',
);
clock = 61_000;
await request();
assert.equal(calls, 2);
await request('/?lat=10&lon=10');
assert.equal(calls, 3);
});
test('an upstream failure serves the last snapshot as stale, else 502', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 120_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(
stale.body.fetchedAt,
0,
'a stale answer keeps the time it was observed',
);
assert.equal(stale.body.ageMs, 120_000);
assert.equal(stale.headers['X-Data-Stale'], 'true');
const missing = await request('/?lat=-10&lon=-10');
assert.equal(missing.status, 502);
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
});
test('a stale snapshot older than the layer window is no longer served', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 30 * 60_000;
assert.equal((await request()).status, 200);
clock = 30 * 60_000 + 1;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' });
});
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return new Response('slow down', {
status: 429,
headers: { 'retry-after': '90' },
});
},
});
const first = await request();
assert.equal(first.status, 429);
assert.equal(first.headers['Retry-After'], '90');
clock = 30_500;
const cooling = await request('/?lat=1&lon=1');
assert.equal(cooling.status, 429);
// The browser is told the remaining shared cooldown, rounded up.
assert.equal(cooling.headers['Retry-After'], '60');
assert.equal(calls, 1);
clock = 91_000;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
});
test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => {
const nowMs = Date.parse('2026-09-29T12:00:00Z');
assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000);
assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000);
assert.equal(gnssRetryCooldownMs('3600', nowMs), 3_600_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 13:00:00 GMT', nowMs),
3_600_000,
);
assert.equal(gnssRetryCooldownMs('1e309', nowMs), 60_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs),
45_000,
);
// Garbage, a past date or no header fall back to the default minute.
assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs),
60_000,
);
assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000);
});
test('overlapping 429s preserve the longest shared deadline', async () => {
let clock = 0,
calls = 0;
const pending = [];
const request = install({
now: () => clock,
fetchImpl: () => {
calls += 1;
return new Promise((resolve) => pending.push(resolve));
},
});
const long = request('/?lat=1&lon=0'),
short = request('/?lat=2&lon=0');
await new Promise((resolve) => setImmediate(resolve));
pending[0](
new Response('', { status: 429, headers: { 'Retry-After': '3600' } }),
);
assert.equal((await long).headers['Retry-After'], '3600');
clock = 1000;
pending[1](
new Response('', { status: 429, headers: { 'Retry-After': '5' } }),
);
assert.equal((await short).headers['Retry-After'], '3599');
clock = 7000;
assert.equal((await request('/?lat=3&lon=0')).status, 429);
assert.equal(calls, 2);
clock = 3_600_001;
const resumed = request('/?lat=3&lon=0');
await new Promise((resolve) => setImmediate(resolve));
pending[2](Response.json(snapshot));
assert.equal((await resumed).status, 200);
assert.equal(calls, 3);
});
test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => {
let clock = 0;
let limited = false;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return limited
? new Response('slow down', { status: 429 })
: Response.json(snapshot);
},
});
await request();
limited = true;
clock = 61_000;
// Another anchor trips the shared cooldown…
assert.equal((await request('/?lat=5&lon=5')).status, 429);
clock = 62_000;
// …and the cached anchor is answered from cache without an upstream call.
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.rows.length, 1);
assert.equal(calls, 2);
});
test('an oversized upstream body is refused as 502 without being parsed', async () => {
const request = install({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(9 * 1024 * 1024) },
}),
});
const res = await request();
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' });
});
test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
const request = install({
fetchImpl: async () => Response.json({ nope: true }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/other?lat=1&lon=1')).status, 404);
assert.equal((await request('/?lat=1')).status, 400);
assert.equal((await request('/?lat=1&lon=1')).status, 502);
const flood = install({ fetchImpl: async () => Response.json(snapshot) });
let limited = 0;
for (let i = 0; i < 40; i += 1)
if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429)
limited += 1;
assert.ok(limited > 0);
});
test('GERMAN: pathological retry deadlines are bounded', () => {
const at = Date.parse('2026-10-14T12:00:00Z');
assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000);
assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000);
assert.equal(
gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at),
3600000,
);
assert.equal(gnssRetryCooldownMs('0x10', at), 60000);
});
test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => {
for (const fail of [
async () => new Response('', { status: 503 }),
async () => {
throw new DOMException('timeout', 'TimeoutError');
},
async () => new Response('bad JSON'),
]) {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls++;
return fail();
},
});
assert.equal((await request()).status, 502);
clock = 14000;
assert.equal((await request('/?lat=1&lon=1')).status, 502);
assert.equal(calls, 1);
clock = 15001;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
}
});
test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => {
const now = 1800000000000;
const request = install({
now: () => now,
fetchImpl: async () =>
Response.json({ ...snapshot, now: (now - 120000) / 1000 }),
});
const res = await request();
assert.equal(res.body.ageMs, 120000);
assert.equal(res.body.stale, true);
for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) {
const missing = install({
now: () => now,
fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }),
});
assert.equal((await missing()).status, 502);
}
});
test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => {
let wall = 1800000000000,
mono = 0,
calls = 0;
const request = install({
now: () => wall,
monotonicNow: () => mono,
fetchImpl: async () => {
calls++;
return calls === 1
? Response.json({ ...snapshot, now: wall / 1000 })
: new Response('', { status: 429, headers: { 'Retry-After': '60' } });
},
});
await request();
wall -= 3600000;
mono = 61000;
const stale = await request();
assert.equal(stale.body.ageMs, 61000);
assert.equal(stale.body.stale, true);
mono += 1000;
assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59');
assert.equal(calls, 2);
});
test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => {
let calls = 0;
const request = install({
now: () => 1800000000000,
fetchImpl: async () => {
calls++;
return Response.json(snapshot);
},
});
for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200);
assert.equal((await request('/?lat=1&lon=1')).status, 200);
assert.equal(calls, 2);
});
test('GERMAN: distinct concurrent upstream reads are bounded', async () => {
const pending = [];
const request = install({
fetchImpl: () => new Promise((resolve) => pending.push(resolve)),
});
const jobs = Array.from({ length: 30 }, (_, i) =>
request(`/?lat=${i}&lon=1`),
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(pending.length <= 8);
pending.forEach((resolve) => resolve(Response.json(snapshot)));
const results = await Promise.all(jobs);
assert.ok(results.some((result) => result.status === 429));
});
+5
View File
@@ -610,6 +610,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'flights',
}),
Object.freeze({
id: 'gnss-integrity',
token: '3',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-datacenters',
+30 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 29);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
assert.equal(REGISTERED_LAYER_IDS.length, 30);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '3');
assert.equal(nextLayerStateToken(), '4');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
alpha: '4',
bravo: '5',
}),
'5',
'6',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
[...'456789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '3',
next: '4',
future: '4',
next: '5',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
/next free token 4/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
'fire-perimeters',
'gnss-integrity',
'recent-imagery',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token,
'3',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, [
+33 -29
View File
@@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
{
...LEGACY_LAYER_STATE_TOKENS,
'street-level': '0',
'gnss-integrity': '3',
},
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
.future,
'3',
'4',
);
assert.throws(
() =>
@@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '3']] },
{ rows: [...reservationRows, ['retired-layer', '4']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
assert.equal(nextLayerStateToken(published), '5');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '4',
newcomer: '5',
}),
true,
);
@@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '3',
competing: '4',
}),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '3',
newcomer: '4',
}),
/changed or removed: retired-layer/,
);
@@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
[...reservationRows, ['pr-b', '4']],
[{ id: 'pr-b', token: '4' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '4' },
],
);
const staleRebase = runCheck();
@@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
assert.equal(nextLayerStateToken(published), '5');
const validBaselineRows = [...reservationRows, ['pr-a', '4']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 4/);
assert.match(helper.stdout, /pr-b: 5/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '5' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /30 published, 1 new/);
assert.match(corrected.stdout, /31 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'3456789'].map((digit) => [
const priorDigits = [...'456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 69);
assert.equal(params.get('l').length, 71);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 30);
assert.equal(Object.keys(published).length, 31);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -9,6 +9,7 @@
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["gnss-integrity", "3"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
+6
View File
@@ -71,6 +71,12 @@ test('military analyst record: full record maps every contract field', () => {
aircraftClass: 'widebody',
originCountry: null,
operator: 'United States Air Force',
squawk: null,
emergency: false,
emergencyKind: null,
emergencySource: null,
emergencyBroadcast: null,
lastContactAtMs: null,
routeOrigin: null,
routeDestination: null,
});
+64
View File
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import https from 'node:https';
import { Readable } from 'node:stream';
import test from 'node:test';
import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js';
import {
createRadioProxyMiddleware,
isPublicRadioAddress,
@@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor
}
assert.equal(fetchCount, 0);
});
// Answer every pinned request with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of leaving the fetch pending.
function mockMirrorStatus(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'application/json' },
}),
),
);
return request;
});
return crashed;
}
function fetchMirror() {
return fetchPinnedRadioResponse(
new URL('https://de1.api.radio-browser.info/json/servers'),
{ headers: {} },
[{ address: '93.184.216.34', family: 4 }],
);
}
for (const status of [204, 205, 304]) {
test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
const response = await Promise.race([fetchMirror(), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
await assert.rejects(
Promise.race([fetchMirror(), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRegionalArticles } from './regionalModel.js';
test('compact source dates must denote an actual calendar instant', () => {
for (const date of [
'20260230T120000Z',
'20260431T120000Z',
'20261301T120000Z',
'20260716T240000Z',
'20260716T126000Z',
'20260716T120060Z',
]) {
const [article] = normalizeRegionalArticles({
articles: [
{
title: 'Fixture',
url: 'https://fixture.example/news',
seendate: date,
},
],
});
assert.equal(article.publishedAt, null, date);
assert.equal(article.title, 'Fixture');
}
for (const [input, expected] of [
['20240229T004500Z', '2024-02-29T00:45:00Z'],
['20260716T000000Z', '2026-07-16T00:00:00Z'],
['2026-07-16T12:00:00Z', '2026-07-16T12:00:00.000Z'],
]) {
assert.equal(
normalizeRegionalArticles({
articles: [
{
title: 'Fixture',
url: 'https://fixture.example/news',
seendate: input,
},
],
})[0].publishedAt,
expected,
);
}
});
+13 -4
View File
@@ -67,11 +67,20 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
seen.add(signature);
const rawDate = cleanText(row?.seendate, 32);
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
const candidateDate = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate))
? null
: new Date(rawDate).toISOString();
: rawDate;
const parsedMs = Date.parse(candidateDate);
const validDate =
Number.isFinite(parsedMs) &&
(!compactDate ||
new Date(parsedMs).toISOString().slice(0, 19) ===
candidateDate.slice(0, 19));
const publishedAt = validDate
? compactDate
? candidateDate
: new Date(parsedMs).toISOString()
: null;
articles.push({
title,
url,
+7 -1
View File
@@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) {
if (parts.length !== 2) return null;
const lon = Number(parts[0]);
const lat = Number(parts[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
if (
!Number.isFinite(lon) ||
!Number.isFinite(lat) ||
Math.abs(lon) > 180 ||
Math.abs(lat) > 90
)
return null;
points.push([lon, lat]);
}
return points;
+11
View File
@@ -3,6 +3,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
parseTerrainPoints,
fetchTerrainChunkWithRetry,
resolveTerrainHeightRequest,
terrainPointKey,
@@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as
assert.equal(response.cacheChanged, false);
}
});
test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => {
for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91'])
assert.equal(parseTerrainPoints(value), null, value);
assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [
[-180, -90],
[180, 90],
[0, 0],
]);
});
+3 -3
View File
@@ -993,11 +993,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// referent-only track_entity alternative (kept out of the model-facing
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level adds toggle enum values and the generated alias hint.
assert.equal(block.length, 29802, 'serialized tool schema length drifted');
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
assert.equal(block.length, 30511, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
googlePlacesContextProxy,
validateGeocodeAddress,
geocodeBounds,
} from '../vite.config.js';
const UPSTREAM = 'https://geocode.test/json';
function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) {
const routes = new Map();
googlePlacesContextProxy({
resolveApiKey: () => apiKey,
fetchImpl,
endpoints: { geocode: UPSTREAM },
}).configureServer({
middlewares: { use: (name, handler) => routes.set(name, handler) },
});
return routes;
}
function invokeRoute(handler, { method = 'GET', url = '/' } = {}) {
return new Promise((resolve, reject) => {
const headers = new Map();
const res = {
statusCode: 200,
setHeader(name, value) {
headers.set(String(name).toLowerCase(), String(value));
},
end(body = '') {
resolve({
statusCode: this.statusCode,
headers: Object.fromEntries(headers),
body: body ? JSON.parse(String(body)) : null,
});
},
};
Promise.resolve(
handler(
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
res,
),
).catch(reject);
});
}
test('geocoder query and viewport bias are validated before Google is paid', () => {
assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), {
ok: false,
error: 'address is required',
});
assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), {
ok: false,
error: 'address is required',
});
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })),
{ ok: false, error: 'address is too long' },
);
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })),
{ ok: true, address: 'Austin' },
);
// A bias only ranks results, so an unusable one is dropped, not refused.
const bias = '30.1,-97.9|30.4,-97.6';
assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias);
for (const bounds of [
'',
'30.1,-97.9',
'30.1,-97.9|30.4',
'north,-97.9|30.4,-97.6',
'91,-97.9|30.4,-97.6',
'30.1,-181|30.4,-97.6',
// A blank component must not read as 0 — that is the equator, not a corner.
'30.1,-97.9|30.4,',
'30.1,|30.4,-97.6',
'30.1,-97.9| ,-97.6',
])
assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null);
});
test('the geocode routes keep the key server-side and answer in Google shape', async () => {
const upstream = [];
const routes = installRoutes({
fetchImpl: async (url) => {
upstream.push(new URL(String(url)));
return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] });
},
});
const forward = routes.get('/api/google/geocode');
const reverse = routes.get('/api/google/reverse-geocode');
assert.equal(typeof forward, 'function');
assert.equal(typeof reverse, 'function');
const forwarded = await invokeRoute(forward, {
url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6',
});
assert.equal(forwarded.statusCode, 200);
assert.deepEqual(forwarded.body, {
status: 'OK',
results: [{ place_id: 'fixture' }],
});
assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin');
assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' });
assert.equal(reversed.statusCode, 200);
assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
// The key travels to Google, never back to the page that asked.
assert.ok(!JSON.stringify(forwarded.body).includes('server-secret'));
assert.ok(!JSON.stringify(reversed.body).includes('server-secret'));
// A malformed bias is dropped rather than forwarded.
await invokeRoute(forward, { url: '/?address=Austin&bounds=north' });
assert.equal(upstream.at(-1).searchParams.has('bounds'), false);
});
test('a bad or keyless geocode request never reaches Google', async () => {
const upstream = [];
const fetchImpl = async (url) => {
upstream.push(String(url));
return Response.json({ status: 'OK', results: [] });
};
const routes = installRoutes({ fetchImpl });
for (const [path, url] of [
['/api/google/geocode', '/?address='],
['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'],
]) {
const response = await invokeRoute(routes.get(path), { url });
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body.results, []);
assert.equal(response.body.status, 'REQUEST_DENIED');
}
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const rejected = await invokeRoute(routes.get(path), {
method: 'POST',
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(rejected.statusCode, 405);
}
// Keyless is an empty capability, not an outage: a 200 the browser adapters
// read as "this provider did not answer", so the chain moves on. It resolves
// ahead of validation, like the Places routes: with nothing configured there
// is no request to validate and no quota to protect.
const keyless = installRoutes({ apiKey: ' ', fetchImpl });
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode'])
for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) {
const response = await invokeRoute(keyless.get(path), { url });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.body, {
configured: false,
status: 'REQUEST_DENIED',
results: [],
error: null,
});
}
assert.deepEqual(upstream, []);
});
test('an upstream failure reaches the browser as fixed public text', async () => {
const routes = installRoutes({
fetchImpl: async () => {
throw new Error('connect ECONNREFUSED 10.0.0.7:443');
},
});
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const response = await invokeRoute(routes.get(path), {
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
}
});
test('an oversized upstream body is dropped rather than buffered', async () => {
const routes = installRoutes({
// Two hundred times the 256 KiB ceiling, declared honestly.
fetchImpl: async () =>
Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }),
});
const response = await invokeRoute(routes.get('/api/google/geocode'), {
url: '/?address=Austin',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
});
+12 -4
View File
@@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the
}
});
test('both Places routes select the intended key and keep it out of responses', async (t) => {
test('every Google route selects the intended key and keeps it out of responses', async (t) => {
const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN };
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, key: options.headers['X-Goog-Api-Key'] });
// Places sends the key as a header; Geocoding's web service takes it as a
// query parameter. Either way it must be the server key, and never the
// browser's copy of it.
calls.push({
url,
key:
options?.headers?.['X-Goog-Api-Key'] ??
new URL(String(url)).searchParams.get('key'),
});
return Response.json({ places: [] });
});
try {
@@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses',
for (const install of ['configureServer', 'configurePreviewServer']) {
const routes = new Map();
googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } });
assert.equal(routes.size, 2);
assert.equal(routes.size, 4);
for (const handler of routes.values()) {
const before = calls.length;
let body;
const res = { setHeader() {}, end(value) { body = value; } };
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
assert.equal(res.statusCode, 200);
assert.ok(!body.includes('server-secret'));
if (expected) {
+35 -1
View File
@@ -2123,5 +2123,39 @@
"fire confidence|low": "baja",
"age|<1h": "<1 h",
"age|{count}h": "{count} h",
"age|{count}d": "{count} d"
"age|{count}d": "{count} d",
"General emergency": "Emergencia general",
"Unlawful interference": "Interferencia ilícita",
"Radio failure": "Fallo de radio",
"Downed aircraft": "Aeronave derribada",
"Minimum fuel": "Combustible mínimo",
"Lifeguard (medical priority)": "Lifeguard (prioridad médica)",
"Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {minAircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Puntuación de baja precisión = 100 × máx(0, malas − 1) / total: se descuenta una aeronave, así que no es la proporción bruta. Las celdas necesitan {minAircraft} aeronaves durante {minutes} minutos. Las anomalías de integridad de navegación no constituyen una detección de jamming o spoofing.",
"{fleet} · {emergency} · {feedState}": "{fleet} · {emergency} · {feedState}",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "No se pudieron refrescar las alturas del terreno. Prueba de nuevo con el archivo cuando el mapa haya cargado.",
"{count} map objects": {
"one": "{count} objeto de mapa",
"many": "{count} objetos de mapa",
"other": "{count} objetos de mapa"
},
"{name} — {format}, {objects}, drawn with its own styles": "{name} — {format}, {objects}, dibujado con sus propios estilos",
"{name} — {format}, {objects}": "{name} — {format}, {objects}",
"Hide {name}": "Ocultar {name}",
"Show {name}": "Mostrar {name}",
"Fly to {name}": "Volar a {name}",
"Remove {name}": "Quitar {name}",
"At most {max} files at once — remove one first.": "Como máximo {max} archivos a la vez — quita uno primero.",
"Reading {file}…": "Leyendo {file}…",
"Added {count} files.": {
"one": "Se añadió {count} archivo.",
"many": "Se añadieron {count} archivos.",
"other": "Se añadieron {count} archivos."
},
"{name}: {error}": "{name}: {error}",
"{count} files could not be added ({name}: {error})": "No se pudieron añadir {count} archivos ({name}: {error})",
"Some KML content was left out.": "Parte del contenido KML se quedó fuera.",
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra tus propios archivos GeoJSON, KML, KMZ o GPX sobre el globo (o suéltalos sobre él). Los bytes del archivo se quedan en este dispositivo; las superposiciones pueden aparecer en capturas del mapa compartidas por voz.",
"Import": "Importar",
"Choose GeoJSON, KML, KMZ or GPX files": "Elige archivos GeoJSON, KML, KMZ o GPX",
"Imported files": "Archivos importados"
}
+37 -1
View File
@@ -2156,5 +2156,41 @@
"fire confidence|low": "низкая",
"age|<1h": "<1 ч",
"age|{count}h": "{count} ч",
"age|{count}d": "{count} д"
"age|{count}d": "{count} д",
"General emergency": "Общая чрезвычайная ситуация",
"Unlawful interference": "Незаконное вмешательство",
"Radio failure": "Отказ радиосвязи",
"Downed aircraft": "Упавшее воздушное судно",
"Minimum fuel": "Минимум топлива",
"Lifeguard (medical priority)": "Lifeguard (медицинский приоритет)",
"Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {minAircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Оценка низкой точности = 100 × макс(0, плохих − 1) / всего: один борт вычитается, поэтому это не сырая доля. Ячейкам нужны {minAircraft} бортов в течение {minutes} минут. Аномалии навигационной целостности — не обнаружение глушения или спуфинга.",
"{fleet} · {emergency} · {feedState}": "{fleet} · {emergency} · {feedState}",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Не удалось обновить высоты рельефа. Повторите загрузку файла, когда карта прогрузится.",
"{count} map objects": {
"one": "{count} объект карты",
"few": "{count} объекта карты",
"many": "{count} объектов карты",
"other": "{count} объекта карты"
},
"{name} — {format}, {objects}, drawn with its own styles": "{name} — {format}, {objects}, отрисован собственными стилями",
"{name} — {format}, {objects}": "{name} — {format}, {objects}",
"Hide {name}": "Скрыть {name}",
"Show {name}": "Показать {name}",
"Fly to {name}": "Перелететь к {name}",
"Remove {name}": "Убрать {name}",
"At most {max} files at once — remove one first.": "Не больше {max} файлов одновременно — сначала уберите один.",
"Reading {file}…": "Чтение {file}…",
"Added {count} files.": {
"one": "Добавлен {count} файл.",
"few": "Добавлено {count} файла.",
"many": "Добавлено {count} файлов.",
"other": "Добавлено {count} файла."
},
"{name}: {error}": "{name}: {error}",
"{count} files could not be added ({name}: {error})": "Не удалось добавить {count} файлов ({name}: {error})",
"Some KML content was left out.": "Часть содержимого KML была пропущена.",
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показывайте свои файлы GeoJSON, KML, KMZ или GPX на глобусе (или перетащите их на него). Байты файла остаются на этом устройстве; наложения могут попасть в снимки карты, которыми делятся голосом.",
"Import": "Импорт",
"Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX",
"Imported files": "Импортированные файлы"
}
+6
View File
@@ -7,6 +7,7 @@ export {
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
@@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
export const EMERGENCY_TINT =
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
// taxiing and landing") -----------------------------------------------------------
// Present-but-grounded planes are RENDERED instead of being skipped: same class
+23 -1
View File
@@ -15,6 +15,7 @@ import {
FOCUS_EVIDENCE_DEV,
FLEET_DR_INTERVAL_MS,
} from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -167,6 +168,10 @@ export function createQueries({
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
),
// This poll's transponder broadcast (not sticky; see records.js).
squawk: info?.squawk ?? null,
emergency: info?.emergency ? { ...info.emergency } : null,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
airline: info?.airline ?? null,
// CLASS label follows the TR-3B conversion so every downstream card
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
@@ -219,7 +224,10 @@ export function createQueries({
* lon: number|null, altitudeM: number|null, speedMps: number|null,
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
* military: boolean, aircraftClass: string|null, originCountry: string|null,
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
* operator: string|null, squawk: string|null, emergency: boolean,
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null,
* routeDestination: string|null}}
*/
function mapAnalystRecord(
@@ -253,6 +261,20 @@ export function createQueries({
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: text(info?.originCountry),
operator: text(info?.airline),
squawk: text(info?.squawk),
// True while this poll broadcast an emergency (7500/7600/7700 or the
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
// without the flag.
emergency: info?.emergency?.severity === 'emergency',
emergencyKind: text(info?.emergency?.kind),
// Provenance, so a summary can say "broadcasting squawk 7700" rather
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
// when the Mode A code names the status, 'ads-b' for the readsb field.
emergencySource: text(info?.emergency?.source),
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
// Latest known general aircraft-message contact, retained if a poll
// has no time. This is not the receipt time of the current status.
lastContactAtMs: num(info?.lastContactEpochMs),
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
};
+17
View File
@@ -4,6 +4,7 @@ import {
pickRenderAltitudeM,
} from '../../data/renderAltitude.js';
import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
@@ -226,6 +227,22 @@ export class FlightRecords {
// like callsign so a transient blank row doesn't blank the field.
originCountry:
stickyText(origin_country, prevMeta?.originCountry) || null,
// Per poll, NOT sticky: an emergency code clears when the crew resets
// it, so a blank squawk must not hold a stale 7700 on screen.
squawk: observation.squawk ?? null,
// One source-switch omission may retain an ADS-B status, while an
// explicit clear or changed squawk always takes effect immediately.
emergency:
observation.emergency === undefined &&
observation.squawk === prevMeta?.squawk &&
prevMeta?.emergency?.source === 'ads-b' &&
(prevMeta?.missingEmergencyPolls ?? 0) < 1
? prevMeta.emergency
: aircraftEmergency(observation),
missingEmergencyPolls:
observation.emergency === undefined
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
: 0,
// OpenSky distinguishes the last position epoch from the last
// transponder message. The fleet coast horizon uses this actual
// contact time so a temporarily old position does not hard-freeze
+10
View File
@@ -106,3 +106,13 @@ test('ground transitions preserve last render height while a surface cell is col
assert.equal(next.meta.renderAltitudeM, 1040);
assert.equal(next.meta.wasAirborne, true);
});
test('civil emergency status follows the latest poll instead of sticking', () => {
const store = records();
const first = store.receive(observation({ squawk: '7700' }), view());
assert.equal(first.meta.squawk, '7700');
assert.equal(first.meta.emergency.kind, 'general');
const next = store.receive(observation({ squawk: '2000' }), view());
assert.equal(next.meta.squawk, '2000');
assert.equal(next.meta.emergency, null);
});
+29 -10
View File
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -13,6 +14,7 @@ import {
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import {
MIL_TINT,
EMERGENCY_TINT,
GROUND_SCALE,
COCKPIT_CONTACT_SIZE_PX,
COCKPIT_CIVILIAN_COLOR,
@@ -54,13 +56,34 @@ export function createRendering({
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
services.recession;
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
/** True while this contact's latest poll broadcast an emergency code. */
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
return isEmergency(info?.emergency);
}
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
* amber for known-military, white otherwise.
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
function _fleetBillboardColor(icao24) {
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
}
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
function _cockpitContactColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
}
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
function _fleetBillboardScale(icao24, klass) {
@@ -129,9 +152,7 @@ export function createRendering({
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = (
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
).withAlpha(freshnessAlpha);
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
@@ -162,7 +183,7 @@ export function createRendering({
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
return _fleetBillboardColor(icao24);
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -1009,10 +1030,8 @@ export function createRendering({
flightState._cockpitNearContacts.has(icao24);
const layerBaseColor =
flightState._cockpitContactMode && !isCockpitNear
? isMilitaryIcao(icao24)
? MIL_TINT
: COCKPIT_CIVILIAN_COLOR
: _fleetBillboardColor(icao24);
? _cockpitContactColor(icao24, info)
: _fleetBillboardColor(icao24, info);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
+429
View File
@@ -0,0 +1,429 @@
import * as Cesium from 'cesium';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
gnssProvenance,
pruneGnssObservations,
} from './records.js';
import { t } from '../../i18n/index.js';
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
/**
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
* This fill participates only in rendering, so sibling polygons remain pickable.
*/
class GnssGroundFill extends Cesium.GroundPrimitive {
update(frameState) {
if (!frameState.passes.pick) super.update(frameState);
}
}
/** Low stays visible over land so coverage reads apart from no data. */
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
const LEVEL_LABELS = Object.freeze({
low: 'Low-accuracy score < 2%',
medium: 'Low-accuracy score 2–10%',
high: 'Low-accuracy score > 10%',
});
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
// At globe scale the visible rectangle can be the whole world, whose
// midpoint is unrelated to the place under the screen centre.
const centre =
canvas?.clientWidth > 0 &&
canvas.clientHeight > 0 &&
viewer.camera?.pickEllipsoid?.(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle =
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
/**
* Own one GNSS navigation-integrity display: rolling integrity observations
* around the views the user visits, binned into cells coloured by the
* gpsjam low-accuracy score. Share links retain token 3.
*/
export function createGnssIntegrityLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
let _viewer = null;
let _primitives = null;
let _request = null;
let _enabled = false;
let _cells = [];
let _signature = null;
let _lastUpdate = null;
let _lastError = null;
let _stale = false;
let _manager = null;
let _cameraTimer = null;
let _backgroundRefresh = null;
let _lastQueryKey = null;
let _lastRequestAt = -Infinity;
let _removeCameraEnd = null;
let _removeCameraChanged = null;
let _coverage = null;
let _retryTimer = null;
let _retryAt = 0;
let _failureCount = 0;
const _observations = new Map();
const provenance = gnssProvenance({ windowMs });
const queryKey = (anchor) =>
anchor
? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}`
: null;
function scheduleCameraRefresh() {
if (!_enabled || !_primitives) return;
if (now() < _retryAt) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
clearTimeout(_cameraTimer);
// Debounce drags and bound moving/follow cameras to at most 24 requests
// per minute. Nearby views share the proxy's whole-degree cache key.
_cameraTimer = setTimeout(
async () => {
_cameraTimer = null;
if (!_enabled || _backgroundRefresh || _request) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
await refreshInBackground();
},
Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)),
);
}
async function refreshInBackground() {
if (!_enabled || _backgroundRefresh) return;
const controller = new AbortController();
_backgroundRefresh = controller;
try {
if (_manager)
await _manager.refreshLayer(layer.id, { signal: controller.signal });
else await layer.update(undefined, { signal: controller.signal });
} finally {
if (_backgroundRefresh === controller) {
_backgroundRefresh = null;
scheduleCameraRefresh();
scheduleRetry();
}
}
}
function scheduleRetry() {
clearTimeout(_retryTimer);
_retryTimer = null;
if (!_enabled || !_retryAt) return;
// Long provider cooldowns keep their full deadline. Browser timers cannot
// exceed ~24 days, so wake in bounded chunks without fetching early.
_retryTimer = setTimeout(
() => {
_retryTimer = null;
void refreshInBackground();
},
Math.min(2147483647, Math.max(0, _retryAt - now())),
);
_retryTimer?.unref?.();
}
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
pruneGnssObservations(_observations, now(), { windowMs });
_cells = binGnssCells(_observations);
render();
}
function render() {
// Only the id and band change what is drawn; counts alone do not.
const signature = JSON.stringify(
_cells.map(({ id, level }) => [id, level]),
);
if (signature === _signature) return;
_signature = signature;
_primitives.removeAll();
if (_cells.length) {
_primitives.add(
new GnssGroundFill({
geometryInstances: _cells.map(
(cell) =>
new Cesium.GeometryInstance({
id: `${layer.id}:${cell.id}`,
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
).withAlpha(FILL_ALPHA[cell.level]),
),
},
}),
),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
}),
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
releaseGeometryInstances: false,
}),
);
}
_viewer?.scene?.requestRender?.();
}
const layer = {
id: 'gnss-integrity',
name: 'GNSS Integrity',
icon: '📡',
source: 'adsb.lol',
/** Where each part of the method comes from; see records.js. */
provenance,
updateInterval: 60000,
attachDataManager(manager) {
_manager = manager;
},
init(viewer) {
if (_viewer)
throw new Error('GNSS integrity layer is already initialized');
_viewer = viewer;
_primitives = new Cesium.PrimitiveCollection();
_primitives.id = layer.id;
_primitives.show = false;
viewer.scene.groundPrimitives.add(_primitives);
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
scheduleCameraRefresh,
);
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
scheduleCameraRefresh,
);
},
enable() {
_enabled = true;
// Cells left from before a long disable must not reappear past the window.
if (_primitives) {
expire();
_primitives.show = true;
}
},
disable() {
clearTimeout(_cameraTimer);
_cameraTimer = null;
clearTimeout(_retryTimer);
_retryTimer = null;
_backgroundRefresh?.abort();
_backgroundRefresh = null;
_request?.abort();
_request = null;
_enabled = false;
if (_primitives) _primitives.show = false;
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || !_primitives || signal?.aborted) return false;
if (now() < _retryAt) {
expire();
scheduleRetry();
return true;
}
_retryAt = 0;
clearTimeout(_retryTimer);
_retryTimer = null;
const anchor = viewAnchor(_viewer);
// No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval
// retries; cells already drawn still age out of the window.
if (!anchor) {
expire();
return true;
}
_request?.abort();
const request = new AbortController();
_request = request;
const onAbort = () => request.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
_lastQueryKey = queryKey(anchor);
_lastRequestAt = monotonicNow();
_coverage = {
kind: 'regional',
latitude: Math.round(anchor.latitude),
longitude: Math.round(anchor.longitude),
radiusNm: 250,
};
let received = false;
try {
const snapshot = await source.getSnapshot(anchor, {
signal: request.signal,
});
if (request.signal.aborted || _request !== request || !_enabled)
return false;
received = true;
// Date the rows by when the proxy observed them: a cached or stale
// replay keeps its original age instead of restarting the window.
// The proxy reports that age, so client and server clocks never mix.
const at = now();
accumulateGnssObservations(_observations, snapshot.rows, at, {
windowMs,
observedAt: at - snapshot.ageMs,
});
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale === true;
_lastUpdate = at - snapshot.ageMs;
_lastError = null;
_retryAt = 0;
_failureCount = 0;
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// A renderer/programming failure remains a real lifecycle failure.
// Only acquisition failures are kept enabled for recovery.
if (received) throw e;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || 'GNSS integrity source unavailable';
// Keep the last cells only while their evidence is inside the window.
expire();
if (e?.retryable === false) return false;
_failureCount = Math.min(3, _failureCount + 1);
const delay =
Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0
? e.retryAfterMs
: Math.min(120000, 30000 * 2 ** (_failureCount - 1));
_retryAt = Math.max(
_retryAt,
Math.min(Number.MAX_VALUE, now() + delay),
);
// Enabling the display succeeded. Stats still report an unavailable
// or degraded feed; the manager keeps its normal refresh failure lane.
return true;
} finally {
signal?.removeEventListener('abort', onAbort);
if (_request === request) _request = null;
scheduleCameraRefresh();
scheduleRetry();
}
},
destroy(viewer = _viewer) {
layer.disable();
_removeCameraEnd?.();
_removeCameraChanged?.();
_removeCameraEnd = null;
_removeCameraChanged = null;
_observations.clear();
_cells = [];
_signature = null;
if (_primitives) {
viewer.scene.groundPrimitives.remove(_primitives);
_primitives = null;
}
_viewer = null;
_lastUpdate = null;
_lastError = null;
_stale = false;
_coverage = null;
_retryAt = 0;
_failureCount = 0;
_lastQueryKey = null;
_manager = null;
},
getRowControls() {
return {
chips: [],
info:
'Regional sample · 250 nm radius · cells from visited views over 30 min' +
(_lastError && _retryAt
? ` · ${_lastError} · Retrying automatically${
_retryAt <= 8640000000000000
? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC`
: ' after provider cooldown'
}`
: ''),
infoTitle:
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: LEVEL_LABELS[level],
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb: t(
'Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {minAircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.',
{
minAircraft: GNSS_MIN_AIRCRAFT,
minutes: provenance.window.minutes,
},
),
}
: {}),
})),
};
},
getStats() {
return {
count: _cells.length,
levels: Object.fromEntries(
['low', 'medium', 'high'].map((level) => [
level,
_cells.filter((cell) => cell.level === level).length,
]),
),
lastUpdate: _lastUpdate,
error: _lastError,
retryInSec: _retryAt
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
: null,
stale: _stale,
provenance,
coverage: _coverage,
// Load-bearing: without an explicit boolean, layerFeedState reads the
// adsb.lol source name as a flights fallback (src/data/feedState.js).
fallback: false,
};
},
};
return layer;
}
+700
View File
@@ -0,0 +1,700 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
const row = (hex, lat, degraded) => ({
hex,
lat,
lon: 30.2,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
function harness(
getSnapshot,
{
anchor: initialAnchor = { latitude: 50, longitude: 30 },
initialize = true,
} = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
value.destroy();
},
},
},
};
let clock = 1_000;
const anchors = [];
const layer = createGnssIntegrityLayer({
// The real source refuses an undated snapshot; stubs default to a fresh one.
source: {
async getSnapshot(...args) {
const snapshot = await getSnapshot(...args);
return { ageMs: 0, ...snapshot };
},
},
viewAnchor: () => {
anchors.push(anchor);
return anchor;
},
now: () => clock,
monotonicNow: () => clock,
});
if (initialize) layer.init(viewer);
return {
layer,
sources,
anchors,
viewer,
advance(ms) {
clock += ms;
},
now: () => clock,
setAnchor(value) {
anchor = value;
},
};
}
const MIN = 60_000;
const threeHealthy = () => [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
];
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
const anchor = defaultGnssViewAnchor({
scene: {
canvas: { clientWidth: 1000, clientHeight: 800 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: {
pickEllipsoid(pixel) {
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => {
throw new Error('centre hit must not compute a rectangle');
},
},
});
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
});
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((change) => changes.push(change.type));
await manager.setEnabled(h.layer.id, true);
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.changed.raiseEvent();
h.setAnchor({ latitude: 55, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls, 2, 'one initial read and one read for the latest view');
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
assert.ok(changes.includes('refresh-transition'));
assert.ok(changes.includes('refresh'));
assert.equal(manager.isEnabled(h.layer.id), true);
assert.deepEqual(h.layer.getStats().coverage, {
kind: 'regional',
latitude: 55,
longitude: 20,
radiusNm: 250,
});
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
h.viewer.camera.changed.raiseEvent();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls, 2, 'nearby views reuse the same query region');
await manager.destroyAll();
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
});
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolvePending;
const h = harness((anchor) => {
calls.push(anchor);
if (calls.length === 2)
return new Promise((resolve) => {
resolvePending = resolve;
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
h.setAnchor({ latitude: 35, longitude: 140 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 2, 'only one camera request in flight');
resolvePending({ rows: threeHealthy() });
await flush();
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 3);
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
h.setAnchor({ latitude: 10, longitude: 10 });
h.viewer.camera.moveEnd.raiseEvent();
h.layer.disable();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls.length, 3, 'disable drains the camera timer');
h.layer.destroy();
});
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
rows: [
row('a1', 50.1, true),
row('a2', 50.1, true),
row('a3', 50.2, false),
row('a4', 50.3, false),
row('b1', 55.1, false),
],
stale: false,
};
});
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].get(0).geometryInstances;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-integrity:280:420');
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
assert.equal(sources[0].get(0).allowPicking, false);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
assert.equal(stats.fallback, false);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ count }) => count),
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
assert.match(legend[0].blurb, /need 3 aircraft/);
});
test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => {
const { layer } = harness(async () => ({ rows: [], stale: false }));
assert.equal(
layer.id,
'gnss-integrity',
'the unpublished id describes the measured state',
);
assert.equal(layer.name, 'GNSS Integrity');
assert.doesNotMatch(layer.name, /interference|jamming/i);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ label }) => label),
[
'Low-accuracy score > 10%',
'Low-accuracy score 2–10%',
'Low-accuracy score < 2%',
],
);
const { blurb } = legend[0];
assert.match(blurb, /one aircraft is discounted/);
assert.match(blurb, /not the raw share/);
assert.match(blurb, /not a detection/);
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
assert.equal(layer.provenance.classifier.validated, false);
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
assert.deepEqual(layer.provenance.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(layer.provenance.interpretation.validated, false);
assert.equal(layer.getStats().provenance, layer.provenance);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
],
stale: true,
};
});
layer.enable();
await layer.update();
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), true);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
signal.addEventListener('abort', () => reject(signal.reason));
}),
);
layer.enable();
let result = layer.update();
pending.resolve({
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
});
assert.equal(await result, true);
assert.equal(layer.getStats().count, 1);
advance(31 * 60_000);
result = layer.update();
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
let calls = 0;
const { layer, sources } = harness(
async () => {
calls += 1;
return { rows: [] };
},
{ anchor: null },
);
layer.enable();
assert.equal(
await layer.update(),
true,
'a settling camera is not a failure',
);
assert.equal(calls, 0);
assert.equal(layer.getStats().error, null);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance, now } = harness(async () => ({
rows: threeHealthy(),
ageMs: now() - 1_000,
stale: true,
}));
layer.enable();
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(29 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(2 * MIN);
assert.equal(
await layer.update(),
true,
'the replay is not an error, only no evidence',
);
assert.equal(
layer.getStats().count,
0,
'the replay did not restart the window',
);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
fail = true;
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1, 'still inside the window');
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
setAnchor(null);
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, null);
});
test('re-enabling after a long disable does not show cells past the window', async () => {
const { layer, sources, advance } = harness(async () => ({
rows: threeHealthy(),
}));
layer.enable();
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
layer.disable();
advance(31 * MIN);
layer.enable();
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
layer.enable();
await layer.update();
const first = sources[0].get(0);
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(sources[0].get(0), first);
});
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
if (calls === 1) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((c) => changes.push(c.type));
assert.equal(await manager.setEnabled(h.layer.id, true), true);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'unavailable');
assert.equal(h.layer.getStats().retryInSec, 30);
assert.match(
h.layer.getRowControls().info,
/HTTP 502.*Retrying automatically at/,
);
assert.equal(changes.includes('visibility-failed'), false);
assert.equal(
await manager.refreshLayer(h.layer.id),
false,
'feed remains honestly unhealthy during cooldown',
);
assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown');
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(calls, 2);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
assert.equal(h.layer.getStats().count, 1);
assert.equal(h.layer.getStats().retryInSec, null);
assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/);
await manager.destroyAll();
});
test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
if (calls === 1)
throw Object.assign(new Error('Rate limited (HTTP 429)'), {
retryAfterMs: 3600000,
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 10, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
await h.layer.update();
assert.equal(calls, 1);
assert.equal(h.layer.getStats().retryInSec, 3570);
h.layer.disable();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
h.layer.enable();
await h.layer.update();
assert.equal(calls, 1, 'reenabling does not erase a provider deadline');
h.advance(3540000);
t.mock.timers.tick(3540000);
await flush();
assert.equal(calls, 2);
assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 });
h.layer.destroy();
const stopped = harness(async () => {
throw new Error('network unavailable');
});
stopped.layer.enable();
await stopped.layer.update();
stopped.layer.disable();
stopped.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry');
stopped.layer.destroy();
});
test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
throw new Error('network unavailable');
});
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().retryInSec, 30);
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 60);
h.advance(60000);
t.mock.timers.tick(60000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 120);
assert.equal(calls, 3);
h.layer.destroy();
const fatal = harness(async () => {
throw Object.assign(new Error('invalid request (HTTP 400)'), {
retryable: false,
});
});
fatal.layer.enable();
assert.equal(await fatal.layer.update(), false);
assert.equal(fatal.layer.getStats().retryInSec, null);
fatal.layer.destroy();
});
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
const h = harness(async () => ({
rows: threeHealthy(),
ageMs: 120000,
stale: true,
}));
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
h.layer.destroy();
});
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
return { rows: [] };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2499);
t.mock.timers.tick(2499);
await flush();
assert.equal(calls, 1);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 2);
h.advance(3000);
h.setAnchor({ latitude: 52, longitude: 32 });
h.viewer.camera.changed.raiseEvent();
h.advance(599);
t.mock.timers.tick(599);
await flush();
assert.equal(calls, 2);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 3);
h.layer.destroy();
});
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolve;
const h = harness((anchor) => {
calls.push(anchor);
return calls.length === 1
? new Promise((r) => (resolve = r))
: Promise.resolve({ rows: threeHealthy() });
});
h.layer.enable();
const initial = h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 1);
resolve({ rows: threeHealthy() });
assert.equal(await initial, true);
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 2);
h.layer.destroy();
});
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness(async () => ({ rows: [] }));
h.layer.destroy();
let wall = 1800000000000,
mono = 0,
anchor = { latitude: 50, longitude: 30 },
calls = 0;
const layer = createGnssIntegrityLayer({
source: {
getSnapshot: async () => {
calls++;
return { rows: [], ageMs: 0 };
},
},
viewAnchor: () => anchor,
now: () => wall,
monotonicNow: () => mono,
});
layer.init(h.viewer);
layer.enable();
await layer.update();
wall -= 3600000;
mono = 3000;
anchor = { latitude: 51, longitude: 31 };
h.viewer.camera.changed.raiseEvent();
mono += 600;
t.mock.timers.tick(600);
await flush();
assert.equal(calls, 2);
layer.destroy();
});
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
const h = harness(async () => ({ rows: threeHealthy() }));
h.layer.enable();
await h.layer.update();
const fill = h.sources[0].get(0);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
h.layer.destroy();
assert.equal(fill.isDestroyed(), true);
});
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.equal(defaultGnssViewAnchor({}), null);
const viewer = {
camera: {
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
},
};
const rectangleAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
viewer.camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
};
const nadirAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
});
+318
View File
@@ -0,0 +1,318 @@
/**
* Low navigation accuracy reported by ADS-B aircraft, binned into cells.
*
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
* their own position: NIC (integrity containment radius) and NACp (position
* accuracy). A cluster of aircraft reporting low values in one area is a
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
* such clusters are a well-known public proxy for GNSS interference, but
* avionics faults and old transponders also report low values: suspected
* interference is a second, unvalidated interpretation, never the primary
* state.
*
* Three separately sourced parts make up the method (see `gnssProvenance`):
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
* (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed
* from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not
* a published interference classifier) or the readsb/tar1090 `gpsOkBefore`
* flag. gpsjam.org does not publish its per-aircraft threshold.
* - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands.
* - Window: a GEV-defined rolling 30 minutes over the views the user visits.
*/
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
const QUALIFYING_VERSIONS = new Set([1, 2]);
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
const MAX_POSITION_AGE_S = 60;
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
export const GNSS_CELL_DEG = 0.5;
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
export const GNSS_MIN_AIRCRAFT = 3;
/** Rolling window over which aircraft seen in a cell are counted. */
export const GNSS_WINDOW_MS = 30 * 60_000;
/** gpsjam.org band edges, in percent of aircraft (after discounting one). */
export const GNSS_MEDIUM_PERCENT = 2;
export const GNSS_HIGH_PERCENT = 10;
/** Primary cell state: what the data shows (low navigation accuracy). */
export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly';
export const GNSS_NOMINAL_STATE = 'nominal';
/** Secondary, unvalidated reading of an anomaly cell. */
export const GNSS_INTERPRETATION = 'suspected-gnss-interference';
/** The per-aircraft rule; GEV-defined and not validated against known events. */
export const GNSS_CLASSIFIER = Object.freeze({
id: 'gev-nic-nacp-v2',
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
/**
* Machine-readable provenance of the cell output: which part of the method
* comes from where, and which parts are unvalidated.
*
* @param {{windowMs?: number}} [options] The window the layer actually uses.
*/
export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
return Object.freeze({
classifier: GNSS_CLASSIFIER,
aggregation: Object.freeze({
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
}),
window: Object.freeze({
minutes: windowMs / 60_000,
scope: 'visited-view',
cellDeg: GNSS_CELL_DEG,
minAircraft: GNSS_MIN_AIRCRAFT,
definedBy: 'GEV',
}),
state: Object.freeze({
id: GNSS_ANOMALY_STATE,
meaning: 'low navigation accuracy reported by ADS-B aircraft',
}),
interpretation: Object.freeze({
id: GNSS_INTERPRETATION,
validated: false,
note: 'one possible cause of an anomaly cell; avionics faults also report low values',
}),
});
}
/** Provenance at the default window. */
export const GNSS_PROVENANCE = gnssProvenance();
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
/**
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
* facts the layer needs. Returns null when the payload has no aircraft array.
*
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
const rows = [];
const seen = new Set();
for (const aircraft of payload.ac) {
if (!aircraft || typeof aircraft !== 'object') continue;
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
let { lat, lon } = aircraft;
const derived = (field) =>
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
const nacp =
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
// Only a position confirmed within the last minute is current evidence; a
// missing, invalid or negative age confirms nothing.
const age = aircraft.seen_pos;
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
// readsb exposes the flag while GPS is lost (15 s–15 min after the
// last good position), and removes it on recovery. Locate that evidence
// at the last good GNSS position, rather than a later MLAT estimate.
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
if (nic === null && nacp === null && !gpsLost) continue;
if (
gpsLost &&
finite(aircraft.gpsOkLat) &&
Math.abs(aircraft.gpsOkLat) <= 90 &&
finite(aircraft.gpsOkLon) &&
Math.abs(aircraft.gpsOkLon) <= 180
) {
lat = aircraft.gpsOkLat;
lon = aircraft.gpsOkLon;
}
seen.add(hex);
rows.push({
hex,
lat,
lon,
nic,
nacp,
gpsLost,
degraded:
gpsLost ||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
(nic !== null &&
nic < GNSS_NIC_THRESHOLD &&
!(nic === 0 && nacp !== null && nacp >= 10)),
});
}
return rows;
}
/** Stable key of the cell containing a position. */
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
return `${row}:${col}`;
}
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window. Presence and degraded evidence age separately: a
* degraded report keeps the aircraft degraded in that cell for one window
* after the report itself, however many healthy reports follow, so an
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
* is not held degraded forever.
*
* Every row is dated by when the snapshot was observed, not when it arrived:
* replaying a cached or stale snapshot never makes its evidence younger, and
* a snapshot older than the window adds nothing.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{
windowMs = GNSS_WINDOW_MS,
cellDeg = GNSS_CELL_DEG,
observedAt = nowMs,
} = {},
) {
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
if (Number.isFinite(at) && nowMs - at <= windowMs) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
const previousDegradedAt = previous?.degradedAt ?? null;
store.set(key, {
cell,
seenAt: Math.max(previous?.seenAt ?? at, at),
degradedAt: row.degraded
? Math.max(previousDegradedAt ?? at, at)
: previousDegradedAt,
});
}
}
return pruneGnssObservations(store, nowMs, { windowMs });
}
/**
* Drop observations older than the window and degraded evidence older than
* the window, without adding anything. Used when no fresh snapshot arrives.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {number} nowMs
* @param {{windowMs?: number}} [options]
* @returns {Map} The same store.
*/
export function pruneGnssObservations(
store,
nowMs,
{ windowMs = GNSS_WINDOW_MS } = {},
) {
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
store.set(key, { ...entry, degradedAt: null });
}
return store;
}
/**
* Navigation-integrity level of a cell, using gpsjam.org's published cell
* formula and bands: the first degraded aircraft is discounted to limit false
* positives, then under 2% is low, 2–10% medium and over 10% high. Medium and
* high cells are navigation-integrity anomalies.
*/
export function gnssIntegrityLevel(total, degraded) {
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
const level =
percent > GNSS_HIGH_PERCENT
? 'high'
: percent >= GNSS_MEDIUM_PERCENT
? 'medium'
: 'low';
const anomaly = level !== 'low';
return {
percent,
level,
state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE,
interpretation: anomaly ? GNSS_INTERPRETATION : null,
};
}
/**
* Aggregate the observation store into displayable cells. Expects a store
* already pruned to the window by `accumulateGnssObservations` or
* `pruneGnssObservations`.
*
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
*/
export function binGnssCells(
store,
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degradedAt } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degradedAt != null) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
for (const [id, { aircraft, degraded }] of tallies) {
if (aircraft < minAircraft) continue;
const [row, col] = id.split(':').map(Number);
const south = row * cellDeg - 90;
const west = col * cellDeg - 180;
const { percent, level, state, interpretation } = gnssIntegrityLevel(
aircraft,
degraded,
);
cells.push({
id,
south,
west,
north: south + cellDeg,
east: west + cellDeg,
lat: south + cellDeg / 2,
lon: west + cellDeg / 2,
aircraft,
degraded,
percentDegraded: Math.round(percent * 10) / 10,
level,
state,
interpretation,
});
}
return cells.sort((a, b) => a.id.localeCompare(b.id));
}
/** Legend colors: gpsjam's green / amber / red convention. */
export const GNSS_LEVEL_COLORS = Object.freeze({
low: '#3fb950',
medium: '#e3b341',
high: '#f85149',
});
+491
View File
@@ -0,0 +1,491 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accumulateGnssObservations,
binGnssCells,
GNSS_CLASSIFIER,
GNSS_PROVENANCE,
GNSS_WINDOW_MS,
gnssCellKey,
gnssIntegrityLevel,
gnssProvenance,
normalizeGnssAircraft,
pruneGnssObservations,
} from './records.js';
const aircraft = (overrides = {}) => ({
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 8,
nac_p: 10,
seen_pos: 1.2,
...overrides,
});
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({
hex: '000001',
type: 'mlat',
nic: undefined,
nac_p: undefined,
}),
aircraft({ hex: '000002', type: 'tisb_icao' }),
aircraft({ hex: '000003', version: 0 }),
aircraft({ hex: '000004', alt_baro: 'ground' }),
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
aircraft({ hex: '000009', version: 3 }),
aircraft({ hex: '00000a', version: '2' }),
aircraft({ hex: '00000b', version: 1.5 }),
// A position is current only when its age is known, finite and 0..60 s.
aircraft({ hex: '00000c', seen_pos: undefined }),
aircraft({ hex: '00000d', seen_pos: null }),
aircraft({ hex: '00000e', seen_pos: -0.5 }),
aircraft({ hex: '00000f', seen_pos: '1.2' }),
aircraft({ hex: '000010', seen_pos: Number.NaN }),
aircraft({ hex: '000011', seen_pos: 60.1 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
aircraft({ hex: '~a1b2c3' }),
// Version 1, and both ends of the position-age range, qualify.
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
aircraft({ hex: 'b00002', seen_pos: 60 }),
null,
],
});
assert.deepEqual(rows, [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
{
hex: '~a1b2c3',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
...['b00001', 'b00002'].map((hex) => ({
hex,
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
})),
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
});
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nic: 6 }),
aircraft({ hex: 'a00002', nac_p: 7 }),
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
],
});
assert.deepEqual(
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
[
['a00001', true, false],
['a00002', true, false],
['a00003', true, true],
['a00004', false, false],
],
);
});
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
});
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
const nominal = {
percent: 0,
level: 'low',
state: 'nominal',
interpretation: null,
};
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
// One degraded aircraft is always discounted.
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
});
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
for (const [total, bad] of [
[50, 2],
[10, 3],
]) {
const { state, interpretation } = gnssIntegrityLevel(total, bad);
assert.equal(state, 'navigation-integrity-anomaly');
assert.equal(interpretation, 'suspected-gnss-interference');
}
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
],
0,
);
const [cell] = binGnssCells(store);
assert.equal(cell.level, 'high');
assert.equal(cell.state, 'navigation-integrity-anomaly');
assert.equal(cell.interpretation, 'suspected-gnss-interference');
for (const key of Object.keys(cell))
assert.doesNotMatch(key, /interference|jamming|spoof/i);
});
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
assert.deepEqual(GNSS_PROVENANCE.classifier, {
id: 'gev-nic-nacp-v2',
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: [2, 10],
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
});
assert.deepEqual(GNSS_PROVENANCE.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
assert.equal(
GNSS_PROVENANCE.interpretation.id,
'suspected-gnss-interference',
);
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
// The bands in provenance are the ones the level function applies.
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
});
test('observations count each aircraft once per cell and expire after the window', () => {
const store = new Map();
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
accumulateGnssObservations(
store,
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
0,
{ windowMs: 1000 },
);
// a1 recovers inside the same cell: it stays degraded for the window.
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
windowMs: 1000,
});
let [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 3);
assert.equal(cell.degraded, 1);
assert.equal(cell.level, 'low');
assert.deepEqual(
[cell.south, cell.west, cell.north, cell.east],
[50, 20, 50.5, 20.5],
);
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
assert.equal(store.size, 1);
assert.deepEqual(binGnssCells(store), []);
});
const MIN = 60_000;
const report = (hex, degraded, lat = 50.1) => ({
hex,
lat,
lon: 20.1,
degraded,
});
const entry = (store, hex) =>
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 keeps reporting healthy every minute in the same cell.
for (let minute = 1; minute <= 30; minute += 1)
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
assert.equal(
entry(store, 'a1').degradedAt,
0,
'healthy reports do not refresh the degraded evidence',
);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// A new degraded report starts its own window.
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
});
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
const store = new Map();
const healthy = [report('a2', false), report('a3', false)];
accumulateGnssObservations(
store,
[report('a1', true), report('b1', true), ...healthy],
0,
);
assert.equal(binGnssCells(store)[0].level, 'high');
for (let minute = 10; minute <= 30; minute += 10)
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
minute * MIN,
);
assert.equal(binGnssCells(store)[0].degraded, 2);
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
31 * MIN,
);
const [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 4);
assert.equal(cell.degraded, 0);
assert.equal(cell.level, 'low');
});
test('an aircraft returning after the window starts fresh', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 leaves; nothing is heard from it for longer than the window.
accumulateGnssObservations(store, [], 31 * MIN);
assert.equal(store.size, 0);
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 32 * MIN,
degradedAt: null,
});
// Returning degraded dates the evidence from the new report.
const again = new Map();
accumulateGnssObservations(again, [report('a9', true)], 0);
accumulateGnssObservations(again, [], 31 * MIN);
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
});
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
// No prune runs in between: the old entry is still in the store when the
// healthy report arrives, so the new report alone must not carry it over.
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// Exactly one window after the degraded report it still counts.
const edge = new Map();
accumulateGnssObservations(edge, [report('a1', true)], 0);
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
assert.equal(entry(edge, 'a1').degradedAt, 0);
});
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
const store = new Map();
const snapshot = [report('a1', true), report('a2', false)];
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
// The same snapshot replayed (cached or stale) 29 minutes later.
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
assert.equal(entry(store, 'a1').seenAt, 0);
assert.equal(entry(store, 'a1').degradedAt, 0);
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
// An older replay never moves an entry's age backwards either.
accumulateGnssObservations(store, snapshot, 40 * MIN);
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
observedAt: 35 * MIN,
});
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
// A future time is clamped to now; an undated snapshot adds nothing.
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
observedAt: 99 * MIN,
});
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
for (const observedAt of [null, Number.NaN])
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
observedAt,
});
assert.equal(entry(store, 'u1'), undefined);
});
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
accumulateGnssObservations(store, [report('a2', false)], 0);
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
assert.deepEqual(
[...store.keys()].map((key) => key.split('|')[0]),
['a1'],
);
assert.equal(entry(store, 'a1').degradedAt, null);
pruneGnssObservations(store, 51 * MIN);
assert.equal(store.size, 0);
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
});
test('rows without finite coordinates never key a cell', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
null,
],
0,
);
assert.equal(store.size, 0);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
],
0,
);
assert.deepEqual(binGnssCells(store), []);
const [cell] = binGnssCells(store, { minAircraft: 2 });
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
const [row] = normalizeGnssAircraft({
ac: [
aircraft({
type: 'mlat',
nac_p: 0,
nic: 0,
mlat: ['lat', 'lon', 'nic'],
gpsOkBefore: 1800000000,
gpsOkLat: 54.2,
gpsOkLon: 19.3,
}),
],
});
assert.ok(row);
assert.equal(row.lat, 54.2);
assert.equal(row.lon, 19.3);
assert.equal(row.nic, null);
assert.equal(row.degraded, true);
const [healthy] = normalizeGnssAircraft({
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
});
assert.equal(healthy.degraded, false);
});
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
],
});
assert.deepEqual(
rows.map((r) => r.degraded),
[false, false, false, true, true],
);
});
// Quality-field subsets captured from public adsb.lol US point responses on
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
const tisbQuality = {
nic: 0,
nac_p: 0,
tisb: [
'altitude',
'alt_baro',
'gs',
'track',
'geom_rate',
'lat',
'lon',
'nic',
'rc',
'nac_p',
'nac_v',
'sil',
'sil_type',
],
mlat: [],
};
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
const unknownContainment = {
nic: 0,
nac_p: 10,
tisb: ['callsign'],
mlat: [],
};
assert.equal(
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
false,
);
});
+76
View File
@@ -0,0 +1,76 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
function retryDelay(raw, nowMs) {
if (!raw) return null;
const seconds = Number(raw);
const ms = seconds * 1000;
if (Number.isFinite(ms) && seconds > 0)
return Math.min(3600000, Math.max(5000, ms));
const at = Date.parse(raw);
return Number.isFinite(at) && at > nowMs
? Math.min(3600000, Math.max(5000, at - nowMs))
: null;
}
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
export function createAdsbGnssSource({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
return {
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error('GNSS snapshot needs a view anchor');
const params = new URLSearchParams({
lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)),
});
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
signal,
});
if (!response.ok) {
const limited = response.status === 429;
const retryAfterMs = retryDelay(
response.headers.get('retry-after'),
now(),
);
throw Object.assign(
new Error(
limited
? 'GNSS feed rate limited (HTTP 429)'
: `GNSS feed unavailable (HTTP ${response.status})`,
),
{
status: response.status,
retryable: limited || response.status >= 500,
retryAfterMs: retryAfterMs ?? (limited ? 60000 : null),
},
);
}
const payload = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
// The snapshot's age (measured on the proxy's clock) dates every row in
// the rolling window; an undated snapshot could only be treated as new,
// so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
throw new Error('GNSS integrity snapshot has no observation time');
return {
rows: payload.rows,
ageMs: payload.ageMs,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
};
},
};
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdsbGnssSource } from './source.js';
test('the source asks the same-origin proxy for the view anchor', async () => {
const calls = [];
const source = createAdsbGnssSource({
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({
fetchedAt: 5,
ageMs: 0,
classifier: 'gev-nic-nacp-v2',
rows: [{ hex: 'abc123' }],
});
},
});
const snapshot = await source.getSnapshot(
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
ageMs: 0,
fetchedAt: 5,
stale: false,
classifier: 'gev-nic-nacp-v2',
});
});
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
assert.equal(stale.fetchedAt, 7);
assert.equal(stale.classifier, null);
// Without an observation age the rows could only be dated as new.
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/no observation time/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
/view anchor/,
);
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ error: 'x' }, { status: 502 }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/HTTP 502/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
latitude: 1,
longitude: 2,
}),
/Malformed/,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
{ latitude: 1, longitude: 2 },
{ signal: controller.signal },
),
);
});
test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => {
const at = Date.UTC(2026, 9, 13, 12);
for (const [status, header, delay, retryable] of [
[429, '3600', 3600000, true],
[429, new Date(at + 3600000).toUTCString(), 3600000, true],
[429, null, 60000, true],
[429, 'Infinity', 60000, true],
[502, '5', 5000, true],
[502, null, null, true],
[400, null, null, false],
]) {
const source = createAdsbGnssSource({
now: () => at,
fetchImpl: async () =>
Response.json(
{},
{ status, headers: header ? { 'Retry-After': header } : {} },
),
});
await assert.rejects(
source.getSnapshot({ latitude: 50, longitude: 20 }),
(e) => {
assert.equal(e.status, status);
assert.equal(e.retryAfterMs, delay);
assert.equal(e.retryable, retryable);
assert.match(
e.message,
status === 429 ? /rate limited/ : /feed unavailable/,
);
return true;
},
);
}
});
+6
View File
@@ -6,6 +6,7 @@ export {
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
/**
* @module militaryFlights
@@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
/** @constant {Cesium.Color} Red tint for aircraft broadcasting an emergency (mirror of flights) */
export const EMERGENCY_ICON_COLOR =
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) ---------------
// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
+22 -1
View File
@@ -10,6 +10,7 @@ import {
CONTACT_MATCH_TIER,
} from '../../data/contactMatch.js';
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -134,6 +135,10 @@ export function createQueries({
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
),
// This poll's transponder broadcast (not sticky; see records.js).
squawk: info?.squawk ?? null,
emergency: info?.emergency ? { ...info.emergency } : null,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
};
}
@@ -157,7 +162,9 @@ export function createQueries({
* lon: number|null, altitudeM: number|null, speedMps: number|null,
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
* military: boolean, aircraftClass: string|null, originCountry: null,
* operator: string|null, routeOrigin: null, routeDestination: null}}
* operator: string|null, squawk: string|null, emergency: boolean,
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: null, routeDestination: null}}
*/
function mapAnalystRecord(icao24, info) {
@@ -188,6 +195,20 @@ export function createQueries({
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: null,
operator: text(info?.operator),
squawk: text(info?.squawk),
// True while this poll broadcast an emergency (7500/7600/7700 or the
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
// without the flag.
emergency: info?.emergency?.severity === 'emergency',
emergencyKind: text(info?.emergency?.kind),
// Provenance, so a summary can say "broadcasting squawk 7700" rather
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
// when the Mode A code names the status, 'ads-b' for the readsb field.
emergencySource: text(info?.emergency?.source),
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
// Latest known general aircraft-message contact, retained if a poll
// has no time. This is not the receipt time of the current status.
lastContactAtMs: num(info?.lastContactEpochMs),
routeOrigin: null,
routeDestination: null,
};
+4
View File
@@ -1,6 +1,7 @@
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { stickyText, stickyNumber } from '../../data/aircraftMeta.js';
import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
@@ -161,6 +162,9 @@ export class MilitaryFlightRecords {
null,
),
turnRateDps: prevMeta?.turnRateDps || 0,
// Per poll, NOT sticky (mirror of flights): a cleared code clears here.
squawk: aircraft.squawk ?? null,
emergency: aircraftEmergency(aircraft),
onGround,
// Round 7: sticky airborne history (see _likelyLanded).
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
+16
View File
@@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only
'remove',
);
});
test('military emergency status follows the latest poll instead of sticking', () => {
const records = store();
const first = records.receive(
observation({ squawk: '1234', emergency: 'minfuel' }),
context(),
);
assert.equal(first.meta.squawk, '1234');
assert.equal(first.meta.emergency.kind, 'minfuel');
assert.equal(first.meta.emergency.severity, 'priority');
const next = records.receive(
observation({ squawk: '1234', emergency: 'none' }),
context(),
);
assert.equal(next.meta.emergency, null);
});
+19 -4
View File
@@ -18,6 +18,7 @@ import {
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import {
PLANE_MODEL_SCALE,
PLANE_MODEL_URL,
@@ -33,6 +34,7 @@ import {
COCKPIT_CONTACT_SIZE_PX,
MIL_ICON_COLOR,
TRACKED_ICON_COLOR,
EMERGENCY_ICON_COLOR,
MODEL_ALT_CEIL_M,
MODEL_MAX_ALL,
MODEL_MAX,
@@ -159,7 +161,7 @@ export function createRendering({
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
@@ -173,7 +175,9 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
cyberSonarBaseAlpha(bb),
);
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
@@ -189,7 +193,17 @@ export function createRendering({
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
return MIL_ICON_COLOR;
return _fleetBillboardColor(icao24);
}
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
* amber otherwise. Cockpit far-contact dots use it too. */
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -965,7 +979,7 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor = MIL_ICON_COLOR;
const baseColor = _fleetBillboardColor(icao24, info);
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
@@ -1082,6 +1096,7 @@ export function createRendering({
_applyFleetBillboardPresentation,
_iconKind,
_modelColor,
_fleetBillboardColor,
_modelRegimeActive,
_modelCap,
_modelAddDistM,
+10 -7
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
turnRateFromFixHistory,
@@ -7,12 +8,7 @@ import {
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import {
BILLBOARD_SCALE,
GROUND_SCALE,
TRACKED_ICON_COLOR,
MIL_ICON_COLOR,
} from './policy.js';
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
/** Apply military record changes to Cesium resources and existing follow operations. */
export function createMilitarySnapshotRenderer({
@@ -125,6 +121,11 @@ export function createMilitarySnapshotRenderer({
if (flightState._billboards.has(icao24)) {
const bb = flightState._billboards.get(icao24);
if (!isTracked) {
bb.color = rendering
._fleetBillboardColor(icao24, meta)
.withAlpha(cyberSonarBaseAlpha(bb));
}
// Position AND rotation are owned by the fleet pass (_fleetTick);
// course changes land on the next rotation pass (forced below).
// Ground flips (landing/takeoff) restyle this SAME billboard in
@@ -161,7 +162,9 @@ export function createMilitarySnapshotRenderer({
// Screen-projected rotation lands on the next fleet tick.
rotation: 0,
alignedAxis: Cesium.Cartesian3.ZERO,
color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR,
color: isTracked
? TRACKED_ICON_COLOR
: rendering._fleetBillboardColor(icao24, meta),
sizeInMeters: false,
scaleByDistance: rendering._normalBillboardScaleByDistance(),
// Grounded/near-surface planes sit at/below the photoreal tile
+6 -3
View File
@@ -22,7 +22,6 @@ import {
TRAIL_COLOR,
BILLBOARD_SCALE,
GROUND_SCALE,
MIL_ICON_COLOR,
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
@@ -781,12 +780,16 @@ export function createTracking({
bb.width = 20;
bb.height = 20;
// Ground-aware restore (a plane untracked while taxiing comes back at
// ground scale; tint is full amber on the ground and in the air).
// ground scale; the fleet tint is amber, or red while broadcasting an
// emergency, on the ground and in the air).
bb.scale =
BILLBOARD_SCALE *
(CLASS_SCALE_2D[meta?.klass] || 1) *
(meta?.onGround ? GROUND_SCALE : 1);
bb.color = MIL_ICON_COLOR;
bb.color = parts.rendering._fleetBillboardColor(
flightState._trackedIcao,
meta,
);
bb.rotation = flightState._lastTrackedRotation;
}
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
+18 -7
View File
@@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* record cap ends the walk. `truncated` means CMR's `hits` exceed the rows
* read, or the cap dropped valid granules from the last page read.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
}) {
const granules = [];
let hits = 0;
let readCount = 0;
let capped = 0;
let cursor = null;
for (;;) {
const init = { signal };
@@ -47,19 +50,27 @@ export async function fetchCmrPages({
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
const json = await response.json();
const rawCount = Array.isArray(json?.items) ? json.items.length : 0;
const page = parseCmrUmm(json, product);
readCount += rawCount;
const admitted = page.granules.slice(
0,
Math.max(0, maxRecords - granules.length),
);
granules.push(...admitted);
capped += page.granules.length - admitted.length;
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
rawCount < CMR_PAGE_SIZE ||
readCount >= maxRecords ||
readCount >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
return { granules, truncated: hits > readCount || capped > 0 };
}
function finiteOrNull(value) {
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchCmrPages } from './catalog.js';
const item = (id) => ({
meta: { 'concept-id': id },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' },
},
},
});
test('discarded rows do not turn a full CMR page into a final page', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
fetchImpl: async () => {
calls++;
const items =
calls === 1
? Array.from({ length: 200 }, (_, i) =>
i === 0 ? {} : item(String(i)),
)
: [item('last')];
return Response.json(
{ hits: 201, items },
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 200);
assert.equal(result.granules.at(-1).id, 'last');
assert.equal(result.truncated, false);
});
test('a non-page-multiple limit never returns more admitted records than its budget', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
return Response.json(
{
hits: 1000,
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
},
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, true);
});
test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
return Response.json(
{
hits: 400,
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
},
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, true);
});
test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
const items = Array.from({ length: 200 }, (_, i) =>
calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`),
);
return Response.json(
{ hits: 400, items },
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, false);
});
+5 -1
View File
@@ -1,7 +1,11 @@
import { searchHls } from './catalog.js';
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
/**
* NASA-compatible imagery acquisition; transport and URL builders are replaceable.
* Thumbnail responses must expose Data-Present: true for image admission, or
* false for confirmed empty coverage, including injected getThumbnail sources.
*/
export function createRecentImagerySource({
fetchImpl = (...args) => globalThis.fetch(...args),
search = searchHls,
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createThumbnailLoader } from './thumbnails.js';
test('missing or malformed presence evidence cannot prove thumbnail pixels', async () => {
for (const header of [null, 'unknown', '', 'true', 'false']) {
let decodes = 0;
const loader = createThumbnailLoader({
requestThumbnail: async () => ({
ok: true,
headers: { get: (key) => (key === 'Data-Present' ? header : null) },
blob: async () => {
decodes++;
return new Blob(['fixture']);
},
}),
createObjectUrl: () => 'blob:fixture',
});
loader.request(
{ key: 'fixture', product: 'S30', day: '2026-09-18' },
{ west: 0, east: 1, south: 0, north: 1 },
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
loader.get('fixture').status,
header === 'true' ? 'present' : header === 'false' ? 'empty' : 'error',
);
assert.equal(decodes, header === 'true' ? 1 : 0);
loader.destroy();
}
});
+2 -2
View File
@@ -100,12 +100,12 @@ export function createThumbnailLoader({
cancelled = true;
} else if (response?.ok) {
const present = String(
response.headers?.get?.('Data-Present') ?? 'true',
response.headers?.get?.('Data-Present') ?? '',
).toLowerCase();
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
if (present === 'false') {
status = 'empty';
} else {
} else if (present === 'true') {
const blob = await response.blob();
if (controller.signal.aborted) cancelled = true;
else {
+1 -13
View File
@@ -1,3 +1,4 @@
import { rangeForBoundingSphere } from './data/cameraFraming.js';
import { isUnavailableCapability } from './sources/capability.js';
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js';
@@ -1377,19 +1378,6 @@ function buildingBoundingRadius(bounds) {
return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18;
}
function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
function buildingPitch(bounds) {
if (!bounds) return -25;
const footprint = Math.max(bounds.width, bounds.depth);
+16
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
@@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => {
assert.equal(absent.status, 200);
assert.deepEqual(JSON.parse(absent.body), { found: false });
});
for (const preview of [false, true]) {
test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => {
let calls = 0;
const app = fixture('terrainHeightsProxy', { parseTerrainPoints,
resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; },
}, preview);
for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) {
assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400);
}
assert.equal(calls, 0);
assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200);
assert.equal(calls, 1);
});
}
+14 -9
View File
@@ -42,18 +42,23 @@ export function createDefaultPlaceSearch({
...(selected
? [selected]
: [
// Google answers through the local proxy, which holds the server
// key: its Geocoding web service refuses referrer-restricted keys,
// so calling it from here would force the bundled browser key to be
// left unrestricted (#363). That key still gates the provider,
// because a keyless session must not spend a request to learn it
// has no Google: the proxy's "not configured" reads as an
// unanswered provider, which stops the chain caching the negative
// Photon or Nominatim then produces.
createGoogleGeocoder({
request(query, { bias, signal }) {
const key = resolveApiKey?.();
if (!key) return null;
const url = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
if (!resolveApiKey?.()) return null;
const params = new URLSearchParams({ address: query });
if (bias) params.set('bounds', bias);
return fetchImpl(
`${endpoints.geocode || '/api/google/geocode'}?${params}`,
{ signal },
);
url.searchParams.set('address', query);
url.searchParams.set('key', key);
if (bias) url.searchParams.set('bounds', bias);
return fetchImpl(url.toString(), { signal });
},
}),
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
+7 -8
View File
@@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({
endpoints = {},
} = {}) {
const urls = {
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
// Same-origin like its siblings below: Google's Geocoding web service
// refuses referrer-restricted keys, so the key stays on the server (#363).
reverse: '/api/google/reverse-geocode',
textSearch: '/api/google/text-search',
nearby: '/api/google/nearby-places',
route: '/api/route',
@@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({
},
routeProfiles: ['foot', 'car', 'bike'],
async reverseGeocode(latitude, longitude, options) {
const key = resolveApiKey?.();
if (resolveApiKey && !key) return null;
// The browser key gates the provider without travelling with the
// request: the proxy supplies the key Google actually sees.
if (resolveApiKey && !resolveApiKey()) return null;
return normalizeGoogleReverse(
await json(
urls.reverse,
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
options,
),
await json(urls.reverse, { lat: latitude, lon: longitude }, options),
);
},
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
+1 -1
View File
@@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
const urls = [];
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit();
} });
const result = await service.geocode('Hanoi');
assert.equal(result.place.name, 'Hà Nội');
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationRequestServices } from './requests.js';
test('unknown capability replies recover after backoff', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 100_000 });
for (const first of [
() => Response.json({ wrong: true }),
() => new Response('bad', { status: 503 }),
]) {
let probes = 0;
let queries = 0;
const services = createApplicationRequestServices({
boundaryProbe: { timeoutMs: 1000, retryMs: 1000 },
fetchImpl: async (url) => {
if (url.endsWith('/status'))
return ++probes === 1
? first()
: Response.json({ configured: false });
queries++;
return Response.json({ elements: [] });
},
});
assert.deepEqual(await services.boundaries.query('fixture'), []);
assert.deepEqual(await services.boundaries.query('fixture'), []);
assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff');
t.mock.timers.tick(1000);
assert.equal(
(await services.boundaries.query('fixture')).code,
'OVERPASS_NOT_CONFIGURED',
);
assert.equal(
(await services.boundaries.query('fixture')).code,
'OVERPASS_NOT_CONFIGURED',
);
assert.equal(probes, 2);
assert.equal(queries, 2);
}
});
+9 -6
View File
@@ -66,9 +66,9 @@ export function createApplicationRequestServices({
throw new Error(`${label} unavailable (${response.status})`);
return response.data;
}
// Learn once per page whether the server has an Overpass instance, so an
// Remember valid capability decisions for the page, so an
// unconfigured server is never asked (and never answers with an error).
// Discovery is bounded; a failed or timed-out probe is forgotten and not
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
// retried for a short backoff, during which queries go to the server as
// they would against an older server without the probe.
let boundaryCapability = null;
@@ -79,10 +79,13 @@ export function createApplicationRequestServices({
boundaryCapability = request(`${urls.boundaries}/status`, {
signal: AbortSignal.timeout(boundaryProbe.timeoutMs),
}).then(
(response) =>
response.ok && typeof response.data?.configured === 'boolean'
? response.data.configured
: null, // an older server: fall back to asking per query
(response) => {
if (response.ok && typeof response.data?.configured === 'boolean')
return response.data.configured;
boundaryCapability = null;
boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs;
return null; // capability unknown: let this query ask the server
},
(error) => {
boundaryCapability = null;
if (lifetime?.aborted) throw error;
+13 -1
View File
@@ -1,3 +1,4 @@
import { normalizeSquawk } from '../../data/aircraftEmergency.js';
import {
admitRecords,
cleanText,
@@ -30,6 +31,9 @@ export function normalizeOpenSkyAircraft(row) {
typeCode: null,
registration: null,
operator: null,
squawk: normalizeSquawk(row[14]),
// State vectors carry no ADS-B emergency field; the squawk stands alone.
emergency: undefined,
};
}
@@ -68,6 +72,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
typeCode: cleanText(row.t),
registration: cleanText(row.r),
operator: cleanText(row.ownOp),
squawk: normalizeSquawk(row.squawk),
emergency: cleanText(row.emergency).toLowerCase() || null,
};
}
@@ -82,7 +88,13 @@ export function openSkySnapshot(
) {
const admitted = admitRecords(
payload?.states,
normalizeOpenSkyAircraft,
(row) => {
const record = normalizeOpenSkyAircraft(row);
const statuses = payload?.emergencyByIcao;
if (record && statuses && Object.hasOwn(statuses, record.id))
record.emergency = cleanText(statuses[record.id]).toLowerCase() || null;
return record;
},
'OpenSky',
);
const observedAtMs = epoch(payload?.time, 1000);
+20
View File
@@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation'
name: 'AbortError',
});
});
test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => {
const squawking = [...aircraft, '7700'];
const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0];
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergency, undefined);
assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null);
const mil = normalizeReadsbAircraft(
{ hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' },
now,
);
assert.equal(mil.squawk, '7600');
assert.equal(mil.emergency, 'nordo');
const quiet = normalizeReadsbAircraft(
{ hex: 'abc123', lat: 30, lon: -97 },
now,
);
assert.equal(quiet.squawk, null);
assert.equal(quiet.emergency, null);
});
+2
View File
@@ -1,6 +1,7 @@
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
import { createAdsbGnssSource } from '../layers/gnss/source.js';
/** Construct the existing reference feeds independently of application setup. */
export function createReferenceSources() {
@@ -8,5 +9,6 @@ export function createReferenceSources() {
earthquakes: createUsgsEarthquakeSource(),
'fire-perimeters': createWfigsPerimeterSource(),
cables: createBundledCableSource(),
'gnss-integrity': createAdsbGnssSource(),
};
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more