mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge upstream/main into feature/catalonia-cctv-cameras
Keeps PR #595 current with #730-#743. Clean merge, no conflicts. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
b27e8a35c8
184 files changed
+23240
-252
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-1
@@ -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.
|
||||
|
||||
@@ -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/<id>` 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).
|
||||
|
||||
@@ -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: <https://github.com/jtarrio/webrtlsdr>
|
||||
- License: Apache License 2.0
|
||||
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
|
||||
## Signals
|
||||
|
||||
- Package: `@jtarrio/signals` 0.10.1
|
||||
- Author: Jacobo Tarrio Barreiro
|
||||
- Source: <https://github.com/jtarrio/signals>
|
||||
- License: Apache License 2.0
|
||||
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
@@ -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,
|
||||
|
||||
+170
-2
@@ -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
|
||||
|
||||
@@ -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 <n>` for dump1090-fa and `--sdr driver=rtlsdr,rtl=<n>` for
|
||||
dump978-fa. `rtl_test` lists the indexes.
|
||||
|
||||
A tar1090 or readsb install on a Raspberry Pi already serves
|
||||
`http://<pi-address>/tar1090/data/aircraft.json`. Use the Pi's private address
|
||||
or its `.local` name.
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -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(/<meta\s+property="og:updated_time"\s+content="([^"]+)"/)?.[1],
|
||||
);
|
||||
const created = Date.parse(
|
||||
html.match(
|
||||
/Date of Origin<\/th>\s*<td[^>]*>\s*<time\s+datetime="([^"]+)"/,
|
||||
)?.[1],
|
||||
);
|
||||
const createdMs = Number.isFinite(created) ? created : null;
|
||||
const changedMs = Number.isFinite(changed) ? changed : null;
|
||||
if (createdMs === null && changedMs === null)
|
||||
throw new Error('invalid_incident_page');
|
||||
return { createdMs, changedMs };
|
||||
}
|
||||
|
||||
async function acquire(key, ttl, load, store) {
|
||||
const previous = store.get(key);
|
||||
if (previous && now() - previous.savedAt < ttl)
|
||||
return { value: previous.value, stale: false };
|
||||
// Bound distinct concurrent publication requests as well as retained entries.
|
||||
if (!inFlight.has(key) && inFlight.size >= 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<Array<{address:string, family:number}>>}
|
||||
*/
|
||||
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<Response>}
|
||||
*/
|
||||
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<object>} 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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 <something specific>" (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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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([], [])),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* getRecords(layerKey) → Array<record> (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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<any>} 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();
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -86,6 +86,14 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
|
||||
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
|
||||
},
|
||||
{
|
||||
key: 'wfigs',
|
||||
html:
|
||||
'Wildfire perimeters: ' +
|
||||
'<a href="https://data-nifc.opendata.arcgis.com/" target="_blank" rel="noopener">National Interagency Fire Center (WFIGS)</a>' +
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
|
||||
+63
-4
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { firePerimetersProxy } from '../../server/providers/firePerimeters.js';
|
||||
|
||||
const incidentHtml = `<meta property="og:updated_time" content="2026-09-23T11:32:31-04:00" />
|
||||
<th>Date of Origin</th>
|
||||
<td class="text-tabular text-left"><time datetime="2026-07-15T20:20:00Z">Origin</time></td>`;
|
||||
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 [
|
||||
'<html>No markers</html>',
|
||||
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);
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
})));
|
||||
|
||||
@@ -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 (`*<hex>;`, 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.
|
||||
@@ -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,"8dabe7c55Line truncated
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '<html>');
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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<string>} 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
+13
-7
@@ -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 = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
<div class="hud-top-bar">
|
||||
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
|
||||
<span class="hud-top-bar-center">${this._missionId}</span>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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 <html> so theme tokens are available to every UI surface.
|
||||
* @param {HTMLElement|{dataset?: Record<string, string>}|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;
|
||||
}
|
||||
+28
-4
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) {
|
||||
<span data-replay-overlay-detail></span>
|
||||
</div>`;
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<object>} [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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<object>} [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<string, object>} id -> admitted model */
|
||||
const models = new Map();
|
||||
/** @type {Map<string, number>} 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<string, object>} 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<string, object>|Set<string>} 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user