diff --git a/.env.example b/.env.example index 046d1f6cb..4ea041ee8 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,13 @@ AISSTREAM_API_KEY= # 0 disables the silence watch entirely. # AISSTREAM_SILENCE_TIMEOUT_MS=120000 +# Optional: local ADS-B decoder feeds for the Local ADS-B layer. SERVER-SIDE +# ONLY. Comma-separated band=url entries; band is 1090 or 978 (UAT). Each host +# must be loopback, a private (RFC1918) address, localhost or a *.local name; +# the path must end in aircraft.json. Invalid entries are logged and skipped. +# See docs/LOCAL-RECEIVERS.md. +# LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json + # Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000 # Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card # required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: diff --git a/CHANGELOG.md b/CHANGELOG.md index 20580b95d..1e3ca273f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +- Region scopes in voice analyst queries ("in the Gulf of Mexico", "over + the Alps") work again in the dev server: the bundled Natural Earth and + neighborhood packs are fetched as JSON in the browser + (`src/data/bundledJson.js`). When a region is not in the bundled packs, the + geocode and admin-boundary fallback answers `region-timeout` after 3 s + instead of holding the reply. + +- Transit and Directions rows repaint as soon as their data lands again: + `refreshLayerStats()` now lives on the layer lifecycle, not only on the + compatibility facade. `scripts/qa-radio.mjs` uses it instead of a private + panel method. +- On phones the title bar sits 16 px from the top so both Radio broadcast + waves stay on-screen. +- Keep Cyber right-rail panels mutually exclusive and Display, CCTV and Context + headers and frames fixed during content scrolling. Restore Radio's nested Context placement and compact + player. Add Cyber Sonar voice controls with settings and effect-state readback. + Keep keyboard-focus outlines visible inside Cyber's clipped map and cockpit + expand/collapse buttons, with a matching red hover border. + +- Add the opt-in Cyber HUD layout with coordinated map and cockpit panel + styling. Display exposes Sonar on/off, rings, range, power, opacity and sector. + Native point, billboard and label highlighting uses GPU draw commands; there + is no scene-dimming effect selector. Unsupported shaders retain native contact + rendering, and leaving Cyber restores the standard shell and contact treatment. + - Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless). Select a box (drag, the current view, or around a pin; up to 1,000 km a side) and the right-rail panel lists the last 30 days of Sentinel-2 / @@ -15,6 +40,60 @@ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and `MapSourceController.subscribe()` reports every settled map switch. +## Unreleased — local receiver feeds + +- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder + feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978 + serves. Configure them server-side with `LOCAL_RECEIVER_FEEDS` + (`band=url`, comma-separated); there is no feed editing in the browser. + Hosts must be loopback, RFC1918, `localhost` or `*.local`, the scheme http or + https and the path must end in `aircraft.json`; any other entry is logged at + startup, reported `invalid` and never fetched. +- Add `GET /api/local-receivers/aircraft`. It reads every configured feed in + parallel (2 s timeout, redirects refused, 2 MB body cap, about 1 s of shared + cache) and reports each feed `live`, `stale` (its own `now` is over 10 s old), + `unreachable` or `invalid`. Unconfigured, it answers + `{ configured: false }` and fetches nothing. Upstream error text is never + returned. +- The layer merges browser-SDR and feed aircraft by ICAO, keeping the newest + position, polls the route every second only while it is enabled, and remembers + which bands and sources heard each aircraft in the last 60 s. The click card + names them (for example "Heard by your receiver · 978 MHz UAT · decoder + feed"); aircraft heard only on 978 MHz carry a thin ring. The row status + covers both inputs ("2 feeds live · 14 heard", "feed 978 unreachable"), and + the Local RTL-SDR card shows a read-only decoder-feed line. +- While any input is producing aircraft (the browser receiver streaming, or a + feed live), the Local ADS-B row stays ON and lists feeds that are not live as + a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale") instead of + showing DEGRADED. +- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`). +- See `docs/LOCAL-RECEIVERS.md`. + +## Unreleased — local RTL-SDR and Local ADS-B + +- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in + desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek, + volume) or 1090 MHz ADS-B. Local FM and internet-radio playback never play + together: starting one stops the other. +- Add a gain control: AUTO or a manual R820T step, remembered per mode. ADS-B + defaults to 28.0 dB (the earlier 20.7 dB gave about 1 msg/s against about 9 + at 28.0 dB on the same antenna; a stored choice still wins), FM to AUTO; + changes apply without reconnecting. In + ADS-B mode the card shows CRC-valid messages per second, aircraft heard, + aircraft positioned and the IQ level. +- Prefer the ADS-B/1090 MHz channel of a dual-channel receiver, remember an + explicitly chosen device per mode, and add CHANGE DEVICE to reopen the + WebUSB picker. +- Add the Local ADS-B layer (`local-adsb`, off by default, not part of share + links). Aircraft heard by the receiver draw in magenta beside public Flights; + a marker drops when its position is 60 s old and the aircraft is forgotten + after 60 s without a message. Clicking one opens a card; markers are not + camera-followed. Voice `set_layer_visibility` accepts `local-adsb`. +- Normalize local ADS-B into one record shape, with a pure adapter for + dump1090/readsb `aircraft.json` documents. +- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see + `THIRD_PARTY_NOTICES.md`. + ## Unreleased — weather review - On 3D Tiles, draw a 4096×2048 detail window around the view on each @@ -103,6 +182,19 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). +- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency + wildfire incident perimeters as ground-clamped polygons with a + containment-colored fire line, refreshed every 5 minutes from the public + keyless feature service with truncation paging. Clicking a perimeter shows + an incident card (acreage, containment, cause, behavior, personnel, county, + cost, complex membership) and, when the incident has a state- and + recency-verified InciWeb page, a click-through link to it. Recency uses + incident page origin and update times because the publication API was retired. + The layer is reachable from the panel, voice control, share links (token `2`), and the + analyst query engine. WFIGS and InciWeb requests use a capped, cached + same-origin proxy with timeouts and a per-client limit. Unchanged refreshes + retain geometry; incident-link checks abort on disable or selection change, + and the row includes a containment legend. - Remove the spurious scrollbars that appeared on both panel stacks at narrow widths (720px and below) as soon as a panel was expanded. The stacks scroll @@ -470,6 +562,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Added +- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the + existing `FIRMS_MAP_KEY` and 30-minute cache. - Two map-orientation controls sit beside Share in the top-center globe actions. Tilt Map swings between a straight-down map and a 35-degree oblique around the point under the centre of the view, keeping that point and the diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index e0210914c..824cb95be 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -32,6 +32,8 @@ How to read this: | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | +| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | +| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" | | **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | | **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) | @@ -66,6 +68,10 @@ How to read this: | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover | +### Local receiver input (not fetched from a service) + +The optional Local ADS-B layer and the broadcast-FM receiver use radio you receive yourself: a USB RTL-SDR opened in the browser through WebUSB, or the `aircraft.json` of a decoder you run on your own machine or network (`LOCAL_RECEIVER_FEEDS`). Radio samples, audio and the optional receiver location stay in the browser. Decoder feeds are read by your local GEV server and relayed only to your browser, with about a second of in-memory caching. When you select a local aircraft, its ICAO address (and an airline-style callsign) is looked up, and aircraft that qualify for a 3D model are looked up by ICAO address, through the same adsbdb proxy the public flight layers use; your server caches those answers on disk and treats them as fresh for 24 hours. Received radio data is not stored or redistributed. ADS-B is an unauthenticated public broadcast and may be incomplete, delayed, spoofed or decoded in error; it is not suitable for navigation or operational use. + ### Notes on the live sources - **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)). @@ -158,7 +164,7 @@ The OSM-derived datasets are under the **Open Database License**. ODbL's share-a FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the `/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21, -Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY +Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. The former bundled 2026-05-25 snapshot was removed 2026-07-16. diff --git a/SECURITY.md b/SECURITY.md index 9a46e2cdd..718c43778 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,6 +66,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. +- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. - **Response-size caps and timeouts** on proxied responses. - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..071299fba --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,21 @@ +# Third-party software notices + +The project depends on third-party npm packages whose own licenses apply. This +file records the packages added for the browser-local SDR feature; the complete +resolved dependency inventory remains in `package-lock.json`. + +## Web RTL-SDR + +- Package: `@jtarrio/webrtlsdr` 3.0.6 +- Author: Jacobo Tarrio Barreiro; portions copyright Google Inc. +- Source: +- License: Apache License 2.0 +- License text: + +## Signals + +- Package: `@jtarrio/signals` 0.10.1 +- Author: Jacobo Tarrio Barreiro +- Source: +- License: Apache License 2.0 +- License text: diff --git a/build/vite.js b/build/vite.js index 82a1c3d68..cdff4a27c 100644 --- a/build/vite.js +++ b/build/vite.js @@ -9,10 +9,25 @@ export function createBrowserViteConfig({ cesiumToken, host = 'localhost', port = 4173, + command, } = {}) { return { plugins: [cesium(), applicationHtmlPlugin(), ...plugins], ...(publicDir === undefined ? {} : { publicDir }), + // A production build must not clean the dependency cache a running dev + // server is still serving optimized module URLs from. + ...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}), + optimizeDeps: { + // First reached through the SDR worker or a dynamic import. Pre-bundle + // them at startup so first use cannot invalidate already-transformed + // URLs with Vite's "Outdated Optimize Dep" 504 response. + include: [ + '@jtarrio/signals/demod/demodulator.js', + '@jtarrio/signals/demod/modes.js', + '@jtarrio/webrtlsdr/rtlsdr.js', + 'egm96-universal', + ], + }, server: { host: host || 'localhost', port: parseInt(port, 10) || 4173, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 942e0605f..bb456d7b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,42 @@ # God's Eye View Current State +## Cyber HUD — September 23, 2026 + +Display > HUD > Layout includes Cyber, also available through the HUD voice +action and shared visual state. An explicit first transition into Cyber selects +FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain +authoritative. The skin uses shared red/slate panel treatments in map and cockpit, +with compact 200px collapsed controls and wider expanded panels. Other HUD +layouts retain their existing presentation. +Chamfered panel expand/collapse controls use an inset keyboard-focus outline and +red hover border in both map and cockpit, so clipping does not hide the highlight. + +Cyber's map-side right rail opens one panel at a time while keeping collapsed +launchers available. Display, CCTV and Context scroll their contents inside fixed +headers and decorative frames. The narrow-screen rail remains scrollable to reach +each panel. Radio retains the shared nested Context player and compact +header disclosure, without relocating playback controls on theme changes. + +Sonar has one contact-highlighting method. Native Cesium points, billboards and +labels are treated in GPU draw commands without replacing their positions, +identities or pick commands. Model and canvas/DOM overlays retain their own +rendering paths. Sonar OFF stops the sweep while retaining Cyber's neutral +contact treatment. Reduced motion disables the animated sweep; cockpit and +hidden HUD states do not apply the map treatment. If the native shader is not +supported, native contacts remain available and Display reports the limitation. +No CSS scene-dimming fallback or effect selector is exposed. + +The Sonar controls adjust rings, range, power, opacity and sector. Sweep opacity +affects detection painting rather than label cohort admission. Theme exit and +renderer teardown release the derived GPU resources and restore owned model +colors without replacing newer layer-owned color changes. + +The `set_cyber_sonar` voice action adjusts the same controls without switching +themes or enabling omitted settings. State readback distinguishes configured +settings from active map/contact effects. Sonar settings are session-only and +return to their defaults on reload. Sliders accept integer values matching the +action's ranges. + Wind appears in the Weather group before Utilities. The surface-weather prototype uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid. It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning. @@ -1013,7 +1050,7 @@ Non-object or array-valued properties reject the response instead of being treat Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. -Updated: September 15, 2026 +Updated: September 23, 2026 ## Aircraft and vessel server modules @@ -2691,8 +2728,11 @@ its criteria cannot be silently ignored. | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | -| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| 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) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| 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) | + +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. Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the @@ -4104,6 +4144,134 @@ Existing catalog validation, category filters, tuning and voice playback behavio remain unchanged. Audio connects directly to the broadcaster after an explicit play action; the source does not relay or record streams. +## Local RTL-SDR and Local ADS-B + +The Radio panel holds a Local RTL-SDR card below internet radio. One browser +WebUSB session (`src/sdr/controller.js`) drives a USB RTL-SDR in desktop Chrome +or Edge on localhost or HTTPS; nothing opens until CONNECT. Demodulation and +Mode S decoding run in a worker; FM audio plays through an audio worklet. +Local FM and internet radio are one listening surface: starting local FM stops +internet-radio playback, and starting internet radio stops local FM. ADS-B +reception does not interrupt internet radio. + +- **Modes.** FM (87.5–108 MHz, tune, seek, volume) or ADS-B at 1090 MHz. + With a receiver open, selecting ADS-B enables the Local ADS-B layer; + selecting FM disables it. + Enabling the layer switches the receiver to ADS-B; disabling it leaves the + receiver mode unchanged. +- **Gain.** AUTO (tuner AGC) or a manual R820T step from 0.0 to 49.6 dB, + stored per mode in `gev:sdr:gain:v1`. Defaults: ADS-B 28.0 dB, FM AUTO. + Changes apply to the open receiver without reconnecting. +- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, + aircraft with a fresh position and the IQ level. +- **Devices.** An already-authorized receiver opens without the WebUSB + picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the + 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. + A device chosen in the picker is remembered per mode by vendor, product, + product name and serial in `gev:sdr:device:v1`. CHANGE DEVICE reopens the + picker. LOCATE uses browser location for receiver-relative CPR decoding. + +The Local ADS-B layer (`local-adsb`, `src/layers/localAdsb/`) is off by default +and registered as local-only: it never enters share links or stored layer +state. Its aircraft get the public Flights treatment in magenta, alongside +the public Flights layer. Public Flights renders about one poll interval +(~30 s) behind for smooth interpolation while local aircraft are shown as +heard, so a local marker usually leads its public twin; both markers are drawn +on purpose: + +- **Class.** Each aircraft is classified with `aircraftClass.js` from its + ADS-B emitter category (dump1090 strings: `A1` light, `A3` large, `A7` + rotorcraft; set A/B/C/D = type code 4/3/2/1) and, once known, its adsbdb + ICAO type. Silhouette and per-class scale come from `aircraftIcons.js` + (×0.8 on the ground), tinted magenta. +- **3D.** The DISPLAY rail's 3D toggle and its proximity/all mode apply: the + layer reads the Flights layer's `models3d` params and uses the same ceiling, + caps, add/keep radii, visible-first eligibility + (`src/data/modelEligibility.js`) and per-class GLB + (`src/layers/flights/modelSpec.js`), tinted magenta through the same MIX + colour blend Flights uses for military amber. Heights are barometric + + geoid N, as Flights renders a contact without a geometric altitude; a + grounded model rides the one-shot ground snap and belly offset. +- **Motion.** No display delay. The marker is extrapolated forward from the + newest fix with `motionModel.js` (arc extrapolation, path-derived course + blended over the reported track, rate-limited slew); a new fix is absorbed + by a correction that decays over 900 ms. Coasting continues while messages + arrive and stops 10 s after the last one (60 s at most). +- **Sanity filter.** For airborne positions the browser decoder prefers a + fresh even/odd pair, then a frame decoded relative to the aircraft's own + position (under 10 minutes old), then the receiver location. Surface + positions are stricter: a single surface frame decodes only against the + aircraft's own recent fix, and only when that fix is provably within 45 NM + (it could not have gone farther since at its speed limit); the receiver + location never decodes a lone surface frame and only picks the quadrant of + a surface pair. Without an existing suitable fix for that aircraft + (airborne or surface, provably within 45 NM), surface acquisition + requires an even/odd pair. Every fix + must be reachable from the last accepted one at 1.5 × reported ground + speed + 50 kt (1,000 kt without a speed, 350 kt for A1/A7/B1/B4) over the + elapsed time + 1 s, plus 500 m. + Refused fixes are counted (`positionsRejected` in the SDR state; the layer + applies the same check to merged feed records and reports the total as + `rejectedPositions` in its stats). Three refusals in a row re-anchor. +- **Trail.** A selected aircraft draws a magenta trail of the positions the + receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft), + with the tracked-flight trail look and a live head segment. No network + backfill. +- **Enrichment.** Only the selected aircraft (type + route) and aircraft + about to render as models (type) are looked up, through the Flights + source's cached adsbdb proxy with the same once-per-session, four-in-flight, + 200 ms drip rules. + +A marker drops once its newest position is 60 s old; an aircraft is forgotten +after 60 s without a message. Clicking a marker opens a readout card (ICAO, +callsign, class and category, adsbdb operator/type and a plausible route when +known, altitude, ground speed, track, vertical rate, position age, message +count, "Heard by your receiver"); markers are not camera-followed. Positioned +aircraft join detection boxes, labelled only with a decoded callsign. + +Receivers publish one record shape from `src/sources/adsbRecords.js` +(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, +`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`, +`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is +`webusb` (browser SDR) or `feed` (decoder feed). +`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ +skyaware978 `aircraft.json` document into the same records. Voice +`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", +"my antenna"). + +**Decoder feeds.** The layer's second input is the server route +`GET /api/local-receivers/aircraft` +(`server/providers/local-receivers.js`), configured only by the +`LOCAL_RECEIVER_FEEDS` environment value (`band=url`, comma-separated, e.g. +`1090=http://localhost:8080/data/aircraft.json`). Every host must pass +`parseTapAddress` (loopback, RFC1918, `localhost`, `*.local`), the scheme must +be http(s) and the path must end in `aircraft.json`; an invalid entry is logged +at startup, reported `invalid` and never fetched. The route reads all feeds in +parallel (2 s timeout, redirects refused, 2 MB cap, single-flight with a 1 s +cache) and returns `{ configured, generatedAt, feeds: [{ band, label, status, +aircraft, ageMs }], records }`, where a feed is `live`, `stale` (its own `now` +is over 10 s old), `unreachable` or `invalid`. Labels name the band only; +addresses and upstream error text stay on the server. Unconfigured, it returns +`{ configured: false, feeds: [], records: [] }` without fetching. + +The browser session (`src/layers/localAdsb/feeds.js`) polls the route every +second only while the layer is enabled and only while the route reports +`configured`; the Local RTL-SDR card makes one probe request at startup to show +its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are +rebased to the browser clock and kept up to 60 s after their last message. +`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position +(tie: newest message) and the bands/sources heard in the last 60 s; the card's +receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT · +browser SDR + decoder feed"). Aircraft heard only on 978 MHz UAT draw with a +thin light-magenta ring. With feeds configured the row status reads "2 feeds +live · 14 heard" when every feed is live. While any input is producing (the +browser receiver streaming, or a feed live) the row stays nominal and names the +other feeds as a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale"), +since a decoder stopped so the browser can take the dongle is not a fault. With +nothing producing, every readable feed stale reads STALE and anything else is +an error. Without feeds it keeps the WebUSB statuses. See +`docs/LOCAL-RECEIVERS.md`. + ## Bundled geography and submarine cable components Submarine cables use separate source, geometry, rendering, interaction and diff --git a/docs/LOCAL-RECEIVERS.md b/docs/LOCAL-RECEIVERS.md new file mode 100644 index 000000000..ee75d9a70 --- /dev/null +++ b/docs/LOCAL-RECEIVERS.md @@ -0,0 +1,82 @@ +# Local receivers + +The Local ADS-B layer draws aircraft heard by your own receivers. It has two +inputs, and it can use both at once. + +| Input | What it is | Bands | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| Browser SDR | A USB RTL-SDR opened by the browser through WebUSB (Radio panel, Local RTL-SDR card). Decoding runs in the page. | 1090 MHz | +| Decoder feed | The `aircraft.json` served by a decoder you run: dump1090-fa, readsb or tar1090 (1090 MHz), or dump978-fa + skyaware978 (978 MHz). | 1090 MHz, 978 MHz UAT | + +Aircraft from both inputs are merged by ICAO address. The newest position +wins, and the click card names every band and source that heard the aircraft +in the last 60 s. Aircraft heard only on 978 MHz UAT draw with a thin ring. + +Local aircraft usually lead their public twins. The public Flights layer +renders positions about one poll interval (~30 s) behind so it can interpolate +smoothly between fixes, while local receiver aircraft are shown as heard. An +aircraft in both layers therefore draws twice, the magenta local marker ahead +of the public one; both markers are drawn on purpose. + +## Which to use + +- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR. + Nothing else to install. +- **A dual-band board, a Raspberry Pi feeder, or a dongle already used by a + decoder:** use a decoder feed. A USB dongle can be opened by only one program + at a time, so a dongle that dump1090 or dump978 owns cannot also be opened by + the browser. + +## Configuring feeds + +Feeds are set in the server environment only (`.env`), never in the browser: + +```sh +LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json +``` + +Each entry is `band=url`, where `band` is `1090` or `978`. The URL must: + +- use `http` or `https`; +- point at loopback, a private (RFC1918) address, `localhost` or a `*.local` + name. Public addresses, link-local addresses (including `169.254.169.254`), + IPv6 and other host names are refused; +- have a path ending in `aircraft.json`, with no credentials, query or + fragment. + +An entry that breaks a rule is logged when the server starts, shown as +`invalid` and never fetched. Restart the server after changing the value. + +The server reads every feed about once a second while the Local ADS-B layer is +on. A feed is `live`, `stale` (its own `now` is more than 10 s old, usually a +stopped decoder), `unreachable` or `invalid`. The Layers panel row and the +Local RTL-SDR card show these states. + +## Example: dump1090-fa and dump978-fa on macOS or Linux + +Write each decoder's JSON to a directory, then serve the directory on +localhost. Each command keeps running, so start each in its own terminal: + +```sh +# 1090 MHz +dump1090-fa --device-index 0 --write-json /tmp/adsb1090 +python3 -m http.server 8080 --bind 127.0.0.1 --directory /tmp/adsb1090 + +# 978 MHz UAT: dump978-fa decodes, skyaware978 writes aircraft.json +dump978-fa --sdr driver=rtlsdr,rtl=1 --raw-port 30978 +skyaware978 --connect localhost:30978 --json-dir /tmp/adsb978 +python3 -m http.server 8978 --bind 127.0.0.1 --directory /tmp/adsb978 +``` + +```sh +LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/aircraft.json,978=http://localhost:8978/aircraft.json +``` + +A board with two identical-serial channels, such as the Nooelec FlyCatcher, +cannot be told apart by serial number. Select each channel by index: +`--device-index ` for dump1090-fa and `--sdr driver=rtlsdr,rtl=` for +dump978-fa. `rtl_test` lists the indexes. + +A tar1090 or readsb install on a Raspberry Pi already serves +`http:///tar1090/data/aircraft.json`. Use the Pi's private address +or its `.local` name. diff --git a/package-lock.json b/package-lock.json index d98116fb4..17bb0691e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.1", "license": "MIT", "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", @@ -1061,6 +1063,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jtarrio/signals": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@jtarrio/signals/-/signals-0.10.1.tgz", + "integrity": "sha512-X6tJOugEATYH9KJy6j2feEBVCUmRv1U9SSbg9ITQZCx/Qruw2fE2zsS1HKH3yOsxrZBE+eQTGM+d0roX4xuDdw==", + "license": "Apache-2.0" + }, + "node_modules/@jtarrio/webrtlsdr": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jtarrio/webrtlsdr/-/webrtlsdr-3.0.6.tgz", + "integrity": "sha512-+P8dsbXQ/6Mwru/vV613yHH68nHFyUJ9zPK68hE/Hh2F3jwyirUAjjBtIAfN/MlJbFioMJ3NS6dMGrRj9deFvw==", + "license": "Apache-2.0", + "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@types/w3c-web-usb": "^1.0.10" + } + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1538,6 +1556,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "node_modules/@zip.js/zip.js": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz", diff --git a/package.json b/package.json index 429c13aa7..3e80dc55e 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "qa:transit": "node scripts/qa-transit.mjs" }, "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", @@ -153,6 +155,7 @@ "./layers/vessels": "./src/layers/vessels/index.js", "./layers/earthquakes": "./src/layers/earthquakes/index.js", "./layers/recent-imagery": "./src/layers/recentImagery/index.js", + "./layers/perimeters": "./src/layers/perimeters/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -224,6 +227,7 @@ "./layers/bikeshare/source": "./src/layers/bikeshare/source.js", "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", + "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 3655cd998..28627e4ac 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -89,6 +89,10 @@ export async function checkPackageBoundaries(root) { const seen = new Set(); await build({ root, + // Keep this gate out of a running dev server's dependency cache + // (node_modules/.vite): a build there re-optimizes dependencies and the + // dev server's workers then fail with 504 "Outdated Optimize Dep". + cacheDir: path.join(root, 'node_modules/.vite-boundaries'), configFile: false, envFile: false, publicDir: false, @@ -115,7 +119,12 @@ export async function checkPackageBoundaries(root) { assetsInlineLimit: 0, rollupOptions: { input, - external: group.external, + // A declared dependency also owns its package subpath exports + // (for example `@jtarrio/webrtlsdr/rtlsdr.js`). + external: (id) => + group.external.some( + (name) => id === name || id.startsWith(`${name}/`), + ), // Unused imports must still obey ownership; tree shaking is not a boundary. treeshake: false, preserveEntrySignatures: 'strict', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 804f7373c..dd258cca6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -338,6 +338,16 @@ "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/ownership.test.mjs", + "src/layers/perimeters/index.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", + "src/layers/perimeters/records.test.mjs", + "src/layers/perimeters/source.test.mjs", + "src/layers/perimeters/cards.test.mjs", + "src/layers/perimeters/inciweb.test.mjs", + "src/layers/perimeters/ownership.test.mjs", "src/data/firmsHeatmap.js", "src/data/fireAnchors.js", "src/layers/firms/anchors.js", @@ -553,6 +563,7 @@ "src/data/telegeographySubmarineCables.test.mjs", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/layers/submarineCables/bundledSource.test.mjs", "src/maps/availability.js", "src/maps/catalog.js", @@ -606,6 +617,7 @@ "src/app/layers/flights.js", "src/app/layers/radio.js", "src/app/layers/earthquakes.js", + "src/app/layers/perimeters.js", "src/app/layers/cctv.js", "src/app/layers/firms.js", "src/app/layers/satellites.js", @@ -785,7 +797,9 @@ "src/layers/weather/imageryOrder.js", "src/layers/weather/imageryOrder.test.mjs", "server/providers/cyclones.js", + "server/providers/firePerimeters.js", "src/data/cyclonesProxy.test.mjs", + "src/data/firePerimetersProxy.test.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", @@ -800,6 +814,21 @@ "scripts/qa-weather-swap.mjs", "scripts/qa-weather-teardown.mjs", "src/layers/weather/imageryHost.test.mjs", + "src/sdr/adsbDecoder.test.mjs", + "src/sdr/controller.test.mjs", + "src/sdr/gain.test.mjs", + "src/sdr/spectrum.test.mjs", + "src/sdr/usbDevices.test.mjs", + "src/sources/adsbRecords.test.mjs", + "src/layers/localAdsb/index.test.mjs", + "src/ui/localSdrControls.test.mjs", + "src/ui/localSdrMarkup.test.mjs", + "src/ui/localSdrPresentation.test.mjs", + "src/data/localReceiversProxy.test.mjs", + "src/layers/localAdsb/feeds.test.mjs", + "src/ui/railScrollers.test.mjs", + "src/layers/localAdsb/motion.test.mjs", + "src/data/modelEligibility.test.mjs", "src/layers/recentImagery/model.test.mjs", "src/layers/recentImagery/catalog.test.mjs", "src/maps/imageryHost.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5273abbf8..13b758c6e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -257,6 +257,7 @@ "runtime": "node", "exports": ["./server/providers/openai"], "modules": [ + "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", "server/providers/common/source-root.js", @@ -327,6 +328,7 @@ "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", + "src/ui/displayPanelScroll.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", "src/ui/panelMeasurement.js", @@ -462,6 +464,7 @@ "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", + "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -487,6 +490,10 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/hudLayouts.js", + "src/hudLayoutPolicy.js", + "src/cyberSonar.js", + "src/ui/radioPanelPlacement.js", "src/app/stateChannel.js", "src/bloom.js", "src/cctvFocusPolicy.js", @@ -517,6 +524,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", "src/ui/cameraOrientationControls.js", @@ -535,6 +543,7 @@ "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", + "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -602,7 +611,10 @@ "src/layers/weather/imageryHost.js", "src/ui/layerBindings.js", "src/ui/displayBindings.js", - "src/ui/shellFacade.js" + "src/ui/shellFacade.js", + "src/ui/localSdrControls.js", + "src/layers/localAdsb/feedNames.js", + "src/ui/localSdrPresentation.js" ], "external": ["cesium"] }, @@ -724,6 +736,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -755,13 +768,16 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/layers/flights/modelSpec.js", + "src/data/inputOwnership.js", + "src/data/modelEligibility.js" ], "external": ["cesium"] }, "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -790,7 +806,8 @@ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/data/modelEligibility.js" ], "external": ["cesium"] }, @@ -915,6 +932,7 @@ "launches-layer": { "exports": ["./layers/launches"], "modules": [ + "src/cyberSonar.js", "src/layers/launches/camera.js", "src/layers/launches/controls.js", "src/layers/launches/index.js", @@ -961,6 +979,7 @@ "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/cyberSonar.js", "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", @@ -1155,6 +1174,7 @@ "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/local_data/neighborhoods/san-francisco.json", "src/data/local_data/natural_earth/regions.json", @@ -1241,6 +1261,12 @@ "./standalone/catalog" ], "modules": [ + "src/cyberSonarScene.js", + "src/cyberSonar.js", + "src/hudLayouts.js", + "src/hudLayoutPolicy.js", + "src/ui/radioPanelPlacement.js", + "src/cyberSonarGpu.js", "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiEvent.js", "src/ui/imagerySplit.js", @@ -1383,6 +1409,7 @@ "src/data/regionalBrief.js", "src/data/regionalModel.js", "src/data/renderAltitude.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/rocketLaunches.js", "src/data/routePlausible.js", @@ -1679,6 +1706,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", "src/ui/cameraOrientationControls.js", @@ -1697,6 +1725,7 @@ "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", + "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -1771,6 +1800,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/modelSpec.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", @@ -1796,6 +1826,12 @@ "src/data/layerSnapshot.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/app/layers/perimeters.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -1824,6 +1860,7 @@ "src/data/contactTrailRenderer.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", + "src/data/modelEligibility.js", "src/layers/transit/trails.js", "src/layers/transit/movement.js", "src/layers/transit/qaMetrics.js", @@ -1890,6 +1927,23 @@ "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", "src/layers/cyclones/source.js", + "src/app/layers/localAdsb.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/status.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/enrichment.js", + "src/sources/adsbRecords.js", + "src/sdr/controller.js", + "src/sdr/spectrum.js", + "src/sdr/gain.js", + "src/sdr/usbDevices.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -1907,7 +1961,8 @@ "egm96-universal", "mgrs", "pbf", - "satellite.js" + "satellite.js", + "@jtarrio/webrtlsdr" ] }, "application-catalog": { @@ -1918,6 +1973,7 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", "src/ui/imagerySplit.js", "src/maps/imageryComparison.js", @@ -2005,6 +2061,7 @@ "src/data/pickRegistry.js", "src/data/radioCountry.js", "src/data/renderAltitude.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/routePlausible.js", "src/data/satelliteClass.js", @@ -2249,6 +2306,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/modelSpec.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", @@ -2272,6 +2330,12 @@ "src/app/localGeojsonServices.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/app/layers/perimeters.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2322,6 +2386,25 @@ "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", "src/layers/cyclones/source.js", + "src/app/layers/localAdsb.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/status.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/enrichment.js", + "src/sources/adsbRecords.js", + "src/sdr/controller.js", + "src/sdr/spectrum.js", + "src/sdr/gain.js", + "src/sdr/usbDevices.js", + "src/data/detection.js", + "src/data/detectionRenderDemand.js", + "src/data/detectionPresentation.js", + "src/data/modelEligibility.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -2337,7 +2420,8 @@ "satellite.js", "@mapbox/vector-tile", "pbf", - "mgrs" + "mgrs", + "@jtarrio/webrtlsdr" ] }, "application-operations": { @@ -2355,6 +2439,7 @@ "src/data/local_data/neighborhoods/san-francisco.json", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/scenePick.js", "src/keylessGeocoder.js", @@ -2422,6 +2507,10 @@ "ui-visual-settings": { "exports": ["./ui/visual-settings"], "modules": [ + "src/cyberSonar.js", + "src/hudLayouts.js", + "src/hudLayoutPolicy.js", + "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", "src/contactsDetectionPolicy.js", @@ -2446,6 +2535,8 @@ "ui-panel-chrome": { "exports": ["./ui/panel-chrome"], "modules": [ + "src/ui/displayPanelScroll.js", + "src/ui/radioPanelPlacement.js", "src/panelStackLayout.js", "src/ui/leftPanelRail.js", "src/ui/panelChrome.js", @@ -2536,7 +2627,7 @@ }, "voice-action-schemas": { "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js"], + "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], "external": [] }, "voice-session": { @@ -2625,6 +2716,9 @@ "src/sources/reference.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", + "src/sources/httpBody.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", "src/layers/submarineCables/bundledSource.js" ], "external": [] @@ -2695,6 +2789,7 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/cyberSonar.js", "src/app/sourceSlot.js", "src/celestialRing.js", "src/data/cameraSensitivity.js", @@ -2820,5 +2915,26 @@ "src/sources/featureSource.js" ], "external": [] + }, + "perimeters": { + "exports": ["./layers/perimeters"], + "modules": [ + "src/layers/perimeters/index.js", + "src/sources/httpBody.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js" + ], + "external": ["cesium"] + }, + "perimeters-source": { + "exports": ["./layers/perimeters/source"], + "modules": [ + "src/layers/perimeters/records.js", + "src/sources/httpBody.js", + "src/layers/perimeters/source.js" + ], + "external": [] } } diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 9213dcaa5..607889bbe 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -1943,6 +1943,98 @@ try { }), ); + // Cyber relocates its collapsed briefing header into the expanded utility + // lane. Exercise actual height changes without reload and measure both cards. + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setHudVariant('cyber'); + manager.hud.setMode('on'); + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', true); + }); + const cyberRadioOrder = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.cockpitView.syncSignalLayout(); + const display = document + .getElementById('cockpit-display-toggle-btn') + .closest('.cockpit-utility-control'); + const radio = document + .getElementById('cockpit-radio-toggle-btn') + .closest('.cockpit-utility-control'); + const displayRect = display.getBoundingClientRect(); + const radioRect = radio.getBoundingClientRect(); + return { + display: displayRect.toJSON(), + radio: radioRect.toJSON(), + displayExpanded: display.classList.contains('is-expanded'), + radioExpanded: radio.classList.contains('is-expanded'), + gap: radioRect.top - displayRect.bottom, + }; + }); + check( + 'Cyber expanding Radio preserves the authored Display-then-Radio order', + !cyberRadioOrder.displayExpanded + && cyberRadioOrder.radioExpanded + && cyberRadioOrder.display.height > 0 + && cyberRadioOrder.radio.height > 0 + && cyberRadioOrder.gap >= 7, + JSON.stringify(cyberRadioOrder), + ); + await page.screenshot({ path: path.join(shotsDir, 'cyber-radio-order.png') }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setCockpitDisclosure('radio', false); + manager._setCockpitDisclosure('display', true); + }); + const cyberStyleStates = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const states = []; + for (const style of ['normal', 'surveillance', 'thermal', 'retro']) { + document.querySelector(`.style-btn[data-style="${style}"]`).click(); + states.push({ + requestedStyle: style, + activeStyle: manager.activeStyle, + hud: manager.hud.getVariant(), + theme: document.documentElement.dataset.uiTheme, + selector: document.getElementById('hud-layout-select').value, + savedHud: manager.getVisualState().hud.variant, + }); + } + return states; + }); + check('Cockpit visual-style controls retain Cyber HUD, selector, shell and saved layout', + cyberStyleStates.every(state => state.activeStyle === state.requestedStyle + && state.hud === 'cyber' && state.theme === 'cyber' + && state.selector === 'cyber' && state.savedHud === 'cyber'), + JSON.stringify(cyberStyleStates)); + for (const height of [987, 720, 640, 900]) { + await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 }); + await new Promise((resolve) => setTimeout(resolve, 400)); + const geometry = await page.evaluate(() => { + const signal = document.getElementById('cockpit-signal-stream'); + const utility = document.getElementById('cockpit-utility-controls'); + const expanded = utility.querySelector('.cockpit-utility-control.is-expanded'); + const s = signal.getBoundingClientRect(); + const u = expanded.getBoundingClientRect(); + const toggle = document.getElementById('cockpit-signal-toggle'); + const t = toggle.getBoundingClientRect(); + const hit = document.elementFromPoint(t.x + t.width / 2, t.y + t.height / 2); + return { height: innerHeight, theme: document.documentElement.dataset.uiTheme, + collapsed: signal.dataset.collapsed, signal: s.toJSON(), utility: u.toJSON(), + reachable: Boolean(hit && toggle.contains(hit)), + contained: u.bottom <= s.top - 7 && s.bottom < innerHeight, + }; + }); + check(`Cyber collapsed Live Signals remains visible and reachable after resize to ${height}px`, + geometry.theme === 'cyber' && geometry.collapsed === 'true' + && geometry.signal.width > 0 && geometry.signal.height > 0 + && geometry.reachable && geometry.contained, JSON.stringify(geometry)); + if (height === 640 || height === 900) { + await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) }); + } + } + await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator')); + const signalTransition = await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; const cockpit = manager.cockpitView; diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index c5629d2f4..c30a17ee0 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -374,7 +374,7 @@ async function main() { enabled: true, uncertain: true, }); - gev.dataManager._refreshTogglePanel(); + gev.dataManager.refreshLayerStats(); const staticResult = module.setTuningStatic(true); const filterBefore = module.getUIState().filter; const filterControl = document.getElementById('radio-filter'); @@ -447,7 +447,7 @@ async function main() { enabled: true, uncertain: false, }); - gev.dataManager._refreshTogglePanel(); + gev.dataManager.refreshLayerStats(); module.cancelTuning(); camera.flyTo = originalFlyTo; return result; @@ -4048,7 +4048,13 @@ async function main() { const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); const expectedEntryId = `selected:${selected.id}`; let painted = null; - for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) { + // Radio's horizon pass reveals the selected entity on its own timer + // after the camera jump, so a stationary view waits for both to settle. + for ( + let attempt = 0; + attempt < 20 && !(painted?.rect && selectedEntity?.show === true); + attempt += 1 + ) { painted = await new Promise((resolve) => { let removePostRender = null; const timeout = setTimeout(() => { diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 4c5c9dedc..de5f44fec 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -625,12 +625,27 @@ async function runBehaviorLayer() { // (4b) analyst engine end-to-end: count flights over Texas (region ring // via NE-pack/admin machinery), then a follow-up over the same set. + // A cold boundary lookup can exceed the resolver's budget; it answers + // region-timeout and keeps loading, so asking again must succeed. + let texasTimeouts = 0; r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 }); + while (r?.code === 'region-timeout' && texasTimeouts < 8) { + texasTimeouts += 1; + await settle(5000); + r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 }); + } report(r?.ok === true && Number.isFinite(r?.count) && r.count > 0 && String(r?.coverage?.scope || '').includes('Texas'), - 'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} err=${r?.error || ''}`); + 'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} timeouts=${texasTimeouts} err=${r?.error || ''}`); r = await run('analyst_query', { followUp: true, filters: [{ field: 'onGround', op: 'eq', value: false }], sortBy: 'altitudeM', limit: 3 }); report(r?.ok === true && r?.coverage?.followUp === true, 'behavior: analyst follow-up re-filters the remembered set', `count=${r?.count} followUp=${r?.coverage?.followUp}`); + // A marine region resolves from the bundled Natural Earth pack in the page, + // without the slower geocode fallback. + const gulfStarted = Date.now(); + r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Gulf of Mexico' }, limit: 5 }); + const gulfMs = Date.now() - gulfStarted; + report(r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && gulfMs < 3000, + 'behavior: analyst resolves the Gulf of Mexico from the bundled pack', `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`); // (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band) r = await run('zoom_to_globe', {}); diff --git a/server/providers/firePerimeters.js b/server/providers/firePerimeters.js new file mode 100644 index 000000000..4e6a933e1 --- /dev/null +++ b/server/providers/firePerimeters.js @@ -0,0 +1,232 @@ +import { normalizeFirePerimeterSnapshot } from '../../src/layers/perimeters/records.js'; +import { + readResponseJsonCapped, + readResponseTextCapped, + coalesceProxyRequest, +} from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// NIFC WFIGS current interagency fire perimeters (public, keyless). +// maxAllowableOffset trades ~100 m of boundary fidelity for a payload small +// enough to refresh continuously (~1 MB for a typical fire season). +const API_URL = + 'https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/' + + 'WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?' + + new URLSearchParams({ + where: '1=1', + outFields: [ + 'poly_IncidentName', + 'attr_UniqueFireIdentifier', + 'attr_IncidentSize', + 'attr_PercentContained', + 'attr_POOState', + 'attr_IncidentTypeCategory', + 'attr_FireDiscoveryDateTime', + 'poly_DateCurrent', + 'attr_FireCause', + 'attr_FireBehaviorGeneral', + 'attr_TotalIncidentPersonnel', + 'attr_POOCounty', + 'attr_EstimatedCostToDate', + 'attr_IncidentComplexityLevel', + 'attr_CpxName', + ].join(','), + maxAllowableOffset: '0.001', + outSR: '4326', + f: 'geojson', + }).toString(); + +// The service caps a single response at its maxRecordCount (2000); a peak +// season can exceed that, so follow exceededTransferLimit with offset pages. +const MAX_PAGES = 5; + +const exceededTransferLimit = (payload) => + payload?.exceededTransferLimit === true || + payload?.properties?.exceededTransferLimit === true; + +const MIB = 1024 * 1024; + +/** Fixed-origin, bounded WFIGS and InciWeb routes for dev and preview. */ +export function firePerimetersProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const cache = new Map(); + const publications = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 }); + + async function upstream(url, cap, timeout, options = {}) { + const signal = AbortSignal.timeout(timeout); + const response = await fetchImpl(url, { + ...options, + signal, + redirect: 'error', + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error('upstream_unavailable'); + } + return readResponseJsonCapped(response, cap, signal); + } + + async function fetchPerimeters() { + const features = []; + for (let page = 0; page < MAX_PAGES; page++) { + const url = + page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`; + const payload = await upstream(url, 16 * MIB, 30_000); + if (!Array.isArray(payload?.features)) + throw new Error('invalid_snapshot'); + features.push(...payload.features); + if (!exceededTransferLimit(payload) || !payload.features.length) break; + } + const rows = normalizeFirePerimeterSnapshot({ features }); + return { fetchedAt: now(), rows }; + } + + async function fetchIndex() { + const rows = await upstream( + 'https://inciweb.wildfire.gov/api/single-publication/', + 4 * MIB, + 20_000, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: '' }), + }, + ); + if (!Array.isArray(rows)) throw new Error('invalid_index'); + return rows; + } + + // InciWeb's publication API was retired; incident pages carry origin and update times. + async function fetchPublication(id) { + const signal = AbortSignal.timeout(15_000); + let response = await fetchImpl(`https://inciweb.wildfire.gov/node/${id}`, { + signal, + redirect: 'manual', + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); + const location = response.headers.get('location'); + if (!location || /\.\.|[?%\\]/.test(location)) + throw new Error('invalid_redirect'); + const url = new URL(location, 'https://inciweb.wildfire.gov/'); + if ( + url.hostname !== 'inciweb.wildfire.gov' || + !/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)?$/.test(url.pathname) || + !['http:', 'https:'].includes(url.protocol) || + url.port || + url.username || + url.password + ) + throw new Error('invalid_redirect'); + url.protocol = 'https:'; + url.search = ''; + url.hash = ''; + response = await fetchImpl(url.href, { signal, redirect: 'error' }); + } + if (response.status !== 200) { + await response.body?.cancel(); + throw new Error('upstream_unavailable'); + } + const html = await readResponseTextCapped(response, 2 * MIB, signal); + const changed = Date.parse( + html.match(/\s*]*>\s*= 256) + throw Object.assign(new Error('busy'), { status: 429 }); + try { + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + const value = await load(); + store.delete(key); + store.set(key, { value, savedAt: now() }); + if (store === publications && store.size > 256) + store.delete(store.keys().next().value); + return value; + }); + return { value: await promise, stale: false }; + } catch (error) { + if (previous) return { value: previous.value, stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(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]; + let key, + ttl, + load, + store = cache; + if (path === '/' || path === '') { + key = 'perimeters'; + ttl = 300_000; + load = fetchPerimeters; + } else if (path === '/inciweb/index') { + key = 'index'; + ttl = 3_600_000; + load = fetchIndex; + } else if (path.startsWith('/inciweb/publication/')) { + const id = path.slice('/inciweb/publication/'.length); + if (!/^\d{1,9}$/.test(id)) + return json(400, { error: 'invalid_publication_id' }); + key = `publication:${id}`; + ttl = 1_800_000; + load = () => fetchPublication(id); + store = publications; + } else return json(404, { error: 'unknown_route' }); + if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); + try { + const { value, stale } = await acquire(key, ttl, load, store); + json( + 200, + key === 'perimeters' && stale ? { ...value, stale: true } : value, + stale, + ); + } catch (error) { + json(error.status === 429 ? 429 : 502, { + error: 'fire_perimeters_unavailable', + }); + } + } + + return { + name: 'fire-perimeters', + configureServer({ middlewares }) { + middlewares.use('/api/fire-perimeters', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/fire-perimeters', handler); + }, + }; +} diff --git a/server/providers/firms.js b/server/providers/firms.js index 3a4c5ea9f..636968348 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -8,10 +8,11 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 * * Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent - * satellites, no cross-source dedup) fetched sequentially with `days=2` - * (`days=1` means "current UTC day", nearly empty just after 00:00Z) and - * clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS quota is - * 5,000 transactions / 10 min per MAP_KEY, so the cache is the point: + * satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, + * ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy) + * with `days=2` (`days=1` means "current UTC day", nearly empty just after + * 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS + * quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point: * TTL 30 min, single-flight refresh, serve-stale-on-failure, and a * fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream * fetch across dev-server restarts. Pattern mirrors celestrakProxy. @@ -28,7 +29,12 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; export function firmsProxy() { const TTL_MS = 30 * 60_000; const STATUS_TTL_MS = 5 * 60_000; - const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT']; + const SOURCES = [ + 'VIIRS_NOAA20_NRT', + 'VIIRS_NOAA21_NRT', + 'VIIRS_SNPP_NRT', + 'MODIS_NRT', + ]; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); const CACHE_PATH = path.join(CACHE_DIR, 'firms.json'); diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js new file mode 100644 index 000000000..994da7bd8 --- /dev/null +++ b/server/providers/local-receivers.js @@ -0,0 +1,521 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import http from 'node:http'; +import https from 'node:https'; +import { Readable } from 'node:stream'; +import { coalesceProxyRequest, readResponseTextCapped } from './common/http.js'; +import { isLocalIpv4, parseTapAddress } from '../../src/data/tapAddress.js'; +import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js'; + +/** + * Local decoder feeds for the Local ADS-B layer. + * + * A user who runs dump1090 / readsb / tar1090 (1090 MHz) or dump978-fa + + * skyaware978 (978 MHz UAT) exposes an `aircraft.json` over HTTP. This route + * reads those documents and returns them as Local ADS-B records. + * + * The feed list comes only from the operator's `LOCAL_RECEIVER_FEEDS` + * environment value; the browser never supplies an address. Every host must + * pass the shared receiver-tap address rule (`src/data/tapAddress.js`: + * loopback, RFC1918, `localhost`, `*.local`), the scheme must be http(s) and + * the path must end in `aircraft.json`. Invalid entries are logged at startup, + * reported as `invalid` and never fetched. + * + * A name (`localhost`, `*.local`) is resolved before every read; each address + * it resolves to must itself be loopback or RFC1918, and the connection is + * pinned to exactly those validated addresses, so a later re-resolution + * cannot redirect it anywhere else. + */ + +export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft'; +export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; +export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); +export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; +export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; +export const LOCAL_RECEIVER_CACHE_MS = 1_000; +/** A feed whose own `now` is older than this is reported stale. */ +export const LOCAL_RECEIVER_STALE_MS = 10_000; +const MAX_FEEDS = 8; +const LOG_PREFIX = '[local-receivers]'; + +const BAND_LABELS = Object.freeze({ 1090: '1090 MHz', 978: '978 MHz UAT' }); +/** Statuses whose `Response` must be constructed without a body. */ +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +function defaultPort(protocol) { + return protocol === 'https:' ? 443 : 80; +} + +/** + * Validate one `band=url` entry. + * @param {string} entry Raw entry text. + * @returns {{band:string, url:string}|{band:string|null, reason:string}} + */ +function parseEntry(entry) { + const separator = entry.indexOf('='); + if (separator <= 0) return { band: null, reason: 'expected band=url' }; + const band = entry.slice(0, separator).trim(); + const rawUrl = entry.slice(separator + 1).trim(); + if (!LOCAL_RECEIVER_BANDS.includes(band)) + return { band: null, reason: 'band must be 1090 or 978' }; + let url; + try { + url = new URL(rawUrl); + } catch { + return { band, reason: 'not a valid URL' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return { band, reason: 'scheme must be http or https' }; + if (url.username || url.password) + return { band, reason: 'credentials are not allowed in the URL' }; + if (url.search || url.hash || rawUrl.includes('?') || rawUrl.includes('#')) + return { band, reason: 'query and fragment are not allowed' }; + const port = url.port ? Number(url.port) : defaultPort(url.protocol); + const address = parseTapAddress(`${url.hostname}:${port}`); + if (!address) + return { + band, + reason: 'host must be loopback, RFC1918, localhost or *.local', + }; + if (!/(?:^|\/)aircraft\.json$/.test(url.pathname)) + return { band, reason: 'path must end in aircraft.json' }; + return { + band, + url: `${url.protocol}//${address.host}:${address.port}${url.pathname}`, + }; +} + +/** + * Parse `LOCAL_RECEIVER_FEEDS` (`band=url[,band=url…]`). + * + * Labels name the band only (plus an ordinal when a band repeats), so feed + * addresses never reach the browser. + * @param {string|undefined} value Environment value. + * @returns {{configured:boolean, feeds:object[]}} Each feed is + * `{ id, band, label, url }` when valid or `{ id, band, label, reason }` + * when invalid (never fetched). + */ +export function parseLocalReceiverFeeds(value) { + const entries = String(value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const feeds = []; + const perBand = new Map(); + entries.forEach((entry, index) => { + const parsed = + index < MAX_FEEDS + ? parseEntry(entry) + : { band: null, reason: `at most ${MAX_FEEDS} feeds are read` }; + const band = parsed.band; + const ordinal = (perBand.get(band) || 0) + 1; + perBand.set(band, ordinal); + const base = band ? BAND_LABELS[band] : `entry ${index + 1}`; + const label = band && ordinal > 1 ? `${base} #${ordinal}` : base; + feeds.push({ + id: `feed-${index + 1}`, + band, + label, + ...(parsed.url ? { url: parsed.url } : { reason: parsed.reason }), + }); + }); + // Ordinals are only needed when a band actually repeats. + for (const feed of feeds) { + if (feed.band && perBand.get(feed.band) > 1 && !feed.label.includes('#')) + feed.label = `${feed.label} #1`; + } + return { configured: feeds.length > 0, feeds }; +} + +class FeedError extends Error { + constructor(code) { + super(code); + this.code = code; + } +} + +/** + * Whether a resolved address satisfies the receiver-tap rule: loopback or + * RFC1918 IPv4, the IPv6 loopback, or an IPv4-mapped form of the former. + * @param {string} address Resolved IP address. + * @returns {boolean} + */ +export function isLocalReceiverAddress(address) { + const value = String(address || '').toLowerCase(); + if (value === '::1') return true; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value); + return isLocalIpv4(mapped ? mapped[1] : value); +} + +/** + * Resolve a feed host name and require every address to be local. + * @param {string} hostname `localhost` or a `*.local` name. + * @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible. + * @returns {Promise>} + */ +export async function resolveLocalReceiverAddresses(hostname, lookupImpl) { + let resolved; + try { + resolved = await lookupImpl(hostname, { all: true, verbatim: true }); + } catch { + throw new FeedError('UNRESOLVED'); + } + const addresses = (Array.isArray(resolved) ? resolved : [resolved]) + .map((row) => ({ + address: String(row?.address || ''), + family: + Number(row?.family) || (String(row?.address).includes(':') ? 6 : 4), + })) + .filter((row) => row.address); + if (!addresses.length) throw new FeedError('UNRESOLVED'); + if (addresses.some((row) => !isLocalReceiverAddress(row.address))) + throw new FeedError('FORBIDDEN_ADDRESS'); + return addresses; +} + +/** + * Settle with `promise`, or reject with an AbortError as soon as `signal` + * aborts; a later settlement of `promise` is ignored. + */ +function untilAborted(promise, signal) { + return new Promise((resolve, reject) => { + const onAbort = () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }; + // Handlers are attached first so a late rejection is never unhandled. + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** A `net`-style lookup that only ever answers the validated addresses. */ +function pinnedLookup(addresses) { + return (_hostname, options, callback) => { + const done = typeof options === 'function' ? options : callback; + if (options?.all) + done( + null, + addresses.map((row) => ({ ...row })), + ); + else done(null, addresses[0].address, addresses[0].family); + }; +} + +/** + * Default transport. With `options.lookup` (a pinned feed name) the request + * is made with node:http(s) so the socket connects only to the validated + * addresses (TLS still verifies the name); otherwise it is a plain fetch of + * an IP-literal URL. Redirects are never followed either way. + * @param {string} url + * @param {object} options + * @returns {Promise} + */ +function fetchLocalReceiverFeed(url, options) { + if (typeof options?.lookup !== 'function') return fetch(url, options); + const client = new URL(url).protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const request = client.request( + url, + { + method: 'GET', + headers: options.headers, + signal: options.signal, + lookup: options.lookup, + // A fresh socket per read: a pooled one could outlive the pin. + agent: false, + }, + (response) => { + // 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; + // A `Response` only represents final statuses 200–599. + if (!Number.isInteger(status) || status < 200 || status > 599) + throw new FeedError('HTTP_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.destroy(); + resolve(upstream); + } catch (error) { + response.destroy(); + reject(error); + } + }, + ); + request.on('error', reject); + request.end(); + }); +} + +/** + * Fetch one validated feed document without following redirects and with the + * body cap enforced while it streams. + * @param {string} url Validated feed URL. + * @param {object} options + * @returns {Promise} Parsed JSON document. + */ +export async function fetchLocalReceiverDocument( + url, + { + fetchImpl = fetchLocalReceiverFeed, + lookupImpl = dnsLookup, + timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS, + maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES, + } = {}, +) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + let upstream; + try { + const { hostname } = new URL(url); + // IP literals were validated when the feed list was parsed; a name is + // validated by what it resolves to now, and the socket is pinned there. + // Resolution runs against the same deadline as the read: a stalled + // lookup fails this feed instead of holding the whole snapshot. + const lookup = isLocalIpv4(hostname) + ? undefined + : pinnedLookup( + await untilAborted( + resolveLocalReceiverAddresses(hostname, lookupImpl), + controller.signal, + ), + ); + upstream = await fetchImpl(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: controller.signal, + ...(lookup ? { lookup } : {}), + }); + if (upstream.status >= 300 && upstream.status < 400) + throw new FeedError('REDIRECT_REFUSED'); + // Opaque redirect responses from `redirect: 'manual'` report status 0. + if (upstream.type === 'opaqueredirect') + throw new FeedError('REDIRECT_REFUSED'); + if (!upstream.ok) throw new FeedError('HTTP_STATUS'); + const text = await readResponseTextCapped( + upstream, + maxBytes, + controller.signal, + ); + let json; + try { + json = JSON.parse(text); + } catch { + throw new FeedError('BAD_JSON'); + } + if (!json || typeof json !== 'object' || !Array.isArray(json.aircraft)) + throw new FeedError('BAD_DOCUMENT'); + return json; + } catch (error) { + controller.abort(); + if (upstream?.body && !upstream.body.locked) + void upstream.body.cancel().catch(() => {}); + if (error instanceof FeedError) throw error; + if (error?.code === 'RESPONSE_TOO_LARGE') + throw new FeedError('RESPONSE_TOO_LARGE'); + if (error?.name === 'AbortError' || controller.signal.aborted) + throw new FeedError('TIMEOUT'); + throw new FeedError('UNREACHABLE'); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Read one feed and describe it. + * @returns {Promise<{status:object, records:object[]}>} + */ +async function readFeed( + feed, + { fetchImpl, lookupImpl, now, timeoutMs, maxBytes, log }, +) { + const summary = { band: feed.band, label: feed.label }; + if (!feed.url) + return { + status: { ...summary, status: 'invalid', aircraft: 0, ageMs: null }, + records: [], + }; + let json; + try { + json = await fetchLocalReceiverDocument(feed.url, { + fetchImpl, + lookupImpl, + timeoutMs, + maxBytes, + }); + } catch (error) { + log(feed, 'unreachable', error.code); + return { + status: { ...summary, status: 'unreachable', aircraft: 0, ageMs: null }, + records: [], + }; + } + const receivedAt = now(); + const feedNowS = Number(json.now); + // The feed's own clock decides staleness. A clock ahead of ours reads as + // age 0; a document without `now` is treated as current. + const ageMs = Number.isFinite(feedNowS) + ? Math.max(0, Math.round(receivedAt - feedNowS * 1000)) + : 0; + const status = ageMs > LOCAL_RECEIVER_STALE_MS ? 'stale' : 'live'; + log(feed, status); + // Anchor record ages at the document time so a stale feed ages out + // naturally, without trusting a receiver clock that runs ahead of ours. + const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, { + band: feed.band, + }); + return { + status: { ...summary, status, aircraft: records.length, ageMs }, + records, + }; +} + +/** + * Build the route handler. Exposed for tests; the plugin wraps it. + * @param {object} [options] + * @param {string} [options.feedsValue] `LOCAL_RECEIVER_FEEDS` value. + * @param {typeof fetch} [options.fetchImpl] Receives `options.lookup` when + * the feed host is a name; the connection must resolve only through it. + * @param {Function} [options.lookupImpl] `dns.promises.lookup`-compatible. + * @param {() => number} [options.now] + * @param {{warn:Function, info?:Function}} [options.logger] + * @returns {{handle:Function, config:object}} + */ +export function createLocalReceiversHandler({ + feedsValue = process.env[LOCAL_RECEIVER_FEEDS_ENV], + fetchImpl = fetchLocalReceiverFeed, + lookupImpl = dnsLookup, + now = Date.now, + logger = console, + timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS, + maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES, + cacheMs = LOCAL_RECEIVER_CACHE_MS, +} = {}) { + const config = parseLocalReceiverFeeds(feedsValue); + for (const feed of config.feeds) { + if (feed.reason) + logger.warn( + `${LOG_PREFIX} ${LOCAL_RECEIVER_FEEDS_ENV} ${feed.id} (${feed.label}) rejected: ${feed.reason}. It will not be fetched.`, + ); + } + const lastStatus = new Map(); + // Log a feed only when its status changes, so a feed that is down does not + // write a line every second. + const log = (feed, status, code) => { + if (lastStatus.get(feed.id) === status) return; + lastStatus.set(feed.id, status); + if (status === 'live') logger.info?.(`${LOG_PREFIX} ${feed.label} live`); + else + logger.warn( + `${LOG_PREFIX} ${feed.label} ${status}${code ? ` (${code})` : ''}`, + ); + }; + const inFlight = new Map(); + let cached = null; + + async function snapshot() { + const results = await Promise.all( + config.feeds.map((feed) => + readFeed(feed, { + fetchImpl, + lookupImpl, + now, + timeoutMs, + maxBytes, + log, + }), + ), + ); + return { + configured: true, + generatedAt: now(), + feeds: results.map((result) => result.status), + records: results.flatMap((result) => result.records), + }; + } + + async function payload() { + if (!config.configured) + return { configured: false, feeds: [], records: [] }; + if (cached && now() - cached.at < cacheMs) return cached.payload; + const { promise } = coalesceProxyRequest(inFlight, 'all', snapshot); + const result = await promise; + cached = { at: now(), payload: result }; + return result; + } + + async function handle(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + try { + const body = await payload(); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); + } catch { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Local receiver feeds unavailable' })); + } + } + + return { handle, config }; +} + +/** + * Vite plugin mounting `GET /api/local-receivers/aircraft`. + * + * The environment is read when the server starts, so construction alone + * neither parses configuration nor fetches anything. + * @param {object} [options] Passed to {@link createLocalReceiversHandler}. + * @returns {import('vite').Plugin} + */ +export function localReceiversProxy(options = {}) { + const install = (server) => { + const { handle } = createLocalReceiversHandler(options); + server.middlewares.use(LOCAL_RECEIVERS_ROUTE, (req, res, next) => { + const path = new URL(req.url || '/', 'http://localhost').pathname; + if (path !== '/' && path !== '') { + if (typeof next === 'function') return next(); + return undefined; + } + return handle(req, res); + }); + }; + return { + name: 'local-receivers-proxy', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 36eb6b22b..398519d92 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js'; import { defaultSourceRoot } from './common/source-root.js'; import { radioBrowserProxy } from './radio.js'; import { gbfsProxy } from './gbfs.js'; +import { localReceiversProxy } from './local-receivers.js'; import { transitProxy } from './transit.js'; import { adsbLolProxy } from './aircraft/adsb-lol.js'; import { aisLiveProxy } from './vessels/ais-live.js'; @@ -21,6 +22,7 @@ import { openAiRealtimeProxy } from './openai.js'; import { googlePlacesContextProxy } from './places.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; +import { firePerimetersProxy } from './firePerimeters.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -42,6 +44,7 @@ function localProviderPlugins() { cctvProxy({ sourceRoot: defaultSourceRoot }), radioBrowserProxy(), gbfsProxy(), + localReceiversProxy(), transitProxy(), adsbLolProxy(), aisLiveProxy(), @@ -51,6 +54,7 @@ function localProviderPlugins() { windProxy(), weatherProxy(), cycloneProxy(), + firePerimetersProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 52c8c0b95..fa957ac7c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -49,7 +49,8 @@ function realtimeInstructions( 'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.', 'For visual filter requests, call set_visual_style with one of the allowed style IDs.', 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', - 'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', + 'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', + 'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.', 'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.', 'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.', '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index f4e328b7f..48177dde5 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -82,7 +82,7 @@ export const ACTION_DESCRIPTIONS = { properties: { layerId: { description: - 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.', + 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras; local ADS-B/my receiver/my antenna (aircraft heard by a local RTL-SDR receiver) → local-adsb.', $position: 1, }, }, @@ -182,6 +182,29 @@ export const ACTION_DESCRIPTIONS = { }, }, }, + set_cyber_sonar: { + description: + 'Adjust Cyber-only sonar controls. Requires the Cyber HUD layout; never switches layout or HUD visibility. Omitted fields stay unchanged. Returns actual settings and separate map/contact sweep activity. A saved setting does not mean the effect is active.', + $position: 1, + parameters: { + properties: { + enabled: { description: 'Explicitly turn sonar on or off.' }, + rings: { description: 'Number of decorative sonar rings, 3–12.' }, + rangePct: { + description: + 'Visual ring range, 60–120 percent; not geographic distance.', + }, + intensityPct: { + description: 'Sonar Power slider, 0–100 percent. Zero is valid.', + }, + opacityPct: { + description: + 'Contact opacity floor between passes, 35–100 percent; labels have a derived floor. Not whole-scene dimming.', + }, + sectorDeg: { description: 'Sonar sweep sector width, 8–60 degrees.' }, + }, + }, + }, set_detection: { description: 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.', diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index 5479a1c14..ffa5a1e27 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -7,7 +7,7 @@ import { apiNotFoundPlugin } from './api-not-found.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); /** Load this checkout's configuration and attach its local provider middleware. */ -export default defineConfig(({ mode }) => { +export default defineConfig(({ command, mode }) => { const loaded = loadEnv(mode, root, ''); for (const [key, value] of Object.entries(loaded)) { if (process.env[key] === undefined) process.env[key] = value; @@ -18,5 +18,6 @@ export default defineConfig(({ mode }) => { cesiumToken: process.env.CESIUM_ION_TOKEN, host: process.env.HOST, port: process.env.PORT, + command, }); }); diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs new file mode 100644 index 000000000..b84bd861b --- /dev/null +++ b/src/annotations/regionRing.test.mjs @@ -0,0 +1,86 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; +import { createAnnotationResolver } from './resolver.js'; + +function featureSource(overrides = {}) { + const source = {}; + for (const method of FEATURE_SOURCE_METHODS) + source[method] = async () => null; + return Object.assign(source, overrides); +} + +const texasGeocode = { + async geocode() { + return { + place: { + lat: 31.0, + lng: -99.9, + label: 'Texas, USA', + name: 'Texas', + types: ['administrative_area_level_1', 'political'], + }, + }; + }, +}; + +test('region ring: Natural Earth names resolve without a geocoder', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + for (const name of ['Gulf of Mexico', 'the Alps']) { + const region = await resolveRegionRingForQuery(name); + assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`); + assert.equal(region.error, undefined); + } +}); + +test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { + let adminSignal; + let release; + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource({ + getAdministrativeAreas(_point, { signal }) { + adminSignal = signal; + return new Promise((resolve) => { + release = resolve; + }); + }, + }), + }); + const started = Date.now(); + const region = await resolveRegionRingForQuery( + 'Texas', + undefined, + texasGeocode, + { budgetMs: 50 }, + ); + assert.deepEqual(region, { + name: 'Texas', + ring: null, + error: 'region-timeout', + }); + assert.ok( + Date.now() - started < 1000, + 'returns at the budget, not the lookup', + ); + // The lookup is left running so it can fill the boundary cache. + assert.equal(adminSignal?.aborted ?? false, false); + release(null); +}); + +test('region ring: an unbounded budget waits for the lookup', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource({ + getAdministrativeAreas: () => + new Promise((resolve) => setTimeout(() => resolve(null), 30)), + }), + }); + const region = await resolveRegionRingForQuery( + 'Texas', + undefined, + texasGeocode, + { budgetMs: Infinity }, + ); + assert.equal(region, null); +}); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 3f757159e..214223ff1 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -25,6 +25,9 @@ import { import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; +/** Ms the analyst region lookup waits on geocode + admin boundary. */ +export const REGION_FALLBACK_BUDGET_MS = 3_000; + /** Own annotation lookup caches and ranking of supplied feature candidates. */ export function createAnnotationResolver({ boundarySource, @@ -1877,14 +1880,22 @@ export function createAnnotationResolver({ * resolve to a region-like boundary — the analyst engine reports that * honestly rather than silently scoping to nothing. * + * The geocode + admin-boundary rung is capped at `budgetMs`. Past it the + * call returns `{name, ring: null, error: 'region-timeout'}` so a voice + * answer is not held for tens of seconds; the lookup keeps running and + * fills the boundary cache, so asking again shortly is fast. + * * @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico" * @param {AbortSignal} [signal] - * @returns {Promise<{name:string, ring:Array<[number,number]>}|null>} + * @param {object} [placeSearch] + * @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup. + * @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>} */ async function resolveRegionRingForQuery( name, signal, placeSearch = unavailablePlaceSearch, + { budgetMs = REGION_FALLBACK_BUDGET_MS } = {}, ) { signal = lifetime ? signal @@ -1901,6 +1912,23 @@ export function createAnnotationResolver({ const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0]; if (ring?.length >= 3) return { name: ne.name, ring }; } + const lookup = resolveAdminRegionRing(q, signal, placeSearch); + if (!Number.isFinite(budgetMs)) return lookup; + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ name: q, ring: null, error: 'region-timeout' }), + budgetMs, + ); + }); + try { + return await Promise.race([lookup, timeout]); + } finally { + clearTimeout(timer); + } + } + + async function resolveAdminRegionRing(q, signal, placeSearch) { const geo = await geocodePlace(q, null, signal, placeSearch).catch( () => null, ); diff --git a/src/app/catalog.js b/src/app/catalog.js index 0d22ec9a1..70b96ffbf 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({ militaryAwarenessLayer: 'military-awareness', militaryInstallationsLayer: 'military-installations', rocketLaunchesLayer: 'rocket-launches', + localAdsbLayer: 'local-adsb', }); /** Capture the ordered application instances and their serialization metadata. */ diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index 820943049..71f87d50a 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', () 'military-awareness', 'military-installations', 'rocket-launches', + 'local-adsb', ]; const layers = ids.map((id) => ({ id })); const catalog = createLayerCatalog(layers, metadata(layers)); @@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', () assert.equal(services.flightsLayer, layers[1]); assert.equal(services.transitLayer, layers[7]); assert.equal(services.aisLiveVesselsLayer, layers[8]); + assert.equal(services.localAdsbLayer, layers[12]); assert.equal(new Set(Object.values(services)).size, layers.length); assert.throws( () => catalogControlServices(createLayerCatalog([], [])), diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 26222c6cb..40ea46596 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -19,9 +19,11 @@ import { createApplicationInstallations } from './layers/militaryInstallations.j import { createApplicationSatellites } from './layers/satellites.js'; import { createApplicationLaunches } from './layers/rocketLaunches.js'; import { createApplicationAlpr } from './layers/alprCameras.js'; +import { createApplicationLocalAdsb } from './layers/localAdsb.js'; 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 { createApplicationCables } from './layers/submarineCables.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; import { localGeoJsonServices } from './localGeojsonServices.js'; @@ -51,9 +53,24 @@ const SOURCE_METHODS = Object.freeze({ weather: ['getSnapshot'], cyclones: ['getSnapshot'], earthquakes: ['getSnapshot'], + 'fire-perimeters': ['getSnapshot'], cables: ['fetch'], }); +/** + * Hardware-local layers are registered like any other but never enter share + * links or stored layer state: another browser cannot have this receiver. + */ +export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([ + Object.freeze({ id: 'local-adsb', disposition: 'local-only' }), +]); + +/** Serialization metadata for every layer the application catalog constructs. */ +export const APPLICATION_LAYER_METADATA = Object.freeze([ + ...LAYER_STATE_REGISTRY, + ...LOCAL_ONLY_LAYER_METADATA, +]); + /** Construct the current catalog without choosing any source provider. * Scene engines remain page-owned; layers and classification have this app's lifetime. * The manager owns layer destruction, while abort releases classification even if startup fails. @@ -62,7 +79,7 @@ export function createApplicationCatalog({ surface, sources, signal, - metadata = LAYER_STATE_REGISTRY, + metadata = APPLICATION_LAYER_METADATA, vesselOptions, resolveAsset, nepalBoundaryResolver, @@ -120,7 +137,16 @@ export function createApplicationCatalog({ }), flights, military, + createApplicationLocalAdsb({ + surface, + enrichment: sources.flights, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationEarthquakes({ source: sources.earthquakes }), + createApplicationFirePerimeters({ + source: sources['fire-perimeters'], + }), createApplicationAlpr({ surface, source: sources.alpr }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 1d8d33f80..6043b1262 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -39,7 +39,13 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 27); + assert.equal(first.layers.length, 29); + assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); + assert.deepEqual( + first.metadata.find(({ id }) => id === 'local-adsb'), + { id: 'local-adsb', disposition: 'local-only' }, + 'the hardware-local layer is never serialized into links', + ); assert.notEqual(first.weatherClock, second.weatherClock); await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z'); assert.match( @@ -52,6 +58,7 @@ test('catalogs construct distinct layers and classification from their supplied first.get(id).getDiagnostics().clock.target, '2026-09-21T12:00:00.000Z', ); + assert.ok(first.get('fire-perimeters')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( diff --git a/src/app/data.js b/src/app/data.js index 254c825c5..85a3183fc 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -1,5 +1,6 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerPresentation } from './layerPresentation.js'; +import { createCyberSonarScene } from '../cyberSonarScene.js'; /** Register the application layer catalog before allowing state restoration. */ export function createApplicationData({ scene: { viewer, mapStackController }, @@ -55,6 +56,7 @@ export function createApplicationData({ } presentation.mount(document.getElementById('data-toggles')); styleManager.attachDataManager(dataManager); + defer(createCyberSonarScene(viewer, dataManager)); return { dataManager, catalog, presentation }; } diff --git a/src/app/layers/localAdsb.js b/src/app/layers/localAdsb.js new file mode 100644 index 000000000..c6e57b3a9 --- /dev/null +++ b/src/app/layers/localAdsb.js @@ -0,0 +1,48 @@ +import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js'; +import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js'; +import { SdrController } from '../../sdr/controller.js'; +import * as render from '../../renderGovernor.js'; +import * as context from '../../data/contextStore.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as trails from '../../data/trailRenderer.js'; +import * as geoid from '../../data/geoid.js'; +import { markDetectionSourcesChanged } from '../../data/detection.js'; +import { refreshTrackedReadout } from '../../data/trackedReadout.js'; + +/** + * Construct the Local ADS-B layer, the browser RTL-SDR session it shares with + * the Radio panel, and the decoder-feed session. Construction opens no device + * and makes no request; the SDR starts only from an explicit Connect and the + * feeds are polled only while the layer is enabled. + * + * `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference + * (`{ models3d, models3dMode }`) so local aircraft follow the same toggle; + * `enrichment` is the Flights source whose cached adsbdb proxy they share. + */ +export function createApplicationLocalAdsb({ + receiver = new SdrController(), + feeds = createLocalReceiverFeeds(), + surface = null, + enrichment = null, + displayParams = null, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +} = {}) { + return createLocalAdsbLayer({ + receiver, + feeds, + resolveAsset, + services: { + render, + context, + picking, + trails, + geoid, + groundSnap: surface?.groundSnap || null, + enrichment, + display: displayParams ? { getParams: displayParams } : null, + detection: { markSourcesChanged: markDetectionSourcesChanged }, + overlays: { refreshReadout: refreshTrackedReadout }, + }, + }); +} diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js new file mode 100644 index 000000000..f5110f630 --- /dev/null +++ b/src/app/layers/perimeters.js @@ -0,0 +1,29 @@ +import * as Cesium from 'cesium'; +import { + createFirePerimetersLayer, + createInciwebIndexSource, + createInciwebPublicationSource, +} from '../../layers/perimeters/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire WFIGS fire perimeters into the application catalog. */ +export function createApplicationFirePerimeters(options) { + return createFirePerimetersLayer({ + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + inciwebSource: createInciwebIndexSource(), + inciwebPublications: createInciwebPublicationSource(), + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/browserModuleBoundary.test.mjs b/src/browserModuleBoundary.test.mjs index d4c1c3bdb..ff883e46f 100644 --- a/src/browserModuleBoundary.test.mjs +++ b/src/browserModuleBoundary.test.mjs @@ -22,7 +22,7 @@ test('no browser-built module imports a Node core module', () => { // import survives the build and turns into a runtime failure the moment the // guard around it is wrong. src/data/naturalEarthRegions.js and // src/data/neighborhoodPolygons.js both carried one to read their bundled - // JSON packs under node:test; an import attribute serves both runtimes. + // JSON packs under node:test; src/data/bundledJson.js now serves both runtimes. const offenders = []; for (const file of browserModules()) { const source = readFileSync(file, 'utf8'); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 7843946aa..6aa614520 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -342,11 +342,11 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat test('Cockpit Display portal retains both scroll owners across round trips', () => { const portal = CockpitDisplayPortal.toString(); - assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/); + assert.match(portal, /this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\?\.scrollTop \|\| 0/); assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/); - assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/); + assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\.scrollTop/); assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/); - assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/); + assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?displayPanelScroller\(this\.standardPanel\)\.scrollTop =\s*this\.standardScrollTop/); }); test('Cockpit side surfaces behave as two single-expanded accordions', () => { diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js index 0d6c09327..1f7bcf8e8 100644 --- a/src/cockpitUtilityLayout.js +++ b/src/cockpitUtilityLayout.js @@ -17,6 +17,11 @@ * @param {number} [input.signalGap] Clearance above the briefing card. * @param {number} [input.minTopFloor] Absolute ceiling for the strip, in px. * @param {number} [input.minTopRatio] Viewport-relative ceiling, 0..1. + * @param {boolean} [input.signalCollapsed] Whether the briefing card is collapsed. + * @param {boolean} [input.utilityExpanded] Whether Display or Radio is expanded. + * @param {number} [input.bottomInset] Reserved viewport space below an expanded panel. + * @param {number} [input.signalHeight] Collapsed briefing header reserved below the utility. + * @param {boolean} [input.reserveViewportLane] Theme relocates the briefing below the expanded utility. * @returns {{ top: number, maxHeight: number }} */ export function resolveCockpitUtilityAnchor({ @@ -29,13 +34,29 @@ export function resolveCockpitUtilityAnchor({ signalGap = 8, minTopFloor = 96, minTopRatio = 0.12, + signalCollapsed = false, + utilityExpanded = false, + bottomInset = 80, + signalHeight = 0, + reserveViewportLane = false, }) { const viewport = Math.max(0, Number(viewportHeight) || 0); const minTop = Math.max(minTopFloor, viewport * minTopRatio); const anchoredTop = Math.max(minTop, (Number(recBottom) || 0) + recGap); const strip = Math.max(0, Number(stripHeight) || 0); const signal = Number(signalTop); - const lowerBound = Number.isFinite(signal) ? signal : viewport; + const expandedLaneBottom = Math.max( + 0, + viewport - + Math.max(0, Number(bottomInset) || 0) - + Math.max(0, Number(signalHeight) || 0), + ); + const lowerBound = + reserveViewportLane && signalCollapsed && utilityExpanded + ? expandedLaneBottom + : Number.isFinite(signal) + ? signal + : viewport; const clearedTop = lowerBound - signalGap - strip; const top = Math.max(minTop, Math.min(anchoredTop, clearedTop)); const floor = Math.max(0, Number(collapsedHeight) || 0) || 50; @@ -79,3 +100,25 @@ export function resolveCockpitUtilityLayout({ ), }; } + +/** Shared Cyber panel slot, with footer controls above the lower frame (83vh). */ +export function resolveCyberCockpitPanelLane({ + viewportHeight, + top, + launcherHeight = 60, + signalHeight = 44, + contactHeight = 64, +}) { + const gap = 8; + const footerHeight = Math.max( + contactHeight + gap, + launcherHeight + signalHeight + gap * 2, + ); + const bottom = viewportHeight * 0.82; + const panelHeight = Math.max(120, bottom - top - footerHeight); + return { + panelHeight, + utilityHeight: panelHeight + gap + launcherHeight, + signalTop: top + panelHeight + launcherHeight + gap * 2, + }; +} diff --git a/src/cockpitUtilityLayout.test.mjs b/src/cockpitUtilityLayout.test.mjs index 2494b3a11..b1998c2f1 100644 --- a/src/cockpitUtilityLayout.test.mjs +++ b/src/cockpitUtilityLayout.test.mjs @@ -4,11 +4,58 @@ import assert from 'node:assert/strict'; import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, + resolveCyberCockpitPanelLane, } from './cockpitUtilityLayout.js'; // 1512x790, the height the strip used to collide with the briefing card at. const desktop = { viewportHeight: 790, stripHeight: 107, collapsedHeight: 50 }; +test('Cyber shared panel slot reserves both footer lanes above the decorative frame', () => { + for (const viewportHeight of [600, 673, 713, 986, 1376]) { + for (const signalHeight of [44, 76]) { + for (const contactHeight of [44, 84, 120]) { + const top = Math.max(164, Math.min(204, viewportHeight * 0.19)); + const lane = resolveCyberCockpitPanelLane({ viewportHeight, top, signalHeight, contactHeight }); + assert.ok(lane.panelHeight >= 120); + assert.ok(top + lane.panelHeight + 8 + contactHeight <= viewportHeight * 0.82 + .01); + assert.ok(lane.signalTop + signalHeight <= viewportHeight * 0.82 + .01); + assert.equal(lane.signalTop, top + lane.utilityHeight + 8); + } + } + } +}); + +test('Cyber keeps the Data Layers top while reserving briefing clearance', () => { + for (const viewportHeight of [673, 713, 986, 1376]) { + const leftTop = Math.max(164, Math.min(204, viewportHeight * 0.19)); + const collapsed = resolveCockpitUtilityAnchor({ + viewportHeight, stripHeight: 128, collapsedHeight: 60, recBottom: 0, + signalTop: leftTop + 128 + 28, minTopFloor: leftTop, minTopRatio: 0, + }); + assert.equal(collapsed.top, leftTop); + assert.ok(collapsed.maxHeight >= 128); + const expanded = resolveCockpitUtilityAnchor({ + viewportHeight, stripHeight: 700, collapsedHeight: 60, recBottom: 0, + signalTop: 255, minTopFloor: leftTop, minTopRatio: 0, + signalCollapsed: true, utilityExpanded: true, reserveViewportLane: true, signalHeight: 44, + }); + assert.equal(expanded.top, leftTop); + assert.equal(expanded.top + expanded.maxHeight + 8 + 44 + 80, viewportHeight); + } +}); + +test('expanded utilities reserve the visible collapsed briefing header at every desktop height', () => { + for (const viewportHeight of [640, 720, 987, 1376]) { + for (const signalHeight of [48, 76, 100]) { + const { top, maxHeight } = resolveCockpitUtilityAnchor({ + ...desktop, viewportHeight, recBottom: 0, signalTop: 255, + signalCollapsed: true, utilityExpanded: true, signalHeight, reserveViewportLane: true, + }); + assert.equal(top + maxHeight + 8, viewportHeight - 80 - signalHeight); + } + } +}); + test('the strip hangs 12px under the REC readout when the briefing card leaves room', () => { const { top } = resolveCockpitUtilityAnchor({ ...desktop, recBottom: 148.1, signalTop: 420 }); assert.equal(Number(top.toFixed(1)), 160.1); @@ -54,6 +101,37 @@ test('a missing REC readout leaves the strip on the viewport ceiling', () => { assert.equal(top, 96); }); +test('an expanded utility claims the vertical lane released by a collapsed briefing', () => { + const { top, maxHeight } = resolveCockpitUtilityAnchor({ + ...desktop, + recBottom: 148.1, + signalTop: 265.4, + signalCollapsed: true, + utilityExpanded: true, + reserveViewportLane: true, + }); + assert.equal(Number(top.toFixed(1)), 160.1); + assert.equal(Number(maxHeight.toFixed(1)), 541.9); +}); + +test('non-relocating themes preserve the visible briefing boundary even when collapsed', () => { + const { top, maxHeight } = resolveCockpitUtilityAnchor({ + ...desktop, recBottom: 148.1, signalTop: 265.4, + signalCollapsed: true, utilityExpanded: true, + }); + assert.equal(Number((top + maxHeight).toFixed(1)), 257.4); +}); + +test('a collapsed briefing still bounds the utility strip until a panel expands', () => { + const { maxHeight } = resolveCockpitUtilityAnchor({ + ...desktop, + recBottom: 148.1, + signalTop: 265.4, + signalCollapsed: true, + }); + assert.equal(Number(maxHeight.toFixed(1)), 107); +}); + test('keeps the collapsed sibling visible when both Cockpit utilities fit', () => { assert.deepEqual(resolveCockpitUtilityLayout({ availableHeight: 320, @@ -94,4 +172,3 @@ test('clamps malformed or short corridors to the minimum expanded height', () => expandedMaxHeight: 120, }); }); - diff --git a/src/cyberSonar.js b/src/cyberSonar.js new file mode 100644 index 000000000..3e0d1c453 --- /dev/null +++ b/src/cyberSonar.js @@ -0,0 +1,562 @@ +/** + * Scanner design inspired by kk376's tactical naval sonar visual style: + * https://github.com/bilawalsidhu/gods-eye-view/pull/214 + * + * This is a separate HUD/contact treatment, not a port of that PR's shader. + */ +export const CYBER_SONAR_PERIOD_MS = 5_200; +// Parked-scene wake-ups and shared overlays use the fleet cadence. Native +// contacts still compose after layer updates on each actual draw so owner +// color writes cannot flash through during camera/fleet animation. +export const CYBER_SONAR_RENDER_INTERVAL_MS = 80; +export const CYBER_SONAR_SWEEP_DEG = 24; +export const CYBER_SONAR_FEATHER_DEG = 10; +// The sector should still read as a clear acquisition pass, but contacts +// outside it must remain immediately legible against FLIR terrain. Icons and +// dots keep a strong operator-tunable opacity floor; labels retain an even +// brighter floor so typography never disappears between passes. +export const CYBER_SONAR_DIM_FACTOR = 0.84; +export const CYBER_SONAR_LABEL_DIM_FACTOR = 0.92; +export const CYBER_SONAR_DEFAULTS = Object.freeze({ + rings: 8, + range: 100, + intensity: 70, + opacity: 84, + sector: 24, +}); + +const clamp01 = (value) => Math.min(1, Math.max(0, value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); +const primitiveColors = new WeakMap(); +const sweepAnimations = new WeakMap(); + +/** Pin the compositor animation to the absolute clock used by contact sampling. + * A negative delay alone is relative to the animation's eventual start, which + * may be much later than theme initialization (or restart after HUD hiding). + * Cache the running animation: no per-frame style writes or animation queries. + */ +export function syncCyberSonarAnimation( + root = globalThis.document?.documentElement, +) { + if (!root) return; + const cached = sweepAnimations.get(root); + if (cached?.element.isConnected && cached.animation.playState !== 'idle') + return; + const element = root.querySelector?.('.hud-sonar'); + const animation = element + ?.getAnimations?.({ subtree: true }) + .find((item) => item.animationName === 'cyber-sonar-sweep'); + if (!animation) return; + // The document animation timeline and performance.now share the navigation + // time origin. Zero both offsets so delayed starts cannot shift the sector. + animation.effect.updateTiming({ delay: 0 }); + animation.startTime = 0; + sweepAnimations.set(root, { element, animation }); +} + +const copyColor = (color) => { + if (!color || !Number.isFinite(color.alpha)) return null; + if (typeof color.clone === 'function') return color.clone(); + return { ...color }; +}; + +/** Read owner opacity before a hue-only restyle, never our composited opacity. + * An independently written color still wins, including real freshness changes. + */ +export function cyberSonarBaseAlpha(visual, property = 'color') { + const current = visual?.[property]; + const state = primitiveColors.get(visual)?.[property]; + const applied = state?.lastAppliedColor; + return applied && + current && + current.red === applied.red && + current.green === applied.green && + current.blue === applied.blue && + current.alpha === applied.alpha + ? state.base.alpha + : (current?.alpha ?? 1); +} + +/** Apply the sector factor to a Cesium point/billboard without owning its base style. */ +export function applyCyberSonarPrimitive( + visual, + factor, + active = true, + sampler, + property = 'color', + opaque = false, +) { + if (!visual) return; + const current = visual[property]; + let slots = primitiveColors.get(visual); + let state = slots?.[property]; + const ownsCurrentColor = + state && + current && + current.red === state.lastAppliedColor.red && + current.green === state.lastAppliedColor.green && + current.blue === state.lastAppliedColor.blue && + current.alpha === state.lastAppliedColor.alpha; + if (!active) { + // An owner refresh between the last paint and teardown wins over our cache. + if (ownsCurrentColor) + visual[property] = state.undefinedBase + ? undefined + : copyColor(state.base); + if (slots) delete slots[property]; + return; + } + if (!current && property !== 'color') return; + const nextFactor = clamp01(factor); + // Cesium setters copy into a stable internal Color instead of retaining the + // assigned object. Compare its channels with our snapshot as well, so an + // owner's in-place style refresh is still recognized as a new base. + if ( + ownsCurrentColor && + Math.abs(nextFactor - state.lastAppliedFactor) <= 0.002 && + state.floor === sampler?.floor + ) { + return; + } + if (!ownsCurrentColor) { + if (!state) { + state = { + base: copyColor(current) || { red: 1, green: 1, blue: 1, alpha: 1 }, + lastAppliedFactor: Number.NaN, + }; + if (!slots) primitiveColors.set(visual, (slots = Object.create(null))); + slots[property] = state; + } else { + state.base.red = current?.red ?? 1; + state.base.green = current?.green ?? 1; + state.base.blue = current?.blue ?? 1; + state.base.alpha = current?.alpha ?? 1; + } + state.undefinedBase = current === undefined; + } + const next = state.lastAppliedColor || copyColor(state.base); + if (!next) return; + const tint = + property === 'outlineColor' || property === 'backgroundColor' + ? state.base + : sampler + ? sampler.tint(state.base, nextFactor, next) + : cyberSonarTintColor(state.base, nextFactor); + next.red = tint.red * (opaque ? nextFactor : 1); + next.green = tint.green * (opaque ? nextFactor : 1); + next.blue = tint.blue * (opaque ? nextFactor : 1); + // Keep models in their owner's opaque/translucent render pass. Dimming RGB + // avoids rebuilding model draw commands twice on every sector crossing. + next.alpha = state.base.alpha * (opaque ? 1 : nextFactor); + visual[property] = next; + state.appliedColor = visual[property]; + state.lastAppliedColor = next; + state.lastAppliedFactor = nextFactor; + state.floor = sampler?.floor; +} + +/** One settings read and one clock/viewport calculation per paint, not contact. */ +export function createCyberSonarSampler( + width, + height, + now, + root = globalThis.document?.documentElement, +) { + if (now === undefined) now = globalThis.performance?.now?.() ?? 0; + const settings = readCyberSonarSettings(root); + const floor = settings.opacity / 100; + const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100; + const radiusSquared = radius * radius; + const angle = cyberSonarAngleDeg(now); + const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42); + return { + floor, + neutral: floor + (1 - floor) * 0.46, + at(x, y) { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + width <= 0 || + height <= 0 + ) + return 1; + const dx = x - width / 2, + dy = y - height / 2; + const distanceSquared = dx * dx + dy * dy; + if (distanceSquared > radiusSquared) return floor; + if (distanceSquared < radiusSquared * 0.000625) return 1; + const delta = + ((((Math.atan2(dx, -dy) * 180) / Math.PI - angle) % 360) + 360) % 360; + if (delta > settings.sector) return floor; + return ( + floor + (1 - floor) * Math.min(1, (settings.sector - delta) / feather) + ); + }, + label(factor) { + return (1 + factor) * 0.5; + }, + tint(base, factor, out) { + const coverage = + floor >= 0.9999 ? 1 : clamp01((factor - floor) / (1 - floor)); + out.red = base.red * 0.28 + (0.62 + 0.38 * coverage) * 0.72; + out.green = base.green * 0.28 + (0.68 + 0.16 * coverage) * 0.72; + out.blue = base.blue * 0.28 + (0.72 + 0.12 * coverage) * 0.72; + return out; + }, + }; +} + +/** Remaining delay before the next contact-projection refresh is due. */ +export function cyberSonarFrameDelay( + lastPaintAt, + nowMs, + intervalMs = CYBER_SONAR_RENDER_INTERVAL_MS, +) { + if ( + !Number.isFinite(lastPaintAt) || + !Number.isFinite(nowMs) || + !Number.isFinite(intervalMs) || + intervalMs <= 0 + ) { + return 0; + } + return Math.max(0, intervalMs - (nowMs - lastPaintAt)); +} + +const datasetNumber = (root, key, fallback) => { + const value = Number(root?.dataset?.[key]); + return Number.isFinite(value) ? value : fallback; +}; + +export function readCyberSonarSettings( + root = globalThis.document?.documentElement, +) { + return { + rings: Math.round( + clamp( + datasetNumber(root, 'cyberSonarRings', CYBER_SONAR_DEFAULTS.rings), + 3, + 12, + ), + ), + range: Math.round( + clamp( + datasetNumber(root, 'cyberSonarRange', CYBER_SONAR_DEFAULTS.range), + 60, + 120, + ), + ), + intensity: Math.round( + clamp( + datasetNumber( + root, + 'cyberSonarIntensity', + CYBER_SONAR_DEFAULTS.intensity, + ), + 0, + 100, + ), + ), + opacity: Math.round( + clamp( + datasetNumber(root, 'cyberSonarOpacity', CYBER_SONAR_DEFAULTS.opacity), + 35, + 100, + ), + ), + sector: Math.round( + clamp( + datasetNumber(root, 'cyberSonarSector', CYBER_SONAR_DEFAULTS.sector), + 8, + 60, + ), + ), + }; +} + +export function applyCyberSonarSettings( + settings, + root = globalThis.document?.documentElement, +) { + if (!root?.dataset || !root?.style) return readCyberSonarSettings(root); + const next = { + ...readCyberSonarSettings(root), + ...(settings || {}), + }; + // Ignore stale session input from the removed effect selector. + delete next.mode; + delete root.dataset.cyberSonarMode; + next.rings = Math.round( + clamp(Number(next.rings) || CYBER_SONAR_DEFAULTS.rings, 3, 12), + ); + next.range = Math.round( + clamp(Number(next.range) || CYBER_SONAR_DEFAULTS.range, 60, 120), + ); + next.intensity = Math.round( + clamp( + Number.isFinite(Number(next.intensity)) + ? Number(next.intensity) + : CYBER_SONAR_DEFAULTS.intensity, + 0, + 100, + ), + ); + next.opacity = Math.round( + clamp( + Number.isFinite(Number(next.opacity)) + ? Number(next.opacity) + : CYBER_SONAR_DEFAULTS.opacity, + 35, + 100, + ), + ); + next.sector = Math.round( + clamp(Number(next.sector) || CYBER_SONAR_DEFAULTS.sector, 8, 60), + ); + root.dataset.cyberSonarRings = String(next.rings); + root.dataset.cyberSonarRange = String(next.range); + root.dataset.cyberSonarIntensity = String(next.intensity); + root.dataset.cyberSonarOpacity = String(next.opacity); + root.dataset.cyberSonarSector = String(next.sector); + root.style.setProperty('--cyber-sonar-ring-step', `${100 / next.rings}%`); + root.style.setProperty( + '--cyber-sonar-size-vw', + `${(80 * next.range) / 100}vw`, + ); + root.style.setProperty( + '--cyber-sonar-size-vh', + `${(104 * next.range) / 100}vh`, + ); + root.style.setProperty( + '--cyber-sonar-visual-intensity', + String(0.22 + 0.5 * (next.intensity / 100)), + ); + root.style.setProperty( + '--cyber-sonar-sector-quarter', + `${next.sector * 0.25}deg`, + ); + root.style.setProperty( + '--cyber-sonar-sector-middle', + `${next.sector * 0.6}deg`, + ); + root.style.setProperty('--cyber-sonar-sector-width', `${next.sector}deg`); + return next; +} + +const cyberSonarDimFactor = (root = globalThis.document?.documentElement) => + readCyberSonarSettings(root).opacity / 100; + +const cyberSonarLabelDimFactor = ( + root = globalThis.document?.documentElement, +) => { + const contactFloor = cyberSonarDimFactor(root); + return Number((contactFloor + (1 - contactFloor) * 0.5).toFixed(4)); +}; + +export function cyberSonarNeutralIntensity( + root = globalThis.document?.documentElement, +) { + const dimFactor = cyberSonarDimFactor(root); + return dimFactor + (1 - dimFactor) * 0.46; +} + +export function cyberSonarTintColor( + baseColor, + contactIntensity, + root = globalThis.document?.documentElement, +) { + const dimFactor = cyberSonarDimFactor(root); + const remainingRange = 1 - dimFactor; + const coverage = + remainingRange <= 0.0001 + ? 1 + : clamp01((contactIntensity - dimFactor) / remainingRange); + const target = { + red: 0.62 + 0.38 * coverage, + green: 0.68 + 0.16 * coverage, + blue: 0.72 + 0.12 * coverage, + }; + const retain = 0.28; + return { + red: (baseColor?.red ?? 1) * retain + target.red * (1 - retain), + green: (baseColor?.green ?? 1) * retain + target.green * (1 - retain), + blue: (baseColor?.blue ?? 1) * retain + target.blue * (1 - retain), + alpha: baseColor?.alpha ?? 1, + }; +} + +const normalizedPhaseMs = (nowMs) => { + if (!Number.isFinite(nowMs)) return 0; + return ( + ((nowMs % CYBER_SONAR_PERIOD_MS) + CYBER_SONAR_PERIOD_MS) % + CYBER_SONAR_PERIOD_MS + ); +}; + +/** Clockwise sweep heading, where 0 degrees is the top of the keyhole. */ +export function cyberSonarAngleDeg(nowMs) { + return (normalizedPhaseMs(nowMs) / CYBER_SONAR_PERIOD_MS) * 360; +} + +/** Negative CSS animation delay that synchronizes the sector to the shared clock. */ +export function cyberSonarAnimationDelay(nowMs) { + return `${-normalizedPhaseMs(nowMs)}ms`; +} + +/** + * Brightness/alpha multiplier for one screen-projected contact. + * Invalid projections fail open so a render edge case never hides a contact. + */ +export function cyberSonarIntensityAtScreenPoint( + x, + y, + viewportWidth, + viewportHeight, + nowMs, + root = globalThis.document?.documentElement, +) { + if ( + ![x, y, viewportWidth, viewportHeight, nowMs].every(Number.isFinite) || + viewportWidth <= 0 || + viewportHeight <= 0 + ) { + return 1; + } + + const centerX = viewportWidth / 2; + const centerY = viewportHeight / 2; + const dx = x - centerX; + const dy = y - centerY; + const settings = readCyberSonarSettings(root); + const dimFactor = cyberSonarDimFactor(root); + const radius = + Math.min(viewportWidth * 0.4, viewportHeight * 0.52) * + (settings.range / 100); + const distance = Math.hypot(dx, dy); + + if (distance > radius) return dimFactor; + if (distance < radius * 0.025) return 1; + + const pointAngle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360; + const delta = (pointAngle - cyberSonarAngleDeg(nowMs) + 360) % 360; + const sweepDeg = settings.sector; + const featherDeg = Math.min(CYBER_SONAR_FEATHER_DEG, sweepDeg * 0.42); + if (delta > sweepDeg) return dimFactor; + + const featherStart = sweepDeg - featherDeg; + const coverage = + delta <= featherStart ? 1 : clamp01((sweepDeg - delta) / featherDeg); + return dimFactor + (1 - dimFactor) * coverage; +} + +/** Preserve label legibility while keeping it synchronized with the stronger contact sweep. */ +export function cyberSonarLabelIntensity( + contactIntensity, + root = globalThis.document?.documentElement, +) { + const dimFactor = cyberSonarDimFactor(root); + const labelDimFactor = cyberSonarLabelDimFactor(root); + const remainingRange = 1 - dimFactor; + if (remainingRange <= 0.0001) return 1; + const normalized = clamp01((contactIntensity - dimFactor) / remainingRange); + return labelDimFactor + (1 - labelDimFactor) * normalized; +} + +export function isCyberSonarEnabled( + root = globalThis.document?.documentElement, +) { + return root?.dataset?.cyberSonar !== 'off'; +} + +export function setCyberSonarEnabled( + enabled, + root = globalThis.document?.documentElement, +) { + if (!root?.dataset) return false; + root.dataset.cyberSonar = enabled ? 'on' : 'off'; + if (root.dataset.uiTheme === 'cyber') { + applyCyberSonarThemePhase(root, enabled); + } + return enabled; +} + +/** Whether the map-mode sweep should alter flight presentation. */ +export function isCyberSonarActive({ + root = globalThis.document?.documentElement, + body = globalThis.document?.body, + hud, + reducedMotion, +} = {}) { + // Keep optional method calls in the body: production downleveling of nested + // parameter initializers can otherwise lose the method's receiver binding. + if (hud === undefined) + hud = globalThis.document?.getElementById?.('intel-hud'); + if (reducedMotion === undefined) + reducedMotion = + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? + false; + return !!( + root?.dataset?.uiTheme === 'cyber' && + isCyberSonarEnabled(root) && + hud?.classList?.contains?.('active') && + !body?.classList?.contains?.('cockpit-mode') && + !reducedMotion + ); +} + +export function isCyberMapThemeActive({ + root = globalThis.document?.documentElement, + body = globalThis.document?.body, + hud, +} = {}) { + if (hud === undefined) + hud = globalThis.document?.getElementById?.('intel-hud'); + return !!( + root?.dataset?.uiTheme === 'cyber' && + hud?.classList?.contains?.('active') && + !body?.classList?.contains?.('cockpit-mode') + ); +} + +// Unsupported engines retain their native contact rendering, without a dimmer +// fallback. Decorative rings remain independent from the contact adapter. +export function isCyberContactThemeActive(options = {}) { + const root = options.root ?? globalThis.document?.documentElement; + return ( + isCyberMapThemeActive(options) && + root?.dataset?.cyberSonarGpu !== 'unsupported' + ); +} + +export function isCyberContactSonarActive(options = {}) { + return isCyberContactThemeActive(options) && isCyberSonarActive(options); +} + +/** Set or clear the shared visual phase used by the sector and contact sweep. */ +export function applyCyberSonarThemePhase(root, enabled, nowMs) { + if (!root?.style) return; + if (!enabled || !isCyberSonarEnabled(root)) { + root.style.removeProperty?.('--cyber-sonar-angle'); + root.style.removeProperty?.('--cyber-sonar-phase-delay'); + return; + } + const clock = Number.isFinite(nowMs) + ? nowMs + : (globalThis.performance?.now?.() ?? 0); + // The visual sweep is compositor-animated. Writing a custom property on the + // document root every frame invalidates styles across the entire HUD, so JS + // only seeds the shared clock when the theme or sonar state changes. + root.style.removeProperty?.('--cyber-sonar-angle'); + const currentDelay = root.style + .getPropertyValue?.('--cyber-sonar-phase-delay') + ?.trim(); + // setVariant() may be called repeatedly with the already-active layout. + // Preserve the running animation's original timeline in that case; changing + // its negative delay after it has started would shift the sector off the + // absolute clock used by contact highlighting. + if (currentDelay) return; + root.style.setProperty?.( + '--cyber-sonar-phase-delay', + cyberSonarAnimationDelay(clock), + ); +} diff --git a/src/cyberSonar.test.mjs b/src/cyberSonar.test.mjs new file mode 100644 index 000000000..50d0338ca --- /dev/null +++ b/src/cyberSonar.test.mjs @@ -0,0 +1,370 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { transformWithEsbuild } from 'vite'; +import { BillboardCollection, Color, PointPrimitive } from 'cesium'; +import { + syncCyberSonarAnimation, + applyCyberSonarPrimitive, + cyberSonarBaseAlpha, + applyCyberSonarThemePhase, + cyberSonarAngleDeg, + cyberSonarAnimationDelay, + cyberSonarFrameDelay, + cyberSonarIntensityAtScreenPoint, + cyberSonarLabelIntensity, + cyberSonarTintColor, + applyCyberSonarSettings, + CYBER_SONAR_DIM_FACTOR, + CYBER_SONAR_LABEL_DIM_FACTOR, + CYBER_SONAR_PERIOD_MS, + CYBER_SONAR_RENDER_INTERVAL_MS, + isCyberSonarEnabled, + isCyberSonarActive, + setCyberSonarEnabled, +} from './cyberSonar.js'; + +test('production-transformed sonar defaults retain the document method receiver', async () => { + const source = await readFile( + new URL('./cyberSonar.js', import.meta.url), + 'utf8', + ); + const { code } = await transformWithEsbuild(source, 'cyberSonar.js', { + target: 'es2019', + minify: true, + }); + const compiled = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + ); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const hud = { classList: { contains: (name) => name === 'active' } }; + const document = { + documentElement: { dataset: { uiTheme: 'cyber' } }, + body: { classList: { contains: () => false } }, + getElementById(id) { + assert.equal(this, document); + assert.equal(id, 'intel-hud'); + return hud; + }, + }; + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: document, + }); + try { + assert.equal(compiled.isCyberSonarActive(), true); + assert.equal(compiled.isCyberMapThemeActive(), true); + assert.ok( + Number.isFinite(compiled.createCyberSonarSampler(800, 600).at(400, 300)), + ); + document.documentElement.dataset.uiTheme = 'tactical'; + assert.equal(compiled.isCyberSonarActive(), false); + assert.equal(compiled.isCyberMapThemeActive(), false); + } finally { + if (previous) Object.defineProperty(globalThis, 'document', previous); + else delete globalThis.document; + } +}); + +test('hue-only aircraft restyles retain owner alpha across sonar opacity changes', () => { + const collection = new BillboardCollection(); + const visual = collection.add({ color: Color.WHITE.withAlpha(0.8) }); + try { + for (const opacity of [0.84, 0.35, 0.81, 1, 0.84]) { + for (let i = 0; i < 8; i++) { + visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual)); + applyCyberSonarPrimitive(visual, opacity); + assert.equal(visual.color.alpha, 0.8 * opacity); + } + } + // A true owner freshness update must not be mistaken for our old alpha. + visual.color = Color.YELLOW.withAlpha(0.4); + assert.equal(cyberSonarBaseAlpha(visual), 0.4); + applyCyberSonarPrimitive(visual, 0.35); + visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual)); + applyCyberSonarPrimitive(visual, 0.84); + applyCyberSonarPrimitive(visual, 1, false); + assert.ok(Color.equals(visual.color, Color.CYAN.withAlpha(0.4))); + } finally { + collection.destroy(); + } +}); + +test('the Cyber sonar clock advances clockwise and wraps cleanly', () => { + assert.equal(cyberSonarAngleDeg(0), 0); + assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS / 4), 90); + assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS), 0); + assert.equal(cyberSonarAnimationDelay(CYBER_SONAR_PERIOD_MS / 2), '-2600ms'); +}); + +test('contact refresh cadence waits only until the next bounded sonar frame', () => { + assert.equal(CYBER_SONAR_RENDER_INTERVAL_MS, 80); + assert.equal(cyberSonarFrameDelay(1_000, 1_000), 80); + assert.equal(cyberSonarFrameDelay(1_000, 1_035), 45); + assert.equal(cyberSonarFrameDelay(1_000, 1_080), 0); + assert.equal(cyberSonarFrameDelay(Number.NEGATIVE_INFINITY, 1_000), 0); +}); + +test('the sweep brightens contacts inside its sector and dims the remainder', () => { + const width = 1_000; + const height = 800; + const centerX = width / 2; + const centerY = height / 2; + + assert.equal( + cyberSonarIntensityAtScreenPoint(centerX, centerY - 200, width, height, 0), + 1, + ); + assert.equal( + cyberSonarIntensityAtScreenPoint(centerX + 200, centerY, width, height, 0), + CYBER_SONAR_DIM_FACTOR, + ); + assert.equal( + cyberSonarIntensityAtScreenPoint( + centerX + 200, + centerY, + width, + height, + CYBER_SONAR_PERIOD_MS / 4, + ), + 1, + ); + assert.equal( + cyberSonarIntensityAtScreenPoint(centerX, centerY - 401, width, height, 0), + CYBER_SONAR_DIM_FACTOR, + ); + assert.equal( + cyberSonarIntensityAtScreenPoint(Number.NaN, 0, width, height, 0), + 1, + ); +}); + +test('the sonar activates only for a visible Cyber map HUD with motion enabled', () => { + const root = { dataset: { uiTheme: 'cyber' } }; + const classes = (...values) => ({ + contains: (value) => values.includes(value), + }); + assert.equal( + isCyberSonarActive({ + root, + body: { classList: classes() }, + hud: { classList: classes('active') }, + reducedMotion: false, + }), + true, + ); + assert.equal( + isCyberSonarActive({ + root, + body: { classList: classes('cockpit-mode') }, + hud: { classList: classes('active') }, + reducedMotion: false, + }), + false, + ); + assert.equal( + isCyberSonarActive({ + root, + body: { classList: classes() }, + hud: { classList: classes('active') }, + reducedMotion: true, + }), + false, + ); + root.dataset.cyberSonar = 'off'; + assert.equal( + isCyberSonarActive({ + root, + body: { classList: classes() }, + hud: { classList: classes('active') }, + reducedMotion: false, + }), + false, + ); +}); + +test('labels keep a more readable baseline while tracking the same sweep', () => { + assert.equal(CYBER_SONAR_DIM_FACTOR, 0.84); + assert.equal(CYBER_SONAR_LABEL_DIM_FACTOR, 0.92); + assert.equal( + cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR), + CYBER_SONAR_LABEL_DIM_FACTOR, + ); + assert.equal(cyberSonarLabelIntensity(1), 1); + assert.ok( + cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR) > CYBER_SONAR_DIM_FACTOR, + ); +}); + +test('the opacity setting controls every contact floor and preserves brighter labels', () => { + const values = new Map(); + const root = { + dataset: {}, + style: { setProperty: (name, value) => values.set(name, value) }, + }; + const settings = applyCyberSonarSettings({ opacity: 62 }, root); + assert.equal(settings.opacity, 62); + assert.equal(root.dataset.cyberSonarOpacity, '62'); + assert.equal( + cyberSonarIntensityAtScreenPoint(900, 400, 1_000, 800, 0, root), + 0.62, + ); + assert.equal(cyberSonarLabelIntensity(0.62, root), 0.81); + assert.equal(cyberSonarLabelIntensity(1, root), 1); + + applyCyberSonarSettings({ opacity: 100 }, root); + assert.equal(cyberSonarLabelIntensity(1, root), 1); + assert.deepEqual( + cyberSonarTintColor({ red: 1, green: 1, blue: 1, alpha: 1 }, 1, root), + { red: 1, green: 0.8848, blue: 0.8848, alpha: 1 }, + ); +}); + +test('the sonar control defaults on and records explicit operator state', () => { + const root = { dataset: {} }; + assert.equal(isCyberSonarEnabled(root), true); + assert.equal(setCyberSonarEnabled(false, root), false); + assert.equal(root.dataset.cyberSonar, 'off'); + assert.equal(isCyberSonarEnabled(root), false); + assert.equal(setCyberSonarEnabled(true, root), true); + assert.equal(root.dataset.cyberSonar, 'on'); +}); + +test('real Cesium points and billboards recover after repeated low/high opacity cycles', () => { + const collection = new BillboardCollection(); + const base = new Color(0.7, 0.5, 0.25, 0.8); + const visuals = [ + new PointPrimitive({ color: base }), + collection.add({ color: base }), + ]; + try { + for (const visual of visuals) { + for (const factor of [0.35, 1, 0.5, 1, 0.35]) { + for (let frame = 0; frame < 60; frame++) { + applyCyberSonarPrimitive(visual, factor); + } + assert.equal(visual.color.alpha, base.alpha * factor); + } + // Owner setters mutate the same Cesium Color object, even when the new + // alpha equals the last sonar output. Its new RGB must become the base. + const refreshed = new Color(0.2, 0.8, 0.4, visual.color.alpha); + visual.color = refreshed; + applyCyberSonarPrimitive(visual, 0.5); + assert.equal(visual.color.alpha, refreshed.alpha * 0.5); + applyCyberSonarPrimitive(visual, 1, false); + assert.ok(Color.equals(visual.color, refreshed)); + } + } finally { + collection.destroy(); + } +}); + +test('generic Cesium primitives dim, accept owner style refreshes, and restore', () => { + const makeColor = (alpha, red = 1) => ({ + red, + green: 0.5, + blue: 0.25, + alpha, + clone() { + return makeColor(this.alpha, this.red); + }, + }); + const visual = { color: makeColor(0.8) }; + applyCyberSonarPrimitive(visual, 0.25, true); + assert.equal(visual.color.alpha, 0.2); + const paintedColor = visual.color; + applyCyberSonarPrimitive(visual, 0.25, true); + assert.equal(visual.color, paintedColor); + visual.color = makeColor(0.6, 0.7); + applyCyberSonarPrimitive(visual, 0.5, true); + assert.equal(visual.color.alpha, 0.3); + assert.notEqual(visual.color.red, 0.7); + applyCyberSonarPrimitive(visual, 1, false); + assert.equal(visual.color.alpha, 0.6); + assert.equal(visual.color.red, 0.7); +}); + +test('Cyber contact tint moves from cool-muted to warm-bright with the sector', () => { + const root = { dataset: { cyberSonarIntensity: '70' } }; + const base = { red: 0.2, green: 0.8, blue: 1, alpha: 0.75 }; + const dim = cyberSonarTintColor(base, CYBER_SONAR_DIM_FACTOR, root); + const bright = cyberSonarTintColor(base, 1, root); + assert.ok(bright.red > dim.red); + assert.ok(bright.green > dim.green); + assert.equal(bright.alpha, 0.75); +}); + +test('unswept white map symbols retain a bright readable Cyber tint', () => { + const root = { dataset: { cyberSonarIntensity: '70' } }; + const dim = cyberSonarTintColor( + { red: 1, green: 1, blue: 1, alpha: 1 }, + CYBER_SONAR_DIM_FACTOR, + root, + ); + assert.ok(dim.red >= 0.72); + assert.ok(dim.green >= 0.76); + assert.ok(dim.blue >= 0.79); + assert.equal(dim.alpha, 1); +}); + +test('the sweep corrects delayed starts and reacquires restarted animations without per-frame writes', () => { + let queries = 0, + writes = 0; + const makeAnimation = () => ({ + animationName: 'cyber-sonar-sweep', + playState: 'running', + startTime: 1800, + effect: { + updateTiming(value) { + assert.deepEqual(value, { delay: 0 }); + writes++; + }, + }, + }); + let animation = makeAnimation(); + let element = { + isConnected: true, + getAnimations() { + queries++; + return [animation]; + }, + }; + const root = { querySelector: () => element }; + syncCyberSonarAnimation(root); + assert.equal(animation.startTime, 0); + for (let i = 0; i < 100; i++) syncCyberSonarAnimation(root); + assert.equal(queries, 1); + assert.equal(writes, 1); + animation.playState = 'idle'; + animation = makeAnimation(); + syncCyberSonarAnimation(root); + assert.equal(animation.startTime, 0); + assert.equal(writes, 2); + element.isConnected = false; + animation = makeAnimation(); + element = { isConnected: true, getAnimations: () => [animation] }; + syncCyberSonarAnimation(root); + assert.equal(animation.startTime, 0); + assert.equal(writes, 3); + assert.doesNotThrow(() => syncCyberSonarAnimation({})); +}); + +test('the selected Cyber theme seeds and releases the compositor sweep phase', () => { + const values = new Map(); + const root = { + dataset: {}, + style: { + setProperty: (name, value) => values.set(name, value), + removeProperty: (name) => values.delete(name), + getPropertyValue: (name) => values.get(name) || '', + }, + }; + applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 4); + assert.equal(values.has('--cyber-sonar-angle'), false); + assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms'); + applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 2); + assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms'); + applyCyberSonarThemePhase(root, false, 0); + assert.equal(values.has('--cyber-sonar-angle'), false); + assert.equal(values.has('--cyber-sonar-phase-delay'), false); +}); diff --git a/src/cyberSonarGpu.js b/src/cyberSonarGpu.js new file mode 100644 index 000000000..bb4aff4d6 --- /dev/null +++ b/src/cyberSonarGpu.js @@ -0,0 +1,295 @@ +import * as Cesium from 'cesium'; + +// Narrow, version-tested engine adapter. Cesium has no public custom shader +// option for native point/billboard collections. Do not patch its prototypes or +// access EntityCluster's private collections. An engine upgrade must revalidate +// this adapter; unsupported versions keep the original commands unchanged. +export const CYBER_SONAR_CESIUM_VERSION = '1.138.0'; + +// Follow every public DrawCommand setter, including pick/shadow/culling flags. +// Copying native.dirty would keep derived pipelines dirty forever: the scene +// consumes our command, so it never clears that flag on the native command. +const commandProperties = Object.entries( + Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype), +) + .filter( + ([key, descriptor]) => + descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap', + ) + .map(([key]) => key); + +export function syncCyberSonarCommand( + native, + record, + program, + uniforms, + labelBackground, +) { + if (!record.command) { + record.command = new Cesium.DrawCommand(); + record.uniformMap = { + ...uniforms, + u_gevSonarLabelBackground: () => record.labelBackground, + }; + } + const command = record.command; + record.labelBackground = labelBackground; + // Uniform values (angle/viewport/etc.) change through stable callbacks, not + // new maps/functions. Owner callback replacement and removal still propagate. + for (const key in record.uniformMap) { + if ( + !(key in uniforms) && + key !== 'u_gevSonarLabelBackground' && + !(key in native.uniformMap) + ) { + delete record.uniformMap[key]; + command.dirty = true; + } + } + for (const key in native.uniformMap) { + if (record.uniformMap[key] !== native.uniformMap[key]) { + record.uniformMap[key] = native.uniformMap[key]; + command.dirty = true; + } + } + for (const key of commandProperties) command[key] = native[key]; + command.shaderProgram = program; + command.uniformMap = record.uniformMap; + return command; +} + +export function sonarVertexSource(source, kind) { + const text = source.sources.join('\n'); + const attribute = + kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale'; + const lowAttribute = + kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation'; + if ( + !text.includes(attribute) || + !text.includes(lowAttribute) || + !text.includes('v_color') || + !/void\s+main\s*\(\s*\)/.test(text) + ) + return null; + const next = source.clone(); + next.sources = [ + text.replace(/void\s+main\s*\(\s*\)/, 'void gev_sonar_main()'), + ` +uniform vec4 u_gevSonar; // angle, radius, opacity floor, sweep enabled +uniform vec2 u_gevSonarViewport; +uniform float u_gevSonarSector; +uniform float u_gevSonarLabelBackground; +void main() { + gev_sonar_main(); + // All corners/glyphs share the contact anchor, before native screen offsets. + // Leave gl_Position untouched so appearance never changes geometry or picks. + vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz); + if (anchor.w <= 0.0) return; + vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5; + float floorValue = u_gevSonar.z; + float factor = floorValue + (1.0 - floorValue) * 0.46; + if (u_gevSonar.w > 0.5) { + float distanceSquared = dot(pixel, pixel); + float radiusSquared = u_gevSonar.y * u_gevSonar.y; + float delta = mod(degrees(atan(pixel.x, pixel.y)) - u_gevSonar.x + 360.0, 360.0); + if (delta > 359.9999) delta = 0.0; + factor = floorValue; + if (distanceSquared < radiusSquared * 0.000625) factor = 1.0; + else if (distanceSquared <= radiusSquared && delta <= u_gevSonarSector) + factor += (1.0 - floorValue) * min(1.0, (u_gevSonarSector - delta) / min(10.0, u_gevSonarSector * 0.42)); + } + #ifdef SDF + factor = (1.0 + factor) * 0.5; + #else + if (u_gevSonarLabelBackground > 0.5) factor = (1.0 + factor) * 0.5; + #endif + float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0); + if (u_gevSonarLabelBackground < 0.5) + v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72; + // Apply before native fragment pass classification, never after main(). + v_color.a *= factor; + ${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'} +} +`, + ]; + return next; +} + +// Use native batch bounds, never individual contacts, for idle render demand. +// Like Cesium's frustum builder, exclude the far plane (it fits multiple frusta). +export function isCyberSonarCommandVisible(command, frameState, culling) { + const bounds = command.boundingVolume; + if (!bounds || !command.cull) return true; + culling ??= new Cesium.CullingVolume( + frameState.cullingVolume.planes.slice(0, 5), + ); + if (culling.computeVisibility(bounds) === Cesium.Intersect.OUTSIDE) + return false; + return !( + frameState.mode === Cesium.SceneMode.SCENE3D && + frameState.occluder && + command.occlude && + bounds.isOccluded(frameState.occluder) + ); +} + +/** Work scales with draw commands, not the number of contacts in a collection. + * Source programs, colors, pick IDs, render states and other scenes stay owned + * by Cesium. Only this scene's rendered command slice receives derived shaders. + */ +export function createCyberSonarGpu(scene, readFrame) { + const root = scene.primitives; + const original = root.update; + const programs = new Map(); + let commands = new WeakMap(); + let disposed = false; + const stats = { + commands: 0, + visibleCommands: 0, + programs: 0, + supported: Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION, + error: null, + }; + const uniforms = new Cesium.Cartesian4(); + const viewport = new Cesium.Cartesian2(); + let sector = 24; + const sonarUniforms = { + u_gevSonar: () => uniforms, + u_gevSonarViewport: () => viewport, + u_gevSonarSector: () => sector, + }; + const culling = new Cesium.CullingVolume(); + const used = new Set(); + const replaced = []; + const release = () => { + for (const { program } of programs.values()) + if (!program.isDestroyed()) program.destroy(); + programs.clear(); + commands = new WeakMap(); + used.clear(); + replaced.length = 0; + stats.programs = 0; + }; + const wrapped = function (frameState) { + const start = frameState.commandList.length; + original.call(this, frameState); + stats.commands = 0; + stats.visibleCommands = 0; + if ( + disposed || + !stats.supported || + !frameState.passes.render || + frameState.passes.pick + ) + return; + const frame = readFrame(); + if (!frame.enabled) { + if (programs.size) release(); + return; + } + viewport.x = scene.drawingBufferWidth; + viewport.y = scene.drawingBufferHeight; + uniforms.x = frame.angle; + uniforms.y = + (Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100; + uniforms.z = frame.opacity / 100; + uniforms.w = frame.active ? 1 : 0; + sector = frame.sector; + used.clear(); + replaced.length = 0; + if (frameState.cullingVolume) + for (let i = 0; i < 5; i++) + culling.planes[i] = frameState.cullingVolume.planes[i]; + try { + for (let i = start; i < frameState.commandList.length; i++) { + const native = frameState.commandList[i]; + const kind = + native.owner instanceof Cesium.PointPrimitiveCollection + ? 'point' + : native.owner instanceof Cesium.BillboardCollection + ? 'billboard' + : null; + if (!kind || !native.shaderProgram || !native.uniformMap) continue; + const source = native.shaderProgram; + // Label backgrounds are non-SDF billboard batches. The version-tested + // engine pick association distinguishes them without enumerating labels + // or reaching into EntityCluster. One representative per draw batch; + // Cesium's label glyph/background collections are homogeneous. + const labelBackground = + kind === 'billboard' && + !source.vertexShaderSource.defines.includes('SDF') && + native.owner.length > 0 && + native.owner.get(0).pickPrimitive instanceof Cesium.Label + ? 1 + : 0; + let entry = programs.get(source); + if (!entry) { + const vertex = sonarVertexSource(source.vertexShaderSource, kind); + if (!vertex) throw new Error('Unsupported native contact shader'); + const attributes = Object.fromEntries( + Object.entries(source.vertexAttributes).map(([name, value]) => [ + name, + value.index, + ]), + ); + const program = Cesium.ShaderProgram.fromCache({ + context: frameState.context, + vertexShaderSource: vertex, + fragmentShaderSource: source.fragmentShaderSource, + attributeLocations: attributes, + }); + // Compile while errors can still leave the whole native command slice + // intact, rather than stopping the viewer's render loop at draw time. + try { + void program.allUniforms; + } catch (error) { + program.destroy(); + throw error; + } + programs.set(source, (entry = { program })); + } + used.add(source); + let record = commands.get(native); + if (!record) commands.set(native, (record = {})); + const derived = syncCyberSonarCommand( + native, + record, + entry.program, + sonarUniforms, + labelBackground, + ); + replaced.push(i, native); + frameState.commandList[i] = derived; + stats.commands++; + if (isCyberSonarCommandVisible(native, frameState, culling)) + stats.visibleCommands++; + } + for (const [source, entry] of programs) + if (!used.has(source)) { + if (!entry.program.isDestroyed()) entry.program.destroy(); + programs.delete(source); + } + stats.programs = programs.size; + } catch (error) { + // Recover the original commands already replaced in this slice. + for (let i = 0; i < replaced.length; i += 2) + frameState.commandList[replaced[i]] = replaced[i + 1]; + stats.supported = false; + stats.commands = 0; + stats.visibleCommands = 0; + stats.error = String(error?.message || error); + release(); + } + }; + root.update = wrapped; + return { + stats, + destroy() { + if (disposed) return; + disposed = true; + if (root.update === wrapped && !root.isDestroyed?.()) + root.update = original; + release(); + }, + }; +} diff --git a/src/cyberSonarGpu.test.mjs b/src/cyberSonarGpu.test.mjs new file mode 100644 index 000000000..8507620ba --- /dev/null +++ b/src/cyberSonarGpu.test.mjs @@ -0,0 +1,354 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + createCyberSonarGpu, + sonarVertexSource, + CYBER_SONAR_CESIUM_VERSION, + isCyberSonarCommandVisible, + syncCyberSonarCommand, +} from './cyberSonarGpu.js'; + +test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => { + const native = new Cesium.DrawCommand({ + count: 6, + uniformMap: { value: () => 1 }, + }); + const record = {}, + program = {}, + uniforms = { u_gevSonar: () => 42 }; + const derived = syncCyberSonarCommand(native, record, program, uniforms, 0); + const map = derived.uniformMap; + derived.dirty = false; + derived.lastDirtyTime = 7; + assert.equal(native.dirty, true); + assert.equal( + syncCyberSonarCommand(native, record, program, uniforms, 1), + derived, + ); + assert.equal( + derived.dirty, + false, + 'stale native dirty flag must not rebuild pipelines', + ); + assert.equal(derived.lastDirtyTime, 7); + assert.equal(derived.uniformMap, map); + assert.equal(map.u_gevSonarLabelBackground(), 1); + for (const [key, value] of Object.entries({ + count: 12, + offset: 2, + instanceCount: 3, + pickId: 'pick', + pass: Cesium.Pass.TRANSLUCENT, + cull: false, + occlude: false, + castShadows: true, + receiveShadows: true, + pickOnly: true, + renderState: {}, + vertexArray: {}, + modelMatrix: Cesium.Matrix4.IDENTITY, + framebuffer: {}, + })) { + derived.dirty = false; + native[key] = value; + syncCyberSonarCommand(native, record, program, uniforms, 0); + assert.equal(derived[key], value, key); + assert.equal(derived.dirty, true, key); + } + native.uniformMap.value = () => 2; + syncCyberSonarCommand(native, record, program, uniforms, 0); + assert.equal(map.value(), 2); + native.uniformMap = { replacement: () => 3 }; + syncCyberSonarCommand(native, record, program, uniforms, 0); + assert.equal(map.value, undefined); + assert.equal(map.replacement(), 3); + assert.equal(map.u_gevSonar(), 42); + const nextProgram = {}; + syncCyberSonarCommand(native, record, nextProgram, uniforms, 0); + assert.equal(derived.shaderProgram, nextProgram); + assert.equal(native.shaderProgram, undefined); +}); + +test('GPU shader transform is isolated, settings-driven, and precedes native fragment classification', () => { + assert.equal( + Cesium.VERSION, + CYBER_SONAR_CESIUM_VERSION, + 'engine upgrades require GPU revalidation', + ); + for (const [kind, attr, low] of [ + ['point', 'positionHighAndSize', 'positionLowAndOutline'], + ['billboard', 'positionHighAndScale', 'positionLowAndRotation'], + ]) { + const source = new Cesium.ShaderSource({ + defines: ['SDF'], + sources: [ + `in vec4 ${attr}; in vec4 ${low}; out vec4 v_color; void main() { v_color=vec4(1.); }`, + ], + }); + const original = source.sources[0]; + const next = sonarVertexSource(source, kind); + assert.equal(source.sources[0], original); + assert.notEqual(next, source); + assert.deepEqual(next.defines, ['SDF']); + assert.match(next.sources[0], /void gev_sonar_main/); + assert.match(next.sources[1], /v_color.a \*= factor/); + assert.match(next.sources[1], /u_gevSonarSector/); + assert.match(next.sources[1], /floorValue \+ \(1.0 - floorValue\) \* 0.46/); + assert.ok( + next.sources[1].includes( + `czm_translateRelativeToEye(${attr}.xyz, ${low}.xyz)`, + ), + ); + assert.match(next.sources[1], /anchor.xy \/ anchor.w/); + assert.doesNotMatch(next.sources[1], /gl_Position\s*[=\/]/); + } + assert.equal( + sonarVertexSource( + new Cesium.ShaderSource({ sources: ['void main() {}'] }), + 'point', + ), + null, + ); +}); + +test('render demand follows batch bounds, including horizon culling, without far-plane rejection', () => { + const planes = Array.from( + { length: 6 }, + (_, i) => new Cesium.Cartesian4(1, 0, 0, i), + ); + const frame = { + cullingVolume: { planes }, + mode: Cesium.SceneMode.SCENE3D, + occluder: {}, + }; + let intersections = 0; + let occluded = false; + const command = { + cull: true, + occlude: true, + boundingVolume: { + intersectPlane(plane) { + intersections++; + return plane.distance === 5 + ? Cesium.Intersect.OUTSIDE + : Cesium.Intersect.INSIDE; + }, + isOccluded() { + return occluded; + }, + }, + }; + assert.equal(isCyberSonarCommandVisible(command, frame), true); + assert.equal(intersections, 5); + occluded = true; + assert.equal(isCyberSonarCommandVisible(command, frame), false); + frame.mode = Cesium.SceneMode.SCENE2D; + assert.equal(isCyberSonarCommandVisible(command, frame), true); + command.boundingVolume.intersectPlane = () => Cesium.Intersect.OUTSIDE; + assert.equal(isCyberSonarCommandVisible(command, frame), false); + command.cull = false; + assert.equal(isCyberSonarCommandVisible(command, frame), true); + assert.equal(isCyberSonarCommandVisible({}, frame), true); +}); + +test('adapter never walks contacts, touches other scenes, or transforms pick-only passes', () => { + let calls = 0; + const original = function (fs) { + assert.equal(this, root); + calls++; + fs.commandList.push({ owner: {}, id: 'terrain' }); + }; + const root = { update: original }; + const other = { update: original }; + const adapter = createCyberSonarGpu({ primitives: root }, () => ({ + enabled: false, + })); + const frame = { commandList: [], passes: { render: true, pick: false } }; + root.update(frame); + assert.equal(calls, 1); + assert.equal(frame.commandList[0].id, 'terrain'); + assert.equal(other.update, original); + root.update({ commandList: [], passes: { render: false, pick: true } }); + assert.equal(calls, 2); + adapter.destroy(); + adapter.destroy(); + assert.equal(root.update, original); +}); + +test('unknown native shaders fail closed without losing native commands', () => { + const points = new Cesium.PointPrimitiveCollection(); + const command = { + owner: points, + uniformMap: {}, + shaderProgram: { + vertexShaderSource: new Cesium.ShaderSource({ + sources: ['void main() {}'], + }), + }, + }; + const root = { + update(fs) { + fs.commandList.push(command); + }, + }; + const adapter = createCyberSonarGpu( + { primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 }, + () => ({ + enabled: true, + active: true, + angle: 0, + range: 100, + opacity: 84, + sector: 24, + }), + ); + const frame = { commandList: [], passes: { render: true } }; + root.update(frame); + assert.equal(frame.commandList[0], command); + assert.equal(adapter.stats.supported, false); + assert.match(adapter.stats.error, /Unsupported native contact shader/); + assert.equal(adapter.stats.programs, 0); + adapter.destroy(); + points.destroy(); +}); + +test('program swaps, layer removal and late shader failure release only adapter resources', () => { + const points = new Cesium.PointPrimitiveCollection(); + const source = () => ({ + vertexShaderSource: new Cesium.ShaderSource({ + sources: [ + 'in vec4 positionHighAndSize; in vec4 positionLowAndOutline; out vec4 v_color; void main() {}', + ], + }), + vertexAttributes: {}, + fragmentShaderSource: new Cesium.ShaderSource({ + sources: ['void main() {}'], + }), + }); + const native = new Cesium.DrawCommand({ + owner: points, + uniformMap: {}, + shaderProgram: source(), + cull: false, + vertexArray: { generation: 1 }, + pickId: 'native-pick', + }); + const unknown = new Cesium.DrawCommand({ + owner: points, + uniformMap: {}, + shaderProgram: { + vertexShaderSource: new Cesium.ShaderSource({ + sources: ['void main() {}'], + }), + }, + }); + let nativeCommands = [native]; + let enabled = true; + const root = { + update(frame) { + frame.commandList.push(...nativeCommands); + }, + }; + const fromCache = Cesium.ShaderProgram.fromCache; + const created = []; + Cesium.ShaderProgram.fromCache = () => { + const program = { + allUniforms: {}, + destroyed: false, + isDestroyed() { + return this.destroyed; + }, + destroy() { + assert.equal(this.destroyed, false); + this.destroyed = true; + }, + }; + created.push(program); + return program; + }; + const adapter = createCyberSonarGpu( + { primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 }, + () => ({ + enabled, + active: true, + angle: 0, + range: 100, + opacity: 84, + sector: 24, + }), + ); + const draw = () => { + const frame = { commandList: [], passes: { render: true } }; + root.update(frame); + return frame.commandList; + }; + try { + const first = draw()[0]; + assert.notEqual(first, native); + first.dirty = false; + assert.equal(draw()[0], first); + assert.equal(first.dirty, false); + assert.equal(created.length, 1, 'stable draws reuse the program'); + const buffer = { generation: 2 }; + native.vertexArray = buffer; + native.shaderProgram = source(); + const swapped = draw()[0]; + assert.equal(swapped, first); + assert.equal(swapped.vertexArray, buffer); + assert.equal(swapped.pickId, 'native-pick'); + assert.equal(swapped.shaderProgram, created[1]); + assert.equal(created[0].destroyed, true); + assert.equal(adapter.stats.programs, 1); + nativeCommands = []; + draw(); + assert.equal(created[1].destroyed, true); + assert.equal( + adapter.stats.programs, + 0, + 'disabled layers retain no adapter program', + ); + nativeCommands = [native]; + draw(); + enabled = false; + assert.equal(draw()[0], native); + assert.equal( + created[2].destroyed, + true, + 'theme/cockpit exit releases the derived program', + ); + enabled = true; + nativeCommands = [native, unknown]; + assert.deepEqual( + draw(), + nativeCommands, + 'a later failure rolls back earlier replacements in the same slice', + ); + assert.equal(created[3].destroyed, true); + assert.equal(adapter.stats.supported, false); + assert.equal(adapter.stats.programs, 0); + assert.equal(adapter.stats.commands, 0); + assert.equal(adapter.stats.visibleCommands, 0); + assert.deepEqual(draw(), nativeCommands); + assert.equal( + created.length, + 4, + 'unsupported draws do not retry compilation', + ); + } finally { + adapter.destroy(); + Cesium.ShaderProgram.fromCache = fromCache; + points.destroy(); + } +}); + +test('disposal does not overwrite a successor update owner', () => { + const root = { update() {} }; + const adapter = createCyberSonarGpu({ primitives: root }, () => ({ + enabled: false, + })); + const successor = () => {}; + root.update = successor; + adapter.destroy(); + assert.equal(root.update, successor); +}); diff --git a/src/cyberSonarScene.js b/src/cyberSonarScene.js new file mode 100644 index 000000000..f07159cbf --- /dev/null +++ b/src/cyberSonarScene.js @@ -0,0 +1,211 @@ +import * as Cesium from 'cesium'; +import { createCyberSonarGpu } from './cyberSonarGpu.js'; +import { + applyCyberSonarPrimitive, + createCyberSonarSampler, + isCyberContactThemeActive, + isCyberContactSonarActive, + isCyberSonarActive, + readCyberSonarSettings, + cyberSonarAngleDeg, + syncCyberSonarAnimation, + CYBER_SONAR_RENDER_INTERVAL_MS, +} from './cyberSonar.js'; + +const alive = (value) => value && !value.isDestroyed?.(); +const diagnostics = new WeakMap(); +export const getCyberSonarSceneDiagnostics = (viewer) => ({ + ...diagnostics.get(viewer), +}); + +export function visitCyberSonarModels(primitive, visit, visible = true) { + if (!alive(primitive)) return; + visible = visible && primitive.show !== false; + if (primitive instanceof Cesium.PrimitiveCollection) { + for (let i = 0; i < primitive.length; i++) + visitCyberSonarModels(primitive.get(i), visit, visible); + } else if (primitive instanceof Cesium.Model) { + visit(primitive, primitive, 'model', visible && primitive.ready); + } +} + +/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain + * their existing reversible CPU color treatment without walking native records. + */ +export function createCyberSonarScene(viewer, manager) { + const scene = viewer?.scene; + if ( + !scene?.postUpdate?.addEventListener || + !scene?.preRender?.addEventListener + ) + return () => {}; + const tracked = new Map(); + const matrix = new Cesium.Matrix4(); + const position = new Cesium.Cartesian3(); + let epoch = 0, + timer = null, + disposed = false; + let sampler, width, height, active, count; + const stats = { contacts: 0, updateMs: 0, frames: 0 }; + const root = globalThis.document?.documentElement; + const gpu = createCyberSonarGpu(scene, () => ({ + ...readCyberSonarSettings(), + enabled: isCyberContactThemeActive(), + active: isCyberContactSonarActive(), + angle: cyberSonarAngleDeg(performance.now()), + })); + stats.gpu = gpu.stats; + let gpuSupported = gpu.stats.supported; + diagnostics.set(viewer, stats); + const restore = (visual, state) => { + if (!alive(state.owner)) return; + for (const key of state.keys) + applyCyberSonarPrimitive(visual, 1, false, undefined, key); + }; + const visit = (visual, owner, kind, visible) => { + let state = tracked.get(visual); + if (!visible) { + if (state) { + restore(visual, state); + tracked.delete(visual); + } + return; + } + let p = visual.position; + if (kind === 'model') + p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position); + else if ( + p && + owner.modelMatrix && + !Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY) + ) + p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position); + if (!p) return; + const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15]; + const x = + (matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w; + const y = + (matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w; + if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return; + if (!state) { + state = { + owner, + keys: + kind === 'label' + ? ['fillColor', 'outlineColor', 'backgroundColor'] + : kind === 'marker' && visual.outlineColor + ? ['color', 'outlineColor'] + : ['color'], + epoch, + }; + tracked.set(visual, state); + } + state.epoch = epoch; + const factor = active + ? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5) + : sampler.neutral; + for (const key of state.keys) { + if (key === 'outlineColor' && visual.outlineWidth === 0) continue; + if (key === 'backgroundColor' && !visual.showBackground) continue; + applyCyberSonarPrimitive( + visual, + kind === 'label' ? sampler.label(factor) : factor, + true, + sampler, + key, + kind === 'model', + ); + } + count++; + }; + const update = () => { + if (disposed) return; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (!isCyberContactThemeActive()) { + for (const [visual, state] of tracked) restore(visual, state); + tracked.clear(); + stats.contacts = 0; + stats.updateMs = 0; + return; + } + const started = performance.now(); + width = scene.canvas.clientWidth; + height = scene.canvas.clientHeight; + sampler = createCyberSonarSampler(width, height, started); + active = isCyberContactSonarActive(); + Cesium.Matrix4.multiply( + scene.camera.frustum.projectionMatrix, + scene.camera.viewMatrix, + matrix, + ); + epoch++; + count = 0; + visitCyberSonarModels(scene.primitives, visit); + for (const [visual, state] of tracked) + if (state.epoch !== epoch) { + restore(visual, state); + tracked.delete(visual); + } + stats.contacts = count; + stats.updateMs = performance.now() - started; + stats.frames++; + // No contacts, no scanner-owned render demand. In continuous scenes the + // next rendered frame cancels this timer; parked scenes refresh at fleet + // cadence. Idle update-only ticks must not continually postpone the timer. + }; + let removeRender; + // Aircraft fleet writers run in preRender, while tracked models and entity + // visualizers update earlier. Compose AFTER all those owners and BEFORE + // Cesium uploads primitive attributes for this draw. Requeue at postUpdate + // so writers installed later (enable/re-enable/track) cannot overwrite sonar. + // This only reorders one callback; the contact walk runs once per actual draw. + const removeUpdate = scene.postUpdate.addEventListener(() => { + if (isCyberSonarActive()) syncCyberSonarAnimation(); + const supportChanged = gpuSupported !== gpu.stats.supported; + gpuSupported = gpu.stats.supported; + const support = gpu.stats.supported ? 'supported' : 'unsupported'; + if (root?.dataset && root.dataset.cyberSonarGpu !== support) + root.dataset.cyberSonarGpu = support; + // Shader setup runs after preRender's model tint. If it fails while the + // scene is parked (including Sonar OFF/reduced motion), draw once more so + // the now-unsupported contact path restores models before becoming idle. + if (supportChanged) scene.requestRender(); + const status = globalThis.document?.getElementById?.('cyber-sonar-status'); + const message = gpu.stats.supported + ? '' + : 'Contact GPU unavailable. Native contacts remain visible.'; + if (status && status.textContent !== message) status.textContent = message; + removeRender?.(); + removeRender = scene.preRender.addEventListener(update); + }); + const removePostRender = scene.postRender?.addEventListener(() => { + if (timer !== null) clearTimeout(timer); + timer = null; + if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands)) + timer = setTimeout(() => { + timer = null; + if (!disposed && isCyberContactSonarActive()) scene.requestRender(); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + }); + const unsubscribe = manager?.subscribe?.((change) => { + if (change.type !== 'visibility') return; + scene.requestRender(); + }); + return () => { + if (disposed) return; + disposed = true; + removeUpdate(); + removeRender?.(); + removePostRender?.(); + gpu.destroy(); + if (root?.dataset) delete root.dataset.cyberSonarGpu; + unsubscribe?.(); + if (timer !== null) clearTimeout(timer); + for (const [visual, state] of tracked) restore(visual, state); + tracked.clear(); + diagnostics.delete(viewer); + }; +} diff --git a/src/cyberSonarScene.test.mjs b/src/cyberSonarScene.test.mjs new file mode 100644 index 000000000..7d0db8551 --- /dev/null +++ b/src/cyberSonarScene.test.mjs @@ -0,0 +1,314 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + createCyberSonarSampler, + cyberSonarIntensityAtScreenPoint, + applyCyberSonarPrimitive, + applyCyberSonarSettings, + isCyberContactSonarActive, + isCyberSonarActive, +} from './cyberSonar.js'; +import { + createCyberSonarScene, + visitCyberSonarModels, + getCyberSonarSceneDiagnostics, +} from './cyberSonarScene.js'; + +test('frame sampler matches reference without per-contact settings access', () => { + let reads = 0; + const values = { + cyberSonarOpacity: '62', + cyberSonarSector: '40', + cyberSonarRange: '110', + }; + const root = { + dataset: new Proxy(values, { + get(o, k) { + reads++; + return o[k]; + }, + }), + }; + const frame = createCyberSonarSampler(1000, 800, 1234, root), + once = reads; + for (let x = 0; x < 1000; x += 33) + for (let y = 0; y < 800; y += 31) frame.at(x, y); + assert.equal(reads, once); + for (let x = 0; x < 1000; x += 33) + for (let y = 0; y < 800; y += 31) + assert.ok( + Math.abs( + frame.at(x, y) - + cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, { + dataset: values, + }), + ) < 1e-10, + ); +}); + +test('model walk never enumerates native contact or EntityCluster records', () => { + const root = new Cesium.PrimitiveCollection(); + const points = root.add(new Cesium.PointPrimitiveCollection()); + points.add(); + points.get = () => { + throw new Error('must not enumerate contacts'); + }; + const cluster = root.add(new Cesium.EntityCluster()); + Object.defineProperty(cluster, '_billboardCollection', { + get() { + throw new Error('must not access cluster internals'); + }, + configurable: true, + }); + const seen = []; + visitCyberSonarModels(root, (...v) => seen.push(v)); + assert.equal(seen.length, 0); + delete cluster._billboardCollection; + root.destroy(); +}); + +test('models retain render-pass alpha and owner refresh survives teardown', () => { + const frame = createCyberSonarSampler(1000, 800, 0); + const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) }; + applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true); + assert.equal(model.color.alpha, 1); + assert.ok(model.color.red < 1); + const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7); + model.color = owner; + applyCyberSonarPrimitive(model, 1, false); + assert.equal(model.color, owner); + const unset = {}; + applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true); + applyCyberSonarPrimitive(unset, 1, false); + assert.equal(unset.color, undefined); +}); + +test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => { + const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } }; + const options = { + root, + body: { classList: { contains: () => false } }, + hud: { classList: { contains: () => true } }, + reducedMotion: false, + }; + root.dataset.cyberSonarMode = 'css'; + assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined); + assert.equal(root.dataset.cyberSonarMode, undefined); + assert.equal(isCyberSonarActive(options), true); + assert.equal(isCyberContactSonarActive(options), true); + assert.equal( + applyCyberSonarSettings({ mode: 'invalid' }, root).mode, + undefined, + ); + assert.equal(isCyberContactSonarActive(options), true); + root.dataset.cyberSonarGpu = 'unsupported'; + assert.equal(isCyberContactSonarActive(options), false); +}); + +test('native owner colors remain untouched and scene teardown releases listeners', () => { + const previous = globalThis.document; + const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } }; + let cockpit = false; + globalThis.document = { + documentElement: root, + body: { classList: { contains: () => cockpit } }, + getElementById: () => ({ classList: { contains: () => true } }), + }; + const primitives = new Cesium.PrimitiveCollection(); + const points = primitives.add(new Cesium.PointPrimitiveCollection()); + const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) }); + const original = primitives.update; + const scene = { + primitives, + postUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + canvas: { clientWidth: 1000, clientHeight: 800 }, + camera: { + frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, + viewMatrix: Cesium.Matrix4.IDENTITY, + }, + requestRender() {}, + }; + const viewer = { scene }; + const dispose = createCyberSonarScene(viewer); + try { + for (const enabled of ['on', 'off', 'on']) { + root.dataset.cyberSonar = enabled; + for (const opacity of [35, 84, 100]) { + root.dataset.cyberSonarOpacity = String(opacity); + point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7); + scene.postUpdate.raiseEvent(); + scene.preRender.raiseEvent(); + assert.equal(point.color.alpha, 0.7); + assert.equal(point.color.red, 0.6); + } + } + const previousTimeout = globalThis.setTimeout; + const previousClear = globalThis.clearTimeout; + let timers = 0; + globalThis.setTimeout = () => { + timers++; + return 1; + }; + globalThis.clearTimeout = () => {}; + try { + const gpu = getCyberSonarSceneDiagnostics(viewer).gpu; + gpu.commands = 6; + gpu.visibleCommands = 0; + scene.postRender.raiseEvent(); + assert.equal(timers, 0, 'culled batches must not schedule a sonar draw'); + gpu.visibleCommands = 1; + scene.postRender.raiseEvent(); + assert.equal(timers, 1, 'a visible batch keeps the sweep moving'); + root.dataset.cyberSonar = 'off'; + scene.postRender.raiseEvent(); + assert.equal(timers, 1, 'OFF does not schedule contact draws'); + } finally { + globalThis.setTimeout = previousTimeout; + globalThis.clearTimeout = previousClear; + } + cockpit = true; + scene.postUpdate.raiseEvent(); + scene.preRender.raiseEvent(); + root.dataset.uiTheme = 'tactical'; + scene.postUpdate.raiseEvent(); + scene.preRender.raiseEvent(); + dispose(); + dispose(); + assert.equal(primitives.update, original); + assert.equal(scene.postUpdate.numberOfListeners, 0); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(root.dataset.cyberSonarGpu, undefined); + } finally { + dispose(); + primitives.destroy(); + globalThis.document = previous; + } +}); + +for (const reducedMotion of [false, true]) { + test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => { + const previousDocument = globalThis.document; + const previousMatchMedia = globalThis.matchMedia; + const previousTimeout = globalThis.setTimeout; + const previousClear = globalThis.clearTimeout; + const root = { + dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' }, + }; + const status = { textContent: '' }; + globalThis.document = { + documentElement: root, + body: { classList: { contains: () => false } }, + getElementById: (id) => + id === 'cyber-sonar-status' + ? status + : { classList: { contains: () => true } }, + }; + globalThis.matchMedia = () => ({ matches: reducedMotion }); + let scheduled = 0; + globalThis.setTimeout = () => { + scheduled++; + return 1; + }; + globalThis.clearTimeout = () => {}; + // A model-shaped fixture exercises the actual scene walk without loading a + // glTF or allocating a WebGL context. Rendering remains the browser gate. + const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1); + const model = Object.create(Cesium.Model.prototype, { + ready: { value: true }, + show: { value: true }, + color: { value: nativeColor.clone(), writable: true }, + modelMatrix: { value: Cesium.Matrix4.IDENTITY }, + }); + const primitives = new Cesium.PrimitiveCollection({ + destroyPrimitives: false, + }); + primitives.add(model); + const points = new Cesium.PointPrimitiveCollection(); + const nativeCommand = { + owner: points, + uniformMap: {}, + shaderProgram: { + vertexShaderSource: new Cesium.ShaderSource({ + sources: ['void main() {}'], + }), + }, + }; + primitives.update = (frame) => frame.commandList.push(nativeCommand); + let requests = 0; + const scene = { + primitives, + postUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + canvas: { clientWidth: 1000, clientHeight: 800 }, + drawingBufferWidth: 1000, + drawingBufferHeight: 800, + camera: { + frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, + viewMatrix: Cesium.Matrix4.IDENTITY, + }, + requestRender() { + requests++; + }, + }; + const viewer = { scene }; + const dispose = createCyberSonarScene(viewer); + try { + scene.postUpdate.raiseEvent(); + scene.preRender.raiseEvent(); + assert.notDeepEqual( + model.color, + nativeColor, + 'Cyber neutral treatment precedes shader compilation', + ); + const frame = { commandList: [], passes: { render: true } }; + primitives.update(frame); + assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false); + assert.equal(frame.commandList[0], nativeCommand); + scene.postRender.raiseEvent(); + assert.equal( + scheduled, + 0, + 'inactive sweep has no timer to perform recovery', + ); + scene.postUpdate.raiseEvent(); + assert.equal(root.dataset.cyberSonarGpu, 'unsupported'); + assert.match(status.textContent, /Native contacts remain visible/); + assert.equal( + requests, + 1, + 'support loss explicitly wakes the parked scene', + ); + scene.preRender.raiseEvent(); + assert.deepEqual( + model.color, + nativeColor, + 'the requested draw restores native model color', + ); + assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0); + scene.postRender.raiseEvent(); + for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent(); + assert.equal( + requests, + 1, + 'unsupported idle ticks do not create a render loop', + ); + assert.equal(scheduled, 0); + dispose(); + scene.postUpdate.raiseEvent(); + assert.equal(requests, 1, 'disposed callbacks cannot wake the scene'); + } finally { + dispose(); + points.destroy(); + primitives.destroy(); + globalThis.document = previousDocument; + globalThis.matchMedia = previousMatchMedia; + globalThis.setTimeout = previousTimeout; + globalThis.clearTimeout = previousClear; + } + }); +} diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index ca52ea484..a6bbe3c70 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) { return 'airliner'; } +/** Readable name of each class, for cards and readouts. */ +export const AIRCRAFT_CLASS_LABELS = Object.freeze({ + light: 'Light aircraft', + glider: 'Glider', + turboprop: 'Turboprop', + airliner: 'Airliner', + widebody: 'Widebody', + quadjet: 'Four-engine jet', + helicopter: 'Helicopter', + fastjet: 'Fast jet', + bizjet: 'Business jet', + uav: 'Drone', +}); + /** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */ export const CLASS_SCALE_2D = { light: 0.62, diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index e2a288fc8..6016048d3 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -18,7 +18,7 @@ * * Providers (injected — keeps the engine pure and node-testable): * getRecords(layerKey) → Array (layer accessor snapshot) - * resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary) + * resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null> * getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived) * * @module data/analystEngine @@ -77,6 +77,11 @@ export const ANALYST_LAYERS = { text: ['name', 'operator', 'river', 'output'], flags: [], }, + 'fire-perimeters': { + numeric: ['acres', 'containedPct', 'personnel', 'costToDate'], + text: ['name', 'state', 'county', 'cause', 'behavior', 'complexity'], + flags: [], + }, }; const EARTH_R_KM = 6371; @@ -229,6 +234,14 @@ export function createAnalystEngine(providers) { const scope = spec.scope || { kind: 'view' }; if (scope.kind === 'region' && scope.name) { const region = await providers.resolveRegionRing(scope.name); + if (region?.error === 'region-timeout') { + return { + ok: false, + code: 'region-timeout', + error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`, + coverage: { layersQueried, scope: `region:${scope.name}:timeout` }, + }; + } if (!region?.ring) { return { ok: false, diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 72a0d0f5c..2b2e6dae4 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -187,6 +187,19 @@ test('analyst: unresolved region is an honest failure, not empty success', async assert.match(r.error, /Atlantis/); }); +test('analyst: a region lookup timeout is reported as region-timeout', async () => { + const eng = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }), + getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), + }); + const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } }); + assert.equal(r.ok, false); + assert.equal(r.code, 'region-timeout'); + assert.match(r.error, /Texas/); + assert.equal(r.coverage.scope, 'region:Texas:timeout'); +}); + test('analyst: route fields queryable from cached enrichment only', async () => { const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, diff --git a/src/data/bundledJson.js b/src/data/bundledJson.js new file mode 100644 index 000000000..61e98fc14 --- /dev/null +++ b/src/data/bundledJson.js @@ -0,0 +1,29 @@ +/** + * @file Load a JSON file shipped beside the source modules. + * + * The browser fetches the file from `url` — a `new URL('./….json', + * import.meta.url)` written in the calling module, which Vite serves as-is in + * dev and emits as an asset in the production build. Under Node (node:test) + * that URL is a `file:` URL, which `fetch` cannot read, so the caller's + * JSON-attributed import runs instead. + * + * The attributed import cannot serve the browser: the Vite dev server answers + * it with a JavaScript module, which the browser rejects for a JSON import. + * + * @module data/bundledJson + */ + +/** + * @param {URL} url - Resolved against the calling module's `import.meta.url`. + * @param {() => Promise<{default: any}>} importJson - The caller's literal + * `import('./….json', { with: { type: 'json' } })`, used only under Node. + * @returns {Promise} The parsed JSON. + */ +export async function loadBundledJson(url, importJson) { + if (url.protocol === 'file:') return (await importJson()).default; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url.pathname}`); + } + return response.json(); +} diff --git a/src/data/bundledJson.test.mjs b/src/data/bundledJson.test.mjs new file mode 100644 index 000000000..2a7dfe293 --- /dev/null +++ b/src/data/bundledJson.test.mjs @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadBundledJson } from './bundledJson.js'; + +const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url); +const importMarine = () => + import('./local_data/natural_earth/marine.json', { with: { type: 'json' } }); + +test('bundled JSON: a file URL uses the JSON import under Node', async (t) => { + const fetchMock = t.mock.method(globalThis, 'fetch'); + const pack = await loadBundledJson(MARINE, importMarine); + assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico')); + assert.equal(fetchMock.mock.callCount(), 0); +}); + +test('bundled JSON: a served URL is fetched as plain JSON', async (t) => { + const requests = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requests.push(String(url)); + return new Response('{"features":[]}', { + headers: { 'content-type': 'application/json' }, + }); + }); + const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json'); + const importJson = () => assert.fail('the browser path must not import JSON'); + assert.deepEqual(await loadBundledJson(url, importJson), { features: [] }); + assert.deepEqual(requests, [url.href]); +}); + +test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 })); + await assert.rejects( + loadBundledJson(new URL('http://localhost/missing.json'), importMarine), + /HTTP 404/, + ); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index de72dc329..e9cc3b326 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -86,6 +86,14 @@ export const DATA_CREDITS = [ 'gibs.earthdata.nasa.gov · ' + 'HLS product page', }, + { + key: 'wfigs', + html: + 'Wildfire perimeters: ' + + 'National Interagency Fire Center (WFIGS)' + + ' · Incident information: ' + + 'InciWeb', + }, { key: 'overpass', html: diff --git a/src/data/detection.js b/src/data/detection.js index 83d7949b5..5c5e90396 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -48,6 +48,12 @@ import { viewScaleForAltitude, } from './detectionPolicy.js'; import { detectionBracketOpacity } from './detectionPresentation.js'; +import { + createCyberSonarSampler, + cyberSonarFrameDelay, + isCyberContactSonarActive as isCyberSonarActive, + isCyberContactThemeActive as isCyberMapThemeActive, +} from '../cyberSonar.js'; /** * @module detection @@ -206,6 +212,8 @@ let _lastRenderMs = 0; let _lastPaintMs = 0; let _lastSolveMs = 0; let _throttleSkipCount = 0; +let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY; +let _cyberSonarRenderTimer = null; /** @type {Function|null} External callback invoked when the detection mode changes */ let _onModeChange = null; /** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */ @@ -325,6 +333,7 @@ export function initDetection(viewer, layers, onModeChange) { /** Release the host lane and all retained detection runtime state. */ export function destroyDetection() { + _clearCyberSonarRenderTimer(); if (_cockpitModeListener && typeof window !== 'undefined') { window.removeEventListener( 'gev:cockpit-mode-changed', @@ -362,6 +371,25 @@ export function destroyDetection() { _lastPaintMs = 0; _lastSolveMs = 0; _throttleSkipCount = 0; + _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY; +} + +function _clearCyberSonarRenderTimer() { + if (_cyberSonarRenderTimer != null) { + globalThis.clearTimeout?.(_cyberSonarRenderTimer); + _cyberSonarRenderTimer = null; + } +} + +function _scheduleCyberSonarRender(nowMs) { + if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return; + const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs); + _cyberSonarRenderTimer = globalThis.setTimeout(() => { + _cyberSonarRenderTimer = null; + if (isCyberSonarActive()) { + governorRequestRender('cyber-sonar-cadence'); + } + }, delay); } /** @@ -682,7 +710,18 @@ function _applyModeState() { function _shouldPaintDetectionLane(frame) { if (_mode === MODE_OFF || _suspended) return false; const layoutChanged = frame.layoutRevision !== _hostLayoutRevision; + const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(); _frameCount++; + if (isCyberSonarActive()) { + const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs); + if (!layoutChanged && sonarDelay > 0) { + _scheduleCyberSonarRender(nowMs); + return false; + } + } else { + _clearCyberSonarRenderTimer(); + _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY; + } // The skip and the follow-up request come from ONE decision, so the valve // cannot drop a frame without handing its request forward — see // `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation: @@ -729,6 +768,14 @@ function _paintDetectionLane(frame) { _lastRenderMs = performance.now() - start; _lastSolveMs = result.solveMs || 0; _lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs); + const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(); + if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) { + _lastCyberSonarPaintAt = nowMs; + _scheduleCyberSonarRender(nowMs); + } else { + _clearCyberSonarRenderTimer(); + _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY; + } if (_lastDiagnostics) { _lastDiagnostics.frameTotalMs = _lastRenderMs; _lastDiagnostics.paintMs = _lastPaintMs; @@ -748,7 +795,7 @@ function _paintDetectionLane(frame) { // The frame's OWN timestamp, not a fresh sample — re-reading the clock here // is what dropped the terminal frame of a fade (paint at 219 ms, policy at // 220 ms, and the settled alpha never painted). - nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(), + nowMs, enabledAtMs: _enableTime, fadeMs: FADE_MS, animatingLabelCount: result.animatingCount || 0, @@ -996,7 +1043,8 @@ function _stashCallout(entry, acquireFade, keyhole) { placement.centerY, keyhole, ); - const alpha = acquireFade * temporalAlpha * radialAlpha; + const alpha = + acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1); if (alpha <= 0.001) return; let row = _calloutPool[_calloutCount]; @@ -1146,6 +1194,7 @@ function _materializeCandidate( keyholeAlpha, placements, color: obj._candidateColor, + sonarFactor: obj._candidateSonarFactor ?? 1, // Costed here rather than in the object sweep: the sweep walks every // observation in view, while this runs only for callouts that actually // placed — a budgeted handful per frame. @@ -1180,6 +1229,9 @@ function _drawOverlay(frame) { // with every arbiter call in this function — so the frame that paints an // animation's final state is the same frame that ends its demand. const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(); + const sonarActive = isCyberSonarActive(); + const cyberMapActive = isCyberMapThemeActive(); + const sonarSampler = createCyberSonarSampler(width, height, now); const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive); const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS; @@ -1362,11 +1414,16 @@ function _drawOverlay(frame) { // The bracket follows the same linear radial keyhole fade as its callout. const color = colorFor(resolveTier(obj)); const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole); - const bracketAlpha = detectionBracketAlpha( + const sonarFactor = + sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1; + const sonarLabelFactor = sonarSampler.label(sonarFactor); + const admissionAlpha = detectionBracketAlpha( obj.type, keyholeAlpha, keyholeOutsideOpacity, + cyberMapActive, ); + const bracketAlpha = admissionAlpha * sonarFactor; if (bracketAlpha > 0) { const transit = obj.tier?.startsWith('transit_'); (transit ? appendTransitBracket : appendCornerBracket)( @@ -1404,13 +1461,15 @@ function _drawOverlay(frame) { obj._cohortSourceId = sourceId; obj._cohortPriority = _semanticPriority(obj); - obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8); + obj._cohortBand = + admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8); obj._cohortHash = stableIdentityHash(layerId, sourceId); obj._candidateScreenX = sx; obj._candidateScreenY = sy; obj._candidateHalfW = halfW; obj._candidateHalfH = halfH; obj._candidateColor = color; + obj._candidateSonarFactor = sonarLabelFactor; obj._candidatePrimary = primary; obj._candidateMicro = micro; diff --git a/src/data/detectionPolicy.js b/src/data/detectionPolicy.js index 2ad55dcfc..bf47785fd 100644 --- a/src/data/detectionPolicy.js +++ b/src/data/detectionPolicy.js @@ -92,14 +92,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) { * @param {number} keyholeAlpha - Radial keyhole alpha for this contact. * @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means * the default, so a caller that does not know it reproduces the shipped look. + * @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR + * readability floor; the bracket follows the radial keyhole alpha exactly. * @returns {number} Paint alpha, 0-1. */ export function detectionBracketAlpha( type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR, + strictKeyholeFade = false, ) { const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0)); + if (strictKeyholeFade) return alpha; if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha; return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha); } diff --git a/src/data/detectionPolicy.test.mjs b/src/data/detectionPolicy.test.mjs index afddb8bd1..a596be7c4 100644 --- a/src/data/detectionPolicy.test.mjs +++ b/src/data/detectionPolicy.test.mjs @@ -28,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent' assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05); }); +test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => { + assert.equal( + detectionBracketAlpha( + 'AIR', + 0.01, + KEYHOLE_OUTSIDE_OPACITY_DEFAULT, + true, + ), + 0.01, + ); + assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42); + assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1); +}); + test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => { // detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole // default ever moves, this fails rather than silently shifting the shipped diff --git a/src/data/detectionRenderDemand.test.mjs b/src/data/detectionRenderDemand.test.mjs index c140bdd22..e104684cc 100644 --- a/src/data/detectionRenderDemand.test.mjs +++ b/src/data/detectionRenderDemand.test.mjs @@ -285,7 +285,15 @@ test('detection holds nothing, and asks for its own frames instead', async () => assert.ok(paintLane, 'detection.js still has a paint lane'); assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/, 'demand must not re-sample the clock — that is the dropped terminal frame'); - assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/); + assert.match( + paintLane, + /const nowMs = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/, + ); + assert.match( + paintLane, + /detectionNeedsFollowUpFrame\(\{[\s\S]*?\n\s*nowMs,/, + 'the sonar cadence and fade demand share the same frame-clock sample', + ); assert.match(paintLane, /animatingLabelCount: result\.animatingCount/, 'demand counts fades in both directions'); // …and `animatingCount` must really be the BOTH-directions count. Feeding the diff --git a/src/data/detectionSonarAdmission.test.mjs b/src/data/detectionSonarAdmission.test.mjs new file mode 100644 index 000000000..cecf34846 --- /dev/null +++ b/src/data/detectionSonarAdmission.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { detectionBracketAlpha } from './detectionPolicy.js'; + +// Exercise the actual renderer's admission and paint expressions. A sweep is +// appearance only: it must not reshuffle candidates at the cohort cutoff. +const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); +const alphaBlock = source.match( + /const admissionAlpha = detectionBracketAlpha\([\s\S]*?const bracketAlpha = [^;]+;/, +)?.[0]; +const bandAssignment = source.match(/obj\._cohortBand\s*=\s*[^;]+;/)?.[0]; + +test('Sonar changes paint alpha without changing detection cohort admission', () => { + assert.ok(alphaBlock, 'renderer separates admission from paint'); + assert.ok(bandAssignment); + for (const cyberMapActive of [false, true]) { + for (const type of ['AIR', 'SAT', 'SEA']) { + for (const keyholeAlpha of [0, 0.01, 0.125, 0.5, 0.999, 1]) { + const alpha = detectionBracketAlpha( + type, + keyholeAlpha, + 0.35, + cyberMapActive, + ); + const expectedBand = alpha >= 0.999 ? 8 : Math.floor(alpha * 8); + for (const sonarFactor of [0.01, 0.12, 0.5, 1]) { + const state = { + obj: { type }, + keyholeAlpha, + keyholeOutsideOpacity: 0.35, + cyberMapActive, + sonarFactor, + detectionBracketAlpha, + }; + vm.runInNewContext( + alphaBlock + bandAssignment + '; result = bracketAlpha;', + state, + ); + assert.equal(state.obj._cohortBand, expectedBand); + assert.equal(state.result, alpha * sonarFactor); + } + } + } + } +}); diff --git a/src/data/firePerimetersProxy.test.mjs b/src/data/firePerimetersProxy.test.mjs new file mode 100644 index 000000000..6c559c0d7 --- /dev/null +++ b/src/data/firePerimetersProxy.test.mjs @@ -0,0 +1,410 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { firePerimetersProxy } from '../../server/providers/firePerimeters.js'; + +const incidentHtml = ` +Date of Origin +`; +const incidentTimes = { + createdMs: Date.parse('2026-07-15T20:20:00Z'), + changedMs: Date.parse('2026-09-23T11:32:31-04:00'), +}; +const incidentRedirect = () => + new Response('redirect', { + status: 301, + headers: { location: 'http://inciweb.wildfire.gov/test-fire' }, + }); + +const feature = (id = 'fire') => ({ + properties: { attr_UniqueFireIdentifier: id, poly_DateCurrent: 123 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-108, 35], + [-107, 35], + [-107, 36], + [-108, 35], + ], + ], + }, +}); +function install(options = {}, hook = 'configureServer') { + let handler; + const plugin = firePerimetersProxy(options); + assert.equal(typeof plugin.configureServer, 'function'); + assert.equal(typeof plugin.configurePreviewServer, 'function'); + plugin[hook]({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/fire-perimeters'); + handler = callback; + }, + }, + }); + return async (url = '/', 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; + }; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => { + const calls = []; + const request = install( + { + now: () => 1234, + fetchImpl: async (url, options) => { + calls.push(new URL(url)); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.redirect, 'error'); + return Response.json({ + features: [feature(String(calls.length))], + exceededTransferLimit: true, + }); + }, + }, + hook, + ); + const res = await request('/?url=https://invalid.example&resultOffset=999'); + assert.equal(res.status, 200); + assert.equal(res.body.fetchedAt, 1234); + assert.equal(res.body.rows.length, 5); + assert.deepEqual( + calls.map((url) => url.searchParams.get('resultOffset')), + [null, '1', '2', '3', '4'], + ); + assert.ok(calls.every((url) => url.hostname === 'services3.arcgis.com')); + assert.equal(calls[0].searchParams.get('maxAllowableOffset'), '0.001'); + }); +} + +test('an explicit transfer-limit false stops paging; a fresh cache does not fetch', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return Response.json({ + features: [feature()], + exceededTransferLimit: false, + }); + }, + }); + const first = await request(); + assert.deepEqual((await request()).body, first.body); + assert.equal(calls, 1); +}); + +test('a failed refresh serves the last good perimeter snapshot as stale', async () => { + let clock = 0, + calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + if (++calls > 1) throw new Error('private upstream details'); + return Response.json({ features: [feature()] }); + }, + }); + await request(); + clock = 300_000; + const res = await request(); + assert.equal(res.status, 200); + assert.equal(res.body.stale, true); + assert.equal(res.body.fetchedAt, 0); + assert.equal(res.body.rows[0].stableId, 'fire'); +}); + +test('oversized streamed and declared bodies produce sanitized 502 responses', async () => { + for (const declared of [true, false]) { + let cancelled = false; + const request = install({ + fetchImpl: async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel() { + cancelled = true; + }, + }), + { + headers: declared + ? { 'Content-Length': String(17 * 1024 * 1024) } + : {}, + }, + ), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.deepEqual(res.body, { error: 'fire_perimeters_unavailable' }); + assert.equal(cancelled, true); + } +}); + +test('malformed WFIGS data does not become a cached empty snapshot', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return Response.json({ error: 'upstream detail' }); + }, + }); + assert.equal((await request()).status, 502); + assert.equal((await request()).status, 502); + assert.equal(calls, 2); +}); + +test('concurrent requests share one upstream operation for every route', async () => { + for (const [path, payload] of [ + ['/', { features: [] }], + ['/inciweb/index', []], + ['/inciweb/publication/42', {}], + ]) { + let release, + calls = 0; + const pending = new Promise((resolve) => { + release = resolve; + }); + const request = install({ + fetchImpl: async () => { + calls++; + await pending; + if (path.includes('/publication/')) + return calls === 1 ? incidentRedirect() : new Response(incidentHtml); + return Response.json(payload); + }, + }); + const responses = [request(path), request(path)]; + await new Promise(setImmediate); + assert.equal(calls, 1); + release(); + for (const res of await Promise.all(responses)) + assert.equal(res.status, 200); + assert.equal(calls, path.includes('/publication/') ? 2 : 1); + } +}); + +test('bad publication ids and non-GET requests never fetch upstream', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + throw new Error('unexpected'); + }, + }); + for (const id of ['', 'abc', '-1', '1234567890', '1/2', '%31', '1.0']) + assert.equal((await request(`/inciweb/publication/${id}`)).status, 400); + for (const path of ['/', '/inciweb/index', '/inciweb/publication/123']) { + for (const method of ['POST', 'HEAD', 'PUT', 'DELETE']) + assert.equal((await request(path, method)).status, 405); + } + assert.equal(calls, 0); +}); + +test('InciWeb index preserves the fixed POST, caches for one hour and serves stale arrays', async () => { + let clock = 0, + calls = 0; + const rows = [{ incident_id: '42', incident_title: 'Fire' }]; + const request = install({ + now: () => clock, + fetchImpl: async (url, options) => { + calls++; + assert.equal(url, 'https://inciweb.wildfire.gov/api/single-publication/'); + assert.equal(options.method, 'POST'); + assert.deepEqual(options.headers, { 'Content-Type': 'application/json' }); + assert.deepEqual(JSON.parse(options.body), { title: '' }); + if (calls > 1) throw new Error('offline'); + return Response.json(rows); + }, + }); + assert.deepEqual((await request('/inciweb/index')).body, rows); + clock = 3_599_999; + assert.deepEqual((await request('/inciweb/index')).body, rows); + assert.equal(calls, 1); + clock++; + const stale = await request('/inciweb/index'); + assert.deepEqual(stale.body, rows); + assert.equal(stale.headers['X-Data-Stale'], 'true'); +}); + +test('publication timestamps, thirty-minute TTL and oldest-entry eviction', async () => { + let clock = 0; + const calls = []; + const request = install({ + now: () => clock, + fetchImpl: async (url) => { + calls.push(url); + return url.includes('/node/') + ? incidentRedirect() + : new Response(incidentHtml); + }, + }); + const get = (id) => + request(`/inciweb/publication/${id}`, 'GET', `peer-${id}`); + assert.deepEqual((await get(1)).body, incidentTimes); + clock = 1_799_999; + await get(1); + assert.equal(calls.length, 2); + clock++; + await get(1); + assert.equal(calls.length, 4); + for (let id = 2; id <= 257; id++) assert.equal((await get(id)).status, 200); + await get(2); + assert.equal(calls.length, 516); + await get(1); + assert.equal(calls.length, 518); + assert.equal(calls.at(-2), 'https://inciweb.wildfire.gov/node/1'); +}); + +test('InciWeb index and publication reads enforce their smaller caps', async () => { + for (const [path, cap] of [ + ['/inciweb/index', 4 * 1024 * 1024], + ['/inciweb/publication/1', 2 * 1024 * 1024], + ]) { + const request = install({ + fetchImpl: async () => + new Response('{}', { headers: { 'Content-Length': String(cap + 1) } }), + }); + assert.equal((await request(path)).status, 502); + } +}); + +test('the per-client limit stops a loop while another client can read the cache', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return Response.json({ features: [] }); + }, + }); + for (let i = 0; i < 60; i++) assert.equal((await request()).status, 200); + assert.equal((await request()).status, 429); + assert.equal((await request('/', 'GET', 'another-client')).status, 200); + assert.equal(calls, 1); +}); + +test('publication redirects share a signal, cancel the redirect body and force HTTPS', async () => { + for (const status of [301, 302, 303, 307, 308]) { + const calls = []; + const redirect = new Response('redirect', { + status, + headers: { + location: 'http://inciweb.wildfire.gov/incident/test-fire#details', + }, + }); + const request = install({ + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return calls.length === 1 ? redirect : new Response(incidentHtml); + }, + }); + assert.deepEqual( + (await request('/inciweb/publication/42')).body, + incidentTimes, + ); + assert.equal(calls.length, 2); + assert.equal(redirect.bodyUsed, true); + assert.equal(calls[0].url, 'https://inciweb.wildfire.gov/node/42'); + assert.equal(calls[0].options.redirect, 'manual'); + assert.equal( + calls[1].url, + 'https://inciweb.wildfire.gov/incident/test-fire', + ); + assert.equal(calls[1].options.redirect, 'error'); + assert.ok(calls[0].options.signal instanceof AbortSignal); + assert.equal(calls[0].options.signal, calls[1].options.signal); + } +}); + +test('unsafe publication redirect locations fail before a second fetch', async () => { + for (const location of [ + 'https://evil.example/x', + '/a/../x', + '/x?q=1', + '/x?', + '/%2e%2e/x', + '/a/b/c', + '', + ]) { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return new Response('redirect', { status: 301, headers: { location } }); + }, + }); + assert.equal((await request('/inciweb/publication/42')).status, 502); + assert.equal(calls, 1); + } +}); + +test('incident pages without valid timestamps fail closed and are not cached', async () => { + for (const html of [ + 'No markers', + incidentHtml.replaceAll(/2026-[^"]+/g, 'invalid'), + ]) { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return calls % 2 ? incidentRedirect() : new Response(html); + }, + }); + assert.equal((await request('/inciweb/publication/42')).status, 502); + assert.equal((await request('/inciweb/publication/42')).status, 502); + assert.equal(calls, 4); + } +}); + +test('direct 200 incident pages work, including one missing timestamp', async () => { + for (const [html, expected] of [ + [incidentHtml, incidentTimes], + [ + incidentHtml.replace('og:updated_time', 'unrelated'), + { ...incidentTimes, changedMs: null }, + ], + [ + incidentHtml.replace('Date of Origin', 'Unrelated'), + { ...incidentTimes, createdMs: null }, + ], + ]) { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return new Response(html); + }, + }); + assert.deepEqual((await request('/inciweb/publication/42')).body, expected); + assert.equal(calls, 1); + } +}); + +test('publication HTML streams are capped at 2 MiB', async () => { + let cancelled = false; + const request = install({ + fetchImpl: async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel() { + cancelled = true; + }, + }), + ), + }); + assert.equal((await request('/inciweb/publication/42')).status, 502); + assert.equal(cancelled, true); +}); diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 138b1a4db..7f9019e18 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -5,9 +5,15 @@ * VIIRS header (confirmed live 2026-07-16): * latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite, * instrument,confidence,version,bright_ti5,frp,daynight + * MODIS header: + * latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite, + * instrument,confidence,version,bright_t31,frp,daynight * * Quirks this module owns: - * - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) — passed through raw. + * - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) and numeric 0-100 for + * MODIS — passed through raw. + * - Brightness maps from `bright_ti4`/`brightness`; the secondary band maps + * from `bright_ti5`/`bright_t31`. * - `acq_time` is NOT zero-padded ("45" = 00:45 UTC) — kept as-is in records; * {@link acquisitionMsUtc} does the padding. * - Upstream errors come back as HTML or plain text ("Invalid MAP_KEY"), diff --git a/src/data/firmsCsv.test.mjs b/src/data/firmsCsv.test.mjs index 798946e25..301fb4938 100644 --- a/src/data/firmsCsv.test.mjs +++ b/src/data/firmsCsv.test.mjs @@ -48,6 +48,25 @@ test('fixture first row maps every field, categorical confidence preserved raw', assert.equal(first.instrument, 'VIIRS'); }); +test('MODIS schema maps fields and preserves numeric confidence raw', () => { + const header = + 'latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_t31,frp,daynight'; + const row = + '34.123,-117.456,321.5,1.2,0.8,2026-07-16,1045,Aqua,MODIS,78,6.1NRT,290.25,12.4,D'; + const [record] = parseFirmsCsv(`${header}\n${row}\n`); + assert.equal(record.lat, 34.123); + assert.equal(record.lon, -117.456); + assert.equal(record.frp, 12.4); + assert.equal(record.brightness, 321.5); + assert.equal(record.brightnessTi5, 290.25); + assert.equal(record.daynight, 'D'); + assert.equal(record.acqDate, '2026-07-16'); + assert.equal(record.acqTime, '1045'); + assert.equal(record.satellite, 'Aqua'); + assert.equal(record.instrument, 'MODIS'); + assert.equal(record.confidence, '78'); +}); + test('acquisitionMsUtc: "1006" = 10:06 UTC', () => { assert.equal( acquisitionMsUtc('2026-07-16', '1006'), diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index a6dcdb774..a54bd42fe 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -9,7 +9,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT']; +const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT']; const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -42,7 +42,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () = assert.equal(result.fires[199_999], large.at(-1)); assert.equal(result.fires.at(-1), last); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ - source, count: [200_000, 1, 0][index], ok: true, + source, count: [200_000, 1, 0, 0][index], ok: true, }))); }); @@ -51,7 +51,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => { if (source === SOURCES[1]) throw new Error('upstream unavailable'); return [recent]; })('fixture'); - assert.equal(result.fires.length, 2); + assert.equal(result.fires.length, 3); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 1 ? 0 : 1, ok: index !== 1, }))); @@ -65,7 +65,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy // Fault injection for aggregation; ordinary parsed CSV returns an array. return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } }; })('fixture'); - assert.equal(result.fires.length, 2); + assert.equal(result.fires.length, 3); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 0 ? 0 : 1, ok: index !== 0, }))); diff --git a/src/data/fixtures/README.md b/src/data/fixtures/README.md index 7f22e94b0..b251475ce 100644 --- a/src/data/fixtures/README.md +++ b/src/data/fixtures/README.md @@ -7,3 +7,16 @@ (22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures` browser mode — it is a point-in-time congestion snapshot, not a bundled data layer, and is never loaded by ordinary application startup. © TomTom. +- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*;`, one per line, + in receive order) received over Austin, TX, and + `adsb-austin-dump1090-aircraft.json` — dump1090-fa's `aircraft.json` decode + of the same frames, trimmed to the fields the tests assert on. Used by the + Local ADS-B decoder and record-adapter tests; never loaded at runtime. +- `adsb-austin-capture-timed.json` — a second ~90 s receive over Austin, TX: + 846 extended squitters from six aircraft (`frames`: `[ms, hex]`), with + dump1090-fa's decoded positions and emitter categories for the same + interval (`dump1090`: per ICAO `flight`, `category` and `fixes` as + `[ms, lat, lon]`). The capture has no per-frame timestamps; each frame's + time is interpolated between the frames whose decoded position equals a + dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's + category, speed-check and track-accuracy tests; never loaded at runtime. diff --git a/src/data/fixtures/adsb-austin-capture-timed.json b/src/data/fixtures/adsb-austin-capture-timed.json new file mode 100644 index 000000000..fba542822 --- /dev/null +++ b/src/data/fixtures/adsb-austin-capture-timed.json @@ -0,0 +1 @@ +{"receiver":{"latitude":30.27,"longitude":-97.8},"frames":[[10,"8d0d0c07501d0030aa4c0c2140c6"],[80,"8da5b3a758b99408f8dda679bb00"],[150,"8da0b702e1150d0000000004eb56"],[220,"8d0d0c0722594377e37820b79a0d"],[290,"8d0d0c07990c111d483c0c92e5f3"],[360,"8da0b702590d40253c4af26e47d9"],[430,"8da0b70299105d833818018770a8"],[500,"8d0d0c07501bf7da9cd71ab9e124"],[570,"8da5b3a7ea466864013c085e68e2"],[640,"8da3627d58a580665464f23579f2"],[780,"8d0d0c07990c111d483c0c92e5f3"],[850,"8da0b702590d47cf46d60b223463"],[920,"8da0b70299105d83380c015f9ea8"],[990,"8d0d0c07f8200002004ab89cec0b"],[1060,"8da0b702f8122006005ab8b0ce08"],[1130,"8d0d0c07501bf030d84c0b00e5d5"],[1200,"8da5b3a758b9905ff8528e447e65"],[1275,"8d0d0c07990c111d483c0c92e5f3"],[1350,"8da0b702590d5025384afb963e58"],[1425,"8da0b70299105d83300481465660"],[1500,"8da3627d58a5840ef0ef82e7f39f"],[1600,"8d0d0c07501bf030f64c0af8367b"],[1675,"8da5b3a7990da30b30085d4a3fb6"],[1750,"8d0d0c07990c111d483c0b6d35d7"],[1825,"8da5b3a723041333c31c2045cc84"],[1900,"8da0b702590d4025364affe4f100"],[1963,"8da0b70299105c833008010a006e"],[2088,"8dabe7c59915ad0d380456abc760"],[2150,"8dabe7c558af87ce8ccac01d62c7"],[2213,"8d0d0c07501be031124c090e5e08"],[2275,"8d0d0c07e1058a00000000c4d44e"],[2338,"8da3627de106290000000030ef86"],[2400,"8da5b3a758b994091cdd51bae36d"],[2460,"8d0d0c07990c111d483c0c92e5f3"],[2580,"8da0b702590d47cf40d6170e6567"],[2640,"8da0b70299105c83100c01774a47"],[2700,"8d0d0c07501be031244c08456bbe"],[2800,"8da5b3a7990da30b30085d4a3fb6"],[2900,"8da27e81592147de34ccbfe1e71f"],[3000,"8d0d0c07990c111d683c0b2849fe"],[3100,"8da0b702590d57cf3cd61d759697"],[3133,"8da0b70299105c83101081d8ce87"],[3200,"8da5b3a7ea466864013c085e68e2"],[3233,"8d0d0c07501be7db16d7163df6ea"],[3267,"8da3627d991417bb100854ccc1a2"],[3300,"8da27e81592147de3cccc18db35e"],[3333,"8da27e819910390f18080b8ece70"],[3367,"8d0d0c07990c111d683c0cd799da"],[3400,"8da5b3a758b990601e52360713ad"],[3450,"8da0b702f8122006005ab8b0ce08"],[3500,"8d0d0c07f8200002004ab89cec0b"],[3550,"8da0b702590d50252e4b0d6c0eab"],[3600,"8da0b70299105b83101481e9cced"],[3650,"8da5b3a7f82300060049b8e08700"],[3700,"8d0d0c07501be7db32d715b033d5"],[3700,"8da3627d58a580652c64ebca78d9"],[3860,"8da5b3a7990da30b30085eb5d7a4"],[3940,"8d0d0c07990c111d68380b107ffe"],[4020,"8dabe7c59915ad0d380456abc760"],[4100,"8da0b702590d57cf38d625439697"],[4140,"8da0b70299105b83101801a6902d"],[4180,"8d0d0c07501bd031724c05906709"],[4220,"8dabe7c558af87ceb8ca66b4e8a7"],[4260,"8d0d0c07990c111d68380cefafda"],[4300,"8da5b3a758b9906030520dfe8d1c"],[4417,"8d0d0c07501bd031844c05445a49"],[4533,"8da0b702590d57cf36d62b019958"],[4650,"8da0b70299105b83101881a196ed"],[4767,"8da5b3a7990da30b30085d4a3fb6"],[4883,"8d0d0c07990c111d88380b35ff28"],[5000,"8da0b702590d57cf34d62ee5fde5"],[5000,"8da0b70299105b82f01481e7b168"],[5000,"8da0b702e1150d0000000004eb56"],[5000,"8d0d0c07501bd7db74d71347e30c"],[5150,"8da0b70227c74d5462082004597a"],[5300,"8da5b3a758b990604051e7fb7390"],[5333,"8d0d0c0722594377e37820b79a0d"],[5367,"8d0d0c07990c111d88380cca2f0c"],[5400,"8dabe7c558af8024c23ee7c8e962"],[5460,"8dabe7c5f8230006004ab8db4fc2"],[5520,"8da0b702590d5025264b1f025060"],[5580,"8da0b70299105b82f01401e0b7a8"],[5640,"8da5b3a7ea466864013c085e68e2"],[5700,"8d0d0c07501bd031b44c03dc465d"],[5760,"8da5b3a7990da30b30085eb5d7a4"],[5820,"8da0b702f8122006005ab8b0ce08"],[5880,"8d0d0c07990c111d88380b35ff28"],[5940,"8dabe7c59915ad0d300456c56568"],[6000,"8da0b702590d57cf30d6372dc85c"],[6050,"8da0b70299105b82f01401e0b7a8"],[6100,"8d0d0c07501bc031c64c021a765d"],[6250,"8d0d0c07f8200002004ab89cec0b"],[6400,"8dabe7c558af8024d83eba9c7813"],[6500,"8d0d0c07501bc7dbb4d71117af08"],[6600,"8d0d0c07990c111da83c0b48b501"],[6700,"8da5b3a7990da30b30085d4a3fb6"],[6800,"8dabe7c59915ad0d300456c56568"],[6900,"8d0d0c07501bc7dbc8d710a36866"],[7050,"8d0d0c07990c111da83c0cb76525"],[7200,"8da5b3a758b990606451947414a7"],[7300,"8dabe7c558af87cef8c9e42b1cae"],[7360,"8dabe7c5ea428867215c08fe0874"],[7420,"8d0d0c07e1058a00000000c4d44e"],[7540,"8d0d0c07501bc0320a4c005f6240"],[7600,"8d0d0c07990c111da83c0b48b501"],[7660,"8da0b702590d67cf28d645cd6577"],[7720,"8da0b70299105a82f01081dc8da6"],[7780,"8da5b3a7990da30b30085eb5d7a4"],[7840,"8dabe7c59915ad0d300456c56568"],[7900,"8d0d0c07501bb032264bff008fc5"],[7933,"8d0d0c07990c111da83c0cb76525"],[7967,"8da5b3a7ea466864013c085e68e2"],[8000,"8da0b702590d67cf26d6487082aa"],[8150,"8da0b70299105a82d01481a1c78f"],[8300,"8da5b3a758b9906076516b48fcb5"],[8300,"8dabe7c558af8025003e6a60fadf"],[8364,"8da5b3a7f82300060049b8e08700"],[8427,"8da0b702f8122006005ab8b0ce08"],[8491,"8d0d0c07f8200002004ab89cec0b"],[8555,"8d0d0c07990c111dc83c0b87317a"],[8618,"8d0d0c07501bb032444bfe2b5735"],[8682,"8da5b3a7e10d2800000000aefd57"],[8745,"8da0b702590d67cf24d64d94e617"],[8809,"8da0b70299105a82d01481a1c78f"],[8873,"8dabe7c59915ad0d300456c56568"],[8936,"8d0d0c07990c111dc83c0c78e15e"],[9000,"8d0d0c07501bb032564bfeedbba7"],[9200,"8da0b702590d6025164b3fca5cc9"],[9267,"8da0b70299105a82d010019ef74f"],[9333,"8da5b3a758b9940996dc34d66e25"],[9400,"8dabe7c558af87cf24c98d196cab"],[9475,"8d0d0c07501ba7dc42d70c4cd63a"],[9550,"8d0d0c07990c111dc83c0b87317a"],[9625,"8da0b702590d5025144b4382eb39"],[9700,"8da0b70299105b82d00c01357f81"],[9775,"8dabe7c59915ad0d300456c56568"],[9850,"8dabe7c5ea428867215c08fe0874"],[9925,"8da5b3a7990da30b30085eb5d7a4"],[10000,"8da0b702590d57cf1ed65ad71178"],[10000,"8da0b70299105b82b00c01fafbfa"],[10000,"8d0d0c07501ba032864bfc60b3b3"],[10067,"8da0b702e1150d0000000004eb56"],[10133,"8da0b70227c74d5462082004597a"],[10200,"8da5b3a758b99409a6dc0fb0ef0a"],[10300,"8dabe7c558af80252a3e13adaa11"],[10357,"8d0d0c0722594377e37820b79a0d"],[10414,"8d0d0c07990c111de8380bfa7b53"],[10471,"8dabe7c5f8230006004ab8db4fc2"],[10529,"8da0b702590d6025104b4be43f71"],[10586,"8da0b70299105b82b00c81fdfd3a"],[10643,"8da5b3a7ea466864013c085e68e2"],[10700,"8d0d0c07501ba7dc72d70ad4ca2e"],[10814,"8dabe7c5e1122e00000000bdd208"],[10871,"8dabe7c59915ad0d300456c56568"],[10929,"8da0b702f8122006005ab8b0ce08"],[10986,"8d0d0c07990c111de8380bfa7b53"],[11043,"8d0d0c07f8200002004ab89cec0b"],[11100,"8d0d0c07501ba7dc8ed7098a15f6"],[11100,"8da5b3a7990da30b30085d4a3fb6"],[11100,"8da0b702590d60250e4b517bac40"],[11186,"8da0b70299105b82b00c81fdfd3a"],[11271,"8da5b3a758b99060a850f2417029"],[11357,"8da3627dea3e9860015f888cbc76"],[11443,"8d0d0c07990c111de8380bfa7b53"],[11529,"8da0b702590d60250c4b549fc8fd"],[11614,"8d0d0c07501b9032d44bf9829e6c"],[11700,"8da3627d58a580623264da78409f"],[11760,"8d0d0c07990c111de8380bfa7b53"],[11820,"8dabe7c59915ad0d300456c56568"],[11880,"8da5b3a723041333c31c2045cc84"],[12000,"8da0b702590d50250a4b58e28c01"],[12033,"8da0b70299105b82b80401e435f2"],[12067,"8da5b3a7990da30b30085eb5d7a4"],[12100,"8d0d0c07501b97dcbed708bc2ce6"],[12145,"8da5b3a758b99409cadbbef56b5c"],[12191,"8d0d0c07990c111de8380bfa7b53"],[12236,"8da27e8199103a0f58080b012970"],[12327,"8d0d0c07e1058a00000000c4d44e"],[12373,"8dabe7c5ea428867215c08fe0874"],[12418,"8da0b702590d5025084b5d06e8bc"],[12464,"8da0b70299105b82b80801ac6ff2"],[12509,"8da3627df8230002004ab8760258"],[12555,"8d0d0c07501b97dcdad707bb59d1"],[12600,"8da3627d58a5840adcef6a51bd8a"],[12700,"8d0d0c07990c111e08380ba3fc70"],[12800,"8da0b702590d57cf12d6748f7785"],[12900,"8da0b70299105b82b80c019459f2"],[13000,"8da5b3a7ea466864013c085e68e2"],[13100,"8da5b3a7990da30b30085eb5d7a4"],[13200,"8d0d0c07501b9033224bf77d0a3e"],[13250,"8da5b3a7f82300060049b8e08700"],[13300,"8da5b3a758b99060ce509b5fcb61"],[13350,"8d0d0c07990c111e08380ba3fc70"],[13400,"8da0b702590d57cf10d6776b3715"],[13475,"8da0b70299105b82980481a64f1b"],[13550,"8da0b702f8122006005ab8b0ce08"],[13625,"8d0d0c07f8200002004ab89cec0b"],[13775,"8da3627dea3e9860015f888cbc76"],[13850,"8d0d0c07990c121e08380ba6e322"],[13925,"8d0d0c07501b87dd0ad7051dac96"],[14000,"8da0b702590d5025024b6a8d137a"],[14075,"8da0b70299105c82980881e72171"],[14150,"8da5b3a7990da30b30085d4a3fb6"],[14225,"8d0d0c07990c121e08380ba6e322"],[14300,"8d0d0c07501b87dd26d704fed7ba"],[14433,"8da0b702590d6025004b6f3882e7"],[14567,"8da0b70299105c82980881e72171"],[14700,"8da3627d58a5840a22ef66eb6a98"],[14756,"8d0d0c07501b87dd3ad7047a60a6"],[14811,"8d0d0c07990c121e083c0b9ed522"],[14867,"8dabe7c5ea428867215c08fe0874"],[14922,"8da0b702590d57cf0ad68639294e"],[14978,"8da0b70299105c82980c01d811b1"],[15033,"8da5b3a7990da30b30085d4a3fb6"],[15089,"8da0b70227c74d5462082004597a"],[15144,"8dabe7c59915ad0d380456abc760"],[15200,"8da5b3a758b99409fedb445fcade"],[15225,"8d0d0c0722594377e37820b79a0d"],[15250,"8d0d0c07990c121e083c0b9ed522"],[15275,"8da0b702e1150d0000000004eb56"],[15300,"8d0d0c07501b77dd56d703fa125c"],[15400,"8da0b702590d57cf0ad689c68906"],[15500,"8da5b3a7ea466864013c085e68e2"],[15600,"8da3627d58a58409ceef64eb02ac"],[15688,"8d0d0c07501b7033a04bf355e76e"],[15775,"8d0d0c07990c121e28400b31e710"],[15862,"8d0d0c07f8200002004ab89cec0b"],[15950,"8da5b3a758b9a0610250231a9b25"],[16037,"8da5b3a7990da30b30085d4a3fb6"],[16125,"8da3627dea3e9860015f888cbc76"],[16212,"8d0d0c07990c121e28400b31e710"],[16300,"8d0d0c07501b7033be4bf2350cf9"],[16400,"8da0b702590d6024f84b8180092b"],[16514,"8da0b70299105c829004823e316b"],[16629,"8d0d0c07501b7033d04bf1473b1e"],[16743,"8d0d0c07990c121e28400b31e710"],[16857,"8da0b702590d5024f64b85939fb3"],[16971,"8da0b70299105c82900881898379"],[17086,"8dabe7c59915ad0d380456abc760"],[17200,"8da5b3a758b9a40a20daf285d56f"],[17250,"8da5b3a7990da30b30085eb5d7a4"],[17300,"8d0d0c07501b67ddb6d6ffe4df78"],[17350,"8d0d0c07990c121e28400b31e710"],[17400,"8da0b702590d57cf02d69ba8d7cd"],[17450,"8da0b70299105d82900481c2d3b7"],[17500,"8dabe7c558af8025c23ce400e723"],[17578,"8dabe7c5ea428867215c08fe0874"],[17656,"8d0d0c07e1058a00000000c4d44e"],[17733,"8da0b702590d57cf00d69e4cb370"],[17811,"8da0b70299105d82980881e42bbf"],[17889,"8d0d0c07501b67ddd2d6fee3fe0e"],[17967,"8d0d0c07990c121e483c0b142d70"],[18044,"8da3627d991417baf00854c2bc27"],[18122,"8dabe7c59915ad0d380456abc760"],[18200,"8da0b702590d57cefed6a2dcb859"],[18289,"8da0b70299105d82980c81dc1dbf"],[18378,"8d0d0c07501b60341e4bee7982a6"],[18467,"8da0b702f8122006005ab8b0ce08"],[18556,"8d0d0c07990c121e483c0b142d70"],[18644,"8dabe7c558af8025d63cb8e9d9d5"],[18733,"8d0d0c07f8200002004ab89cec0b"],[18822,"8da0b702590d47cefcd6a7087004"],[18911,"8da0b70299105d8298180103f57f"],[19000,"8d0d0c07501b50343c4bed894b3f"],[19125,"8d0d0c07990c121e483c0b142d70"],[19250,"8dabe7c59915ad0d380456abc760"],[19375,"8da0b702590d4024ee4b9b1061d2"],[19500,"8da0b70299105d82981c013bc37f"],[19625,"8da0b702590d57cef8d6b0f0bd1c"],[19750,"8da0b70299105e82981c8139daed"],[19875,"8da0b70227c74d5462082004597a"],[20000,"8d0d0c07501b50346c4bec210374"],[20050,"8da3627d991417bad00454cf9a0e"],[20100,"8dabe7c59915ae0d380456aed832"],[20150,"8d0d0c07990c121e483c0b142d70"],[20200,"8da0b702590d47cef8d6b4c029ca"],[20333,"8da0b702e1150d0000000004eb56"],[20600,"8da0b702590d4024e84ba8c2512e"],[20733,"8da0b70299105e82b82001ed660d"],[20867,"8d0d0c07501b40349e4beaf2e7fd"],[21000,"8da0b702590d5024e64bab4f4e52"],[21133,"8da3627dea3e9860015f888cbc76"],[21267,"8d0d0c07990c121e483c0b142d70"],[21400,"8da3627d58a58407a6ef57ecfcde"],[21500,"8d0d0c07501b47de80d6f8b290d6"],[21500,"8da0b702590d47cef2d6c1484d77"],[21600,"8dabe7c558af80261a3c32abbb64"],[21700,"8d0d0c07990c131e283c0bd8a3c5"],[21900,"8da0b702590d47cef0d6c4ac29ca"],[22000,"8dabe7c59915ae0d380456aed832"],[22100,"8d0d0c07501b47de9cd6f7c98782"],[22200,"8d0d0c07990c131e283c0bd8a3c5"],[22300,"8da5b3a758b9a0616e4f25365cff"],[22400,"8da0b702590d47ceeed6c8337e0e"],[22467,"8da5b3a7990da30b30045efd8da4"],[22533,"8dabe7c5ea428867215c08fe0874"],[22600,"8dabe7c558af87d034c76b592c85"],[22633,"8d0d0c07e1058a00000000c4d44e"],[22667,"8d0d0c07990c131e283c0bd8a3c5"],[22700,"8d0d0c07501b37deb8d6f6d71806"],[22750,"8dabe7c5234d7071e74c209887e7"],[22800,"8da0b702590d4024e04bbcac2bc8"],[22886,"8da5b3a7f82300060049b8e08700"],[22971,"8da5b3a7ea466864013c085e68e2"],[23057,"8d0d0c07990c131e283c0bd8a3c5"],[23143,"8da5b3a758b9a40a8ad9fe4df9bb"],[23229,"8da3627d991417bad00454cf9a0e"],[23314,"8d0d0c07501b37ded6d6f5a52fe1"],[23400,"8dabe7c558af87d046c74751cd86"],[23500,"8d0d0c07990c131e283c0bd8a3c5"],[23700,"8d0d0c07501b37dee8d6f57f4c56"],[23860,"8d0d0c07f8200002004ab89cec0b"],[24020,"8dabe7c59915ae0d300456c07a3a"],[24180,"8d0d0c07990c131e283c0bd8a3c5"],[24340,"8d0d0c07501b27df06d6f4fc32b4"],[24500,"8dabe7c558af87d05cc71a055cf7"],[24650,"8d0d0c07990c131e28380be095c5"],[24800,"8d0d0c07501b2035564be38b2a7c"],[24900,"8dabe7c59915ae0d300456c07a3a"],[25000,"8dabe7c5ea428867215c08fe0874"],[25100,"8d0d0c07990c131e28380be095c5"],[25200,"8da5b3a758b9a40aacd9ac2703b7"],[25267,"8dabe7c5f8230006004ab8db4fc2"],[25333,"8d0d0c0722594377e37820b79a0d"],[25400,"8dabe7c558af87d06ec6f772398f"],[25525,"8d0d0c07501b2035744be22a0ade"],[25650,"8da5b3a7ea466864013c085e68e2"],[25775,"8d0d0c07990c131e28380be095c5"],[25900,"8da0b702590d47cee2d6e794ecfa"],[25900,"8dabe7c59915ae0d300456c07a3a"],[25900,"8d0d0c07501b27df52d6f16313cd"],[26000,"8da0b702f8122006005ab8b0ce08"],[26100,"8da5b3a758b9a40abcd988fbbe21"],[26160,"8d0d0c07990c131e28380be095c5"],[26220,"8da0b702590d4024d24bdbd22987"],[26280,"8da0b70299105e82780801c3b2fb"],[26340,"8d0d0c07f8200002004ab89cec0b"],[26400,"8d0d0c07501b1035a44be0c65b0a"],[26500,"8dabe7c558af87d086c6c8c79a71"],[26600,"8da5b3a7990da30b30045d0265b6"],[26700,"8da0b702590d47ceded6f0aa1733"],[26789,"8da0b70299105e827804818cee3b"],[26878,"8d0d0c07990c131e083c0b9ddfec"],[26967,"8dabe7c59915ae0d5004560ffe41"],[27056,"8d0d0c07501b1035c04bdfc00f55"],[27144,"8da0b702590d4024ce4be4a81fbb"],[27233,"8da0b70299105e8258088181c812"],[27322,"8d0d0c07990c131e083c0b9ddfec"],[27411,"8dabe7c5ea428867215c08fe0874"],[27500,"8d0d0c07501b17df9ed6eef4a98e"],[27600,"8dabe7c558af87d09cc69d937b6c"],[27750,"8d0d0c07e1058a00000000c4d44e"],[27900,"8da0b702590d47cedad6f89d365b"],[27967,"8da0b70299105d8278048189f169"],[28033,"8d0d0c07990c131e08400b7791f7"],[28100,"8d0d0c07501b0035f24bde8cc707"],[28167,"8da0b702590d47ced8d6fc86a6ef"],[28233,"8da0b70299105d82700481e75361"],[28300,"8d0d0c07990c131e08400b7791f7"],[28367,"8d0d0c07501b0036064bddc0bd22"],[28433,"8da0b702f8122006005ab8b0ce08"],[28500,"8dabe7c558af87d0b0c6738ae418"],[28700,"8da0b702590d4024ca4bf1606258"],[28740,"8da0b70299105d8258088184d740"],[28780,"8dabe7c59915ad0d5004560ae113"],[28820,"8d0d0c07f8200002004ab89cec0b"],[28860,"8d0d0c07990c131e08400b7791f7"],[28900,"8d0d0c07501b07dfe0d6ec948290"],[29020,"8da0b702590d4024c84bf57bf2ec"],[29140,"8da0b70299105d82780801c6ada9"],[29260,"8d0d0c07990c131e08400b7791f7"],[29380,"8d0d0c075019f7dffed6eb0820ae"],[29500,"8da0b702590d4024c64bf8c61531"],[29500,"8da0b70299105d82780881c1ab69"],[29500,"8dabe7c558af87d0c6c647b5c4ab"],[29600,"8d0d0c07990c131e08400a8865fe"],[29700,"8dabe7c59915ad0d5004560ae113"],[29800,"8da0b70227c74d5462082004597a"],[29900,"8d0d0c075019f7e010d6eadc4ca3"],[29960,"8dabe7c5ea428867215c08fe0874"],[30020,"8da0b702590d4024c44bfedd999e"],[30080,"8da0b70299105d82780881c1ab69"],[30140,"8dabe7c5f8230006004ab8db4fc2"],[30200,"8da5b3a758b9a40b04d8e13f16db"],[30250,"8d0d0c07990c131e08400b7791f7"],[30300,"8d0d0c075019f036664bd9f36ceb"],[30350,"8d0d0c0722594377e37820b79a0d"],[30400,"8da27e81592147e0c8cd52d39b19"],[30450,"8da0b702e1150d0000000004eb56"],[30500,"8dabe7c5e1122e00000000bdd208"],[30550,"8dabe7c59915ad0d58045664431b"],[30600,"8da0b702590d47ced0d712e31936"],[30633,"8da0b70299105d82780881c1ab69"],[30667,"8da5b3a7ea466864013c085e68e2"],[30700,"8dabe7c558af87d0dcc61b1ea1d3"],[30700,"8da3627d58a584044cef443f79bd"],[30800,"8d0d0c07990c131de83c0a3bacc6"],[30900,"8da0b702f8122006005ab8b0ce08"],[31000,"8d0d0c075019f036824bd81e4930"],[31088,"8da0b702590d47ceced71783ca97"],[31175,"8da0b70299105d82700881af0961"],[31263,"8d0d0c07f8200002004ab89cec0b"],[31350,"8d0d0c07990c131de83c0bc458cf"],[31438,"8d0d0c075019e7e05cd6e7c0574e"],[31525,"8da5b3a723041333c31c2045cc84"],[31613,"8dabe7c59915ad0d58045664431b"],[31700,"8da0b702590d3024be4c0df97985"],[31733,"8da0b70299105c82700401e35f6f"],[31767,"8da5b3a7990da30b30045efd8da4"],[31800,"8dabe7c558af87d0f4c5ecdd1d9e"],[31867,"8d0d0c07990c131de8380a039ac6"],[32000,"8d0d0c075019e036b24bd7497da1"],[32200,"8da5b3a758b9a40b28d88f218f6f"],[32275,"8da0b702590d37cecad720d92c7f"],[32350,"8d0d0c07990c131dc8380a46e6ef"],[32425,"8dabe7c5ea428867215c08fe0874"],[32500,"8d0d0c075019e036d04bd662a551"],[32575,"8d0d0c07e1058a00000000c4d44e"],[32650,"8da0b70299105c82980881e72171"],[32725,"8dabe7c59915ad0d58045664431b"],[32800,"8dabe7c558af8027063a57dd52c4"],[32883,"8da5b3a7f82300060049b8e08700"],[32967,"8d0d0c07990c131dc8380bb912e6"],[33050,"8dabe7c5234d7071e74c209887e7"],[33133,"8da5b3a758b9a062304d60bfe899"],[33217,"8d0d0c075019d036ec4bd4f2875f"],[33300,"8da0b702590d47cec6d729ec1db6"],[33383,"8da0b70299105c82b0048184a550"],[33467,"8da0b702f8122006005ab8b0ce08"],[33550,"8da5b3a7990da30b30045efd8da4"],[33633,"8d0d0c07990c121dc83c0a7dda21"],[33800,"8d0d0c075019d7e0c4d6e437a8c9"],[33800,"8d0d0c07f8200002004ab89cec0b"],[33800,"8da0b702590d47cec4d72e086510"],[33850,"8da0b70299105b82b004818d913a"],[33900,"8dabe7c558af87d120c595812f4b"],[34000,"8dabe7c59915ad0d58045664431b"],[34100,"8d0d0c07990c121dc83c0b822e28"],[34200,"8da5b3a758b9a062424d374a33ec"],[34286,"8da0b70299105b82d00401451381"],[34371,"8d0d0c075019d7e0e0d6e3ba55c0"],[34457,"8da5b3a7990da30b30045d0265b6"],[34543,"8d0d0c07990c131dc83c0a7ed0ef"],[34629,"8da0b70227c74d5462082004597a"],[34714,"8da0b70299105b82d00481421541"],[34800,"8dabe7c558af8027303a049518d1"],[34850,"8dabe7c59915ad0d58045664431b"],[34900,"8d0d0c075019c7e0fcd6e2f1ba35"],[34950,"8dabe7c5ea428867215c08fe0874"],[35000,"8d0d0c07990c131dc83c0b8124e6"],[35050,"8dabe7c5f8230006004ab8db4fc2"],[35100,"8da5b3a758b9a062544d0dbac365"],[35186,"8da0b70299105b82d008010d4981"],[35271,"8d0d0c075019c7e110d6e159dcae"],[35357,"8da5b3a7990da30b30045d0265b6"],[35443,"8d0d0c07990c131da83c0ab15494"],[35529,"8d0d0c0722594377e37820b79a0d"],[35614,"8da5b3a7ea466864013c085e68e2"],[35700,"8d0d0c075019c7e122d6e025b81c"],[35800,"8dabe7c558af80274439dea69631"],[35950,"8dabe7c59915ad0d58045664431b"],[36100,"8da5b3a758b9a062664ce4cd9e2b"],[36220,"8d0d0c075019c7e13ed6df5f8e20"],[36340,"8d0d0c07f8200002004ab89cec0b"],[36580,"8d0d0c07990c131da83c0ab15494"],[36700,"8d0d0c075019b7e152d6dea54bfe"],[36800,"8dabe7c558af87d15cc51dcd6a80"],[36980,"8dabe7c59915ad0d58045664431b"],[37160,"8d0d0c07990c131da83c0b4ea09d"],[37340,"8dabe7c5ea428867215c08fe0874"],[37520,"8dabe7c59915ad0d58045664431b"],[37700,"8d0d0c075019b7e180d6ddfcb381"],[37700,"8dabe7c558af80276e39876a074f"],[38200,"8d0d0c07990c131d88380acc1ebd"],[38700,"8d0d0c075019a037ee4bcbaed4be"],[38880,"8dabe7c59915ad0d5004560ae113"],[39060,"8dabe7c558af802784395d3e841c"],[39240,"8d0d0c07f8200002004ab89cec0b"],[39420,"8dabe7c59915ad0d5004560ae113"],[39600,"8d0d0c075019a0381e4bc925e04b"],[39750,"8d0d0c07990c131d68340aa1c46b"],[39900,"8dabe7c558af87d19ec49a40e599"],[40017,"8dabe7c5ea428867215c08fe0874"],[40133,"8dabe7c5f8230006004ab8db4fc2"],[40250,"8dabe7c59915ad0d5004560ae113"],[40367,"8d0d0c07990c131d682c0a31706b"],[40483,"8dabe7c5e1122e00000000bdd208"],[40600,"8d0d0c07501997e20cd6d7527662"],[40700,"8da0b70299105882b020818efc61"],[40800,"8dabe7c558af8027b039046d0afc"],[41025,"8da0b702f8122006005ab8b0ce08"],[41250,"8d0d0c07990c131d682c0a31706b"],[41475,"8dabe7c59915ad0d58045664431b"],[41700,"8d0d0c07501997e23ad6d61943d4"],[41860,"8dabe7c558af87d1c6c4498276b9"],[42020,"8dabe7c59915ad0d58045664431b"],[42180,"8dabe7c5ea428867215c08fe0874"],[42340,"8d0d0c07990c141d48240a0d5428"],[42500,"8d0d0c0750199038ac4bc4e23d2e"],[42650,"8d0d0c07e1058a00000000c4d44e"],[42800,"8dabe7c558af8027da38b0df9613"],[42950,"8da5b3a7f82300060049b8e08700"],[43100,"8dabe7c5234d7071e74c209887e7"],[43250,"8dabe7c59915ad0d58045664431b"],[43400,"8d0d0c07990c141d48240a0d5428"],[43550,"8da27e819910370e58100c52c781"],[43700,"8d0d0c07501987e298d6d29d0704"],[43800,"8dabe7c558af8027ee38898e5a23"],[43929,"8da27e81ea0ad870015c00df9121"],[44057,"8d0d0c07f8200002004ab89cec0b"],[44186,"8dabe7c59915ad0d58045664431b"],[44314,"8d0d0c07990c141d281c0a6c205a"],[44443,"8da27e81592127e20acd9ba7de47"],[44571,"8da27e819910370e58100c52c781"],[44700,"8d0d0c07501987e2c6d6d088fcd3"],[44800,"8dabe7c558af87d204c3cfa8275c"],[44877,"8dabe7c5ea428867215c08fe0874"],[44954,"8d0d0c07990c141d081c0a295c73"],[45031,"8dabe7c5f8230006004ab8db4fc2"],[45108,"8da27e81ea0ad870015c00df9121"],[45185,"8dabe7c59915ad0d58045664431b"],[45262,"8da27e81592127e218cd9e9efeea"],[45338,"8da27e819910370e58140c6af181"],[45415,"8d0d0c07990c141d081c0a295c73"],[45492,"8da27e81e116260000000095bccf"],[45569,"8d0d0c0750198039424bbe9c79ba"],[45646,"8da27e81592127e226cda1ba1c7d"],[45723,"8da27e819910360e58100c51cd4f"],[45800,"8dabe7c558af80281a3832cb9c1f"],[45900,"8d0d0c07990c141ce8180a1f17f6"],[46000,"8d0d0c0722594377e37820b79a0d"],[46100,"8da27e81213b2d960a082039f0c9"],[46200,"8dabe7c59915ad0d5808562c191b"],[46300,"8d0d0c07501987e30ed6cd5203fc"],[46400,"8d0d0c07990c141ce8180a1f17f6"],[46600,"8d0d0c07f8200002004ab89cec0b"],[46700,"8dabe7c558af87d22cc3807fda55"],[46800,"8d0d0c0750198039704bbc1ff51a"],[47083,"8dabe7c59915ad0d5808562c191b"],[47367,"8dabe7c5ea428867215c08fe0874"],[47650,"8dabe7c558af80284237e2acabad"],[47933,"8dabe7c59915ad0d5808562c191b"],[48217,"8da0b702590d60247e4c9c93880b"],[48500,"8da0b702590d50247c4ca0d8bc9b"],[48550,"8da0b702991059821804014d58ea"],[48600,"8dabe7c558af87d254c332058385"],[49050,"8dabe7c59915ad0d5808562c191b"],[49500,"8dabe7c558af87d266c30a87423a"],[49620,"8da0b70227c74d5462082004597a"],[49740,"8dabe7c5ea428867215c08fe0874"],[49860,"8dabe7c59915ad0d780856696532"],[49980,"8dabe7c5f8230006004ab8db4fc2"],[50100,"8da0b702590d6024784caebe4cfe"],[50250,"8d0d0c07990c141ca8100ae583a4"],[50400,"8dabe7c558af87d27ac2e5f71cfe"],[50675,"8dabe7c5e1122e00000000bdd208"],[50813,"8dabe7c59915ad0d780456213f32"],[50950,"8da0b702590d6024744cb718db4f"],[51088,"8da0b70299105a823804810a3d51"],[51225,"8da0b702f8122006005ab8b0ce08"],[51363,"8da5b3a7990da30b18045d29bb97"],[51500,"8dabe7c558af8028923741e83c20"],[51500,"8da0b702590d57ce82d7c69bfc58"],[51667,"8da0b70299105a82380801456191"],[52000,"8da5b3a758b9a0637c4a5393f76e"],[52050,"8dabe7c59915ad0d780456213f32"],[52100,"8da0b702590d5024724cbf65a785"],[52200,"8da0b70299105a82380c817a5151"],[52300,"8d0d0c075019703a6a4bb2223d9d"],[52400,"8dabe7c5ea428867215c08fe0874"],[52500,"8dabe7c558af87d2a4c2910a65a1"],[52580,"8d0d0c07990c141ca8080a7537a4"],[52660,"8da5b3a7990da30b18045ed65385"],[52740,"8da0b70299105a825810811d572a"],[52820,"8d0d0c07501977e432d6c2564f91"],[52900,"8da5b3a758b9a0638e4a29725555"],[52971,"8da5b3a7f82300060049b8e08700"],[53043,"8dabe7c59915ad0d780456213f32"],[53114,"8da0b702590d57ce7ed7d43a3751"],[53186,"8da0b70299105a825810811d572a"],[53257,"8da5b3a7ea466864013c085e68e2"],[53329,"8dabe7c5234d7071e74c209887e7"],[53400,"8dabe7c558af87d2b8c26b8b1a02"],[53500,"8da27e819910370e38100c9d43fa"],[53600,"8da5b3a7990da30b18045ed65385"],[53700,"8da0b702590d50246c4cce07ee1a"],[53775,"8da0b70299105a827818012f41c3"],[53850,"8d0d0c07f8200002004ab89cec0b"],[53925,"8da27e81ea0ad870015c00df9121"],[54000,"8da5b3a758b99063a249fcd69097"],[54050,"8da0b702590d50246a4cd1d4570c"],[54100,"8da0b70299105b8278200182bb04"],[54150,"8d0d0c07990c131c88040a7125e7"],[54200,"8dabe7c59915ad0d7004564f9d3a"],[54250,"8d0d0c07501977e47ad6bf4f0b90"],[54300,"8dabe7c558af8028d036c97017dc"],[54340,"8da27e81592107e2e6cdce465bda"],[54380,"8da27e819910370e38100c9d43fa"],[54420,"8da5b3a7990da40b18045d208ffd"],[54460,"8d0d0c07990c131c80040a1f87ef"],[54500,"8da0b702590d47ce78d7e1d88f60"],[54700,"8d0d0c07501977e48cd6be64c2d9"],[54767,"8dabe7c5ea428867215c08fe0874"],[54833,"8da0b70227c74d5462082004597a"],[54900,"8da27e81592100393c4292bac70c"],[54933,"8da27e819910370e38100c9d43fa"],[54967,"8dabe7c5f8230006004ab8db4fc2"],[55000,"8da5b3a758b9a40cb6d4ea32b20a"],[55133,"8dabe7c59915ad0d7004564f9d3a"],[55267,"8da0b702590d57ce76d7e655a82a"],[55400,"8dabe7c558af8028e6369a385dc9"],[55450,"8da5b3a7990da40b10045d4e2df5"],[55500,"8da27e8159210039484295608647"],[55550,"8da27e819910370e38100c9d43fa"],[55600,"8da3627d991417bab00854484475"],[55650,"8d0d0c07990c141c80080a5ee985"],[55700,"8da0b702590d5024644cdf9658c3"],[55744,"8da0b70299105b82b81c828c6f20"],[55789,"8da0b702e1150d0000000004eb56"],[55833,"8da27e81213b2d960a082039f0c9"],[55878,"8da5b3a7ea466864013c085e68e2"],[55922,"8da27e81f8132006005ab848315e"],[55967,"8da27e81592107e30acdd61145e7"],[56011,"8da27e819910380e38100b73f1c4"],[56056,"8da3627d2314e673cf4d2011090e"],[56100,"8da3627d58a58051c0647ade432a"],[56129,"8dabe7c59915ad0d7004564f9d3a"],[56157,"8da0b702f8122006005ab8b0ce08"],[56186,"8da3627dea3e9860015f888cbc76"],[56214,"8d0d0c0722594377e37820b79a0d"],[56243,"8da0b702590d57ce72d7ef9d7d4b"],[56271,"8da0b70299105b82d81881843549"],[56300,"8d0d0c07501977e4d4d6bba210ac"],[56300,"8dabe7c558af87d2f6c1ef5691a8"],[56500,"8da27e81ea0ad870015c00df9121"],[56600,"8da27e815921003960429b4b0c27"],[56700,"8d0d0c07990c141c60080a7b6953"],[56800,"8da0b702590d57ce70d7f479ad6f"],[56875,"8da0b70299105b82d81481cc6f49"],[56950,"8da27e81592107e320cddc215f33"],[57025,"8da27e819910380e18100b368ded"],[57100,"8d0d0c07501977e4f8d6ba416b80"],[57100,"8da3627d58a587fab0ef0dfe55ff"],[57133,"8dabe7c59915ad0d7004564f9d3a"],[57167,"8dabe7c5ea428867215c08fe0874"],[57200,"8da0b702590d40245e4ceeb513f1"],[57250,"8da0b70299105b82f81881c14960"],[57300,"8dabe7c558af87d30ac1c7ddeaed"],[57600,"8da27e81592100397642a04408a7"],[57900,"8da27e81592107e336cde12e7f9e"],[57971,"8d0d0c07501977e526d6b89569a9"],[58043,"8dabe7c59915ad0d7004564f9d3a"],[58114,"8d0d0c07990c141c60080a7b6953"],[58186,"8dabe7c558af802924361f903e18"],[58257,"8da27e81f8132006005ab848315e"],[58329,"8da0b702590d47ce68d802a52bf2"],[58400,"8da27e81592100398c42a6c9e2c6"],[58471,"8da27e819910380e18100cc95dc9"],[58543,"8da3627d991417bab00854484475"],[58614,"8d0d0c075019703b904ba684d66c"],[58686,"8da3627dea3e9860015f888cbc76"],[58900,"8da27e81592100399642a861f81d"],[58900,"8da27e819910380e18100cc95dc9"],[58900,"8da27e81ea0ad870015c00df9121"],[58900,"8da0b702590d4024564cfcdb4d3a"],[58967,"8da0b70299105b83181081bf58e5"],[59033,"8d0d0c07f8200002004ab89cec0b"],[59100,"8d0d0c075019703ba24ba60746d7"],[59200,"8dabe7c558af80293835f903ffb9"],[59300,"8dabe7c59915ad0d780456213f32"],[59400,"8da27e819910380e18100cc95dc9"],[59500,"8da0b70299105b83381481c212cc"],[59600,"8d0d0c07990c141c60080a7b6953"],[59700,"8da3627d991417bab00854484475"],[59800,"8dabe7c5ea428867215c08fe0874"],[59900,"8da3627d58a587f9acef07068961"],[59950,"8dabe7c5f8230006004ab8db4fc2"],[60000,"8d0d0c075019703bd04ba40e3225"],[60200,"8dabe7c558af80294c35d2d83718"],[60500,"8da27e81592107e36ccdeef3691e"],[60800,"8da27e81592107e374cdf0403b9f"],[61000,"8da3627d58a5804ffa6470d4c5be"],[61200,"8dabe7c5e1122e00000000bdd208"],[61400,"8dabe7c558af87d35cc125a3f722"],[61600,"8da3627dea3e9860015f888cbc76"],[61800,"8dabe7c59915ad0d780456213f32"],[62000,"8da3627d58a587f8ecef0258405f"],[62100,"8dabe7c558af802978357c71cd14"],[62260,"8da3627df8230002004ab8760258"],[62420,"8da3627d991417bab00454001e75"],[62580,"8da27e819910380df80c0c3858ea"],[62740,"8dabe7c59915ad0d780456213f32"],[62900,"8da3627d58a5804f3c646c986845"],[63057,"8da3627de106290000000030ef86"],[63214,"8da3627d991417bab00454001e75"],[63371,"8dabe7c5234d7071e74c209887e7"],[63529,"8dabe7c59915ad0d7004564f9d3a"],[63686,"8da3627dea3e9860015f888cbc76"],[63843,"8da27e81ea0ad870015c00df9121"],[64000,"8da27e815921003a0642c3d765fb"],[64180,"8da3627d991417bab00454001e75"],[64360,"8da27e819910380dd0080c2bb0cb"],[64540,"8dabe7c59915ad0d7004564f9d3a"],[64720,"8dabe7c5ea428867215c08fe0874"],[64900,"8da5b3a758b9940d60d35af5fc38"],[65000,"8da3627d58a5804e7864680e0476"],[65000,"8da27e81592107e3d0ce07307073"],[65100,"8dabe7c558af8029b834fe1826b0"],[65200,"8da27e81ea0ad870015c00df9121"],[65300,"8da3627d58a5804e526467c1d29d"],[65400,"8da27e81592107e3d8ce095e863a"],[65588,"8da27e819910380db00c0cdc02b0"],[65775,"8da3627d991417bab00854484475"],[65963,"8dabe7c59915ad0d7004564f9d3a"],[66150,"8da3627dea3e9860015f888cbc76"],[66338,"8da3627d2314e673cf4d2011090e"],[66525,"8da3627d991417bab00854484475"],[66713,"8dabe7c59915ad0d7004564f9d3a"],[66900,"8da3627d58a5804dc06463910561"],[67200,"8dabe7c558af87d3dac02c9483ec"],[67300,"8da3627df8230002004ab8760258"],[67400,"8dabe7c5ea428867215c08fe0874"],[67500,"8da3627d991417bab00854484475"],[67600,"8da0b702590d3024264d49ad2de6"],[67900,"8d0d0c07501977e6e0d6a75a2bb5"],[68000,"8da3627d58a587f6baeef670859e"],[68100,"8da0b70299105883d808014d0988"],[68200,"8dabe7c558af8029fa347b71bc9f"],[68333,"8d0d0c07990c121c60080a7157f7"],[68467,"8da3627d991417bab00854484475"],[68600,"8da3627dea3e9860015f888cbc76"],[68733,"8da27e81ea0ad870015c00df9121"],[68867,"8dabe7c59915ad0d7004564f9d3a"],[69000,"8da3627d58a5804d02645feb382e"],[69225,"8dabe7c558af87d40abfd0077db3"],[69450,"8da3627d991417bab00454001e75"],[69675,"8dabe7c5ea428867215c08fe0874"],[69900,"8da3627d58a587f608eef21929be"],[70000,"8dabe7c5f8230006004ab8db4fc2"],[70100,"8dabe7c59915ad0d7004564f9d3a"],[70200,"8dabe7c558af87d41cbfad0bde7e"],[70400,"8da3627d991417bab00454001e75"],[70600,"8da3627d58a5804c46645b7d541d"],[70800,"8dabe7c5e1122e00000000bdd208"],[71000,"8dabe7c59915ad0d7004564f9d3a"],[71200,"8dabe7c558af87d430bf84e9148e"],[71343,"8da3627dea3e9860015f888cbc76"],[71486,"8da27e81ea0ad870015c00df9121"],[71629,"8da3627d991417bab8085426e67d"],[71771,"8da27e819910370db0040cbd0caa"],[71914,"8da3627d58a5804bfa6459857527"],[72057,"8dabe7c59915ad0d780456213f32"],[72200,"8da27e815921003ab842f019cb9b"],[72233,"8da27e819910370db0040cbd0caa"],[72267,"8da3627df8230002004ab8760258"],[72300,"8dabe7c558af802a5033d1fb42e3"],[72375,"8dabe7c5ea428867215c08fe0874"],[72450,"8da3627d991417bad80854e96206"],[72525,"8da5b3a7990da40b38045d65f3d4"],[72600,"8da27e81592107e474ce2f7a1a96"],[72700,"8da3627d58a587f502eeec109058"],[72800,"8da3627de106290000000030ef86"],[72900,"8da5b3a758b9940dead2146728be"],[73000,"8dabe7c59915ad0d780456213f32"],[73100,"8dabe7c5234d7071e74c209887e7"],[73200,"8dabe7c558af802a6433a756adfb"],[73371,"8da3627d991417bad80854e96206"],[73543,"8da5b3a7990da40b38045d65f3d4"],[73714,"8da3627d58a5804b3c6455c93804"],[73886,"8da3627dea3e9860015f888cbc76"],[74057,"8da3627d58a5804b16645406baae"],[74229,"8dabe7c59915ad0d780456213f32"],[74400,"8dabe7c558af87d472bf007f7aa8"],[74550,"8da3627d991417bad80854e96206"],[74700,"8da3627d58a587f448eee8c4a7e5"],[74829,"8dabe7c5ea428867215c08fe0874"],[74957,"8da5b3a7990da40b18045edf67ef"],[75086,"8dabe7c5f8230006004ab8db4fc2"],[75214,"8dabe7c59915ad0d58045664431b"],[75343,"8dabe7c558af87d486bedb451fec"],[75471,"8da3627d991417bad80854e96206"],[75600,"8da3627d58a587f3f6eee6276607"],[75775,"8da3627dea3e9860015f888cbc76"],[75950,"8dabe7c59915ad0d58045664431b"],[76125,"8da3627d991417bad80854e96206"],[76300,"8dabe7c558af87d49cbeae103f41"],[76450,"8da3627d2314e673cf4d2011090e"],[76600,"8da3627d58a587f38eeee3a4c85b"],[76760,"8da3627df8230002004ab8760258"],[76920,"8dabe7c59915ad0d58045664431b"],[77080,"8da3627d991417bad80854e96206"],[77240,"8dabe7c5ea428867215c08fe0874"],[77400,"8dabe7c558af87d4b2be83e96505"],[77733,"8dabe7c59915ad0d5004560ae113"],[78067,"8da3627d991417bad80854e96206"],[78400,"8dabe7c558af802ad232cd044bce"],[78500,"8da5b3a7990da40b18045d208ffd"],[78600,"8da3627dea3e9860015f888cbc76"],[78700,"8da5b3a758b990655645f8a96889"],[78750,"8da5b3a7e10d2800000000aefd57"],[78800,"8da3627d58a5804950644af7f545"],[79000,"8dabe7c59915ad0d58045664431b"],[79200,"8dabe7c558af802ae632a656f85d"],[79567,"8dabe7c5ea428867215c08fe0874"],[79933,"8dabe7c5f8230006004ab8db4fc2"],[80300,"8dabe7c558af87d4eebe0e1f90d8"],[80633,"8dabe7c5e1122e00000000bdd208"],[80967,"8dabe7c59915ad0d780456213f32"],[81300,"8dabe7c558af87d504bde18c150f"],[81750,"8dabe7c5ea428867215c08fe0874"],[82200,"8dabe7c558af802b243229fef7fb"],[82733,"8dabe7c5234d7071e74c209887e7"],[83267,"8dabe7c59915ad0d780456213f32"],[83800,"8dabe7c59915ad0d780456213f32"],[84333,"8dabe7c5ea428867215c08fe0874"],[84867,"8dabe7c5f8230006004ab8db4fc2"],[85400,"8dabe7c558af87d558bd39864f55"],[85850,"8dabe7c59915ad0d7004564f9d3a"],[86300,"8dabe7c558af87d56ebd0f338baf"],[86850,"8dabe7c59915ad0d780456213f32"],[87400,"8dabe7c5ea428867215c08fe0874"],[87950,"8dabe7c59915ad0d780456213f32"],[88500,"8dabe7c558af802baa312344d22f"],[88950,"8dabe7c59915ad0d780456213f32"],[89400,"8dabe7c558af802bbe30fa5b2ba6"],[89700,"8da5b3a758b99066164431aa9b6b"],[89900,"8da5b3a7990da40b18045edf67ef"],[90100,"8dabe7c5ea428867215c08fe0874"],[90300,"8dabe7c59915ad0d780456213f32"],[90500,"8dabe7c558af87d5c6bc6201a220"],[90600,"8dabe7c5e1122e00000000bdd208"],[90700,"8da5b3a758b9940f1ecf3da78d8f"],[90800,"8dabe7c59915ad0d780456213f32"],[90900,"8da5b3a7990da40b18045edf67ef"],[91000,"8dabe7c558af802bec309feab189"],[91200,"8dabe7c59915ad0d780456213f32"],[91300,"8da5b3a723041333c31c2045cc84"],[91400,"8dabe7c5ea428867215c08fe0874"],[91500,"8da5b3a7990da40b10045eb1c5e7"],[91600,"8dabe7c558af802c0230735b9422"],[91700,"8da5b3a758b990664c43b05affea"],[91800,"8dabe7c5234d7071e74c209887e7"],[91900,"8dabe7c59915ad0d780456213f32"],[92000,"8da5b3a7f82300060049b8e08700"],[92100,"8da5b3a7ea466864013c085e68e2"],[92200,"8da3627de106290000000030ef86"]],"dump1090":{"0d0c07":{"flight":"VTM787","category":"A2","fixes":[[500,30.285676,-97.775134],[1600,30.28688,-97.775197],[2700,30.287933,-97.775304],[3700,30.289167,-97.775409],[5000,30.290703,-97.775519],[6100,30.291641,-97.775628],[6900,30.292658,-97.775684],[7900,30.293839,-97.775789],[9000,30.294937,-97.775843],[10000,30.296036,-97.775951],[11100,30.297267,-97.776068],[12100,30.298384,-97.776123],[13200,30.299606,-97.77622],[14300,30.300805,-97.776343],[15300,30.301922,-97.776398],[16300,30.303177,-97.776489],[17300,30.304157,-97.776617],[18400,30.305374,-97.776705],[19000,30.306061,-97.776759],[20000,30.307159,-97.776812],[21500,30.308859,-97.777002],[22700,30.310162,-97.777112],[23700,30.311279,-97.777167],[24800,30.312515,-97.777297],[25900,30.313747,-97.777386],[26400,30.314301,-97.777459],[27500,30.315516,-97.777551],[28900,30.317052,-97.777661],[29900,30.318169,-97.777771],[31000,30.319382,-97.777889],[32000,30.32048,-97.777943],[32500,30.321167,-97.777997],[33800,30.322359,-97.778101],[34900,30.323662,-97.77821],[35700,30.324547,-97.77832],[36700,30.325664,-97.77843],[37700,30.326735,-97.778485],[38700,30.327713,-97.77859],[39600,30.328812,-97.778697],[40600,30.329993,-97.778815],[41700,30.331064,-97.77887],[42500,30.332062,-97.778967],[43700,30.333252,-97.779089],[44700,30.334323,-97.779199],[46300,30.335999,-97.779364],[46900,30.336548,-97.779397],[52800,30.342795,-97.779968],[54700,30.34489,-97.780188],[56300,30.346566,-97.780353],[57100,30.347404,-97.780408],[59100,30.349411,-97.780582],[60000,30.350464,-97.78069],[67900,30.358763,-97.781451]]},"a3627d":{"flight":"ENY3344","category":"A3","fixes":[[1500,30.597482,-97.431921],[3700,30.592804,-97.432197],[11700,30.575363,-97.433113],[12600,30.573182,-97.43324],[14700,30.568853,-97.433459],[15600,30.566898,-97.433569],[21500,30.554049,-97.434283],[30800,30.534078,-97.435327],[56100,30.479004,-97.438283],[57100,30.476819,-97.438348],[59900,30.470767,-97.438678],[61000,30.468613,-97.438821],[62000,30.466298,-97.438953],[62900,30.464264,-97.439037],[65000,30.459778,-97.439252],[65300,30.458908,-97.439306],[66900,30.455566,-97.439521],[68000,30.453217,-97.439612],[69000,30.451218,-97.439737],[69900,30.449074,-97.439832],[70800,30.446915,-97.439952],[71800,30.445175,-97.44006],[72700,30.442976,-97.440161],[74700,30.438646,-97.440381],[75600,30.436738,-97.440491],[76600,30.434317,-97.440656],[78900,30.429565,-97.440868]]},"a0b702":{"flight":"145TX","category":"A7","fixes":[[1900,30.218033,-97.789576],[3100,30.217896,-97.789032],[4100,30.217802,-97.788593],[5000,30.217709,-97.788098],[6000,30.217616,-97.787604],[8000,30.217383,-97.78667],[9200,30.2173,-97.786129],[10000,30.217197,-97.785681],[11100,30.217117,-97.78516],[12000,30.217026,-97.784783],[13400,30.216871,-97.784088],[14000,30.216843,-97.783813],[15400,30.216732,-97.783099],[16400,30.216614,-97.782575],[17400,30.216545,-97.782111],[18200,30.216452,-97.781726],[19300,30.216385,-97.781175],[20200,30.216313,-97.780737],[21500,30.216173,-97.780023],[22400,30.21608,-97.779639],[22900,30.216064,-97.779397],[25900,30.215801,-97.777936],[26700,30.215708,-97.777441],[27900,30.215614,-97.777002],[28700,30.215561,-97.776543],[29500,30.215469,-97.776166],[30600,30.215382,-97.775574],[31700,30.215286,-97.775035],[32200,30.215242,-97.774805],[33800,30.215102,-97.774036],[48500,30.213776,-97.767119],[50100,30.213684,-97.766365],[51500,30.213566,-97.765686],[52100,30.213547,-97.765449],[53700,30.213409,-97.764641],[54500,30.213333,-97.764203],[55700,30.213226,-97.763726],[56800,30.213147,-97.763159],[57200,30.213089,-97.762918],[59000,30.212906,-97.762164],[67600,30.211807,-97.758017]]},"a5b3a7":{"flight":"AAL3010","category":"A3","fixes":[[1200,30.562317,-97.685367],[2400,30.562754,-97.687738],[3400,30.563187,-97.690107],[4300,30.563599,-97.692315],[5300,30.563965,-97.694361],[7200,30.564789,-97.698831],[8300,30.565201,-97.701039],[10200,30.565967,-97.705426],[11200,30.566345,-97.707555],[12200,30.566805,-97.709875],[13200,30.567215,-97.712241],[15200,30.568015,-97.716577],[16100,30.568405,-97.718703],[17200,30.568806,-97.721082],[22300,30.570877,-97.732382],[23200,30.571274,-97.734485],[25200,30.572065,-97.738989],[26100,30.572437,-97.740967],[30200,30.574113,-97.75014],[32200,30.574951,-97.754645],[34200,30.575729,-97.758987],[35100,30.576141,-97.761248],[36200,30.576553,-97.763456],[52000,30.582916,-97.798839],[52900,30.583328,-97.801101],[54000,30.583786,-97.803524],[55100,30.584215,-97.805896],[64900,30.588172,-97.827869],[72900,30.591384,-97.845776],[78700,30.593765,-97.858887],[89700,30.59816,-97.883391],[90700,30.598553,-97.885712]]},"abe7c5":{"flight":"SWA1940","category":"A3","fixes":[[5400,30.215378,-97.95631],[6400,30.215881,-97.958733],[7300,30.216313,-97.960913],[8300,30.216797,-97.963041],[9400,30.217337,-97.965692],[10400,30.217758,-97.967727],[17500,30.221237,-97.984045],[18500,30.221695,-97.986414],[21600,30.223251,-97.993631],[22600,30.223668,-97.995685],[23400,30.224087,-97.997662],[24500,30.224599,-98.000134],[25400,30.225018,-98.002057],[26500,30.225577,-98.004639],[27600,30.226089,-98.007001],[28500,30.226554,-98.009308],[29500,30.227066,-98.011725],[30700,30.227578,-98.014142],[31800,30.228137,-98.016724],[32800,30.228653,-98.019212],[33900,30.229161,-98.021503],[34800,30.229614,-98.023682],[35800,30.230072,-98.025728],[36800,30.230558,-98.028094],[37700,30.231033,-98.030413],[38800,30.231537,-98.032675],[39900,30.232094,-98.035291],[40800,30.232544,-98.037468],[41800,30.233025,-98.03974],[42800,30.233505,-98.041992],[43800,30.233963,-98.044093],[44800,30.234468,-98.046442],[45800,30.23497,-98.048778],[46700,30.235399,-98.050781],[48600,30.23633,-98.055066],[49500,30.236749,-98.057263],[50400,30.237215,-98.059296],[51500,30.237717,-98.061757],[52500,30.238192,-98.06391],[53400,30.238658,-98.065997],[54300,30.239136,-98.068219],[55400,30.239639,-98.070751],[56300,30.240101,-98.072809],[57300,30.240566,-98.075006],[58300,30.241058,-98.077375],[59200,30.241516,-98.079421],[60200,30.241974,-98.081521],[62200,30.242981,-98.086153],[65100,30.244446,-98.092939],[67200,30.245408,-98.097583],[68200,30.245956,-98.099994],[69400,30.246525,-98.102637],[70200,30.246944,-98.104559],[71200,30.24741,-98.106812],[72300,30.247925,-98.109149],[73200,30.248383,-98.111411],[74400,30.248946,-98.114062],[75300,30.249411,-98.116095],[76300,30.249923,-98.118567],[77400,30.250436,-98.120929],[78400,30.2509,-98.123151],[79200,30.251358,-98.125251],[80300,30.251832,-98.127356],[81300,30.252344,-98.129828],[82200,30.252777,-98.131983],[85400,30.254299,-98.139056],[86400,30.254811,-98.141364],[88500,30.255844,-98.146093],[89400,30.256302,-98.148301],[90500,30.25686,-98.150867],[91500,30.257355,-98.153202]]},"a27e81":{"flight":"N26VB","category":"A1","fixes":[[54900,30.335358,-97.90574],[55500,30.335632,-97.905579],[56500,30.336182,-97.905255],[57900,30.33693,-97.904828],[58900,30.337418,-97.904555],[60900,30.338373,-97.904004],[64000,30.339981,-97.903101],[65000,30.340514,-97.90274],[65400,30.3407,-97.902631],[72200,30.344055,-97.900678],[72700,30.344331,-97.900543]]}}} \ No newline at end of file diff --git a/src/data/fixtures/adsb-austin-dump1090-aircraft.json b/src/data/fixtures/adsb-austin-dump1090-aircraft.json new file mode 100644 index 000000000..88d2011e3 --- /dev/null +++ b/src/data/fixtures/adsb-austin-dump1090-aircraft.json @@ -0,0 +1,53 @@ +{ + "now": 1790127097.5, + "messages": 112, + "aircraft": [ + { + "hex": "a15c54", + "flight": "SKW3301 ", + "alt_baro": 5050, + "gs": 287.5, + "track": 291.2, + "baro_rate": 3072, + "seen": 31.5, + "messages": 14, + "rssi": -28.2 + }, + { + "hex": "ae5d8a", + "flight": "SHINR42 ", + "lat": 30.269662, + "lon": -97.793262, + "alt_baro": 1600, + "gs": 129.9, + "track": 330.5, + "baro_rate": 64, + "seen": 30.5, + "seen_pos": 34.6, + "messages": 74, + "rssi": -30.0 + }, + { + "hex": "a3c775", + "lat": 30.360625, + "lon": -97.919659, + "alt_baro": 34000, + "gs": 434.4, + "track": 283.7, + "baro_rate": 0, + "seen": 38.2, + "seen_pos": 43.4, + "messages": 20, + "rssi": -30.5 + }, + { + "hex": "a46e6a", + "gs": 319.8, + "track": 351.2, + "baro_rate": 2688, + "seen": 32.0, + "messages": 4, + "rssi": -37.1 + } + ] +} diff --git a/src/data/fixtures/adsb-austin-frames.txt b/src/data/fixtures/adsb-austin-frames.txt new file mode 100644 index 000000000..089695868 --- /dev/null +++ b/src/data/fixtures/adsb-austin-frames.txt @@ -0,0 +1,112 @@ +*8da46e6a99143227b0ac21405d5c; +*8da46e6aea428858015c080bb810; +*5da46e6ae86d20; +*8da3c775990da70d180457361e57; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a580d77d658d63e32c82f; +*5dae5d8a54a93d; +*8da3c77558af803cce430dad950f; +*200015b8f3156e; +*02e615b82df0c7; +*02e615b82df0c7; +*8da3c775990da70d180458c9be1f; +*8dae5d8a99a4400e70088202ff80; +*5dae5d8a54a93d; +*200015b8f3156e; +*200015b8f3156e; +*8dae5d8a580d702c844b24b6523e; +*8dae5d8af80300020049bc1cd8f3; +*200015b8f3156e; +*20000197d80069; +*5dae5d8a54a93d; +*020001970f9c90; +*8dae5d8a580d77d688d63170d088; +*8da3c77558af87e6a0cdf2c8bbf8; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a274c824e4b4ca06f9608; +*5dae5d8a54a93d; +*8dae5d8a99a4400e700c823ac980; +*020001970f9c90; +*5da15c54111a4b; +*8dae5d8a580d77d6a0d62aa4760f; +*5da3c7758f35d4; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a99a4410e500c827cbf67; +*8da15c54581b8019e24c96ebec6d; +*8dae5d8a580d77d6ccd61fccec2f; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500c827cbf67; +*8dae5d8a580d77d6d8d61cd91129; +*8dae5d8a99a4410e500c827cbf67; +*8dae5d8a580d77d6e2d619cbefa5; +*8dae5d8af80300020049bc1cd8f3; +*20000197d80069; +*80000197580d77d6e8d6172cc0e3; +*8dae5d8a580d702d044b0189a64f; +*020001970f9c90; +*8dae5d8a99a4410e500882448967; +*8dae5d8a580d77d6fad6128791c3; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500882448967; +*020001970f9c90; +*8da15c54581bc01a424c2f038351; +*8dae5d8ae1080800000000e6168f; +*8da3c77558af87e730ccd3243e2f; +*8dae5d8a580d87d728d6051c99a8; +*5dae5d8a54a93d; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a99a4410e500882448967; +*8dae5d8a580d802d584aea9aed46; +*5da15c54111a4b; +*8dae5d8a99a4410e500c827cbf67; +*2000019827a021; +*8dae5d8af80300020049bc1cd8f3; +*8dae5d8a580d87d754d5f94050dd; +*8dae5d8a99a4410e500c827cbf67; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500c827cbf67; +*02000198f03cd8; +*8dae5d8a580d87d76ed5f252fa10; +*8dae5d8aea00085ee53e004546c3; +*8da15c5499150a0df0ac0d713d8d; +*200015b8f3156e; +*8da3c775990da70d100458a71c17; +*8dae5d8a580d87d778d5ef5c1b0d; +*8dae5d8a99a4410e500882448967; +*5dae5d8a54a93d; +*02818393027eb0; +*5da3c7758f35d5; +*5da3c7758f35d5; +*5da3c7758f35d4; +*200015b8f3156e; +*8dae5d8a580d87d782d5ec2e3d53; +*8da15c54ea178858015e88a53d08; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500882448967; +*02000198f03cd8; +*8dae5d8ae1080800000000e6168f; +*200015b8f3156e; +*8dae5d8a580d802db04ad2d09e9c; +*02e195b8f5cd24; +*8da15c5499150b0db0b40d687b11; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a99a4410e500882448967; +*8dae5d8a580d802dc64accee13e8; +*2000019827a021; +*8dae5d8a99a4410e500881bb6175; +*8dae5d8a580d802ddc4ac5b9d917; +*8dae5d8af80300020049bc1cd8f3; +*80000198580d87d7cad5d9283036; +*02000198f03cd8; +*5da15c54111a4b; +*8dae5d8a580d87d7ecd5d0a28b94; +*8dae5d8a99a4410e500881bb6175; +*8da15c54234cb5f3cf0c6055934a; +*5dae5d8a54a93d; +*8da15c5499150d0d50c40e1a396a; +*8da15c5499150d0d30c40ed5bd11; +*5da15c54111a4b; +*5da46e6ae86d29; +*8da15c54581f201b484af27089b0; +*02000198f03cd8; +*5dae5d8a54a93d; diff --git a/src/data/layerState.js b/src/data/layerState.js index da1b86b9e..e559a4c40 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -480,6 +480,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), + Object.freeze({ + id: 'fire-perimeters', + token: '2', + disposition: 'enabled-only', + }), Object.freeze({ id: 'flights', token: 'f', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 51d39bc61..fb6d04a5d 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -194,8 +194,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, 27); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 27); + assert.equal(REGISTERED_LAYER_IDS.length, 28); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( @@ -2418,3 +2418,10 @@ test('the recent-imagery split is share-link only: never stored locally, and a s 50, ); }); + +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, ['fire-perimeters', 'recent-imagery', 'wind']); + assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2'); + assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index fed812d47..e501d04df 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -16,6 +16,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ 'enabled-only', 'enabled+options', 'enabled+mirrored-options', + // Registered but never serialized (for example hardware-local layers). + 'local-only', ]); function isAbortError(error) { @@ -2302,6 +2304,14 @@ export class LayerLifecycle { return () => this._activityListeners.delete(callback); } + /** + * Ask presentation to repaint layer rows now, for data that lands outside + * the manager tick (Transit's proximity polls, Directions' route steps). + */ + refreshLayerStats() { + this._publishActivity({ type: 'status' }); + } + _publishActivity(change) { for (const callback of this._activityListeners) { try { diff --git a/src/data/localReceiversProxy.test.mjs b/src/data/localReceiversProxy.test.mjs new file mode 100644 index 000000000..79ea9a558 --- /dev/null +++ b/src/data/localReceiversProxy.test.mjs @@ -0,0 +1,459 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + LOCAL_RECEIVERS_ROUTE, + createLocalReceiversHandler, + localReceiversProxy, + parseLocalReceiverFeeds, +} from '../../server/providers/local-receivers.js'; +import { localProviderPlugins } from '../../server/providers/local.js'; + +const FIXTURE = readFileSync( + new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url), + 'utf8', +); +const FIXTURE_NOW_MS = JSON.parse(FIXTURE).now * 1000; +const FEED_1090 = 'http://localhost:8080/data/aircraft.json'; +const FEED_978 = 'http://127.0.0.1:8978/data/aircraft.json'; + +function quietLogger() { + const lines = []; + return { + lines, + warn: (line) => lines.push(line), + info: (line) => lines.push(line), + }; +} + +function respond(status = 200, body = FIXTURE, headers = {}) { + return new Response(body, { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +async function call(handler, method = 'GET') { + const res = { + writeHead(status, headers) { + Object.assign(this, { status, headers }); + }, + end(body) { + this.body = body; + }, + }; + await handler.handle({ url: '/', method }, res); + return { ...res, json: JSON.parse(res.body) }; +} + +test('LOCAL_RECEIVER_FEEDS accepts local http(s) aircraft.json feeds per band', () => { + const { configured, feeds } = parseLocalReceiverFeeds( + ` 1090=${FEED_1090} , 978=http://skyaware.local/skyaware978/data/aircraft.json,1090=https://192.168.1.20:8443/tar1090/data/aircraft.json`, + ); + assert.equal(configured, true); + assert.deepEqual( + feeds.map(({ band, label, url }) => ({ band, label, url })), + [ + { band: '1090', label: '1090 MHz #1', url: FEED_1090 }, + { + band: '978', + label: '978 MHz UAT', + url: 'http://skyaware.local:80/skyaware978/data/aircraft.json', + }, + { + band: '1090', + label: '1090 MHz #2', + url: 'https://192.168.1.20:8443/tar1090/data/aircraft.json', + }, + ], + ); + assert.deepEqual(parseLocalReceiverFeeds(undefined), { + configured: false, + feeds: [], + }); + assert.equal(parseLocalReceiverFeeds(' , ').configured, false); +}); + +test('LOCAL_RECEIVER_FEEDS rejects public, link-local and named hosts, bad paths and bands', () => { + const cases = { + '1090=http://8.8.8.8/data/aircraft.json': 'host must be', + '1090=http://169.254.169.254/latest/aircraft.json': 'host must be', + '1090=http://example.com/data/aircraft.json': 'host must be', + '1090=http://[::1]:8080/data/aircraft.json': 'host must be', + '1090=http://localhost:8080/data/receiver.json': 'path must end', + '1090=http://localhost:8080/data/aircraft.json.bak': 'path must end', + '1090=http://localhost:8080/data/aircraft.json?x=1': 'query', + '1090=http://user:pw@localhost:8080/data/aircraft.json': 'credentials', + '1090=ftp://localhost/data/aircraft.json': 'scheme', + '1090=not a url': 'not a valid URL', + '868=http://localhost:8080/data/aircraft.json': 'band must be', + 'http://localhost:8080/data/aircraft.json': 'expected band=url', + }; + for (const [entry, reason] of Object.entries(cases)) { + const [feed] = parseLocalReceiverFeeds(entry).feeds; + assert.equal(feed.url, undefined, `${entry} must not be fetchable`); + assert.match(feed.reason, new RegExp(reason), entry); + } + const [badBand] = parseLocalReceiverFeeds( + '868=http://localhost/aircraft.json', + ).feeds; + assert.equal(badBand.band, null); + assert.equal(badBand.label, 'entry 1'); +}); + +test('an unconfigured route answers 200 without fetching anything', async (t) => { + t.mock.method(globalThis, 'fetch', () => { + throw Error('must not fetch'); + }); + const handler = createLocalReceiversHandler({ + feedsValue: undefined, + logger: quietLogger(), + }); + const res = await call(handler); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { configured: false, feeds: [], records: [] }); +}); + +test('construction mounts the route once and neither parses nor fetches', (t) => { + t.mock.method(globalThis, 'fetch', () => { + throw Error('construction must not fetch'); + }); + const plugins = localProviderPlugins(); + assert.equal( + plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length, + 1, + ); + const routes = []; + localReceiversProxy({ + feedsValue: '', + logger: quietLogger(), + }).configureServer({ middlewares: { use: (route) => routes.push(route) } }); + assert.deepEqual(routes, [LOCAL_RECEIVERS_ROUTE]); +}); + +test('live feeds are read in parallel and labelled with their band', async () => { + const calls = []; + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090},978=${FEED_978}`, + now: () => FIXTURE_NOW_MS + 2_000, + logger: quietLogger(), + fetchImpl: async (url, options) => { + calls.push({ url, redirect: options.redirect }); + return respond(); + }, + }); + const { status, json } = await call(handler); + assert.equal(status, 200); + assert.deepEqual( + calls.sort((a, b) => (a.url < b.url ? 1 : -1)), + [ + { url: FEED_1090, redirect: 'manual' }, + { url: FEED_978, redirect: 'manual' }, + ], + ); + assert.equal(json.configured, true); + assert.deepEqual(json.feeds, [ + { + band: '1090', + label: '1090 MHz', + status: 'live', + aircraft: 4, + ageMs: 2_000, + }, + { + band: '978', + label: '978 MHz UAT', + status: 'live', + aircraft: 4, + ageMs: 2_000, + }, + ]); + assert.equal(json.records.length, 8); + const shinr = json.records.filter((record) => record.icao === 'ae5d8a'); + assert.deepEqual( + shinr.map(({ band, source }) => ({ band, source })), + [ + { band: '1090', source: 'feed' }, + { band: '978', source: 'feed' }, + ], + ); + // Ages are anchored at the feed's own `now`. + assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600); + assert.equal(shinr[0].lat, 30.269662); +}); + +test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => { + const logger = quietLogger(); + const handler = createLocalReceiversHandler({ + feedsValue: [ + `1090=${FEED_1090}`, + `978=${FEED_978}`, + '1090=http://10.0.0.5/redirect/aircraft.json', + '1090=http://10.0.0.6/big/aircraft.json', + '1090=http://10.0.0.7/error/aircraft.json', + '978=http://10.0.0.8/garbage/aircraft.json', + '1090=http://8.8.8.8/aircraft.json', + ].join(','), + now: () => FIXTURE_NOW_MS + 10_001, + logger, + maxBytes: 4_096, + fetchImpl: async (url) => { + if (url === FEED_978) throw new TypeError('connect ECONNREFUSED secret'); + if (url.includes('/redirect/')) + return respond(302, '', { location: 'http://169.254.169.254/' }); + if (url.includes('/big/')) + return respond(200, `{"aircraft":[${' '.repeat(5_000)}]}`); + if (url.includes('/error/')) + return respond(500, 'upstream internal detail'); + if (url.includes('/garbage/')) return respond(200, ''); + if (url.includes('8.8.8.8')) throw Error('invalid feed was fetched'); + return respond(); + }, + }); + const { json, body } = await call(handler); + assert.deepEqual( + json.feeds.map(({ band, status }) => `${band} ${status}`), + [ + '1090 stale', + '978 unreachable', + '1090 unreachable', + '1090 unreachable', + '1090 unreachable', + '978 unreachable', + '1090 invalid', + ], + ); + assert.equal(json.feeds[0].ageMs, 10_001); + assert.equal(json.records.length, 4, 'stale feed records still flow'); + for (const secret of ['ECONNREFUSED', 'secret', 'internal detail', '169.254']) + assert.equal(body.includes(secret), false, `${secret} leaked`); + assert.equal( + body.includes('10.0.0'), + false, + 'feed addresses stay server-side', + ); + assert.ok( + logger.lines.some((line) => + /LOCAL_RECEIVER_FEEDS feed-7 .* rejected: host must be/.test(line), + ), + ); + assert.ok(logger.lines.some((line) => /REDIRECT_REFUSED/.test(line))); + assert.ok(logger.lines.some((line) => /RESPONSE_TOO_LARGE/.test(line))); +}); + +test('a feed that exceeds the timeout is unreachable', async () => { + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090}`, + logger: quietLogger(), + timeoutMs: 5, + fetchImpl: (url, { signal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }); + const { json } = await call(handler); + assert.equal(json.feeds[0].status, 'unreachable'); +}); + +test('requests within a second share one read of every feed', async () => { + let clock = FIXTURE_NOW_MS; + let fetches = 0; + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090}`, + now: () => clock, + logger: quietLogger(), + fetchImpl: async () => { + fetches += 1; + return respond(); + }, + }); + await Promise.all([call(handler), call(handler), call(handler)]); + assert.equal(fetches, 1, 'concurrent requests are single-flight'); + clock += 500; + await call(handler); + assert.equal(fetches, 1, 'cached for about a second'); + clock += 1_000; + await call(handler); + assert.equal(fetches, 2); + const res = await call(handler, 'POST'); + assert.equal(res.status, 405); +}); + +test('a feed name is resolved, every address checked, and the connection pinned to it', async () => { + const lookups = []; + const answers = { + 'good.local': [{ address: '10.0.0.9', family: 4 }], + 'public.local': [{ address: '93.184.216.34', family: 4 }], + 'linklocal.local': [{ address: '169.254.169.254', family: 4 }], + 'mixed.local': [ + { address: '192.168.1.4', family: 4 }, + { address: '8.8.8.8', family: 4 }, + ], + }; + const fetched = []; + const logger = quietLogger(); + const handler = createLocalReceiversHandler({ + feedsValue: [ + '1090=http://good.local:8080/data/aircraft.json', + '1090=http://public.local:8080/data/aircraft.json', + '978=http://linklocal.local/data/aircraft.json', + '978=http://mixed.local/data/aircraft.json', + `1090=${FEED_978}`, + ].join(','), + now: () => FIXTURE_NOW_MS, + logger, + lookupImpl: async (hostname, options) => { + lookups.push({ hostname, all: options?.all }); + return answers[hostname]; + }, + fetchImpl: async (url, options) => { + fetched.push({ url, lookup: options.lookup }); + return respond(); + }, + }); + const { json } = await call(handler); + assert.deepEqual( + json.feeds.map(({ status }) => status), + ['live', 'unreachable', 'unreachable', 'unreachable', 'live'], + ); + assert.deepEqual( + lookups.map(({ hostname }) => hostname).sort(), + ['good.local', 'linklocal.local', 'mixed.local', 'public.local'], + 'IP literals are not resolved', + ); + assert.ok( + lookups.every(({ all }) => all === true), + 'every address', + ); + assert.deepEqual( + fetched.map(({ url }) => url).sort(), + [FEED_978, 'http://good.local:8080/data/aircraft.json'], + 'a name resolving outside loopback/RFC1918 is never contacted', + ); + // The connection resolves only to the validated address, whatever the + // resolver would answer later. + answers['good.local'] = [{ address: '93.184.216.34', family: 4 }]; + const pinned = fetched.find(({ url }) => url.includes('good.local')).lookup; + assert.equal(typeof pinned, 'function'); + const one = await new Promise((resolve) => + pinned('good.local', {}, (error, address, family) => + resolve({ error, address, family }), + ), + ); + assert.deepEqual(one, { error: null, address: '10.0.0.9', family: 4 }); + const all = await new Promise((resolve) => + pinned('good.local', { all: true }, (error, addresses) => + resolve(addresses), + ), + ); + assert.deepEqual(all, [{ address: '10.0.0.9', family: 4 }]); + assert.ok(logger.lines.some((line) => /FORBIDDEN_ADDRESS/.test(line))); +}); + +test('the default transport connects to the validated address, not a re-resolution', async (t) => { + const { createServer } = await import('node:http'); + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ now: FIXTURE_NOW_MS / 1000, aircraft: [] })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const { port } = server.address(); + // `receiver-pin-test.local` has no real DNS entry: only the pinned lookup + // can reach the loopback server. + const handler = createLocalReceiversHandler({ + feedsValue: `1090=http://receiver-pin-test.local:${port}/data/aircraft.json`, + now: () => FIXTURE_NOW_MS, + logger: quietLogger(), + lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }], + }); + const { json } = await call(handler); + assert.equal(json.feeds[0].status, 'live'); +}); + +test('null-body statuses (204, 205, 304) from a named feed are a clean feed error, never an uncaught exception', async (t) => { + const { createServer } = await import('node:http'); + const server = createServer((req, res) => { + const status = Number(req.url.split('/')[1]); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const { port } = server.address(); + const uncaught = []; + const onUncaught = (error) => uncaught.push(error); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + const handler = createLocalReceiversHandler({ + feedsValue: [204, 205, 304] + .map( + (status) => + `1090=http://receiver-null-body.local:${port}/${status}/aircraft.json`, + ) + .join(','), + now: () => FIXTURE_NOW_MS, + logger: quietLogger(), + timeoutMs: 300, + lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }], + }); + const { status, json } = await call(handler); + // Let any exception thrown inside the response callback surface. + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual(uncaught, [], 'no uncaught exception'); + assert.equal(status, 200); + assert.deepEqual( + json.feeds.map((feed) => feed.status), + ['unreachable', 'unreachable', 'unreachable'], + ); +}); + +test('a stalled DNS lookup fails its feed at the deadline without holding other feeds or later polls', async () => { + let clock = FIXTURE_NOW_MS; + const lookups = [ + // Never answers. + () => new Promise(() => {}), + // Answers only after the deadline: the late answer is ignored. + () => + new Promise((resolve) => + setTimeout(() => resolve([{ address: '127.0.0.1', family: 4 }]), 80), + ), + ]; + const fetched = []; + const handler = createLocalReceiversHandler({ + feedsValue: `1090=http://stalled.local/data/aircraft.json,978=${FEED_978}`, + now: () => clock, + logger: quietLogger(), + timeoutMs: 20, + lookupImpl: () => lookups.shift()(), + fetchImpl: async (url) => { + fetched.push(url); + return respond(); + }, + }); + const within = (promise) => + Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve('timed out'), 60)), + ]); + const first = await within(call(handler)); + assert.notEqual(first, 'timed out', 'the snapshot is not held by DNS'); + assert.deepEqual( + first.json.feeds.map((feed) => feed.status), + ['unreachable', 'live'], + ); + clock += 2_000; + const second = await within(call(handler)); + assert.notEqual(second, 'timed out', 'a later poll is not held either'); + assert.deepEqual( + second.json.feeds.map((feed) => feed.status), + ['unreachable', 'live'], + ); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.ok( + fetched.every((url) => !url.includes('stalled.local')), + 'a late DNS answer is never fetched', + ); +}); diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 5c9edc107..fce64ae8f 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3477,3 +3477,12 @@ test('row info and legends reconcile in place while chips retain focus and order else globalThis.document = originalDocument; } }); + +test('refreshLayerStats reaches presentation through the bare lifecycle', async () => { + const { LayerLifecycle } = await import('./lifecycle.js'); + const lifecycle = new LayerLifecycle({}); + const changes = []; + lifecycle.subscribeActivity((change) => changes.push(change.type)); + lifecycle.refreshLayerStats(); + assert.deepEqual(changes, ['status']); +}); diff --git a/src/data/modelEligibility.js b/src/data/modelEligibility.js new file mode 100644 index 000000000..c757435f7 --- /dev/null +++ b/src/data/modelEligibility.js @@ -0,0 +1,45 @@ +/** + * Shared 3D-model eligibility for the aircraft layers. + * + * Eligibility is by DISTANCE (the mode's add/keep band) with ON-SCREEN + * PRIORITY under the cap, in four visible-first passes so the slots are spent + * on what the operator can see: + * 1. KEEP on-screen contacts that already have a model (hysteresis for + * visible planes); + * 2. ADD on-screen new contacts inside the add radius, nearest first; + * 3. KEEP off-screen contacts that already have a model; + * 4. ADD off-screen new contacts inside the add radius with leftover slots. + * KEEP is split by frustum so an off-screen retained model can never starve + * an on-screen contact that wants one. + * + * Portable: no Cesium, DOM or layer state. + */ + +/** + * @param {Array<[string, number, boolean]>} candidates `[id, distanceSq, + * onScreen]` for every contact inside the KEEP radius, sorted nearest first. + * @param {object} options + * @param {number} options.cap Maximum number of eligible contacts. + * @param {number} options.addDistSq Squared ADD radius (m²). + * @param {(id: string) => boolean} options.isModeled Whether the contact + * already has a model (live, not merely pending). + * @returns {Set} Contacts that may hold a model this tick. + */ +export function selectModelEligible(candidates, { cap, addDistSq, isModeled }) { + const eligible = new Set(); + const passes = [ + (onScreen, modeled) => onScreen && modeled, + (onScreen, modeled, inAdd) => onScreen && inAdd, + (onScreen, modeled) => !onScreen && modeled, + (onScreen, modeled, inAdd) => !onScreen && inAdd, + ]; + for (const admit of passes) { + for (const [id, distanceSq, onScreen] of candidates) { + if (eligible.size >= cap) return eligible; + if (eligible.has(id)) continue; + if (admit(onScreen, isModeled(id), distanceSq <= addDistSq)) + eligible.add(id); + } + } + return eligible; +} diff --git a/src/data/modelEligibility.test.mjs b/src/data/modelEligibility.test.mjs new file mode 100644 index 000000000..d29ae81e9 --- /dev/null +++ b/src/data/modelEligibility.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { selectModelEligible } from './modelEligibility.js'; + +const ADD_SQ = 100 * 100; + +test('visible contacts win the cap before off-screen retained models', () => { + // Nearest first: an off-screen retained model, then two on-screen contacts. + const candidates = [ + ['off-kept', 10, false], + ['on-new', 20, true], + ['on-kept', 30, true], + ]; + const modeled = new Set(['off-kept', 'on-kept']); + const eligible = selectModelEligible(candidates, { + cap: 2, + addDistSq: ADD_SQ, + isModeled: (id) => modeled.has(id), + }); + assert.deepEqual([...eligible], ['on-kept', 'on-new']); +}); + +test('retained models keep their slot beyond the add radius; new ones do not', () => { + const candidates = [ + ['near-new', 50, true], + ['far-kept', ADD_SQ + 1, true], + ['far-new', ADD_SQ + 2, true], + ['off-new', 60, false], + ]; + const eligible = selectModelEligible(candidates, { + cap: 10, + addDistSq: ADD_SQ, + isModeled: (id) => id === 'far-kept', + }); + assert.deepEqual([...eligible], ['far-kept', 'near-new', 'off-new']); +}); + +test('an empty cap admits nothing', () => { + assert.equal( + selectModelEligible([['a', 1, true]], { + cap: 0, + addDistSq: ADD_SQ, + isModeled: () => true, + }).size, + 0, + ); +}); diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 891e7e9a1..5eba85231 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -11,11 +11,12 @@ * * PURE data module — no Cesium imports, node-testable. The packs are lazy- * loaded on first lookup and cached in module scope (bbox/area computed once - * at load). In the browser Vite bundles the JSON via dynamic import; under - * node the same files are read from disk. A failed load is retried on the - * next lookup rather than cached (see `createRetryableLoader`). + * at load) through `loadBundledJson`, which works in the browser and under + * node:test. A failed load is retried on the next lookup rather than cached + * (see `createRetryableLoader`). */ +import { loadBundledJson } from './bundledJson.js'; import { createRetryableLoader } from './retryableLoad.js'; const EARTH_RADIUS_KM = 6371; @@ -120,20 +121,22 @@ function suffixVariants(norm) { /** @type {Array|null} flat entry list for listRegions() */ let _entries = null; -async function loadPackFile(base) { - // Vite bundles these JSON files as modules; the import attribute is what Node - // needs to load the same files under node:test (same pattern as - // neighborhoodPolygons.js). One path, so no node: import reaches the browser. - const mod = - base === 'regions' - ? await import('./local_data/natural_earth/regions.json', { - with: { type: 'json' }, - }) - : await import('./local_data/natural_earth/marine.json', { - with: { type: 'json' }, - }); - return mod.default || mod; -} +const PACKS = { + regions: { + url: new URL('./local_data/natural_earth/regions.json', import.meta.url), + importJson: () => + import('./local_data/natural_earth/regions.json', { + with: { type: 'json' }, + }), + }, + marine: { + url: new URL('./local_data/natural_earth/marine.json', import.meta.url), + importJson: () => + import('./local_data/natural_earth/marine.json', { + with: { type: 'json' }, + }), + }, +}; function buildEntries(pack, kind) { const out = []; @@ -175,8 +178,8 @@ function buildEntries(pack, kind) { */ const loadIndex = createRetryableLoader(async () => { const [regions, marine] = await Promise.all([ - loadPackFile('regions'), - loadPackFile('marine'), + loadBundledJson(PACKS.regions.url, PACKS.regions.importJson), + loadBundledJson(PACKS.marine.url, PACKS.marine.importJson), ]); _entries = [ ...buildEntries(regions, 'natural'), diff --git a/src/data/neighborhoodPolygons.js b/src/data/neighborhoodPolygons.js index 2861f9318..2a3c8ca87 100644 --- a/src/data/neighborhoodPolygons.js +++ b/src/data/neighborhoodPolygons.js @@ -10,6 +10,7 @@ * touching this module — see `local_data/neighborhoods/SOURCE.md`. */ +import { loadBundledJson } from './bundledJson.js'; import { createRetryableLoader } from './retryableLoad.js'; // bbox = [west, south, east, north]; only load a city file when the point falls in its box. @@ -17,7 +18,11 @@ const CITY_FILES = [ { id: 'san-francisco', bbox: [-122.55, 37.7, -122.35, 37.84], - loader: () => + url: new URL( + './local_data/neighborhoods/san-francisco.json', + import.meta.url, + ), + importJson: () => import('./local_data/neighborhoods/san-francisco.json', { with: { type: 'json' }, }), @@ -107,10 +112,7 @@ function cityLoader(city) { let loader = _cityLoaders.get(city.id); if (!loader) { loader = createRetryableLoader(async () => { - // One path for both runtimes: Vite bundles the JSON as a module, and the - // import attribute is what Node needs to load the same file under node:test. - const mod = await city.loader(); - const fc = mod.default || mod; + const fc = await loadBundledJson(city.url, city.importJson); return Array.isArray(fc.features) ? fc.features : []; }); _cityLoaders.set(city.id, loader); diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index 07b7666f6..a1a3fab44 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -1059,6 +1059,10 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths this.parentElement = null; } setAttribute() {} + // This fixture has no parsed DOM children; it exercises native label + // ownership, not the replay overlay's separately rendered icon children. + querySelectorAll() { return []; } + querySelector() { return null; } getBoundingClientRect() { return { left: 0, top: 0, width: 1600, height: 900 }; } getContext() { return this.tagName === 'CANVAS' ? context : null; } } diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index c081206bd..0fb5f3623 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -415,8 +415,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => { assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`); assert.match(loop, /if \(isTr3b\(icao\)\) continue;/, `${name}: converted contacts are dropped BEFORE entering the capped candidate list`); - // ...and the cap really is applied to that list, so a dropped candidate is a freed slot. - assert.match(source, /modelEligible\.size >= cap/, + // ...and the cap really is applied to that list, so a dropped candidate is a + // freed slot. The four-pass selection itself is the shared + // selectModelEligible (behavior-tested in modelEligibility.test.mjs). + assert.match(source, /modelEligible\s*=\s*selectModelEligible\(\s*cand,\s*\{\s*cap,/, `${name}: the cap bounds the candidate-derived eligible set`); } }); diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 8e24cb9d9..dfb4ee858 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() { const READOUT_CONTEXT_LAYERS = new Set([ 'military-installations', 'alpr-cameras', + 'local-adsb', ]); /** diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 99026d10a..347b8c9ef 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -661,11 +661,18 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). - const block = JSON.stringify(GEV_REALTIME_TOOLS); - assert.equal(block.length, 27271, 'serialized tool schema length drifted'); + const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar'); + const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout; + assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); + // Cyber deliberately adds one layout; first-run missions still change no tools. + hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + const block = JSON.stringify(legacyTools); + // Re-derived for the additive `local-adsb` set_layer_visibility value and + // its common-name mapping; the missions still ride existing tools. + assert.equal(block.length, 27432, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918', + 'a2a4a787f4528f75b01f3f42caec636f29c37452c0d45b11f4f986171d6be57d', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/hud.js b/src/hud.js index 236df7013..eb8c0ab34 100644 --- a/src/hud.js +++ b/src/hud.js @@ -30,6 +30,11 @@ import { hudTelemetryProvenanceTag, isHudSummaryUnconfigured, } from './hudSummaryResponse.js'; +import { + applyHudUiTheme, + DEFAULT_HUD_LAYOUT, + normalizeHudLayout, +} from './hudLayouts.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -58,8 +63,6 @@ const HUD_COLORS = { /** Shader modes that automatically show the HUD overlay. */ const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']); -/** Allowed HUD layout variants. */ -const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']); const HUD_SUMMARY_INTERVAL_MS = 15000; /** @@ -110,7 +113,7 @@ export class IntelHUD { this._autoMode = true; // auto show/hide based on style this._currentStyle = 'normal'; this._el = null; - this._variant = 'tactical'; + this._variant = DEFAULT_HUD_LAYOUT; this._recBlinkState = true; this._updateInterval = null; this._recBlinkInterval = null; @@ -183,6 +186,8 @@ export class IntelHUD { if (!this._el) return; this._el.innerHTML = ` + +
TOP SECRET // SI-TK // NOFORN ${this._missionId} @@ -879,15 +884,16 @@ export class IntelHUD { /** * Switch the HUD layout variant. Falls back to `'tactical'` if the - * name is unrecognized. - * @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`. + * name is unrecognized. Cyber also applies a coordinated skin to the full + * application shell; leaving Cyber restores the standard shell tokens. + * @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`. */ setVariant(variantName) { - const normalized = String(variantName || '').toLowerCase(); - this._variant = HUD_VARIANTS.has(normalized) ? normalized : 'tactical'; + this._variant = normalizeHudLayout(variantName); if (this._el) { this._el.dataset.variant = this._variant; } + applyHudUiTheme(document.documentElement, this._variant); } /** diff --git a/src/hudLayoutPolicy.js b/src/hudLayoutPolicy.js new file mode 100644 index 000000000..6b3e2287b --- /dev/null +++ b/src/hudLayoutPolicy.js @@ -0,0 +1,7 @@ +/** Browser-free layout vocabulary shared by UI and server-side voice tools. */ +export const HUD_LAYOUTS = Object.freeze([ + 'tactical', + 'operator', + 'minimal', + 'cyber', +]); diff --git a/src/hudLayouts.js b/src/hudLayouts.js new file mode 100644 index 000000000..d232ca427 --- /dev/null +++ b/src/hudLayouts.js @@ -0,0 +1,64 @@ +import { applyCyberSonarThemePhase } from './cyberSonar.js'; +import { syncRadioPanelPlacement } from './ui/radioPanelPlacement.js'; +import { HUD_LAYOUTS } from './hudLayoutPolicy.js'; +export { HUD_LAYOUTS } from './hudLayoutPolicy.js'; + +/** Canonical HUD layouts shared by the UI, voice schema and HUD renderer. */ + +export const DEFAULT_HUD_LAYOUT = 'tactical'; + +/** Visual treatment applied only when an operator explicitly enters Cyber. */ +export const CYBER_VISUAL_DEFAULTS = Object.freeze({ + style: 'thermal', + ironbow: 0.42, +}); + +const HUD_LAYOUT_SET = new Set(HUD_LAYOUTS); + +/** @param {unknown} value */ +export function isHudLayout(value) { + return HUD_LAYOUT_SET.has(String(value ?? '').toLowerCase()); +} + +/** @param {unknown} value */ +export function normalizeHudLayout(value) { + const normalized = String(value ?? '').toLowerCase(); + return isHudLayout(normalized) ? normalized : DEFAULT_HUD_LAYOUT; +} + +/** + * Resolve the opt-in visual defaults for an explicit layout transition. + * Restores and repeated Cyber selections return null so they never overwrite + * a shared link or a user's subsequent FLIR tuning. + */ +export function cyberVisualDefaultsForHudTransition( + previousLayout, + nextLayout, + { explicit = false } = {}, +) { + if (!explicit) return null; + if (normalizeHudLayout(previousLayout) === 'cyber') return null; + if (normalizeHudLayout(nextLayout) !== 'cyber') return null; + return CYBER_VISUAL_DEFAULTS; +} + +/** + * Keep the global shell skin derived from the selected HUD layout. The data + * attribute lives on so theme tokens are available to every UI surface. + * @param {HTMLElement|{dataset?: Record}|null|undefined} root + * @param {unknown} layout + * @returns {'cyber'|null} + */ +export function applyHudUiTheme(root, layout) { + if (!root?.dataset) return null; + if (normalizeHudLayout(layout) === 'cyber') { + root.dataset.uiTheme = 'cyber'; + applyCyberSonarThemePhase(root, true); + syncRadioPanelPlacement(root.ownerDocument); + return 'cyber'; + } + delete root.dataset.uiTheme; + applyCyberSonarThemePhase(root, false); + syncRadioPanelPlacement(root.ownerDocument); + return null; +} diff --git a/src/keySetup.js b/src/keySetup.js index d94dac58f..ee1d3b2dd 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -147,10 +147,29 @@ function buildRow(documentRef, key) { return row; } -/** - * Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when - * the surface has no business existing (prod build, LAN visitor, no markup). - */ +/** Move the existing setup action with the theme, retaining its listeners. */ +export function bindKeySetupPlacement(documentRef, chip, root) { + const theme = documentRef?.documentElement; + const toolbar = documentRef?.getElementById?.('top-center-actions'); + const Observer = documentRef?.defaultView?.MutationObserver; + if (!theme || !toolbar || !Observer) return () => {}; + const sync = () => { + if (theme.dataset.uiTheme === 'cyber') { + if (chip.parentNode !== toolbar) toolbar.append(chip); + } else if (chip.parentNode !== root.parentNode) { + root.before(chip); + } + }; + const observer = new Observer(sync); + observer.observe(theme, { + attributes: true, + attributeFilter: ['data-ui-theme'], + }); + sync(); + return () => observer.disconnect(); +} + +/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */ export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, @@ -163,12 +182,14 @@ export async function initKeySetup({ const lifetime = new AbortController(); let disposed = false; let disposeControls = () => {}; + let disposePlacement = () => {}; const destroy = () => { if (disposed) return; disposed = true; lifetime.abort(); signal?.removeEventListener('abort', destroy); disposeControls(); + disposePlacement(); chip.remove(); root.remove(); }; @@ -196,6 +217,7 @@ export async function initKeySetup({ } const rowsHost = root.querySelector('[data-key-setup-rows]'); + disposePlacement = bindKeySetupPlacement(documentRef, chip, root); const applyButton = root.querySelector('[data-key-setup-apply]'); const closeButton = root.querySelector('[data-key-setup-close]'); const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip; @@ -208,6 +230,8 @@ export async function initKeySetup({ if (disposed) return; status = nextStatus; chipLabel.textContent = keySetupChipLabel(status); + chip.title = `Project keys: ${keySetupChipLabel(status)}`; + chip.setAttribute('aria-label', chip.title); // Fully powered is the owner's clean screen: the chip retires. The dialog // stays reachable this session (and via ?setup=1) to swap or verify keys. chip.hidden = status.setCount >= status.total; diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 2918e6f5c..c0d9520c0 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -4,8 +4,43 @@ import { collectKeyUpdates, keySetupChipLabel, stripKeylessBasemapFromHash, + bindKeySetupPlacement, } from './keySetup.js'; +test('setup placement moves the same button only in Cyber and disconnects on teardown', () => { + const home = {}; + const chip = { parentNode: home, hidden: true }; + const root = { parentNode: home, before: (node) => { node.parentNode = home; } }; + let moves = 0; + const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } }; + const theme = { dataset: { uiTheme: 'tactical' } }; + let notify, disconnected = false; + class Observer { + constructor(callback) { notify = callback; } + observe(target, options) { + assert.equal(target, theme); + assert.deepEqual(options.attributeFilter, ['data-ui-theme']); + } + disconnect() { disconnected = true; } + } + const dispose = bindKeySetupPlacement({ + documentElement: theme, + getElementById: () => toolbar, + defaultView: { MutationObserver: Observer }, + }, chip, root); + assert.equal(chip.parentNode, home); + for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) { + theme.dataset.uiTheme = variant; + notify(); + assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home); + notify(); + } + assert.equal(moves, 2, 'identical theme sync does not reparent again'); + assert.equal(chip.hidden, true, 'placement cannot reveal a retired button'); + dispose(); + assert.equal(disconnected, true); +}); + test('the chip counts what is missing, and retires the count at zero', () => { assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING'); assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING'); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8ef9b2afe..471c11b51 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -1,4 +1,8 @@ import * as Cesium from 'cesium'; +import { + createCyberSonarSampler, + isCyberContactSonarActive as isCyberSonarActive, +} from '../../cyberSonar.js'; import { formatAwarenessLabel, formatAwarenessDistance, @@ -105,6 +109,9 @@ export function createRendering({ const canvas = layerState.viewer.scene.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; + const sonar = isCyberSonarActive() + ? createCyberSonarSampler(width, height) + : null; const geometry = getKeyholeGeometry(width, height); if (!(geometry.radius > 0)) { root.hidden = true; @@ -182,6 +189,15 @@ export function createRendering({ marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`; marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`; marker.style.opacity = String(projection.opacity); + const sonarFactor = + sonar?.at( + geometry.centerX + Math.cos(projection.angle) * markerRadius, + geometry.centerY + Math.sin(projection.angle) * markerRadius, + ) ?? 1; + marker._arrow.style.opacity = String(sonarFactor); + marker._label.style.opacity = String( + sonar ? sonar.label(sonarFactor) : 1, + ); marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`; marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`; const label = formatAwarenessLabel(item); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js new file mode 100644 index 000000000..850433824 --- /dev/null +++ b/src/layers/flights/modelSpec.js @@ -0,0 +1,52 @@ +import { + CLASS_MODEL_REAL, + CLASS_MODEL_URL, + CLASS_SCALE_3D, +} from '../../data/aircraftClass.js'; +import { + trailAnchorForModel, + visualCenterForModel, +} from '../../data/modelVisualAnchor.js'; +import { + MODEL_BELLY_OFFSET_NATIVE, + MODEL_COLOR_BLEND_AMOUNT, + MODEL_NATIVE_RADIUS_M, + MODEL_SCALE, + PLANE_MODEL_URL, +} from './policy.js'; + +/** + * The glTF asset, scale and measured geometry a civil aircraft class renders + * with. Real per-class GLBs are baked to real-world meters (scale 1, measured + * belly and radius); classes without one share airplane.glb at the class's + * 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw + * the same model for the same class. + * @param {string} klass Aircraft class from `classifyAircraft`. + * @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number, + * blendAmount:number, visualCenterNative:object, trailAnchorNative:object}} + */ +export function civilAircraftModelSpec(klass) { + const real = CLASS_MODEL_REAL[klass]; + if (real) { + return { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; + return { + url, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(url), + trailAnchorNative: trailAnchorForModel(url), + }; +} diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 74ee4541b..44e1b02c3 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,14 +1,8 @@ import * as Cesium from 'cesium'; -import { - CLASS_SCALE_2D, - CLASS_MODEL_REAL, - CLASS_SCALE_3D, - CLASS_MODEL_URL, -} from '../../data/aircraftClass.js'; -import { - visualCenterForModel, - trailAnchorForModel, -} from '../../data/modelVisualAnchor.js'; +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 { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -20,11 +14,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { MIL_TINT, GROUND_SCALE, - MODEL_COLOR_BLEND_AMOUNT, - MODEL_SCALE, - PLANE_MODEL_URL, - MODEL_NATIVE_RADIUS_M, - MODEL_BELLY_OFFSET_NATIVE, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CIVILIAN_COLOR, MODEL_ALT_CEIL_M, @@ -100,31 +89,7 @@ export function createRendering({ function _modelSpec(klass) { const cached = flightState._specCache.get(klass); if (cached) return cached; - const real = CLASS_MODEL_REAL[klass]; - let spec; - if (real) { - spec = { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; - spec = { - url, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(url), - trailAnchorNative: trailAnchorForModel(url), - }; - } + const spec = civilAircraftModelSpec(klass); flightState._specCache.set(klass, spec); return spec; } @@ -158,7 +123,7 @@ export function createRendering({ const isCockpitNear = isCockpitContact && flightState._cockpitNearContacts.has(icao24); if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = bb.color?.alpha ?? 1; + const freshnessAlpha = cyberSonarBaseAlpha(bb); bb.image = cockpitContactDotImage(); bb.width = COCKPIT_CONTACT_SIZE_PX; bb.height = COCKPIT_CONTACT_SIZE_PX; @@ -180,7 +145,7 @@ export function createRendering({ bb.height = icao24 === flightState._trackedIcao ? 24 : 20; bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale; bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1); + bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb)); } /** @@ -953,31 +918,11 @@ export function createRendering({ ); flightState._lastModelCapWarnMs = nowMs; } - modelEligible = new Set(); - // 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see). - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && flightState._models.has(icao)) modelEligible.add(icao); - } - // 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap). - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } - // 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a - // retained off-screen model can never starve an on-screen plane that wants one; dropping it is - // invisible and it re-adds the moment it's back in view). - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && flightState._models.has(icao)) modelEligible.add(icao); - } - // 4. ADD off-screen NEW inside the add radius with any leftover slots. - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } + modelEligible = selectModelEligible(cand, { + cap, + addDistSq, + isModeled: (icao) => flightState._models.has(icao), + }); const toRelease = []; for (const icao of flightState._models.keys()) { if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) @@ -1062,12 +1007,13 @@ export function createRendering({ const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); - const baseColor = + const layerBaseColor = flightState._cockpitContactMode && !isCockpitNear ? isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR : _fleetBillboardColor(icao24); + const baseColor = layerBaseColor; const treatment = applyAircraftBillboardTreatment({ billboard: bb, baseScale: diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 626488a90..636e691fd 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { turnRateFromFixHistory, liftRepeatedGroundFix, @@ -165,7 +166,7 @@ export function createFlightSnapshotRenderer({ // freshness × focus × horizon alpha composition. bb.color = rendering ._fleetBillboardColor(icao24) - .withAlpha(bb.color?.alpha ?? 1); + .withAlpha(cyberSonarBaseAlpha(bb)); } if ( prevMeta?.klass !== meta.klass || diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index cb870a3b3..2978ceba4 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -250,6 +250,8 @@ export function createFlightState({ source, services }) { flightState._scratchFleetPos = new Cesium.Cartesian3(); + flightState._scratchSonarScreen = new Cesium.Cartesian2(); + flightState._scratchDrRaw = new Cesium.Cartesian3(); flightState._scratchWarmupTime = new Cesium.JulianDate(); diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index 600b5c781..682007e84 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -1,4 +1,8 @@ import * as Cesium from 'cesium'; +import { + createCyberSonarSampler, + isCyberContactSonarActive as isCyberSonarActive, +} from '../../cyberSonar.js'; import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, @@ -303,6 +307,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
`; host.appendChild(layerState._replayVehicleOverlay); + layerState._replayVehicleOverlay._sonarIcons = [ + ...layerState._replayVehicleOverlay.querySelectorAll( + '.mission-replay-flight-symbol, .mission-replay-orbit-dot', + ), + ]; + layerState._replayVehicleOverlay._sonarLabel = + layerState._replayVehicleOverlay.querySelector( + '.mission-replay-overlay-callout', + ); } function hideReplayVehicleOverlay() { @@ -502,6 +515,17 @@ export function createOverlays({ state: layerState, services, parts, source }) { : windowPosition; track.lastOverlayWindowPosition = renderedWindowPosition; track.lastOverlayMode = mode; + const sonar = isCyberSonarActive() + ? createCyberSonarSampler(canvas.clientWidth, canvas.clientHeight) + : null; + const factor = + sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1; + for (const icon of layerState._replayVehicleOverlay._sonarIcons || []) + icon.style.opacity = String(factor); + if (layerState._replayVehicleOverlay._sonarLabel) + layerState._replayVehicleOverlay._sonarLabel.style.opacity = String( + sonar ? sonar.label(factor) : 1, + ); layerState._replayVehicleOverlay.hidden = false; layerState._replayVehicleOverlay.dataset.mode = mode; layerState._replayVehicleOverlay.classList.toggle( diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 1555b5a66..c97077d42 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -268,7 +268,7 @@ export function createPanel({ state: layerState, services, parts, source }) { index < 0 || index >= layerState._launches.length - 1; parts.replay.syncReplayButton(); const panelScroller = layerState._missionPanel.closest( - '.global-context-panel-inner', + ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner", ); if (panelScroller) panelScroller.scrollTop = 0; } diff --git a/src/layers/launches/panelScroll.test.mjs b/src/layers/launches/panelScroll.test.mjs new file mode 100644 index 000000000..b43e634e1 --- /dev/null +++ b/src/layers/launches/panelScroll.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPanel } from './panel.js'; + +test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => { + for (const cyber of [true, false]) { + const body = { scrollTop: 180 }; + const inner = { scrollTop: 90 }; + const output = { closest: () => null, removeAttribute() {} }; + const state = { + _missionPanel: { + querySelector: () => output, + closest(selector) { + assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner"); + return cyber ? body : inner; + }, + }, + _selectedLaunchId: 'mission-1', + _launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }], + _replayTracks: new Map(), + }; + const panel = createPanel({ + state, + parts: { + overlays: { shortMissionLabel: (name) => name }, + policyHelpers: { missionPathPresentation: () => ({}) }, + replay: { syncReplayButton() {} }, + }, + }); + panel.renderMissionPanel(); + assert.equal(body.scrollTop, cyber ? 0 : 180); + assert.equal(inner.scrollTop, cyber ? 90 : 0); + } +}); diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js new file mode 100644 index 000000000..c656bd9fe --- /dev/null +++ b/src/layers/localAdsb/card.js @@ -0,0 +1,143 @@ +import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js'; +import { + BAND_LABELS, + HEARD_BY_RECEIVER, + LOCAL_ADSB_COLOR, + SOURCE_LABELS, +} from './policy.js'; + +const DASH = '—'; + +function rounded(value, digits = 0) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function grouped(value) { + return value === null ? DASH : value.toLocaleString('en-US'); +} + +/** + * Title of a locally heard aircraft: callsign when decoded, else ICAO hex. + * @param {object} record Local ADS-B record. + * @returns {string} + */ +export function localAdsbTitle(record) { + return record?.callsign || String(record?.icao || '').toUpperCase(); +} + +function recordBands(record) { + if (Array.isArray(record?.bands) && record.bands.length) return record.bands; + return record?.band ? [record.band] : []; +} + +function recordSources(record) { + if (Array.isArray(record?.sources) && record.sources.length) + return record.sources; + return record?.source ? [record.source] : []; +} + +/** + * Whether an aircraft was heard only on 978 MHz UAT (its marker gets the + * UAT ring). One also heard on 1090 MHz draws as a 1090 aircraft. + * @param {object} record Local ADS-B record. + * @returns {boolean} + */ +export function localAdsbIsUat(record) { + const bands = recordBands(record); + return bands.includes('978') && !bands.includes('1090'); +} + +/** + * Receiver line of the click card, e.g. "Heard by your receiver · 1090 MHz · + * browser SDR" or "… · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed". + * @param {object} record Local ADS-B record (merged: `bands`, `sources`). + * @returns {string} + */ +export function localAdsbReceiverLine(record) { + const bands = recordBands(record) + .map((band) => BAND_LABELS[band]) + .filter(Boolean); + const sources = recordSources(record) + .map((source) => SOURCE_LABELS[source]) + .filter(Boolean); + return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')] + .filter(Boolean) + .join(' · '); +} + +/** + * Where a heard aircraft came from, for the entity context's source field. + * @param {object} record Local ADS-B record. + * @returns {string} + */ +export function localAdsbSourceText(record) { + const sources = recordSources(record); + if (sources.includes('webusb') && sources.includes('feed')) + return 'Your RTL-SDR receiver and decoder feed'; + if (sources.includes('feed')) return 'Your decoder feed'; + return 'Your RTL-SDR receiver'; +} + +/** + * Class line of the click card, e.g. "Helicopter · A7". Empty when neither an + * ICAO type nor an emitter category backs the class. + * @param {object} record Local ADS-B record. + * @param {{klass?:string, evidence?:boolean}} [aircraftClass] + * @returns {string} + */ +export function localAdsbClassLine(record, aircraftClass = {}) { + if (!aircraftClass.evidence) return ''; + const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass]; + return [label, record?.category].filter(Boolean).join(' · '); +} + +/** + * Click-card presentation for one locally heard aircraft. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms, for the position age. + * @param {object} [extras] + * @param {{klass:string, evidence:boolean}} [extras.aircraftClass] Class and + * whether a type or category backs it. + * @param {object|null} [extras.meta] adsbdb metadata (typeCode, typeName, + * airline). + * @param {object|null} [extras.route] A route already judged plausible. + * @returns {{title:string, details:string[], accent:string}} + */ +export function localAdsbCardModel( + record, + nowMs, + { aircraftClass = null, meta = null, route = null } = {}, +) { + const icao = String(record?.icao || '').toUpperCase(); + const altitude = rounded(record?.altitudeFt); + const speed = rounded(record?.groundSpeedKt); + const track = rounded(record?.trackDeg); + const vertical = rounded(record?.verticalRateFpm); + const ageS = Number.isFinite(record?.lastPositionAt) + ? Math.max(0, Math.round((nowMs - record.lastPositionAt) / 1000)) + : null; + const messages = Math.max(0, Math.trunc(Number(record?.messageCount) || 0)); + // Same identity and route lines the public flight card shows. + const identity = [meta?.airline, meta?.typeName || meta?.typeCode] + .filter(Boolean) + .join(' · '); + const routeLine = + route?.origin?.code && route?.destination?.code + ? `${route.origin.code} → ${route.destination.code}` + : ''; + return { + title: localAdsbTitle(record), + details: [ + `ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`, + localAdsbClassLine(record, aircraftClass || {}), + identity, + routeLine, + `ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`, + `V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`, + `POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`, + localAdsbReceiverLine(record), + ].filter(Boolean), + accent: LOCAL_ADSB_COLOR, + }; +} diff --git a/src/layers/localAdsb/enrichment.js b/src/layers/localAdsb/enrichment.js new file mode 100644 index 000000000..4fb4673a8 --- /dev/null +++ b/src/layers/localAdsb/enrichment.js @@ -0,0 +1,166 @@ +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { stickyText } from '../../data/aircraftMeta.js'; +import { + ENRICH_DISPATCH_GAP_MS, + ENRICH_MAX_INFLIGHT, +} from '../flights/policy.js'; + +const ICAO = /^[0-9a-f]{6}$/i; +/** Airline-style callsigns only (LLL + digit); GA tails do not resolve. */ +const AIRLINE_CALLSIGN = /^[A-Z]{3}\d/; +/** Bound on the per-session metadata map. */ +const META_LIMIT = 2_000; + +/** + * Class of a locally heard aircraft: the adsbdb ICAO type when known, else + * the emitter category it broadcasts. `evidence` is false when neither is + * known, so the card never presents the classifier's default as fact. + * @param {{category?:string|null}} record + * @param {{typeCode?:string|null}} [meta] + * @returns {{klass:string, evidence:boolean}} + */ +export function localAdsbClass(record, meta = null) { + const typeCode = meta?.typeCode || null; + const category = record?.category || null; + return { + klass: classifyAircraft({ typeCode, category }), + evidence: Boolean(typeCode) || /^(?:A[1-7]|B1)$/.test(category || ''), + }; +} + +/** + * adsbdb enrichment for Local ADS-B through the same cached proxy and the same + * rules as the public Flights layer: each key is asked for at most once per + * session, at most four requests are in flight and dispatches are dripped + * 200 ms apart. Only the selected aircraft (type + route) and aircraft that + * are about to render as 3D models (type) are ever looked up — never one + * request per contact per tick. + * @param {object} options + * @param {{getEnrichment?: Function}|null} options.source Flights source. + * @param {() => void} options.onChange Called when metadata arrives. + * @param {() => number} [options.now] + * @returns {object} + */ +export function createLocalAdsbEnrichment({ + source, + onChange, + now = Date.now, +}) { + const meta = new Map(); + const seen = new Set(); + const queue = []; + let active = 0; + let lastDispatchAt = -Infinity; + let dripTimer = null; + let lifetime = new AbortController(); + + function entry(icao) { + let value = meta.get(icao); + if (!value) { + value = {}; + meta.set(icao, value); + if (meta.size > META_LIMIT) meta.delete(meta.keys().next().value); + } + return value; + } + + function enqueue(key, query, apply, priority) { + if (typeof source?.getEnrichment !== 'function' || seen.has(key)) return; + seen.add(key); + const job = { query, apply }; + if (priority) queue.unshift(job); + else queue.push(job); + drain(); + } + + function drain() { + const signal = lifetime.signal; + while (!signal.aborted && active < ENRICH_MAX_INFLIGHT && queue.length) { + const wait = ENRICH_DISPATCH_GAP_MS - (now() - lastDispatchAt); + if (wait > 0) { + if (!dripTimer) + dripTimer = setTimeout(() => { + dripTimer = null; + drain(); + }, wait); + return; + } + lastDispatchAt = now(); + const job = queue.shift(); + active += 1; + Promise.resolve() + .then(() => source.getEnrichment(job.query, { signal })) + .then((data) => { + if (!signal.aborted && data?.found) { + job.apply(data); + onChange?.(); + } + }) + .catch(() => { + /* enrichment never surfaces errors */ + }) + .finally(() => { + if (signal.aborted) return; + active -= 1; + drain(); + }); + } + } + + return { + /** @param {string} icao @returns {object|null} */ + get(icao) { + return meta.get(String(icao || '').toLowerCase()) || null; + }, + + /** + * Ask for the aircraft's type (and, when selected, its route). + * @param {object} record Local ADS-B record. + * @param {{selected?: boolean}} [options] + */ + request(record, { selected = false } = {}) { + const icao = String(record?.icao || '').toLowerCase(); + if (!ICAO.test(icao)) return; + enqueue( + `t:${icao}`, + { kind: 'type', id: icao }, + (data) => { + const value = entry(icao); + value.typeCode = stickyText(data.typeCode, value.typeCode) || null; + value.typeName = stickyText(data.typeName, value.typeName) || null; + value.registration = + stickyText(data.registration, value.registration) || null; + }, + selected, + ); + const callsign = String(record.callsign || '') + .trim() + .toUpperCase(); + if (!selected || !AIRLINE_CALLSIGN.test(callsign)) return; + enqueue( + `r:${callsign}`, + { kind: 'route', id: callsign }, + (data) => { + const value = entry(icao); + value.airline = stickyText(data.airline, value.airline) || null; + if (data.origin && data.destination) + value.route = { + callsign, + origin: data.origin, + destination: data.destination, + }; + }, + true, + ); + }, + + destroy() { + lifetime.abort(); + lifetime = new AbortController(); + clearTimeout(dripTimer); + dripTimer = null; + queue.length = 0; + active = 0; + }, + }; +} diff --git a/src/layers/localAdsb/feedNames.js b/src/layers/localAdsb/feedNames.js new file mode 100644 index 000000000..cb816f503 --- /dev/null +++ b/src/layers/localAdsb/feedNames.js @@ -0,0 +1,21 @@ +/** + * Names for the server's decoder feeds, shared by the Local ADS-B row status + * and the Radio card. Pure; no imports, so any surface may use it. + */ + +/** + * Display name of one feed: its band, or its ordinal label ("978 MHz UAT #2") + * when another configured feed shares the band. + * @param {object} feed Feed status from the route. + * @param {object[]} [feeds] Every configured feed. + * @returns {string} + */ +export function localReceiverFeedName(feed, feeds = []) { + const band = feed?.band; + if (!band) return feed?.label || 'feed'; + const sharing = feeds.filter((other) => other?.band === band); + if (sharing.length < 2) return band; + if (typeof feed.label === 'string' && feed.label.includes('#')) + return feed.label; + return `${feed.label || band} #${sharing.indexOf(feed) + 1}`; +} diff --git a/src/layers/localAdsb/feeds.js b/src/layers/localAdsb/feeds.js new file mode 100644 index 000000000..4bb4bced7 --- /dev/null +++ b/src/layers/localAdsb/feeds.js @@ -0,0 +1,253 @@ +import { + localAdsbRecordIsLive, + localAdsbRecordIsNewer, +} from '../../sources/adsbRecords.js'; + +/** Same-origin route served by `server/providers/local-receivers.js`. */ +export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft'; +export const LOCAL_RECEIVER_POLL_MS = 1_000; +/** + * Browser-side deadline for one route request, body included. The server + * bounds each upstream read at 2 s; this bounds the browser-to-server leg. + */ +export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; + +function abortError() { + const error = new Error('Local receiver request aborted'); + error.name = 'AbortError'; + return error; +} + +/** Reject with an AbortError as soon as `signal` aborts. */ +function untilAborted(promise, signal) { + if (signal.aborted) return Promise.reject(abortError()); + let onAbort = null; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + }); + return Promise.race([promise, aborted]).finally(() => + signal.removeEventListener('abort', onAbort), + ); +} + +function shiftTime(value, offset) { + return Number.isFinite(value) ? value + offset : value; +} + +/** + * Browser poller for the server's local decoder feeds. + * + * Polls only between `start()` and `stop()` (the Local ADS-B layer calls them + * on enable/disable), and only while the route reports `configured`: an + * unconfigured server, or a build without the route, stops the loop after one + * request. `probe()` makes that one request without polling, so the Radio card + * can show which feeds exist before the layer is turned on. + * + * Records are rebased from the server clock to this browser's clock and kept + * by ICAO and band for up to 60 s after their last message, so a feed that + * drops an aircraft or goes briefly unreachable does not erase it at once. + * @param {object} [options] + * @param {typeof fetch} [options.fetchImpl] + * @param {string} [options.url] + * @param {number} [options.intervalMs] + * @param {number} [options.requestTimeoutMs] Deadline per request. + * @param {() => number} [options.now] + * @returns {object} Feed session: start, stop, probe, getState, subscribe. + */ +export function createLocalReceiverFeeds({ + fetchImpl = (...args) => globalThis.fetch(...args), + url = LOCAL_RECEIVERS_URL, + intervalMs = LOCAL_RECEIVER_POLL_MS, + requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS, + now = Date.now, +} = {}) { + const listeners = new Set(); + const retained = new Map(); + /** Abort controllers of requests still in flight. */ + const inFlight = new Set(); + let state = { + configured: null, + polling: false, + feeds: [], + records: [], + error: null, + updatedAt: null, + }; + let generation = 0; + let timer = null; + let probing = null; + + function emit(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener(state); + } + + function retain(records, at) { + for (const record of records) { + if (!record?.icao) continue; + // Two feeds on one band both report an aircraft: keep the fresher + // record, never simply the one listed (or polled) last. + const key = `${record.icao}|${record.band}`; + const current = retained.get(key); + if (!current || !localAdsbRecordIsNewer(current, record)) + retained.set(key, record); + } + for (const [key, record] of retained) { + if (!localAdsbRecordIsLive(record, at)) retained.delete(key); + } + return [...retained.values()]; + } + + /** One route request with a deadline through the body, abortable by stop. */ + async function request() { + const controller = new AbortController(); + inFlight.add(controller); + const timer = setTimeout(() => controller.abort(), requestTimeoutMs); + try { + const response = await untilAborted( + fetchImpl(url, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }), + controller.signal, + ); + if (!response?.ok) throw new Error('local receiver route unavailable'); + return await untilAborted(response.json(), controller.signal); + } finally { + clearTimeout(timer); + inFlight.delete(controller); + } + } + + function abortInFlight() { + for (const controller of inFlight) controller.abort(); + inFlight.clear(); + } + + function apply(body) { + if (body?.configured !== true) { + retained.clear(); + emit({ + configured: false, + polling: false, + feeds: [], + records: [], + error: null, + }); + return false; + } + const at = now(); + const offset = Number.isFinite(body.generatedAt) + ? at - body.generatedAt + : 0; + const records = (Array.isArray(body.records) ? body.records : []).map( + (record) => ({ + ...record, + lastPositionAt: shiftTime(record.lastPositionAt, offset), + lastMessageAt: shiftTime(record.lastMessageAt, offset), + }), + ); + emit({ + configured: true, + feeds: Array.isArray(body.feeds) ? body.feeds : [], + records: retain(records, at), + error: null, + updatedAt: at, + }); + return true; + } + + function fail() { + // A route that never answered means there is nothing to poll; a route + // that answered before is reported unreachable and polled again. + if (state.configured !== true) { + emit({ configured: false, polling: false, error: 'unavailable' }); + return false; + } + emit({ + feeds: state.feeds.map((feed) => + feed.status === 'invalid' + ? feed + : { ...feed, status: 'unreachable', aircraft: 0 }, + ), + records: retain([], now()), + error: 'unavailable', + }); + return true; + } + + async function tick(current) { + timer = null; + let keepPolling; + try { + const body = await request(); + if (current !== generation) return; + keepPolling = apply(body); + } catch { + if (current !== generation) return; + keepPolling = fail(); + } + if (!keepPolling) { + generation += 1; + return; + } + timer = setTimeout(() => void tick(current), intervalMs); + } + + return { + /** Start polling (no-op while already polling). */ + start() { + if (state.polling) return; + generation += 1; + emit({ polling: true }); + void tick(generation); + }, + + /** Stop polling; the last feed statuses stay readable. */ + stop() { + generation += 1; + clearTimeout(timer); + timer = null; + abortInFlight(); + if (state.polling) emit({ polling: false }); + }, + + /** + * Ask the route once whether feeds are configured, without polling. + * @returns {Promise} + */ + probe() { + if (state.polling || state.configured !== null) return Promise.resolve(); + if (!probing) { + const current = generation; + probing = request() + .then((body) => { + if (current === generation && !state.polling) apply(body); + }) + .catch(() => { + if (current === generation && !state.polling) + emit({ configured: false, error: 'unavailable' }); + }) + .finally(() => { + probing = null; + }); + } + return probing; + }, + + getState: () => state, + + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + + destroy() { + this.stop(); + listeners.clear(); + retained.clear(); + }, + }; +} diff --git a/src/layers/localAdsb/feeds.test.mjs b/src/layers/localAdsb/feeds.test.mjs new file mode 100644 index 000000000..e1ad540d4 --- /dev/null +++ b/src/layers/localAdsb/feeds.test.mjs @@ -0,0 +1,256 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js'; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function route(bodies) { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + const next = bodies.length > 1 ? bodies.shift() : bodies[0]; + if (next instanceof Error) throw next; + if (next === 404) return { ok: false, status: 404 }; + return { ok: true, json: async () => structuredClone(next) }; + }; + return { calls, fetchImpl }; +} + +function payload(records, generatedAt = 1_000_000) { + return { + configured: true, + generatedAt, + feeds: [ + { band: '1090', label: '1090 MHz', status: 'live', aircraft: 1 }, + { band: '978', label: '978 MHz UAT', status: 'live', aircraft: 1 }, + ], + records, + }; +} + +function aircraft(overrides = {}) { + return { + icao: 'ae5d8a', + lat: 30.27, + lon: -97.79, + lastPositionAt: 999_000, + lastMessageAt: 999_500, + band: '1090', + source: 'feed', + ...overrides, + }; +} + +test('an unconfigured server is asked once and never polled', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([ + { configured: false, feeds: [], records: [] }, + ]); + const feeds = createLocalReceiverFeeds({ fetchImpl }); + feeds.start(); + await flush(); + assert.deepEqual(calls, [LOCAL_RECEIVERS_URL]); + assert.equal(feeds.getState().configured, false); + assert.equal(feeds.getState().polling, false); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 1); +}); + +test('a build without the route stops after one failed request', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([404]); + const feeds = createLocalReceiverFeeds({ fetchImpl }); + feeds.start(); + await flush(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 1); + assert.equal(feeds.getState().configured, false); +}); + +test('configured feeds poll every second between start and stop, on the browser clock', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([payload([aircraft()])]); + const clock = 1_000_250; + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock }); + const seen = []; + feeds.subscribe((state) => seen.push(state.polling)); + feeds.start(); + await flush(); + const state = feeds.getState(); + assert.equal(state.configured, true); + assert.equal(state.polling, true); + assert.equal(state.feeds.length, 2); + // Server generated at 1_000_000; received at 1_000_250: rebased +250 ms. + assert.equal(state.records[0].lastPositionAt, 999_250); + assert.equal(state.records[0].lastMessageAt, 999_750); + + t.mock.timers.tick(1_000); + await flush(); + t.mock.timers.tick(1_000); + await flush(); + assert.equal(calls.length, 3); + feeds.stop(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 3, 'no polling once stopped'); + assert.equal(feeds.getState().polling, false); + assert.equal(seen.at(-1), false); +}); + +test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const bodies = [ + payload([aircraft(), aircraft({ band: '978' })], 1_000_000), + payload([aircraft()], 1_010_000), + payload([], 1_060_000), + ]; + let clock = 1_000_000; + const { fetchImpl } = route(bodies); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock }); + feeds.start(); + await flush(); + assert.equal(feeds.getState().records.length, 2); + clock = 1_010_000; + t.mock.timers.tick(1_000); + await flush(); + assert.deepEqual( + feeds.getState().records.map((record) => record.band), + ['1090', '978'], + 'the 978 reception is remembered', + ); + clock = 1_060_000; + t.mock.timers.tick(1_000); + await flush(); + assert.deepEqual(feeds.getState().records, [], 'forgotten after 60 s'); + feeds.stop(); +}); + +test('a route that answered before is reported unreachable and polled again', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([ + payload([aircraft()]), + new TypeError('network down'), + payload([aircraft()]), + ]); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + feeds.start(); + await flush(); + t.mock.timers.tick(1_000); + await flush(); + assert.deepEqual( + feeds.getState().feeds.map((feed) => feed.status), + ['unreachable', 'unreachable'], + ); + assert.equal(feeds.getState().records.length, 1, 'recent records remain'); + t.mock.timers.tick(1_000); + await flush(); + assert.equal(calls.length, 3); + assert.equal(feeds.getState().feeds[0].status, 'live'); + feeds.stop(); +}); + +test('probe asks once without polling so the Radio card can list feeds', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([payload([aircraft()])]); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + await Promise.all([feeds.probe(), feeds.probe()]); + assert.equal(calls.length, 1); + assert.equal(feeds.getState().configured, true); + assert.equal(feeds.getState().polling, false); + await feeds.probe(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 1); +}); + +test('two feeds on one band keep the fresher record, whichever is listed last', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fresh = aircraft({ lastPositionAt: 999_000, lastMessageAt: 999_500 }); + // A second 1090 feed that heard the aircraft earlier and has no position. + const older = aircraft({ + lat: null, + lon: null, + lastPositionAt: null, + lastMessageAt: 990_000, + }); + const { fetchImpl } = route([ + payload([fresh, older], 1_000_000), + payload([older], 1_000_000), + ]); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + feeds.start(); + await flush(); + let [kept] = feeds.getState().records; + assert.equal(feeds.getState().records.length, 1); + assert.equal(kept.lastPositionAt, 999_000, 'same response'); + t.mock.timers.tick(1_000); + await flush(); + [kept] = feeds.getState().records; + assert.equal(kept.lastPositionAt, 999_000, 'next poll'); + assert.equal(kept.lat, 30.27); + feeds.stop(); +}); + +/** A route whose requests never answer until their signal aborts. */ +function stalledRoute({ stallBody = false } = {}) { + const signals = []; + const hang = (signal) => + new Promise((_, reject) => { + signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }); + const fetchImpl = async (url, options = {}) => { + signals.push(options.signal); + if (!stallBody) return hang(options.signal); + return { ok: true, json: () => hang(options.signal) }; + }; + return { signals, fetchImpl }; +} + +test('a stalled feed request is abandoned at its deadline and polling continues', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const stallBody of [false, true]) { + const { signals, fetchImpl } = stalledRoute({ stallBody }); + const feeds = createLocalReceiverFeeds({ + fetchImpl, + now: () => 1_000_000, + requestTimeoutMs: 3_000, + }); + feeds.start(); + await flush(); + assert.equal(signals.length, 1); + assert.ok(signals[0], 'every request carries an abort signal'); + t.mock.timers.tick(3_000); + await flush(); + assert.equal(signals[0].aborted, true, `aborted (stallBody ${stallBody})`); + assert.equal(feeds.getState().configured, false, 'never answered'); + feeds.destroy(); + } +}); + +test('stop() aborts the request in flight so start() is never blocked by it', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { signals, fetchImpl } = stalledRoute(); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + feeds.start(); + await flush(); + feeds.stop(); + assert.equal(signals[0].aborted, true); + feeds.start(); + await flush(); + feeds.stop(); + feeds.start(); + await flush(); + assert.equal(signals.length, 3); + assert.deepEqual( + signals.map((signal) => signal.aborted), + [true, true, false], + 'no disable/enable cycle leaves an old request outstanding', + ); + feeds.destroy(); + assert.equal(signals[2].aborted, true); +}); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js new file mode 100644 index 000000000..9bfffb722 --- /dev/null +++ b/src/layers/localAdsb/index.js @@ -0,0 +1,846 @@ +import * as Cesium from 'cesium'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + horizonOccluder, + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; +import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; +import { routePlausible } from '../../data/routePlausible.js'; +import { + localAdsbPositionIsFresh, + mergeLocalAdsbRecords, + summarizeLocalAdsb, +} from '../../sources/adsbRecords.js'; +import { GROUND_SCALE } from '../flights/policy.js'; +import { + localAdsbCardModel, + localAdsbIsUat, + localAdsbSourceText, + localAdsbTitle, +} from './card.js'; +import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js'; +import { createLocalAdsbModels } from './models.js'; +import { LocalAdsbMotion } from './motion.js'; +import { + ENTITY_PREFIX, + LAYER_ID, + LAYER_NAME, + LAYER_SOURCE, + LOCAL_ADSB_COLOR, + LOCAL_ADSB_SYNC_MS, + LOCAL_ADSB_TICK_MS, + LOCAL_ADSB_UAT_RING_COLOR, +} from './policy.js'; +import { localAdsbStatus } from './status.js'; + +export { + LAYER_ID as LOCAL_ADSB_LAYER_ID, + HEARD_BY_RECEIVER, +} from './policy.js'; +export { + localAdsbCardModel, + localAdsbClassLine, + localAdsbIsUat, + localAdsbReceiverLine, + localAdsbTitle, +} from './card.js'; +export { localAdsbClass } from './enrichment.js'; +export { localAdsbStatus } from './status.js'; + +const FEET_TO_METERS = 0.3048; +const RENDER_HOLD_ID = 'local-adsb'; +/** Picks looked through under a click for a local aircraft. */ +const CLICK_DRILL_LIMIT = 8; +/** Trail lines pass through their aircraft; a click never stops on one. */ +const TRAIL_PICK_PREFIX = 'gev-trail:'; +/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */ +const DEFAULT_DISPLAY = Object.freeze({ + models3d: true, + models3dMode: 'proximity', +}); + +/** + * Local ADS-B layer: draws aircraft heard by the user's own receivers. + * + * Two inputs feed it. The receiver is any object with `getState()` returning + * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from + * `src/sources/adsbRecords.js`. The browser WebUSB session is one such + * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * by the server from local 1090/978 MHz decoders; it polls only while this + * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). + * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. + * + * Aircraft get the public Flights treatment in magenta: a class from the + * emitter category or adsbdb type (`aircraftClass.js`), the class silhouette + * and scale, the Flights 3D models under the DISPLAY rail's 3D toggle, smooth + * real-time motion (`./motion.js`, no display delay) and, while selected, a + * trail of the positions the receiver heard. + * + * Markers are never followed by the camera. A marker disappears once its + * position is older than 60 s; a record without any message for 60 s is gone. + * @param {object} options + * @param {object} options.receiver Local receiver session. + * @param {object} [options.feeds] Decoder-feed session (start, stop, + * getState, subscribe). + * @param {object} options.services Render, context, picking, detection and + * readout operations supplied by the application; optionally `display` + * (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source + * with `getEnrichment`), `trails` (`createTrail`), `geoid` + * (`ensureGeoidReady`, `geoidHeight`) and `groundSnap` + * (`createGroundSnap`). + * @param {() => number} [options.now] Clock, injectable for tests. + * @param {(url: string) => string} [options.resolveAsset] Model URL resolver. + * @param {(options: object) => Promise} [options.loadModel] Model + * loader, injectable for tests. + * @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler] + * Click-handler factory, injectable for tests. + * @returns {object} Data-layer module. + */ +export function createLocalAdsbLayer({ + receiver, + feeds = null, + services, + now = Date.now, + resolveAsset = (url) => url, + loadModel, + createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), +}) { + if (typeof receiver?.getState !== 'function') + throw new TypeError('Local ADS-B requires a receiver session'); + const { + governorRequestRender, + holdContinuousRender = () => {}, + releaseContinuousRender = () => {}, + } = services.render; + const { + registerEntityContext, + selectEntityContext, + clearSelectedEntityContextForLayer, + getSelectedEntityContext, + removeEntityContextsForLayer, + } = services.context; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR); + const uatRingColor = Cesium.Color.fromCssColorString( + LOCAL_ADSB_UAT_RING_COLOR, + ); + + let viewer = null; + let dataSource = null; + let enabled = false; + let unsubscribe = null; + let unsubscribeFeeds = null; + let tickTimer = null; + let syncTimer = null; + let clickHandler = null; + let removePreRender = null; + let renderHeld = false; + let selectedId = null; + let selectedTrail = null; + let trailHeadSeq = 0; + let models = null; + let geoidReady = false; + let rejectedFixes = 0; + let state = receiver.getState(); + const markers = new Map(); + // Per-ICAO receptions (band|source -> last message) across merges. + const heardBy = new Map(); + const enrichment = createLocalAdsbEnrichment({ + source: services.enrichment || null, + onChange: () => { + for (const marker of markers.values()) refreshClass(marker); + refreshSelectedCard(); + governorRequestRender('local-adsb-enrichment'); + }, + }); + + function displayPreferences() { + return services.display?.getParams?.() || DEFAULT_DISPLAY; + } + + function markSourcesChanged(reason) { + services.detection?.markSourcesChanged?.(reason); + } + + function mergedRecords(at = now(), receiverState = state) { + return mergeLocalAdsbRecords( + [receiverState.aircraft || [], feeds?.getState?.()?.records || []], + at, + heardBy, + ); + } + + function freshRecords(at = now()) { + return mergedRecords(at).filter((record) => + localAdsbPositionIsFresh(record, at), + ); + } + + function markerVisible(marker) { + if (!viewer?.camera?.positionWC || !marker.position) return true; + return horizonOccluder(viewer.camera).isPointVisible(marker.position); + } + + function markerRotation(marker) { + if (!viewer?.scene || !marker.position) return marker.lastRotation; + const projected = screenProjectedRotation( + viewer.scene, + marker.position, + marker.courseDeg ?? 0, + marker.lastRotation, + ); + const stable = stabilizeScreenRotation(marker.lastRotation, projected); + if (stable !== null) marker.lastRotation = stable; + return marker.lastRotation; + } + + function geoidN(lat, lon) { + if (!geoidReady || typeof services.geoid?.geoidHeight !== 'function') + return null; + try { + const value = services.geoid.geoidHeight(lat, lon); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } + } + + /** Barometric feet → ellipsoidal render metres, as the Flights layer does + * for a contact without a geometric altitude: baro + geoid N. */ + function renderHeightM(marker, altitudeFt, lat, lon) { + if (marker.geoidN === null) marker.geoidN = geoidN(lat, lon); + const height = pickRenderAltitudeM({ + geoAltM: null, + baroAltM: Number.isFinite(altitudeFt) + ? altitudeFt * FEET_TO_METERS + : null, + onGround: false, + surfaceM: null, + geoidN: marker.geoidN, + }); + return Number.isFinite(height) ? height : (marker.geoidN ?? 0); + } + + function fixPosition(marker, fix) { + return Cesium.Cartesian3.fromDegrees( + fix.lon, + fix.lat, + renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon), + ); + } + + function updateDisplay(marker, at) { + const display = marker.motion.displayAt(at); + if (!display) return; + marker.lat = display.lat; + marker.lon = display.lon; + marker.position = Cesium.Cartesian3.fromDegrees( + display.lon, + display.lat, + renderHeightM(marker, display.altitudeFt, display.lat, display.lon), + Cesium.Ellipsoid.WGS84, + marker.position || new Cesium.Cartesian3(), + ); + if (display.courseDeg !== null) marker.courseDeg = display.courseDeg; + } + + function billboardScale(marker) { + return ( + (CLASS_SCALE_2D[marker.klass] || 1) * (marker.onGround ? GROUND_SCALE : 1) + ); + } + + /** Class silhouette and per-class scale (×0.8 on the ground), magenta. */ + function refreshClass(marker) { + const { klass, evidence } = localAdsbClass( + marker.record, + enrichment.get(marker.record.icao), + ); + marker.evidence = evidence; + marker.klass = klass; + const billboard = marker.entity?.billboard; + if (!billboard) return; + if (marker.iconKind !== klass) { + marker.iconKind = klass; + billboard.image = aircraftIcon(klass); + } + const scale = billboardScale(marker); + if (marker.iconScale !== scale) { + marker.iconScale = scale; + billboard.scale = scale; + } + } + + function plausibleRoute(marker, meta) { + const route = meta?.route; + if (!route || route.callsign !== marker.record.callsign) return null; + const record = marker.record; + const ok = routePlausible({ + latDeg: marker.lat ?? record.lat, + lonDeg: marker.lon ?? record.lon, + altitudeM: Number.isFinite(record.altitudeFt) + ? record.altitudeFt * FEET_TO_METERS + : null, + verticalRateMps: Number.isFinite(record.verticalRateFpm) + ? (record.verticalRateFpm * FEET_TO_METERS) / 60 + : null, + origin: route.origin, + destination: route.destination, + }); + return ok ? route : null; + } + + function cardModel(marker, at) { + const meta = enrichment.get(marker.record.icao); + return localAdsbCardModel(marker.record, at, { + aircraftClass: { klass: marker.klass, evidence: marker.evidence }, + meta, + route: plausibleRoute(marker, meta), + }); + } + + function contextMetadata(id, marker) { + const record = marker.record; + const meta = enrichment.get(record.icao); + return { + id, + layerId: LAYER_ID, + dataSource, + layerName: LAYER_NAME, + source: localAdsbSourceText(record), + label: localAdsbTitle(record), + latitude: marker.lat ?? record.lat, + longitude: marker.lon ?? record.lon, + properties: { + icao: record.icao, + callsign: record.callsign, + category: record.category ?? null, + aircraftClass: marker.evidence ? marker.klass : null, + typeCode: meta?.typeCode ?? null, + altitudeFt: record.altitudeFt, + groundSpeedKt: record.groundSpeedKt, + trackDeg: record.trackDeg, + verticalRateFpm: record.verticalRateFpm, + lastPositionAt: record.lastPositionAt, + messageCount: record.messageCount, + receiverSource: record.source, + bands: record.bands, + sources: record.sources, + }, + }; + } + + function uatRing(marker) { + return new Cesium.PointGraphics({ + pixelSize: 24, + color: Cesium.Color.TRANSPARENT, + outlineColor: uatRingColor, + outlineWidth: 1.5, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), + show: new Cesium.CallbackProperty(() => markerVisible(marker), false), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + 2_000_000, + ), + }); + } + + // ── Trail of the selected aircraft ────────────────────────────────────── + + function releaseTrail() { + if (!selectedTrail) return; + selectedTrail.trail?.destroy(); + if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) { + try { + viewer.entities.remove(selectedTrail.head); + } catch { + /* torn down */ + } + } + selectedTrail = null; + } + + function refreshTrail() { + const marker = selectedId ? markers.get(selectedId) : null; + if (!marker || !selectedTrail?.trail) return; + const positions = marker.motion.fixes.map((fix) => + fixPosition(marker, fix), + ); + selectedTrail.lastFix = positions.at(-1) || null; + selectedTrail.trail.setPositions(positions); + } + + function startTrail(marker) { + releaseTrail(); + const createTrail = services.trails?.createTrail; + if (typeof createTrail !== 'function' || !viewer?.entities) return; + selectedTrail = { + id: marker.id, + trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }), + head: null, + lastFix: null, + }; + // Live head: last heard fix → the extrapolated marker, every frame. + selectedTrail.head = viewer.entities.add({ + // 'gev-trail' namespace: claimed by the trail pick owner so a click on + // the head segment never reads as empty space. + id: `gev-trail:local-adsb-head-${++trailHeadSeq}`, + polyline: { + positions: new Cesium.CallbackProperty(() => { + const current = markers.get(selectedTrail?.id); + const from = selectedTrail?.lastFix; + if (!current?.position || !from) return []; + return [from, Cesium.Cartesian3.clone(current.position)]; + }, false), + width: 2.5, + material: color.withAlpha(0.9), + depthFailMaterial: color.withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, + }, + }); + refreshTrail(); + } + + // ── Markers ───────────────────────────────────────────────────────────── + + function upsertMarker(record, at) { + const id = `${ENTITY_PREFIX}${record.icao}`; + let marker = markers.get(id); + if (!marker) { + marker = { + id, + entity: null, + record, + motion: new LocalAdsbMotion(), + position: null, + lat: null, + lon: null, + courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null, + lastRotation: 0, + klass: null, + evidence: false, + iconKind: null, + iconScale: null, + onGround: Boolean(record.onGround), + geoidN: null, + modelOwnsVisual: false, + }; + markers.set(id, marker); + } + marker.record = record; + marker.onGround = Boolean(record.onGround); + const rejectedBefore = marker.motion.rejectedFixes; + const newFix = marker.motion.observe(record, at); + rejectedFixes += marker.motion.rejectedFixes - rejectedBefore; + updateDisplay(marker, at); + if (!marker.entity) { + marker.entity = dataSource.entities.add({ + id, + position: new Cesium.CallbackProperty(() => marker.position, false), + billboard: { + image: aircraftIcon('airliner'), + width: 20, + height: 20, + scale: 1, + color, + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + rotation: new Cesium.CallbackProperty( + () => markerRotation(marker), + false, + ), + show: new Cesium.CallbackProperty( + () => !marker.modelOwnsVisual && markerVisible(marker), + false, + ), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + 2_000_000, + ), + }, + }); + marker.entity.gevTrackedId = id; + marker.entity.gevDisplayPosition = () => marker.position; + } + refreshClass(marker); + const uat = localAdsbIsUat(record); + if (uat !== Boolean(marker.entity.point)) + marker.entity.point = uat ? uatRing(marker) : undefined; + marker.entity.gevLabelModel = cardModel(marker, at); + registerEntityContext(marker.entity, contextMetadata(id, marker)); + if (newFix && id === selectedId) refreshTrail(); + return id; + } + + function clearSelection({ evicted = false } = {}) { + if (!selectedId) return; + selectedId = null; + releaseTrail(); + clearSelectedEntityContextForLayer(LAYER_ID, { evicted }); + } + + function refreshSelectedCard() { + const marker = selectedId ? markers.get(selectedId) : null; + if (!marker) return; + marker.entity.gevLabelModel = cardModel(marker, now()); + services.overlays?.refreshReadout?.(marker.entity); + } + + function updateRenderHold() { + const want = enabled && markers.size > 0; + if (want === renderHeld) return; + renderHeld = want; + if (want) holdContinuousRender(RENDER_HOLD_ID); + else releaseContinuousRender(RENDER_HOLD_ID); + } + + function requestEnrichment() { + const selected = selectedId ? markers.get(selectedId) : null; + if (selected) enrichment.request(selected.record, { selected: true }); + for (const id of models?.eligible || []) { + const marker = markers.get(id); + if (marker) enrichment.request(marker.record); + } + } + + function sync() { + clearTimeout(syncTimer); + syncTimer = null; + if (!dataSource) return; + const at = now(); + const live = new Set(); + let added = false; + if (enabled) { + for (const record of freshRecords(at)) { + const id = `${ENTITY_PREFIX}${record.icao}`; + if (!markers.has(id)) added = true; + live.add(upsertMarker(record, at)); + } + } + let removed = false; + for (const [id, marker] of markers) { + if (live.has(id)) continue; + if (marker.entity) dataSource.entities.remove(marker.entity); + markers.delete(id); + removed = true; + } + // Expired aircraft take their 3D models (and pending loads) with them. + if (removed) models?.retain(markers); + if (selectedId && !live.has(selectedId)) + clearSelection({ evicted: enabled }); + removeEntityContextsForLayer(LAYER_ID, { retainIds: live }); + if (selectedId) { + const selected = markers.get(selectedId); + if (getSelectedEntityContext()?.id !== selectedId) { + selectedId = null; + releaseTrail(); + } else services.overlays?.refreshReadout?.(selected.entity); + } + requestEnrichment(); + updateRenderHold(); + if (live.size || removed) governorRequestRender('local-adsb-update'); + // Detection re-solves labels only when the set of contacts changes, not + // on every position report. + if (added || removed) markSourcesChanged('local-adsb-update'); + } + + /** Per-frame pass: smooth positions and courses, then 3D models. */ + function frame() { + if (!enabled || !markers.size) return; + const at = now(); + for (const marker of markers.values()) updateDisplay(marker, at); + models?.frame(markers, displayPreferences(), at); + } + + function scheduleSync(nextState) { + state = nextState; + requestSync(); + } + + function requestSync() { + if (!enabled || syncTimer) return; + syncTimer = setTimeout(sync, LOCAL_ADSB_SYNC_MS); + } + + function selectMarker(id) { + const marker = markers.get(id); + if (!marker) return false; + const reselect = id === selectedId && selectedTrail?.id === id; + selectedId = id; + enrichment.request(marker.record, { selected: true }); + marker.entity.gevLabelModel = cardModel(marker, now()); + selectEntityContext(marker.entity); + if (!reselect) startTrail(marker); + governorRequestRender('local-adsb-selection'); + return true; + } + + function pickedId(picked) { + const resolved = services.picking.resolvePickId?.(picked); + if (resolved !== undefined) return resolved; + if (typeof picked?.id === 'string') return picked.id; + if (typeof picked?.id?.id === 'string') return picked.id.id; + return typeof picked?.primitive?.id === 'string' + ? picked.primitive.id + : null; + } + + function ownedElsewhere(id) { + return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id); + } + + /** + * What a click at `position` means for this layer: a local aircraft id, + * `'other'` when a sibling layer's contact is under the pointer, `'trail'` + * for a trail line, or null for empty map. + * + * The frontmost pick decides, except that something no layer owns (a + * photoreal tile, a ring) or a trail line drawn over or through a local + * aircraft does not hide it: the click looks underneath for the aircraft. + */ + function clickTarget(position) { + const scene = viewer.scene; + const top = pickedId(scene.pick(position)); + if (top && markers.has(top)) return top; + const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX); + if (ownedElsewhere(top) && !trail) return 'other'; + let hits = []; + try { + hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || []; + } catch { + hits = []; + } + for (const hit of hits) { + const id = pickedId(hit); + if (id && markers.has(id)) return id; + if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue; + // A sibling layer's contact above ours takes the click. + if (ownedElsewhere(id)) return 'other'; + } + return trail ? 'trail' : null; + } + + function installInteraction() { + if (clickHandler || !viewer?.scene?.canvas) return; + clickHandler = createInputHandler(viewer.scene.canvas); + clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree() || !enabled) return; + const target = clickTarget(click.position); + // Clicking a local aircraft always (re)selects it and republishes its + // card, including the one already selected: its card may have been + // displaced, and a click on a marker must never read as "deselect". + if (target && markers.has(target)) { + selectMarker(target); + return; + } + // A click on a trail line is not empty map. + if (target === 'trail' || !selectedId) return; + // Another contact or empty map releases only this layer's selection, + // leaving a sibling layer's new selection intact. + clearSelection(); + governorRequestRender('local-adsb-selection'); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function releaseInteraction() { + clickHandler?.destroy(); + clickHandler = null; + } + + function installFrame() { + if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return; + removePreRender = viewer.scene.preRender.addEventListener(frame); + } + + function releaseFrame() { + removePreRender?.(); + removePreRender = null; + } + + function positionRows(maxCount = 500) { + if (!enabled) return []; + const rows = []; + for (const marker of markers.values()) { + if (rows.length >= maxCount) break; + if (!marker.position) continue; + const carto = Cesium.Cartographic.fromCartesian(marker.position); + rows.push({ + id: marker.record.icao, + label: localAdsbTitle(marker.record), + callsign: marker.record.callsign, + position: Cesium.Cartesian3.clone(marker.position), + latitude: marker.lat, + longitude: marker.lon, + altitudeM: carto?.height ?? 0, + }); + } + return rows; + } + + return { + id: LAYER_ID, + name: LAYER_NAME, + icon: '📡', + source: LAYER_SOURCE, + updateInterval: 0, + statsRefreshInterval: LOCAL_ADSB_TICK_MS, + /** The shared receiver session, also driven by the Radio panel card. */ + receiver, + /** Decoder-feed session; the Radio card shows its one-line summary. */ + feeds, + + init(nextViewer) { + viewer = nextViewer; + dataSource = new Cesium.CustomDataSource(LAYER_ID); + dataSource.show = false; + viewer.dataSources.add(dataSource); + if (viewer.scene?.primitives) { + models = createLocalAdsbModels({ + viewer, + color, + resolveAsset, + groundSnap: services.groundSnap?.createGroundSnap?.() || null, + ...(loadModel ? { loadModel } : {}), + }); + } + services.geoid + ?.ensureGeoidReady?.() + ?.then(() => { + geoidReady = true; + for (const marker of markers.values()) marker.geoidN = null; + }) + ?.catch(() => { + /* the baro path stays un-geoid-corrected */ + }); + unsubscribe = receiver.subscribe?.(scheduleSync) || null; + unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null; + return true; + }, + + async enable() { + enabled = true; + if (dataSource) dataSource.show = true; + registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue)); + installInteraction(); + installFrame(); + clearInterval(tickTimer); + tickTimer = setInterval(sync, LOCAL_ADSB_TICK_MS); + feeds?.start?.(); + // The layer asks the shared tuner for 1090 MHz. Disabling it leaves the + // receiver in whatever mode the Radio card shows, so turning the layer + // off never starts FM audio on its own. + if (receiver.getState().mode !== 'adsb') await receiver.setMode('adsb'); + state = receiver.getState(); + sync(); + return true; + }, + + async disable() { + enabled = false; + feeds?.stop?.(); + clearInterval(tickTimer); + tickTimer = null; + releaseInteraction(); + releaseFrame(); + unregisterPickOwner(LAYER_ID); + sync(); + models?.clear(); + if (dataSource) dataSource.show = false; + markSourcesChanged('local-adsb-disabled'); + return true; + }, + + async update() { + state = receiver.getState(); + sync(); + frame(); + return true; + }, + + destroy() { + enabled = false; + clearInterval(tickTimer); + clearTimeout(syncTimer); + tickTimer = null; + syncTimer = null; + releaseInteraction(); + releaseFrame(); + unregisterPickOwner(LAYER_ID); + unsubscribe?.(); + unsubscribe = null; + unsubscribeFeeds?.(); + unsubscribeFeeds = null; + feeds?.destroy?.(); + enrichment.destroy(); + clearSelection(); + removeEntityContextsForLayer(LAYER_ID); + markers.clear(); + updateRenderHold(); + models?.destroy(); + models = null; + if (viewer && dataSource) viewer.dataSources.remove(dataSource, true); + dataSource = null; + viewer = null; + void receiver.destroy?.(); + }, + + getStats() { + const current = receiver.getState(); + const at = now(); + const records = mergedRecords(at, current); + const { heard, positioned } = summarizeLocalAdsb(records, at); + const lastUpdate = + records.reduce( + (latest, record) => Math.max(latest, record.lastMessageAt || 0), + 0, + ) || null; + return { + count: positioned, + lastUpdate, + source: LAYER_SOURCE, + // Fixes refused by the speed check: the browser decoder's own count + // plus the layer's check on every merged record. + rejectedPositions: + Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes, + ...localAdsbStatus({ + receiver: current, + feedState: feeds?.getState?.() || null, + heard, + }), + }; + }, + + /** + * Select a heard aircraft's marker and publish its readout card, exactly + * as a click does. The camera does not follow it. + * @param {string} icao Lowercase ICAO hex. + * @returns {boolean} Whether a fresh marker was selected. + */ + selectAircraft(icao) { + return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`); + }, + + getAllPositions(maxCount = 500) { + return positionRows(maxCount); + }, + + getDetectableObjects(options = {}) { + return positionRows(options.maxCount).map((row) => ({ + position: row.position, + sourceId: row.id, + // Label only a decoded callsign; never substitute the raw ICAO hex. + id: row.callsign || '', + type: 'AIR', + skipLabel: false, + })); + }, + }; +} diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs new file mode 100644 index 000000000..263c21210 --- /dev/null +++ b/src/layers/localAdsb/index.test.mjs @@ -0,0 +1,1167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; + +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { layerFeedState } from '../../data/feedState.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + createLocalAdsbLayer, + HEARD_BY_RECEIVER, + localAdsbCardModel, + localAdsbReceiverLine, + localAdsbStatus, +} from './index.js'; + +function fakeReceiver(initial = {}) { + const state = { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + message: 'Ready', + aircraft: [], + messagesPerSecond: null, + ...initial, + }; + const modeCalls = []; + const subscribers = new Set(); + const snapshot = () => ({ ...state, aircraft: [...state.aircraft] }); + return { + modeCalls, + getState: snapshot, + subscribe(listener) { + subscribers.add(listener); + return () => subscribers.delete(listener); + }, + set(patch) { + Object.assign(state, patch); + for (const listener of subscribers) listener(snapshot()); + }, + async setMode(mode) { + modeCalls.push(mode); + state.mode = mode; + return true; + }, + }; +} + +function fakeServices() { + const calls = { registered: [], selected: [], cleared: [], removed: [] }; + let selected = null; + return { + calls, + services: { + render: { governorRequestRender() {} }, + context: { + registerEntityContext(entity, metadata) { + entity.__gevContextId = metadata.id; + calls.registered.push(metadata); + }, + selectEntityContext(entity) { + selected = { id: entity.__gevContextId, entity }; + calls.selected.push(selected.id); + return selected; + }, + clearSelectedEntityContextForLayer(layerId, options) { + calls.cleared.push({ layerId, ...options }); + selected = null; + }, + getSelectedEntityContext: () => selected, + removeEntityContextsForLayer(layerId, options) { + calls.removed.push({ layerId, retainIds: options?.retainIds }); + }, + }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + detection: { markSourcesChanged() {} }, + overlays: { refreshReadout() {} }, + }, + }; +} + +function fakeViewer() { + const sources = []; + const camera = { + rightWC: new Cesium.Cartesian3(0, 1, 0), + upWC: new Cesium.Cartesian3(0, 0, 1), + }; + return { + sources, + viewer: { + camera, + scene: { camera }, + entities: { + add: (entity) => entity, + remove() {}, + }, + dataSources: { + add(source) { + sources.push(source); + }, + remove() {}, + }, + }, + }; +} + +function record(overrides = {}) { + return { + icao: 'abc123', + callsign: 'LOCAL1', + lat: 0, + lon: 0, + altitudeFt: 10_000, + groundSpeedKt: 180, + trackDeg: 0, + verticalRateFpm: 0, + lastPositionAt: 100_000, + lastMessageAt: 100_000, + messageCount: 12, + rssiDbfs: null, + band: '1090', + source: 'webusb', + ...overrides, + }; +} + +function fakeFeeds(initial = {}) { + let state = { + configured: null, + polling: false, + feeds: [], + records: [], + ...initial, + }; + const listeners = new Set(); + const calls = []; + return { + calls, + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener(state); + }, + start() { + calls.push('start'); + state = { ...state, polling: true }; + }, + stop() { + calls.push('stop'); + state = { ...state, polling: false }; + }, + }; +} + +/** A viewer whose camera looks down on (0°, 0°) from 50 km, with primitives. */ +function modelViewer(primitives) { + const base = fakeViewer(); + const camera = { + ...base.viewer.camera, + positionWC: new Cesium.Cartesian3(6_378_137 + 50_000, 0, 0), + positionCartographic: new Cesium.Cartographic(0, 0, 50_000), + directionWC: new Cesium.Cartesian3(-1, 0, 0), + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + return { + ...base, + viewer: { + ...base.viewer, + camera, + scene: { + camera, + primitives: { + add(collection) { + primitives.push(collection); + return collection; + }, + remove() {}, + }, + }, + }, + }; +} + +async function enabledLayer(receiver, clock, feeds = null, options = {}) { + const { services, calls } = fakeServices(); + const { sources, viewer } = options.viewer || fakeViewer(); + const layer = createLocalAdsbLayer({ + receiver, + feeds, + services: { ...services, ...options.services }, + now: () => clock.now, + loadModel: options.loadModel, + ...(options.createInputHandler + ? { createInputHandler: options.createInputHandler } + : {}), + }); + layer.init(viewer); + await layer.enable(); + return { layer, calls, sources }; +} + +test('enabling Local ADS-B asks the shared tuner for 1090 MHz; disabling leaves it alone', async (t) => { + const receiver = fakeReceiver({ mode: 'fm' }); + const clock = { now: 0 }; + const { layer } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + assert.equal(layer.id, 'local-adsb'); + assert.equal(layer.name, 'Local ADS-B'); + assert.deepEqual(receiver.modeCalls, ['adsb']); + assert.equal(await layer.disable(), true); + assert.deepEqual( + receiver.modeCalls, + ['adsb'], + 'turning the layer off never starts FM audio on its own', + ); +}); + +test('local aircraft use their class silhouette and scale in magenta', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [record(), record({ icao: 'a0b702', category: 'A7' })], + }); + await layer.update(); + + const entity = sources[0].entities.getById('local-adsb:abc123'); + assert.ok(entity); + assert.equal(entity.point, undefined); + assert.equal(entity.label, undefined); + assert.equal(entity.billboard.image.getValue(), aircraftIcon('airliner')); + assert.equal(entity.billboard.width.getValue(), 20); + assert.ok( + Cesium.Color.equals( + entity.billboard.color.getValue(), + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + ); + assert.ok( + Cesium.Cartesian3.equals( + entity.billboard.alignedAxis.getValue(), + Cesium.Cartesian3.ZERO, + ), + ); + assert.equal(entity.billboard.rotation.getValue(), 0); + assert.equal(entity.gevLabelModel.title, 'LOCAL1'); + assert.equal(typeof entity.gevDisplayPosition, 'function'); + + const heli = sources[0].entities.getById('local-adsb:a0b702'); + assert.equal(heli.billboard.image.getValue(), aircraftIcon('helicopter')); + assert.equal(heli.billboard.scale.getValue(), CLASS_SCALE_2D.helicopter); + assert.ok( + Cesium.Color.equals( + heli.billboard.color.getValue(), + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + 'the helicopter keeps the local magenta', + ); + assert.equal(heli.gevLabelModel.details[1], 'Helicopter · A7'); + + // On the ground the silhouette shrinks like a grounded public flight. + receiver.set({ + aircraft: [ + record({ + icao: 'a0b702', + category: 'A7', + onGround: true, + lastPositionAt: 100_500, + }), + ], + }); + await layer.update(); + assert.equal( + heli.billboard.scale.getValue(), + CLASS_SCALE_2D.helicopter * 0.8, + ); + + const [detectable] = layer.getDetectableObjects(); + assert.equal(detectable.sourceId, 'a0b702'); + assert.equal(detectable.type, 'AIR'); +}); + +test('the heading slews toward a new track instead of snapping', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const entity = sources[0].entities.getById('local-adsb:abc123'); + assert.equal(entity.billboard.rotation.getValue(), 0); + + clock.now = 101_000; + receiver.set({ + aircraft: [ + record({ + lat: 0.0008, + trackDeg: 90, + lastPositionAt: 101_000, + lastMessageAt: 101_000, + }), + ], + }); + await layer.update(); + const first = entity.billboard.rotation.getValue(); + assert.ok( + first > -Cesium.Math.PI_OVER_TWO && first <= 0, + 'one frame does not swing the nose 90°', + ); + for (let frame = 0; frame < 12; frame += 1) { + clock.now += 250; + await layer.update(); + } + assert.ok( + Math.abs(entity.billboard.rotation.getValue() + Cesium.Math.PI_OVER_TWO) < + 1e-6, + 'the nose settles on the new track', + ); +}); + +test('markers move in real time between fixes and never jump on a new fix', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + const latitude = () => + Cesium.Math.toDegrees( + Cesium.Cartographic.fromCartesian( + sources[0].entities.getById('local-adsb:abc123').position.getValue(), + ).latitude, + ); + // 180 kt due north: 1 s covers 92.6 m, 0.000833° of latitude. + const degPerSecond = (180 * 1852) / 3600 / 111_195; + receiver.set({ aircraft: [record({ lastPositionAt: 99_000 })] }); + await layer.update(); + assert.ok( + Math.abs(latitude() - degPerSecond) < 1e-5, + 'drawn where the aircraft is now, not at its last fix (no display delay)', + ); + clock.now = 100_500; + await layer.update(); + assert.ok(Math.abs(latitude() - 1.5 * degPerSecond) < 1e-5); + + // The next fix is 150 m behind the extrapolation; the marker slides. + const before = latitude(); + receiver.set({ + aircraft: [ + record({ + lat: before - 150 / 111_195, + lastPositionAt: 100_500, + lastMessageAt: 100_500, + }), + ], + }); + await layer.update(); + assert.ok(Math.abs(latitude() - before) < 1e-7, 'no jump at the new fix'); + clock.now = 101_500; + await layer.update(); + assert.ok( + Math.abs(latitude() - (before - 150 / 111_195 + degPerSecond)) < 1e-5, + 'the correction has decayed onto the new fix', + ); +}); + +test('an implausible jump in a feed record is ignored by the display', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ groundSpeedKt: 120, trackDeg: 0 })] }); + await layer.update(); + clock.now = 101_000; + receiver.set({ + aircraft: [ + record({ + lat: 0.05, + groundSpeedKt: 120, + lastPositionAt: 101_000, + lastMessageAt: 101_000, + }), + ], + }); + await layer.update(); + const lat = Cesium.Math.toDegrees( + Cesium.Cartographic.fromCartesian( + sources[0].entities.getById('local-adsb:abc123').position.getValue(), + ).latitude, + ); + assert.ok(lat < 0.001, `stayed on its track (${lat})`); + assert.equal(layer.getStats().rejectedPositions, 1); +}); + +test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const trails = []; + const { layer } = await enabledLayer(receiver, clock, null, { + services: { + trails: { + createTrail(viewer, options) { + const trail = { options, positions: [], destroyed: false }; + trails.push(trail); + return { + setPositions: (positions) => (trail.positions = positions), + destroy: () => (trail.destroyed = true), + }; + }, + }, + }, + }); + t.after(() => layer.destroy()); + for (let second = 0; second < 4; second += 1) { + clock.now = 100_000 + second * 1_000; + receiver.set({ + aircraft: [ + record({ + lat: second * 0.0008, + lastPositionAt: clock.now, + lastMessageAt: clock.now, + }), + ], + }); + await layer.update(); + if (second === 1) assert.equal(layer.selectAircraft('abc123'), true); + } + assert.equal(trails.length, 1); + assert.equal(trails[0].options.color, '#ff4fd8'); + assert.equal(trails[0].options.width, 2.5); + assert.equal(trails[0].positions.length, 4, 'every heard fix, oldest first'); + assert.ok( + Cesium.Cartographic.fromCartesian(trails[0].positions[0]).latitude < + Cesium.Cartographic.fromCartesian(trails[0].positions[3]).latitude, + ); + clock.now = 200_000; + await layer.update(); + assert.equal(trails[0].destroyed, true, 'the trail leaves with the aircraft'); +}); + +test('adsbdb type, operator and route reach the card for the selected aircraft only', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const queries = []; + const { layer, sources } = await enabledLayer(receiver, clock, null, { + services: { + enrichment: { + async getEnrichment(query) { + queries.push(`${query.kind}:${query.id}`); + if (query.kind === 'type') + return { found: true, typeCode: 'B407', typeName: 'Bell 407' }; + return { + found: true, + airline: 'Air Evac', + origin: { code: 'AUS', lat: 0.1, lon: 0 }, + destination: { code: 'SAT', lat: -0.1, lon: 0 }, + }; + }, + }, + }, + }); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [ + record({ icao: 'a0b702', callsign: 'EVC145', category: 'A7' }), + record({ icao: 'def456', callsign: 'OTHER1' }), + ], + }); + await layer.update(); + assert.deepEqual(queries, [], 'no request per contact'); + assert.equal(layer.selectAircraft('a0b702'), true); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.deepEqual([...queries].sort(), ['route:EVC145', 'type:a0b702']); + const entity = sources[0].entities.getById('local-adsb:a0b702'); + assert.deepEqual(entity.gevLabelModel.details.slice(0, 4), [ + 'ICAO A0B702 · EVC145', + 'Helicopter · A7', + 'Air Evac · Bell 407', + 'AUS → SAT', + ]); + assert.equal( + entity.gevLabelModel.details.at(-1), + 'Heard by your receiver · 1090 MHz · browser SDR', + ); + layer.selectAircraft('a0b702'); + await layer.update(); + assert.equal(queries.length, 2, 'each key is asked for once'); +}); + +test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const loads = []; + const display = { models3d: true, models3dMode: 'proximity' }; + const primitives = []; + const { layer, sources } = await enabledLayer(receiver, clock, null, { + services: { display: { getParams: () => display } }, + viewer: modelViewer(primitives), + loadModel: async (options) => { + const model = { + ...options, + ready: true, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; + loads.push(model); + return model; + }, + }); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ icao: 'a0b702', category: 'A7' })] }); + await layer.update(); + await new Promise((resolve) => setImmediate(resolve)); + await layer.update(); + assert.equal(loads.length, 1); + const [model] = loads; + assert.equal( + model.url, + '/models/bell206.glb', + 'the Flights helicopter model', + ); + assert.equal(model.scale, 1); + assert.equal(model.id, 'local-adsb:a0b702'); + assert.ok( + Cesium.Color.equals( + model.color, + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + ); + assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(model.colorBlendAmount, 0.94); + assert.equal(model.show, true); + const heli = sources[0].entities.getById('local-adsb:a0b702'); + assert.equal( + heli.billboard.show.getValue(), + false, + 'the model is the visual', + ); + const origin = Cesium.Matrix4.getTranslation( + model.modelMatrix, + new Cesium.Cartesian3(), + ); + assert.ok(Cesium.Cartesian3.distance(origin, heli.position.getValue()) < 1); + + display.models3d = false; + await layer.update(); + assert.equal(model.destroyed || !primitives[0].contains(model), true); + assert.equal(heli.billboard.show.getValue(), true, 'the billboard is back'); +}); + +test('markers drop when the position is 60 s old and records when silent 60 s', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [ + record(), + record({ + icao: 'def456', + callsign: ' ', + lat: null, + lon: null, + lastPositionAt: null, + }), + ], + }); + await layer.update(); + const entities = () => sources[0].entities.values.map((entity) => entity.id); + assert.deepEqual( + entities(), + ['local-adsb:abc123'], + 'only positioned aircraft draw', + ); + assert.equal(layer.getStats().count, 1); + + // Still transmitting (no position) but the last position is 60 s old. + receiver.set({ aircraft: [record({ lastMessageAt: 150_000 })] }); + clock.now = 159_999; + await layer.update(); + assert.deepEqual(entities(), ['local-adsb:abc123']); + clock.now = 160_000; + await layer.update(); + assert.deepEqual(entities(), [], 'a stale position is not drawn'); + assert.deepEqual(layer.getDetectableObjects(), []); +}); + +test('selecting a marker publishes the readout card and eviction clears it', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, calls } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const metadata = calls.registered.at(-1); + assert.equal(metadata.id, 'local-adsb:abc123'); + assert.equal(metadata.layerId, 'local-adsb'); + assert.equal(metadata.properties.icao, 'abc123'); + assert.equal(layer.selectAircraft('abc123'), true); + assert.deepEqual(calls.selected, ['local-adsb:abc123']); + assert.equal(layer.selectAircraft('ffffff'), false); + + clock.now = 200_000; + await layer.update(); + assert.deepEqual(calls.cleared, [{ layerId: 'local-adsb', evicted: true }]); + assert.equal(calls.removed.at(-1).layerId, 'local-adsb'); + assert.equal(calls.removed.at(-1).retainIds.size, 0); +}); + +test('the click card lists identity, kinematics, freshness and the receiver line', () => { + const card = localAdsbCardModel( + record({ + icao: 'ae5d8a', + callsign: 'SHINR42', + altitudeFt: 1600, + groundSpeedKt: 129.9, + trackDeg: 330.5, + verticalRateFpm: 64, + lastPositionAt: 96_000, + messageCount: 48, + }), + 100_000, + ); + assert.equal(card.title, 'SHINR42'); + assert.equal(card.accent, '#ff4fd8'); + assert.deepEqual(card.details, [ + 'ICAO AE5D8A · SHINR42', + 'ALT 1,600 FT · GS 130 KT · TRK 331°', + 'V/S +64 FPM', + 'POSITION 4 S AGO · 48 MSGS', + 'Heard by your receiver · 1090 MHz · browser SDR', + ]); + assert.equal(HEARD_BY_RECEIVER, 'Heard by your receiver'); + const bare = localAdsbCardModel( + record({ + callsign: null, + altitudeFt: null, + groundSpeedKt: null, + trackDeg: null, + verticalRateFpm: -1_280, + messageCount: 1, + }), + 100_000, + ); + assert.equal(bare.title, 'ABC123'); + assert.deepEqual(bare.details.slice(0, 4), [ + 'ICAO ABC123 · NO CALLSIGN', + 'ALT — FT · GS — KT · TRK —', + 'V/S -1,280 FPM', + 'POSITION 0 S AGO · 1 MSG', + ]); +}); + +test('layer stats guide the operator to the Radio card until ADS-B is streaming', () => { + const clock = { now: 100_000 }; + const { services } = fakeServices(); + const receiver = fakeReceiver(); + const layer = createLocalAdsbLayer({ + receiver, + services, + now: () => clock.now, + }); + assert.equal(layer.getStats().statusMessage, 'connect a receiver in Radio'); + receiver.set({ connected: true, status: 'streaming', mode: 'fm' }); + assert.equal(layer.getStats().statusMessage, 'receiver is in FM mode'); + receiver.set({ + mode: 'adsb', + messagesPerSecond: 3.5, + aircraft: [record(), record({ icao: 'def456', lat: null, lon: null })], + }); + const stats = layer.getStats(); + assert.equal(stats.count, 1); + assert.equal(stats.loadingLabel, '2 heard · 3.5 msg/s'); + receiver.set({ status: 'error', message: 'RTL-SDR sample stream stopped' }); + assert.equal(layer.getStats().error, 'RTL-SDR sample stream stopped'); + receiver.set({ status: 'idle', webUsbSupported: false, connected: false }); + assert.equal( + layer.getStats().statusMessage, + 'WebUSB needs desktop Chrome or Edge', + ); +}); + +test('the receiver line names every band and source that heard the aircraft', () => { + const line = (overrides) => localAdsbReceiverLine(record(overrides)); + assert.equal(line({}), 'Heard by your receiver · 1090 MHz · browser SDR'); + assert.equal( + line({ band: '1090', source: 'feed' }), + 'Heard by your receiver · 1090 MHz · decoder feed', + ); + assert.equal( + line({ band: '978', source: 'feed' }), + 'Heard by your receiver · 978 MHz UAT · decoder feed', + ); + assert.equal( + line({ bands: ['1090', '978'], sources: ['feed'] }), + 'Heard by your receiver · 1090 MHz + 978 MHz UAT · decoder feed', + ); + assert.equal( + line({ bands: ['1090'], sources: ['webusb', 'feed'] }), + 'Heard by your receiver · 1090 MHz · browser SDR + decoder feed', + ); + assert.equal( + line({ bands: ['1090', '978'], sources: ['webusb', 'feed'] }), + 'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed', + ); + assert.equal( + localAdsbReceiverLine({ icao: 'abc123' }), + HEARD_BY_RECEIVER, + 'a record without band or source keeps the plain line', + ); +}); + +test('feed records merge with browser SDR records by ICAO and UAT-only aircraft get a ring', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const feeds = fakeFeeds(); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock, feeds); + t.after(() => layer.destroy()); + assert.deepEqual(feeds.calls, ['start'], 'the feeds poll while enabled'); + + receiver.set({ aircraft: [record({ lastPositionAt: 98_000 })] }); + feeds.set({ + configured: true, + feeds: [{ band: '978', label: '978 MHz UAT', status: 'live' }], + records: [ + record({ lat: 1, band: '978', source: 'feed', lastPositionAt: 99_000 }), + record({ + icao: 'a1b2c3', + callsign: 'N978UA', + band: '978', + source: 'feed', + }), + ], + }); + await layer.update(); + const shared = sources[0].entities.getById('local-adsb:abc123'); + const uat = sources[0].entities.getById('local-adsb:a1b2c3'); + assert.equal(sources[0].entities.values.length, 2); + assert.equal( + Cesium.Cartographic.fromCartesian( + shared.position.getValue(), + ).latitude.toFixed(4), + Cesium.Math.toRadians(1).toFixed(4), + 'the newer feed position wins', + ); + assert.equal(shared.point, undefined, 'heard on 1090 too: no UAT ring'); + assert.equal( + shared.gevLabelModel.details.at(-1), + 'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed', + ); + assert.ok(uat.point, 'a UAT-only aircraft carries the ring'); + assert.equal(uat.point.outlineWidth.getValue(), 1.5); + assert.ok( + Cesium.Color.equals(uat.point.color.getValue(), Cesium.Color.TRANSPARENT), + ); + assert.ok( + Cesium.Color.equals( + uat.billboard.color.getValue(), + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + 'same magenta marker family', + ); + assert.equal( + uat.gevLabelModel.details.at(-1), + 'Heard by your receiver · 978 MHz UAT · decoder feed', + ); + const stats = layer.getStats(); + assert.equal(stats.source, 'WebUSB + decoder feeds'); + assert.equal(stats.loadingLabel, '1 feed live · 2 heard'); + assert.equal(stats.count, 2); + + await layer.disable(); + assert.deepEqual(feeds.calls, ['start', 'stop']); +}); + +test('row status reflects decoder feeds and the browser SDR together', () => { + const idle = { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + }; + const usb = { + ...idle, + connected: true, + mode: 'adsb', + status: 'streaming', + messagesPerSecond: 3.5, + }; + const feedState = (...statuses) => ({ + configured: true, + polling: true, + feeds: statuses.map(([band, status]) => ({ + band, + label: band, + status, + })), + }); + const status = (receiver, feeds, heard = 14) => + localAdsbStatus({ receiver, feedState: feeds, heard }); + + // No feeds configured: the WebUSB statuses are unchanged. + assert.deepEqual(status(idle, null), { + source: 'RTL-SDR · WebUSB', + status: 'idle', + statusMessage: 'connect a receiver in Radio', + }); + assert.deepEqual( + status(idle, { configured: false, feeds: [], polling: false }), + status(idle, null), + ); + assert.equal(status(usb, null).loadingLabel, '14 heard · 3.5 msg/s'); + assert.equal( + status(idle, { configured: null, polling: true, feeds: [] }).loadingLabel, + 'checking decoder feeds', + ); + + let stats = status(idle, feedState(['1090', 'live'], ['978', 'live'])); + assert.deepEqual(stats, { + source: 'Decoder feeds', + status: 'streaming', + loadingLabel: '2 feeds live · 14 heard', + }); + stats = status(usb, feedState(['978', 'live'])); + assert.equal(stats.source, 'WebUSB + decoder feeds'); + assert.equal(stats.loadingLabel, '1 feed live · 14 heard · USB 3.5 msg/s'); + + // A live feed keeps the row nominal; the unreachable one is a trailing note. + stats = status(idle, feedState(['1090', 'live'], ['978', 'unreachable']), 9); + assert.deepEqual(stats, { + source: 'Decoder feeds', + status: 'streaming', + loadingLabel: '9 heard · feed 978 unreachable', + }); + assert.equal(layerFeedState(stats), 'nominal'); + + stats = status(idle, feedState(['978', 'unreachable']), 0); + assert.equal(stats.status, 'error'); + assert.equal(stats.error, 'feed 978 unreachable'); + stats = status( + usb, + feedState(['1090', 'stale'], ['978', 'stale'], ['1090', 'invalid']), + ); + assert.equal(stats.status, 'streaming'); + assert.equal(stats.degraded, undefined); + assert.equal(layerFeedState(stats), 'nominal'); + assert.equal( + stats.loadingLabel, + '14 heard · USB 3.5 msg/s · feeds 1090 #1, 978 stale · feed 1090 #2 invalid', + ); +}); + +test('a stale decoder feed does not mark a streaming browser receiver degraded', () => { + // Owner field test: dump1090 stopped so the browser could take the dongle. + const stats = localAdsbStatus({ + receiver: { + webUsbSupported: true, + connected: true, + mode: 'adsb', + status: 'streaming', + messagesPerSecond: 5.8, + }, + feedState: { + configured: true, + polling: true, + feeds: [{ band: '1090', label: '1090 MHz', status: 'stale' }], + }, + heard: 3, + }); + assert.deepEqual(stats, { + source: 'WebUSB + decoder feeds', + status: 'streaming', + loadingLabel: '3 heard · USB 5.8 msg/s · feed 1090 stale', + }); + assert.equal(layerFeedState(stats), 'nominal'); +}); + +test('a browser receiver error beside a live feed is a note on a nominal row', () => { + const stats = localAdsbStatus({ + receiver: { + webUsbSupported: true, + connected: true, + mode: 'adsb', + status: 'error', + message: 'RTL-SDR sample stream stopped', + }, + feedState: { + configured: true, + polling: true, + feeds: [{ band: '978', label: '978 MHz UAT', status: 'live' }], + }, + heard: 2, + }); + assert.equal(stats.status, 'streaming'); + assert.equal(layerFeedState(stats), 'nominal'); + assert.equal(stats.loadingLabel, '2 heard · USB error'); +}); + +test('every feed stale reads STALE, not an error; unreachable stays an error', () => { + const idle = { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + }; + const feedState = (...feeds) => ({ + configured: true, + polling: true, + feeds: feeds.map(([band, status, label = band]) => ({ + band, + label, + status, + })), + }); + const status = (feeds, heard = 3) => + localAdsbStatus({ receiver: idle, feedState: feeds, heard }); + + // A decoder that stopped but still serves its last aircraft.json. + let stats = status(feedState(['1090', 'stale'], ['978', 'stale'])); + assert.equal(stats.status, 'stale'); + assert.equal(stats.error, undefined); + assert.equal(layerFeedState(stats), 'stale'); + assert.equal(stats.statusMessage, 'feeds 1090, 978 stale · 3 heard'); + + // An invalid entry is a configuration note, not a reason to fail the row. + stats = status(feedState(['1090', 'stale'], ['978', 'invalid']), 0); + assert.equal(layerFeedState(stats), 'stale'); + + stats = status(feedState(['1090', 'stale'], ['978', 'unreachable']), 0); + assert.equal(stats.status, 'error'); + assert.equal(layerFeedState(stats), 'unavailable'); + stats = status(feedState(['978', 'invalid']), 0); + assert.equal(stats.status, 'error'); +}); + +test('row status names two same-band feeds by their ordinal labels', () => { + const stats = localAdsbStatus({ + receiver: { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + }, + feedState: { + configured: true, + polling: true, + feeds: [ + { band: '978', label: '978 MHz UAT #1', status: 'live' }, + { band: '978', label: '978 MHz UAT #2', status: 'unreachable' }, + { band: '1090', label: '1090 MHz', status: 'unreachable' }, + ], + }, + heard: 4, + }); + assert.equal( + stats.loadingLabel, + '4 heard · feeds 978 MHz UAT #2, 1090 unreachable', + ); +}); + +function fakeModel(options) { + return { + ...options, + ready: true, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; +} + +test('the last aircraft expiring removes its 3D model and cancels a pending load', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const primitives = []; + const loads = []; + const deferred = []; + const { layer } = await enabledLayer(receiver, clock, null, { + services: { + display: { + getParams: () => ({ models3d: true, models3dMode: 'proximity' }), + }, + }, + viewer: modelViewer(primitives), + loadModel: (options) => { + const model = fakeModel(options); + loads.push(model); + if (options.id === 'local-adsb:abc123') return Promise.resolve(model); + return new Promise((resolve) => deferred.push(() => resolve(model))); + }, + }); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ category: 'A7' })] }); + await layer.update(); + await new Promise((resolve) => setImmediate(resolve)); + await layer.update(); + const [model] = loads; + assert.equal(model.show, true, 'the model is the visual'); + assert.ok(primitives[0].contains(model)); + + // Its position ages out: no markers remain, so no per-frame model pass. + clock.now = 160_000; + await layer.update(); + assert.equal( + model.destroyed || !primitives[0].contains(model), + true, + 'the expired aircraft leaves no model behind', + ); + + // A load still in flight when its aircraft expires is never admitted. + clock.now = 200_000; + receiver.set({ + aircraft: [ + record({ + icao: 'def456', + lastPositionAt: 200_000, + lastMessageAt: 200_000, + }), + ], + }); + await layer.update(); + clock.now = 260_000; + await layer.update(); + assert.equal(deferred.length, 1, 'a load was in flight'); + deferred[0](); + await new Promise((resolve) => setImmediate(resolve)); + const late = loads.at(-1); + assert.equal(late.id, 'local-adsb:def456'); + assert.equal(primitives[0].contains(late), false); + assert.equal(late.destroyed, true); +}); + +/** A viewer with a canvas and scripted picks, plus the layer's click action. */ +function clickHarness() { + const base = fakeViewer(); + const scene = { + ...base.viewer.scene, + canvas: {}, + pickResult: null, + drillResult: [], + pick() { + return scene.pickResult; + }, + drillPick() { + return scene.drillResult; + }, + }; + const handler = { actions: new Map() }; + return { + scene, + viewer: { ...base, viewer: { ...base.viewer, scene } }, + createInputHandler: () => ({ + setInputAction(action, type) { + handler.actions.set(type, action); + }, + destroy() {}, + }), + click() { + handler.actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)({ + position: new Cesium.Cartesian2(10, 10), + }); + }, + }; +} + +test('clicking the selected aircraft keeps it selected and republishes its card', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const harness = clickHarness(); + const { layer, calls } = await enabledLayer(receiver, clock, null, { + viewer: harness.viewer, + createInputHandler: harness.createInputHandler, + }); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const entity = + harness.viewer.sources[0].entities.getById('local-adsb:abc123'); + harness.scene.pickResult = { id: entity, primitive: {} }; + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123']); + // Its card may have been displaced (another readout, a camera move): a + // second click on the same marker must show it again, never deselect it. + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:abc123']); + assert.deepEqual(calls.cleared, []); +}); + +test('a click on a local aircraft under an unowned or trail pick still selects it', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const harness = clickHarness(); + const owners = new Map(); + const { resolvePickId } = await import('../../data/pickRegistry.js'); + const { layer, calls } = await enabledLayer(receiver, clock, null, { + viewer: harness.viewer, + createInputHandler: harness.createInputHandler, + services: { + picking: { + registerPickOwner: (id, predicate) => owners.set(id, predicate), + unregisterPickOwner: (id) => owners.delete(id), + resolvePickId, + isOwnedByOtherLayer: (layerId, pickedId) => + [...owners].some( + ([owner, predicate]) => owner !== layerId && predicate(pickedId), + ), + }, + }, + }); + owners.set('trails', (id) => String(id).startsWith('gev-trail:')); + owners.set('flights', (id) => id === 'abc123'); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [record(), record({ icao: 'def456', lat: 0.01 })], + }); + await layer.update(); + const [first, second] = ['abc123', 'def456'].map((icao) => + harness.viewer.sources[0].entities.getById(`local-adsb:${icao}`), + ); + // A photoreal tile (no pick id) drawn over the 3D model at this pixel. + harness.scene.pickResult = { primitive: { tileset: true } }; + harness.scene.drillResult = [ + harness.scene.pickResult, + { primitive: { id: 'local-adsb:abc123' }, id: 'local-adsb:abc123' }, + ]; + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123']); + + // The selected aircraft's trail crosses another local aircraft's marker. + harness.scene.pickResult = { id: { id: 'gev-trail:local-adsb-head-1' } }; + harness.scene.drillResult = [ + harness.scene.pickResult, + { id: second, primitive: {} }, + ]; + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:def456']); + + // Another layer's own contact on top is that layer's click. + harness.scene.pickResult = { id: 'abc123', primitive: {} }; + harness.scene.drillResult = [ + harness.scene.pickResult, + { id: first, primitive: {} }, + ]; + harness.click(); + assert.equal(calls.selected.length, 2, 'the Flights contact is not taken'); +}); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js new file mode 100644 index 000000000..50cebe07e --- /dev/null +++ b/src/layers/localAdsb/models.js @@ -0,0 +1,282 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { selectModelEligible } from '../../data/modelEligibility.js'; +import { civilAircraftModelSpec } from '../flights/modelSpec.js'; +import { + FLEET_DR_INTERVAL_MS, + MODEL_ALL_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_ALT_CEIL_M, + MODEL_HEADING_OFFSET_DEG, + MODEL_MAX, + MODEL_MAX_ALL, + MODEL_MIN_PX, + MODEL_PROX_ADD_M, + MODEL_PROX_KEEP_M, +} from '../flights/policy.js'; + +const specKey = (spec) => `${spec.url}@${spec.scale}`; + +/** + * 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle. + * + * Mirrors the public Flights fleet: models only below the altitude ceiling, + * the same proximity/all caps and add/keep radii, the same visible-first + * eligibility (`selectModelEligible`), the same per-class asset and scale + * (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here + * magenta, as Flights tints military amber). Each model owns its matrix. A + * billboard stays the visual until its model is loaded AND placed; a grounded + * aircraft's model rides the one-shot ground snap plus the class belly + * offset, and without a resolved ground it stays a billboard. + * @param {object} options + * @param {object} options.viewer Cesium viewer. + * @param {Cesium.Color} options.color Model tint. + * @param {(url: string) => string} options.resolveAsset + * @param {object|null} [options.groundSnap] `createGroundSnap()` instance. + * @param {(options: object) => Promise} [options.loadModel] + * @returns {object} + */ +export function createLocalAdsbModels({ + viewer, + color, + resolveAsset, + groundSnap = null, + loadModel = (options) => Cesium.Model.fromGltfAsync(options), +}) { + const collection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(collection); + /** @type {Map} id -> admitted model */ + const models = new Map(); + /** @type {Map} id -> generation of an in-flight load */ + const pending = new Map(); + let generation = 0; + let destroyed = false; + let lastEligibilityAt = 0; + let eligible = new Set(); + const scratchHpr = new Cesium.HeadingPitchRoll(); + const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1); + const scratchCarto = new Cesium.Cartographic(); + const scratchGround = new Cesium.Cartesian3(); + + function release(id) { + pending.delete(id); + const model = models.get(id); + if (!model) return; + models.delete(id); + if (!collection.isDestroyed()) collection.remove(model); + groundSnap?.forget?.(id); + } + + function releaseAll() { + for (const id of [...models.keys()]) release(id); + pending.clear(); + generation += 1; + } + + async function ensure(id, marker, cap) { + const spec = civilAircraftModelSpec(marker.klass); + const key = specKey(spec); + const current = models.get(id); + if (current && current._gevSpecKey === key) return; + if (current) release(id); + if (pending.has(id) || models.size + pending.size >= cap) return; + const loadGeneration = generation; + pending.set(id, loadGeneration); + let model; + try { + model = await loadModel({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: spec.blendAmount, + id, + }); + } catch { + if (pending.get(id) === loadGeneration) pending.delete(id); + return; + } + const stale = + destroyed || + pending.get(id) !== loadGeneration || + generation !== loadGeneration || + !eligible.has(id) || + models.has(id) || + specKey(civilAircraftModelSpec(marker.klass)) !== key; + if (pending.get(id) === loadGeneration) pending.delete(id); + if (stale) { + model.destroy?.(); + return; + } + model.id = id; + model._gevSpecKey = key; + model._gevBellyM = spec.bellyM; + // Admitted, not yet the visual: placement turns it on. + model.show = false; + collection.add(model); + models.set(id, model); + } + + function regime(preferences) { + if (!preferences?.models3d) return false; + const height = viewer.camera?.positionCartographic?.height ?? Infinity; + return height < MODEL_ALT_CEIL_M; + } + + function refreshEligibility(markers, preferences) { + const all = preferences.models3dMode === 'all'; + const cap = all ? MODEL_MAX_ALL : MODEL_MAX; + const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; + const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; + const camera = viewer.camera; + const cameraPosition = camera.positionWC; + const cull = camera.frustum.computeCullingVolume( + cameraPosition, + camera.directionWC, + camera.upWC, + ); + const candidates = []; + for (const [id, marker] of markers) { + if (!marker.position) continue; + const distanceSq = Cesium.Cartesian3.distanceSquared( + cameraPosition, + marker.position, + ); + if (distanceSq > keepM * keepM) continue; + Cesium.Cartesian3.clone(marker.position, scratchSphere.center); + candidates.push([ + id, + distanceSq, + cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE, + ]); + } + candidates.sort((a, b) => a[1] - b[1]); + eligible = selectModelEligible(candidates, { + cap, + addDistSq: addM * addM, + isModeled: (id) => models.has(id), + }); + for (const id of [...models.keys(), ...pending.keys()]) + if (!eligible.has(id)) release(id); + for (const id of eligible) void ensure(id, markers.get(id), cap); + } + + function placement(id, marker) { + if (!marker.onGround) return marker.position; + const model = models.get(id); + // The vertical sample must hit the tile skin, not this aircraft's own + // billboard or model. + const height = groundSnap?.heightFor?.(viewer, id, marker.position, () => + [marker.entity, model].filter(Boolean), + ); + if (!Number.isFinite(height)) return null; + const carto = Cesium.Cartographic.fromCartesian( + marker.position, + Cesium.Ellipsoid.WGS84, + scratchCarto, + ); + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + height + (model?._gevBellyM || 0), + Cesium.Ellipsoid.WGS84, + scratchGround, + ); + } + + return { + /** Ids whose model admission is current; the layer enriches these. */ + get eligible() { + return eligible; + }, + + /** + * Per-frame pass: re-derive eligibility at the fleet cadence, then place + * every admitted model and hand each marker's visual to its model only + * once the model is ready and placed. + * @param {Map} markers Layer markers (position, courseDeg, + * klass, onGround); `modelOwnsVisual` is written back. + * @param {object} preferences `{ models3d, models3dMode }`. + * @param {number} nowMs + */ + frame(markers, preferences, nowMs) { + if (destroyed) return; + if (!regime(preferences)) { + if (models.size || pending.size) releaseAll(); + eligible = new Set(); + for (const marker of markers.values()) marker.modelOwnsVisual = false; + return; + } + if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) { + lastEligibilityAt = nowMs; + refreshEligibility(markers, preferences); + } + const occluder = horizonOccluder(viewer.camera); + for (const [id, marker] of markers) { + const model = models.get(id); + marker.modelOwnsVisual = false; + if (!model || !marker.position) continue; + const position = placement(id, marker); + if (!position || !occluder.isPointVisible(marker.position)) { + model.show = false; + continue; + } + scratchHpr.heading = Cesium.Math.toRadians( + (marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG, + ); + scratchHpr.pitch = 0; + scratchHpr.roll = 0; + Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + model.modelMatrix, + ); + if (!model.ready) { + model.show = false; + continue; + } + model.show = true; + marker.modelOwnsVisual = true; + } + for (const id of [...models.keys()]) if (!markers.has(id)) release(id); + }, + + /** + * Drop the model, and any load still in flight, of every aircraft that + * is no longer a marker. The layer calls this whenever markers expire: + * the per-frame pass does not run once the last marker is gone. + * @param {Map|Set} live Current marker ids. + */ + retain(live) { + if (destroyed) return; + let dropped = false; + for (const id of [...models.keys(), ...pending.keys()]) { + if (live.has(id)) continue; + release(id); + eligible.delete(id); + dropped = true; + } + if (dropped) eligible = new Set(eligible); + }, + + /** Drop every model (3D off, layer disabled). */ + clear: releaseAll, + + /** Number of admitted models (tests and diagnostics). */ + get size() { + return models.size; + }, + + destroy() { + destroyed = true; + releaseAll(); + groundSnap?.clear?.(); + if (!viewer.isDestroyed?.() && !collection.isDestroyed()) + viewer.scene.primitives.remove(collection); + }, + }; +} diff --git a/src/layers/localAdsb/motion.js b/src/layers/localAdsb/motion.js new file mode 100644 index 000000000..279bef6d3 --- /dev/null +++ b/src/layers/localAdsb/motion.js @@ -0,0 +1,427 @@ +import * as Cesium from 'cesium'; +import { + COURSE_HOLD_SPEED_MPS, + TURN_MIN_SPEED_MPS, + courseBetweenCartesians, + courseSlewCapDps, + estimateTurnRateDps, + lerpAngleDeg, + limitCourseStep, + norm360, + projectGroundArcLatLon, + speedRamp, + staleCoastLimitSeconds, +} from '../../data/motionModel.js'; +import { + LOCAL_ADSB_REANCHOR_AFTER, + localAdsbFixIsPlausible, +} from '../../sources/adsbRecords.js'; +import { + COURSE_MAX_DPS, + COURSE_SLEW_DT_MAX_SEC, + DR_CORRECTION_MS, +} from '../flights/policy.js'; + +/** Heard positions kept per aircraft for its trail: 10 minutes, 600 fixes. */ +export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000; +export const LOCAL_ADSB_HISTORY_POINTS = 600; +/** A display correction larger than this snaps instead of sliding. */ +export const LOCAL_ADSB_SNAP_M = 3_000; +/** Chord course uses the newest fix at least this much older than the anchor. */ +const CHORD_BASELINE_MS = 2_000; +/** Turn rate is estimated over this window of heard fixes. */ +const TURN_WINDOW_MS = 30_000; +/** Coast at least this long past a fix; longer while messages keep coming. */ +const COAST_MINIMUM_SEC = 10; +const COAST_CONTACT_GRACE_SEC = 10; +const COAST_MAXIMUM_SEC = 60; +const KT_TO_MPS = 1852 / 3600; +const EARTH_RADIUS_M = 6_371_008.8; +const DEG = Math.PI / 180; +const _chordFrom = new Cesium.Cartesian3(); +const _chordTo = new Cesium.Cartesian3(); + +function finite(value) { + return Number.isFinite(value) ? value : null; +} + +/** + * When a record's telemetry (altitude, speed, track, climb) was last heard: + * its newest message, which velocity-only and altitude-only messages advance + * without a new position. + */ +function telemetryTime(record) { + const messageAt = finite(record?.lastMessageAt); + const positionAt = finite(record?.lastPositionAt); + if (messageAt === null) return positionAt; + return positionAt === null ? messageAt : Math.max(messageAt, positionAt); +} + +function chordCourse(from, to) { + Cesium.Cartesian3.fromDegrees(from.lon, from.lat, 0, undefined, _chordFrom); + Cesium.Cartesian3.fromDegrees(to.lon, to.lat, 0, undefined, _chordTo); + return courseBetweenCartesians(_chordFrom, _chordTo); +} + +/** + * Real-time display motion for one locally heard aircraft. + * + * Unlike the public flight layers there is no deliberate one-poll display + * delay: local fixes arrive about once a second, so the marker is + * extrapolated FORWARD from the newest fix with the shared motion model + * (constant-rate-turn arc, path-derived course blended over the reported + * track by speed, rate-limited slew) and a new fix is absorbed by a + * correction offset that decays over the same window the tracked public + * flight uses, so the marker never snaps. Every fix also passes the same + * speed check the decoder applies, which covers decoder-feed records too. + * + * Positions are geodetic (degrees, barometric feet); callers add the render + * datum. The heard history doubles as the selected aircraft's trail. + */ +export class LocalAdsbMotion { + constructor() { + /** @type {Array<{at:number, lat:number, lon:number, altitudeFt:number|null, + * trackDeg:number|null, speedMps:number|null}>} */ + this.fixes = []; + this.anchor = null; + this.correction = null; + this.courseDeg = null; + this.lastCourseAt = null; + this.lastContactAt = null; + this.rejectStreak = 0; + this.rejectedFixes = 0; + // The newest position evaluated, accepted or refused. Re-reading it (the + // layer re-syncs the same record several times a second) is not a new + // observation and must not count toward a re-anchor. + this.lastCandidate = null; + // When the anchor's telemetry was heard. Telemetry has its own freshness: + // position freshness only decides trail points and re-anchoring. + this.telemetryAt = null; + } + + /** + * Take a record's newest position. Returns true when it became the new + * anchor; a repeated, out-of-order or implausible fix returns false. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms (display clock). + * @returns {boolean} + */ + observe(record, nowMs) { + if (Number.isFinite(record?.lastMessageAt)) + this.lastContactAt = Math.max( + this.lastContactAt ?? -Infinity, + record.lastMessageAt, + ); + const at = finite(record?.lastPositionAt); + const lat = finite(record?.lat); + const lon = finite(record?.lon); + if (at === null || lat === null || lon === null) return false; + const newest = this.fixes.at(-1); + const seen = this.lastCandidate; + // No new position, but velocity and altitude messages arrive between + // position messages: newer telemetry still reaches the anchor. + if ( + (newest && at <= newest.at) || + (seen && at <= seen.at) || + // A decoder feed re-reads the same fix every poll and its rebased time + // jitters with `seen_pos` rounding: the same position is not a new fix. + (seen && !seen.accepted && lat === seen.lat && lon === seen.lon) + ) { + this._refreshTelemetry(record, nowMs); + return false; + } + if (newest && lat === newest.lat && lon === newest.lon) { + // Same coordinates, but altitude and velocity may still be newer (a + // hovering helicopter climbing, an aircraft coming to a stop): update + // the motion telemetry without adding a trail point. + this.lastCandidate = { at, lat, lon, accepted: true }; + this.rejectStreak = 0; + this._refreshTelemetry(record, nowMs); + return false; + } + const speedMps = Number.isFinite(record.groundSpeedKt) + ? Math.max(0, record.groundSpeedKt) * KT_TO_MPS + : (newest?.speedMps ?? null); + const fix = { + at, + lat, + lon, + altitudeFt: finite(record.altitudeFt), + trackDeg: Number.isFinite(record.trackDeg) + ? norm360(record.trackDeg) + : null, + speedMps, + }; + if ( + newest && + !localAdsbFixIsPlausible(newest, fix, { + // The last known speed stands in while a record carries none. + groundSpeedKt: Number.isFinite(speedMps) ? speedMps / KT_TO_MPS : null, + category: record.category, + }) + ) { + this.rejectedFixes += 1; + this.rejectStreak += 1; + this.lastCandidate = { at, lat, lon, accepted: false }; + if (this.rejectStreak < LOCAL_ADSB_REANCHOR_AFTER) { + // The position is refused, not the record's telemetry. + this._refreshTelemetry(record, nowMs); + return false; + } + // Three refusals in a row: the stream moved on and the old anchor was + // the outlier. Restart the history from the new fix. + this.fixes = []; + this.correction = null; + } + this.rejectStreak = 0; + this.lastCandidate = { at, lat, lon, accepted: true }; + + const prior = this.anchor; + const before = prior ? this.displayAt(nowMs, { slew: false }) : null; + this.fixes.push(fix); + this._trimHistory(at); + // Position and telemetry are accepted independently: merged receivers + // can deliver a newer position in a record whose last message is OLDER + // than the telemetry the anchor already holds. The trail point keeps the + // record's values as heard; the anchor keeps the newer telemetry. + const recordTelemetryAt = telemetryTime(record); + const keepTelemetry = + prior && + this.telemetryAt !== null && + (recordTelemetryAt === null || recordTelemetryAt < this.telemetryAt); + if (keepTelemetry) { + this.anchor = { + ...this._anchorFrom( + { + ...fix, + altitudeFt: prior.altitudeFt, + speedMps: prior.speedMps, + trackDeg: prior.trackDeg, + }, + { verticalRateFpm: prior.verticalRateFpm }, + ), + altitudeAt: Number.isFinite(prior.altitudeAt) + ? prior.altitudeAt + : prior.at, + }; + } else { + this.anchor = this._anchorFrom(fix, record); + this.telemetryAt = recordTelemetryAt; + } + this._absorb(before, nowMs); + return true; + } + + /** + * Apply a record's altitude, speed, track and vertical rate to the current + * anchor when they were heard after the anchor's telemetry, whether or not + * its position is new. + */ + _refreshTelemetry(record, nowMs) { + const anchor = this.anchor; + const newest = this.fixes.at(-1); + if (!anchor || !newest) return; + const at = telemetryTime(record); + if (at === null || (this.telemetryAt !== null && at <= this.telemetryAt)) + return; + this.telemetryAt = at; + const altitudeFt = finite(record.altitudeFt) ?? anchor.altitudeFt; + const speedMps = Number.isFinite(record.groundSpeedKt) + ? Math.max(0, record.groundSpeedKt) * KT_TO_MPS + : anchor.speedMps; + const trackDeg = Number.isFinite(record.trackDeg) + ? norm360(record.trackDeg) + : anchor.trackDeg; + const verticalRateFpm = finite(record.verticalRateFpm); + if ( + altitudeFt === anchor.altitudeFt && + speedMps === anchor.speedMps && + trackDeg === anchor.trackDeg && + verticalRateFpm === anchor.verticalRateFpm + ) + return; + const before = this.displayAt(nowMs, { slew: false }); + // The trail point keeps its heard position; the anchor takes the newer + // telemetry, with altitude extrapolated from when it was reported. + this.anchor = { + ...this._anchorFrom( + { ...newest, altitudeFt, speedMps, trackDeg }, + { verticalRateFpm }, + ), + altitudeAt: at, + }; + this._absorb(before, nowMs); + } + + /** Slide from where the marker was drawn to the new anchor, or snap. */ + _absorb(before, nowMs) { + if (before) { + const after = this._raw(nowMs); + const cosLat = Math.cos(after.lat * DEG); + const north = (before.lat - after.lat) * DEG * EARTH_RADIUS_M; + const east = (before.lon - after.lon) * DEG * EARTH_RADIUS_M * cosLat; + const up = + Number.isFinite(before.altitudeFt) && Number.isFinite(after.altitudeFt) + ? before.altitudeFt - after.altitudeFt + : 0; + this.correction = + Math.hypot(north, east) > LOCAL_ADSB_SNAP_M + ? null + : { east, north, upFt: up, startAt: nowMs }; + } + } + + _trimHistory(newestAt) { + let drop = 0; + while ( + drop < this.fixes.length - 1 && + (newestAt - this.fixes[drop].at > LOCAL_ADSB_HISTORY_MS || + this.fixes.length - drop > LOCAL_ADSB_HISTORY_POINTS) + ) + drop += 1; + if (drop) this.fixes.splice(0, drop); + } + + _anchorFrom(fix, record) { + let chord = null; + for (let index = this.fixes.length - 2; index >= 0; index -= 1) { + if (fix.at - this.fixes[index].at < CHORD_BASELINE_MS) continue; + chord = chordCourse(this.fixes[index], fix); + break; + } + let courseDeg = fix.trackDeg; + if (chord !== null) + courseDeg = + courseDeg === null + ? chord + : lerpAngleDeg(courseDeg, chord, speedRamp(fix.speedMps)); + const samples = []; + let lastSampleAt = -Infinity; + for (const sample of this.fixes) { + if (fix.at - sample.at > TURN_WINDOW_MS) continue; + if (sample.at - lastSampleAt < CHORD_BASELINE_MS) continue; + lastSampleAt = sample.at; + samples.push({ + tSec: sample.at / 1000, + trackDeg: sample.trackDeg, + speedMps: sample.speedMps, + }); + } + return { + ...fix, + courseDeg, + turnRateDps: estimateTurnRateDps( + samples, + undefined, + undefined, + TURN_MIN_SPEED_MPS, + ), + verticalRateFpm: finite(record.verticalRateFpm), + }; + } + + _coastSeconds(nowMs) { + const anchor = this.anchor; + const limit = staleCoastLimitSeconds({ + fixEpochMs: anchor.at, + lastContactEpochMs: this.lastContactAt, + minimumSec: COAST_MINIMUM_SEC, + contactGraceSec: COAST_CONTACT_GRACE_SEC, + maximumSec: COAST_MAXIMUM_SEC, + }); + return Math.min(limit, Math.max(0, (nowMs - anchor.at) / 1000)); + } + + _raw(nowMs) { + const anchor = this.anchor; + const dtSec = this._coastSeconds(nowMs); + const moving = + Number.isFinite(anchor.speedMps) && + anchor.speedMps >= COURSE_HOLD_SPEED_MPS && + Number.isFinite(anchor.courseDeg); + const projected = moving + ? projectGroundArcLatLon( + anchor.lat, + anchor.lon, + anchor.courseDeg, + anchor.speedMps, + anchor.turnRateDps, + dtSec, + ) + : { lat: anchor.lat, lon: anchor.lon }; + const altitudeDtSec = Number.isFinite(anchor.altitudeAt) + ? Math.min(dtSec, Math.max(0, (nowMs - anchor.altitudeAt) / 1000)) + : dtSec; + const altitudeFt = + Number.isFinite(anchor.altitudeFt) && + Number.isFinite(anchor.verticalRateFpm) + ? anchor.altitudeFt + (anchor.verticalRateFpm * altitudeDtSec) / 60 + : anchor.altitudeFt; + return { + lat: projected.lat, + lon: projected.lon, + altitudeFt, + courseDeg: moving + ? norm360(anchor.courseDeg + anchor.turnRateDps * dtSec) + : null, + speedMps: anchor.speedMps, + }; + } + + /** + * Where to draw the aircraft now. + * @param {number} nowMs Current epoch ms. + * @param {{slew?: boolean}} [options] `slew: false` reads without advancing + * the rate-limited course. + * @returns {{lat:number, lon:number, altitudeFt:number|null, + * courseDeg:number|null}|null} + */ + displayAt(nowMs, { slew = true } = {}) { + if (!this.anchor) return null; + const raw = this._raw(nowMs); + const correction = this.correction; + if (correction) { + const weight = 1 - (nowMs - correction.startAt) / DR_CORRECTION_MS; + if (weight <= 0) this.correction = null; + else { + const w = Math.min(1, weight); + raw.lat += (correction.north * w) / EARTH_RADIUS_M / DEG; + raw.lon += + (correction.east * w) / + (EARTH_RADIUS_M * Math.max(1e-6, Math.cos(raw.lat * DEG))) / + DEG; + if (Number.isFinite(raw.altitudeFt)) + raw.altitudeFt += correction.upFt * w; + } + } + let courseDeg = this.courseDeg; + // Hover and taxi noise: below the hold speed the course is not updated. + const target = + raw.courseDeg ?? (courseDeg === null ? this.anchor.trackDeg : null); + if (slew && target !== null) { + const dtSec = + this.lastCourseAt === null + ? 0 + : Math.min( + COURSE_SLEW_DT_MAX_SEC, + Math.max(0, (nowMs - this.lastCourseAt) / 1000), + ); + courseDeg = limitCourseStep( + courseDeg, + target, + courseSlewCapDps(raw.speedMps, COURSE_MAX_DPS), + dtSec, + ); + this.courseDeg = courseDeg; + this.lastCourseAt = nowMs; + } else if (courseDeg === null && target !== null) { + courseDeg = target; + } + return { + lat: raw.lat, + lon: raw.lon, + altitudeFt: raw.altitudeFt, + courseDeg, + }; + } +} diff --git a/src/layers/localAdsb/motion.test.mjs b/src/layers/localAdsb/motion.test.mjs new file mode 100644 index 000000000..d4096db8c --- /dev/null +++ b/src/layers/localAdsb/motion.test.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + LOCAL_ADSB_HISTORY_MS, + LOCAL_ADSB_HISTORY_POINTS, + LocalAdsbMotion, +} from './motion.js'; + +function fix(at, lat, overrides = {}) { + return { + icao: 'abc123', + lat, + lon: 0, + altitudeFt: 5_000, + groundSpeedKt: 120, + trackDeg: 0, + verticalRateFpm: 0, + lastPositionAt: at, + lastMessageAt: at, + ...overrides, + }; +} + +// 120 kt north: degrees of latitude per second. +const STEP = (120 * 1852) / 3600 / 111_195; + +test('the heard history is bounded to 600 fixes and 10 minutes', () => { + const motion = new LocalAdsbMotion(); + for (let second = 0; second < 700; second += 1) + motion.observe(fix(second * 1_000, second * STEP), second * 1_000); + assert.equal(motion.fixes.length, LOCAL_ADSB_HISTORY_POINTS); + assert.equal(motion.fixes[0].at, 100_000); + + const sparse = new LocalAdsbMotion(); + for (let minute = 0; minute < 15; minute += 1) { + const at = minute * 60_000; + sparse.observe(fix(at, minute * 60 * STEP), at); + } + assert.ok( + sparse.fixes.at(-1).at - sparse.fixes[0].at <= LOCAL_ADSB_HISTORY_MS, + ); + assert.equal(sparse.fixes.length, 11); +}); + +test('repeated and out-of-order fixes do not move the anchor', () => { + const motion = new LocalAdsbMotion(); + assert.equal(motion.observe(fix(10_000, 0), 10_000), true); + assert.equal(motion.observe(fix(10_000, 0), 10_500), false); + assert.equal(motion.observe(fix(9_000, 0), 10_500), false); + // A feed re-reading the same fix with a jittered, later time. + assert.equal(motion.observe(fix(10_080, 0), 11_000), false); + assert.equal(motion.fixes.length, 1); +}); + +test('coasting stops 10 s after the last message', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + const coasted = motion.displayAt(30_000).lat; + assert.ok(Math.abs(coasted - 10 * STEP) < 1e-5, `${coasted}`); +}); + +test('three refused fixes in a row restart the track from the new stream', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + for (let index = 1; index <= 2; index += 1) + assert.equal( + motion.observe(fix(index * 1_000, 1 + index * STEP), index * 1_000), + false, + ); + assert.equal(motion.rejectedFixes, 2); + assert.equal(motion.observe(fix(3_000, 1 + 3 * STEP), 3_000), true); + assert.equal(motion.rejectedFixes, 3); + assert.equal( + motion.fixes.length, + 1, + 'the history restarts at the new stream', + ); + assert.ok(Math.abs(motion.displayAt(3_000).lat - (1 + 3 * STEP)) < 1e-9); +}); + +test('the last known speed bounds a fix whose record carries none', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + // 1.2 nm in 10 s (432 kt): under the 1,000 kt unknown-speed cap but far + // beyond the 120 kt the aircraft last reported. + assert.equal( + motion.observe(fix(10_000, 1.2 / 60, { groundSpeedKt: null }), 10_000), + false, + ); + assert.equal(motion.rejectedFixes, 1); +}); + +test('re-observing one refused fix never re-anchors on it', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + const outlier = fix(1_000, 1); + // The layer re-syncs the same record several times a second. + for (let sync = 0; sync < 6; sync += 1) + assert.equal(motion.observe(outlier, 1_000 + sync * 250), false); + assert.equal(motion.rejectedFixes, 1, 'one fix is refused once'); + assert.equal(motion.rejectStreak, 1); + assert.ok(motion.displayAt(2_000).lat < 0.01, 'still on the old track'); + // A jittered re-read of the same refused position is the same fix too. + assert.equal(motion.observe(fix(1_080, 1), 2_500), false); + assert.equal(motion.rejectStreak, 1); +}); + +test('an unchanged position still delivers newer altitude and velocity', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 120 }), 0); + // A hovering helicopter: same coordinates, climbing, now stationary. + motion.observe( + fix(5_000, 0, { + altitudeFt: 1_500, + groundSpeedKt: 0, + verticalRateFpm: 0, + trackDeg: 90, + }), + 5_000, + ); + assert.equal(motion.fixes.length, 1, 'no duplicate trail point'); + assert.equal(motion.anchor.speedMps, 0); + assert.equal(motion.anchor.trackDeg, 90); + // Past the correction window the display settles on the new telemetry. + const display = motion.displayAt(20_000); + assert.ok( + Math.abs(display.altitudeFt - 1_500) < 1e-6, + `${display.altitudeFt}`, + ); + assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast'); +}); + +test('a delayed position never rolls back newer telemetry', () => { + const motion = new LocalAdsbMotion(); + const step100 = (100 * 1852) / 3600 / 111_195; + assert.equal( + motion.observe( + fix(10_000, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), + 10_000, + ), + true, + ); + // Velocity and altitude heard at 20 s, no new position. + motion.observe( + fix(10_000, 0, { + altitudeFt: 1_500, + groundSpeedKt: 0, + lastMessageAt: 20_000, + }), + 20_000, + ); + assert.equal(motion.anchor.speedMps, 0); + assert.equal(motion.telemetryAt, 20_000); + // Another receiver's record: a newer position (15 s) whose telemetry is + // older than what the anchor already holds. + const delayed = fix(15_000, 5 * step100, { + altitudeFt: 1_000, + groundSpeedKt: 100, + }); + assert.equal(motion.observe(delayed, 21_000), true, 'the position is new'); + assert.equal(motion.fixes.length, 2); + assert.equal(motion.anchor.lat, 5 * step100, 'the anchor moves'); + assert.equal(motion.anchor.speedMps, 0, 'telemetry stays 0 kt'); + assert.equal(motion.anchor.altitudeFt, 1_500, 'telemetry stays 1,500 ft'); + assert.equal(motion.telemetryAt, 20_000, 'telemetryAt never moves back'); + assert.equal(motion.fixes.at(-1).altitudeFt, 1_000, 'trail point as heard'); + const display = motion.displayAt(40_000); + assert.ok( + Math.abs(display.altitudeFt - 1_500) < 1e-6, + `${display.altitudeFt}`, + ); + assert.ok( + Math.abs(display.lat - 5 * step100) < 1e-9, + 'a stopped aircraft does not coast', + ); +}); + +test('a velocity/altitude-only update with the same position time still refreshes telemetry', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), 0); + // No new position message: only velocity and altitude are newer. + const newer = fix(0, 0, { + altitudeFt: 1_500, + groundSpeedKt: 0, + trackDeg: 90, + lastMessageAt: 5_000, + }); + assert.equal(motion.observe(newer, 5_000), false, 'no new trail point'); + assert.equal(motion.fixes.length, 1); + assert.equal(motion.anchor.speedMps, 0); + assert.equal(motion.anchor.altitudeFt, 1_500); + assert.equal(motion.anchor.trackDeg, 90); + // Re-reading that record is not newer telemetry, and an older message + // never rolls it back. + motion.observe(newer, 5_500); + motion.observe( + fix(0, 0, { altitudeFt: 900, groundSpeedKt: 300, lastMessageAt: 4_000 }), + 6_000, + ); + assert.equal(motion.anchor.altitudeFt, 1_500); + assert.equal(motion.anchor.speedMps, 0); + const display = motion.displayAt(20_000); + assert.ok( + Math.abs(display.altitudeFt - 1_500) < 1e-6, + `${display.altitudeFt}`, + ); + assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast'); +}); diff --git a/src/layers/localAdsb/policy.js b/src/layers/localAdsb/policy.js new file mode 100644 index 000000000..5e6ae7a9f --- /dev/null +++ b/src/layers/localAdsb/policy.js @@ -0,0 +1,21 @@ +export const LAYER_ID = 'local-adsb'; +export const LAYER_NAME = 'Local ADS-B'; +export const LAYER_SOURCE = 'RTL-SDR · WebUSB'; +/** Distinct from the public Flights palette so both can be drawn together. */ +export const LOCAL_ADSB_COLOR = '#ff4fd8'; +/** Ring drawn around 978 MHz UAT aircraft: same magenta family, lighter. */ +export const LOCAL_ADSB_UAT_RING_COLOR = '#ffb3ef'; +export const ENTITY_PREFIX = `${LAYER_ID}:`; +/** Re-evaluate marker freshness and the card's position age this often. */ +export const LOCAL_ADSB_TICK_MS = 1_000; +/** Coalesce receiver bursts into at most one scene sync per interval. */ +export const LOCAL_ADSB_SYNC_MS = 200; +export const HEARD_BY_RECEIVER = 'Heard by your receiver'; +export const BAND_LABELS = Object.freeze({ + 1090: '1090 MHz', + 978: '978 MHz UAT', +}); +export const SOURCE_LABELS = Object.freeze({ + webusb: 'browser SDR', + feed: 'decoder feed', +}); diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js new file mode 100644 index 000000000..6d5c523e2 --- /dev/null +++ b/src/layers/localAdsb/status.js @@ -0,0 +1,141 @@ +import { localReceiverFeedName } from './feedNames.js'; +import { LAYER_SOURCE } from './policy.js'; + +/** + * Layers-panel status for Local ADS-B, from its two inputs: the browser + * WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium. + */ + +export const FEED_SOURCE = 'Decoder feeds'; +export const COMBINED_SOURCE = 'WebUSB + decoder feeds'; + +function plural(count, one, many) { + return count === 1 ? one : many; +} + +/** + * Describe the feeds that are not live, grouped by status, e.g. + * "feed 978 unreachable" or "feeds 1090, 978 stale". + * @param {object[]} feeds Feed statuses from the route. + * @returns {string} + */ +export function describeFeedProblems(feeds) { + const byStatus = new Map(); + for (const feed of feeds) { + if (feed?.status === 'live') continue; + const status = feed?.status || 'unreachable'; + if (!byStatus.has(status)) byStatus.set(status, new Set()); + byStatus.get(status).add(localReceiverFeedName(feed, feeds)); + } + return [...byStatus] + .map(([status, names]) => { + const list = [...names]; + return `${plural(list.length, 'feed', 'feeds')} ${list.join(', ')} ${status}`; + }) + .join(' · '); +} + +/** Whether the server reported at least one configured feed. */ +export function feedsConfigured(feedState) { + return Boolean( + feedState?.configured === true && + Array.isArray(feedState.feeds) && + feedState.feeds.length, + ); +} + +function webUsbStatus(receiver, heard, feedState) { + if (receiver.status === 'error') + return { status: 'error', error: receiver.message }; + if (receiver.status === 'connecting' || receiver.status === 'tuning') + return { loading: true, loadingLabel: 'opening receiver' }; + if ( + feedState?.polling && + feedState.configured === null && + !receiver.connected + ) + return { loading: true, loadingLabel: 'checking decoder feeds' }; + if (!receiver.webUsbSupported) + return { + status: 'idle', + statusMessage: 'WebUSB needs desktop Chrome or Edge', + }; + if (!receiver.connected) + return { status: 'idle', statusMessage: 'connect a receiver in Radio' }; + if (receiver.mode !== 'adsb') + return { status: 'idle', statusMessage: 'receiver is in FM mode' }; + const rate = Number.isFinite(receiver.messagesPerSecond) + ? `${receiver.messagesPerSecond} msg/s` + : 'listening'; + return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` }; +} + +/** + * Status fields of `getStats()` for the Local ADS-B row. + * + * Without configured feeds this is exactly the WebUSB status. With feeds: + * "2 feeds live · 14 heard" when every feed is live. While any input is + * producing (the browser receiver streaming, or a feed live) the row stays + * nominal and names the other feeds as a trailing note, e.g. "3 heard · USB + * 5.8 msg/s · feed 1090 stale": a decoder stopped so the browser can take the + * dongle is not a fault. With nothing producing, every readable feed stale is + * STALE and anything else is an error. + * @param {object} options + * @param {object} options.receiver WebUSB receiver snapshot. + * @param {object|null} options.feedState Feed poller snapshot. + * @param {number} options.heard Merged aircraft heard in the last 60 s. + * @returns {object} status / statusMessage / loading / loadingLabel / error / + * stale / source. + */ +export function localAdsbStatus({ receiver, feedState, heard }) { + if (!feedsConfigured(feedState)) + return { + source: LAYER_SOURCE, + ...webUsbStatus(receiver, heard, feedState), + }; + const usbActive = Boolean(receiver.connected && receiver.mode === 'adsb'); + const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE; + const live = feedState.feeds.filter((feed) => feed.status === 'live'); + const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean); + if (usbActive && receiver.status === 'error') problems.push('USB error'); + const rate = + usbActive && + receiver.status !== 'error' && + Number.isFinite(receiver.messagesPerSecond) + ? ` · USB ${receiver.messagesPerSecond} msg/s` + : ''; + const heardText = `${heard} heard`; + if (!problems.length) { + return { + source, + status: 'streaming', + loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`, + }; + } + const problemText = problems.join(' · '); + const usbProducing = usbActive && receiver.status !== 'error'; + if (!live.length && !usbProducing) { + // Decoders that stopped but still serve their last aircraft.json are + // stale, not down: only an unreachable or unusable feed is an error. + const readable = feedState.feeds.filter( + (feed) => feed.status !== 'invalid', + ); + if (readable.length && readable.every((feed) => feed.status === 'stale')) + return { + source, + status: 'stale', + stale: true, + statusMessage: `${problemText} · ${heardText}`, + }; + return { + source, + status: 'error', + error: heard > 0 ? `${problemText} · ${heardText}` : problemText, + }; + } + return { + source, + status: 'streaming', + loadingLabel: `${heardText}${rate} · ${problemText}`, + }; +} diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 79ba10b5b..9ebd03180 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -8,6 +8,8 @@ import { trailAnchorForModel, } from '../../data/modelVisualAnchor.js'; import * as Cesium from 'cesium'; +import { selectModelEligible } from '../../data/modelEligibility.js'; +import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -151,7 +153,7 @@ export function createRendering({ const isCockpitNear = isCockpitContact && flightState._cockpitNearContacts.has(icao24); if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = bb.color?.alpha ?? 1; + const freshnessAlpha = cyberSonarBaseAlpha(bb); bb.image = cockpitContactDotImage(); bb.width = COCKPIT_CONTACT_SIZE_PX; bb.height = COCKPIT_CONTACT_SIZE_PX; @@ -171,7 +173,7 @@ export function createRendering({ bb.height = icao24 === flightState._trackedIcao ? 24 : 20; bb.scale = _militaryBillboardScale(icao24) * limbScale; bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); + bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb)); } /** Sprite kind for one contact's billboard. Identity for every aircraft except @@ -892,25 +894,11 @@ export function createRendering({ ); flightState._lastModelCapWarnMs = nowMs; } - modelEligible = new Set(); - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && flightState._models.has(icao)) modelEligible.add(icao); - } // 1. KEEP on-screen - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } // 2. ADD on-screen - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && flightState._models.has(icao)) modelEligible.add(icao); - } // 3. KEEP off-screen (can't starve visible) - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } // 4. ADD off-screen leftover + modelEligible = selectModelEligible(cand, { + cap, + addDistSq, + isModeled: (icao) => flightState._models.has(icao), + }); const toRelease = []; for (const icao of flightState._models.keys()) { if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) @@ -977,6 +965,7 @@ export function createRendering({ const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); + const baseColor = MIL_ICON_COLOR; const treatment = applyAircraftBillboardTreatment({ billboard: bb, baseScale: @@ -984,7 +973,7 @@ export function createRendering({ ? 1 : _militaryBillboardScale(icao24), baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1, - baseColor: MIL_ICON_COLOR, + baseColor, focusFactor: focus.factor, cameraDistanceM, cameraHeightM: camera.positionCartographic?.height, diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 38ac280b4..f1a2d691f 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -227,6 +227,8 @@ export function createFlightState({ source, services }) { flightState._scratchFleetPos = new Cesium.Cartesian3(); + flightState._scratchSonarScreen = new Cesium.Cartesian2(); + flightState._scratchDrRaw = new Cesium.Cartesian3(); flightState._scratchWarmupTime = new Cesium.JulianDate(); diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js new file mode 100644 index 000000000..6609d0a73 --- /dev/null +++ b/src/layers/perimeters/cards.js @@ -0,0 +1,157 @@ +/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */ + +export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters'; + +/** CSS accent for a perimeter card by containment progress (mirrors the fill). */ +export function containmentAccent(containedPct) { + if (!Number.isFinite(containedPct) || containedPct <= 0) return '#ff3b30'; + if (containedPct < 50) return '#ff7a00'; + if (containedPct < 100) return '#ffb300'; + return '#8bc34a'; +} + +/** Shoelace area (degree², sign dropped) — relative sizes only. */ +function ringArea(ring) { + let sum = 0; + for (let i = 0; i < ring.length - 1; i++) { + const [x1, y1] = ring[i]; + const [x2, y2] = ring[i + 1]; + sum += x1 * y2 - x2 * y1; + } + return Math.abs(sum / 2); +} + +/** + * Anchor point for the incident card: the centroid of the largest polygon's + * outer ring. Degrees, so callers own the Cartesian conversion. + * @param {Array>>} polygons - Normalized rows' polygons. + * @returns {{lon: number, lat: number}} + */ +export function perimeterAnchorDegrees(polygons) { + let best = null; + let bestArea = -1; + for (const rings of polygons) { + const outer = rings[0]; + const area = ringArea(outer); + if (area > bestArea) { + bestArea = area; + best = outer; + } + } + // Vertex mean over the closed ring (first == last point dropped): perimeters + // are compact enough that this stays inside or near the polygon. + let lon = 0; + let lat = 0; + const count = best.length - 1; + for (let i = 0; i < count; i++) { + lon += best[i][0]; + lat += best[i][1]; + } + return { lon: lon / count, lat: lat / count }; +} + +/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */ +function formatCost(dollars) { + if (!Number.isFinite(dollars) || dollars < 1000) return null; + const units = [ + [1e3, 'K'], + [1e6, 'M'], + [1e9, 'B'], + ]; + let index = 0; + for (let i = units.length - 1; i >= 0; i--) { + if (dollars >= units[i][0]) { + index = i; + break; + } + } + // 999,500 must promote to $1M, never render as $1000K. + if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++; + const value = dollars / units[index][0]; + return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`; +} + +/** WFIGS complex names arrive ALL-CAPS; render them in title case. */ +function titleCase(name) { + return String(name) + .toLowerCase() + .replace(/\b[a-z]/g, (letter) => letter.toUpperCase()); +} + +function formatAge(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return null; + const hours = Math.floor(deltaMs / 3600000); + if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`; + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +/** + * Build the overlay-host entry for one selected incident. The caller supplies + * `position` (Cartesian) separately — this model stays JSON-safe for tests. + * @param {Object} row - Normalized perimeter row (see records.js). + * @param {number} nowMs - Current epoch milliseconds. + * @param {{link: ?string}} [extras] - Optional InciWeb page for this incident. + * @returns {Object} World-overlay entry without `position`. + */ +export function buildIncidentCard(row, nowMs, { link = null } = {}) { + const facts = []; + if (Number.isFinite(row.acres)) + facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`); + facts.push( + Number.isFinite(row.containedPct) + ? `${Math.round(row.containedPct)}% contained` + : 'containment unknown', + ); + if (row.state) facts.push(row.state); + else if (row.category) facts.push(row.category); + + const situation = []; + if (row.cause) situation.push(`${row.cause} cause`); + if (row.behavior) situation.push(row.behavior); + if (row.complexity) situation.push(row.complexity); + + const response = []; + if (Number.isFinite(row.personnel)) + response.push( + `${Math.round(row.personnel).toLocaleString('en-US')} personnel`, + ); + if (row.county) response.push(`${row.county} County`); + if (Number.isFinite(row.costToDate)) { + const cost = formatCost(row.costToDate); + if (cost) response.push(`${cost} to date`); + } + + const ages = []; + const discovered = formatAge(nowMs - row.discoveredTime); + if (row.discoveredTime != null && discovered) + ages.push(`discovered ${discovered} ago`); + const updated = formatAge(nowMs - row.updatedTime); + if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`); + + const details = [facts.join(' · ')]; + if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`); + if (situation.length) details.push(situation.join(' · ')); + if (response.length) details.push(response.join(' · ')); + if (ages.length) details.push(ages.join(' · ')); + if (link) details.push('InciWeb ↗ · click card to open'); + + const title = `FIRE · ${row.name || 'Unnamed incident'}`; + return { + id: `fire-perimeter-card:${row.stableId}`, + // `selected` picks the paint lane and collision protection; an explicit + // paintLane would override it in the host's lane resolver. + selected: true, + // Only a linked card is clickable — the overlay host registers hit + // rectangles solely for interactive entries. + interactive: Boolean(link), + ...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined), + title, + details, + accent: containmentAccent(row.containedPct), + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + }; +} diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs new file mode 100644 index 000000000..1b32a5e06 --- /dev/null +++ b/src/layers/perimeters/cards.test.mjs @@ -0,0 +1,237 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { perimeterAnchorDegrees, buildIncidentCard } from './cards.js'; + +const square = [ + [-108.2, 35.0], + [-108.0, 35.0], + [-108.0, 35.2], + [-108.2, 35.2], + [-108.2, 35.0], +]; +const tiny = [ + [-107.01, 34.0], + [-107.0, 34.0], + [-107.0, 34.01], + [-107.01, 34.0], +]; + +test('the card anchors at the centroid of the largest polygon', () => { + const anchor = perimeterAnchorDegrees([[tiny], [square]]); + assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9); + assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9); +}); + +test('a full incident row renders name, size, containment, and ages', () => { + const now = 1758000000000; + const card = buildIncidentCard( + { + stableId: '2026-NMGNF-000123', + name: 'Frijoles', + acres: 15956.4, + containedPct: 74, + state: 'US-NM', + category: 'WF', + discoveredTime: now - 36 * 3600000, + updatedTime: now - 2 * 3600000, + cause: 'Natural', + behavior: 'Active', + personnel: 380, + county: 'Sandoval', + costToDate: 4200000, + complexity: 'Type 3 Incident', + }, + now, + ); + assert.equal(card.id, 'fire-perimeter-card:2026-NMGNF-000123'); + assert.equal(card.title, 'FIRE · Frijoles'); + assert.deepEqual(card.details, [ + '15,956 ac · 74% contained · US-NM', + 'Natural cause · Active · Type 3 Incident', + '380 personnel · Sandoval County · $4.2M to date', + 'discovered 1d ago · updated 2h ago', + ]); + assert.equal(card.selected, true); + assert.equal(typeof card.accent, 'string'); +}); + +test('missing attributes degrade to available facts instead of placeholders', () => { + const card = buildIncidentCard( + { + stableId: 'x', + name: null, + acres: null, + containedPct: null, + state: null, + category: 'RX', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }, + 1758000000000, + ); + assert.equal(card.title, 'FIRE · Unnamed incident'); + assert.deepEqual(card.details, ['containment unknown · RX']); +}); + +test('a known InciWeb page adds a link line to the card', () => { + const card = buildIncidentCard( + { + stableId: 'x', + name: 'Timber', + acres: 25426, + containedPct: 44, + state: 'US-CA', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }, + 1758000000000, + { + link: 'https://inciweb.wildfire.gov/incident-information/calpf-timber-fire', + }, + ); + assert.equal(card.details.at(-1), 'InciWeb ↗ · click card to open'); + assert.equal(card.interactive, true); + assert.match(card.accessibilityLabel, /InciWeb/); +}); + +test('the selected card claims the selected paint lane, not the ambient lane', () => { + const card = buildIncidentCard( + { + stableId: 'x', + name: 'Any', + acres: 1, + containedPct: 1, + state: 'US-NM', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + complexName: null, + }, + 1758000000000, + ); + assert.equal(card.selected, true); + // An explicit paintLane would override `selected` in the overlay host's + // lane resolver and push the card under other selected cards. + assert.equal('paintLane' in card, false); + assert.equal('collisionGroup' in card, false); +}); + +test('costs near a unit boundary promote to the larger unit', () => { + const base = { + stableId: 'x', + name: 'Any', + acres: null, + containedPct: null, + state: null, + category: null, + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + complexity: null, + complexName: null, + }; + const costLine = (costToDate) => + buildIncidentCard({ ...base, costToDate }, 0).details.find((line) => + line.includes('to date'), + ); + assert.equal(costLine(999500), '$1M to date'); + assert.equal(costLine(999499), '$999K to date'); + assert.equal(costLine(999600000), '$1B to date'); + assert.equal(costLine(4200000), '$4.2M to date'); +}); + +test('a complex member names its complex on the card', () => { + const card = buildIncidentCard( + { + stableId: 'x', + name: 'Crosswhite', + acres: 342923, + containedPct: 99, + state: 'US-OR', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + complexName: 'ROWE CREEK COMPLEX', + }, + 1758000000000, + ); + assert.ok(card.details.includes('part of Rowe Creek Complex')); +}); + +test('a card without a link is not interactive', () => { + const card = buildIncidentCard( + { + stableId: 'x', + name: 'Crosswhite', + acres: 1, + containedPct: 1, + state: 'US-OR', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }, + 1758000000000, + ); + assert.equal(card.interactive, false); +}); + +test('partial incident details render only the facts that exist', () => { + const card = buildIncidentCard( + { + stableId: 'x', + name: 'Whiskey', + acres: 542, + containedPct: 48, + state: 'US-NM', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: 'Human', + behavior: null, + personnel: null, + county: 'Catron', + costToDate: 85000, + complexity: null, + }, + 1758000000000, + ); + assert.deepEqual(card.details, [ + '542 ac · 48% contained · US-NM', + 'Human cause', + 'Catron County · $85K to date', + ]); +}); diff --git a/src/layers/perimeters/inciweb.js b/src/layers/perimeters/inciweb.js new file mode 100644 index 000000000..3cc3ae538 --- /dev/null +++ b/src/layers/perimeters/inciweb.js @@ -0,0 +1,173 @@ +/** InciWeb incident catalog: full-index fetch and WFIGS-name matching. */ + +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +/** + * Normalize an incident title for matching: lowercase, collapsed whitespace, + * a leading year dropped ('2026 Coleman Creek'), a trailing 'Fire' dropped + * ('Coyote Fire') — InciWeb titles carry both decorations inconsistently and + * WFIGS names carry neither. + */ +function normalizeName(name) { + return String(name) + .toLowerCase() + .trim() + .replace(/\s+/g, ' ') + .replace(/^(19|20)\d{2} /, '') + .replace(/ fire$/, ''); +} + +/** + * Resolve one WFIGS incident against the publication catalog. + * Only rows whose normalized title equals the incident's normalized name + * count. Ambiguity resolves by the tau dispatch unit's state prefix, then + * by newest incident id (same-state duplicates are usually reburns of the + * same name). The `/node/{id}` link 301s to the canonical page, so no slug + * construction is needed. + * @param {?Array} publications - Publication catalog rows. + * @param {{name: ?string, state: ?string}} incident - WFIGS row facts. + * @returns {?string} InciWeb node URL. + */ +export function resolveInciwebNodeLink(publications, { name, state }) { + if (!Array.isArray(publications) || !name) return null; + const wanted = normalizeName(name); + let candidates = publications.filter( + (row) => + typeof row?.incident_title === 'string' && + /^\d+$/.test(String(row?.incident_id ?? '')) && + normalizeName(row.incident_title) === wanted, + ); + // The catalog is all-time and nationwide, so the dispatch unit's state + // must corroborate every match — a globally unique title is NOT evidence + // it's the same fire (live data shows same-name incidents across states). + const statePrefix = + typeof state === 'string' && /^US-[A-Za-z]{2}$/.test(state) + ? state.slice(3).toLowerCase() + : null; + if (statePrefix) { + candidates = candidates.filter( + (row) => + typeof row.tau === 'string' && + row.tau.slice(0, 2).toLowerCase() === statePrefix, + ); + } else if (candidates.length > 1) { + return null; + } + if (candidates.length > 1) { + // Same-state duplicates are usually reburns of the same name — newest + // wins here, and the publication-currency check guards the final link. + candidates = [ + candidates.reduce((a, b) => + Number(a.incident_id) >= Number(b.incident_id) ? a : b, + ), + ]; + } + return candidates.length === 1 + ? `https://inciweb.wildfire.gov/node/${candidates[0].incident_id}` + : null; +} + +/** The publication id inside a node link produced above, or null. */ +export function inciwebNodeId(link) { + const match = /^https:\/\/inciweb\.wildfire\.gov\/node\/(\d+)$/.exec( + String(link ?? ''), + ); + return match ? match[1] : null; +} + +// A fire's InciWeb page is created around the incident's start; a match +// whose page predates discovery by more than this is an archived +// same-name incident from a earlier season. +const CREATED_BEFORE_DISCOVERY_MS = 60 * 24 * 3600000; +// Without a discovery date, require recent editorial activity on the page. +const CHANGED_WITHIN_MS = 180 * 24 * 3600000; + +/** + * Whether a publication plausibly describes the given current incident. + * Fails closed on unusable timestamps. + * @param {{createdMs: ?number, changedMs: ?number}} publication + * @param {{discoveredTime: ?number, nowMs?: number}} incident + * @returns {boolean} + */ +export function isCurrentPublication( + { createdMs, changedMs }, + { discoveredTime, nowMs = Date.now() }, +) { + if (Number.isFinite(discoveredTime) && Number.isFinite(createdMs)) { + return createdMs >= discoveredTime - CREATED_BEFORE_DISCOVERY_MS; + } + if (Number.isFinite(changedMs)) { + return nowMs - changedMs <= CHANGED_WITHIN_MS; + } + return false; +} + +/** Fetch one publication's created/changed timestamps for currency checks. */ +export function createInciwebPublicationSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getPublication(id, { signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl( + `/api/fire-perimeters/inciweb/publication/${encodeURIComponent(id)}`, + { signal }, + ); + if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 512 * 1024, + signal, + ); + signal?.throwIfAborted(); + return { + createdMs: Number.isFinite(payload?.createdMs) + ? payload.createdMs + : null, + changedMs: Number.isFinite(payload?.changedMs) + ? payload.changedMs + : null, + }; + }, + }; +} + +/** + * Resolve one WFIGS incident to its InciWeb page, or null. + * The incident's own name is tried first; a member of a complex whose own + * name has no page falls back to the complex's page (InciWeb tracks the + * managing complex, not each member fire). Anything ambiguous yields null + * rather than a wrong page. + * @param {?Array} publications - Publication catalog rows. + * @param {{name: ?string, state: ?string, complexName: ?string}} incident + * - WFIGS row facts. + * @returns {?string} InciWeb URL. + */ +export function findInciwebLink(publications, { name, state, complexName }) { + return ( + resolveInciwebNodeLink(publications, { name, state }) ?? + resolveInciwebNodeLink(publications, { name: complexName, state }) + ); +} + +/** Fetch the full InciWeb incident catalog. */ +export function createInciwebIndexSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getIndex({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/fire-perimeters/inciweb/index', { + signal, + }); + if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`); + const rows = await readResponseJsonCapped( + response, + 4 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + return Array.isArray(rows) ? rows : []; + }, + }; +} diff --git a/src/layers/perimeters/inciweb.test.mjs b/src/layers/perimeters/inciweb.test.mjs new file mode 100644 index 000000000..70d4e147c --- /dev/null +++ b/src/layers/perimeters/inciweb.test.mjs @@ -0,0 +1,334 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + findInciwebLink, + resolveInciwebNodeLink, + createInciwebIndexSource, + createInciwebPublicationSource, + isCurrentPublication, + inciwebNodeId, +} from './inciweb.js'; + +const catalog = [ + { + incident_id: '322812', + tau: 'NMSNF Gobernador Pile Burn Coyote Ranger District', + incident_title: 'Gobernador Pile Burn - Coyote Ranger District', + }, + { + incident_id: '327504', + tau: 'SDBKF Coyote Flats Fire', + incident_title: 'Coyote Flats Fire', + }, + { + incident_id: '328922', + tau: 'OR95S Coyote Fire', + incident_title: 'Coyote Fire', + }, + { + incident_id: '329195', + tau: 'ORBUD Second Flat', + incident_title: 'Second Flat', + }, + { + incident_id: '329273', + tau: 'ORBUD 2026 Coleman Creek', + incident_title: '2026 Coleman Creek', + }, + { + incident_id: '328923', + tau: 'ORPRD Rowe Creek Complex', + incident_title: 'Rowe Creek Complex', + }, + { + incident_id: '291765', + tau: 'CALPF Timber Fire', + incident_title: 'Timber Fire', + }, +]; + +test('incidents resolve by exact normalized title to a node link', () => { + // Title formats vary: with/without a 'Fire' suffix, with/without a year + // prefix ('2026 Coleman Creek'). All normalize to the WFIGS name. + assert.equal( + resolveInciwebNodeLink(catalog, { name: 'Second Flat', state: 'US-OR' }), + 'https://inciweb.wildfire.gov/node/329195', + ); + assert.equal( + resolveInciwebNodeLink(catalog, { name: 'Coleman Creek', state: 'US-OR' }), + 'https://inciweb.wildfire.gov/node/329273', + ); + assert.equal( + resolveInciwebNodeLink(catalog, { name: 'Timber', state: 'US-CA' }), + 'https://inciweb.wildfire.gov/node/291765', + ); + assert.equal( + resolveInciwebNodeLink(catalog, { name: 'Coyote', state: 'US-OR' }), + 'https://inciweb.wildfire.gov/node/328922', + ); + // Substring-style near-misses never match. + assert.equal( + resolveInciwebNodeLink(catalog, { name: 'Gobernador', state: 'US-NM' }), + null, + ); + assert.equal( + resolveInciwebNodeLink(catalog, { name: 'Nope', state: null }), + null, + ); + assert.equal( + resolveInciwebNodeLink(null, { name: 'Coyote', state: 'US-OR' }), + null, + ); +}); + +test('a unique candidate in the wrong state is rejected, not linked', () => { + // The catalog is all-time and nationwide: a current Arizona "Willow" + // must not link to the only "Willow Fire" on file when it's a Montana + // incident. + const willow = [ + { + incident_id: '300001', + tau: 'MTFNF Willow Fire', + incident_title: 'Willow Fire', + }, + ]; + assert.equal( + resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-AZ' }), + null, + ); + assert.equal( + resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-MT' }), + 'https://inciweb.wildfire.gov/node/300001', + ); +}); + +test('ambiguous titles disambiguate by state, then newest id', () => { + const twoCoyotes = [ + { + incident_id: '100', + tau: 'AZASF Coyote Fire', + incident_title: 'Coyote Fire', + }, + { + incident_id: '328922', + tau: 'OR95S Coyote Fire', + incident_title: 'Coyote Fire', + }, + ]; + assert.equal( + resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: 'US-AZ' }), + 'https://inciweb.wildfire.gov/node/100', + ); + assert.equal( + resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: null }), + null, + ); + const twoSameState = [ + { + incident_id: '100', + tau: 'ORXXX Coyote Fire', + incident_title: 'Coyote Fire', + }, + { + incident_id: '328922', + tau: 'OR95S Coyote Fire', + incident_title: 'Coyote Fire', + }, + ]; + // Same-state duplicates are usually reburns of the same name — newest wins. + assert.equal( + resolveInciwebNodeLink(twoSameState, { name: 'Coyote', state: 'US-OR' }), + 'https://inciweb.wildfire.gov/node/328922', + ); +}); + +test('a complex member falls back to its complex page', () => { + assert.equal( + findInciwebLink(catalog, { + name: 'Crosswhite', + state: 'US-OR', + complexName: 'ROWE CREEK COMPLEX', + }), + 'https://inciweb.wildfire.gov/node/328923', + ); + // A fire with its own page keeps it even when it belongs to a complex. + assert.equal( + findInciwebLink(catalog, { + name: 'Timber', + state: 'US-CA', + complexName: 'SOME COMPLEX', + }), + 'https://inciweb.wildfire.gov/node/291765', + ); + assert.equal( + findInciwebLink(catalog, { + name: 'Nope', + state: 'US-NM', + complexName: null, + }), + null, + ); +}); + +test('a node link exposes its publication id', () => { + assert.equal( + inciwebNodeId('https://inciweb.wildfire.gov/node/329195'), + '329195', + ); + assert.equal(inciwebNodeId('https://example.com/other'), null); + assert.equal(inciwebNodeId(null), null); +}); + +test('publication currency accepts pages created around or after discovery', () => { + const day = 24 * 3600000; + const discovery = 1785000000000; + // Created shortly before discovery (page opened as the fire started). + assert.equal( + isCurrentPublication( + { createdMs: discovery - 10 * day, changedMs: discovery + 30 * day }, + { discoveredTime: discovery }, + ), + true, + ); + // Created years before discovery: an archived same-name incident. + assert.equal( + isCurrentPublication( + { createdMs: discovery - 900 * day, changedMs: discovery - 800 * day }, + { discoveredTime: discovery }, + ), + false, + ); + // No discovery date: fall back to recent activity on the page. + const now = 1787600000000; + assert.equal( + isCurrentPublication( + { createdMs: now - 900 * day, changedMs: now - 20 * day }, + { discoveredTime: null, nowMs: now }, + ), + true, + ); + assert.equal( + isCurrentPublication( + { createdMs: now - 900 * day, changedMs: now - 400 * day }, + { discoveredTime: null, nowMs: now }, + ), + false, + ); + // Unusable timestamps fail closed. + assert.equal( + isCurrentPublication( + { createdMs: null, changedMs: null }, + { discoveredTime: discovery }, + ), + false, + ); +}); + +test('the publication source fetches timestamps and honors cancellation', async () => { + let requested; + const source = createInciwebPublicationSource({ + fetchImpl: async (url) => { + requested = String(url); + return { + ok: true, + headers: new Headers(), + text: async () => + JSON.stringify({ + createdMs: 1785025540000, + changedMs: 1787589503000, + }), + }; + }, + }); + const publication = await source.getPublication('329195'); + assert.equal(requested, '/api/fire-perimeters/inciweb/publication/329195'); + assert.deepEqual(publication, { + createdMs: 1785025540000, + changedMs: 1787589503000, + }); + + const malformed = createInciwebPublicationSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + text: async () => JSON.stringify({ nope: 1 }), + }), + }); + assert.deepEqual(await malformed.getPublication('1'), { + createdMs: null, + changedMs: null, + }); + + const failing = createInciwebPublicationSource({ + fetchImpl: async () => ({ ok: false, status: 503 }), + }); + await assert.rejects(failing.getPublication('1'), /InciWeb HTTP 503/); + + const abort = new AbortController(); + const cancelled = createInciwebPublicationSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + text: async () => { + abort.abort(); + return JSON.stringify({}); + }, + }), + }); + await assert.rejects( + cancelled.getPublication('1', { signal: abort.signal }), + { + name: 'AbortError', + }, + ); +}); + +test('the index source requests the same-origin full catalog', async () => { + let request; + const source = createInciwebIndexSource({ + fetchImpl: async (url, options) => { + request = { url: String(url), options }; + return { + ok: true, + headers: new Headers(), + text: async () => JSON.stringify(catalog), + }; + }, + }); + const rows = await source.getIndex(); + assert.equal(rows.length, catalog.length); + assert.equal(request.url, '/api/fire-perimeters/inciweb/index'); + assert.equal(request.options.method, undefined); + assert.equal(request.options.body, undefined); +}); + +test('the index source rejects failures and honors cancellation', async () => { + const failing = createInciwebIndexSource({ + fetchImpl: async () => ({ ok: false, status: 503 }), + }); + await assert.rejects(failing.getIndex(), /InciWeb HTTP 503/); + + const malformed = createInciwebIndexSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + text: async () => JSON.stringify({ nope: 1 }), + }), + }); + assert.deepEqual(await malformed.getIndex(), []); + + const abort = new AbortController(); + const cancelled = createInciwebIndexSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + text: async () => { + abort.abort(); + return JSON.stringify(catalog); + }, + }), + }); + await assert.rejects(cancelled.getIndex({ signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js new file mode 100644 index 000000000..f59f4aef3 --- /dev/null +++ b/src/layers/perimeters/index.js @@ -0,0 +1,448 @@ +import * as Cesium from 'cesium'; +import { + PERIMETER_OVERLAY_SOURCE_ID, + perimeterAnchorDegrees, + buildIncidentCard, + containmentAccent, +} from './cards.js'; +import { + findInciwebLink, + inciwebNodeId, + isCurrentPublication, +} from './inciweb.js'; +export { normalizeFirePerimeterSnapshot } from './records.js'; +export { createWfigsPerimeterSource } from './source.js'; +export * from './cards.js'; +export * from './inciweb.js'; + +/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */ +export function containmentColor(containedPct) { + return Cesium.Color.fromCssColorString(containmentAccent(containedPct)); +} + +const ringPositions = (ring) => + ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); + +const PICK_PREFIX = 'fire-perimeter:'; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); +// The InciWeb catalog changes on the order of days; refreshing it on the +// 5-minute perimeter cadence would hammer a public .gov endpoint for ~200 KB +// of near-identical bytes. +const INCIWEB_INDEX_TTL_MS = 6 * 3600000; + +/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */ +export function createFirePerimetersLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, + inciwebSource = null, + inciwebPublications = null, + openExternal = null, +} = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Fire perimeters require a snapshot source'); + let _viewer = null; + let _request = null; + let _linkRequest = null; + let _snapshotSignature = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + let _clickHandler = null; + let _selectedId = null; + let _selectedLink = null; + let _selectedCardId = null; + let _inciwebIndex = []; + let _indexFetchedAt = 0; + let _indexFetchInFlight = false; + /** @type {Map} */ + const _rowById = new Map(); + // Link → currency verdict from the publication check; unknown links are + // absent. Failed checks are not cached so a transient outage retries. + const _linkVerdicts = new Map(); + function abortLinkVerification() { + _linkRequest?.controller.abort(); + _linkRequest = null; + } + + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + /** + * Refresh the InciWeb catalog off the render path: a hanging or failed + * fetch only costs the link line, never the perimeter refresh — the + * catalog is consumed solely by the selected card. + */ + function refreshInciwebIndex(signal) { + if (!inciwebSource || _indexFetchInFlight) return; + if (Date.now() - _indexFetchedAt < INCIWEB_INDEX_TTL_MS) return; + _indexFetchInFlight = true; + inciwebSource + .getIndex({ signal }) + .then((rows) => { + if (signal.aborted || !_enabled || !Array.isArray(rows)) return; + _inciwebIndex = rows; + _indexFetchedAt = Date.now(); + if (_selectedId) publishSelectedCard(); + }) + .catch(() => { + // Catalog unavailable — cards stay linkless until the next attempt. + }) + .finally(() => { + _indexFetchInFlight = false; + }); + } + + /** + * Verify that a matched publication describes the current incident, not + * an archived same-name fire — the catalog is all-time. Async: the card + * republishes with the link once (and only if) the check passes. + */ + function verifyLink(link, row) { + const id = inciwebNodeId(link); + if (!id || !inciwebPublications || _linkRequest?.link === link) return; + abortLinkVerification(); + const request = { link, controller: new AbortController() }; + _linkRequest = request; + const { signal } = request.controller; + inciwebPublications + .getPublication(id, { signal }) + .then((publication) => { + if (signal.aborted || _linkRequest !== request || !_enabled) return; + _linkVerdicts.set( + link, + isCurrentPublication(publication, { + discoveredTime: row.discoveredTime, + }), + ); + if (_selectedId === row.stableId) publishSelectedCard(); + }) + .catch(() => { + // Fail closed without caching: no link now, retry on reselection. + }) + .finally(() => { + if (_linkRequest === request) _linkRequest = null; + }); + } + + function publishSelectedCard() { + if (!canSelect()) return; + const row = _selectedId ? _rowById.get(_selectedId) : null; + if (!row) { + _selectedId = null; + _selectedLink = null; + _selectedCardId = null; + overlayHost.setEntries( + PERIMETER_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const candidate = findInciwebLink(_inciwebIndex, row); + if (candidate && !_linkVerdicts.has(candidate)) verifyLink(candidate, row); + _selectedLink = + candidate && _linkVerdicts.get(candidate) === true ? candidate : null; + const card = { + ...buildIncidentCard(row, Date.now(), { link: _selectedLink }), + position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat), + }; + if (!openExternal) card.interactive = false; + if (_selectedLink && openExternal) { + const link = _selectedLink; + // Keyboard/assistive activation mirrors the pointer click-through. + card.activate = () => { + openExternal(link); + return true; + }; + } + _selectedCardId = card.id; + overlayHost.setEntries( + PERIMETER_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + } + + /** Resolve a scene pick to one of this layer's incident ids, or null. */ + function pickedIncidentId(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0]; + return _rowById.has(incidentId) ? incidentId : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + // Deliberately NOT registered in the pick-ownership registry: sibling + // layers yield to owned picks before their own overlay-card hit-tests, + // so claiming these large ground polygons would make FIRMS/vessel/CCTV + // cards inert anywhere over a perimeter. Unowned, our picks read as + // "empty space" to siblings — the pre-existing behavior for the globe. + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + // A click on the incident card itself opens its InciWeb page (when the + // incident has one) and never disturbs the selection. + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: PERIMETER_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) { + if (_selectedLink && openExternal) openExternal(_selectedLink); + return; + } + const picked = _viewer.scene.pick(click.position); + const incidentId = picked ? pickedIncidentId(picked) : null; + if (incidentId) { + abortLinkVerification(); + _selectedId = incidentId; + publishSelectedCard(); + return; + } + // A pick that belongs to a sibling layer (e.g. an aircraft) is not + // "empty space" — leave the selection alone and let that layer handle it. + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return; + } + if (_selectedId) { + abortLinkVerification(); + _selectedId = null; + publishSelectedCard(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + } + + function clearSelection() { + abortLinkVerification(); + _selectedId = null; + _selectedLink = null; + _selectedCardId = null; + if (overlayHost) { + overlayHost.clearSource(PERIMETER_OVERLAY_SOURCE_ID); + overlayHost.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false); + } + } + + const layer = { + id: 'fire-perimeters', + name: 'Fire Perimeters', + icon: '🔥', + source: 'NIFC WFIGS', + updateInterval: 300000, + + init(viewer) { + if (_viewer) + throw new Error('Fire perimeter layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('fire-perimeters'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + console.log('[Data:FirePerimeters] Initialized'); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true); + installClickHandler(); + }, + + disable() { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + removeClickHandler(); + clearSelection(); + }, + + async update() { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + refreshInciwebIndex(request.signal); + try { + const rows = await source.getSnapshot({ signal: request.signal }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + + // Incident facts may change independently of the polygon's source clock. + const signature = JSON.stringify( + rows + .map(({ polygons, ...facts }) => facts) + .sort((a, b) => a.stableId.localeCompare(b.stableId)), + ); + if (signature === _snapshotSignature) { + if (_selectedId) publishSelectedCard(); + _lastUpdate = Date.now(); + _lastError = null; + return true; + } + abortLinkVerification(); + const nextEntities = []; + _rowById.clear(); + for (const row of rows) { + const color = containmentColor(row.containedPct); + for (const [index, rings] of row.polygons.entries()) { + const [outer, ...holes] = rings; + const outerPositions = ringPositions(outer); + nextEntities.push( + new Cesium.Entity({ + id: `fire-perimeter:${row.stableId}:${index}`, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + outerPositions, + holes.map( + (hole) => + new Cesium.PolygonHierarchy(ringPositions(hole)), + ), + ), + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.25), + ), + }, + // The fire line: ground-clamped outline (entity polygons cannot + // outline clamped geometry themselves). + polyline: { + positions: outerPositions, + clampToGround: true, + width: 2, + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.9), + ), + }, + }), + ); + } + // Keep a geometry-free projection: the coordinate arrays would + // otherwise be retained for the layer's lifetime while only the + // anchor is ever read again. + const { polygons, ...facts } = row; + _rowById.set(row.stableId, { + ...facts, + anchor: perimeterAnchorDegrees(polygons), + }); + } + + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); + _snapshotSignature = signature; + // Refresh (or drop) the selected incident's card against the new feed. + if (_selectedId) publishSelectedCard(); + _count = rows.length; + _lastUpdate = Date.now(); + _lastError = null; + console.log( + `[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`, + ); + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:FirePerimeters] Fetch error:', e); + _lastError = e?.message || 'Perimeter source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + removeClickHandler(); + clearSelection(); + _rowById.clear(); + _snapshotSignature = null; + _inciwebIndex = []; + _indexFetchedAt = 0; + _linkVerdicts.clear(); + _viewer = null; + _enabled = false; + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _count = 0; + _lastUpdate = null; + _lastError = null; + }, + + /** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */ + getAnalystRecords(maxCount = 2000) { + if (!_dataSource || !_dataSource.show) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const row of _rowById.values()) { + if (result.length >= limit) break; + const { anchor, stableId, ...facts } = row; + result.push({ + id: stableId, + ...facts, + lat: anchor.lat, + lon: anchor.lon, + }); + } + return result; + }, + + getRowControls() { + const bands = [ + { label: 'Not contained or unknown', color: containmentAccent(0) }, + { label: 'Under 50% contained', color: containmentAccent(1) }, + { label: '50–99% contained', color: containmentAccent(50) }, + { label: 'Fully contained', color: containmentAccent(100) }, + ]; + return { + chips: [], + legend: bands.map((band, index) => ({ + ...band, + count: [..._rowById.values()].filter( + (row) => containmentAccent(row.containedPct) === band.color, + ).length, + ...(index === 0 + ? { + blurb: + 'Colour shows reported containment. Perimeters are simplified to about 100 m.', + } + : {}), + })), + }; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + }; + }, + }; + return layer; +} diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs new file mode 100644 index 000000000..debd76aa8 --- /dev/null +++ b/src/layers/perimeters/ownership.test.mjs @@ -0,0 +1,554 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFirePerimetersLayer } from './index.js'; + +function harness( + source, + { + pick = () => null, + inciwebIndex = null, + cardHit = () => null, + publication = async () => ({ + createdMs: 1757000000000, + changedMs: 1757900000000, + }), + } = {}, +) { + const sources = []; + const overlay = { entries: new Map(), visible: null }; + const clicks = { handler: null, destroyed: 0 }; + const owners = new Map(); + const opened = []; + const viewer = { + scene: { pick }, + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createFirePerimetersLayer({ + source, + overlayHost: { + setEntries(sourceId, entries) { + overlay.entries.set(sourceId, entries); + }, + setVisible(sourceId, visible) { + overlay.visible = visible; + }, + clearSource(sourceId) { + overlay.entries.delete(sourceId); + }, + hitTest: (x, y, options) => cardHit(x, y, options), + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(callback) { + clicks.handler = callback; + }, + destroy() { + clicks.destroyed += 1; + clicks.handler = null; + }, + }), + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (layerId, pickedId) => + String(pickedId).startsWith('other-layer:'), + registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate), + unregisterPickOwner: (layerId) => owners.delete(layerId), + }, + pointer: { isPointerFree: () => true }, + inciwebSource: inciwebIndex + ? { getIndex: async () => inciwebIndex } + : { getIndex: async () => [] }, + inciwebPublications: { getPublication: publication }, + openExternal: (url) => opened.push(url), + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, overlay, clicks, owners, opened }; +} + +const ring = [ + [-108.1, 35.2], + [-108.0, 35.2], + [-108.0, 35.3], + [-108.1, 35.2], +]; +const row = { + stableId: '2026-NMGNF-000123', + name: 'Fixture Fire', + acres: 512.5, + containedPct: 40, + state: 'US-NM', + category: 'WF', + discoveredTime: 1757900000000, + updatedTime: 1757950000000, + cause: 'Natural', + behavior: 'Active', + personnel: 380, + county: 'Sandoval', + costToDate: 4200000, + complexity: 'Type 3 Incident', + complexName: 'ROWE CREEK COMPLEX', + polygons: [[ring]], +}; + +test('each perimeter polygon renders as one filled entity with its fire line', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: 'other', polygons: [[ring], [ring]] }, + ], + }); + assert.equal(await h.layer.update(h.viewer), true); + const entities = h.sources[0].entities.values; + assert.equal(entities.length, 3); + assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0'); + assert.ok(entities[0].polygon, 'perimeter fill missing'); + assert.ok(entities[0].polyline, 'fire line missing'); + assert.equal(entities[1].id, 'fire-perimeter:other:0'); + assert.equal(entities[2].id, 'fire-perimeter:other:1'); + assert.equal(h.layer.getStats().count, 2); +}); + +test('late refresh cannot publish after disable or destroy', async () => { + for (const action of ['disable', 'destroy']) { + let resolve, signal; + const h = harness({ + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = h.layer.update(h.viewer); + h.layer[action](h.viewer); + assert.equal(signal.aborted, true); + if (action === 'disable') h.layer.enable(h.viewer); + resolve([row]); + assert.equal(await pending, false); + assert.equal(h.layer.getStats().count, 0); + h.layer.destroy(h.viewer); + } +}); + +test('clicking a perimeter publishes its incident card; empty space clears it', async () => { + let pickResult = null; + const h = harness( + { getSnapshot: async () => [row] }, + { + pick: () => pickResult, + }, + ); + await h.layer.update(h.viewer); + assert.equal(typeof h.clicks.handler, 'function', 'click handler missing'); + // Deliberately NOT registered as a pick owner: sibling layers' click + // handlers yield to owned picks before their own card hit-tests, so + // claiming these large ground polygons would make FIRMS/vessel/CCTV + // overlay cards inert anywhere over a perimeter. + assert.equal(h.owners.size, 0); + + pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + const entries = h.overlay.entries.get('fire-perimeters'); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123'); + assert.equal(entries[0].title, 'FIRE · Fixture Fire'); + assert.ok(entries[0].position, 'card must carry a world anchor'); + + pickResult = { id: 'other-layer:aircraft-1' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal( + h.overlay.entries.get('fire-perimeters')?.length, + 1, + 'sibling-layer picks must not clear the selection', + ); + + pickResult = null; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0); +}); + +test('disable removes the click handler, pick ownership, and any card', async () => { + const h = harness( + { getSnapshot: async () => [row] }, + { pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) }, + ); + await h.layer.update(h.viewer); + h.clicks.handler({ position: { x: 10, y: 10 } }); + h.layer.disable(h.viewer); + assert.equal(h.clicks.destroyed, 1); + assert.equal(h.owners.has('fire-perimeters'), false); + assert.equal(h.overlay.entries.has('fire-perimeters'), false); +}); + +test('a refresh that drops the selected incident also drops its card', async () => { + let rows = [row]; + const h = harness( + { getSnapshot: async () => rows }, + { pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) }, + ); + await h.layer.update(h.viewer); + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal(h.overlay.entries.get('fire-perimeters').length, 1); + rows = [{ ...row, stableId: 'different' }]; + await h.layer.update(h.viewer); + assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0); +}); + +test('a matched incident card carries the InciWeb line and click-through', async () => { + const inciwebIndex = [ + { + incident_id: '329300', + tau: 'NMGNF Fixture Fire', + incident_title: 'Fixture Fire', + }, + ]; + let cardHitResult = null; + const h = harness( + { getSnapshot: async () => [{ ...row, name: 'Fixture' }] }, + { + pick: () => null, + inciwebIndex, + cardHit: () => cardHitResult, + }, + ); + await h.layer.update(h.viewer); + // Select via a perimeter pick. + h.viewer.scene.pick = () => ({ + id: 'fire-perimeter:2026-NMGNF-000123:0', + }); + h.clicks.handler({ position: { x: 10, y: 10 } }); + // The card publishes immediately; the link line lands once the + // publication's currency is validated. + assert.equal(h.overlay.entries.get('fire-perimeters').length, 1); + await new Promise((done) => setTimeout(done, 0)); + const entries = h.overlay.entries.get('fire-perimeters'); + assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open'); + + // Clicking the card itself opens the InciWeb page. + cardHitResult = { entryId: entries[0].id }; + h.viewer.scene.pick = () => null; + h.clicks.handler({ position: { x: 12, y: 12 } }); + assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']); + assert.equal( + h.overlay.entries.get('fire-perimeters').length, + 1, + 'opening the link must not clear the selection', + ); +}); + +test('an unmatched incident renders no InciWeb line and card clicks stay inert', async () => { + let cardHitResult = null; + const h = harness( + { getSnapshot: async () => [row] }, + { + pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }), + inciwebIndex: [], + cardHit: () => cardHitResult, + }, + ); + await h.layer.update(h.viewer); + h.clicks.handler({ position: { x: 10, y: 10 } }); + const entries = h.overlay.entries.get('fire-perimeters'); + assert.equal( + entries[0].details.some((line) => line.includes('InciWeb')), + false, + ); + cardHitResult = { entryId: entries[0].id }; + h.viewer.scene.pick = () => null; + h.clicks.handler({ position: { x: 12, y: 12 } }); + assert.deepEqual(h.opened, []); + assert.equal(h.overlay.entries.get('fire-perimeters').length, 1); +}); + +test('a complex member resolves to its complex page from the catalog', async () => { + const h = harness( + { getSnapshot: async () => [row] }, + { + pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }), + inciwebIndex: [ + { + // Same state as the member fire: the state filter applies to + // complex fallbacks too. + incident_id: '328923', + tau: 'NMPRD Rowe Creek Complex', + incident_title: 'Rowe Creek Complex', + }, + ], + }, + ); + await h.layer.update(h.viewer); + h.clicks.handler({ position: { x: 10, y: 10 } }); + await new Promise((done) => setTimeout(done, 0)); + const entries = h.overlay.entries.get('fire-perimeters'); + assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open'); +}); + +test('a stale or unverifiable publication yields no link', async () => { + const inciwebIndex = [ + { + incident_id: '100', + tau: 'NMGNF Fixture Fire', + incident_title: 'Fixture Fire', + }, + ]; + for (const publication of [ + // Created two years before this fire's discovery: an archived page. + async () => ({ + createdMs: 1757900000000 - 700 * 86400000, + changedMs: null, + }), + // Validation endpoint down: fail closed, no link. + async () => { + throw new Error('InciWeb HTTP 503'); + }, + ]) { + const h = harness( + { getSnapshot: async () => [row] }, + { + pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }), + inciwebIndex, + publication, + }, + ); + await h.layer.update(h.viewer); + h.clicks.handler({ position: { x: 10, y: 10 } }); + await new Promise((done) => setTimeout(done, 0)); + const entries = h.overlay.entries.get('fire-perimeters'); + assert.equal(entries.length, 1); + assert.equal( + entries[0].details.some((line) => line.includes('InciWeb')), + false, + ); + assert.equal(entries[0].interactive, false); + h.layer.destroy(h.viewer); + } +}); + +test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => { + const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null }); + // Replace the index source with one that never resolves. + h.layer.destroy(h.viewer); + const hanging = harness({ getSnapshot: async () => [row] }); + hanging.layer.destroy(hanging.viewer); + const sources = []; + const viewer = { + scene: { pick: () => null }, + dataSources: { + add: (v) => sources.push(v), + remove: (v) => sources.splice(sources.indexOf(v), 1), + }, + }; + const layer = createFirePerimetersLayer({ + source: { getSnapshot: async () => [row] }, + overlayHost: { + setEntries() {}, + setVisible() {}, + clearSource() {}, + hitTest: () => null, + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), + picking: { + resolvePickId: () => null, + isOwnedByOtherLayer: () => false, + registerPickOwner() {}, + unregisterPickOwner() {}, + }, + pointer: { isPointerFree: () => true }, + inciwebSource: { getIndex: () => new Promise(() => {}) }, + inciwebPublications: { + getPublication: async () => ({ createdMs: 1, changedMs: 1 }), + }, + openExternal: () => {}, + }); + layer.init(viewer); + layer.enable(viewer); + assert.equal(await layer.update(viewer), true); + assert.equal(layer.getStats().count, 1); + layer.destroy(viewer); +}); + +test('an InciWeb outage never breaks the perimeter refresh', async () => { + const h = harness({ getSnapshot: async () => [row] }); + const failing = createFirePerimetersLayer({ + source: { getSnapshot: async () => [row] }, + overlayHost: { + setEntries() {}, + setVisible() {}, + clearSource() {}, + hitTest: () => null, + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), + picking: { + resolvePickId: () => null, + isOwnedByOtherLayer: () => false, + registerPickOwner() {}, + unregisterPickOwner() {}, + }, + pointer: { isPointerFree: () => true }, + inciwebSource: { + getIndex: async () => { + throw new Error('InciWeb HTTP 503'); + }, + }, + openExternal: () => {}, + }); + const viewer = { + scene: { pick: () => null }, + dataSources: { add() {}, remove() {} }, + }; + failing.init(viewer); + failing.enable(viewer); + assert.equal(await failing.update(viewer), true); + assert.equal(failing.getStats().count, 1); + failing.destroy(viewer); + h.layer.destroy(h.viewer); +}); + +test('analyst records expose incident facts without geometry payloads', async () => { + const h = harness({ getSnapshot: async () => [row] }); + await h.layer.update(h.viewer); + const records = h.layer.getAnalystRecords(); + assert.equal(records.length, 1); + assert.equal(records[0].name, 'Fixture Fire'); + assert.equal(records[0].acres, 512.5); + assert.equal(records[0].containedPct, 40); + assert.equal(records[0].state, 'US-NM'); + assert.equal(records[0].cause, 'Natural'); + assert.equal(records[0].behavior, 'Active'); + assert.equal(records[0].personnel, 380); + assert.equal(records[0].county, 'Sandoval'); + assert.equal(records[0].costToDate, 4200000); + assert.equal(records[0].complexity, 'Type 3 Incident'); + // Spatial scoping in the analyst engine requires coordinates. + assert.ok(Math.abs(records[0].lat - 35.2333) < 0.01); + assert.ok(Math.abs(records[0].lon - -108.0333) < 0.01); + assert.equal('polygons' in records[0], false); +}); + +test('unchanged snapshots retain entity identity even if source ordering changes', async () => { + let rows = [row, { ...row, stableId: 'other' }]; + const h = harness({ getSnapshot: async () => rows }); + await h.layer.update(); + const original = [...h.sources[0].entities.values]; + let removals = 0; + const entities = h.sources[0].entities; + const removeAll = entities.removeAll.bind(entities); + entities.removeAll = () => { + removals++; + removeAll(); + }; + rows = [...rows].reverse(); + await h.layer.update(); + assert.equal(removals, 0); + assert.equal(entities.values[0], original[0]); + rows = rows.map((value) => ({ + ...value, + updatedTime: value.updatedTime + 1, + })); + await h.layer.update(); + assert.equal(removals, 1); + assert.notEqual(entities.values[0], original[0]); + rows = rows.map((value) => ({ ...value, containedPct: 100 })); + await h.layer.update(); + assert.equal( + removals, + 2, + 'incident facts can change without a new polygon timestamp', + ); + assert.equal(h.layer.getRowControls().legend[3].count, 2); + rows = []; + await h.layer.update(); + assert.equal(entities.values.length, 0); + h.layer.destroy(); +}); + +test('containment legend uses the existing thresholds, colours and incident counts', async () => { + const values = [null, -1, 0, 0.5, 49.9, 50, 99.9, 100, 101]; + const h = harness({ + getSnapshot: async () => + values.map((containedPct, index) => ({ + ...row, + stableId: String(index), + containedPct, + })), + }); + await h.layer.update(); + const controls = h.layer.getRowControls(); + assert.deepEqual(controls.chips, []); + assert.deepEqual( + controls.legend.map(({ color, count }) => [color, count]), + [ + ['#ff3b30', 3], + ['#ff7a00', 2], + ['#ffb300', 2], + ['#8bc34a', 2], + ], + ); + assert.ok( + controls.legend.every( + ({ label }) => typeof label === 'string' && label.length, + ), + ); + assert.equal( + controls.legend[0].blurb, + 'Colour shows reported containment. Perimeters are simplified to about 100 m.', + ); + h.layer.destroy(); + assert.deepEqual( + h.layer.getRowControls().legend.map(({ count }) => count), + [0, 0, 0, 0], + ); +}); + +for (const action of ['disable', 'destroy', 'selection']) { + test(`incident-link verification aborts on ${action} and ignores late completion`, async () => { + let finish, signal; + let pick = { id: `fire-perimeter:${row.stableId}:0` }; + const h = harness( + { + getSnapshot: async () => [ + row, + { ...row, stableId: 'other', name: 'Other Fire' }, + ], + }, + { + pick: () => pick, + inciwebIndex: [ + { incident_id: '123', incident_title: row.name, tau: 'nm' }, + ], + publication: (_id, options) => { + signal = options.signal; + return new Promise((resolve) => { + finish = resolve; + }); + }, + }, + ); + await h.layer.update(); + h.clicks.handler({ position: { x: 1, y: 1 } }); + assert.equal(signal.aborted, false); + if (action === 'selection') { + pick = { id: 'fire-perimeter:other:0' }; + h.clicks.handler({ position: { x: 1, y: 1 } }); + } else h.layer[action](); + assert.equal(signal.aborted, true); + const entries = h.overlay.entries.get('fire-perimeters'); + finish({ createdMs: row.discoveredTime, changedMs: row.updatedTime }); + await new Promise(setImmediate); + assert.equal(h.overlay.entries.get('fire-perimeters'), entries); + if (action !== 'destroy') h.layer.destroy(); + }); +} diff --git a/src/layers/perimeters/records.js b/src/layers/perimeters/records.js new file mode 100644 index 000000000..faed5ba64 --- /dev/null +++ b/src/layers/perimeters/records.js @@ -0,0 +1,94 @@ +/** + * Normalize a WFIGS feed, skipping individually invalid features. + * Only a payload that is not a feature collection at all rejects the whole + * snapshot: a single degenerate incident (ArcGIS emits geometry:null when + * maxAllowableOffset generalizes a tiny polygon away) must never blank the + * layer — a failed first update makes the lifecycle disable it entirely. + */ + +const finiteOrNull = (value) => (Number.isFinite(value) ? value : null); +const textOrNull = (value) => (typeof value === 'string' ? value : null); + +function validRing(ring) { + if (!Array.isArray(ring) || ring.length < 4) return false; + for (const position of ring) { + if (!Array.isArray(position) || position.length < 2) return false; + const [lon, lat] = position; + if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false; + if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false; + } + return true; +} + +/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an + * array of rings). Returns null on malformed geometry, [] when empty. */ +function normalizePolygons(geometry) { + if (!geometry || typeof geometry !== 'object') return null; + let polygons; + if (geometry.type === 'Polygon') polygons = [geometry.coordinates]; + else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates; + else return null; + if (!Array.isArray(polygons)) return null; + const result = []; + for (const rings of polygons) { + if (!Array.isArray(rings)) return null; + if (!rings.length) continue; + if (!rings.every(validRing)) return null; + result.push(rings); + } + return result; +} + +export function normalizeFirePerimeterSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const feature of geojson.features) { + const properties = feature?.properties; + if ( + !properties || + typeof properties !== 'object' || + Array.isArray(properties) + ) + continue; + const polygons = normalizePolygons(feature.geometry); + if (polygons === null || !polygons.length) continue; + const uniqueId = properties.attr_UniqueFireIdentifier; + const stableId = + typeof uniqueId === 'string' && uniqueId !== '' + ? uniqueId + : feature.id == null || feature.id === '' + ? null + : String(feature.id); + if (stableId == null || ids.has(stableId)) continue; + ids.add(stableId); + rows.push({ + stableId, + name: + typeof properties.poly_IncidentName === 'string' + ? properties.poly_IncidentName + : null, + acres: finiteOrNull(properties.attr_IncidentSize), + containedPct: finiteOrNull(properties.attr_PercentContained), + state: + typeof properties.attr_POOState === 'string' + ? properties.attr_POOState + : null, + category: + typeof properties.attr_IncidentTypeCategory === 'string' + ? properties.attr_IncidentTypeCategory + : null, + discoveredTime: finiteOrNull(properties.attr_FireDiscoveryDateTime), + updatedTime: finiteOrNull(properties.poly_DateCurrent), + cause: textOrNull(properties.attr_FireCause), + behavior: textOrNull(properties.attr_FireBehaviorGeneral), + personnel: finiteOrNull(properties.attr_TotalIncidentPersonnel), + county: textOrNull(properties.attr_POOCounty), + costToDate: finiteOrNull(properties.attr_EstimatedCostToDate), + complexity: textOrNull(properties.attr_IncidentComplexityLevel), + complexName: textOrNull(properties.attr_CpxName), + polygons, + }); + } + return rows; +} diff --git a/src/layers/perimeters/records.test.mjs b/src/layers/perimeters/records.test.mjs new file mode 100644 index 000000000..fb11193ed --- /dev/null +++ b/src/layers/perimeters/records.test.mjs @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeFirePerimeterSnapshot } from './records.js'; + +const ring = [ + [-108.1, 35.2], + [-108.0, 35.2], + [-108.0, 35.3], + [-108.1, 35.2], +]; + +function feature(overrides = {}) { + return { + id: 1, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + poly_IncidentName: 'Fixture Fire', + attr_UniqueFireIdentifier: '2026-NMGNF-000123', + attr_IncidentSize: 512.5, + attr_PercentContained: 40, + attr_POOState: 'US-NM', + attr_IncidentTypeCategory: 'WF', + attr_FireDiscoveryDateTime: 1757900000000, + poly_DateCurrent: 1757950000000, + attr_FireCause: 'Natural', + attr_FireBehaviorGeneral: 'Active', + attr_TotalIncidentPersonnel: 380, + attr_POOCounty: 'Sandoval', + attr_EstimatedCostToDate: 4200000, + attr_IncidentComplexityLevel: 'Type 3 Incident', + attr_CpxName: 'ROWE CREEK COMPLEX', + ...overrides.properties, + }, + ...overrides, + }; +} + +test('a valid snapshot yields one row per incident with polygons preserved', () => { + const rows = normalizeFirePerimeterSnapshot({ + features: [ + feature(), + feature({ + id: 2, + geometry: { + type: 'MultiPolygon', + coordinates: [[ring], [ring]], + }, + properties: { attr_UniqueFireIdentifier: '2026-AZASF-000456' }, + }), + ], + }); + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + stableId: '2026-NMGNF-000123', + name: 'Fixture Fire', + acres: 512.5, + containedPct: 40, + state: 'US-NM', + category: 'WF', + discoveredTime: 1757900000000, + updatedTime: 1757950000000, + cause: 'Natural', + behavior: 'Active', + personnel: 380, + county: 'Sandoval', + costToDate: 4200000, + complexity: 'Type 3 Incident', + complexName: 'ROWE CREEK COMPLEX', + polygons: [[ring]], + }); + assert.equal(rows[1].polygons.length, 2); +}); + +test('a missing unique fire identifier falls back to the feature id', () => { + const rows = normalizeFirePerimeterSnapshot({ + features: [feature({ properties: { attr_UniqueFireIdentifier: null } })], + }); + assert.equal(rows[0].stableId, '1'); +}); + +test('optional attributes normalize to null rather than leaking undefined', () => { + const rows = normalizeFirePerimeterSnapshot({ + features: [ + feature({ + properties: { + poly_IncidentName: null, + attr_IncidentSize: null, + attr_PercentContained: null, + attr_POOState: null, + attr_IncidentTypeCategory: null, + attr_FireDiscoveryDateTime: null, + poly_DateCurrent: null, + attr_FireCause: null, + attr_FireBehaviorGeneral: null, + attr_TotalIncidentPersonnel: null, + attr_POOCounty: null, + attr_EstimatedCostToDate: null, + attr_IncidentComplexityLevel: null, + attr_CpxName: null, + }, + }), + ], + }); + assert.deepEqual( + { + name: rows[0].name, + acres: rows[0].acres, + containedPct: rows[0].containedPct, + state: rows[0].state, + category: rows[0].category, + discoveredTime: rows[0].discoveredTime, + updatedTime: rows[0].updatedTime, + cause: rows[0].cause, + behavior: rows[0].behavior, + personnel: rows[0].personnel, + county: rows[0].county, + costToDate: rows[0].costToDate, + complexity: rows[0].complexity, + complexName: rows[0].complexName, + }, + { + name: null, + acres: null, + containedPct: null, + state: null, + category: null, + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + complexName: null, + }, + ); +}); + +test('a feed that is not a feature collection is rejected as a whole', () => { + for (const payload of [null, {}, { features: null }, { features: {} }]) { + assert.equal(normalizeFirePerimeterSnapshot(payload), null); + } +}); + +test('an invalid feature is skipped so one bad incident cannot blank the layer', () => { + const good = feature({ + id: 2, + properties: { attr_UniqueFireIdentifier: 'good' }, + }); + for (const bad of [ + feature({ geometry: null }), + feature({ geometry: { type: 'Point', coordinates: [0, 1] } }), + feature({ + geometry: { + type: 'Polygon', + coordinates: [[[Infinity, 35.2], ...ring.slice(1)]], + }, + }), + feature({ + geometry: { + type: 'Polygon', + coordinates: [[[-200, 35.2], ...ring.slice(1)]], + }, + }), + feature({ geometry: { type: 'Polygon', coordinates: [ring.slice(0, 2)] } }), + feature({ properties: null }), + feature({ id: null, properties: { attr_UniqueFireIdentifier: null } }), + ]) { + const rows = normalizeFirePerimeterSnapshot({ features: [bad, good] }); + assert.equal(rows.length, 1, 'bad feature must be skipped, not fatal'); + assert.equal(rows[0].stableId, 'good'); + } +}); + +test('a duplicate incident id keeps the first occurrence and skips the rest', () => { + const rows = normalizeFirePerimeterSnapshot({ + features: [ + feature(), + feature({ + properties: { + attr_UniqueFireIdentifier: '2026-NMGNF-000123', + attr_IncidentSize: 999, + }, + }), + ], + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].acres, 512.5); +}); + +test('a perimeter with no rings is skipped rather than rendered empty', () => { + const rows = normalizeFirePerimeterSnapshot({ + features: [ + feature({ geometry: { type: 'Polygon', coordinates: [] } }), + feature({ id: 2, properties: { attr_UniqueFireIdentifier: 'other' } }), + ], + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].stableId, 'other'); +}); diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js new file mode 100644 index 000000000..9147fe0f9 --- /dev/null +++ b/src/layers/perimeters/source.js @@ -0,0 +1,23 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */ +export function createWfigsPerimeterSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/fire-perimeters', { signal }); + if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed perimeter snapshot'); + return payload.rows; + }, + }; +} diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs new file mode 100644 index 000000000..a9f8fbd4c --- /dev/null +++ b/src/layers/perimeters/source.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createWfigsPerimeterSource } from './source.js'; +import { normalizeFirePerimeterSnapshot } from './records.js'; +import { firePerimetersProxy } from '../../../server/providers/firePerimeters.js'; + +const ring = [ + [-108.1, 35.2], + [-108.0, 35.2], + [-108.0, 35.3], + [-108.1, 35.2], +]; +const validPayload = { + features: [ + { + id: 1, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { attr_UniqueFireIdentifier: '2026-NMGNF-000123' }, + }, + ], +}; + +test('a successful response yields normalized perimeter rows', async () => { + let requested; + const source = createWfigsPerimeterSource({ + fetchImpl: async (url) => { + requested = String(url); + return Response.json({ + rows: normalizeFirePerimeterSnapshot(validPayload), + }); + }, + }); + const rows = await source.getSnapshot(); + assert.equal(rows.length, 1); + assert.equal(rows[0].stableId, '2026-NMGNF-000123'); + assert.equal(requested, '/api/fire-perimeters'); +}); + +test('a truncated response pages until the feed is complete', async () => { + const pageRing = (id) => ({ + id, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { attr_UniqueFireIdentifier: `fire-${id}` }, + }); + const requests = []; + let handler; + const plugin = firePerimetersProxy({ + fetchImpl: async (url) => { + requests.push(new URL(String(url)).searchParams.get('resultOffset')); + const page = requests.length; + return Response.json({ + features: [pageRing(page)], + properties: { exceededTransferLimit: page < 3 }, + }); + }, + }); + plugin.configureServer({ + middlewares: { + use: (_path, callback) => { + handler = callback; + }, + }, + }); + let payload; + await handler( + { url: '/', method: 'GET' }, + { + writeHead(status) { + assert.equal(status, 200); + }, + end(body) { + payload = JSON.parse(body); + }, + }, + ); + const rows = payload.rows; + assert.deepEqual( + rows.map((row) => row.stableId), + ['fire-1', 'fire-2', 'fire-3'], + ); + assert.deepEqual(requests, [null, '1', '2']); +}); + +test('an upstream failure surfaces its HTTP status', async () => { + const source = createWfigsPerimeterSource({ + fetchImpl: async () => ({ ok: false, status: 503 }), + }); + await assert.rejects(source.getSnapshot(), /WFIGS HTTP 503/); +}); + +test('a malformed successful response is never accepted as an empty snapshot', async () => { + for (const payload of [{}, { rows: null }, { rows: {} }]) { + const source = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json(payload), + }); + await assert.rejects(source.getSnapshot(), /Malformed perimeter snapshot/); + } +}); + +test('response-body completion honors cancellation without replacing records', async () => { + const abort = new AbortController(); + const source = createWfigsPerimeterSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + text: async () => { + abort.abort(); + return JSON.stringify({ + rows: normalizeFirePerimeterSnapshot(validPayload), + }); + }, + }), + }); + await assert.rejects(source.getSnapshot({ signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index 5b2db50b3..ccbb8e991 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -4,13 +4,21 @@ import { infraredAlpha } from './infraredAlpha.js'; export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024; -/** Apply the display transfer once to a decoded image. */ -export function processInfraredImage(image, mode, createCanvas) { +/** Apply the display transfer once to a decoded image; `flipY` draws a + * bottom-up image back upright. */ +export function processInfraredImage( + image, + mode, + createCanvas, + { flipY = false } = {}, +) { const canvas = createCanvas(); canvas.width = image.width; canvas.height = image.height; const context = canvas.getContext('2d'); + if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height); context.drawImage(image, 0, 0); + if (flipY) context.setTransform(1, 0, 0, 1, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height); pixels.data.set(infraredAlpha(pixels.data, mode)); context.putImageData(pixels, 0, 0); diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index f93a7816a..f3937abc3 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -10,8 +10,12 @@ import { function canvas() { const output = {}; output.getContext = () => ({ + setTransform: (...matrix) => { + output.transforms = [...(output.transforms ?? []), matrix]; + }, drawImage: (image) => { output.image = image; + output.drawnWith = output.transforms?.at(-1) ?? null; }, getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }), putImageData: (pixels) => { @@ -34,6 +38,14 @@ test('decoded pixels are processed once on a same-sized canvas', () => { ); }); +test('flipY draws a bottom-up image upright, then restores the transform', () => { + const image = { width: 4, height: 8 }; + assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null); + const output = processInfraredImage(image, 'full', canvas, { flipY: true }); + assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]); + assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]); +}); + test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => { const controller = new AbortController(); let closes = 0, diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 34224ecc8..8d195f302 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -381,8 +381,14 @@ function createGlobeRendering({ if (request) frame.requests.add(request); return Promise.resolve(result) .then((image) => { + // Cesium decodes tiles as ImageBitmaps already flipped, since WebGL + // ignores UNPACK_FLIP_Y for them; a canvas upload flips again. if (!frame.closed && snapshot.product === 'clouds-regional') - image = processInfraredImage(image, infrared, createCanvas); + image = processInfraredImage(image, infrared, createCanvas, { + flipY: + typeof ImageBitmap !== 'undefined' && + image instanceof ImageBitmap, + }); frame.retries.delete(tileKey); frame.loaded++; return image; diff --git a/src/layers/weather/shellFixture.mjs b/src/layers/weather/shellFixture.mjs index 685c72c56..18e5de788 100644 --- a/src/layers/weather/shellFixture.mjs +++ b/src/layers/weather/shellFixture.mjs @@ -71,6 +71,8 @@ export function createShellCesium({ maximumTextureSize = 0 } = {}) { GeometryInstance, BlendingState: Cesium.BlendingState, Cartesian4: Cesium.Cartesian4, + Ellipsoid: Cesium.Ellipsoid, + Matrix4: Cesium.Matrix4, Math: Cesium.Math, Rectangle: Cesium.Rectangle, ContextLimits: { maximumTextureSize }, diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 42a8ed521..cc8b32035 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -25,6 +25,12 @@ const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 }); const DETAIL_MIN_WIDTH = 6; const DETAIL_GRID = 0.5; const shellHeights = new WeakMap(); +// From far away Google's coarse 3D tiles rise tens of kilometres above the +// ground and hide the shells, so the shells rise with the camera: 4 m per +// kilometre of camera height, at most 60 km, in 500 m steps. +const LIFT_PER_METRE = 0.004; +const MAX_LIFT = 60_000; +const LIFT_STEP = 500; // Cesium binds a 1×1 white texture to an image uniform until its first upload: // draw nothing until the full-extent image has arrived, and sample the detail // image only once it has arrived. The window is west, south, east, north in the @@ -71,6 +77,13 @@ const sameEdges = (a, b) => a.east === b.east && a.north === b.north; +/** Metres every shell rises for a camera this high above the ellipsoid. */ +export function shellLift(cameraHeight) { + if (!(cameraHeight > 0)) return 0; + const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE); + return Math.round(lift / LIFT_STEP) * LIFT_STEP; +} + /** Keep weather shells first in the primitive collection, in ascending height: * higher shells draw over lower ones and other opaque content draws over both. */ export function orderWeatherShells(primitives, primitive, height) { @@ -135,6 +148,17 @@ export function createShellSurface({ }); primitives.add(primitive); orderWeatherShells(primitives, primitive, height); + // A uniform scale about the Earth's centre lifts every shell alike and keeps + // their order. + let lift = 0; + const offLift = scene.preRender?.addEventListener(() => { + const next = shellLift(scene.camera?.positionCartographic?.height); + if (next === lift) return; + lift = next; + primitive.modelMatrix = cesium.Matrix4.fromUniformScale( + 1 + lift / cesium.Ellipsoid.WGS84.maximumRadius, + ); + }); let image = null; let frames = 0; // The detail image, its own upload count and a window waiting for it to draw. @@ -265,6 +289,7 @@ export function createShellSurface({ destroyed = true; offRender?.(); offRender = null; + offLift?.(); if (!primitives.isDestroyed?.() && primitives.contains(primitive)) primitives.remove(primitive); if (!primitive.isDestroyed()) primitive.destroy(); diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index 3fcfdf28e..ad65dcef2 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -6,6 +6,7 @@ import { createWeatherShell, detailWindow, orderWeatherShells, + shellLift, WEATHER_SHELL_CACHE_BYTES, WEATHER_SHELL_HEIGHTS, } from './shellRendering.js'; @@ -297,6 +298,44 @@ test('a surface applies a new detail window only once its image has drawn; the s assert.equal(scene.postRender.size, 0); }); +test('shells rise with the camera over coarse 3D tiles, in steps, up to 60 km', () => { + assert.equal(shellLift(undefined), 0); + assert.equal(shellLift(1_200), 0, 'city views keep the shell heights'); + assert.equal(shellLift(3_000_000), 12_000); + assert.equal(shellLift(8_000_000), 32_000); + assert.equal(shellLift(18_500_000), 60_000); + const camera = { positionCartographic: { height: 1_200 } }; + const scene = { + primitives: new Cesium.PrimitiveCollection(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + camera, + requestRender() {}, + }; + const surface = createShellSurface({ + viewer: { scene }, + cesium: Cesium, + rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), + height: 5_800, + }); + const primitive = scene.primitives.get(0); + const identity = primitive.modelMatrix; + scene.preRender.raiseEvent(); + assert.equal(primitive.modelMatrix, identity, 'no lift, no new matrix'); + camera.positionCartographic.height = 8_000_000; + scene.preRender.raiseEvent(); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + assert.ok( + Math.abs(primitive.modelMatrix[0] * radius - (radius + 32_000)) < 1e-3, + ); + const lifted = primitive.modelMatrix; + camera.positionCartographic.height = 8_010_000; + scene.preRender.raiseEvent(); + assert.equal(primitive.modelMatrix, lifted, 'the same step keeps the matrix'); + surface.destroy(); + assert.equal(scene.preRender.numberOfListeners, 0); +}); + test('real Cesium builds the two-texture material, the opaque-pass appearance and the rectangle primitive', () => { const scene = { primitives: new Cesium.PrimitiveCollection(), diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index abdc01c7a..a80ad6560 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -75,9 +75,13 @@ function target(extra = {}) { function mockCanvas() { const canvas = { width: 0, height: 0 }; canvas.getContext = () => ({ + setTransform(...matrix) { + canvas.transforms = [...(canvas.transforms ?? []), matrix]; + }, drawImage(source, ...crop) { canvas.source = source; canvas.crop = crop; + canvas.drawnWith = canvas.transforms?.at(-1) ?? null; }, getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }), putImageData(pixels) { @@ -1510,6 +1514,37 @@ test('regional transfer preserves deferrals, original Request, rejection and can assert.equal(await stage, false); }); +test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => { + const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap'); + const previous = globalThis.ImageBitmap; + globalThis.ImageBitmap = class ImageBitmap { + width = 256; + height = 256; + }; + try { + const h = renderingHarness(); + void h.rendering.setFrame( + { ...snapshot, product: 'clouds-regional' }, + times[0], + ); + const provider = h.providers[0]; + // Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again. + provider.response = { promise: Promise.resolve(new ImageBitmap()) }; + const bitmapTile = await provider.requestImage(0, 0, 0, {}); + assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]); + // An HTMLImageElement fallback is not pre-flipped. + provider.response = { + promise: Promise.resolve({ width: 256, height: 256 }), + }; + const imageTile = await provider.requestImage(1, 0, 0, {}); + assert.equal(imageTile.drawnWith, null); + h.rendering.clear(); + } finally { + if (hadBitmap) globalThis.ImageBitmap = previous; + else delete globalThis.ImageBitmap; + } +}); + for (const kind of ['globe']) { test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => { let host, diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index e2e8375a4..fd1248f0c 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1,4 +1,9 @@ import * as Cesium from 'cesium'; +import { + createCyberSonarSampler, + isCyberContactSonarActive as isCyberSonarActive, + CYBER_SONAR_RENDER_INTERVAL_MS, +} from '../cyberSonar.js'; import { getKeyholeFadeTuning, getKeyholeGeometry, @@ -170,6 +175,7 @@ let _resizeObserver = null; let _mutationObserver = null; let _observedOccluderElements = new WeakSet(); let _occluderRefreshTimer = null; +let _sonarRenderTimer = null; let _cockpitModeHandler = null; let _windowResizeHandler = null; let _cockpitActive = false; @@ -2443,7 +2449,7 @@ function paintCustomLane(lane) { } } -function paintEntryItem(item, keyhole) { +function paintEntryItem(item, keyhole, sonar) { const { record, placement } = item; const entry = record.entry; let keyholeAlpha = 1; @@ -2468,7 +2474,8 @@ function paintEntryItem(item, keyhole) { item.temporalAlpha * record.distanceAlpha * record.altitudeAlpha * - keyholeAlpha; + keyholeAlpha * + (sonar ? sonar.label(sonar.at(placement.anchorX, placement.anchorY)) : 1); if (finalAlpha <= 0.001) return; if (record.paintScale === 1) { paintOverlayEntry( @@ -2508,6 +2515,9 @@ function paintEntryItem(item, keyhole) { function paintFrame(keyhole) { const started = nowMs(); + const sonar = isCyberSonarActive() + ? createCyberSonarSampler(_canvasWidth, _canvasHeight, started) + : null; clearCanvas(true, false); _detectionSurfacePrepared = false; if ( @@ -2528,7 +2538,7 @@ function paintFrame(keyhole) { // retains its former z5 position below every ordinary host entry at z6. paintCustomLane(lane); while (itemIndex < _paintCount && _paintQueue[itemIndex].lane === lane) { - paintEntryItem(_paintQueue[itemIndex], keyhole); + paintEntryItem(_paintQueue[itemIndex], keyhole, sonar); itemIndex++; } } @@ -2581,6 +2591,10 @@ function resetFrameDiagnostics() { } function drawWorldOverlay() { + if (_sonarRenderTimer !== null) { + clearTimeout(_sonarRenderTimer); + _sonarRenderTimer = null; + } if (_destroyed || !_viewer || !_canvas || !_ctx) return; const timestamp = nowMs(); if (!overlayHasPaintWork(timestamp)) { @@ -2622,6 +2636,15 @@ function drawWorldOverlay() { _diagnostics.projectionMs = nowMs() - projectionStarted; solveDomains(timestamp); paintFrame(keyhole); + // Labels-only layers need a bounded sweep refresh even with detection off. + // Empty/hidden hosts and teardown never retain scanner render demand. + if (_paintRectCount > 0 && isCyberSonarActive()) { + _sonarRenderTimer = setTimeout(() => { + _sonarRenderTimer = null; + if (!_destroyed && isCyberSonarActive()) + _viewer?.scene?.requestRender?.(); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + } const animationsRemaining = activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp); if (animationsRemaining > 0) _viewer.scene.requestRender?.(); @@ -2679,6 +2702,8 @@ export function initWorldOverlay(viewer) { /** Remove all listeners, observers, entries, diagnostics, and overlay DOM. */ export function destroyWorldOverlay() { + if (_sonarRenderTimer !== null) clearTimeout(_sonarRenderTimer); + _sonarRenderTimer = null; // Tearing down a host that was never initialized must not arm the // post-destroy guard: sources are allowed to publish before the first // `initWorldOverlay`, and that buffering has to survive a stray destroy. diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 5a899ebc8..2a472e246 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -197,7 +197,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre assert.match( ui, - /const collapsed = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, + /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, 'responsive auto-collapse must serialize the explicit expanded preference', ); assert.match( @@ -218,9 +218,9 @@ test('parameterized Display presets keep one stable scroll owner', () => { const ui = readShellSource(); const css = readStylesheet(new URL('../style.css', import.meta.url)); - assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/); - assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); - assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); + assert.match(css, /#pp-toggles:not\(\.collapsed\) > \.pp-panel-body > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/); + assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?displayPanelScroller\(this\._ppToggles\)\?\.scrollTop \|\| 0/); + assert.match(rightRail, /scroller\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); assert.match( ui, /this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index c56c16762..5b161ba0e 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -25,11 +25,11 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 29-tool inventory', () => { +test('Realtime schema exposes the authoritative 30-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 29); + assert.equal(tools.length, 30); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 29, 'tool names are unique'); + assert.equal(new Set(names).size, 30, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); assert.ok(names.includes('select_nearest_aircraft')); @@ -173,6 +173,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { // in the mic-test brief which tools moved — the session cache busts on any // schema change. const TOUCHED = new Set([ + 'set_cyber_sonar', 'set_context_mode', 'control_cockpit', 'set_panel_open', @@ -183,21 +184,29 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'analyst_query', 'next_iss_pass', 'next_satellite_pass', + // Local ADS-B adds one layer enum value and its common-name mapping. + 'set_layer_visibility', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) - .sort((a, b) => a.name.localeCompare(b.name)); - assert.equal(unchanged.length, 19); + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tool) => structuredClone(tool)) + .filter((tool) => tool.name !== 'set_cyber_sonar'); + // Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field. + const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; + assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); + hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + assert.equal(unchanged.length, 18); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); // Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin. - assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted'); + assert.equal(digest, '91935845ef2598b1', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { - for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume']) { + for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) { assert.match( html, new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`), @@ -272,7 +281,10 @@ test('Radio is nested inside Context with separate disclosure and power controls assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/); assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/); assert.match(radioBindings, /radioTunerPointerPosition\(/); - assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/); + // Only the Cyber skin promotes Radio to a peer panel. Other themes retain + // the embedded Context layout; theme round-trip behavior has separate tests. + const baseCss = css.replace(readFileSync(new URL('./ui/styles/cyber.css', import.meta.url), 'utf8'), ''); + assert.doesNotMatch(baseCss, /#right-context-rail\s*>\s*#radio-panel/); assert.match(css, /#global-context-panel #radio-panel\.collapsed/); assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/); assert.doesNotMatch(css, /\.context-radio-dock\.active:focus-within \.context-radio-mini/); diff --git a/src/sdr/adsbDecoder.js b/src/sdr/adsbDecoder.js new file mode 100644 index 000000000..f6172d354 --- /dev/null +++ b/src/sdr/adsbDecoder.js @@ -0,0 +1,594 @@ +import { + LOCAL_ADSB_POSITION_MARGIN_M, + LOCAL_ADSB_REANCHOR_AFTER, + LOCAL_ADSB_REFERENCE_MAX_AGE_MS, + localAdsbFixIsPlausible, + localAdsbSpeedLimitKt, +} from '../sources/adsbRecords.js'; + +const MODE_S_POLYNOMIAL = 0xfff409; +/** An aircraft with no CRC-valid message for this long is removed. */ +export const LOCAL_ADSB_STALE_MS = 60_000; +const CPR_PAIR_MAX_AGE_MS = 10_000; +/** Surface aircraft move slowly; an even/odd surface pair may be further apart. */ +const CPR_SURFACE_PAIR_MAX_AGE_MS = 25_000; +/** + * Half a surface CPR latitude zone (90° / 60 zones / 2 = 0.75°, 45 NM): a + * single surface frame decodes unambiguously only against a reference known + * to be closer than this to the aircraft. + */ +const SURFACE_REFERENCE_RANGE_M = 45 * 1_852; +const KT_TO_MPS = 1_852 / 3_600; +const CPR_SCALE = 131_072; +const CPR_NZ = 15; +const CALLSIGN_CHARSET = + '#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############0123456789######'; +const PREAMBLE_SAMPLES = 16; +const FRAME_BITS = 112; +const FRAME_SAMPLES = PREAMBLE_SAMPLES + FRAME_BITS * 2; +const STREAM_CARRY_BYTES = (FRAME_SAMPLES - 1) * 2; + +function bit(bytes, index) { + return (bytes[index >> 3] >> (7 - (index & 7))) & 1; +} + +function bits(bytes, start, length) { + let value = 0; + for (let index = 0; index < length; index += 1) { + value = value * 2 + bit(bytes, start + index); + } + return value; +} + +function positiveModulo(value, divisor) { + return ((value % divisor) + divisor) % divisor; +} + +function cprNl(latitude) { + const absolute = Math.abs(latitude); + if (absolute >= 87) return absolute === 87 ? 2 : 1; + const numerator = 1 - Math.cos(Math.PI / (2 * CPR_NZ)); + const denominator = Math.cos((Math.PI / 180) * absolute) ** 2; + return Math.floor((2 * Math.PI) / Math.acos(1 - numerator / denominator)); +} + +function normalizeLongitude(longitude) { + let normalized = longitude; + while (normalized > 180) normalized -= 360; + while (normalized < -180) normalized += 360; + return normalized; +} + +function validReference(reference) { + return ( + Boolean(reference) && + Number.isFinite(reference.latitude) && + Number.isFinite(reference.longitude) + ); +} + +/** The candidate `value + k * period` nearest `target`. */ +function nearestAlias(value, period, target) { + return value + period * Math.round((target - value) / period); +} + +/** + * Locally decode one CPR frame against a reference within half a zone. + * Surface frames use quarter-size zones (90° instead of 360°), so their + * reference must be within 45 NM of the aircraft: only the aircraft's own + * recent position qualifies (see `surfaceReference`). + */ +function decodeLocalCpr(frame, reference) { + if (!validReference(reference)) return null; + const span = frame.surface ? 90 : 360; + const parity = frame.odd ? 1 : 0; + const dLat = span / (4 * CPR_NZ - parity); + const latitudeIndex = + Math.floor(reference.latitude / dLat) + + Math.floor( + 0.5 + + positiveModulo(reference.latitude, dLat) / dLat - + frame.latitude / CPR_SCALE, + ); + let latitude = dLat * (latitudeIndex + frame.latitude / CPR_SCALE); + if (latitude >= 270) latitude -= 360; + if (latitude < -90 || latitude > 90) return null; + + const longitudeZones = Math.max(cprNl(latitude) - parity, 1); + const dLon = span / longitudeZones; + const longitudeIndex = + Math.floor(reference.longitude / dLon) + + Math.floor( + 0.5 + + positiveModulo(reference.longitude, dLon) / dLon - + frame.longitude / CPR_SCALE, + ); + const longitude = normalizeLongitude( + dLon * (longitudeIndex + frame.longitude / CPR_SCALE), + ); + return { latitude, longitude }; +} + +function decodeGlobalCpr(even, odd) { + if ( + !even || + !odd || + Math.abs(even.receivedAt - odd.receivedAt) > CPR_PAIR_MAX_AGE_MS + ) + return null; + const latitudeIndex = Math.floor( + (59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5, + ); + let evenLatitude = + 6 * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE); + let oddLatitude = + (360 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE); + if (evenLatitude >= 270) evenLatitude -= 360; + if (oddLatitude >= 270) oddLatitude -= 360; + if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null; + + const useOdd = odd.receivedAt > even.receivedAt; + const latitude = useOdd ? oddLatitude : evenLatitude; + const longitudeZones = Math.max(cprNl(latitude) - (useOdd ? 1 : 0), 1); + const longitudeIndex = Math.floor( + (even.longitude * (cprNl(latitude) - 1) - odd.longitude * cprNl(latitude)) / + CPR_SCALE + + 0.5, + ); + const frame = useOdd ? odd : even; + const longitude = normalizeLongitude( + (360 / longitudeZones) * + (positiveModulo(longitudeIndex, longitudeZones) + + frame.longitude / CPR_SCALE), + ); + return { latitude, longitude }; +} + +/** + * Globally decode an even/odd pair of SURFACE position frames. Surface CPR + * encodes a quarter of the globe, so the pair leaves four candidate + * latitudes/longitudes 90° apart; the one nearest `reference` (the aircraft's + * last position or the receiver) is the answer, and without a reference + * there is no answer. + */ +function decodeGlobalSurfaceCpr(even, odd, reference) { + if ( + !even || + !odd || + !validReference(reference) || + Math.abs(even.receivedAt - odd.receivedAt) > CPR_SURFACE_PAIR_MAX_AGE_MS + ) + return null; + const latitudeIndex = Math.floor( + (59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5, + ); + const evenLatitude = nearestAlias( + (90 / 60) * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE), + 90, + reference.latitude, + ); + const oddLatitude = nearestAlias( + (90 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE), + 90, + reference.latitude, + ); + if (Math.abs(evenLatitude) > 90 || Math.abs(oddLatitude) > 90) return null; + if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null; + + const useOdd = odd.receivedAt > even.receivedAt; + const latitude = useOdd ? oddLatitude : evenLatitude; + const nl = cprNl(latitude); + const longitudeZones = Math.max(nl - (useOdd ? 1 : 0), 1); + const longitudeIndex = Math.floor( + (even.longitude * (nl - 1) - odd.longitude * nl) / CPR_SCALE + 0.5, + ); + const frame = useOdd ? odd : even; + const longitude = normalizeLongitude( + nearestAlias( + (90 / longitudeZones) * + (positiveModulo(longitudeIndex, longitudeZones) + + frame.longitude / CPR_SCALE), + 90, + reference.longitude, + ), + ); + return { latitude, longitude }; +} + +/** + * Ground speed in knots from a surface message's 7-bit movement field + * (DO-260B §2.2.3.2.4.2): null for "no information" and reserved codes. + */ +function decodeSurfaceMovement(movement) { + if (movement === 0 || movement > 124) return null; + if (movement === 1) return 0; + if (movement === 124) return 175; + const bands = [ + [2, 0.125, 0.125], + [9, 1, 0.25], + [13, 2, 0.5], + [39, 15, 1], + [94, 70, 2], + [109, 100, 5], + ]; + let band = bands[0]; + for (const candidate of bands) if (movement >= candidate[0]) band = candidate; + const [firstCode, firstKt, stepKt] = band; + return firstKt + (movement - firstCode) * stepKt; +} + +function decodeAltitude(bytes) { + if (!bit(bytes, 47)) return null; + const encoded = (bits(bytes, 40, 7) << 4) | bits(bytes, 48, 4); + return encoded * 25 - 1_000; +} + +function decodeCallsign(bytes) { + let callsign = ''; + for (let index = 0; index < 8; index += 1) { + callsign += CALLSIGN_CHARSET[bits(bytes, 40 + index * 6, 6)] || ' '; + } + return callsign.replace(/[_#]/g, ' ').trim(); +} + +function decodeVelocity(bytes) { + const subtype = bits(bytes, 37, 3); + if (subtype !== 1 && subtype !== 2) return null; + const multiplier = subtype === 2 ? 4 : 1; + const eastWestRaw = bits(bytes, 46, 10); + const northSouthRaw = bits(bytes, 57, 10); + if (!eastWestRaw || !northSouthRaw) return null; + const eastWest = (eastWestRaw - 1) * multiplier * (bit(bytes, 45) ? -1 : 1); + const northSouth = + (northSouthRaw - 1) * multiplier * (bit(bytes, 56) ? -1 : 1); + const speedKt = Math.hypot(eastWest, northSouth); + const headingDeg = positiveModulo( + (Math.atan2(eastWest, northSouth) * 180) / Math.PI, + 360, + ); + const verticalRaw = bits(bytes, 69, 9); + const verticalRateFpm = verticalRaw + ? (verticalRaw - 1) * 64 * (bit(bytes, 68) ? -1 : 1) + : null; + return { speedKt, headingDeg, verticalRateFpm }; +} + +/** Compute the 24-bit Mode S parity remainder. Valid extended squitters return zero. */ +export function modeSChecksum(bytes) { + let remainder = 0; + for (let index = 0; index < bytes.length * 8; index += 1) { + const feedback = bit(bytes, index) ^ ((remainder >> 23) & 1); + remainder = (remainder << 1) & 0xffffff; + if (feedback) remainder ^= MODE_S_POLYNOMIAL; + } + return remainder; +} + +/** Decode one 112-bit DF17/DF18 extended-squitter message. */ +export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) { + if (!(bytes instanceof Uint8Array) || bytes.length !== 14) return null; + const downlinkFormat = bits(bytes, 0, 5); + if ( + (downlinkFormat !== 17 && downlinkFormat !== 18) || + modeSChecksum(bytes) !== 0 + ) + return null; + const icao = [...bytes.slice(1, 4)] + .map((value) => value.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); + const typeCode = bits(bytes, 32, 5); + const message = { icao, typeCode, receivedAt }; + if (typeCode >= 1 && typeCode <= 4) { + message.callsign = decodeCallsign(bytes); + // dump1090 convention: category set A = TC 4, B = TC 3, C = TC 2, D = TC 1, + // followed by the 3-bit emitter category ('A7' rotorcraft, 'A1' light). + message.category = `${String.fromCharCode(69 - typeCode)}${bits(bytes, 37, 3)}`; + } else if (typeCode >= 5 && typeCode <= 8) { + // Surface position: movement, ground track when its status bit is set, + // and a quarter-globe CPR position; there is no altitude on the ground. + message.onGround = true; + message.altitudeFt = null; + const speedKt = decodeSurfaceMovement(bits(bytes, 37, 7)); + if (speedKt !== null) message.speedKt = speedKt; + if (bit(bytes, 44)) message.headingDeg = (bits(bytes, 45, 7) * 360) / 128; + message.cpr = { + surface: true, + odd: Boolean(bit(bytes, 53)), + latitude: bits(bytes, 54, 17), + longitude: bits(bytes, 71, 17), + receivedAt, + }; + } else if (typeCode >= 9 && typeCode <= 18) { + message.onGround = false; + message.altitudeFt = decodeAltitude(bytes); + message.cpr = { + odd: Boolean(bit(bytes, 53)), + latitude: bits(bytes, 54, 17), + longitude: bits(bytes, 71, 17), + receivedAt, + }; + } else if (typeCode === 19) { + Object.assign(message, decodeVelocity(bytes) || {}); + } + return message; +} + +function recentReference(track, receivedAt) { + if ( + !Number.isFinite(track.latitude) || + !Number.isFinite(track.longitude) || + !Number.isFinite(track.lastPositionAt) || + receivedAt - track.lastPositionAt > LOCAL_ADSB_REFERENCE_MAX_AGE_MS + ) + return null; + return { latitude: track.latitude, longitude: track.longitude }; +} + +/** + * The aircraft's own last position as a single-frame SURFACE reference, when + * it is established to be within 45 NM: recent, and the aircraft cannot have + * covered 45 NM since at its speed limit (the speed check's limit). The + * receiver location never qualifies, since how far away a heard aircraft is + * cannot be known from one frame. + */ +function surfaceReference(track, message) { + const reference = recentReference(track, message.receivedAt); + if (!reference) return null; + const groundSpeedKt = Number.isFinite(track.speedKt) + ? Math.max( + track.speedKt, + Number.isFinite(message.speedKt) ? message.speedKt : 0, + ) + : null; + const limitKt = localAdsbSpeedLimitKt({ + groundSpeedKt, + category: track.category, + }); + const reachM = + LOCAL_ADSB_POSITION_MARGIN_M + + ((message.receivedAt - track.lastPositionAt + 1_000) / 1_000) * + limitKt * + KT_TO_MPS; + return reachM < SURFACE_REFERENCE_RANGE_M ? reference : null; +} + +/** + * Merge one decoded message into a stable aircraft record. + * + * Airborne (TC 9–18) and surface (TC 5–8) positions are paired separately. + * A surface pair needs a reference to pick its quadrant (the aircraft's last + * position, else the receiver); surface messages also set `onGround` and + * clear the altitude, and the next airborne position clears `onGround`. + * + * A position comes from a fresh even/odd pair (global CPR), else from one + * frame decoded relative to the aircraft's own last accepted position when it + * is under 10 minutes old, else (airborne only) relative to the receiver + * location. A single surface frame needs the aircraft's own position within + * 45 NM, so a surface track is always seeded by a pair. Every + * candidate then passes the dump1090-style speed check against the last + * accepted fix; a failing fix is counted (`rejectedPositions` on the track and + * `positionsRejected` on the optional `stats`) and not applied. From the + * third consecutive rejection on, a globally decoded fix is accepted as the + * new reference, so one bad accepted fix cannot freeze a track. + * @param {Map} tracks Tracks keyed by uppercase ICAO. + * @param {object} message Output of `decodeAdsbMessage`. + * @param {{latitude:number, longitude:number}|null} [receiverLocation] + * @param {{positionsRejected?:number}} [stats] Mutable decoder counters. + * @returns {object|null} The updated track. + */ +export function updateAircraftTrack( + tracks, + message, + receiverLocation = null, + stats = null, +) { + if (!(tracks instanceof Map) || !message?.icao) return null; + const prior = tracks.get(message.icao) || { + icao: message.icao, + callsign: null, + category: null, + altitudeFt: null, + latitude: null, + longitude: null, + speedKt: null, + headingDeg: null, + verticalRateFpm: null, + messages: 0, + lastSeen: 0, + lastPositionAt: null, + onGround: false, + cprEven: null, + cprOdd: null, + cprSurfaceEven: null, + cprSurfaceOdd: null, + rejectedPositions: 0, + rejectStreak: 0, + }; + const next = { + ...prior, + messages: prior.messages + 1, + lastSeen: message.receivedAt, + }; + for (const key of [ + 'callsign', + 'category', + 'altitudeFt', + 'speedKt', + 'headingDeg', + 'verticalRateFpm', + ]) { + if (message[key] !== undefined && message[key] !== null) + next[key] = message[key]; + } + // A position message says where the aircraft is: on the surface (no + // altitude, no climb) or airborne again. + if (typeof message.onGround === 'boolean') { + next.onGround = message.onGround; + if (message.onGround) { + next.altitudeFt = null; + next.verticalRateFpm = null; + } + } + if (message.cpr) { + const surface = Boolean(message.cpr.surface); + // Airborne and surface frames use different zone sizes: never pair them. + if (surface) { + if (message.cpr.odd) next.cprSurfaceOdd = message.cpr; + else next.cprSurfaceEven = message.cpr; + } else if (message.cpr.odd) next.cprOdd = message.cpr; + else next.cprEven = message.cpr; + const reference = recentReference(prior, message.receivedAt); + const global = surface + ? decodeGlobalSurfaceCpr( + next.cprSurfaceEven, + next.cprSurfaceOdd, + reference || receiverLocation, + ) + : decodeGlobalCpr(next.cprEven, next.cprOdd); + const position = + global || + (surface + ? decodeLocalCpr(message.cpr, surfaceReference(prior, message)) + : decodeLocalCpr(message.cpr, reference) || + decodeLocalCpr(message.cpr, receiverLocation)); + if (position) { + const plausible = + !reference || + localAdsbFixIsPlausible( + { + lat: prior.latitude, + lon: prior.longitude, + at: prior.lastPositionAt, + }, + { + lat: position.latitude, + lon: position.longitude, + at: message.receivedAt, + }, + { groundSpeedKt: next.speedKt, category: next.category }, + ); + const reanchor = + !plausible && + Boolean(global) && + (prior.rejectStreak || 0) + 1 >= LOCAL_ADSB_REANCHOR_AFTER; + if (plausible || reanchor) { + Object.assign(next, position); + next.lastPositionAt = message.receivedAt; + next.rejectStreak = 0; + } else { + next.rejectedPositions = (prior.rejectedPositions || 0) + 1; + next.rejectStreak = (prior.rejectStreak || 0) + 1; + if (stats) stats.positionsRejected = (stats.positionsRejected || 0) + 1; + } + } + } + tracks.set(message.icao, next); + return next; +} + +/** Remove aircraft that have not transmitted recently. */ +export function pruneAircraftTracks( + tracks, + now = Date.now(), + ttlMs = LOCAL_ADSB_STALE_MS, +) { + if (!(tracks instanceof Map)) return 0; + let removed = 0; + for (const [icao, aircraft] of tracks) { + if (now - aircraft.lastSeen < ttlMs) continue; + tracks.delete(icao); + removed += 1; + } + return removed; +} + +function preambleContrast(power, offset) { + const pulseOffsets = [0, 2, 7, 9]; + // Only the six defined gaps between Mode S preamble pulses establish the + // local floor. Samples 10–15 can contain the trailing shape of the last RF + // pulse on a real 2 Msps receiver and treating them as quiet rejects frames + // that a clean synthetic fixture does not expose. + const quietOffsets = [1, 3, 4, 5, 6, 8]; + let pulseMean = 0; + let quietMean = 0; + for (const index of pulseOffsets) pulseMean += power[offset + index]; + for (const index of quietOffsets) quietMean += power[offset + index]; + pulseMean /= pulseOffsets.length; + quietMean /= quietOffsets.length; + const contrast = pulseMean - quietMean; + if (contrast < 120 || pulseMean < quietMean * 1.8) return 0; + const decisionLevel = quietMean + contrast * 0.35; + if (pulseOffsets.some((index) => power[offset + index] <= decisionLevel)) + return 0; + return contrast; +} + +/** Extract CRC-valid 112-bit ADS-B messages from unsigned 8-bit interleaved IQ. */ +export function extractAdsbMessages(buffer, sampleRate = 2_000_000) { + if ( + !(buffer instanceof ArrayBuffer) || + Math.abs(sampleRate - 2_000_000) > 30_000 + ) + return []; + const iq = new Uint8Array(buffer); + const sampleCount = Math.floor(iq.length / 2); + const power = new Float32Array(sampleCount); + for (let index = 0; index < sampleCount; index += 1) { + const i = iq[index * 2] - 127.5; + const q = iq[index * 2 + 1] - 127.5; + power[index] = i * i + q * q; + } + + const messages = []; + for (let offset = 0; offset <= sampleCount - FRAME_SAMPLES; offset += 1) { + const contrast = preambleContrast(power, offset); + if (!contrast) continue; + const bytes = new Uint8Array(14); + let uncertainBits = 0; + for (let bitIndex = 0; bitIndex < FRAME_BITS; bitIndex += 1) { + const sampleOffset = offset + PREAMBLE_SAMPLES + bitIndex * 2; + const first = power[sampleOffset]; + const second = power[sampleOffset + 1]; + const total = first + second; + const difference = Math.abs(first - second); + if ( + difference < Math.max(20, contrast * 0.02) || + difference / Math.max(total, 1) < 0.04 + ) + uncertainBits += 1; + if (first > second) bytes[bitIndex >> 3] |= 1 << (7 - (bitIndex & 7)); + } + if (uncertainBits <= 10 && modeSChecksum(bytes) === 0) { + messages.push(bytes); + offset += FRAME_SAMPLES - 1; + } + } + return messages; +} + +/** Preserve enough IQ between USB reads to recover frames split at a block edge. */ +export class AdsbStreamDecoder { + constructor() { + this._carry = new Uint8Array(0); + } + + reset() { + this._carry = new Uint8Array(0); + } + + extract(buffer, sampleRate = 2_000_000) { + if (!(buffer instanceof ArrayBuffer)) return []; + const incoming = new Uint8Array(buffer); + let combined = incoming; + if (this._carry.length) { + combined = new Uint8Array(this._carry.length + incoming.length); + combined.set(this._carry); + combined.set(incoming, this._carry.length); + } + this._carry = combined.slice( + Math.max(0, combined.length - STREAM_CARRY_BYTES), + ); + return extractAdsbMessages(combined.buffer, sampleRate); + } +} diff --git a/src/sdr/adsbDecoder.test.mjs b/src/sdr/adsbDecoder.test.mjs new file mode 100644 index 000000000..3b963f942 --- /dev/null +++ b/src/sdr/adsbDecoder.test.mjs @@ -0,0 +1,624 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { classifyAircraft } from '../data/aircraftClass.js'; +import { recordFromDecoderTrack } from '../sources/adsbRecords.js'; +import { + AdsbStreamDecoder, + LOCAL_ADSB_STALE_MS, + decodeAdsbMessage, + extractAdsbMessages, + modeSChecksum, + pruneAircraftTracks, + updateAircraftTrack, +} from './adsbDecoder.js'; + +function fromHex(value) { + return Uint8Array.from(value.match(/../g), (pair) => + Number.parseInt(pair, 16), + ); +} + +function synthesizeIq( + bytes, + { floor = 128, high = 255, preambleTail = null } = {}, +) { + const sampleCount = 16 + 112 * 2 + 8; + const iq = new Uint8Array(sampleCount * 2).fill(128); + for (let sample = 0; sample < sampleCount; sample += 1) + iq[sample * 2] = floor; + const pulse = (sample) => { + iq[sample * 2] = high; + }; + for (const offset of [0, 2, 7, 9]) pulse(offset); + if (Number.isFinite(preambleTail)) { + for (let sample = 10; sample < 16; sample += 1) + iq[sample * 2] = preambleTail; + } + for (let index = 0; index < 112; index += 1) { + const value = (bytes[index >> 3] >> (7 - (index & 7))) & 1; + pulse(16 + index * 2 + (value ? 0 : 1)); + } + return iq.buffer; +} + +test('decodes standard callsign, velocity, and altitude examples', () => { + const callsign = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098')); + assert.equal(callsign.callsign, 'KLM1023'); + assert.equal(callsign.icao, '4840D6'); + + const velocity = decodeAdsbMessage(fromHex('8D485020994409940838175B284F')); + assert.ok(Math.abs(velocity.speedKt - 159.2) < 0.1); + assert.ok(Math.abs(velocity.headingDeg - 182.88) < 0.1); + assert.equal(velocity.verticalRateFpm, -832); + + const position = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7')); + assert.equal(position.altitudeFt, 38_000); + assert.equal(position.cpr.odd, false); +}); + +test('globally decodes a valid even/odd CPR pair and expires tracks at 60 seconds', () => { + const tracks = new Map(); + const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), { + receivedAt: 1_000, + }); + const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 1_500, + }); + updateAircraftTrack(tracks, even); + const aircraft = updateAircraftTrack(tracks, odd); + assert.ok(Math.abs(aircraft.latitude - 52.26578) < 0.0001); + assert.ok(Math.abs(aircraft.longitude - 3.93891) < 0.0001); + assert.equal(aircraft.lastPositionAt, 1_500); + assert.equal(LOCAL_ADSB_STALE_MS, 60_000); + assert.equal( + pruneAircraftTracks(tracks, 61_499), + 0, + 'contact survives below 60 seconds', + ); + assert.equal( + pruneAircraftTracks(tracks, 61_500), + 1, + 'contact expires at 60 seconds', + ); + assert.equal(tracks.size, 0); +}); + +test('extracts a CRC-valid Mode S frame from synthetic 2 Msps IQ', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + assert.equal(modeSChecksum(bytes), 0); + const frames = extractAdsbMessages(synthesizeIq(bytes), 2_000_000); + assert.equal(frames.length, 1); + assert.deepEqual(frames[0], bytes); +}); + +test('accepts a valid weak frame above an elevated local noise floor', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + const frames = extractAdsbMessages( + synthesizeIq(bytes, { floor: 140, high: 150 }), + 2_000_000, + ); + assert.equal(frames.length, 1); + assert.deepEqual(frames[0], bytes); +}); + +test('accepts a real-shaped preamble with energy trailing its final pulse', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + const frames = extractAdsbMessages( + synthesizeIq(bytes, { + floor: 138, + high: 148, + preambleTail: 145, + }), + 2_000_000, + ); + assert.equal(frames.length, 1); + assert.deepEqual(frames[0], bytes); +}); + +test('preserves a Mode S frame split across consecutive USB blocks', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + const iq = new Uint8Array(synthesizeIq(bytes)); + const decoder = new AdsbStreamDecoder(); + assert.deepEqual(decoder.extract(iq.slice(0, 300).buffer, 2_000_000), []); + assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), [bytes]); + decoder.reset(); + assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), []); +}); + +test('rejects corrupt frames and unsupported sample rates', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + bytes[5] ^= 0x01; + assert.notEqual(modeSChecksum(bytes), 0); + assert.equal(decodeAdsbMessage(bytes), null); + assert.deepEqual(extractAdsbMessages(synthesizeIq(bytes), 1_024_000), []); +}); + +const AUSTIN_RECEIVER = Object.freeze({ latitude: 30.27, longitude: -97.8 }); +const fixtureFrames = readFileSync( + new URL('../data/fixtures/adsb-austin-frames.txt', import.meta.url), + 'utf8', +) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => fromHex(line.replace(/^\*|;$/g, ''))); +const dump1090 = JSON.parse( + readFileSync( + new URL( + '../data/fixtures/adsb-austin-dump1090-aircraft.json', + import.meta.url, + ), + 'utf8', + ), +); + +// The capture keeps receive order but not receive times, so frames are spaced +// 100 ms apart: every even/odd pair then falls inside the 10 s CPR window. +function decodeFixture(receiverLocation) { + const tracks = new Map(); + fixtureFrames.forEach((bytes, index) => { + const message = decodeAdsbMessage(bytes, { receivedAt: (index + 1) * 100 }); + if (message) updateAircraftTrack(tracks, message, receiverLocation); + }); + return tracks; +} + +test('real Austin frames decode to the same aircraft, callsigns and positions as dump1090', () => { + assert.equal(fixtureFrames.length, 112); + const tracks = decodeFixture(AUSTIN_RECEIVER); + const heard = [...tracks.keys()].map((icao) => icao.toLowerCase()).sort(); + assert.deepEqual(heard, dump1090.aircraft.map((entry) => entry.hex).sort()); + for (const oracle of dump1090.aircraft) { + const track = tracks.get(oracle.hex.toUpperCase()); + if (oracle.flight) + assert.equal(track.callsign, oracle.flight.trim(), oracle.hex); + if (Number.isFinite(oracle.alt_baro)) + assert.equal(track.altitudeFt, oracle.alt_baro, oracle.hex); + if (Number.isFinite(oracle.gs)) + assert.ok(Math.abs(track.speedKt - oracle.gs) < 0.5, oracle.hex); + if (Number.isFinite(oracle.track)) + assert.ok(Math.abs(track.headingDeg - oracle.track) < 0.5, oracle.hex); + if (Number.isFinite(oracle.baro_rate)) + assert.equal(track.verticalRateFpm, oracle.baro_rate, oracle.hex); + if (!Number.isFinite(oracle.lat)) continue; + assert.ok( + Math.abs(track.latitude - oracle.lat) < 0.01, + `${oracle.hex} latitude`, + ); + assert.ok( + Math.abs(track.longitude - oracle.lon) < 0.01, + `${oracle.hex} longitude`, + ); + } +}); + +test('receiver-relative CPR positions an even-only aircraft that dump1090 left unpositioned', () => { + // SKW3301 (a15c54) sent three airborne positions, all even-parity, so no + // global even/odd pair exists. dump1090 ran without a receiver location and + // could not position it; with one, the local decode places it near AUS. + const skywest = fixtureFrames.filter( + (bytes) => + decodeAdsbMessage(bytes)?.icao === 'A15C54' && + decodeAdsbMessage(bytes).cpr, + ); + assert.equal(skywest.length, 3); + assert.ok( + skywest.every((bytes) => decodeAdsbMessage(bytes).cpr.odd === false), + ); + + const located = decodeFixture(AUSTIN_RECEIVER).get('A15C54'); + assert.ok(Math.abs(located.latitude - 30.1599) < 0.01); + assert.ok(Math.abs(located.longitude + 97.7903) < 0.01); + + const unlocated = decodeFixture(null); + assert.equal(unlocated.get('A15C54').latitude, null); + const positioned = [...unlocated.values()] + .filter((track) => Number.isFinite(track.latitude)) + .map((track) => track.icao.toLowerCase()) + .sort(); + assert.deepEqual( + positioned, + dump1090.aircraft + .filter((entry) => Number.isFinite(entry.lat)) + .map((entry) => entry.hex) + .sort(), + 'without a receiver location the positioned set matches dump1090 exactly', + ); +}); + +// A second real capture over Austin with receive times: 846 extended +// squitters from six aircraft, each frame timed by interpolating between the +// frames whose decoded position matches a dump1090 fix (dump1090's `t - +// seen_pos`), plus dump1090's own positions for the same interval. +const timed = JSON.parse( + readFileSync( + new URL('../data/fixtures/adsb-austin-capture-timed.json', import.meta.url), + 'utf8', + ), +); + +function nauticalMiles(a, b) { + const toRad = Math.PI / 180; + const dLat = (b.lat - a.lat) * toRad; + const dLon = (b.lon - a.lon) * toRad; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * 3440.065 * Math.asin(Math.sqrt(h)); +} + +function replayTimed({ inject = null, receiver = timed.receiver } = {}) { + const tracks = new Map(); + const stats = {}; + const accepted = new Map(); + for (const [receivedAt, hex] of timed.frames) { + const message = decodeAdsbMessage(fromHex(hex), { receivedAt }); + if (!message) continue; + for (const extra of inject?.(message) || []) { + updateAircraftTrack(tracks, extra, receiver, stats); + } + const track = updateAircraftTrack(tracks, message, receiver, stats); + if (message.cpr && track.lastPositionAt === receivedAt) { + const key = message.icao.toLowerCase(); + if (!accepted.has(key)) accepted.set(key, []); + accepted.get(key).push({ + at: receivedAt, + lat: track.latitude, + lon: track.longitude, + }); + } + } + return { tracks, stats, accepted }; +} + +function dump1090Final(hex) { + const [, lat, lon] = timed.dump1090[hex].fixes.at(-1); + return { lat, lon }; +} + +test('identification messages decode to the dump1090 emitter category strings', () => { + const { tracks } = replayTimed(); + const decoded = Object.fromEntries( + Object.keys(timed.dump1090).map((hex) => [ + hex, + tracks.get(hex.toUpperCase()).category, + ]), + ); + assert.deepEqual(decoded, { + '0d0c07': 'A2', + a3627d: 'A3', + a0b702: 'A7', + a5b3a7: 'A3', + abe7c5: 'A3', + a27e81: 'A1', + }); + for (const [hex, oracle] of Object.entries(timed.dump1090)) + assert.equal(decoded[hex], oracle.category, hex); + // Category set D (type code 1) and C (type code 2) follow the same rule. + const message = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098')); + assert.equal(message.typeCode, 4); + assert.equal(message.category, 'A0'); +}); + +test('145TX, a rotorcraft (A7), classifies as a helicopter', () => { + const { tracks } = replayTimed(); + const heli = tracks.get('A0B702'); + assert.equal(heli.callsign, '145TX'); + assert.equal(classifyAircraft({ category: heli.category }), 'helicopter'); + assert.equal( + classifyAircraft({ category: tracks.get('A27E81').category }), + 'light', + ); +}); + +test('the speed check refuses none of the capture and every track ends on dump1090', () => { + const { tracks, stats, accepted } = replayTimed(); + assert.equal(stats.positionsRejected || 0, 0); + for (const hex of Object.keys(timed.dump1090)) { + const track = tracks.get(hex.toUpperCase()); + assert.equal(track.rejectedPositions, 0, `${hex} rejected a fix`); + const fixes = accepted.get(hex); + assert.ok(fixes.length >= 20, `${hex}: ${fixes.length} fixes`); + const delta = nauticalMiles(fixes.at(-1), dump1090Final(hex)); + assert.ok(delta < 0.3, `${hex} ends ${delta.toFixed(3)} nm from dump1090`); + } +}); + +test("N26VB's 1.0 nm step spans a 27 s reception gap at its reported speed", () => { + // The field report read this as a bad decode. The step follows single odd + // frames decoded against the receiver location, which dump1090 (run without + // one) never positions; the implied speed matches the reported 134 kt. + const { tracks, accepted } = replayTimed(); + const fixes = accepted.get('a27e81'); + let widest = null; + for (let index = 1; index < fixes.length; index += 1) { + const step = nauticalMiles(fixes[index - 1], fixes[index]); + if (!widest || step > widest.step) + widest = { + step, + seconds: (fixes[index].at - fixes[index - 1].at) / 1000, + }; + } + assert.ok(Math.abs(widest.step - 1.0) < 0.05, `${widest.step} nm`); + assert.ok(widest.seconds > 25, `${widest.seconds} s`); + const impliedKt = widest.step / (widest.seconds / 3600); + assert.ok(Math.abs(impliedKt - 134) < 10, `${impliedKt.toFixed(0)} kt`); + assert.equal(tracks.get('A27E81').rejectedPositions, 0); +}); + +test('a corrupt even/odd pair is refused and the track stays on dump1090', () => { + // Pair a foreign odd frame (ENY3344, 27 nm away) with N26VB's fresh even + // frame: the global decode lands far off the track, as a mixed-up pair does. + const foreign = timed.frames + .map(([, hex]) => decodeAdsbMessage(fromHex(hex))) + .find((message) => message?.icao === 'A3627D' && message.cpr?.odd); + let injected = 0; + const { tracks, stats, accepted } = replayTimed({ + inject(message) { + if (message.icao !== 'A27E81' || message.cpr?.odd !== false) return []; + if (injected >= 2) return []; + injected += 1; + return [ + { + ...foreign, + icao: 'A27E81', + receivedAt: message.receivedAt - 1, + cpr: { ...foreign.cpr, receivedAt: message.receivedAt - 1 }, + }, + ]; + }, + }); + const track = tracks.get('A27E81'); + assert.ok(track.rejectedPositions >= 1); + assert.equal(stats.positionsRejected, track.rejectedPositions); + for (const fix of accepted.get('a27e81')) + assert.ok( + nauticalMiles(fix, { lat: 30.32, lon: -97.91 }) < 3, + 'no accepted fix leaves the track', + ); + assert.ok( + nauticalMiles(accepted.get('a27e81').at(-1), dump1090Final('a27e81')) < 0.3, + ); +}); + +test('a lone frame decodes relative to the aircraft own recent position', () => { + const tracks = new Map(); + const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), { + receivedAt: 1_000, + }); + const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 1_500, + }); + updateAircraftTrack(tracks, even); + updateAircraftTrack(tracks, odd); + // 20 s later the pair has expired and there is no receiver location. + const later = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 20_000, + }); + const track = updateAircraftTrack(tracks, later, null); + assert.equal(track.lastPositionAt, 20_000); + assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001); + assert.ok(Math.abs(track.longitude - 3.93891) < 0.0001); +}); + +test('three refused global fixes in a row re-anchor the track', () => { + const tracks = new Map(); + const stats = {}; + updateAircraftTrack( + tracks, + decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), { + receivedAt: 1_000, + }), + ); + updateAircraftTrack( + tracks, + decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 1_500, + }), + ); + // Austin pairs relabelled as this aircraft: global decodes 8,000 km away. + const austin = timed.frames + .map(([, hex]) => decodeAdsbMessage(fromHex(hex))) + .filter((message) => message?.icao === 'A0B702' && message.cpr); + const firstOdd = austin.find((message) => message.cpr.odd); + const firstEven = austin.find((message) => !message.cpr.odd); + let at = 2_000; + const feed = (message) => { + at += 500; + return updateAircraftTrack( + tracks, + { + ...message, + icao: '40621D', + receivedAt: at, + cpr: { ...message.cpr, receivedAt: at }, + }, + null, + stats, + ); + }; + feed(firstEven); + let track = feed(firstOdd); + assert.equal(track.rejectedPositions, 2); + assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001, 'still anchored'); + track = feed(firstEven); + assert.equal(stats.positionsRejected, 2, 'the third global fix re-anchors'); + assert.ok(Math.abs(track.latitude - 30.2) < 0.2, 'moved to the new stream'); + assert.equal(track.rejectStreak, 0); +}); + +// Surface position examples from "The 1090MHz Riddle" (J. Sun), surface +// position chapter: ICAO C8200A, even 8CC8200A3AC8F009BCDEF2..., odd +// 8FC8200A3AB8F5F893096B..., receiver (-43.496, 172.558); published result +// (-43.48564, 172.53942). The book prints the parity field as zeros, so the +// 24-bit parity is recomputed here to make the frames CRC-valid. +function withParity(hex) { + const bytes = fromHex(hex); + // The Mode S parity is the CRC remainder of the first 88 bits. + const parity = modeSChecksum(bytes.slice(0, 11)); + bytes[11] = (parity >> 16) & 0xff; + bytes[12] = (parity >> 8) & 0xff; + bytes[13] = parity & 0xff; + assert.equal(modeSChecksum(bytes), 0); + return bytes; +} +const SURFACE_EVEN = withParity('8CC8200A3AC8F009BCDEF2000000'); +const SURFACE_ODD = withParity('8FC8200A3AB8F5F893096B000000'); +const SURFACE_RECEIVER = { latitude: -43.496, longitude: 172.558 }; + +test('surface position messages decode movement, track and ground state', () => { + // Odd frame ME field, read by hand: TC 00111 (7), MOV 0101011 (43), + // S 1, TRK 0001111 (15), T 0, F 1. MOV 39–93 is 15 kt + 1 kt per step + // (43 → 19 kt); TRK is in 360/128° steps (15 → 42.1875°). + const odd = decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }); + assert.equal(odd.icao, 'C8200A'); + assert.equal(odd.typeCode, 7); + assert.equal(odd.onGround, true); + assert.equal(odd.speedKt, 19); + assert.equal(odd.headingDeg, 42.1875); + assert.equal(odd.altitudeFt, null, 'no altitude on the surface'); + assert.equal(odd.cpr.surface, true); + assert.equal(odd.cpr.odd, true); + // Even frame: MOV 0101100 (44 → 20 kt), same track. + const even = decodeAdsbMessage(SURFACE_EVEN); + assert.equal(even.speedKt, 20); + assert.equal(even.cpr.odd, false); + + // A real Schiphol frame with a valid parity (Riddle ground-speed example): + // MOV 0101010 (42 → 18 kt), S 1, TRK 0110010 (50 → 140.625°). + const schiphol = decodeAdsbMessage(fromHex('8C4841753AAB238733C8CD4020B1')); + assert.equal(schiphol.icao, '484175'); + assert.equal(schiphol.speedKt, 18); + assert.equal(schiphol.headingDeg, 140.625); + + // MOV 1 is "stopped"; MOV 0 and a track status of 0 carry no value. + const stopped = withParity('8CC8200A3810F009BCDEF2000000'); + const still = decodeAdsbMessage(stopped); + assert.equal(still.speedKt, 0); + assert.equal(still.headingDeg, undefined, 'track status 0: no track'); +}); + +test('a surface even/odd pair decodes to the published position and marks the aircraft on the ground', () => { + const tracks = new Map(); + // Airborne first: the landing must clear the old altitude. + const airborne = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7')); + updateAircraftTrack( + tracks, + // Its altitude only: the CPR belongs to another aircraft. + { ...airborne, icao: 'C8200A', receivedAt: 0, cpr: null }, + SURFACE_RECEIVER, + ); + updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }), + SURFACE_RECEIVER, + ); + const track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }), + SURFACE_RECEIVER, + ); + assert.ok( + Math.abs(track.latitude - -43.48564) < 0.00001, + `${track.latitude}`, + ); + assert.ok( + Math.abs(track.longitude - 172.53942) < 0.00001, + `${track.longitude}`, + ); + assert.equal(track.lastPositionAt, 2_000); + assert.equal(track.onGround, true); + assert.equal(track.altitudeFt, null); + assert.equal(track.speedKt, 19); + const record = recordFromDecoderTrack(track); + assert.equal(record.onGround, true); + assert.equal(record.altitudeFt, null); + assert.ok(Math.abs(record.lat - -43.48564) < 0.00001); + + // An airborne position afterwards clears the ground state. + const departed = updateAircraftTrack( + tracks, + { ...airborne, icao: 'C8200A', receivedAt: 3_000, cpr: null }, + SURFACE_RECEIVER, + ); + assert.equal(departed.onGround, false); + assert.equal(departed.altitudeFt, 38_000); +}); + +test('a surface track is seeded by a pair; a single frame then decodes against its own fix', () => { + // The receiver is not established to be within 45 NM of the aircraft, so a + // lone surface frame does not decode against it (even though this one is). + const receiver = { latitude: -43.5, longitude: 172.5 }; + const tracks = new Map(); + let track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }), + receiver, + ); + assert.equal(track.lastPositionAt, null, 'no single-frame receiver fix'); + // The even/odd pair seeds the track at the published position. + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }), + receiver, + ); + assert.ok(Math.abs(track.latitude - -43.48564) < 0.00001); + assert.ok(Math.abs(track.longitude - 172.53942) < 0.00001); + // With no receiver location, the aircraft's own recent fix is the reference + // (the other frame of the pair is too old to pair with). + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 40_000 }), + null, + ); + assert.equal(track.lastPositionAt, 40_000); + assert.ok(Math.abs(track.latitude - -43.4856) < 0.001); + assert.ok(Math.abs(track.longitude - 172.5394) < 0.001); + // An own fix the aircraft may have left by 45 NM or more (unknown speed, + // four minutes old) is no single-frame reference either. + tracks.set('C8200A', { ...track, speedKt: null, category: null }); + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 280_000 }), + null, + ); + assert.equal(track.lastPositionAt, 40_000, 'reference too old to trust'); + // Without any reference a lone surface frame is ambiguous: no position. + const blind = updateAircraftTrack( + new Map(), + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 1_000 }), + null, + ); + assert.equal(blind.lastPositionAt, null); + assert.equal(blind.onGround, true); +}); + +// "The 1090MHz Riddle", surface position chapter: Schiphol pair of ICAO +// 484175, published position about (52.3206, 4.7347). +const SCHIPHOL_EVEN = fromHex('8C4841753AAB238733C8CD4020B1'); +const SCHIPHOL_ODD = fromHex('8C4841753A9A153237AEF0F275BE'); + +test('a far receiver never places a lone surface frame; the next valid pair seeds the track', () => { + // About 80 NM from the airport: beyond half a surface zone (45 NM). + const receiver = { latitude: 51, longitude: 4.375 }; + const tracks = new Map(); + let track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SCHIPHOL_EVEN, { receivedAt: 1_000 }), + receiver, + ); + assert.equal(track.lastPositionAt, null, 'no position from one frame'); + assert.equal(track.latitude, null, 'never (50.823040, 4.602622)'); + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SCHIPHOL_ODD, { receivedAt: 2_000 }), + receiver, + ); + assert.equal(track.lastPositionAt, 2_000); + assert.equal(track.rejectedPositions, 0, 'the valid pair is accepted'); + assert.ok(Math.abs(track.latitude - 52.3206) < 0.002, `${track.latitude}`); + assert.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`); + assert.equal(track.onGround, true); +}); diff --git a/src/sdr/controller.js b/src/sdr/controller.js new file mode 100644 index 000000000..1c86658ec --- /dev/null +++ b/src/sdr/controller.js @@ -0,0 +1,1367 @@ +import { RTL2832U_Provider } from '@jtarrio/webrtlsdr/rtlsdr.js'; +import { findDirectionalFmPeak } from './spectrum.js'; +import { + SDR_GAIN_DEFAULTS, + normalizeSdrGain, + readSdrGainSettings, + tunerGainValue, + writeSdrGainSettings, +} from './gain.js'; +import { + RTL_SDR_USB_FILTERS, + createRememberingWebUsb, + createSdrDeviceMemory, + sameSdrDevice, + sdrDeviceIdentity, + sdrDeviceLabel, + selectAuthorizedSdrDevice, +} from './usbDevices.js'; +import { summarizeLocalAdsb } from '../sources/adsbRecords.js'; + +export { createRememberingWebUsb } from './usbDevices.js'; + +const FM_MIN_HZ = 87_500_000; +const FM_MAX_HZ = 108_000_000; +const READ_SAMPLE_COUNT = 32_768; +const RATE_WINDOW_MS = 1_000; +// A bulk read that has not completed in this long is treated as a stalled +// USB transfer: the session is torn down instead of waiting forever. +export const SDR_READ_STALL_MS = 5_000; +// Upper bound on a graceful close queued behind other USB work; past it the +// raw USB device is closed directly, which aborts any pending transfer. The +// raw close is bounded by the same deadline, so a teardown settles within +// twice this even when `USBDevice.close()` itself never settles. +export const SDR_CLOSE_TIMEOUT_MS = 2_000; + +/** Tuning presets per receiver mode; gain is a separate per-mode setting. */ +export const SDR_RECEIVER_PRESETS = Object.freeze({ + fm: Object.freeze({ + frequencyHz: 98_500_000, + sampleRate: 2_048_000, + ppm: 0, + rtlAgc: false, + }), + adsb: Object.freeze({ + frequencyHz: 1_090_000_000, + sampleRate: 2_000_000, + ppm: 0, + rtlAgc: false, + }), +}); + +const FM_DEFAULT_HZ = SDR_RECEIVER_PRESETS.fm.frequencyHz; +const FM_SAMPLE_RATE = SDR_RECEIVER_PRESETS.fm.sampleRate; +const ADSB_FREQUENCY_HZ = SDR_RECEIVER_PRESETS.adsb.frequencyHz; +const ADSB_SAMPLE_RATE = SDR_RECEIVER_PRESETS.adsb.sampleRate; + +function normalizeMode(mode) { + return mode === 'adsb' ? 'adsb' : 'fm'; +} + +function defaultStorage() { + try { + return globalThis.localStorage || null; + } catch { + return null; + } +} + +async function setRtlAgc(device, enabled) { + const method = device?._enableRtlAgc; + if (typeof method === 'function') await method.call(device, enabled); +} + +// Guarded stand-in -> the WebUSB `USBDevice` it forwards to. Ownership checks +// compare real devices, never the per-acquisition stand-ins. +const GUARDED_USB_DEVICES = new WeakMap(); + +/** The real WebUSB `USBDevice` behind a guarded stand-in (or itself). */ +function unwrapUsb(usbDevice) { + return (usbDevice && GUARDED_USB_DEVICES.get(usbDevice)) || usbDevice; +} + +/** The WebUSB `USBDevice` under an opened RTL2832U, when reachable. */ +function rawUsbDevice(device, provider) { + return unwrapUsb(device?.com?.device || provider?.device || null); +} + +/** Close a raw USB device, ignoring an already-closed or vanished one. */ +async function forceCloseUsb(usbDevice) { + if (!usbDevice || typeof usbDevice.close !== 'function') return; + try { + await usbDevice.close(); + } catch { + /* already closed or unplugged */ + } +} + +function closedUsbError() { + return Promise.reject(new Error('RTL-SDR session was closed')); +} + +/** + * A stand-in for `usbDevice` whose methods reject once `isFenced()` holds, + * so an abandoned acquisition that is still running can never open, claim, + * write to or close the device a newer session has opened since (WebUSB + * hands every opener the same `USBDevice`). + */ +function guardUsbDevice(usbDevice, isFenced) { + const guarded = new Proxy(usbDevice, { + get(target, property) { + const value = Reflect.get(target, property, target); + if (typeof value !== 'function') return value; + return (...args) => + isFenced() ? closedUsbError() : value.apply(target, args); + }, + }); + GUARDED_USB_DEVICES.set(guarded, usbDevice); + return guarded; +} + +/** + * Wrap a WebUSB entry point for one acquisition: every `USBDevice` it hands + * out is reported to `onDevice` and handed on guarded by `isFenced`. The + * RTL-SDR provider opens and initializes the device inside `get()`, so this + * is the only way to reach a device whose initialization failed before + * `get()` returned it, and the only way to stop a selection that resolves + * after the acquisition was abandoned from initializing that device. + */ +function captureRequestedUsb(webUsb, { onDevice, isFenced }) { + if (!webUsb || typeof webUsb.requestDevice !== 'function') return webUsb; + return new Proxy(webUsb, { + get(target, property) { + if (property === 'requestDevice') { + return async (...args) => { + const usbDevice = await target.requestDevice(...args); + if (!usbDevice) return usbDevice; + // A late selection belongs to no session: the device it names may + // already be open for a newer one. + if (isFenced()) + throw new Error('RTL-SDR device selection was abandoned'); + onDevice(usbDevice); + return guardUsbDevice(usbDevice, isFenced); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** + * Stand-in for the WebUSB device of an abandoned session: every method + * rejects, so nothing still running for that session reaches the hardware. + */ +const CLOSED_USB_DEVICE = new Proxy( + {}, + { + get: (_target, property) => + property === 'then' ? undefined : closedUsbError, + }, +); + +/** + * Detach an abandoned RTL2832U from its WebUSB device. WebUSB hands a later + * session the SAME `USBDevice` object, so a graceful close (or any other + * transfer) of the old session that resumes after we gave up on it would + * otherwise act on the new session's open device. + */ +function fenceDevice(device) { + const com = device?.com; + if (com && typeof com === 'object' && 'device' in com) { + try { + com.device = CLOSED_USB_DEVICE; + } catch { + /* read-only: nothing to fence */ + } + } +} + +/** Resolve with the operation's outcome, or reject once `ms` elapses. */ +function withDeadline(promise, ms, message) { + let timer = null; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(message); + error.name = 'TimeoutError'; + reject(error); + }, ms); + }); + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)); +} + +function publicAircraft(aircraft) { + return Array.isArray(aircraft) ? aircraft.map((entry) => ({ ...entry })) : []; +} + +function clampFmFrequency(value) { + return Math.max( + FM_MIN_HZ, + Math.min(FM_MAX_HZ, Math.round(Number(value) / 100_000) * 100_000), + ); +} + +function describeConnectionError(error) { + const message = String(error?.message || error || '').trim(); + if (/No device was selected|NotFoundError/i.test(message)) + return 'No RTL-SDR device selected'; + if ( + /does not support the WebUSB|usb/i.test(message) && + /support/i.test(message) + ) { + return 'WebUSB requires desktop Chrome or Edge on localhost/HTTPS'; + } + if (/access|permission|claim|busy/i.test(message)) { + return 'RTL-SDR access failed; close other SDR apps and check USB permissions'; + } + return message || 'RTL-SDR connection failed'; +} + +function streamingMessage(mode) { + return mode === 'adsb' + ? 'Receiving 1090 MHz ADS-B' + : 'Receiving broadcast FM'; +} + +/** + * Browser-local RTL-SDR session shared by the FM controls and the Local ADS-B + * layer. `state.aircraft` holds source-agnostic records from + * `src/sources/adsbRecords.js`. + */ +export class SdrController { + /** + * @param {object} [options] + * @param {(webusb: USB) => {get(): Promise}} [options.providerFactory] + * Builds the RTL-SDR provider around the selecting WebUSB wrapper. + * @param {Storage|null} [options.storage] Gain and device preferences. + * @param {USB|null} [options.webUsb] WebUSB entry point. + * @param {number} [options.readStallMs] Deadline for one bulk sample read. + * @param {number} [options.closeTimeoutMs] Deadline for a graceful close. + */ + constructor({ + providerFactory = null, + readStallMs = SDR_READ_STALL_MS, + closeTimeoutMs = SDR_CLOSE_TIMEOUT_MS, + storage = defaultStorage(), + webUsb = typeof navigator !== 'undefined' ? navigator.usb : null, + } = {}) { + const webUsbSupported = Boolean(webUsb); + this._webUsb = webUsb || null; + this._storage = storage; + this._gainByMode = readSdrGainSettings(storage); + this._deviceMemory = createSdrDeviceMemory(storage); + this.state = { + webUsbSupported, + connected: false, + status: webUsbSupported ? 'idle' : 'unsupported', + message: webUsbSupported + ? 'Connect an RTL-SDR to begin.' + : 'WebUSB is unavailable in this browser', + mode: 'fm', + frequencyHz: FM_DEFAULT_HZ, + sampleRate: FM_SAMPLE_RATE, + volume: 0.8, + gain: this._gainByMode.fm, + seeking: false, + seekMessage: '', + snr: null, + aircraft: [], + decodedMessages: 0, + messagesPerSecond: null, + aircraftHeard: 0, + aircraftPositioned: 0, + positionsRejected: 0, + deviceLabel: null, + receiverLocation: null, + locationStatus: 'unknown', + audioState: 'idle', + samplesPerSecond: 0, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + }; + this._listeners = new Set(); + this._provider = null; + this._device = null; + this._deviceIdentity = null; + this._forcePicker = false; + this._worker = null; + this._audioContext = null; + this._audioNode = null; + this._readStallMs = readStallMs; + this._closeTimeoutMs = closeTimeoutMs; + this._usbTail = Promise.resolve(); + // Bumped whenever a stalled queue is abandoned; work queued in an older + // epoch refuses to touch whatever device is current when it finally runs. + this._usbEpoch = 0; + this._readGeneration = 0; + // Bumped by every connect() and stop(); a connect, mode change or + // configuration whose token is stale after an await has been superseded + // (stopped, destroyed or restarted) and must not publish its result. + this._sessionToken = 0; + // The device teardown in progress, if any. Single-flight: a stop() during + // it joins it, and connect() waits for it before opening a device, so an + // old session's close can never race a new session's open. + this._teardown = null; + // Device acquisitions (`provider.get()` plus the cleanup of whatever it + // opened) that have not handed their device to a session yet. A connect + // waits for older ones, so an old acquisition can never close the WebUSB + // device a newer session opened. + this._acquisitions = new Set(); + this._acquisitionSequence = 0; + this._destroyed = false; + this._fmFrequencyHz = FM_DEFAULT_HZ; + this._spectrum = null; + this._spectrumSequence = 0; + this._spectrumWaiters = new Set(); + this._seekToken = 0; + // Every provider opens its device through the selecting WebUSB wrapper. + const createProvider = + providerFactory || ((webusb) => new RTL2832U_Provider({ webusb })); + this._providerFactory = ({ + onUsbDevice = () => {}, + isFenced = () => false, + } = {}) => + createProvider( + captureRequestedUsb( + createRememberingWebUsb(this._webUsb, { + getMode: () => this.state.mode, + memory: this._deviceMemory, + consumeForcePicker: () => { + const force = this._forcePicker; + this._forcePicker = false; + return force; + }, + onSelected: (device) => { + if (!isFenced()) this._deviceIdentity = sdrDeviceIdentity(device); + }, + }), + { onDevice: onUsbDevice, isFenced }, + ), + ); + this._sampleWindowStartedAt = 0; + this._sampleWindowSamples = 0; + this._messageWindowStartedAt = 0; + this._messageWindowCount = 0; + } + + getState() { + return { ...this.state, aircraft: publicAircraft(this.state.aircraft) }; + } + + subscribe(listener) { + if (typeof listener !== 'function') return () => {}; + this._listeners.add(listener); + listener(this.getState()); + return () => this._listeners.delete(listener); + } + + _setState(patch) { + Object.assign(this.state, patch); + const snapshot = this.getState(); + for (const listener of this._listeners) { + try { + listener(snapshot); + } catch (error) { + console.warn('[SDR] State listener failed:', error); + } + } + } + + _queueUsb(operation) { + const epoch = this._usbEpoch; + const run = () => { + if (epoch !== this._usbEpoch) + throw new Error('USB work abandoned after a stalled transfer'); + return operation(); + }; + const queued = this._usbTail.then(run, run); + this._usbTail = queued.catch(() => {}); + return queued; + } + + /** Abandon a queue that a stalled transfer is blocking. */ + _resetUsbQueue() { + this._usbEpoch += 1; + this._usbTail = Promise.resolve(); + } + + _ensureWorker() { + if (this._worker) return this._worker; + const worker = new Worker(new URL('./sdr.worker.js', import.meta.url), { + type: 'module', + }); + this._worker = worker; + worker.onmessage = (event) => { + if (this._worker !== worker) return; + this._handleWorkerMessage(event.data || {}); + }; + worker.onerror = (event) => { + if (this._worker !== worker) return; + console.warn('[SDR] Decoder worker error:', event?.message || event); + // A failed worker (e.g. its module did not load) never recovers: drop + // it so the next session builds and configures a fresh one. + this._discardWorker(); + this._setState({ status: 'error', message: 'SDR decoder worker failed' }); + if (this._device) void this.stop({ preserveMessage: true }); + }; + return worker; + } + + _discardWorker() { + const worker = this._worker; + this._worker = null; + if (!worker) return; + worker.onmessage = null; + worker.onerror = null; + try { + worker.terminate(); + } catch { + /* already gone */ + } + } + + _configureWorker() { + this._ensureWorker().postMessage({ + type: 'configure', + mode: this.state.mode, + sampleRate: this.state.sampleRate, + location: this.state.receiverLocation, + }); + } + + async _ensureAudio() { + if (typeof AudioContext === 'undefined') return false; + if (!this._audioContext) { + this._audioContext = new AudioContext({ + sampleRate: 48_000, + latencyHint: 'interactive', + }); + this._audioContext.onstatechange = () => { + this._setState({ audioState: this._audioContext?.state || 'closed' }); + }; + } + if (!this._audioNode) { + await this._audioContext.audioWorklet.addModule( + new URL('./sdrAudioWorklet.js', import.meta.url), + ); + this._audioNode = new AudioWorkletNode( + this._audioContext, + 'gev-sdr-audio-player', + { + outputChannelCount: [1], + }, + ); + this._audioNode.connect(this._audioContext.destination); + this._audioNode.port.postMessage({ + type: 'volume', + value: this.state.volume, + }); + } + if (this._audioContext.state !== 'running') + await this._audioContext.resume(); + const audioState = this._audioContext.state; + this._setState({ audioState }); + return audioState === 'running'; + } + + _handleWorkerMessage(message) { + const diagnostics = message.diagnostics; + if (diagnostics && Number.isFinite(diagnostics.workerBlocks)) { + this._setState({ + workerBlocks: diagnostics.workerBlocks, + iqLevelDbfs: Number.isFinite(diagnostics.iqLevelDbfs) + ? diagnostics.iqLevelDbfs + : this.state.iqLevelDbfs, + audioLevelDbfs: Number.isFinite(diagnostics.audioLevelDbfs) + ? diagnostics.audioLevelDbfs + : this.state.audioLevelDbfs, + }); + } + if (message.type === 'fm') { + if ( + message.samples instanceof Float32Array && + this._audioNode && + !this.state.seeking + ) { + this._audioNode.port.postMessage( + { type: 'samples', samples: message.samples }, + [message.samples.buffer], + ); + } + if (message.bins instanceof Float32Array) { + this._spectrum = message.bins; + this._spectrumSequence += 1; + for (const waiter of this._spectrumWaiters) + waiter(this._spectrumSequence); + this._spectrumWaiters.clear(); + } + if (Number.isFinite(message.snr)) this._setState({ snr: message.snr }); + return; + } + if (message.type === 'adsb') { + this._applyAdsbUpdate(message, Date.now()); + } + } + + /** Merge one decoder update: records, cumulative and per-second counts. */ + _applyAdsbUpdate(message, now) { + const decodedCount = Math.max(0, Number(message.decodedCount) || 0); + if (!this._messageWindowStartedAt) this._messageWindowStartedAt = now; + this._messageWindowCount += decodedCount; + const elapsed = now - this._messageWindowStartedAt; + const patch = { + aircraft: publicAircraft(message.aircraft), + decodedMessages: this.state.decodedMessages + decodedCount, + }; + // Cumulative count of fixes the decoder's speed check refused. + if (Number.isFinite(message.positionsRejected)) + patch.positionsRejected = Math.max(0, message.positionsRejected); + if (elapsed >= RATE_WINDOW_MS) { + patch.messagesPerSecond = + Math.round(((this._messageWindowCount * 1_000) / elapsed) * 10) / 10; + this._messageWindowStartedAt = now; + this._messageWindowCount = 0; + } + const { heard, positioned } = summarizeLocalAdsb(patch.aircraft, now); + patch.aircraftHeard = heard; + patch.aircraftPositioned = positioned; + this._setState(patch); + } + + _resetAdsbCounters() { + this._messageWindowStartedAt = 0; + this._messageWindowCount = 0; + return { messagesPerSecond: null }; + } + + /** + * Whether the session identified by `token` still owns `device`. + * @param {number} token Session token captured before an await. + * @param {object} device The device that session was configuring. + */ + _owns(token, device) { + return ( + !this._destroyed && + token === this._sessionToken && + Boolean(device) && + device === this._device + ); + } + + /** + * Tune the open device for a mode and configure the decoder worker. + * @returns {Promise} False when the session was superseded (stopped, + * torn down after a stall, or replaced) during configuration; nothing is + * published then. + */ + async _configureDevice(mode, frequencyHz, token = this._sessionToken) { + const preset = + mode === 'adsb' ? SDR_RECEIVER_PRESETS.adsb : SDR_RECEIVER_PRESETS.fm; + const sampleRate = preset.sampleRate; + const tunedFrequency = + mode === 'adsb' ? preset.frequencyHz : clampFmFrequency(frequencyHz); + const gain = this._gainByMode[mode]; + const device = this._device; + if (!this._owns(token, device)) return false; + const owned = await this._queueUsb(async () => { + const steps = [ + () => device.setSampleRate(sampleRate), + () => device.setFrequencyCorrection(preset.ppm), + () => device.setCenterFrequency(tunedFrequency), + () => device.setGain(tunerGainValue(gain)), + () => setRtlAgc(device, preset.rtlAgc), + () => device.resetBuffer(), + ]; + const results = []; + for (const step of steps) { + if (!this._owns(token, device)) return false; + results.push(await step()); + } + if (!this._owns(token, device)) return false; + const [actualRate, , actualFrequency] = results; + this._setState({ + sampleRate: Number.isFinite(actualRate) ? actualRate : sampleRate, + frequencyHz: Number.isFinite(actualFrequency) + ? actualFrequency + : tunedFrequency, + gain, + }); + return true; + }); + if (!owned || !this._owns(token, device)) return false; + this._configureWorker(); + this._audioNode?.port.postMessage({ type: 'clear' }); + return true; + } + + async connect(mode = this.state.mode) { + if (this._destroyed) return false; + if (!this.state.webUsbSupported) { + this._setState({ + status: 'unsupported', + message: 'WebUSB requires desktop Chrome or Edge', + }); + return false; + } + if (this._device) return this.setMode(mode); + const token = ++this._sessionToken; + const superseded = () => this._destroyed || token !== this._sessionToken; + this._setState({ + status: 'connecting', + message: 'Waiting for an RTL-SDR device…', + }); + let acquisition = null; + try { + const nextMode = normalizeMode(mode); + this._setState({ mode: nextMode, gain: this._gainByMode[nextMode] }); + // Resume audio completely before opening the USB chooser. A context that + // has only been constructed can still be suspended after the chooser + // consumes the click activation. + if (nextMode === 'fm' && !(await this._ensureAudio())) { + throw new Error( + 'Browser audio is suspended; press Connect again to resume it', + ); + } + if (superseded()) return false; + // An old session's device may still be closing, or an older connect may + // still be opening or cleaning up the same WebUSB device: opening it now + // would let that close act on this session. + await this._awaitUsbOwnership(superseded); + if (superseded()) return false; + acquisition = this._beginAcquisition(); + const device = await acquisition.provider.get(); + acquisition.device = device || null; + // stop() or destroy() ran while the picker/open was pending: the late + // device belongs to no session, so close it without publishing state. + if (superseded()) { + await this._discardAcquisition(acquisition, device); + return false; + } + this._provider = acquisition.provider; + this._device = device; + // The session owns the device now; teardown takes over from here. + this._endAcquisition(acquisition); + acquisition = null; + const configured = await this._configureDevice( + this.state.mode, + this._fmFrequencyHz, + token, + ); + // A stop() during configuration already closed this device. + if (!configured || superseded()) return false; + this._readGeneration += 1; + this._sampleWindowStartedAt = Date.now(); + this._sampleWindowSamples = 0; + this._setState({ + connected: true, + status: 'streaming', + message: streamingMessage(this.state.mode), + deviceLabel: this._deviceIdentity + ? sdrDeviceLabel(this._deviceIdentity) + : null, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + ...this._resetAdsbCounters(), + }); + void this._readLoop(this._readGeneration); + return true; + } catch (error) { + // provider.get() failed after it may already have opened and claimed + // the raw device (a failed initialization transfer): close whatever it + // opened, whether or not this connect was superseded meanwhile. + if (acquisition) await this._discardAcquisition(acquisition, null); + if (superseded()) { + // An installed device was closed by the stop() that superseded us. + return false; + } + console.warn('[SDR] Connection failed:', error); + const failedDevice = this._device; + const failedProvider = this._provider; + this._device = null; + this._provider = null; + // Best-effort failed-open cleanup, bounded and fenced like stop(). + if (failedDevice) + await this._teardownDevice(failedDevice, failedProvider); + if (superseded()) return false; + this._setState({ + connected: false, + status: 'error', + message: describeConnectionError(error), + }); + return false; + } + } + + /** + * Start one device acquisition. Its provider reports every WebUSB device + * it is handed, so a device opened by a `get()` that later fails can still + * be closed. + */ + _beginAcquisition() { + let settle = null; + const acquisition = { + sequence: ++this._acquisitionSequence, + usbDevices: new Set(), + provider: null, + // The RTL2832U its get() returned, once it has. + device: null, + ended: false, + // Superseded: from here on its provider cannot reach the hardware. + abandoned: false, + // A newer connect stopped waiting for it to settle. + waitedOut: false, + settled: new Promise((resolve) => { + settle = resolve; + }), + settle, + }; + acquisition.provider = this._providerFactory({ + onUsbDevice: (usbDevice) => { + acquisition.usbDevices.add(usbDevice); + }, + isFenced: () => acquisition.abandoned, + }); + this._acquisitions.add(acquisition); + return acquisition; + } + + _endAcquisition(acquisition) { + if (acquisition.ended) return; + acquisition.ended = true; + this._acquisitions.delete(acquisition); + acquisition.settle(); + } + + /** Every raw WebUSB device an acquisition opened or may have opened. */ + _acquisitionUsbDevices(acquisition, device = null) { + const usbDevices = new Set(acquisition.usbDevices); + const provider = acquisition.provider; + for (const usbDevice of [ + rawUsbDevice(device || acquisition.device, provider), + unwrapUsb(provider?.device), + ]) { + if (usbDevice && typeof usbDevice === 'object') usbDevices.add(usbDevice); + } + return usbDevices; + } + + /** + * Whether a raw WebUSB device belongs to someone other than `acquisition`: + * the installed session, or a newer acquisition that reopened it (WebUSB + * hands every opener the same `USBDevice`). + */ + _usbClaimedElsewhere(usbDevice, acquisition) { + if (!usbDevice) return false; + const installed = this._device + ? [ + rawUsbDevice(this._device, this._provider), + unwrapUsb(this._provider?.device), + ] + : []; + if (installed.includes(usbDevice)) return true; + for (const other of this._acquisitions) { + if ( + other !== acquisition && + other.sequence > acquisition.sequence && + this._acquisitionUsbDevices(other).has(usbDevice) + ) + return true; + } + return false; + } + + /** Close raw WebUSB devices, each bounded by the close deadline. */ + async _forceCloseUsbDevices(usbDevices) { + await Promise.all( + [...usbDevices].map((usbDevice) => + withDeadline( + forceCloseUsb(usbDevice), + this._closeTimeoutMs, + 'RTL-SDR USB close timed out', + ).catch((error) => { + console.warn('[SDR] USB close did not settle:', error); + }), + ), + ); + } + + /** + * Close everything an acquisition that will not become a session opened: + * the RTL2832U it returned (a superseded connect), or only the raw WebUSB + * device a failed `get()` left open. A raw device that another session + * has reopened meanwhile is left alone. Bounded; never throws. + */ + async _discardAcquisition(acquisition, device) { + if (acquisition.ended) return; + try { + const usbDevices = this._acquisitionUsbDevices(acquisition, device); + const ownRaw = rawUsbDevice(device, acquisition.provider); + const claimedElsewhere = (usbDevice) => + this._usbClaimedElsewhere(usbDevice, acquisition); + if (device) { + // A newer connect that stopped waiting may be opening this device + // right now: never let this old handle reach it gracefully. + if (acquisition.waitedOut) fenceDevice(device); + if (claimedElsewhere(ownRaw)) fenceDevice(device); + else + await this._closeDevice(device, acquisition.provider, { + queued: false, + // Re-checked when the fallback comes due: a newer session may + // have opened the device while the graceful close stalled. + mayForceClose: (usbDevice) => !claimedElsewhere(usbDevice), + }); + } + // Without an RTL2832U (a failed get()), every raw device is closed + // directly; with one, its own raw device was handled above. + const remaining = [...usbDevices].filter( + (usbDevice) => + !(device && usbDevice === ownRaw) && + !this._usbClaimedElsewhere(usbDevice, acquisition), + ); + await this._forceCloseUsbDevices(remaining); + } catch (error) { + console.warn('[SDR] Acquisition cleanup failed:', error); + } finally { + this._endAcquisition(acquisition); + } + } + + /** + * Abort a superseded acquisition that is still initializing: closing the + * raw device it opened fails its pending transfers, so its `get()` settles + * (and cleans up) promptly instead of holding up the next session. + */ + _abandonAcquisitions() { + for (const acquisition of this._acquisitions) { + if (acquisition.abandoned) continue; + acquisition.abandoned = true; + const usbDevices = [...this._acquisitionUsbDevices(acquisition)].filter( + (usbDevice) => !this._usbClaimedElsewhere(usbDevice, acquisition), + ); + if (usbDevices.length) void this._forceCloseUsbDevices(usbDevices); + } + } + + /** + * Wait until no teardown is closing a device and no older acquisition can + * still open or close one. An older acquisition is abandoned first and + * waited for within the close deadlines; one that still has not settled + * (a `get()` that ignores the closed device) stays fenced by + * `_usbClaimedElsewhere`, so it cannot close this session's device later. + */ + async _awaitUsbOwnership(superseded) { + const waitedOut = new Set(); + for (;;) { + if (this._teardown) { + await this._teardown; + if (superseded()) return; + continue; + } + const older = [...this._acquisitions].filter( + (acquisition) => !waitedOut.has(acquisition), + ); + if (!older.length) return; + this._abandonAcquisitions(); + try { + await withDeadline( + Promise.all(older.map((acquisition) => acquisition.settled)), + this._closeTimeoutMs * 3, + 'RTL-SDR acquisition did not settle', + ); + } catch { + for (const acquisition of older) { + waitedOut.add(acquisition); + this._fenceWaitedOut(acquisition); + } + } + if (superseded()) return; + } + } + + /** + * Cut an acquisition that a newer connect stopped waiting for off the + * hardware: its provider's WebUSB calls already reject (it was abandoned), + * and an RTL2832U it returned is detached from the shared `USBDevice`, so + * a graceful close still running for it cannot close the newer session's + * device. Its cleanup still closes the device unless someone else owns it. + */ + _fenceWaitedOut(acquisition) { + acquisition.abandoned = true; + acquisition.waitedOut = true; + if (acquisition.device) fenceDevice(acquisition.device); + } + + /** + * Start the single-flight teardown of a detached device. A teardown + * requested while another runs waits for it first. + * @returns {Promise} Settles within the close deadlines. + */ + _teardownDevice(device, provider) { + const prior = this._teardown || Promise.resolve(); + const teardown = prior + .then(() => this._closeDevice(device, provider, { queued: true })) + .finally(() => { + if (this._teardown === teardown) this._teardown = null; + }); + this._teardown = teardown; + return teardown; + } + + /** + * Close one device: gracefully when that finishes in time, else by closing + * the raw WebUSB device. Before the raw close, queued USB work is abandoned + * (so a graceful close still waiting in the queue never starts) and the old + * device is fenced from the hardware (so a graceful close that is already + * running cannot reach a device a later session reopens). `mayForceClose` + * is asked immediately before the raw close, so a device that someone else + * opened while the graceful close stalled is left alone. The raw close is + * bounded too; this never throws. + */ + async _closeDevice(device, provider, { queued, mayForceClose = () => true }) { + const usbDevice = rawUsbDevice(device, provider); + try { + // Graceful close waits behind queued USB work, but only so long: a + // stalled bulk read would otherwise wedge stop, mode and device + // changes behind it. + const graceful = queued + ? this._queueUsb(() => device.close()) + : Promise.resolve().then(() => device?.close()); + await withDeadline( + graceful, + this._closeTimeoutMs, + 'RTL-SDR close timed out', + ); + return; + } catch (error) { + console.warn('[SDR] Device close failed:', error); + } + if (queued) this._resetUsbQueue(); + fenceDevice(device); + if (!mayForceClose(usbDevice)) return; + try { + // Closing the raw USB device aborts any pending transfer. + await withDeadline( + forceCloseUsb(usbDevice), + this._closeTimeoutMs, + 'RTL-SDR USB close timed out', + ); + } catch (error) { + console.warn('[SDR] USB close did not settle:', error); + } + } + + /** + * Open the WebUSB picker on the next connection so the user can choose a + * different receiver (or the other channel of a dual-channel board). Call + * from a click handler: the picker needs user activation. + * @returns {Promise} Whether a receiver is streaming afterwards. + */ + async changeDevice() { + if (!this.state.webUsbSupported) return this.connect(); + this._forcePicker = true; + if (this._device) await this.stop({ preserveMessage: true }); + const connected = await this.connect(this.state.mode); + this._forcePicker = false; + return connected; + } + + async _readLoop(generation) { + while (this._device && generation === this._readGeneration) { + try { + const device = this._device; + // A transfer that never settles would hold the shared USB queue + // forever; past the deadline the session is torn down (stop() closes + // the raw USB device, which aborts the transfer). + const block = await this._queueUsb(() => + withDeadline( + device.readSamples(READ_SAMPLE_COUNT), + this._readStallMs, + 'RTL-SDR sample read stalled', + ), + ); + if (!this._device || generation !== this._readGeneration) break; + const buffer = block.data; + this._sampleWindowSamples += buffer.byteLength / 2; + const now = Date.now(); + const elapsed = now - this._sampleWindowStartedAt; + if (elapsed >= RATE_WINDOW_MS) { + this._setState({ + samplesPerSecond: Math.round( + (this._sampleWindowSamples * 1_000) / elapsed, + ), + }); + this._sampleWindowStartedAt = now; + this._sampleWindowSamples = 0; + } + this._ensureWorker().postMessage({ type: 'samples', buffer }, [buffer]); + } catch (error) { + if (generation !== this._readGeneration) break; + console.warn('[SDR] Sample read failed:', error); + this._setState({ + connected: false, + status: 'error', + message: 'RTL-SDR sample stream stopped', + }); + await this.stop({ preserveMessage: true }); + break; + } + } + } + + async setMode(mode) { + const nextMode = normalizeMode(mode); + this.cancelSeek(); + const token = this._sessionToken; + const device = this._device; + if (!device) { + this._setState({ + mode: nextMode, + gain: this._gainByMode[nextMode], + frequencyHz: + nextMode === 'adsb' ? ADSB_FREQUENCY_HZ : this._fmFrequencyHz, + sampleRate: nextMode === 'adsb' ? ADSB_SAMPLE_RATE : FM_SAMPLE_RATE, + }); + return true; + } + // A different authorized receiver preferred for this mode (the 1090 MHz + // channel of a dual-channel board, or a device remembered for the mode) + // takes over: reopen through selection instead of retuning this one. + if (nextMode !== this.state.mode && this._deviceIdentity) { + const preferred = await this._preferredDevice(nextMode); + if (!this._owns(token, device)) return false; + if (preferred && !sameSdrDevice(preferred, this._deviceIdentity)) { + await this.stop({ preserveMessage: true }); + return this.connect(nextMode); + } + } + this._setState({ + status: 'tuning', + mode: nextMode, + gain: this._gainByMode[nextMode], + message: `Switching to ${nextMode === 'adsb' ? 'ADS-B' : 'FM'}…`, + }); + try { + if (nextMode === 'fm' && !(await this._ensureAudio())) { + throw new Error( + 'Browser audio is suspended; select FM again to resume it', + ); + } + // A stall teardown, stop() or reconnect during the switch supersedes + // it: that path owns the published state, never "streaming" from here. + if (!this._owns(token, device)) return false; + const configured = await this._configureDevice( + nextMode, + this._fmFrequencyHz, + token, + ); + if (!configured || !this._owns(token, device)) return false; + this._setState({ + status: 'streaming', + message: streamingMessage(nextMode), + snr: null, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + ...this._resetAdsbCounters(), + }); + return true; + } catch (error) { + if (!this._owns(token, device)) return false; + console.warn('[SDR] Mode switch failed:', error); + this._setState({ + status: 'error', + message: describeConnectionError(error), + }); + return false; + } + } + + /** The authorized receiver selection would open for a mode, if any. */ + async _preferredDevice(mode) { + try { + return selectAuthorizedSdrDevice(await this._webUsb?.getDevices?.(), { + filters: RTL_SDR_USB_FILTERS, + mode, + remembered: this._deviceMemory.get(mode), + }); + } catch { + return null; + } + } + + /** + * Set the tuner gain for a mode and apply it live when that mode is active. + * @param {'auto'|number|string} value `'auto'` or a gain in dB. + * @param {object} [options] + * @param {'fm'|'adsb'} [options.mode] Mode to configure; defaults to current. + * @returns {Promise<'auto'|number|null>} Applied setting, or null if invalid. + */ + async setGain(value, { mode = this.state.mode } = {}) { + const targetMode = normalizeMode(mode); + const setting = normalizeSdrGain(value); + if (setting === null) return null; + this._gainByMode = { ...this._gainByMode, [targetMode]: setting }; + writeSdrGainSettings(this._storage, this._gainByMode); + if (targetMode !== this.state.mode) return setting; + this._setState({ gain: setting }); + if (!this._device) return setting; + try { + await this._queueUsb(() => + this._device?.setGain(tunerGainValue(setting)), + ); + if (targetMode === 'adsb') this._setState(this._resetAdsbCounters()); + } catch (error) { + console.warn('[SDR] Gain change failed:', error); + this._setState({ + status: 'error', + message: 'RTL-SDR gain change failed', + }); + return null; + } + return setting; + } + + /** + * Current gain setting for a mode. + * @param {'fm'|'adsb'} [mode] + * @returns {'auto'|number} + */ + getGain(mode = this.state.mode) { + const target = normalizeMode(mode); + return this._gainByMode[target] ?? SDR_GAIN_DEFAULTS[target]; + } + + async tuneFm(frequencyHz, { quiet = false } = {}) { + const frequency = clampFmFrequency(frequencyHz); + this._fmFrequencyHz = frequency; + if (!this._device) { + this._setState({ + mode: 'fm', + gain: this._gainByMode.fm, + frequencyHz: frequency, + sampleRate: FM_SAMPLE_RATE, + }); + return true; + } + if (this.state.mode !== 'fm') await this.setMode('fm'); + try { + let actualFrequency = frequency; + await this._queueUsb(async () => { + const tuned = await this._device.setCenterFrequency(frequency); + if (Number.isFinite(tuned)) actualFrequency = tuned; + await this._device.resetBuffer(); + }); + this._worker?.postMessage({ type: 'reset' }); + this._audioNode?.port.postMessage({ type: 'clear' }); + this._setState({ + frequencyHz: actualFrequency, + status: 'streaming', + ...(quiet + ? {} + : { + message: `Tuned to ${(frequency / 1_000_000).toFixed(1)} MHz FM`, + }), + }); + return true; + } catch (error) { + this._setState({ + status: 'error', + message: describeConnectionError(error), + }); + return false; + } + } + + _waitForSpectrum(afterSequence, timeoutMs = 900) { + if (this._spectrumSequence > afterSequence) return Promise.resolve(true); + return new Promise((resolve) => { + const finish = (result) => { + clearTimeout(timer); + this._spectrumWaiters.delete(waiter); + resolve(result); + }; + const waiter = (sequence) => finish(sequence > afterSequence); + const timer = setTimeout(() => finish(false), timeoutMs); + this._spectrumWaiters.add(waiter); + }); + } + + async seekFm(direction = 1) { + if (!this._device || this.state.mode !== 'fm' || this.state.seeking) + return null; + const seekDirection = direction < 0 ? -1 : 1; + const origin = this.state.frequencyHz; + const token = ++this._seekToken; + this._setState({ + seeking: true, + seekMessage: `Seeking ${seekDirection > 0 ? 'up' : 'down'}…`, + }); + this._audioNode?.port.postMessage({ type: 'volume', value: 0 }); + try { + for (let scan = 0; scan < 18 && token === this._seekToken; scan += 1) { + const span = FM_MAX_HZ - FM_MIN_HZ; + const rawCenter = origin + seekDirection * scan * 1_200_000; + const center = FM_MIN_HZ + positiveModulo(rawCenter - FM_MIN_HZ, span); + const sequence = this._spectrumSequence; + await this.tuneFm(center, { quiet: true }); + this._setState({ + seekMessage: `Scanning ${(center / 1_000_000).toFixed(1)} MHz…`, + }); + if ( + !(await this._waitForSpectrum(sequence)) || + token !== this._seekToken + ) + continue; + const peak = findDirectionalFmPeak( + this._spectrum, + this.state.frequencyHz, + this.state.sampleRate, + seekDirection, + origin, + ); + if (!peak) continue; + await this.tuneFm(peak.frequency, { quiet: true }); + this._setState({ + seeking: false, + seekMessage: `Found ${(peak.frequency / 1_000_000).toFixed(1)} MHz · ${peak.snr.toFixed(1)} dB`, + message: `FM signal found at ${(peak.frequency / 1_000_000).toFixed(1)} MHz`, + }); + return peak; + } + if (token === this._seekToken) { + await this.tuneFm(origin, { quiet: true }); + this._setState({ seeking: false, seekMessage: 'No FM signal found' }); + } + return null; + } finally { + if (token === this._seekToken) { + this._audioNode?.port.postMessage({ + type: 'volume', + value: this.state.volume, + }); + } + } + } + + cancelSeek() { + this._seekToken += 1; + if (!this.state.seeking) return false; + this._setState({ seeking: false, seekMessage: 'Seek stopped' }); + this._audioNode?.port.postMessage({ + type: 'volume', + value: this.state.volume, + }); + return true; + } + + setVolume(value) { + const volume = Math.max(0, Math.min(1, Number(value) || 0)); + this._setState({ volume }); + if (!this.state.seeking) + this._audioNode?.port.postMessage({ type: 'volume', value: volume }); + return volume; + } + + async requestReceiverLocation() { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + this._setState({ + locationStatus: 'unavailable', + message: 'Browser location is unavailable', + }); + return false; + } + this._setState({ locationStatus: 'requesting' }); + try { + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 10_000, + maximumAge: 300_000, + }); + }); + const receiverLocation = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }; + this._setState({ receiverLocation, locationStatus: 'ready' }); + this._worker?.postMessage({ + type: 'location', + location: receiverLocation, + }); + return true; + } catch (error) { + console.warn('[SDR] Receiver location failed:', error); + this._setState({ + locationStatus: 'denied', + message: 'Receiver location was not granted', + }); + return false; + } + } + + /** + * Close the receiver. + * @param {object} [options] + * @param {boolean} [options.preserveMessage] Keep the current status text. + * @param {string} [options.message] Status text explaining the stop. + * @returns {Promise} + */ + async stop({ preserveMessage = false, message = null } = {}) { + this.cancelSeek(); + this._readGeneration += 1; + const token = ++this._sessionToken; + const device = this._device; + const provider = this._provider; + this._device = null; + this._provider = null; + // A connect still opening a device is superseded: abort its + // initialization so it settles and closes what it opened. + this._abandonAcquisitions(); + this._audioNode?.port.postMessage({ type: 'clear' }); + // Single-flight: a stop() while another teardown runs joins it. + const teardown = device + ? this._teardownDevice(device, provider) + : this._teardown; + if (teardown) await teardown; + // A connect() that started meanwhile owns the published state now. + if (token !== this._sessionToken) return true; + const idleStatus = this.state.webUsbSupported ? 'idle' : 'unsupported'; + this._setState({ + connected: false, + status: preserveMessage ? this.state.status : idleStatus, + ...(preserveMessage + ? {} + : { message: message || 'RTL-SDR disconnected' }), + snr: null, + samplesPerSecond: 0, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + ...this._resetAdsbCounters(), + }); + return true; + } + + async destroy() { + this._destroyed = true; + await this.stop(); + this._discardWorker(); + await this._audioContext?.close(); + this._audioContext = null; + this._audioNode = null; + this.state.audioState = 'closed'; + this._listeners.clear(); + } +} + +function positiveModulo(value, divisor) { + return ((value % divisor) + divisor) % divisor; +} + +export const SDR_FM_BAND = Object.freeze({ + minHz: FM_MIN_HZ, + maxHz: FM_MAX_HZ, +}); +export const SDR_ADSB_FREQUENCY_HZ = ADSB_FREQUENCY_HZ; diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs new file mode 100644 index 000000000..95b1eb5f2 --- /dev/null +++ b/src/sdr/controller.test.mjs @@ -0,0 +1,1021 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRememberingWebUsb, + SDR_RECEIVER_PRESETS, + SdrController, +} from './controller.js'; +import { RTL2832U_Provider } from '@jtarrio/webrtlsdr/rtlsdr.js'; +import { RTL_SDR_USB_FILTERS } from './usbDevices.js'; +import { SDR_GAIN_STORAGE_KEY } from './gain.js'; + +function replaceGlobal(name, value) { + const prior = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + return () => { + if (prior) Object.defineProperty(globalThis, name, prior); + else delete globalThis[name]; + }; +} + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +class FakeWorker { + postMessage() {} + terminate() {} +} + +function fakeDevice(events = [], name = 'device') { + return { + setSampleRate: async (rate) => rate, + setFrequencyCorrection: async () => {}, + setCenterFrequency: async (frequency) => { + events.push(`${name}:tune:${frequency}`); + return frequency; + }, + setGain: async (gain) => { + events.push(`${name}:gain:${gain}`); + }, + _enableRtlAgc: async (enabled) => { + events.push(`rtl-agc:${enabled}`); + }, + resetBuffer: async () => {}, + // Resolve like hardware so queued USB work can run between blocks. + readSamples: () => + new Promise((resolve) => + setTimeout(() => resolve({ data: new ArrayBuffer(8) }), 2), + ), + close: async () => { + events.push(`${name}:close`); + }, + }; +} + +test('FM resumes audio before opening WebUSB and reports the running context', async (t) => { + const events = []; + class FakeAudioContext { + constructor() { + events.push('audio:create'); + this.state = 'suspended'; + this.destination = {}; + this.audioWorklet = { + addModule: async () => { + events.push('audio:module'); + }, + }; + this.onstatechange = null; + } + + async resume() { + events.push('audio:resume'); + this.state = 'running'; + this.onstatechange?.(); + } + + async close() { + this.state = 'closed'; + } + } + class FakeAudioWorkletNode { + constructor() { + this.port = { postMessage() {} }; + } + + connect() {} + } + const device = fakeDevice(events); + const restore = [ + replaceGlobal('navigator', { usb: {} }), + replaceGlobal('AudioContext', FakeAudioContext), + replaceGlobal('AudioWorkletNode', FakeAudioWorkletNode), + replaceGlobal('Worker', FakeWorker), + ]; + t.after(() => restore.reverse().forEach((callback) => callback())); + + const controller = new SdrController({ + storage: memoryStorage(), + providerFactory: () => ({ + async get() { + events.push('usb:get'); + return device; + }, + }), + }); + t.after(() => controller.stop()); + + assert.equal(await controller.connect('fm'), true); + assert.ok(events.indexOf('audio:resume') < events.indexOf('usb:get')); + assert.equal(controller.getState().audioState, 'running'); + assert.equal(controller.getState().connected, true); + assert.ok(events.includes('rtl-agc:false')); + assert.ok(events.includes('device:gain:null'), 'FM defaults to tuner AGC'); + assert.equal(controller.getState().gain, 'auto'); +}); + +test('receiver presets keep the known-working tuning settings', () => { + assert.deepEqual(SDR_RECEIVER_PRESETS.fm, { + frequencyHz: 98_500_000, + sampleRate: 2_048_000, + ppm: 0, + rtlAgc: false, + }); + assert.deepEqual(SDR_RECEIVER_PRESETS.adsb, { + frequencyHz: 1_090_000_000, + sampleRate: 2_000_000, + ppm: 0, + rtlAgc: false, + }); +}); + +test('ADS-B opens at manual 28.0 dB and gain changes apply live without reconnecting', async (t) => { + const events = []; + let opens = 0; + const storage = memoryStorage(); + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage, + webUsb: {}, + providerFactory: () => ({ + async get() { + opens += 1; + return fakeDevice(events); + }, + }), + }); + t.after(() => controller.stop()); + assert.equal(controller.getState().gain, 'auto'); + assert.equal(await controller.connect('adsb'), true); + assert.ok(events.includes('device:gain:28')); + assert.equal(controller.getState().gain, 28); + + assert.equal(await controller.setGain('36.4'), 36.4); + assert.equal(events.at(-1), 'device:gain:36.4'); + assert.equal(await controller.setGain('auto'), 'auto'); + assert.equal(events.at(-1), 'device:gain:null'); + assert.equal(opens, 1, 'gain changes never reopen the receiver'); + assert.deepEqual(JSON.parse(storage.values.get(SDR_GAIN_STORAGE_KEY)), { + fm: 'auto', + adsb: 'auto', + }); + + const before = events.length; + assert.equal(await controller.setGain(28, { mode: 'fm' }), 28); + assert.equal(events.length, before, 'another mode is stored, not applied'); + assert.equal(controller.getGain('fm'), 28); + assert.equal(controller.getState().gain, 'auto'); + assert.equal(await controller.setGain('very loud'), null); +}); + +test('stored per-mode gain is restored on the next session', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage: memoryStorage({ + [SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 'auto', adsb: 12.5 }), + }), + webUsb: {}, + providerFactory: () => ({ get: async () => fakeDevice(events) }), + }); + t.after(() => controller.stop()); + await controller.connect('adsb'); + assert.ok(events.includes('device:gain:12.5')); +}); + +test('ADS-B updates report CRC-valid messages per second, heard and positioned', () => { + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + }); + const heardOnly = { + icao: 'a15c54', + lat: null, + lon: null, + lastPositionAt: null, + lastMessageAt: 10_000, + }; + const positioned = { + icao: 'ae5d8a', + lat: 30.27, + lon: -97.79, + lastPositionAt: 10_000, + lastMessageAt: 10_000, + }; + controller._applyAdsbUpdate( + { aircraft: [heardOnly, positioned], decodedCount: 12 }, + 10_000, + ); + assert.equal(controller.getState().messagesPerSecond, null); + controller._applyAdsbUpdate( + { aircraft: [heardOnly, positioned], decodedCount: 13 }, + 12_000, + ); + const state = controller.getState(); + assert.equal(state.messagesPerSecond, 12.5); + assert.equal(state.decodedMessages, 25); + assert.equal(state.aircraftHeard, 2); + assert.equal(state.aircraftPositioned, 1); +}); + +test('switching to ADS-B moves to the authorized 1090 MHz channel of a dual board', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const uat = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'FlyCatcher_UAT', + serialNumber: '00000001', + }; + const adsb = { ...uat, productName: 'FlyCatcher_ADS_B' }; + const webUsb = { + async getDevices() { + return [uat, adsb]; + }, + async requestDevice() { + throw new Error('picker should not open'); + }, + }; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb, + providerFactory: (selectingUsb) => ({ + async get() { + const chosen = await selectingUsb.requestDevice({ + filters: RTL_SDR_USB_FILTERS, + }); + return fakeDevice(events, chosen.productName); + }, + }), + }); + t.after(() => controller.stop()); + // FM on a FlyCatcher has no preferred channel, so the first one opens. + controller.state.mode = 'fm'; + controller._ensureAudio = async () => true; + assert.equal(await controller.connect('fm'), true); + assert.equal(controller.getState().deviceLabel, 'FlyCatcher_UAT'); + + assert.equal(await controller.setMode('adsb'), true); + assert.ok(events.includes('FlyCatcher_UAT:close')); + assert.ok(events.includes('FlyCatcher_ADS_B:tune:1090000000')); + assert.equal(controller.getState().deviceLabel, 'FlyCatcher_ADS_B'); + assert.equal(controller.getState().mode, 'adsb'); + assert.equal(controller.getState().connected, true); +}); + +test('CHANGE DEVICE reopens through the WebUSB picker', async (t) => { + t.after(replaceGlobal('Worker', FakeWorker)); + let pickerCalls = 0; + const device = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: '1', + }; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: { + async getDevices() { + return [device]; + }, + async requestDevice() { + pickerCalls += 1; + return device; + }, + }, + providerFactory: (selectingUsb) => ({ + async get() { + await selectingUsb.requestDevice({ filters: RTL_SDR_USB_FILTERS }); + return fakeDevice(); + }, + }), + }); + t.after(() => controller.stop()); + controller.state.mode = 'adsb'; + assert.equal(await controller.connect('adsb'), true); + assert.equal(pickerCalls, 0, 'an authorized receiver opens without a picker'); + assert.equal(await controller.changeDevice(), true); + assert.equal(pickerCalls, 1); + assert.equal(controller.getState().connected, true); +}); + +test('remembered WebUSB receiver is reused without opening the picker', async () => { + const remembered = { + vendorId: 0x0bda, + productId: 0x2838, + serialNumber: 'known', + }; + let pickerCalls = 0; + const webUsb = createRememberingWebUsb({ + async getDevices() { + return [remembered]; + }, + async requestDevice() { + pickerCalls += 1; + return null; + }, + }); + + const result = await webUsb.requestDevice({ + filters: [{ vendorId: 0x0bda, productId: 0x2838 }], + }); + + assert.equal(result, remembered); + assert.equal(pickerCalls, 0); +}); + +test('WebUSB picker remains available when no authorized receiver matches', async () => { + const selected = { vendorId: 0x0bda, productId: 0x2832 }; + let pickerCalls = 0; + const webUsb = createRememberingWebUsb({ + async getDevices() { + return [{ vendorId: 0x1234, productId: 0x5678 }]; + }, + async requestDevice() { + pickerCalls += 1; + return selected; + }, + }); + + const result = await webUsb.requestDevice({ + filters: [{ vendorId: 0x0bda, productId: 0x2832 }], + }); + + assert.equal(result, selected); + assert.equal(pickerCalls, 1); +}); + +/** Resolve with the promise's value, or 'timed out' after `ms`. */ +function within(promise, ms = 1_000) { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)), + ]); +} + +/** + * A device whose bulk reads hang until the raw WebUSB device is closed, + * the way a stalled `transferIn` behaves: `USBDevice.close()` aborts it. + */ +function stallingDevice(events, name = 'stalled') { + const pendingReads = new Set(); + const usb = { + opened: true, + async close() { + events.push(`${name}:usb-close`); + usb.opened = false; + for (const reject of pendingReads) reject(new Error('AbortError')); + pendingReads.clear(); + }, + }; + const base = fakeDevice(events, name); + return { + ...base, + com: { device: usb }, + readSamples: () => + new Promise((_, reject) => { + events.push(`${name}:read`); + pendingReads.add(reject); + }), + // The graceful close needs the bus, which the stalled transfer holds. + close: () => new Promise(() => events.push(`${name}:graceful-close`)), + }; +} + +test('a stalled USB read cannot wedge stop(): the raw device is closed and the queue replaced', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const devices = [stallingDevice(events), fakeDevice(events, 'fresh')]; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 60_000, + closeTimeoutMs: 50, + providerFactory: () => ({ get: async () => devices.shift() }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(events.includes('stalled:read'), 'a read is in flight'); + + assert.equal(await within(controller.stop()), true, 'stop() returns'); + assert.ok(events.includes('stalled:usb-close'), 'raw USB device closed'); + assert.equal(controller.getState().connected, false); + + // The next session is not queued behind the abandoned transfer. + assert.equal(await within(controller.connect('adsb')), true); + assert.ok(events.includes('fresh:tune:1090000000')); +}); + +test('a stalled USB read times out and tears the session down on its own', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 30, + closeTimeoutMs: 30, + providerFactory: () => ({ get: async () => stallingDevice(events) }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await new Promise((resolve) => setTimeout(resolve, 150)); + const state = controller.getState(); + assert.equal(state.connected, false); + assert.equal(state.status, 'error'); + assert.equal(state.message, 'RTL-SDR sample stream stopped'); + assert.ok(events.includes('stalled:usb-close')); + // Mode changes work again instead of queuing behind the dead read. + assert.equal(await within(controller.setMode('fm')), true); +}); + +test('destroy() during a pending connect closes the late device instead of resuming the session', async (t) => { + const events = []; + const workers = []; + class CountingWorker extends FakeWorker { + constructor() { + super(); + workers.push(this); + } + } + t.after(replaceGlobal('Worker', CountingWorker)); + let deliver = null; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + providerFactory: () => ({ + get: () => + new Promise((resolve) => { + deliver = resolve; + }), + }), + }); + const connecting = controller.connect('adsb'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(typeof deliver, 'function', 'device acquisition is pending'); + await controller.destroy(); + deliver(fakeDevice(events, 'late')); + assert.equal(await within(connecting), false); + assert.ok(events.includes('late:close'), 'late device is closed'); + assert.ok(!events.includes('late:tune:1090000000'), 'never configured'); + assert.equal(controller.getState().connected, false); + assert.notEqual(controller.getState().status, 'streaming'); + assert.equal(workers.length, 0, 'no decoder worker is created'); +}); + +test('a failed decoder worker is discarded and reconnect builds a fresh one', async (t) => { + const events = []; + const workers = []; + class RecordingWorker { + constructor() { + this.messages = []; + this.terminated = false; + workers.push(this); + } + + postMessage(message) { + this.messages.push(message.type); + } + + terminate() { + this.terminated = true; + } + } + t.after(replaceGlobal('Worker', RecordingWorker)); + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + providerFactory: () => ({ get: async () => fakeDevice(events) }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + assert.equal(workers.length, 1); + workers[0].onerror?.({ message: 'module failed to load' }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(controller.getState().status, 'error'); + assert.equal(workers[0].terminated, true, 'failed worker terminated'); + + await controller.stop(); + assert.equal(await controller.connect('adsb'), true); + assert.equal(workers.length, 2, 'reconnect creates a fresh worker'); + assert.ok(workers[1].messages.includes('configure')); + assert.equal(controller.getState().status, 'streaming'); +}); + +/** + * One WebUSB device shared by every session: WebUSB hands a reopened + * receiver back as the SAME `USBDevice` object. Like the real one, close() + * aborts pending transfers at once and resolves a little later. + */ +function sharedUsb(events, { closeNeverSettles = false } = {}) { + const usb = { + opened: false, + pendingReads: new Set(), + async open() { + events.push('usb-open'); + usb.opened = true; + }, + close() { + events.push('usb-close'); + usb.opened = false; + for (const reject of usb.pendingReads) reject(new Error('AbortError')); + usb.pendingReads.clear(); + if (closeNeverSettles) return new Promise(() => {}); + return new Promise((resolve) => setTimeout(resolve, 5)); + }, + }; + return usb; +} + +/** + * An RTL2832U over `usb` that, like the library, reaches the hardware + * through `com.device`; its graceful close can be held open by `closeGate`. + */ +function rtlOver(usb, events, name, { stallReads = false, closeGate } = {}) { + const device = { + ...fakeDevice(events, name), + com: { device: usb }, + readSamples() { + if (stallReads) + return new Promise((_, reject) => { + events.push(`${name}:read`); + usb.pendingReads.add(reject); + }); + return new Promise((resolve, reject) => + setTimeout( + () => + usb.opened + ? resolve({ data: new ArrayBuffer(8) }) + : reject(new Error('device closed')), + 2, + ), + ); + }, + async close() { + events.push(`${name}:graceful-close`); + if (closeGate) await closeGate; + await device.com.device.close(); + }, + }; + return device; +} + +function gate() { + let open; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { promise, open }; +} + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function sharedUsbController(usb, devices) { + return new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 60_000, + closeTimeoutMs: 30, + providerFactory: () => ({ + async get() { + await usb.open(); + return devices.shift(); + }, + }), + }); +} + +test('a graceful close queued behind a stalled read never runs once the raw device is closed', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const held = gate(); + const controller = sharedUsbController(usb, [ + rtlOver(usb, events, 'old', { stallReads: true, closeGate: held.promise }), + rtlOver(usb, events, 'new'), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await pause(5); + assert.equal(await within(controller.stop()), true); + assert.equal(await within(controller.connect('adsb')), true); + // Whatever the old session still had queued is released now. + held.open(); + await pause(30); + assert.ok( + !events.includes('old:graceful-close'), + 'queued close abandoned before the raw close', + ); + assert.equal(usb.opened, true, 'the reopened device stays open'); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); + +test('a graceful close already running when abandoned cannot close the reopened device', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const held = gate(); + const controller = sharedUsbController(usb, [ + rtlOver(usb, events, 'old', { closeGate: held.promise }), + rtlOver(usb, events, 'new'), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await pause(5); + assert.equal(await within(controller.stop()), true); + assert.ok(events.includes('old:graceful-close'), 'graceful close started'); + assert.equal(await within(controller.connect('adsb')), true); + // The old close resumes, as a stuck control transfer finally completes. + held.open(); + await pause(30); + assert.equal(usb.opened, true, 'the reopened device stays open'); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); + +test('teardown is single-flight: a second stop() and a connect() wait for the device close', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const controller = sharedUsbController(usb, [ + // The graceful close never finishes: only the raw close ends it. + rtlOver(usb, events, 'old', { closeGate: new Promise(() => {}) }), + rtlOver(usb, events, 'new'), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + const first = controller.stop(); + let secondDone = false; + const second = controller.stop().then(() => { + secondDone = true; + events.push('second-stop-done'); + }); + const reconnect = controller.connect('adsb'); + await pause(5); + assert.equal(secondDone, false, 'the second stop joins the teardown'); + await within(Promise.all([first, second])); + assert.equal(await within(reconnect), true); + assert.ok( + events.indexOf('usb-close') < events.indexOf('second-stop-done'), + 'second stop returns after the device is closed', + ); + assert.ok( + events.indexOf('usb-close') < events.lastIndexOf('usb-open'), + 'the device is reopened only after the old session closed it', + ); + await pause(30); + assert.equal(usb.opened, true); + assert.equal(controller.getState().status, 'streaming'); + assert.equal(controller.getState().connected, true); +}); + +test('stop() settles within its deadline even when the raw USB close never settles', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events, { closeNeverSettles: true }); + const controller = sharedUsbController(usb, [ + rtlOver(usb, events, 'old', { stallReads: true }), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await pause(5); + const startedAt = Date.now(); + assert.equal(await within(controller.stop(), 500), true); + // Graceful close deadline plus raw close deadline, with scheduling slack. + assert.ok(Date.now() - startedAt < 250, `${Date.now() - startedAt} ms`); + assert.ok(events.includes('usb-close'), 'raw close attempted'); + assert.equal(controller.getState().connected, false); +}); + +test('a mode change queued behind a stalled read never reports streaming after the stall teardown', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 30, + closeTimeoutMs: 30, + providerFactory: () => ({ get: async () => stallingDevice(events) }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + const switched = await within(controller.setMode('adsb')); + assert.equal(switched, false, 'the superseded switch fails'); + await pause(100); + const state = controller.getState(); + assert.notEqual(state.status, 'streaming'); + assert.equal(state.connected, false); + assert.equal(controller._device, null); +}); + +/** + * A raw WebUSB RTL-SDR for the installed `RTL2832U_Provider`: open and claim + * succeed, and the first initialization transfer fails, either at once or + * (with `hold`) only when the device is closed, like a stuck control transfer. + */ +function failingInitUsb(events, { hold = false } = {}) { + const pending = new Set(); + const usb = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: 'init-fails', + opened: false, + async open() { + events.push('open'); + usb.opened = true; + }, + async claimInterface() { + events.push('claim'); + }, + async releaseInterface() {}, + controlTransferOut() { + events.push('init-transfer'); + if (!hold) return Promise.reject(new Error('transfer error')); + return new Promise((_, reject) => pending.add(reject)); + }, + controlTransferIn() { + return Promise.reject(new Error('transfer error')); + }, + async close() { + events.push('close'); + usb.opened = false; + for (const reject of pending) reject(new Error('AbortError')); + pending.clear(); + }, + }; + return usb; +} + +function failingInitController(usb) { + return new SdrController({ + storage: memoryStorage(), + closeTimeoutMs: 30, + // No providerFactory: the installed RTL2832U_Provider opens the device. + webUsb: { + async getDevices() { + return [usb]; + }, + async requestDevice() { + return usb; + }, + }, + }); +} + +test('a receiver whose initialization fails is closed and destroy() settles', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = failingInitUsb(events); + const controller = failingInitController(usb); + assert.equal(await within(controller.connect('adsb')), false); + assert.deepEqual(events, ['open', 'claim', 'init-transfer', 'close']); + assert.equal(usb.opened, false, 'the raw device is not left open'); + assert.equal(controller.getState().status, 'error'); + assert.equal(controller.getState().connected, false); + assert.notEqual(await within(controller.destroy()), 'timed out'); + assert.equal(controller._acquisitions.size, 0); +}); + +test('a superseded receiver initialization that then fails still closes the device', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = failingInitUsb(events, { hold: true }); + const controller = failingInitController(usb); + const connecting = controller.connect('adsb'); + await pause(5); + assert.ok(events.includes('init-transfer'), 'initialization is in flight'); + assert.notEqual(await within(controller.destroy()), 'timed out'); + assert.equal(await within(connecting), false); + assert.ok(events.includes('close')); + assert.equal(usb.opened, false, 'the raw device is not left open'); + assert.notEqual(controller.getState().status, 'streaming'); + assert.equal(controller._acquisitions.size, 0); +}); + +/** + * A provider shaped like the library's: it records the raw device before + * opening it, and its initialization can be held open by `initGate`. + */ +function heldProvider(usb, events, name, initGate) { + return { + device: undefined, + async get() { + this.device = usb; + await usb.open(); + events.push(`${name}:init`); + if (initGate) await initGate; + return rtlOver(usb, events, name); + }, + }; +} + +function acquisitionController(providers, closeTimeoutMs = 30) { + return new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 60_000, + closeTimeoutMs, + providerFactory: () => providers.shift(), + }); +} + +test('a newer connect waits for an older acquisition to finish closing its device', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const heldA = gate(); + const controller = acquisitionController( + [ + heldProvider(usb, events, 'A', heldA.promise), + heldProvider(usb, events, 'B'), + ], + // Long enough that B does not give up on A while it is held. + 1_000, + ); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + const connectB = controller.connect('adsb'); + await pause(5); + assert.ok(!events.includes('B:init'), 'B waits for A'); + heldA.open(); + assert.equal(await within(connectA), false, 'A was superseded'); + assert.equal(await within(connectB), true); + assert.ok( + events.lastIndexOf('usb-close') < events.indexOf('B:init'), + 'A closed its device before B opened it', + ); + await pause(30); + assert.equal(usb.opened, true, "B's device stays open"); + assert.equal(controller.getState().status, 'streaming'); + assert.equal(controller.getState().connected, true); +}); + +test("a held older acquisition released after a newer session starts never closes that session's device", async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const heldA = gate(); + const controller = acquisitionController([ + heldProvider(usb, events, 'A', heldA.promise), + heldProvider(usb, events, 'B'), + ]); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + // B gives up waiting on the stuck acquisition A and completes. + assert.equal(await within(controller.connect('adsb')), true); + assert.equal(controller.getState().status, 'streaming'); + const closes = () => events.filter((event) => event === 'usb-close').length; + const closesBeforeRelease = closes(); + heldA.open(); + assert.equal(await within(connectA), false); + await pause(30); + assert.equal( + closes(), + closesBeforeRelease, + "A's cleanup does not close the device B reopened", + ); + assert.ok(!events.includes('A:graceful-close')); + assert.equal(usb.opened, true); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); + assert.equal(controller._acquisitions.size, 0); +}); + +test("an older acquisition's cleanup that outlives the wait never closes the newer session's device", async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const heldA = gate(); + // A's graceful close never finishes: only its fallback raw close ends it. + const providerA = { + device: undefined, + async get() { + this.device = usb; + await usb.open(); + events.push('A:init'); + await heldA.promise; + return rtlOver(usb, events, 'A', { closeGate: new Promise(() => {}) }); + }, + }; + const controller = acquisitionController( + [providerA, heldProvider(usb, events, 'B')], + 40, + ); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + const connectB = controller.connect('adsb'); + // A's cleanup starts shortly before B stops waiting for it (3 x 40 ms), so + // its fallback raw close comes due after B is already streaming. + await pause(100); + heldA.open(); + assert.equal(await within(connectA), false, 'A was superseded'); + assert.ok(events.includes('A:graceful-close'), "A's cleanup is running"); + assert.equal(await within(connectB), true); + const closesAfterB = events.filter((event) => event === 'usb-close').length; + await pause(80); + assert.equal( + events.filter((event) => event === 'usb-close').length, + closesAfterB, + "A's fallback close does not close B's device", + ); + assert.equal(usb.opened, true, "B's device stays open"); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); + +test("a device selection that resolves after its acquisition was superseded cannot touch the newer session's device", async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = Object.assign(sharedUsb(events), { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: 'shared', + async claimInterface() { + events.push('usb-claim'); + }, + async releaseInterface() { + events.push('usb-release'); + }, + async controlTransferOut() { + events.push('usb-write'); + throw new Error('transfer error'); + }, + async controlTransferIn() { + events.push('usb-read'); + throw new Error('transfer error'); + }, + }); + const heldSelection = gate(); + let getDevicesCalls = 0; + const webUsb = { + async getDevices() { + getDevicesCalls += 1; + // A's selection is held; B's resolves at once. + if (getDevicesCalls === 1) await heldSelection.promise; + return [usb]; + }, + async requestDevice() { + return usb; + }, + }; + let providers = 0; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb, + readStallMs: 60_000, + closeTimeoutMs: 20, + providerFactory: (webusb) => { + providers += 1; + // A is the installed library provider; B opens the same device + // through the same selecting wrapper and streams. + if (providers === 1) return new RTL2832U_Provider({ webusb }); + return { + device: undefined, + async get() { + this.device = await webusb.requestDevice({ + filters: RTL_SDR_USB_FILTERS, + }); + await this.device.open(); + return rtlOver(this.device, events, 'B'); + }, + }; + }, + }); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + assert.equal(await within(controller.connect('adsb')), true); + assert.equal(controller.getState().status, 'streaming'); + const eventsBeforeRelease = events.length; + heldSelection.open(); + assert.equal(await within(connectA), false, 'A was superseded'); + await pause(30); + const late = events + .slice(eventsBeforeRelease) + .filter((event) => event.startsWith('usb-')); + assert.deepEqual(late, [], "A's late selection never reaches the device"); + assert.equal(usb.opened, true, "B's device stays open"); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); diff --git a/src/sdr/gain.js b/src/sdr/gain.js new file mode 100644 index 000000000..761edf013 --- /dev/null +++ b/src/sdr/gain.js @@ -0,0 +1,93 @@ +/** + * Tuner gain settings for the local RTL-SDR, remembered per receiver mode. + * + * `'auto'` hands gain to the tuner's AGC. Manual values are the R820T gain + * steps librtlsdr and dump1090 expose, in dB. Receivers with an onboard LNA + * (for example a Nooelec FlyCatcher with its LNA on) overload at AGC or high + * gain, so the ADS-B default is a fixed step. It is 28.0 dB rather than the + * lower steps dump1090 users pick because the WebUSB library maps dB to tuner + * gain stages differently from librtlsdr: on a FlyCatcher, 20.7 dB here gave + * about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna. + */ + +export const SDR_GAIN_AUTO = 'auto'; + +/** R820T tuner gain steps in dB, as listed by librtlsdr. */ +export const R820T_GAIN_STEPS_DB = Object.freeze([ + 0.0, 0.9, 1.4, 2.7, 3.7, 7.7, 8.7, 12.5, 14.4, 15.7, 16.6, 19.7, 20.7, 22.9, + 25.4, 28.0, 29.7, 32.8, 33.8, 36.4, 37.2, 38.6, 40.2, 42.1, 43.4, 43.9, 44.5, + 48.0, 49.6, +]); + +export const SDR_GAIN_DEFAULTS = Object.freeze({ + fm: SDR_GAIN_AUTO, + adsb: 28.0, +}); + +export const SDR_GAIN_STORAGE_KEY = 'gev:sdr:gain:v1'; + +/** + * Normalize a requested gain to `'auto'` or the nearest listed R820T step. + * @param {*} value `'auto'`, a dB number or a numeric string. + * @returns {'auto'|number|null} Normalized setting, or null when invalid. + */ +export function normalizeSdrGain(value) { + if (value === null || value === undefined || value === '') return null; + if (String(value).trim().toLowerCase() === SDR_GAIN_AUTO) + return SDR_GAIN_AUTO; + const requested = Number(value); + if (!Number.isFinite(requested)) return null; + let nearest = R820T_GAIN_STEPS_DB[0]; + for (const step of R820T_GAIN_STEPS_DB) { + if (Math.abs(step - requested) < Math.abs(nearest - requested)) + nearest = step; + } + return nearest; +} + +/** + * Read per-mode gain settings, falling back to the defaults. + * @param {Storage|null} storage Browser storage or a test double. + * @returns {{fm:'auto'|number, adsb:'auto'|number}} + */ +export function readSdrGainSettings(storage) { + let stored = null; + try { + stored = JSON.parse(storage?.getItem?.(SDR_GAIN_STORAGE_KEY) || 'null'); + } catch { + stored = null; + } + return Object.fromEntries( + Object.entries(SDR_GAIN_DEFAULTS).map(([mode, fallback]) => [ + mode, + normalizeSdrGain(stored?.[mode]) ?? fallback, + ]), + ); +} + +/** + * Persist per-mode gain settings. Storage failures are ignored. + * @param {Storage|null} storage Browser storage or a test double. + * @param {{fm:'auto'|number, adsb:'auto'|number}} settings Settings to save. + * @returns {boolean} Whether the write succeeded. + */ +export function writeSdrGainSettings(storage, settings) { + try { + storage?.setItem?.( + SDR_GAIN_STORAGE_KEY, + JSON.stringify({ fm: settings.fm, adsb: settings.adsb }), + ); + return Boolean(storage?.setItem); + } catch { + return false; + } +} + +/** + * The value handed to the tuner: null selects AGC. + * @param {'auto'|number} setting Normalized gain setting. + * @returns {number|null} + */ +export function tunerGainValue(setting) { + return setting === SDR_GAIN_AUTO ? null : setting; +} diff --git a/src/sdr/gain.test.mjs b/src/sdr/gain.test.mjs new file mode 100644 index 000000000..60f21c6be --- /dev/null +++ b/src/sdr/gain.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + R820T_GAIN_STEPS_DB, + SDR_GAIN_DEFAULTS, + SDR_GAIN_STORAGE_KEY, + normalizeSdrGain, + readSdrGainSettings, + tunerGainValue, + writeSdrGainSettings, +} from './gain.js'; + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +test('gain steps are the R820T table and ADS-B defaults to manual 28.0 dB', () => { + assert.equal(R820T_GAIN_STEPS_DB.length, 29); + assert.equal(R820T_GAIN_STEPS_DB[0], 0); + assert.equal(R820T_GAIN_STEPS_DB.at(-1), 49.6); + assert.ok(R820T_GAIN_STEPS_DB.includes(20.7)); + assert.ok(R820T_GAIN_STEPS_DB.includes(28.0)); + assert.deepEqual(SDR_GAIN_DEFAULTS, { fm: 'auto', adsb: 28.0 }); + assert.equal(tunerGainValue('auto'), null, 'AUTO hands gain to tuner AGC'); + assert.equal(tunerGainValue(20.7), 20.7); +}); + +test('requested gains normalize to AUTO or the nearest listed step', () => { + assert.equal(normalizeSdrGain('AUTO'), 'auto'); + assert.equal(normalizeSdrGain('36.4'), 36.4); + assert.equal(normalizeSdrGain(21), 20.7); + assert.equal(normalizeSdrGain(100), 49.6); + assert.equal(normalizeSdrGain(-3), 0); + assert.equal(normalizeSdrGain('loud'), null); + assert.equal(normalizeSdrGain(null), null); +}); + +test('per-mode gain persists in storage and survives unavailable storage', () => { + const storage = memoryStorage(); + assert.deepEqual(readSdrGainSettings(storage), SDR_GAIN_DEFAULTS); + assert.equal(writeSdrGainSettings(storage, { fm: 28, adsb: 'auto' }), true); + assert.equal( + storage.values.get(SDR_GAIN_STORAGE_KEY), + '{"fm":28,"adsb":"auto"}', + ); + assert.deepEqual(readSdrGainSettings(storage), { fm: 28, adsb: 'auto' }); + + const corrupt = memoryStorage({ [SDR_GAIN_STORAGE_KEY]: '{not json' }); + assert.deepEqual(readSdrGainSettings(corrupt), SDR_GAIN_DEFAULTS); + const throwing = { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }; + assert.deepEqual(readSdrGainSettings(throwing), SDR_GAIN_DEFAULTS); + assert.equal(writeSdrGainSettings(throwing, SDR_GAIN_DEFAULTS), false); + assert.deepEqual(readSdrGainSettings(null), SDR_GAIN_DEFAULTS); +}); + +test('a stored ADS-B gain wins over the 28.0 dB default', () => { + const storage = memoryStorage({ + [SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 'auto', adsb: 20.7 }), + }); + assert.deepEqual(readSdrGainSettings(storage), { fm: 'auto', adsb: 20.7 }); + const onlyFm = memoryStorage({ + [SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 36.4 }), + }); + assert.deepEqual(readSdrGainSettings(onlyFm), { fm: 36.4, adsb: 28.0 }); +}); diff --git a/src/sdr/sdr.worker.js b/src/sdr/sdr.worker.js new file mode 100644 index 000000000..07f3c5bc6 --- /dev/null +++ b/src/sdr/sdr.worker.js @@ -0,0 +1,168 @@ +import '@jtarrio/signals/demod/demodulator.js'; +import { getDemod, getMode } from '@jtarrio/signals/demod/modes.js'; +import { + AdsbStreamDecoder, + decodeAdsbMessage, + pruneAircraftTracks, + updateAircraftTrack, +} from './adsbDecoder.js'; +import { analyzeSpectrum } from './spectrum.js'; +import { recordFromDecoderTrack } from '../sources/adsbRecords.js'; + +let mode = 'fm'; +let sampleRate = 2_048_000; +let receiverLocation = null; +let demodulator = null; +let blockCount = 0; +const aircraft = new Map(); +// Positions refused by the decoder's speed check since the last clear. +const decoderStats = { positionsRejected: 0 }; +const adsbStream = new AdsbStreamDecoder(); + +function configure(nextMode, nextSampleRate, location) { + const modeChanged = mode !== (nextMode === 'adsb' ? 'adsb' : 'fm'); + mode = nextMode === 'adsb' ? 'adsb' : 'fm'; + sampleRate = + Number(nextSampleRate) || (mode === 'adsb' ? 2_000_000 : 2_048_000); + receiverLocation = location || receiverLocation; + blockCount = 0; + demodulator = + mode === 'fm' + ? getDemod(sampleRate, 48_000, { ...getMode('WBFM'), stereo: false }) + : null; + if (modeChanged) { + adsbStream.reset(); + aircraft.clear(); + } +} + +function normalizedIq(buffer) { + const bytes = new Uint8Array(buffer); + const length = Math.floor(bytes.length / 2); + const i = new Float32Array(length); + const q = new Float32Array(length); + let power = 0; + for (let index = 0; index < length; index += 1) { + i[index] = (bytes[index * 2] - 127.5) / 128; + q[index] = (bytes[index * 2 + 1] - 127.5) / 128; + power += i[index] * i[index] + q[index] * q[index]; + } + return { + i, + q, + iqLevelDbfs: 10 * Math.log10(Math.max(power / Math.max(length, 1), 1e-12)), + }; +} + +function sampledIqLevel(buffer) { + const bytes = new Uint8Array(buffer); + let power = 0; + let count = 0; + for (let index = 0; index + 1 < bytes.length; index += 32) { + const i = (bytes[index] - 127.5) / 128; + const q = (bytes[index + 1] - 127.5) / 128; + power += i * i + q * q; + count += 1; + } + return 10 * Math.log10(Math.max(power / Math.max(count, 1), 1e-12)); +} + +function audioLevel(samples) { + let power = 0; + for (const sample of samples) power += sample * sample; + return ( + 20 * + Math.log10(Math.max(Math.sqrt(power / Math.max(samples.length, 1)), 1e-12)) + ); +} + +function publicAircraft() { + const records = []; + for (const track of aircraft.values()) { + const record = recordFromDecoderTrack(track); + if (record) records.push(record); + } + return records; +} + +self.onmessage = (event) => { + const message = event.data || {}; + if (message.type === 'configure') { + configure(message.mode, message.sampleRate, message.location); + return; + } + if (message.type === 'location') { + receiverLocation = message.location || null; + return; + } + if (message.type === 'reset') { + blockCount = 0; + adsbStream.reset(); + if (message.clearAircraft) { + aircraft.clear(); + decoderStats.positionsRejected = 0; + } + if (mode === 'fm') configure(mode, sampleRate, receiverLocation); + return; + } + if (message.type !== 'samples' || !(message.buffer instanceof ArrayBuffer)) + return; + + blockCount += 1; + if (mode === 'fm') { + const bins = blockCount % 3 === 0 ? analyzeSpectrum(message.buffer) : null; + const { i, q, iqLevelDbfs } = normalizedIq(message.buffer); + const decoded = demodulator.demodulate(i, q, 0); + // The demodulator owns pooled output arrays. Transfer a copy so detaching + // the message buffer cannot invalidate the pool used by the next block. + const samples = new Float32Array(decoded.left); + self.postMessage( + { + type: 'fm', + samples, + bins, + snr: + Number.isFinite(decoded.snr) && decoded.snr > 0 + ? 10 * Math.log10(decoded.snr) + : null, + diagnostics: + blockCount % 15 === 0 + ? { + workerBlocks: blockCount, + iqLevelDbfs, + audioLevelDbfs: audioLevel(samples), + } + : null, + }, + [samples.buffer, ...(bins ? [bins.buffer] : [])], + ); + return; + } + + const now = Date.now(); + let decodedCount = 0; + for (const bytes of adsbStream.extract(message.buffer, sampleRate)) { + const decoded = decodeAdsbMessage(bytes, { receivedAt: now }); + if (!decoded) continue; + updateAircraftTrack(aircraft, decoded, receiverLocation, decoderStats); + decodedCount += 1; + } + const removed = pruneAircraftTracks(aircraft, now); + if (decodedCount || removed || blockCount % 30 === 0) { + self.postMessage({ + type: 'adsb', + aircraft: publicAircraft(), + decodedCount, + positionsRejected: decoderStats.positionsRejected, + diagnostics: + blockCount % 30 === 0 + ? { + workerBlocks: blockCount, + iqLevelDbfs: sampledIqLevel(message.buffer), + } + : null, + }); + } +}; + +configure(mode, sampleRate, receiverLocation); diff --git a/src/sdr/sdrAudioWorklet.js b/src/sdr/sdrAudioWorklet.js new file mode 100644 index 000000000..4983e43bf --- /dev/null +++ b/src/sdr/sdrAudioWorklet.js @@ -0,0 +1,48 @@ +class GevSdrAudioPlayer extends AudioWorkletProcessor { + constructor() { + super(); + this.queue = []; + this.offset = 0; + this.volume = 0.8; + this.port.onmessage = (event) => { + if ( + event.data?.type === 'samples' && + event.data.samples instanceof Float32Array + ) { + this.queue.push(event.data.samples); + if (this.queue.length > 48) + this.queue.splice(0, this.queue.length - 48); + } else if (event.data?.type === 'volume') { + this.volume = Math.max(0, Math.min(1, Number(event.data.value) || 0)); + } else if (event.data?.type === 'clear') { + this.queue.length = 0; + this.offset = 0; + } + }; + } + + process(_inputs, outputs) { + const output = outputs[0]?.[0]; + if (!output) return true; + output.fill(0); + let outputOffset = 0; + while (outputOffset < output.length && this.queue.length) { + const samples = this.queue[0]; + const available = samples.length - this.offset; + const count = Math.min(output.length - outputOffset, available); + for (let index = 0; index < count; index += 1) { + output[outputOffset + index] = + samples[this.offset + index] * this.volume; + } + outputOffset += count; + this.offset += count; + if (this.offset >= samples.length) { + this.queue.shift(); + this.offset = 0; + } + } + return true; + } +} + +registerProcessor('gev-sdr-audio-player', GevSdrAudioPlayer); diff --git a/src/sdr/spectrum.js b/src/sdr/spectrum.js new file mode 100644 index 000000000..acec025c4 --- /dev/null +++ b/src/sdr/spectrum.js @@ -0,0 +1,147 @@ +function nextPowerOfTwo(value) { + return 2 ** Math.floor(Math.log2(Math.max(2, value))); +} + +function fft(real, imag) { + const length = real.length; + for (let index = 1, reversed = 0; index < length; index += 1) { + let bit = length >> 1; + while (reversed & bit) { + reversed ^= bit; + bit >>= 1; + } + reversed ^= bit; + if (index >= reversed) continue; + [real[index], real[reversed]] = [real[reversed], real[index]]; + [imag[index], imag[reversed]] = [imag[reversed], imag[index]]; + } + for (let size = 2; size <= length; size <<= 1) { + const angle = (-2 * Math.PI) / size; + const stepReal = Math.cos(angle); + const stepImag = Math.sin(angle); + for (let start = 0; start < length; start += size) { + let twiddleReal = 1; + let twiddleImag = 0; + for (let index = 0; index < size / 2; index += 1) { + const even = start + index; + const odd = even + size / 2; + const oddReal = real[odd] * twiddleReal - imag[odd] * twiddleImag; + const oddImag = real[odd] * twiddleImag + imag[odd] * twiddleReal; + real[odd] = real[even] - oddReal; + imag[odd] = imag[even] - oddImag; + real[even] += oddReal; + imag[even] += oddImag; + const nextReal = twiddleReal * stepReal - twiddleImag * stepImag; + twiddleImag = twiddleReal * stepImag + twiddleImag * stepReal; + twiddleReal = nextReal; + } + } + } +} + +/** Create an FFT-shifted dB power spectrum from interleaved unsigned 8-bit IQ. */ +export function analyzeSpectrum(buffer, maxBins = 2_048) { + const iq = new Uint8Array(buffer); + const length = Math.min(nextPowerOfTwo(Math.floor(iq.length / 2)), maxBins); + const real = new Float32Array(length); + const imag = new Float32Array(length); + const sampleOffset = Math.max(0, Math.floor((iq.length / 2 - length) / 2)); + for (let index = 0; index < length; index += 1) { + const window = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (length - 1)); + real[index] = ((iq[(sampleOffset + index) * 2] - 127.5) / 128) * window; + imag[index] = ((iq[(sampleOffset + index) * 2 + 1] - 127.5) / 128) * window; + } + fft(real, imag); + const bins = new Float32Array(length); + for (let index = 0; index < length; index += 1) { + const shifted = (index + length / 2) % length; + const power = (real[shifted] ** 2 + imag[shifted] ** 2) / length ** 2; + bins[index] = 10 * Math.log10(power + 1e-12); + } + return bins; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] ?? -120; +} + +function channelPower(bins, center, radius) { + let total = 0; + let count = 0; + for ( + let index = Math.max(0, center - radius); + index <= Math.min(bins.length - 1, center + radius); + index += 1 + ) { + total += 10 ** (bins[index] / 10); + count += 1; + } + return 10 * Math.log10(total / Math.max(count, 1) + 1e-12); +} + +/** Find the nearest directional broadcast-FM channel peak in one spectrum frame. */ +export function findDirectionalFmPeak( + bins, + centerFrequency, + sampleRate, + direction, + afterFrequency, + { + minFrequency = 87_500_000, + maxFrequency = 108_000_000, + thresholdDb = 6, + } = {}, +) { + if ( + !(bins instanceof Float32Array) || + bins.length < 16 || + !Number.isFinite(sampleRate) + ) + return null; + const binWidth = sampleRate / bins.length; + const channelRadius = Math.max(1, Math.round(55_000 / binWidth)); + const comparisonDistance = Math.max( + channelRadius + 1, + Math.round(150_000 / binWidth), + ); + const powers = new Float32Array(bins.length); + for (let index = 0; index < bins.length; index += 1) { + powers[index] = channelPower(bins, index, channelRadius); + } + const noiseFloor = median(powers); + const candidates = []; + const frameMin = centerFrequency - sampleRate / 2; + const frameMax = centerFrequency + sampleRate / 2; + const firstChannel = + Math.ceil(Math.max(minFrequency, frameMin) / 100_000) * 100_000; + const lastChannel = + Math.floor(Math.min(maxFrequency, frameMax) / 100_000) * 100_000; + for ( + let frequency = firstChannel; + frequency <= lastChannel; + frequency += 100_000 + ) { + if (direction > 0 && frequency <= afterFrequency + 75_000) continue; + if (direction < 0 && frequency >= afterFrequency - 75_000) continue; + const index = Math.round( + (frequency - centerFrequency) / binWidth + bins.length / 2, + ); + if (index < comparisonDistance || index >= bins.length - comparisonDistance) + continue; + const power = powers[index]; + if (power - noiseFloor < thresholdDb) continue; + if ( + power < powers[index - comparisonDistance] || + power < powers[index + comparisonDistance] + ) + continue; + candidates.push({ frequency, snr: power - noiseFloor, power }); + } + candidates.sort( + (a, b) => + Math.abs(a.frequency - afterFrequency) - + Math.abs(b.frequency - afterFrequency) || b.snr - a.snr, + ); + return candidates[0] || null; +} diff --git a/src/sdr/spectrum.test.mjs b/src/sdr/spectrum.test.mjs new file mode 100644 index 000000000..551b3f824 --- /dev/null +++ b/src/sdr/spectrum.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { analyzeSpectrum, findDirectionalFmPeak } from './spectrum.js'; + +function toneIq(frequencyOffset, sampleRate, samples = 2_048) { + const iq = new Uint8Array(samples * 2); + for (let index = 0; index < samples; index += 1) { + const phase = (2 * Math.PI * frequencyOffset * index) / sampleRate; + iq[index * 2] = Math.round(127.5 + 100 * Math.cos(phase)); + iq[index * 2 + 1] = Math.round(127.5 + 100 * Math.sin(phase)); + } + return iq.buffer; +} + +test('FFT-shifted spectrum preserves the sign and offset of a complex tone', () => { + const sampleRate = 2_048_000; + const bins = analyzeSpectrum(toneIq(300_000, sampleRate)); + let peakIndex = 0; + for (let index = 1; index < bins.length; index += 1) { + if (bins[index] > bins[peakIndex]) peakIndex = index; + } + const offset = (peakIndex - bins.length / 2) * (sampleRate / bins.length); + assert.ok(Math.abs(offset - 300_000) < 2_000); +}); + +test('directional FM seek chooses the nearest rounded station in-band', () => { + const sampleRate = 2_048_000; + const bins = analyzeSpectrum(toneIq(300_000, sampleRate)); + const forward = findDirectionalFmPeak( + bins, + 98_500_000, + sampleRate, + 1, + 98_500_000, + ); + assert.equal(forward.frequency, 98_800_000); + assert.ok(forward.snr > 6); + assert.equal( + findDirectionalFmPeak(bins, 98_500_000, sampleRate, -1, 98_500_000), + null, + ); +}); diff --git a/src/sdr/usbDevices.js b/src/sdr/usbDevices.js new file mode 100644 index 000000000..4a4cd1ab4 --- /dev/null +++ b/src/sdr/usbDevices.js @@ -0,0 +1,203 @@ +/** + * WebUSB receiver selection for the local RTL-SDR. + * + * Dual-channel boards such as the Nooelec FlyCatcher enumerate as two USB + * devices (an ADS-B 1090 MHz channel and a UAT 978 MHz channel) that can share + * a serial number. Selection therefore remembers the full identity + * (vendorId, productId, productName, serialNumber) per receiver mode and, for + * ADS-B, prefers a channel whose product name names ADS-B or 1090 MHz. + */ + +export const SDR_DEVICE_STORAGE_KEY = 'gev:sdr:device:v1'; +/** RTL2832U vendor/product pairs requested by the WebUSB RTL-SDR provider. */ +export const RTL_SDR_USB_FILTERS = Object.freeze([ + Object.freeze({ vendorId: 0x0bda, productId: 0x2832 }), + Object.freeze({ vendorId: 0x0bda, productId: 0x2838 }), +]); +const ADSB_PRODUCT_PATTERN = /ads.?b|1090/i; +const NON_FM_PRODUCT_PATTERN = /ads.?b|1090|uat|978/i; + +function text(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +/** + * Plain identity of a WebUSB device (or a stored identity). + * @param {object|null} device USBDevice-like object. + * @returns {{vendorId:number|null, productId:number|null, productName:string|null, serialNumber:string|null}|null} + */ +export function sdrDeviceIdentity(device) { + if (!device || typeof device !== 'object') return null; + return { + vendorId: Number.isInteger(device.vendorId) ? device.vendorId : null, + productId: Number.isInteger(device.productId) ? device.productId : null, + productName: text(device.productName), + serialNumber: text(device.serialNumber), + }; +} + +/** + * Whether two devices or identities name the same receiver channel. + * @param {object|null} a Device or identity. + * @param {object|null} b Device or identity. + * @returns {boolean} + */ +export function sameSdrDevice(a, b) { + const left = sdrDeviceIdentity(a); + const right = sdrDeviceIdentity(b); + return Boolean( + left && + right && + left.vendorId === right.vendorId && + left.productId === right.productId && + left.productName === right.productName && + left.serialNumber === right.serialNumber, + ); +} + +/** + * Human-readable receiver label for status text. + * @param {object|null} device Device or identity. + * @returns {string} + */ +export function sdrDeviceLabel(device) { + const identity = sdrDeviceIdentity(device); + if (!identity) return 'RTL-SDR'; + return identity.productName || 'RTL-SDR'; +} + +function filterMatchesDevice(filter, device) { + return ( + (filter.vendorId === undefined || filter.vendorId === device.vendorId) && + (filter.productId === undefined || filter.productId === device.productId) && + (filter.serialNumber === undefined || + filter.serialNumber === device.serialNumber) + ); +} + +/** + * Choose an already-authorized receiver without opening the WebUSB picker. + * Order: the device remembered for this mode; then, for ADS-B, a channel + * named for ADS-B/1090 MHz, or for FM a channel not named for ADS-B/UAT; then + * the first matching device. + * @param {object[]} devices Authorized devices from `navigator.usb.getDevices()`. + * @param {object} [options] + * @param {object[]} [options.filters] WebUSB request filters. + * @param {'fm'|'adsb'} [options.mode] Receiver mode being connected. + * @param {object|null} [options.remembered] Identity remembered for this mode. + * @returns {object|null} Chosen device, or null when the picker is needed. + */ +export function selectAuthorizedSdrDevice( + devices, + { filters = [], mode = 'fm', remembered = null } = {}, +) { + const candidates = (Array.isArray(devices) ? devices : []).filter( + (device) => + device && + (filters.length === 0 || + filters.some((filter) => filterMatchesDevice(filter, device))), + ); + if (!candidates.length) return null; + if (remembered) { + const match = candidates.find((device) => + sameSdrDevice(device, remembered), + ); + if (match) return match; + } + const preferred = + mode === 'adsb' + ? candidates.find((device) => + ADSB_PRODUCT_PATTERN.test(device.productName || ''), + ) + : candidates.find( + (device) => !NON_FM_PRODUCT_PATTERN.test(device.productName || ''), + ); + return preferred || candidates[0]; +} + +/** + * Per-mode remembered receiver identities in browser storage. + * @param {Storage|null} storage Browser storage or a test double. + * @returns {{get(mode:string):object|null, set(mode:string, device:object):void}} + */ +export function createSdrDeviceMemory(storage) { + const read = () => { + try { + const value = JSON.parse( + storage?.getItem?.(SDR_DEVICE_STORAGE_KEY) || 'null', + ); + return value && typeof value === 'object' ? value : {}; + } catch { + return {}; + } + }; + return { + get(mode) { + return sdrDeviceIdentity(read()[mode]); + }, + set(mode, device) { + const identity = sdrDeviceIdentity(device); + if (!identity) return; + try { + storage?.setItem?.( + SDR_DEVICE_STORAGE_KEY, + JSON.stringify({ ...read(), [mode]: identity }), + ); + } catch { + /* storage can be unavailable or quota-limited */ + } + }, + }; +} + +/** + * Wrap `navigator.usb` so the RTL-SDR provider reuses an authorized receiver + * for the current mode before asking WebUSB to show its picker. + * @param {USB} webUsb Browser WebUSB object. + * @param {object} [options] + * @param {() => 'fm'|'adsb'} [options.getMode] Mode being connected. + * @param {{get:Function,set:Function}} [options.memory] Remembered identities. + * @param {() => boolean} [options.consumeForcePicker] Returns true once when + * the user asked to choose a different device. + * @param {(device:object) => void} [options.onSelected] Selected device hook. + * @returns {USB} Proxy exposing the same WebUSB surface. + */ +export function createRememberingWebUsb( + webUsb, + { + getMode = () => 'fm', + memory = null, + consumeForcePicker = () => false, + onSelected = () => {}, + } = {}, +) { + if (!webUsb?.getDevices || !webUsb?.requestDevice) return webUsb; + return new Proxy(webUsb, { + get(target, property) { + if (property === 'requestDevice') { + return async (options = {}) => { + const mode = getMode(); + const filters = Array.isArray(options.filters) ? options.filters : []; + let device = null; + if (!consumeForcePicker()) { + device = selectAuthorizedSdrDevice(await target.getDevices(), { + filters, + mode, + remembered: memory?.get(mode) || null, + }); + } + if (!device) { + // Only an explicit picker choice is remembered; automatic + // selection is re-derived from the preference on every connect. + device = await target.requestDevice(options); + if (device) memory?.set(mode, device); + } + if (device) onSelected(device); + return device; + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} diff --git a/src/sdr/usbDevices.test.mjs b/src/sdr/usbDevices.test.mjs new file mode 100644 index 000000000..70ba67d0e --- /dev/null +++ b/src/sdr/usbDevices.test.mjs @@ -0,0 +1,222 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + SDR_DEVICE_STORAGE_KEY, + createRememberingWebUsb, + createSdrDeviceMemory, + sameSdrDevice, + selectAuthorizedSdrDevice, +} from './usbDevices.js'; + +const RTL_FILTERS = [ + { vendorId: 0x0bda, productId: 0x2832 }, + { vendorId: 0x0bda, productId: 0x2838 }, +]; + +// A FlyCatcher enumerates as two RTL2832U devices that may share a serial. +const flyCatcherUat = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'FlyCatcher_UAT', + serialNumber: '00000001', +}; +const flyCatcherAdsb = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'FlyCatcher_ADS_B', + serialNumber: '00000001', +}; +const genericDongle = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: '00000001', +}; +const unrelated = { vendorId: 0x1234, productId: 0x5678, productName: 'Other' }; + +function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +test('ADS-B mode prefers the 1090 MHz channel of a dual-channel board', () => { + const devices = [unrelated, flyCatcherUat, flyCatcherAdsb]; + assert.equal( + selectAuthorizedSdrDevice(devices, { filters: RTL_FILTERS, mode: 'adsb' }), + flyCatcherAdsb, + ); + assert.equal( + selectAuthorizedSdrDevice( + [genericDongle, { ...flyCatcherAdsb, productName: 'Receiver 1090' }], + { + filters: RTL_FILTERS, + mode: 'adsb', + }, + ).productName, + 'Receiver 1090', + ); + assert.equal( + selectAuthorizedSdrDevice([genericDongle], { + filters: RTL_FILTERS, + mode: 'adsb', + }), + genericDongle, + 'a single generic dongle is still used for ADS-B', + ); +}); + +test('FM mode avoids ADS-B/UAT channels, and nothing matches unrelated devices', () => { + assert.equal( + selectAuthorizedSdrDevice([flyCatcherAdsb, flyCatcherUat, genericDongle], { + filters: RTL_FILTERS, + mode: 'fm', + }), + genericDongle, + ); + assert.equal( + selectAuthorizedSdrDevice([unrelated], { + filters: RTL_FILTERS, + mode: 'fm', + }), + null, + ); + assert.equal(selectAuthorizedSdrDevice([], { mode: 'adsb' }), null); +}); + +test('a remembered device wins, matched by the full identity rather than serial alone', () => { + const devices = [flyCatcherAdsb, flyCatcherUat]; + assert.equal( + selectAuthorizedSdrDevice(devices, { + filters: RTL_FILTERS, + mode: 'adsb', + remembered: { ...flyCatcherUat }, + }), + flyCatcherUat, + 'an explicit earlier choice is honored even for ADS-B', + ); + assert.equal(sameSdrDevice(flyCatcherAdsb, flyCatcherUat), false); + assert.equal(sameSdrDevice(flyCatcherAdsb, { ...flyCatcherAdsb }), true); + assert.equal( + selectAuthorizedSdrDevice(devices, { + filters: RTL_FILTERS, + mode: 'adsb', + remembered: { ...flyCatcherAdsb, serialNumber: 'unplugged' }, + }), + flyCatcherAdsb, + 'an absent remembered device falls back to the ADS-B preference', + ); +}); + +test('device memory is per mode and tolerates unavailable storage', () => { + const storage = memoryStorage(); + const memory = createSdrDeviceMemory(storage); + memory.set('adsb', flyCatcherAdsb); + memory.set('fm', genericDongle); + assert.deepEqual(memory.get('adsb'), flyCatcherAdsb); + assert.deepEqual(memory.get('fm'), genericDongle); + assert.ok( + storage.values.get(SDR_DEVICE_STORAGE_KEY).includes('FlyCatcher_ADS_B'), + ); + const blocked = createSdrDeviceMemory({ + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }); + assert.equal(blocked.get('adsb'), null); + assert.doesNotThrow(() => blocked.set('adsb', flyCatcherAdsb)); +}); + +test('the WebUSB wrapper reuses the remembered channel without the picker', async () => { + const memory = createSdrDeviceMemory(memoryStorage()); + memory.set('adsb', flyCatcherAdsb); + let pickerCalls = 0; + const selected = []; + const webUsb = createRememberingWebUsb( + { + async getDevices() { + return [flyCatcherUat, flyCatcherAdsb]; + }, + async requestDevice() { + pickerCalls += 1; + return null; + }, + }, + { + getMode: () => 'adsb', + memory, + onSelected: (device) => selected.push(device), + }, + ); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherAdsb, + ); + assert.equal(pickerCalls, 0); + assert.deepEqual(selected, [flyCatcherAdsb]); +}); + +test('CHANGE DEVICE forces the picker once and remembers the choice for the mode', async () => { + const memory = createSdrDeviceMemory(memoryStorage()); + memory.set('adsb', flyCatcherAdsb); + let force = true; + let pickerCalls = 0; + const webUsb = createRememberingWebUsb( + { + async getDevices() { + return [flyCatcherUat, flyCatcherAdsb]; + }, + async requestDevice() { + pickerCalls += 1; + return flyCatcherUat; + }, + }, + { + getMode: () => 'adsb', + memory, + consumeForcePicker: () => { + const value = force; + force = false; + return value; + }, + }, + ); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherUat, + ); + assert.equal(pickerCalls, 1); + assert.deepEqual(memory.get('adsb'), flyCatcherUat); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherUat, + ); + assert.equal(pickerCalls, 1, 'the next connection reuses the new choice'); +}); + +test('automatic selection is not remembered, so a fallback never becomes sticky', async () => { + const memory = createSdrDeviceMemory(memoryStorage()); + const webUsb = createRememberingWebUsb( + { + async getDevices() { + return [flyCatcherUat]; + }, + async requestDevice() { + throw new Error('picker should not open'); + }, + }, + { getMode: () => 'adsb', memory }, + ); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherUat, + ); + assert.equal(memory.get('adsb'), null); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js new file mode 100644 index 000000000..9c0082700 --- /dev/null +++ b/src/sources/adsbRecords.js @@ -0,0 +1,400 @@ +/** + * Source-agnostic records for locally received ADS-B aircraft. + * + * Every local receiver path produces the same plain record, so the Local ADS-B + * renderer does not care whether the frames were demodulated in this browser + * from a WebUSB RTL-SDR or decoded by a dump1090/readsb/dump978 process whose + * `aircraft.json` the server reads as a decoder feed: + * + * { + * icao: string, // lowercase 24-bit hex address + * callsign: string|null, + * lat: number|null, // degrees, null until a position decodes + * lon: number|null, + * altitudeFt: number|null, // barometric altitude + * groundSpeedKt: number|null, + * trackDeg: number|null, // true track over ground, [0, 360) + * verticalRateFpm: number|null, + * category: string|null, // ADS-B emitter category, dump1090 style + * // ('A1' light, 'A3' large, 'A7' rotorcraft…) + * onGround: boolean, // the receiver reported the aircraft on the + * // surface (dump1090 `alt_baro: "ground"`) + * lastPositionAt: number|null, // epoch ms of the newest decoded position + * lastMessageAt: number, // epoch ms of the newest CRC-valid message + * messageCount: number, + * rssiDbfs: number|null, + * band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT + * source: 'webusb'|'feed', // browser SDR or decoder feed + * } + * + * This module is portable: no Cesium, DOM, network or storage access. + */ + +/** A marker is dropped once its newest position is this old. */ +export const LOCAL_ADSB_POSITION_STALE_MS = 60_000; +/** An aircraft is forgotten once no message has arrived for this long. */ +export const LOCAL_ADSB_MESSAGE_STALE_MS = 60_000; + +const ICAO_PATTERN = /^[0-9a-f]{6}$/; +const CATEGORY_PATTERN = /^[A-D][0-7]$/; + +/** A previous accepted fix older than this is no reference for a new one. */ +export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000; +/** Speed limit (kt) for an aircraft that has not reported a ground speed. */ +export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; +/** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */ +export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; +/** Reported ground speed is multiplied by this, plus the margin below. */ +export const LOCAL_ADSB_SPEED_FACTOR = 1.5; +export const LOCAL_ADSB_SPEED_MARGIN_KT = 50; +/** Distance always allowed between two fixes (CPR and reception error). */ +export const LOCAL_ADSB_POSITION_MARGIN_M = 500; +/** + * After this many consecutive rejected fixes the newest one becomes the new + * reference: the previously accepted fix was the outlier, not the stream. + */ +export const LOCAL_ADSB_REANCHOR_AFTER = 3; +const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']); +const KT_TO_MPS = 1852 / 3600; +const EARTH_RADIUS_M = 6_371_008.8; + +function finiteOrNull(value) { + const number = typeof value === 'string' ? Number.NaN : Number(value); + return value !== null && value !== undefined && Number.isFinite(number) + ? number + : null; +} + +function normalizeIcao(value) { + const icao = String(value ?? '') + .trim() + .toLowerCase(); + return ICAO_PATTERN.test(icao) ? icao : null; +} + +function normalizeCallsign(value) { + const callsign = String(value ?? '') + .replace(/[^0-9A-Za-z]/g, ' ') + .trim() + .toUpperCase(); + return callsign || null; +} + +function normalizeTrack(value) { + const track = finiteOrNull(value); + return track === null ? null : ((track % 360) + 360) % 360; +} + +/** + * Normalize an ADS-B emitter category to the dump1090 string ('A7'). + * @param {unknown} value + * @returns {string|null} + */ +export function normalizeAdsbCategory(value) { + const category = String(value ?? '') + .trim() + .toUpperCase(); + return CATEGORY_PATTERN.test(category) ? category : null; +} + +function surfaceDistanceM(lat1, lon1, lat2, lon2) { + const toRad = Math.PI / 180; + const dLat = (lat2 - lat1) * toRad; + const dLon = (lon2 - lon1) * toRad; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a))); +} + +/** + * The fastest an aircraft is assumed to move, in knots: 1.5 × its reported + * ground speed + 50 kt, or 1,000 kt (350 kt for light aircraft, gliders and + * rotorcraft) when no speed is known. + * @param {{groundSpeedKt?:number|null, category?:string|null}} [options] + * @returns {number} + */ +export function localAdsbSpeedLimitKt({ + groundSpeedKt = null, + category = null, +} = {}) { + if (Number.isFinite(groundSpeedKt)) + return ( + Math.max(0, groundSpeedKt) * LOCAL_ADSB_SPEED_FACTOR + + LOCAL_ADSB_SPEED_MARGIN_KT + ); + return SLOW_CATEGORIES.has(normalizeAdsbCategory(category)) + ? LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT + : LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT; +} + +/** + * dump1090-style position sanity check: whether `next` is reachable from the + * previous accepted fix at a plausible speed. The allowance is a fixed + * reception margin plus the distance covered in the elapsed time + 1 s at + * 1.5 × the reported ground speed + 50 kt, or at 1,000 kt (350 kt for light + * aircraft, gliders and rotorcraft) when no speed is known. Without a + * reference younger than 10 minutes every fix is plausible. + * @param {{lat:number, lon:number, at:number}|null} previous Last accepted fix. + * @param {{lat:number, lon:number, at:number}} next Candidate fix. + * @param {{groundSpeedKt?:number|null, category?:string|null}} [options] + * @returns {boolean} + */ +export function localAdsbFixIsPlausible( + previous, + next, + { groundSpeedKt = null, category = null } = {}, +) { + if ( + !previous || + !Number.isFinite(previous.lat) || + !Number.isFinite(previous.lon) || + !Number.isFinite(previous.at) || + !Number.isFinite(next?.at) + ) + return true; + const elapsedMs = next.at - previous.at; + if (elapsedMs < 0 || elapsedMs > LOCAL_ADSB_REFERENCE_MAX_AGE_MS) return true; + const limitKt = localAdsbSpeedLimitKt({ groundSpeedKt, category }); + const allowedM = + LOCAL_ADSB_POSITION_MARGIN_M + + ((elapsedMs + 1_000) / 1_000) * limitKt * KT_TO_MPS; + return ( + surfaceDistanceM(previous.lat, previous.lon, next.lat, next.lon) <= allowedM + ); +} + +function normalizePosition(lat, lon) { + const latitude = finiteOrNull(lat); + const longitude = finiteOrNull(lon); + if ( + latitude === null || + longitude === null || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 + ) + return { lat: null, lon: null }; + return { lat: latitude, lon: longitude }; +} + +/** + * Map one track from the browser Mode S decoder into a local ADS-B record. + * @param {object} track Decoder track (icao, callsign, latitude, longitude, + * altitudeFt, speedKt, headingDeg, verticalRateFpm, messages, lastSeen, + * lastPositionAt, onGround from surface position messages). + * @returns {object|null} Normalized record, or null without a valid address. + */ +export function recordFromDecoderTrack(track) { + const icao = normalizeIcao(track?.icao); + if (!icao) return null; + const { lat, lon } = normalizePosition(track.latitude, track.longitude); + return { + icao, + callsign: normalizeCallsign(track.callsign), + category: normalizeAdsbCategory(track.category), + onGround: track.onGround === true, + lat, + lon, + altitudeFt: finiteOrNull(track.altitudeFt), + groundSpeedKt: finiteOrNull(track.speedKt), + trackDeg: normalizeTrack(track.headingDeg), + verticalRateFpm: finiteOrNull(track.verticalRateFpm), + lastPositionAt: lat === null ? null : finiteOrNull(track.lastPositionAt), + lastMessageAt: finiteOrNull(track.lastSeen) ?? 0, + messageCount: Math.max(0, Math.trunc(finiteOrNull(track.messages) ?? 0)), + rssiDbfs: null, + band: '1090', + source: 'webusb', + }; +} + +/** + * Map a dump1090/readsb `aircraft.json` document into local ADS-B records. + * + * Ages (`seen`, `seen_pos`) are relative to the document's own `now`, so they + * are anchored to the caller's receipt clock rather than trusting the + * receiver host's wall clock. Entries without a valid 24-bit address (for + * example non-ICAO TIS-B `~` addresses) are skipped. + * skyaware978 writes the same document shape for 978 MHz UAT, so one adapter + * serves both bands; the caller names the band. + * @param {object} json Parsed aircraft.json document. + * @param {number} nowMs Local epoch ms at which the document was received. + * @param {object} [options] + * @param {'1090'|'978'} [options.band='1090'] Band the feed decodes. + * @returns {object[]} Normalized records. + */ +export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { + const recordBand = band === '978' ? '978' : '1090'; + const receivedAt = finiteOrNull(nowMs); + if (receivedAt === null || !Array.isArray(json?.aircraft)) return []; + const records = []; + for (const entry of json.aircraft) { + const icao = normalizeIcao(entry?.hex); + if (!icao) continue; + const seenS = Math.max(0, finiteOrNull(entry.seen) ?? 0); + const seenPosS = finiteOrNull(entry.seen_pos); + const position = normalizePosition(entry.lat, entry.lon); + const hasPosition = position.lat !== null && seenPosS !== null; + const altitude = + entry.alt_baro === 'ground' ? 0 : finiteOrNull(entry.alt_baro); + records.push({ + icao, + callsign: normalizeCallsign(entry.flight), + category: normalizeAdsbCategory(entry.category), + onGround: entry.alt_baro === 'ground', + lat: hasPosition ? position.lat : null, + lon: hasPosition ? position.lon : null, + altitudeFt: altitude, + groundSpeedKt: finiteOrNull(entry.gs), + trackDeg: normalizeTrack(entry.track), + verticalRateFpm: finiteOrNull(entry.baro_rate), + lastPositionAt: hasPosition + ? receivedAt - Math.max(0, seenPosS) * 1000 + : null, + lastMessageAt: receivedAt - seenS * 1000, + messageCount: Math.max(0, Math.trunc(finiteOrNull(entry.messages) ?? 0)), + rssiDbfs: finiteOrNull(entry.rssi), + band: recordBand, + source: 'feed', + }); + } + return records; +} + +/** + * Whether a record has been heard recently enough to keep. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms. + * @returns {boolean} + */ +export function localAdsbRecordIsLive(record, nowMs) { + return ( + Number.isFinite(record?.lastMessageAt) && + nowMs - record.lastMessageAt < LOCAL_ADSB_MESSAGE_STALE_MS + ); +} + +/** + * Whether a record carries a position fresh enough to draw a marker. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms. + * @returns {boolean} + */ +export function localAdsbPositionIsFresh(record, nowMs) { + return ( + localAdsbRecordIsLive(record, nowMs) && + Number.isFinite(record.lat) && + Number.isFinite(record.lon) && + Number.isFinite(record.lastPositionAt) && + nowMs - record.lastPositionAt < LOCAL_ADSB_POSITION_STALE_MS + ); +} + +/** + * Receiver counts shown beside the gain control. + * @param {object[]} records Local ADS-B records. + * @param {number} nowMs Current epoch ms. + * @returns {{heard:number, positioned:number}} + */ +export function summarizeLocalAdsb(records, nowMs) { + let heard = 0; + let positioned = 0; + for (const record of Array.isArray(records) ? records : []) { + if (!localAdsbRecordIsLive(record, nowMs)) continue; + heard += 1; + if (localAdsbPositionIsFresh(record, nowMs)) positioned += 1; + } + return { heard, positioned }; +} + +const BAND_ORDER = Object.freeze(['1090', '978']); +const SOURCE_ORDER = Object.freeze(['webusb', 'feed']); + +function ordered(values, order) { + return order.filter((value) => values.has(value)); +} + +function positionTime(record) { + return Number.isFinite(record?.lastPositionAt) + ? record.lastPositionAt + : Number.NEGATIVE_INFINITY; +} + +function messageTime(record) { + return Number.isFinite(record?.lastMessageAt) + ? record.lastMessageAt + : Number.NEGATIVE_INFINITY; +} + +/** + * Whether `candidate` should replace `current` for the same ICAO: the most + * recent position wins, and on a tie the most recent message. + * @param {object} candidate + * @param {object} current + * @returns {boolean} + */ +export function localAdsbRecordIsNewer(candidate, current) { + const byPosition = positionTime(candidate) - positionTime(current); + if (byPosition !== 0 && !Number.isNaN(byPosition)) return byPosition > 0; + return messageTime(candidate) > messageTime(current); +} + +/** + * Merge records from every local input (browser SDR, decoder feeds) into one + * record per ICAO. + * + * Keeps the record with the most recent position (tie: most recent message) + * and annotates it with every band and source that heard the aircraft within + * the last 60 s. A category missing from the winning record is taken from + * another input that decoded it. `memory` (a Map owned by the caller) carries those + * receptions across calls, so an input that drops an aircraft does not erase + * that it was heard there moments ago. + * @param {object[][]} inputs Record lists; each record has `band`/`source`. + * @param {number} nowMs Current epoch ms. + * @param {Map>} [memory] Per-ICAO reception log + * keyed `band|source`, mutated in place and pruned to 60 s. + * @returns {object[]} Merged records with `bands` and `sources` arrays. + */ +export function mergeLocalAdsbRecords(inputs, nowMs, memory = new Map()) { + const best = new Map(); + const categories = new Map(); + for (const list of Array.isArray(inputs) ? inputs : []) { + for (const record of Array.isArray(list) ? list : []) { + if (!record?.icao || !localAdsbRecordIsLive(record, nowMs)) continue; + if (record.category && !categories.has(record.icao)) + categories.set(record.icao, record.category); + const current = best.get(record.icao); + if (!current || localAdsbRecordIsNewer(record, current)) + best.set(record.icao, record); + let heard = memory.get(record.icao); + if (!heard) { + heard = new Map(); + memory.set(record.icao, heard); + } + const key = `${record.band || '1090'}|${record.source || 'webusb'}`; + heard.set(key, Math.max(heard.get(key) ?? 0, record.lastMessageAt)); + } + } + for (const [icao, heard] of memory) { + for (const [key, at] of heard) + if (nowMs - at >= LOCAL_ADSB_MESSAGE_STALE_MS) heard.delete(key); + if (!heard.size) memory.delete(icao); + } + const merged = []; + for (const [icao, record] of best) { + const bands = new Set(); + const sources = new Set(); + for (const key of memory.get(icao)?.keys() || []) { + const [band, source] = key.split('|'); + bands.add(band); + sources.add(source); + } + merged.push({ + ...record, + category: record.category || categories.get(icao) || null, + bands: ordered(bands, BAND_ORDER), + sources: ordered(sources, SOURCE_ORDER), + }); + } + return merged; +} diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs new file mode 100644 index 000000000..12070d47b --- /dev/null +++ b/src/sources/adsbRecords.test.mjs @@ -0,0 +1,326 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + LOCAL_ADSB_MESSAGE_STALE_MS, + LOCAL_ADSB_POSITION_STALE_MS, + LOCAL_ADSB_REFERENCE_MAX_AGE_MS, + localAdsbFixIsPlausible, + localAdsbPositionIsFresh, + localAdsbRecordIsLive, + normalizeAdsbCategory, + mergeLocalAdsbRecords, + normalizeDump1090Aircraft, + recordFromDecoderTrack, + summarizeLocalAdsb, +} from './adsbRecords.js'; + +const dump1090 = JSON.parse( + readFileSync( + new URL( + '../data/fixtures/adsb-austin-dump1090-aircraft.json', + import.meta.url, + ), + 'utf8', + ), +); + +test('dump1090 aircraft.json maps to the shared local ADS-B records', () => { + const now = 1_790_127_100_000; + const records = normalizeDump1090Aircraft(dump1090, now); + assert.equal(records.length, 4); + const byIcao = new Map(records.map((record) => [record.icao, record])); + + assert.deepEqual(byIcao.get('ae5d8a'), { + icao: 'ae5d8a', + callsign: 'SHINR42', + category: null, + onGround: false, + lat: 30.269662, + lon: -97.793262, + altitudeFt: 1600, + groundSpeedKt: 129.9, + trackDeg: 330.5, + verticalRateFpm: 64, + lastPositionAt: now - 34_600, + lastMessageAt: now - 30_500, + messageCount: 74, + rssiDbfs: -30, + band: '1090', + source: 'feed', + }); + const cruise = byIcao.get('a3c775'); + assert.equal(cruise.callsign, null); + assert.equal(cruise.altitudeFt, 34_000); + assert.equal(cruise.lat, 30.360625); + assert.equal(cruise.lon, -97.919659); + assert.equal(cruise.lastPositionAt, now - 43_400); + + const unpositioned = byIcao.get('a15c54'); + assert.equal(unpositioned.callsign, 'SKW3301'); + assert.equal(unpositioned.lat, null); + assert.equal(unpositioned.lon, null); + assert.equal(unpositioned.lastPositionAt, null); + assert.equal(byIcao.get('a46e6a').altitudeFt, null); + + const summary = summarizeLocalAdsb(records, now); + assert.deepEqual(summary, { heard: 4, positioned: 2 }); +}); + +test('dump1090 adapter skips invalid addresses and tolerates missing fields', () => { + const records = normalizeDump1090Aircraft( + { + now: 1, + aircraft: [ + { hex: '~12ab34', seen: 1 }, + { hex: 'zzzzzz' }, + { + hex: 'ABC123', + alt_baro: 'ground', + lat: 95, + lon: 10, + seen_pos: 1, + category: 'a7', + }, + { hex: 'def456', category: 'E9', seen: 1 }, + ], + }, + 10_000, + ); + assert.equal(records.length, 2); + assert.equal(records[0].icao, 'abc123'); + assert.equal(records[0].altitudeFt, 0); + assert.equal(records[0].onGround, true, 'dump1090 "ground" is kept'); + assert.equal(records[0].category, 'A7'); + assert.equal(records[1].category, null, 'an invalid category is dropped'); + assert.equal(records[1].onGround, false); + assert.equal(records[0].lat, null, 'out-of-range latitude is rejected'); + assert.equal(records[0].lastMessageAt, 10_000); + assert.deepEqual(normalizeDump1090Aircraft(null, 1), []); + assert.deepEqual(normalizeDump1090Aircraft({ aircraft: [] }, Number.NaN), []); +}); + +test('decoder tracks map to the same record shape', () => { + const record = recordFromDecoderTrack({ + icao: 'AE5D8A', + callsign: 'SHINR42', + category: 'A1', + latitude: 30.27, + longitude: -97.79, + altitudeFt: 1600, + speedKt: 129.9, + headingDeg: -29.5, + verticalRateFpm: 64, + messages: 48, + lastSeen: 5_000, + lastPositionAt: 4_000, + cprEven: {}, + }); + assert.deepEqual(record, { + icao: 'ae5d8a', + callsign: 'SHINR42', + category: 'A1', + onGround: false, + lat: 30.27, + lon: -97.79, + altitudeFt: 1600, + groundSpeedKt: 129.9, + trackDeg: 330.5, + verticalRateFpm: 64, + lastPositionAt: 4_000, + lastMessageAt: 5_000, + messageCount: 48, + rssiDbfs: null, + band: '1090', + source: 'webusb', + }); + assert.equal(recordFromDecoderTrack({ icao: 'nothex' }), null); +}); + +test('markers need a position newer than 60 s; records need a message newer than 60 s', () => { + assert.equal(LOCAL_ADSB_POSITION_STALE_MS, 60_000); + assert.equal(LOCAL_ADSB_MESSAGE_STALE_MS, 60_000); + const record = { + lat: 30, + lon: -97, + lastPositionAt: 0, + lastMessageAt: 50_000, + }; + assert.equal(localAdsbPositionIsFresh(record, 59_999), true); + assert.equal( + localAdsbPositionIsFresh(record, 60_000), + false, + 'still heard, but the position is too old to draw', + ); + assert.equal(localAdsbRecordIsLive(record, 60_000), true); + assert.equal(localAdsbRecordIsLive(record, 110_000), false); + assert.equal( + localAdsbPositionIsFresh({ ...record, lat: null }, 1_000), + false, + ); +}); + +test('a feed document names its band on every record', () => { + const now = 1_790_127_100_000; + const uat = normalizeDump1090Aircraft(dump1090, now, { band: '978' }); + assert.equal(uat.length, 4); + assert.ok(uat.every((record) => record.band === '978')); + assert.ok(uat.every((record) => record.source === 'feed')); +}); + +function heard(overrides = {}) { + return { + icao: 'abc123', + lat: 30, + lon: -97, + lastPositionAt: 10_000, + lastMessageAt: 10_000, + band: '1090', + source: 'webusb', + ...overrides, + }; +} + +test('merge keeps the most recent position per ICAO, then the most recent message', () => { + const memory = new Map(); + const webusb = heard({ lastPositionAt: 9_000, lastMessageAt: 11_000 }); + const feed = heard({ + lat: 31, + source: 'feed', + lastPositionAt: 10_000, + lastMessageAt: 10_000, + }); + let [merged] = mergeLocalAdsbRecords([[webusb], [feed]], 12_000, memory); + assert.equal(merged.lat, 31, 'the newer position wins over a newer message'); + assert.deepEqual(merged.bands, ['1090']); + assert.deepEqual(merged.sources, ['webusb', 'feed']); + + // Equal positions: the newer message breaks the tie, in either input order. + const tieA = heard({ lat: 1, lastMessageAt: 10_500 }); + const tieB = heard({ lat: 2, source: 'feed', lastMessageAt: 10_900 }); + for (const inputs of [ + [[tieA], [tieB]], + [[tieB], [tieA]], + ]) + assert.equal(mergeLocalAdsbRecords(inputs, 12_000)[0].lat, 2); + + // A positioned record beats one that has never decoded a position. + const bare = heard({ lat: null, lon: null, lastPositionAt: null }); + [merged] = mergeLocalAdsbRecords( + [ + [{ ...bare, lastMessageAt: 11_900 }], + [heard({ band: '978', source: 'feed' })], + ], + 12_000, + ); + assert.equal(merged.lat, 30); + assert.equal(merged.band, '978'); + assert.deepEqual(merged.bands, ['1090', '978']); +}); + +test('merge remembers the bands and sources that heard an aircraft for 60 s', () => { + const memory = new Map(); + mergeLocalAdsbRecords( + [ + [heard()], + [heard({ band: '978', source: 'feed', lastMessageAt: 20_000 })], + ], + 20_000, + memory, + ); + // The 978 feed drops the aircraft; the 1090 browser SDR still hears it. + let [merged] = mergeLocalAdsbRecords( + [[heard({ lastMessageAt: 60_000, lastPositionAt: 60_000 })], []], + 60_000, + memory, + ); + assert.deepEqual(merged.bands, ['1090', '978']); + assert.deepEqual(merged.sources, ['webusb', 'feed']); + [merged] = mergeLocalAdsbRecords( + [[heard({ lastMessageAt: 80_000, lastPositionAt: 80_000 })], []], + 80_000, + memory, + ); + assert.deepEqual(merged.bands, ['1090'], '978 reception expired after 60 s'); + assert.deepEqual(merged.sources, ['webusb']); + assert.deepEqual(mergeLocalAdsbRecords([[], []], 200_000, memory), []); + assert.equal(memory.size, 0, 'the reception log is pruned'); +}); + +test('emitter categories normalize to the dump1090 strings', () => { + assert.equal(normalizeAdsbCategory('a7'), 'A7'); + assert.equal(normalizeAdsbCategory(' B1 '), 'B1'); + assert.equal(normalizeAdsbCategory('A8'), null); + assert.equal(normalizeAdsbCategory(7), null); + assert.equal(normalizeAdsbCategory(null), null); +}); + +test('merging keeps a category decoded by any input', () => { + const base = { + icao: 'a0b702', + lat: 30.2, + lon: -97.8, + lastMessageAt: 10_000, + band: '1090', + }; + const [merged] = mergeLocalAdsbRecords( + [ + [{ ...base, category: null, lastPositionAt: 9_900, source: 'webusb' }], + [{ ...base, category: 'A7', lastPositionAt: 9_000, source: 'feed' }], + ], + 10_000, + ); + assert.equal(merged.source, 'webusb', 'the newest position still wins'); + assert.equal(merged.category, 'A7'); +}); + +test('the position sanity check follows dump1090: speed × 1.5 + margin over elapsed + 1 s', () => { + const from = { lat: 30, lon: -97, at: 0 }; + // 1 nm north in 10 s: 360 kt. + const next = { lat: 30 + 1 / 60, lon: -97, at: 10_000 }; + assert.equal(localAdsbFixIsPlausible(null, next), true, 'no reference'); + assert.equal( + localAdsbFixIsPlausible(from, next, { groundSpeedKt: 300 }), + true, + '300 kt reported allows 500 kt', + ); + assert.equal( + localAdsbFixIsPlausible(from, next, { groundSpeedKt: 120 }), + false, + '120 kt reported allows 230 kt', + ); + assert.equal( + localAdsbFixIsPlausible(from, next), + true, + 'unknown speed allows 1,000 kt', + ); + // 2 nm in 10 s: 720 kt. + const fast = { lat: 30 + 2 / 60, lon: -97, at: 10_000 }; + assert.equal(localAdsbFixIsPlausible(from, fast), true); + assert.equal( + localAdsbFixIsPlausible(from, fast, { category: 'A7' }), + false, + 'rotorcraft without a speed are capped at 350 kt', + ); + assert.equal( + localAdsbFixIsPlausible( + from, + { ...next, lat: 31, at: LOCAL_ADSB_REFERENCE_MAX_AGE_MS + 1 }, + { groundSpeedKt: 100 }, + ), + true, + 'a reference older than 10 minutes proves nothing', + ); + assert.equal( + localAdsbFixIsPlausible( + from, + { lat: 30.004, lon: -97, at: 0 }, + { + groundSpeedKt: 0, + }, + ), + true, + 'the fixed 500 m margin absorbs CPR and reception error', + ); +}); diff --git a/src/sources/reference.js b/src/sources/reference.js index e3accf58e..4bdf7e9ac 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,10 +1,12 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; +import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; /** Construct the existing reference feeds independently of application setup. */ export function createReferenceSources() { return { earthquakes: createUsgsEarthquakeSource(), + 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 9b39277bc..902a72c83 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -12,10 +12,16 @@ test('reference factories retain compatibility without starting acquisition or s assert.equal(createReferenceSources, createStandaloneReferenceSources); const first = createReferenceSources(); const second = createReferenceSources(); - assert.deepEqual(Object.keys(first), ['earthquakes', 'cables']); + assert.deepEqual(Object.keys(first), [ + 'earthquakes', + 'fire-perimeters', + 'cables', + ]); assert.notEqual(first.earthquakes, second.earthquakes); + assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); + assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); assert.equal(requests, 0); }); diff --git a/src/standalone/application.js b/src/standalone/application.js index 3aaa9bde8..cbdf9cb7f 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -48,6 +48,8 @@ export function createStandaloneApplication({ 'Nepal', signal, placeSearch, + // The locator draws the border whenever it arrives. + { budgetMs: Infinity }, ), signal: context.signal, surface: scene.operations.surface, diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 1541d25e2..f8f02595c 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -196,9 +196,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, 3); + assert.equal(calls, 4); assert.equal(json(await request()).count, 130001); - assert.equal(calls, 3); + assert.equal(calls, 4); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 87a49b196..88ca8c94e 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -8,7 +8,9 @@ import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js'; import { makeFixtureRoot } from './fixtureRoot.mjs'; test('data providers have both hooks; credential editing stays development-only', () => { - for (const plugin of localProviderPlugins()) { + const plugins = localProviderPlugins(); + assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); + for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); assert.equal( @@ -99,6 +101,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/adsblol/mil', 200], ['/api/adsbdb/type/invalid', 400], ['/api/firms/status', 200], + ['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/cctv/sources', 200], diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c394232d0..ec51ac1ab 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -16,11 +16,17 @@ import { CockpitCoordinator } from './cockpitCoordinator.js'; import { ContextControls } from './context.js'; import { CctvControls } from './cctv.js'; import { RadioControls } from './radio.js'; +import { LocalSdrControls } from './localSdrControls.js'; import { LocationNavigation } from './locationNavigation.js'; import { bindClearLayersControl } from './layers.js'; import { bindCameraOrientationControls } from './cameraOrientationControls.js'; import { createMapSourceControls } from './mapSource.js'; import { STYLES } from './effects.js'; +import { isHudLayout } from '../hudLayouts.js'; +import { + getCyberSonarControlState, + setCyberSonarControls, +} from './cyberSonarControls.js'; import * as Cesium from 'cesium'; @@ -77,6 +83,7 @@ export class StyleManager extends ShellFacade { transitLayer, aisLiveVesselsLayer, militaryAwarenessLayer, + localAdsbLayer, } = services; this.services = services; this._lifetime = new UiLifetime(); @@ -189,6 +196,17 @@ export class StyleManager extends ShellFacade { _detectionOpacityValue: this._detectionOpacityValue, _detectionSliderRow: this._detectionSliderRow, _hudBtn: this._hudBtn, + _cyberSonarBtn: this._cyberSonarBtn, + _cyberSonarRings: this._cyberSonarRings, + _cyberSonarRingsValue: this._cyberSonarRingsValue, + _cyberSonarRange: this._cyberSonarRange, + _cyberSonarRangeValue: this._cyberSonarRangeValue, + _cyberSonarIntensity: this._cyberSonarIntensity, + _cyberSonarIntensityValue: this._cyberSonarIntensityValue, + _cyberSonarOpacity: this._cyberSonarOpacity, + _cyberSonarOpacityValue: this._cyberSonarOpacityValue, + _cyberSonarSector: this._cyberSonarSector, + _cyberSonarSectorValue: this._cyberSonarSectorValue, _hudLayoutRow: this._hudLayoutRow, _hudLayoutSelect: this._hudLayoutSelect, _ppToggles: this._ppToggles, @@ -449,12 +467,13 @@ export class StyleManager extends ShellFacade { trafficLayer, flightsLayer, militaryFlightsLayer, + localAdsbLayer, satellitesLayer, cctvLayer, bikeshareLayer, transitLayer, aisLiveVesselsLayer, - ], + ].filter(Boolean), (modeLabel) => { this._updateDetectionButton(modeLabel); }, @@ -483,6 +502,12 @@ export class StyleManager extends ShellFacade { _scopeFeatherSlider: this._scopeFeatherSlider, _hudLayoutSelect: this._hudLayoutSelect, _hudBtn: this._hudBtn, + _cyberSonarBtn: this._cyberSonarBtn, + _cyberSonarRings: this._cyberSonarRings, + _cyberSonarRange: this._cyberSonarRange, + _cyberSonarIntensity: this._cyberSonarIntensity, + _cyberSonarOpacity: this._cyberSonarOpacity, + _cyberSonarSector: this._cyberSonarSector, _cleanViewBtn: this._cleanViewBtn, _cleanViewExitBtn: this._cleanViewExitBtn, _detectionDensitySlider: this._detectionDensitySlider, @@ -508,6 +533,8 @@ export class StyleManager extends ShellFacade { _applySharpenIntensity: (...args) => this._applySharpenIntensity(...args), _setHudVariant: (...args) => this._setHudVariant(...args), + _setCyberSonarEnabled: (...args) => this._setCyberSonarEnabled(...args), + _setCyberSonarSetting: (...args) => this._setCyberSonarSetting(...args), _applyDetectionDensityFromUi: (...args) => this._applyDetectionDensityFromUi(...args), _setDetectionAllocation: (...args) => @@ -860,11 +887,33 @@ export class StyleManager extends ShellFacade { isCockpitActive: () => this.cockpitView?.active, signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed, layoutCockpit: () => this.cockpitView?.scheduleContextLayout(), + // An open local receiver keeps the shared Radio panel expanded. preservePanelStateDuringClear: () => - this._preservePanelStateDuringLayerClear, + this._preservePanelStateDuringLayerClear || + Boolean(this._localSdrControls?.isActive()), scheduleLayout: () => this._scheduleRightPanelLayout(), }, }); + this._localSdrControls?.destroy(); + this._localSdrControls = null; + const receiver = this.services.localAdsbLayer?.receiver; + if (receiver) { + this._localSdrControls = new LocalSdrControls({ + document, + receiver, + feeds: this.services.localAdsbLayer?.feeds || null, + radio: radioLayer, + actions: { + isLocalAdsbEnabled: () => + Boolean(this._dataManager?.isEnabled('local-adsb')), + setLocalAdsbEnabled: (enabled) => + this._dataManager?.setEnabled('local-adsb', enabled, { + origin: 'user', + }), + scheduleLayout: () => this._scheduleRightPanelLayout(), + }, + }); + } } /** @@ -1046,16 +1095,16 @@ export class StyleManager extends ShellFacade { /** * Switches the HUD layout variant. - * @param {'tactical'|'operator'|'minimal'} variantName - Layout variant. + * @param {'tactical'|'operator'|'minimal'|'cyber'} variantName - Layout variant. * @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}} */ setHudLayout(variantName) { const variant = String(variantName ?? '').toLowerCase(); - if (!['tactical', 'operator', 'minimal'].includes(variant)) { + if (!isHudLayout(variant)) { return { ok: false, error: `Unknown HUD layout: ${variantName}` }; } this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(variant); + this._setHudVariant(variant, { applyVisualDefaults: true }); return { ok: true, layout: this.hud.getVariant(), @@ -1171,6 +1220,11 @@ export class StyleManager extends ShellFacade { }; } + /** Apply Cyber-only sonar settings through the shared visual controls. */ + setCyberSonar(settings) { + return setCyberSonarControls(this, settings); + } + /** * Full control-state snapshot — single source for voice read-back so the * agent confirms from the same state it acted on. @@ -1183,6 +1237,7 @@ export class StyleManager extends ShellFacade { hud: { visible: !!this.hud?.visible, layout: this.hud?.getVariant?.() || null, + sonar: getCyberSonarControlState(), }, detection: this.getDetectionState(), bloom: { @@ -1479,6 +1534,7 @@ export class StyleManager extends ShellFacade { this._clearLayersControl?.destroy(); this._cctvControls?.destroy(); this._radioControls?.destroy(); + this._localSdrControls?.destroy(); this._cockpitCoordinator.stop(); this._visualSettings.stop(); this.shareLinkManager?.destroy(); diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index a76d8cf7e..887f59d0b 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -293,6 +293,7 @@ export function bindCameraOrientationControls({ ); } if (!applied || applied.heading !== next.heading) { + northButton?.setAttribute('data-north-up', String(next.heading === 0)); northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`); northButton?.setAttribute( 'aria-label', diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 264c68deb..accd003f8 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -337,6 +337,33 @@ test('the needle follows a rotation too small for the camera-changed threshold', controls.destroy(); }); +test('north-up indicator follows real heading including the 360-degree seam', () => { + const { viewer } = createRealViewer(AUSTIN_GROUND); + const northButton = new FakeButton(); + const controls = bindCameraOrientationControls({ + viewer, + elements: { northButton }, + runNavigation: (_noun, navigate) => navigate(), + }); + for (const [heading, active] of [ + [0, true], + [15, false], + [359.8, true], + [359, false], + [360, true], + [90, false], + ]) { + orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000); + viewer.scene.preRender.raiseEvent(); + assert.equal( + northButton.getAttribute('data-north-up'), + String(active), + `heading ${heading}`, + ); + } + controls.destroy(); +}); + test('a settled camera writes nothing, and teardown releases every signal', () => { const { viewer } = createRealViewer(AUSTIN_GROUND); orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000); diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js index 8c653c6f6..1012c087d 100644 --- a/src/ui/cockpitDisplayPortal.js +++ b/src/ui/cockpitDisplayPortal.js @@ -1,3 +1,5 @@ +import { displayPanelScroller } from './displayPanelScroll.js'; + /** Move the existing Display groups between their two presentation surfaces. */ export class CockpitDisplayPortal { constructor({ standardPanel, cockpitPanel, groups, layout }) { @@ -20,15 +22,18 @@ export class CockpitDisplayPortal { group.before(anchor); return [{ name, group, slot, anchor }]; }); - this.standardScrollTop = standardPanel?.scrollTop || 0; + this.standardScrollTop = + displayPanelScroller(standardPanel)?.scrollTop || 0; this.cockpitScrollTop = cockpitPanel?.scrollTop || 0; const options = { passive: true, signal: this.listeners.signal }; standardPanel?.addEventListener( 'scroll', () => { - if (!this.active) this.standardScrollTop = standardPanel.scrollTop; + if (!this.active) + this.standardScrollTop = + displayPanelScroller(standardPanel).scrollTop; }, - options, + { ...options, capture: true }, ); cockpitPanel?.addEventListener( 'scroll', @@ -85,7 +90,8 @@ export class CockpitDisplayPortal { if (nextActive && this.cockpitPanel) this.cockpitPanel.scrollTop = this.cockpitScrollTop; if (!nextActive && this.standardPanel) - this.standardPanel.scrollTop = this.standardScrollTop; + displayPanelScroller(this.standardPanel).scrollTop = + this.standardScrollTop; }; if (!settle) { restoreScroll(); @@ -121,7 +127,8 @@ export class CockpitDisplayPortal { } this.cockpitPanel?.classList.remove('uses-shared-display-controls'); if (this.standardPanel) - this.standardPanel.scrollTop = this.standardScrollTop; + displayPanelScroller(this.standardPanel).scrollTop = + this.standardScrollTop; this.records = []; this.restoreOwner = null; } diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index cb6af30e1..0122a4b6e 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -2,6 +2,7 @@ import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, + resolveCyberCockpitPanelLane, } from '../cockpitUtilityLayout.js'; import { COCKPIT_UTILITY_REC_GAP_PX, @@ -90,7 +91,53 @@ export function syncContextLayout() { '--cockpit-context-left', `${desktopInset.toFixed(1)}px`, ); - this.context.style.removeProperty('--cockpit-context-top'); + if (document.documentElement.dataset.uiTheme !== 'cyber') { + this.context.style.removeProperty('--cockpit-context-top'); + this.context.style.removeProperty('--cockpit-context-max-height'); + return; + } + const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28)); + const dataPanel = document.querySelector( + '#left-panel-stack > #data-panel:not(.collapsed)', + ); + let desiredTop = defaultTop; + if (this.contextCollapsed && dataPanel) { + desiredTop = Math.max( + desiredTop, + dataPanel.getBoundingClientRect().bottom + 8, + ); + } + const contextBounds = this.context.getBoundingClientRect(); + let safeBottom = window.innerHeight - 24; + for (const selector of [ + '#cesium-credits', + '#command-dock', + '#view-switcher', + ]) { + const obstacle = document.querySelector(selector); + if (!obstacle) continue; + const style = window.getComputedStyle(obstacle); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity) === 0 + ) + continue; + const bounds = obstacle.getBoundingClientRect(); + const overlapsHorizontally = + bounds.right > contextBounds.left && bounds.left < contextBounds.right; + if (overlapsHorizontally && bounds.top > desiredTop) { + safeBottom = Math.min(safeBottom, bounds.top - 8); + } + } + const boundedTop = Math.max( + defaultTop, + Math.min(desiredTop, safeBottom - contextBounds.height), + ); + this.context.style.setProperty( + '--cockpit-context-top', + `${boundedTop.toFixed(1)}px`, + ); this.context.style.removeProperty('--cockpit-context-max-height'); } @@ -127,6 +174,34 @@ export function syncSignalLayout() { ); this.signalStream.style.removeProperty('--cockpit-signal-top'); this.signalStream.style.removeProperty('--cockpit-signal-max-height'); + const cyber = document.documentElement.dataset.uiTheme === 'cyber'; + const leftPanel = cyber + ? document.querySelector('#left-panel-stack > #data-panel') + : null; + const alignedTop = isRenderedOnScreen(leftPanel) + ? leftPanel.getBoundingClientRect().top + : null; + // Cyber's two rails share a top edge. Move the briefing below the collapsed + // strip rather than pulling the right rail upward to avoid it. + if (alignedTop !== null && utilityControls) { + const expanded = utilityControls.querySelector( + '.cockpit-utility-control.is-expanded', + ); + if (!expanded) { + const signalTop = Math.max( + Math.max(255, Math.min(300, window.innerHeight * 0.28)), + alignedTop + utilityControls.getBoundingClientRect().height + 28, + ); + this.signalStream.style.setProperty( + '--cockpit-signal-top', + `${signalTop.toFixed(1)}px`, + ); + this.signalStream.style.setProperty( + '--cockpit-signal-max-height', + `${Math.max(44, window.innerHeight - signalTop - 80).toFixed(1)}px`, + ); + } + } const signalBounds = this.signalStream.getBoundingClientRect(); const utilityBounds = utilityControls?.getBoundingClientRect(); if (utilityBounds) { @@ -151,9 +226,51 @@ export function syncSignalLayout() { collapsedLauncher.scrollHeight, ) : 0; - // Cockpit owns this anchor outright. The strip used to inherit the left - // accordion's committed top, which is solved against left-lane obstacles - // and dropped the strip straight through the briefing card below it. + if (alignedTop !== null) { + const lane = resolveCyberCockpitPanelLane({ + viewportHeight: window.innerHeight, + top: alignedTop, + launcherHeight: Math.max( + collapsedHeight, + collapsedLauncher?.getBoundingClientRect().height || 60, + ), + signalHeight: this.signalCollapsed ? signalBounds.height : 44, + contactHeight: + this.contextCollapsed && this.context + ? this.context.getBoundingClientRect().height + : 64, + }); + document.body.style.setProperty( + '--cyber-cockpit-panel-height', + `${lane.panelHeight.toFixed(2)}px`, + ); + this.hud?.style.setProperty( + '--cockpit-utility-top', + `${alignedTop.toFixed(2)}px`, + ); + this.hud?.style.setProperty( + '--cockpit-utility-max-height', + `${lane.utilityHeight.toFixed(2)}px`, + ); + this.hud?.style.setProperty( + '--cockpit-utility-expanded-max-height', + `${lane.panelHeight.toFixed(2)}px`, + ); + utilityControls.classList.remove('layout-primary-only'); + utilityControls + .querySelectorAll('.cockpit-utility-control') + .forEach((control) => control.setAttribute('aria-hidden', 'false')); + if (expandedControl) { + this.signalStream.style.setProperty( + '--cockpit-signal-top', + `${lane.signalTop.toFixed(2)}px`, + ); + } + this.syncContextLayout(); + return; + } + // Non-Cyber themes retain their independent REC/briefing corridor. Cyber + // uses the rendered Data Layers top after reserving briefing space above. // The readout only anchors the strip while it is genuinely on screen: // the Minimal variant drops it with `display:none`, but HUD Off hides the // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. @@ -169,8 +286,20 @@ export function syncSignalLayout() { collapsedHeight, recGap: COCKPIT_UTILITY_REC_GAP_PX, signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, - minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, - minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, + minTopFloor: alignedTop ?? COCKPIT_UTILITY_MIN_TOP_PX, + minTopRatio: alignedTop !== null ? 0 : COCKPIT_UTILITY_MIN_TOP_RATIO, + signalCollapsed: this.signalCollapsed, + utilityExpanded: Boolean(expandedControl), + // Only Cyber relocates its collapsed briefing below the utility lane. + // Other themes keep the measured visible card as the lower boundary. + reserveViewportLane: document.documentElement.dataset.uiTheme === 'cyber', + // Cyber keeps the collapsed briefing reachable below the utility instead + // of hiding it. Reserve its actual height, including wrapped headings. + signalHeight: + document.documentElement.dataset.uiTheme === 'cyber' && + this.signalCollapsed + ? signalBounds.height + : 0, }); const availableHeight = utilityAnchor.maxHeight; this.hud?.style.setProperty( diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs new file mode 100644 index 000000000..ae0256d4c --- /dev/null +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PanelChrome } from './panelChrome.js'; + +function fixture(theme = 'cyber') { + const nodes = new Map(); + const node = (id, collapsed = true) => { + const classes = new Set(collapsed ? ['collapsed'] : []); + const value = { id, hidden: false, children: [], + matches: () => true, + contains(target) { return this === target || this.children.some((child) => child.contains(target)); }, + classList: { + contains: (name) => classes.has(name), + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + toggle: (name, on) => on ? classes.add(name) : classes.delete(name), + }, + }; + nodes.set(id, value); + return value; + }; + const rail = node('rail'); + const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel'); + rail.children = [display, context, cctv]; + for (const panel of rail.children) panel.parentElement = rail; + context.children = [radio]; + radio.parentElement = context; + const saved = [], claimed = []; + const owner = { + _rightPanelStack: rail, + _panelLayout: {}, + _lifetime: { frame() {} }, + _syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {}, + _scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {}, + _savePanelCollapsedState: (...args) => saved.push(args), + shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} }, + }; + owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner); + return { display, context, cctv, radio, owner, saved, claimed, + doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) }, + }; +} + +test('explicit Cyber opening collapses peers and claims their restoration lanes', () => { + const f = fixture(), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true }); + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + assert.equal(f.display.classList.contains('collapsed'), false); + assert.equal(f.context.classList.contains('collapsed'), true); + assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed)); + assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel')); + f.cctv.classList.add('cyber-accordion-collapsed'); + f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true }); + assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false); + } finally { globalThis.document = prior; } +}); + +test('opening already-expanded Radio reveals Context and closes Display', () => { + const f = fixture(), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('radio-panel', false, { explicit: true }); + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + assert.equal(f.radio.classList.contains('collapsed'), false); + assert.equal(f.context.classList.contains('collapsed'), true); + f.owner.setPanelCollapsed('radio-panel', false, { explicit: true }); + assert.equal(f.context.classList.contains('collapsed'), false); + assert.equal(f.display.classList.contains('collapsed'), true); + } finally { globalThis.document = prior; } +}); + +test('non-Cyber explicit panels and restored preferences retain their existing policy', () => { + for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) { + const f = fixture(theme), prior = globalThis.document; + globalThis.document = f.doc; + try { + const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true }; + f.owner.setPanelCollapsed('pp-toggles', false, options); + f.owner.setPanelCollapsed('cctv-panel', false, options); + assert.equal(f.display.classList.contains('collapsed'), false); + assert.equal(f.cctv.classList.contains('collapsed'), false); + if (theme === 'cyber') assert.deepEqual(f.saved, []); + } finally { globalThis.document = prior; } + } +}); diff --git a/src/ui/cyberSonarControls.js b/src/ui/cyberSonarControls.js new file mode 100644 index 000000000..4e16028c6 --- /dev/null +++ b/src/ui/cyberSonarControls.js @@ -0,0 +1,86 @@ +import { + isCyberSonarEnabled, + isCyberSonarActive, + isCyberContactSonarActive, + readCyberSonarSettings, +} from '../cyberSonar.js'; + +const SETTING_FIELDS = Object.freeze({ + rings: ['rings', 3, 12], + rangePct: ['range', 60, 120], + intensityPct: ['intensity', 0, 100], + opacityPct: ['opacity', 35, 100], + sectorDeg: ['sector', 8, 60], +}); + +/** Read configuration separately from whether map effects can currently run. */ +export function getCyberSonarControlState(doc = globalThis.document) { + const root = doc?.documentElement; + const settings = readCyberSonarSettings(root); + const options = { + root, + body: doc?.body, + hud: doc?.getElementById?.('intel-hud') ?? null, + }; + return { + enabled: isCyberSonarEnabled(root), + rings: settings.rings, + rangePct: settings.range, + intensityPct: settings.intensity, + opacityPct: settings.opacity, + sectorDeg: settings.sector, + cyberSelected: root?.dataset?.uiTheme === 'cyber', + mapSweepActive: isCyberSonarActive(options), + contactSweepActive: + root?.dataset?.cyberSonarGpu === 'supported' && + isCyberContactSonarActive(options), + contactRenderer: root?.dataset?.cyberSonarGpu ?? 'not-initialized', + }; +} + +/** Validate every field before mutating through the same owner as the sliders. */ +export function setCyberSonarControls( + owner, + request, + doc = globalThis.document, +) { + const fail = (error) => ({ + ok: false, + error, + sonar: getCyberSonarControlState(doc), + }); + if (!request || typeof request !== 'object' || Array.isArray(request)) { + return fail('Provide Cyber sonar settings as an object.'); + } + const fields = Object.keys(request); + if (!fields.length) return fail('Provide at least one Cyber sonar setting.'); + for (const field of fields) { + const value = request[field]; + if (field === 'enabled') { + if (typeof value !== 'boolean') return fail('enabled must be a boolean.'); + continue; + } + if (!Object.hasOwn(SETTING_FIELDS, field)) { + return fail('Unknown Cyber sonar setting.'); + } + const [, min, max] = SETTING_FIELDS[field]; + if (!Number.isInteger(value) || value < min || value > max) { + return fail(`${field} must be an integer from ${min} to ${max}.`); + } + } + if ( + owner.hud?.getVariant?.() !== 'cyber' || + doc?.documentElement?.dataset?.uiTheme !== 'cyber' + ) { + return fail('Cyber sonar controls require the Cyber HUD layout.'); + } + for (const [field, [setting]] of Object.entries(SETTING_FIELDS)) { + if (Object.hasOwn(request, field)) { + owner._setCyberSonarSetting(setting, request[field]); + } + } + if (Object.hasOwn(request, 'enabled')) { + owner._setCyberSonarEnabled(request.enabled); + } + return { ok: true, sonar: getCyberSonarControlState(doc) }; +} diff --git a/src/ui/cyberSonarControls.test.mjs b/src/ui/cyberSonarControls.test.mjs new file mode 100644 index 000000000..b819d6f3d --- /dev/null +++ b/src/ui/cyberSonarControls.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + getCyberSonarControlState, + setCyberSonarControls, +} from './cyberSonarControls.js'; +import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js'; +import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; +import { StyleManager } from './applicationShell.js'; +import { VisualSettings } from './visualSettings.js'; +import { readFileSync } from 'node:fs'; + +function fixture(theme = 'cyber') { + const root = { + dataset: { uiTheme: theme, cyberSonarGpu: 'supported' }, + style: { setProperty() {} }, + }; + const flags = new Set(); + const hudFlags = new Set(['active']); + const doc = { + documentElement: root, + body: { classList: { contains: (name) => flags.has(name) } }, + getElementById: () => ({ + classList: { contains: (name) => hudFlags.has(name) }, + }), + }; + const calls = []; + const owner = { + hud: { getVariant: () => theme }, + _setCyberSonarSetting(name, value) { + calls.push([name, value]); + const suffix = name[0].toUpperCase() + name.slice(1); + root.dataset['cyberSonar' + suffix] = String(value); + }, + _setCyberSonarEnabled(value) { + calls.push(['enabled', value]); + root.dataset.cyberSonar = value ? 'on' : 'off'; + }, + }; + return { doc, owner, calls, flags, hudFlags, root }; +} + +test('real runner and facade use visual settings, preserve visibility and return actual values', async () => { + const f = fixture(); + const previousDocument = globalThis.document; + globalThis.document = f.doc; + const renders = []; + const visual = Object.create(VisualSettings.prototype); + visual.services = { governorRequestRender: (reason) => renders.push(reason) }; + const owner = Object.create(StyleManager.prototype); + owner._visualSettings = visual; + Object.defineProperty(owner, 'hud', { value: f.owner.hud }); + const viewer = { + camera: { moveEnd: { addEventListener() {} } }, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: new EventTarget() }, + }; + try { + const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} }); + const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 }); + assert.equal(result.ok, true); + assert.equal(result.sonar.intensityPct, 73); + assert.equal(result.sonar.sectorDeg, 9); + assert.equal(result.sonar.enabled, false); + assert.equal(f.root.dataset.uiTheme, 'cyber'); + assert.equal(f.hudFlags.has('active'), true); + assert.equal(renders.length, 3); + const before = getCyberSonarControlState(); + const invalid = await runner('set_cyber_sonar', { rings: 6, opacityPct: 0 }); + assert.equal(invalid.ok, false); + assert.deepEqual(getCyberSonarControlState(), before); + assert.equal(renders.length, 3); + const zero = await runner('set_cyber_sonar', { intensityPct: 0 }); + assert.equal(zero.sonar.intensityPct, 0); + assert.equal(zero.sonar.enabled, false); + const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} }); + assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false); + } finally { + globalThis.document = previousDocument; + } +}); + +test('native sonar sliders represent every accepted integer without browser snapping', () => { + const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8'); + for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) { + assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`)); + } +}); + +test('sonar action is available to both shared and server voice schemas', () => { + for (const tools of [GEV_ACTION_SCHEMAS, GEV_REALTIME_TOOLS]) { + const action = tools.find((tool) => tool.name === 'set_cyber_sonar'); + assert.ok(action); + assert.equal(action.parameters.additionalProperties, false); + assert.deepEqual(Object.keys(action.parameters.properties), [ + 'enabled', 'rings', 'rangePct', 'intensityPct', 'opacityPct', 'sectorDeg', + ]); + assert.equal(action.parameters.properties.intensityPct.minimum, 0); + assert.equal(action.parameters.properties.opacityPct.minimum, 35); + } +}); + +test('maps all settings to the slider owner and reads actual state, including zero', () => { + const f = fixture(); + const result = setCyberSonarControls(f.owner, { + enabled: false, rings: 6, rangePct: 80, + intensityPct: 0, opacityPct: 90, sectorDeg: 30, + }, f.doc); + assert.equal(result.ok, true); + assert.deepEqual(f.calls, [ + ['rings', 6], ['range', 80], ['intensity', 0], + ['opacity', 90], ['sector', 30], ['enabled', false], + ]); + assert.equal(result.sonar.enabled, false); + assert.equal(result.sonar.intensityPct, 0); + assert.equal(result.sonar.contactSweepActive, false); + assert.deepEqual(result.sonar, getCyberSonarControlState(f.doc)); +}); + +test('rejects the whole invalid request before any mutation', () => { + for (const request of [ + {}, null, [], { enabled: 'false' }, { rings: 2 }, { rangePct: 121 }, + { opacityPct: NaN }, { intensityPct: Infinity }, { sectorDeg: 8.5 }, + { intensityPct: '50' }, { enabled: false, unknown: 1 }, + { rings: 6, sectorDeg: 61 }, + ]) { + const f = fixture(); + assert.equal(setCyberSonarControls(f.owner, request, f.doc).ok, false); + assert.deepEqual(f.calls, []); + } +}); + +test('requires Cyber and preserves off state and every omitted setting', () => { + for (const theme of ['tactical', 'operator', 'minimal']) { + const f = fixture(theme); + assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false); + assert.deepEqual(f.calls, []); + } + const f = fixture(); + f.root.dataset.cyberSonar = 'off'; + const before = getCyberSonarControlState(f.doc); + const result = setCyberSonarControls(f.owner, { rings: 12 }, f.doc); + assert.deepEqual(result.sonar, { ...before, rings: 12 }); + assert.deepEqual(f.calls, [['rings', 12]]); +}); + +test('reports inactive map effects in Cockpit, hidden HUD, unsupported and uninitialized GPU', () => { + const f = fixture(); + f.flags.add('cockpit-mode'); + assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, false); + f.flags.clear(); + f.hudFlags.clear(); + assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false); + f.hudFlags.add('active'); + for (const value of ['unsupported', undefined]) { + if (value === undefined) delete f.root.dataset.cyberSonarGpu; + else f.root.dataset.cyberSonarGpu = value; + assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false); + } +}); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs new file mode 100644 index 000000000..033d0053e --- /dev/null +++ b/src/ui/cyberTheme.test.mjs @@ -0,0 +1,573 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + applyHudUiTheme, + CYBER_VISUAL_DEFAULTS, + cyberVisualDefaultsForHudTransition, + HUD_LAYOUTS, + normalizeHudLayout, +} from '../hudLayouts.js'; +import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js'; +import { VisualSettings } from './visualSettings.js'; +import { STYLES } from './visualPresets.js'; + +test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => { + for (const file of ['layer-panels.html', 'context.html']) { + const markup = readFileSync( + new URL(`./templates/${file}`, import.meta.url), + 'utf8', + ); + assert.match(markup, /class="cyber-panel-body" data-rail-scroller/); + assert.ok( + markup.indexOf('class="panel-header"') < + markup.indexOf('class="cyber-panel-body"'), + ); + } + const shared = readFileSync( + new URL('./styles/controls.css', import.meta.url), + 'utf8', + ); + const cyber = readFileSync( + new URL('./styles/cyber.css', import.meta.url), + 'utf8', + ); + assert.match(shared, /\.cyber-panel-body\s*\{\s*display: contents;/); + assert.match( + cyber, + /:root\[data-ui-theme='cyber'\] \.cyber-panel-body\s*\{[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain;/, + ); +}); + +function styleOwner(t, initialVariant = 'cyber') { + const previous = { document: globalThis.document, window: globalThis.window }; + const root = { dataset: {} }; + globalThis.document = { documentElement: root, querySelectorAll: () => [] }; + globalThis.window = { dispatchEvent() {} }; + t.after(() => Object.assign(globalThis, previous)); + let variant = initialVariant; + const calls = []; + const hud = { + getVariant: () => variant, + setVariant: (value) => { + variant = value; + applyHudUiTheme(root, value); + }, + setMode: (value) => calls.push(['mode', value]), + onStyleChange: (value) => calls.push(['style', value]), + }; + hud.setVariant(initialVariant); + const owner = Object.create(VisualSettings.prototype); + Object.assign(owner, { + activeStyle: 'normal', + readHud: () => hud, + readShareLinks: () => ({ onStyleChange() {} }), + _visualEffects: { + stages: Object.fromEntries( + Object.entries(STYLES).map(([name, config]) => [ + name, + { uniforms: { ...config.uniforms, intensity: 0 } }, + ]), + ), + }, + services: { governorRequestRender() {}, setDetectionStyle() {} }, + _hudLayoutSelect: { value: initialVariant }, + _styleIndicator: {}, + _setBloomEnabled: (value) => calls.push(['bloom', value]), + _setSharpenEnabled: (value) => calls.push(['sharpen', value]), + _applyDetectionPreset: (value) => calls.push(['detection', value]), + _syncShareState: () => calls.push(['share', variant]), + }); + for (const name of [ + 'setCelestialRingEnabled', + '_startTransition', + '_updateStyleMiniStatus', + '_updateSliderPanel', + '_updateHudButtonState', + '_syncIrBoost', + '_syncCockpitInheritedStyle', + '_syncCyberSonarControl', + '_scheduleAdaptivePanelLayout', + '_applyDetectionFadeFromUi', + ]) + owner[name] = () => {}; + return { owner, hud, root, calls }; +} + +test('visual styles retain Cyber through real preset application without dropping other defaults', (t) => { + const { owner, hud, root, calls } = styleOwner(t); + for (const style of [ + 'surveillance', + 'normal', + 'noir', + 'anime', + 'snow', + 'thermal', + 'retro', + ]) { + owner.setStyle(style); + assert.equal(owner.activeStyle, style); + assert.equal(hud.getVariant(), 'cyber'); + assert.equal(root.dataset.uiTheme, 'cyber'); + assert.equal(owner._hudLayoutSelect.value, 'cyber'); + assert.deepEqual(calls.at(-1), ['share', 'cyber']); + } + assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85); + assert.equal(calls.filter(([name]) => name === 'detection').length, 3); + assert.equal( + calls.filter(([name, value]) => name === 'bloom' && value === false).length, + 3, + ); + assert.equal( + calls.filter(([name, value]) => name === 'sharpen' && value === true) + .length, + 3, + ); +}); + +test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => { + const { owner, hud, root } = styleOwner(t); + for (const variant of ['tactical', 'operator', 'minimal']) { + owner._setHudVariant(variant); + assert.equal(hud.getVariant(), variant); + assert.equal(root.dataset.uiTheme, undefined); + owner.setStyle('normal'); + owner.setStyle('surveillance'); + assert.equal(hud.getVariant(), 'tactical'); + } +}); + +test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => { + const { owner, hud } = styleOwner(t); + await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } }); + assert.equal(hud.getVariant(), 'minimal'); + owner._setHudVariant('cyber'); + await owner.restoreShareState({ + style: 'surveillance', + hudVariant: 'operator', + }); + assert.equal(hud.getVariant(), 'operator'); + await owner.restoreShareState({ + style: 'thermal', + hudVariant: 'cyber', + styleParams: { palette: 0.73 }, + }); + assert.equal(hud.getVariant(), 'cyber'); + assert.equal(owner.activeStyle, 'thermal'); + assert.equal(owner.stages.thermal.uniforms.palette, 0.73); + owner._setHudVariant('cyber', { applyVisualDefaults: true }); + assert.equal(owner.stages.thermal.uniforms.palette, 0.73); + owner._setHudVariant('tactical'); + owner._setHudVariant('cyber', { applyVisualDefaults: true }); + assert.equal(owner.stages.thermal.uniforms.palette, 0.42); +}); + +const read = (relative) => + readFileSync(new URL(relative, import.meta.url), 'utf8'); + +test('Cyber setup uses a compact toolbar icon and highlights the north-up letter', () => { + const css = read('./styles/cyber.css'); + assert.match( + css, + /#top-center-actions #key-setup-chip\s*\{[^}]*inset: auto;[^}]*width: 36px;/, + ); + assert.match(css, /#key-setup-chip\[hidden\]\s*\{\s*display: none;/); + assert.match( + css, + /#north-up-view\[data-north-up='true'\]\s+\.camera-compass-n\s*\{[^}]*color: var\(--cyber-red-bright\)/, + ); +}); +const cyberStyles = read('./styles/cyber.css'); + +test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => { + assert.match( + cyberStyles, + /:root\[data-ui-theme='cyber'\]\s+:is\(\s*\.panel-collapse-btn,\s*body\.cockpit-mode \.cockpit-utility-glyph\s*\):focus-visible\s*\{[^}]*outline: 2px solid var\(--cyber-red-bright\) !important;[^}]*outline-offset: -3px;[^}]*box-shadow: none;/, + ); + assert.match( + cyberStyles, + /:root\[data-ui-theme='cyber'\]\s+:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\):is\(\s*:hover,\s*:focus-visible\s*\)\s*\{[^}]*border-color: var\(--cyber-red-bright\);/, + ); +}); + +test('Cyber Radio retains upstream nested Context ownership', () => { + assert.doesNotMatch(cyberStyles, /#right-context-rail\s*>\s*#radio-panel/); +}); + +test('Cyber cockpit launchers share Data Layers frame and expand-control treatment', () => { + assert.match( + cyberStyles, + /:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\)/, + ); + assert.match( + cyberStyles, + /body\.cockpit-mode \.cockpit-utility-launcher \{[^}]*width: var\(--cyber-panel-width\);[^}]*min-height: 60px;[^}]*clip-path: var\(--cyber-panel-frame\);/, + ); + assert.match( + cyberStyles, + /\.cockpit-utility-glyph::after \{\s*content: '\+';/, + ); + assert.match( + cyberStyles, + /\.cockpit-utility-glyph\[aria-expanded='true'\]::after \{\s*content: '−';/, + ); + assert.match( + cyberStyles, + /\.cockpit-utility-control\.is-expanded\s+\.cockpit-utility-launcher \{[^}]*width: 100%;[^}]*clip-path: none;/, + ); +}); + +test('Cyber side panels share one width and one framed surface material', () => { + assert.match(cyberStyles, /--cyber-panel-width: 272px;/); + assert.match(cyberStyles, /--cyber-right-panel-width: 312px;/); + assert.match(cyberStyles, /--cyber-panel-collapsed-width: 200px;/); + assert.match(cyberStyles, /--cyber-panel-control-size: 30px;/); + assert.match( + cyberStyles, + /--cyber-panel-surface: rgba\(18, 24, 27, 0\.92\);/, + ); + assert.match( + cyberStyles, + /\.cockpit-utility-control\.is-expanded \{[\s\S]*?width: var\(--cyber-right-panel-width\);[\s\S]*?background-color: var\(--cyber-panel-surface\);[\s\S]*?background-image: var\(--cyber-panel-surface-detail\);[\s\S]*?clip-path: var\(--cyber-panel-frame\);/, + ); + assert.match( + cyberStyles, + /\.cockpit-utility-control\.is-expanded::after \{[\s\S]*?width: 36px;[\s\S]*?border-top: 1px solid rgba\(225, 102, 102, 0\.84\);/, + ); + assert.match( + cyberStyles, + /#global-context-panel #radio-panel\.collapsed \{\s*width: 100%;/, + ); + assert.match( + cyberStyles, + /#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/, + ); + assert.match( + cyberStyles, + /:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/, + ); + assert.match( + cyberStyles, + /\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher \{\s*width: var\(--cyber-panel-collapsed-width\);/, + ); + assert.match( + cyberStyles, + /#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/, + ); + assert.doesNotMatch( + cyberStyles, + /\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/, + 'expanded Cockpit utilities must retain Display-then-Radio DOM order', + ); +}); + +test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => { + assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']); + assert.equal(normalizeHudLayout('CYBER'), 'cyber'); + assert.equal(normalizeHudLayout('unknown'), 'tactical'); +}); + +test('the Cyber HUD owns the global shell skin and leaving it restores defaults', () => { + const root = { dataset: { unrelated: 'preserved' } }; + assert.equal(applyHudUiTheme(root, 'cyber'), 'cyber'); + assert.equal(root.dataset.uiTheme, 'cyber'); + assert.equal(applyHudUiTheme(root, 'minimal'), null); + assert.equal(root.dataset.uiTheme, undefined); + assert.equal(root.dataset.unrelated, 'preserved'); +}); + +test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () => { + assert.deepEqual(CYBER_VISUAL_DEFAULTS, { + style: 'thermal', + ironbow: 0.42, + }); + assert.equal( + cyberVisualDefaultsForHudTransition('tactical', 'cyber', { + explicit: true, + }), + CYBER_VISUAL_DEFAULTS, + ); + assert.equal( + cyberVisualDefaultsForHudTransition('cyber', 'cyber', { + explicit: true, + }), + null, + ); + assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null); + assert.equal( + cyberVisualDefaultsForHudTransition('cyber', 'operator', { + explicit: true, + }), + null, + ); +}); + +test('Cyber visual defaults update the style, thermal palette and render state', () => { + const calls = []; + const owner = { + stages: { thermal: { uniforms: { palette: 0 } } }, + services: { + governorRequestRender: (reason) => calls.push(['render', reason]), + }, + setStyle: (style, options) => calls.push(['style', style, options]), + _updateSliderPanel: (style, options) => + calls.push(['sliders', style, options]), + }; + + VisualSettings.prototype._applyCyberVisualDefaults.call( + owner, + CYBER_VISUAL_DEFAULTS, + ); + + assert.equal(owner.stages.thermal.uniforms.palette, 0.42); + assert.deepEqual(calls, [ + ['style', 'thermal', { applyPreset: false, revealParameters: false }], + ['sliders', 'thermal', { reveal: false }], + ['render', 'cyber-visual-defaults'], + ]); +}); + +test('the Display selector, voice schema and final stylesheet expose Cyber', () => { + const display = read('./templates/display-controls.html'); + const stylesheet = read('../../style.css'); + const hud = read('../hud.js'); + const detection = read('../data/detection.js'); + const civilRendering = read('../layers/flights/rendering.js'); + const militaryRendering = read('../layers/military/rendering.js'); + const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud'); + + assert.match(display, /