From ffb332472d00e21aceb6d24e4f3ae97c8dbcff78 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:35:29 +0000 Subject: [PATCH 01/29] feat(layers): add FAA drone restrictions airspace layer Show US prohibited and restricted special-use airspace from the keyless FAA SUA FeatureServer as an off-by-default Infrastructure layer, with share-link token 3 and an informational-only disclaimer. --- CHANGELOG.md | 8 + DATA_SOURCES.md | 2 + README.md | 3 +- docs/CURRENT-STATE.md | 25 +- package.json | 2 + scripts/format-scope.json | 11 + scripts/package-boundaries.json | 32 ++ server/providers/droneAirspace.js | 135 ++++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 4 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/droneAirspace.js | 22 ++ src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 7 + src/data/droneAirspaceProxy.test.mjs | 146 +++++++++ src/data/layerState.js | 5 + src/data/layerState.test.mjs | 34 +- src/data/layerStateTokenLedger.test.mjs | 62 ++-- src/data/layerStateTokenReservations.json | 1 + src/firstRunExperience.test.mjs | 5 +- src/layers/droneAirspace/cards.js | 93 ++++++ src/layers/droneAirspace/cards.test.mjs | 74 +++++ src/layers/droneAirspace/index.js | 326 ++++++++++++++++++++ src/layers/droneAirspace/ownership.test.mjs | 161 ++++++++++ src/layers/droneAirspace/records.js | 97 ++++++ src/layers/droneAirspace/records.test.mjs | 95 ++++++ src/layers/droneAirspace/source.js | 23 ++ src/layers/droneAirspace/source.test.mjs | 105 +++++++ src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/tooling/previewServing.test.mjs | 1 + src/ui/layerPanel.js | 1 + src/voice/layerManifest.js | 24 ++ src/voice/layerManifest.test.mjs | 128 ++++++-- 35 files changed, 1573 insertions(+), 74 deletions(-) create mode 100644 server/providers/droneAirspace.js create mode 100644 src/app/layers/droneAirspace.js create mode 100644 src/data/droneAirspaceProxy.test.mjs create mode 100644 src/layers/droneAirspace/cards.js create mode 100644 src/layers/droneAirspace/cards.test.mjs create mode 100644 src/layers/droneAirspace/index.js create mode 100644 src/layers/droneAirspace/ownership.test.mjs create mode 100644 src/layers/droneAirspace/records.js create mode 100644 src/layers/droneAirspace/records.test.mjs create mode 100644 src/layers/droneAirspace/source.js create mode 100644 src/layers/droneAirspace/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..90402cad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- Add a Drone Restrictions layer for FAA prohibited (P) and restricted (R) + special-use airspace. The keyless `/api/drone-airspace` proxy pages the + public SUA FeatureServer, keeps a one-hour snapshot, and serves stale data + when a refresh fails. Zones draw as ground polygons (red prohibited, orange + restricted), selection shows name, altitudes, times of use and agency, and + the row legend states the layer is informational only. Off by default; + share-link token `3`. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b8eac358..2aa957e2b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,6 +35,7 @@ How to read this: | **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)" | +| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Drone Restrictions layer: US prohibited (P) and restricted (R) special-use airspace polygons, altitude limits, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Drone restrictions: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (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 | @@ -98,6 +99,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. +- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` uses the shared Nominatim forward-search queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries. - **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit. diff --git a/README.md b/README.md index 5d97dfb2f..6b8043e5a 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-one layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -310,6 +310,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| 🚫 **Drone Restrictions** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitudes and times of use on select — informational only, not for flight planning | FAA SUA | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..2fc40751a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## Drone Restrictions — FAA special-use airspace + +The Infrastructure group includes **Drone Restrictions** (`drone-airspace`, +share-link token `3`), off by default. `/api/drone-airspace` pages the public +FAA Special Use Airspace FeatureServer for `TYPE_CODE` P (prohibited) and R +(restricted), simplifies geometry, caches for an hour, and serves the last +good snapshot as stale when a refresh fails. The globe draws ground-clamped +outlines with a translucent fill: red for prohibited, orange for restricted. +Selecting a zone shows name, altitudes, times of use and controlling agency, +plus a notice that the layer is informational only and not a substitute for +official UAS flight-planning tools. Pick ownership is unregistered so sibling +layer cards still work over a zone. Voice can toggle the layer and query the +loaded records. See the live-layers table below. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing @@ -335,7 +349,7 @@ Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. -Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. +Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, drone restrictions, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no @@ -3070,9 +3084,18 @@ its criteria cannot be silently ignored. | Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | 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) | +| Drone Restrictions 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | 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. +Drone Restrictions is off by default (share-link token `3`) and sits in +Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA +FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last +good payload as stale when a refresh fails. Client-supplied URLs are rejected. +Polygons keep holes; pick ownership is deliberately unregistered so sibling +cards still work over a zone. The row legend and selected card state that the +layer is informational only and not a substitute for B4UFLY / LAANC. + Street Level is a collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts collapsed. It opens itself only when the user, voice or a tool switches the diff --git a/package.json b/package.json index d9bf6cf8a..ca8a972ce 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "./layers/recent-imagery": "./src/layers/recentImagery/index.js", "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", + "./layers/drone-airspace": "./src/layers/droneAirspace/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -238,6 +239,7 @@ "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", + "./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..0d72c1096 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -371,6 +371,14 @@ "src/layers/perimeters/cards.test.mjs", "src/layers/perimeters/inciweb.test.mjs", "src/layers/perimeters/ownership.test.mjs", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/cards.js", + "src/layers/droneAirspace/records.test.mjs", + "src/layers/droneAirspace/source.test.mjs", + "src/layers/droneAirspace/cards.test.mjs", + "src/layers/droneAirspace/ownership.test.mjs", "src/data/firmsHeatmap.js", "src/data/fireAnchors.js", "src/layers/firms/anchors.js", @@ -643,6 +651,7 @@ "src/app/layers/radio.js", "src/app/layers/earthquakes.js", "src/app/layers/perimeters.js", + "src/app/layers/droneAirspace.js", "src/app/layers/cctv.js", "src/app/layers/firms.js", "src/app/layers/satellites.js", @@ -824,8 +833,10 @@ "src/layers/weather/imageryOrder.test.mjs", "server/providers/cyclones.js", "server/providers/firePerimeters.js", + "server/providers/droneAirspace.js", "src/data/cyclonesProxy.test.mjs", "src/data/firePerimetersProxy.test.mjs", + "src/data/droneAirspaceProxy.test.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 497be5b08..ed1fb5c4d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2023,6 +2023,11 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/droneAirspace.js", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2602,6 +2607,11 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/droneAirspace.js", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3097,6 +3107,8 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", "src/layers/submarineCables/bundledSource.js" ], "external": [] @@ -3317,6 +3329,26 @@ ], "external": [] }, + "drone-airspace": { + "exports": ["./layers/drone-airspace"], + "modules": [ + "src/layers/droneAirspace/index.js", + "src/sources/httpBody.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js" + ], + "external": ["cesium"] + }, + "drone-airspace-source": { + "exports": ["./layers/drone-airspace/source"], + "modules": [ + "src/layers/droneAirspace/records.js", + "src/sources/httpBody.js", + "src/layers/droneAirspace/source.js" + ], + "external": [] + }, "tools": { "exports": ["./tools"], "modules": [ diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js new file mode 100644 index 000000000..3a1905fc9 --- /dev/null +++ b/server/providers/droneAirspace.js @@ -0,0 +1,135 @@ +import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js'; +import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and +// R (restricted) only — the small, always-in-force slice of the national +// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a +// payload small enough to refresh (~330 KB for the current P+R set). +const API_URL = + 'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' + + 'Special_Use_Airspace/FeatureServer/0/query?' + + new URLSearchParams({ + where: "TYPE_CODE IN ('P','R')", + outFields: [ + 'NAME', + 'TYPE_CODE', + 'UPPER_DESC', + 'LOWER_DESC', + 'TIMESOFUSE', + 'CONT_AGENT', + 'CITY', + 'STATE', + 'OBJECTID', + ].join(','), + maxAllowableOffset: '0.001', + outSR: '4326', + f: 'geojson', + }).toString(); + +const MAX_PAGES = 5; +const MIB = 1024 * 1024; +const SNAPSHOT_TTL_MS = 3_600_000; + +const exceededTransferLimit = (payload) => + payload?.exceededTransferLimit === true || + payload?.properties?.exceededTransferLimit === true; + +/** Fixed-origin, bounded FAA SUA routes for dev and preview. */ +export function droneAirspaceProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 }); + + async function upstream(url, cap, timeout) { + const signal = AbortSignal.timeout(timeout); + const response = await fetchImpl(url, { + signal, + redirect: 'error', + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error('upstream_unavailable'); + } + return readResponseJsonCapped(response, cap, signal); + } + + async function fetchZones() { + 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, 8 * 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 = normalizeDroneAirspaceSnapshot({ features }); + if (!rows) throw new Error('invalid_snapshot'); + return { fetchedAt: now(), rows }; + } + + async function acquire(key, ttl, load) { + const previous = cache.get(key); + if (previous && now() - previous.savedAt < ttl) + return { value: previous.value, stale: false }; + 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(); + cache.set(key, { value, savedAt: now() }); + 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]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); + try { + const { value, stale } = await acquire( + 'zones', + SNAPSHOT_TTL_MS, + fetchZones, + ); + json(200, stale ? { ...value, stale: true } : value, stale); + } catch (error) { + json(error.status === 429 ? 429 : 502, { + error: 'drone_airspace_unavailable', + }); + } + } + + return { + name: 'drone-airspace', + configureServer({ middlewares }) { + middlewares.use('/api/drone-airspace', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/drone-airspace', handler); + }, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 9acbade31..e976455ed 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; +import { droneAirspaceProxy } from './droneAirspace.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) { cycloneProxy(), mapillaryProxy(), firePerimetersProxy(), + droneAirspaceProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 80315fe85..fe36dd76a 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; +import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -120,6 +121,9 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), + createApplicationDroneAirspace({ + source: sources['drone-airspace'], + }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f42e82c7c..e298580ad 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 30); + assert.equal(first.layers.length, 31); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); + assert.ok(first.get('drone-airspace')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 30); + assert.equal(catalog.layers.length, 31); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js new file mode 100644 index 000000000..f3472fec4 --- /dev/null +++ b/src/app/layers/droneAirspace.js @@ -0,0 +1,22 @@ +import * as Cesium from 'cesium'; +import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire FAA special-use airspace into the application catalog. */ +export function createApplicationDroneAirspace(options) { + return createDroneAirspaceLayer({ + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 14c864ac5..c9f79e407 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 44a7c8fa0..cf8646e25 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 30); + assert.equal(actual.layers.length, 31); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..2e4a8148e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -160,6 +160,13 @@ export const DATA_CREDITS = [ ' · Incident information: ' + 'InciWeb', }, + { + key: 'faa-sua', + html: + 'Drone restrictions: ' + + 'FAA Special Use Airspace' + + ' (prohibited and restricted zones; informational only)', + }, OSM_CREDIT, OPENMAPTILES_CREDIT, { diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs new file mode 100644 index 000000000..52eb0dcc8 --- /dev/null +++ b/src/data/droneAirspaceProxy.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js'; + +const feature = (id = 310, type = 'P') => ({ + properties: { + NAME: `${type}-${id}`, + TYPE_CODE: type, + OBJECTID: id, + UPPER_DESC: 'TNI', + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-77, 38], + [-76, 38], + [-76, 39], + [-77, 38], + ], + ], + }, +}); + +function install(options = {}, hook = 'configureServer') { + let handler; + const plugin = droneAirspaceProxy(options); + assert.equal(typeof plugin.configureServer, 'function'); + assert.equal(typeof plugin.configurePreviewServer, 'function'); + plugin[hook]({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/drone-airspace'); + 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(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.equal(calls.length, 5); + assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')"); + assert.equal(calls[0].searchParams.get('f'), 'geojson'); + assert.equal(calls[0].searchParams.has('resultOffset'), false); + assert.equal(calls[1].searchParams.get('resultOffset'), '1'); + }); +} + +test('a cached snapshot is reused until TTL, then refreshed', async () => { + let now = 1000; + let fetches = 0; + const request = install({ + now: () => now, + fetchImpl: async () => { + fetches += 1; + return Response.json({ features: [feature(fetches)] }); + }, + }); + const first = await request(); + now += 3_599_000; + const cached = await request(); + now += 2000; + const refreshed = await request(); + assert.equal(first.body.rows[0].stableId, '1'); + assert.equal(cached.body.rows[0].stableId, '1'); + assert.equal(refreshed.body.rows[0].stableId, '2'); + assert.equal(fetches, 2); +}); + +test('a failed refresh serves the last good snapshot as stale', async () => { + let now = 1000; + let fail = false; + const request = install({ + now: () => now, + fetchImpl: async () => { + if (fail) return new Response('nope', { status: 503 }); + return Response.json({ features: [feature(7)] }); + }, + }); + const first = await request(); + fail = true; + now += 3_600_001; + const stale = await request(); + assert.equal(first.status, 200); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.rows[0].stableId, '7'); +}); + +test('method, unknown routes and rate limits are rejected', async () => { + const request = install({ + fetchImpl: async () => Response.json({ features: [feature()] }), + }); + assert.equal((await request('/', 'POST')).status, 405); + assert.equal((await request('/nope')).status, 404); + const limited = install({ + fetchImpl: async () => Response.json({ features: [feature()] }), + }); + let last; + for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9'); + assert.equal(last.status, 429); +}); + +test('a cold upstream failure is 502, not an empty snapshot', async () => { + const request = install({ + fetchImpl: async () => new Response('nope', { status: 503 }), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.equal(res.body.error, 'drone_airspace_unavailable'); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index 373eac20d..bc5c660ca 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -595,6 +595,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ optionOwner: 'cctv', }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), + Object.freeze({ + id: 'drone-airspace', + token: '3', + disposition: 'enabled-only', + }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), Object.freeze({ id: 'fire-perimeters', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 08120a604..0fbdcf000 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 29); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); + assert.equal(REGISTERED_LAYER_IDS.length, 30); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,7 +242,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '3'); + assert.equal(nextLayerStateToken(), '4'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '3', - next: '4', + future: '4', + next: '5', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 3/, + /next free token 4/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' }, ), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2639,6 +2639,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s ); }); +test('drone restrictions uses digit 3 without colliding with fire perimeters', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2')); + assert.deepEqual(decoded.enabledLayerIds, [ + 'drone-airspace', + 'fire-perimeters', + ]); + assert.equal( + LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token, + '3', + ); + assert.deepEqual( + decodeLayerStateParams(new URLSearchParams(encode(decoded))), + decoded, + ); +}); + test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1')); assert.deepEqual(decoded.enabledLayerIds, [ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 5a02d85cf..56b2e1582 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' }, + { + ...LEGACY_LAYER_STATE_TOKENS, + 'street-level': '0', + 'drone-airspace': '3', + }, ); assert.throws( () => parseLayerStateTokenReservations(reservationRows.slice(1)), @@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '4']]) .future, - '3', + '4', ); assert.throws( () => @@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '3']] }, + { rows: [...reservationRows, ['retired-layer', '4']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); + assert.equal(nextLayerStateToken(published), '5'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '4', + newcomer: '5', }), true, ); @@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '3', + competing: '4', }), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '3', + newcomer: '4', }), /changed or removed: retired-layer/, ); @@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { +test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '3']], - [{ id: 'pr-b', token: '3' }], + [...reservationRows, ['pr-b', '4']], + [{ id: 'pr-b', token: '4' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '4']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '4' }, ], ); const staleRebase = runCheck(); @@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); - const validBaselineRows = [...reservationRows, ['pr-a', '3']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); + assert.equal(nextLayerStateToken(published), '5'); + const validBaselineRows = [...reservationRows, ['pr-a', '4']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 4/); + assert.match(helper.stdout, /pr-b: 5/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '5']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '4' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '5' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /30 published, 1 new/); + assert.match(corrected.stdout, /31 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'3456789'].map((digit) => [ + const priorDigits = [...'456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 69); + assert.equal(params.get('l').length, 71); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 30); + assert.equal(Object.keys(published).length, 31); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index eb49eed27..6024f5248 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -6,6 +6,7 @@ ["bikeshare", "b"], ["cctv", "c"], ["directions", "n"], + ["drone-airspace", "3"], ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 49711163f..cd8efce77 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. // Street Level adds toggle enum values and the generated alias hint. - assert.equal(block.length, 29802, 'serialized tool schema length drifted'); + // Drone Restrictions adds toggle/query enum values and spoken aliases. + assert.equal(block.length, 29988, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed', + 'e3605286e8b91c07f29b687cb3cf0e9400fef92225fbc7efeaa35451274df571', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/droneAirspace/cards.js b/src/layers/droneAirspace/cards.js new file mode 100644 index 000000000..eb6cc6433 --- /dev/null +++ b/src/layers/droneAirspace/cards.js @@ -0,0 +1,93 @@ +/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */ + +export const DRONE_AIRSPACE_OVERLAY_SOURCE_ID = 'drone-airspace'; + +/** Fill/line CSS color for a zone by TYPE_CODE. */ +export function droneAirspaceAccent(typeCode) { + return typeCode === 'P' ? '#ff3b30' : '#ff7a00'; +} + +/** Shoelace area (degree², sign dropped) — relative sizes only. */ +function ringArea(ring) { + let sum = 0; + for (let i = 0; i < ring.length - 1; i++) { + const [x1, y1] = ring[i]; + const [x2, y2] = ring[i + 1]; + sum += x1 * y2 - x2 * y1; + } + return Math.abs(sum / 2); +} + +/** + * Anchor point for the zone card: the centroid of the largest polygon's + * outer ring. Degrees, so callers own the Cartesian conversion. + * @param {Array>>} polygons + * @returns {{lon: number, lat: number}} + */ +export function droneAirspaceAnchorDegrees(polygons) { + let best = null; + let bestArea = -1; + for (const rings of polygons) { + const outer = rings[0]; + const area = ringArea(outer); + if (area > bestArea) { + bestArea = area; + best = outer; + } + } + let lon = 0; + let lat = 0; + const count = best.length - 1; + for (let i = 0; i < count; i++) { + lon += best[i][0]; + lat += best[i][1]; + } + return { lon: lon / count, lat: lat / count }; +} + +function altitudeLine(row) { + const lower = row.lowerDesc; + const upper = row.upperDesc; + if (lower && upper) return `${lower} – ${upper}`; + if (upper) return `up to ${upper}`; + if (lower) return `from ${lower}`; + return null; +} + +function placeLine(row) { + if (row.city && row.state) return `${row.city}, ${row.state}`; + return row.city || row.state || null; +} + +/** + * Build the overlay-host entry for one selected zone. The caller supplies + * `position` (Cartesian) separately — this model stays JSON-safe for tests. + * @param {Object} row - Normalized zone row (see records.js). + * @returns {Object} World-overlay entry without `position`. + */ +export function buildDroneAirspaceCard(row) { + const facts = [row.type || row.typeCode].filter(Boolean); + const altitude = altitudeLine(row); + if (altitude) facts.push(altitude); + if (row.timesOfUse) facts.push(row.timesOfUse); + + const details = [facts.join(' · ')]; + const place = placeLine(row); + if (place) details.push(place); + if (row.agency) details.push(row.agency); + details.push('Informational only · not for flight planning'); + + const title = `${(row.type || 'SUA').toUpperCase()} · ${row.name || 'Unnamed zone'}`; + return { + id: `drone-airspace-card:${row.stableId}`, + selected: true, + interactive: false, + title, + details, + accent: droneAirspaceAccent(row.typeCode), + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + }; +} diff --git a/src/layers/droneAirspace/cards.test.mjs b/src/layers/droneAirspace/cards.test.mjs new file mode 100644 index 000000000..e8d58c37c --- /dev/null +++ b/src/layers/droneAirspace/cards.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + droneAirspaceAnchorDegrees, + droneAirspaceAccent, + buildDroneAirspaceCard, +} from './cards.js'; + +const square = [ + [-77.2, 38.8], + [-77.0, 38.8], + [-77.0, 39.0], + [-77.2, 39.0], + [-77.2, 38.8], +]; +const tiny = [ + [-76.01, 38.0], + [-76.0, 38.0], + [-76.0, 38.01], + [-76.01, 38.0], +]; + +test('the card anchors at the centroid of the largest polygon', () => { + const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]); + assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9); + assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9); +}); + +test('a prohibited zone card names the type, altitudes and the disclaimer', () => { + const card = buildDroneAirspaceCard({ + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: 'WASHINGTON CENTER', + city: 'WASHINGTON', + state: 'DC', + }); + assert.equal(card.id, 'drone-airspace-card:310'); + assert.equal(card.title, 'PROHIBITED · P-56'); + assert.deepEqual(card.details, [ + 'Prohibited · SFC – TNI · CONTINUOUS', + 'WASHINGTON, DC', + 'WASHINGTON CENTER', + 'Informational only · not for flight planning', + ]); + assert.equal(card.accent, droneAirspaceAccent('P')); + assert.equal(card.interactive, false); + assert.equal(card.selected, true); +}); + +test('missing attributes degrade to available facts instead of placeholders', () => { + const card = buildDroneAirspaceCard({ + stableId: 'x', + name: null, + typeCode: 'R', + type: 'Restricted', + lowerDesc: null, + upperDesc: null, + timesOfUse: null, + agency: null, + city: null, + state: null, + }); + assert.equal(card.title, 'RESTRICTED · Unnamed zone'); + assert.deepEqual(card.details, [ + 'Restricted', + 'Informational only · not for flight planning', + ]); + assert.equal(card.accent, droneAirspaceAccent('R')); +}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js new file mode 100644 index 000000000..d7b4a40c8 --- /dev/null +++ b/src/layers/droneAirspace/index.js @@ -0,0 +1,326 @@ +import * as Cesium from 'cesium'; +import { + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + droneAirspaceAnchorDegrees, + buildDroneAirspaceCard, + droneAirspaceAccent, +} from './cards.js'; +export { normalizeDroneAirspaceSnapshot } from './records.js'; +export { createDroneAirspaceSource } from './source.js'; +export * from './cards.js'; + +/** Fill/line color for a zone by TYPE_CODE. */ +export function droneAirspaceColor(typeCode) { + return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode)); +} + +const ringPositions = (ring) => + ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); + +const PICK_PREFIX = 'drone-airspace:'; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); + +/** Own one drone-restriction display, its refresh lifecycle, and click selection. */ +export function createDroneAirspaceLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, +} = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Drone airspace requires a snapshot source'); + let _viewer = null; + let _request = null; + let _snapshotSignature = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + let _clickHandler = null; + let _selectedId = null; + let _selectedCardId = null; + /** @type {Map} */ + const _rowById = new Map(); + + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function publishSelectedCard() { + if (!canSelect()) return; + const row = _selectedId ? _rowById.get(_selectedId) : null; + if (!row) { + _selectedId = null; + _selectedCardId = null; + overlayHost.setEntries( + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const card = { + ...buildDroneAirspaceCard(row), + position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat), + }; + _selectedCardId = card.id; + overlayHost.setEntries( + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + } + + /** Resolve a scene pick to one of this layer's zone ids, or null. */ + function pickedZoneId(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0]; + return _rowById.has(zoneId) ? zoneId : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + // Deliberately NOT registered in the pick-ownership registry: sibling + // layers yield to owned picks before their own overlay-card hit-tests, + // so claiming these large ground polygons would make FIRMS/vessel/CCTV + // cards inert anywhere over a zone. Unowned, our picks read as + // "empty space" to siblings — the same contract as fire perimeters. + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) return; + const picked = _viewer.scene.pick(click.position); + const zoneId = picked ? pickedZoneId(picked) : null; + if (zoneId) { + _selectedId = zoneId; + publishSelectedCard(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return; + } + if (_selectedId) { + _selectedId = null; + publishSelectedCard(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + } + + function clearSelection() { + _selectedId = null; + _selectedCardId = null; + if (overlayHost) { + overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID); + overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false); + } + } + + const layer = { + id: 'drone-airspace', + name: 'Drone Restrictions', + icon: '🚫', + source: 'FAA SUA', + updateInterval: 3600000, + + init(viewer) { + if (_viewer) + throw new Error('Drone airspace layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('drone-airspace'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + console.log('[Data:DroneAirspace] Initialized'); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true); + installClickHandler(); + }, + + disable() { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + removeClickHandler(); + clearSelection(); + }, + + async update() { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const rows = await source.getSnapshot({ signal: request.signal }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + + const signature = JSON.stringify( + rows + .map(({ polygons, ...facts }) => facts) + .sort((a, b) => a.stableId.localeCompare(b.stableId)), + ); + if (signature === _snapshotSignature) { + if (_selectedId) publishSelectedCard(); + _lastUpdate = Date.now(); + _lastError = null; + return true; + } + const nextEntities = []; + _rowById.clear(); + for (const row of rows) { + const color = droneAirspaceColor(row.typeCode); + for (const [index, rings] of row.polygons.entries()) { + const [outer, ...holes] = rings; + const outerPositions = ringPositions(outer); + nextEntities.push( + new Cesium.Entity({ + id: `drone-airspace:${row.stableId}:${index}`, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + outerPositions, + holes.map( + (hole) => + new Cesium.PolygonHierarchy(ringPositions(hole)), + ), + ), + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.25), + ), + }, + polyline: { + positions: outerPositions, + clampToGround: true, + width: 2, + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.9), + ), + }, + }), + ); + } + const { polygons, ...facts } = row; + _rowById.set(row.stableId, { + ...facts, + anchor: droneAirspaceAnchorDegrees(polygons), + }); + } + + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); + _snapshotSignature = signature; + if (_selectedId) publishSelectedCard(); + _count = rows.length; + _lastUpdate = Date.now(); + _lastError = null; + console.log( + `[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`, + ); + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:DroneAirspace] Fetch error:', e); + _lastError = e?.message || 'Drone airspace source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + removeClickHandler(); + clearSelection(); + _rowById.clear(); + _snapshotSignature = null; + _viewer = null; + _enabled = false; + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _count = 0; + _lastUpdate = null; + _lastError = null; + }, + + getAnalystRecords(maxCount = 2000) { + if (!_dataSource || !_dataSource.show) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const row of _rowById.values()) { + if (result.length >= limit) break; + const { anchor, stableId, ...facts } = row; + result.push({ + id: stableId, + ...facts, + lat: anchor.lat, + lon: anchor.lon, + }); + } + return result; + }, + + getRowControls() { + const bands = [ + { label: 'Prohibited (P)', color: droneAirspaceAccent('P') }, + { label: 'Restricted (R)', color: droneAirspaceAccent('R') }, + ]; + return { + chips: [], + legend: bands.map((band, index) => ({ + ...band, + count: [..._rowById.values()].filter( + (row) => droneAirspaceAccent(row.typeCode) === band.color, + ).length, + ...(index === 0 + ? { + blurb: + 'FAA special-use airspace. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).', + } + : {}), + })), + }; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + }; + }, + }; + return layer; +} diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs new file mode 100644 index 000000000..ae10184f2 --- /dev/null +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDroneAirspaceLayer } from './index.js'; + +function harness(source, { pick = () => null, cardHit = () => null } = {}) { + const sources = []; + const overlay = { entries: new Map(), visible: null }; + const clicks = { handler: null, destroyed: 0 }; + const owners = new Map(); + const viewer = { + scene: { pick }, + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createDroneAirspaceLayer({ + source, + overlayHost: { + setEntries(sourceId, entries) { + overlay.entries.set(sourceId, entries); + }, + setVisible(sourceId, visible) { + overlay.visible = visible; + }, + clearSource(sourceId) { + overlay.entries.delete(sourceId); + }, + hitTest: (x, y, options) => cardHit(x, y, options), + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(callback) { + clicks.handler = callback; + }, + destroy() { + clicks.destroyed += 1; + clicks.handler = null; + }, + }), + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (layerId, pickedId) => + String(pickedId).startsWith('other-layer:'), + registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate), + unregisterPickOwner: (layerId) => owners.delete(layerId), + }, + pointer: { isPointerFree: () => true }, + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, overlay, clicks, owners }; +} + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; +const row = { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: null, + city: 'WASHINGTON', + state: 'DC', + polygons: [[ring]], +}; + +test('each zone polygon renders as one filled entity with its outline', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] }, + ], + }); + assert.equal(await h.layer.update(h.viewer), true); + const entities = h.sources[0].entities.values; + assert.equal(entities.length, 3); + assert.equal(entities[0].id, 'drone-airspace:310:0'); + assert.ok(entities[0].polygon, 'zone fill missing'); + assert.ok(entities[0].polyline, 'zone outline missing'); + assert.equal(h.layer.getStats().count, 2); +}); + +test('clicking a zone publishes its card; empty space clears it', async () => { + let pickResult = null; + const h = harness( + { getSnapshot: async () => [row] }, + { pick: () => pickResult }, + ); + await h.layer.update(h.viewer); + assert.equal(typeof h.clicks.handler, 'function', 'click handler missing'); + assert.equal(h.owners.size, 0); + + pickResult = { id: 'drone-airspace:310:0' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + const entries = h.overlay.entries.get('drone-airspace'); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'drone-airspace-card:310'); + assert.equal(entries[0].title, 'PROHIBITED · P-56'); + assert.ok(entries[0].position, 'card must carry a world anchor'); + + pickResult = { id: 'other-layer:aircraft-1' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal( + h.overlay.entries.get('drone-airspace')?.length, + 1, + 'sibling-layer picks must not clear the selection', + ); + + pickResult = null; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0); +}); + +test('late refresh cannot publish after disable or destroy', async () => { + for (const action of ['disable', 'destroy']) { + let resolve, signal; + const h = harness({ + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = h.layer.update(h.viewer); + h.layer[action](h.viewer); + assert.equal(signal.aborted, true); + if (action === 'disable') h.layer.enable(h.viewer); + resolve([row]); + assert.equal(await pending, false); + assert.equal(h.layer.getStats().count, 0); + h.layer.destroy(h.viewer); + } +}); + +test('the row legend names prohibited and restricted with the disclaimer', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: '400', typeCode: 'R', type: 'Restricted' }, + ], + }); + await h.layer.update(h.viewer); + const legend = h.layer.getRowControls().legend; + assert.equal(legend[0].label, 'Prohibited (P)'); + assert.equal(legend[1].label, 'Restricted (R)'); + assert.equal(legend[0].count, 1); + assert.equal(legend[1].count, 1); + assert.match(legend[0].blurb, /not a substitute/); +}); diff --git a/src/layers/droneAirspace/records.js b/src/layers/droneAirspace/records.js new file mode 100644 index 000000000..c1d5f73e2 --- /dev/null +++ b/src/layers/droneAirspace/records.js @@ -0,0 +1,97 @@ +/** + * Normalize an FAA Special Use Airspace feed, skipping individually invalid + * features. Only a payload that is not a feature collection at all rejects + * the whole snapshot: a single degenerate polygon must never blank the layer. + */ + +const TYPE_LABELS = Object.freeze({ + P: 'Prohibited', + R: 'Restricted', +}); + +const finiteOrNull = (value) => (Number.isFinite(value) ? value : null); +const textOrNull = (value) => { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text ? text : null; +}; + +function validRing(ring) { + if (!Array.isArray(ring) || ring.length < 4) return false; + for (const position of ring) { + if (!Array.isArray(position) || position.length < 2) return false; + const [lon, lat] = position; + if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false; + if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false; + } + return true; +} + +/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an + * array of rings). Returns null on malformed geometry, [] when empty. */ +function normalizePolygons(geometry) { + if (!geometry || typeof geometry !== 'object') return null; + let polygons; + if (geometry.type === 'Polygon') polygons = [geometry.coordinates]; + else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates; + else return null; + if (!Array.isArray(polygons)) return null; + const result = []; + for (const rings of polygons) { + if (!Array.isArray(rings)) return null; + if (!rings.length) continue; + if (!rings.every(validRing)) return null; + result.push(rings); + } + return result; +} + +/** Human label for a TYPE_CODE, or null when the code is not in the pack. */ +export function droneAirspaceTypeLabel(typeCode) { + return TYPE_LABELS[typeCode] || null; +} + +export function normalizeDroneAirspaceSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const feature of geojson.features) { + const properties = feature?.properties; + if ( + !properties || + typeof properties !== 'object' || + Array.isArray(properties) + ) + continue; + const typeCode = textOrNull(properties.TYPE_CODE); + const type = droneAirspaceTypeLabel(typeCode); + if (!type) continue; + const polygons = normalizePolygons(feature.geometry); + if (polygons === null || !polygons.length) continue; + const objectId = finiteOrNull(Number(properties.OBJECTID)); + const featureId = + feature.id == null || feature.id === '' ? null : String(feature.id); + const stableId = + objectId != null + ? String(objectId) + : featureId && !ids.has(featureId) + ? featureId + : null; + if (stableId == null || ids.has(stableId)) continue; + ids.add(stableId); + rows.push({ + stableId, + name: textOrNull(properties.NAME), + typeCode, + type, + lowerDesc: textOrNull(properties.LOWER_DESC), + upperDesc: textOrNull(properties.UPPER_DESC), + timesOfUse: textOrNull(properties.TIMESOFUSE), + agency: textOrNull(properties.CONT_AGENT), + city: textOrNull(properties.CITY), + state: textOrNull(properties.STATE), + polygons, + }); + } + return rows; +} diff --git a/src/layers/droneAirspace/records.test.mjs b/src/layers/droneAirspace/records.test.mjs new file mode 100644 index 000000000..4c8c1a215 --- /dev/null +++ b/src/layers/droneAirspace/records.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + droneAirspaceTypeLabel, + normalizeDroneAirspaceSnapshot, +} from './records.js'; + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; + +function feature(overrides = {}) { + return { + id: 310, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + NAME: 'P-56', + TYPE_CODE: 'P', + UPPER_DESC: 'TNI', + LOWER_DESC: 'SFC', + TIMESOFUSE: 'CONTINUOUS', + CONT_AGENT: 'WASHINGTON CENTER', + CITY: 'WASHINGTON', + STATE: 'DC', + OBJECTID: 310, + ...overrides.properties, + }, + ...overrides, + }; +} + +test('a valid snapshot yields one row per prohibited or restricted zone', () => { + const rows = normalizeDroneAirspaceSnapshot({ + features: [ + feature(), + feature({ + id: 400, + properties: { + NAME: 'R-2508', + TYPE_CODE: 'R', + OBJECTID: 400, + }, + }), + ], + }); + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: 'WASHINGTON CENTER', + city: 'WASHINGTON', + state: 'DC', + polygons: [[ring]], + }); + assert.equal(rows[1].type, 'Restricted'); + assert.equal(rows[1].typeCode, 'R'); +}); + +test('MOA, warning and advisory codes are dropped, as are bad geometries', () => { + const rows = normalizeDroneAirspaceSnapshot({ + features: [ + feature({ properties: { TYPE_CODE: 'MOA', OBJECTID: 1 } }), + feature({ properties: { TYPE_CODE: 'W', OBJECTID: 2 } }), + feature({ properties: { TYPE_CODE: 'A', OBJECTID: 3 } }), + feature({ geometry: null, properties: { OBJECTID: 4 } }), + feature({ + geometry: { type: 'Point', coordinates: [-77, 38] }, + properties: { OBJECTID: 5 }, + }), + feature(), + ], + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'P-56'); +}); + +test('duplicate ids and a missing collection reject or skip as documented', () => { + const dup = normalizeDroneAirspaceSnapshot({ + features: [feature(), feature({ id: 311 })], + }); + assert.equal(dup.length, 1); + assert.equal(normalizeDroneAirspaceSnapshot({}), null); + assert.equal(normalizeDroneAirspaceSnapshot(null), null); + assert.equal(droneAirspaceTypeLabel('P'), 'Prohibited'); + assert.equal(droneAirspaceTypeLabel('R'), 'Restricted'); + assert.equal(droneAirspaceTypeLabel('MOA'), null); +}); diff --git a/src/layers/droneAirspace/source.js b/src/layers/droneAirspace/source.js new file mode 100644 index 000000000..17e67b3ae --- /dev/null +++ b/src/layers/droneAirspace/source.js @@ -0,0 +1,23 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +/** Request a normalized snapshot through the bounded, same-origin FAA proxy. */ +export function createDroneAirspaceSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/drone-airspace', { signal }); + if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed drone airspace snapshot'); + return payload.rows; + }, + }; +} diff --git a/src/layers/droneAirspace/source.test.mjs b/src/layers/droneAirspace/source.test.mjs new file mode 100644 index 000000000..160fb1789 --- /dev/null +++ b/src/layers/droneAirspace/source.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDroneAirspaceSource } from './source.js'; +import { normalizeDroneAirspaceSnapshot } from './records.js'; +import { droneAirspaceProxy } from '../../../server/providers/droneAirspace.js'; + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; +const validPayload = { + features: [ + { + id: 310, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + NAME: 'P-56', + TYPE_CODE: 'P', + OBJECTID: 310, + }, + }, + ], +}; + +test('a successful response yields normalized zone rows', async () => { + let requested; + const source = createDroneAirspaceSource({ + fetchImpl: async (url) => { + requested = String(url); + return Response.json({ + rows: normalizeDroneAirspaceSnapshot(validPayload), + }); + }, + }); + const rows = await source.getSnapshot(); + assert.equal(rows.length, 1); + assert.equal(rows[0].stableId, '310'); + assert.equal(requested, '/api/drone-airspace'); +}); + +test('a truncated response pages until the feed is complete', async () => { + const pageRing = (id) => ({ + id, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { NAME: `P-${id}`, TYPE_CODE: 'P', OBJECTID: id }, + }); + const requests = []; + let handler; + const plugin = droneAirspaceProxy({ + fetchImpl: async (url) => { + requests.push(new URL(String(url)).searchParams.get('resultOffset')); + const page = requests.length; + return Response.json({ + features: [pageRing(page)], + properties: { exceededTransferLimit: page < 3 }, + }); + }, + }); + plugin.configureServer({ + middlewares: { + use: (_path, callback) => { + handler = callback; + }, + }, + }); + let payload; + await handler( + { url: '/', method: 'GET' }, + { + writeHead(status) { + assert.equal(status, 200); + }, + end(body) { + payload = JSON.parse(body); + }, + }, + ); + const rows = payload.rows; + assert.deepEqual( + rows.map((row) => row.stableId), + ['1', '2', '3'], + ); + assert.deepEqual(requests, [null, '1', '2']); +}); + +test('an upstream failure surfaces its HTTP status', async () => { + const source = createDroneAirspaceSource({ + fetchImpl: async () => ({ ok: false, status: 503 }), + }); + await assert.rejects(source.getSnapshot(), /FAA SUA HTTP 503/); +}); + +test('a malformed successful response is never accepted as an empty snapshot', async () => { + for (const payload of [{}, { rows: null }, { rows: {} }]) { + const source = createDroneAirspaceSource({ + fetchImpl: async () => Response.json(payload), + }); + await assert.rejects( + source.getSnapshot(), + /Malformed drone airspace snapshot/, + ); + } +}); diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..302e15303 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,5 +1,6 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; +import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; /** Construct the existing reference feeds independently of application setup. */ @@ -7,6 +8,7 @@ export function createReferenceSources() { return { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), + 'drone-airspace': createDroneAirspaceSource(), cables: createBundledCableSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..735b5c72e 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -15,13 +15,16 @@ test('reference factories retain compatibility without starting acquisition or s assert.deepEqual(Object.keys(first), [ 'earthquakes', 'fire-perimeters', + 'drone-airspace', 'cables', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); + assert.notEqual(first['drone-airspace'], second['drone-airspace']); assert.notEqual(first.cables, second.cables); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); + assert.equal(typeof first['drone-airspace'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); assert.equal(requests, 0); }); diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index d6eb80e20..b88b4e1f8 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs'; test('data providers have both hooks; credential editing stays development-only', () => { const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); + assert.ok(plugins.some(({ name }) => name === 'drone-airspace')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index e4c240f23..ecbf42cca 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -39,6 +39,7 @@ const PANEL_GROUPS = [ ids: [ 'alpr-cameras', 'military-installations', + 'drone-airspace', 'local-datacenters', 'telegeography-submarine-cables', 'local-dams', diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1e348ee41..facc90e32 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -327,6 +327,30 @@ const layers = [ context: true, query: { fields: { name: ['t'], class: ['t'], kind: ['t'] } }, }, + { + id: 'drone-airspace', + aliases: [ + 'drone restrictions', + 'drone airspace', + 'no-fly zones', + 'no fly zones', + 'drones', + ], + context: false, + query: { + fields: { + name: ['t'], + typeCode: ['t'], + type: ['t'], + lowerDesc: ['t'], + upperDesc: ['t'], + timesOfUse: ['t'], + agency: ['t'], + city: ['t'], + state: ['t'], + }, + }, + }, { id: 'local-datacenters', aliases: ['datacenters', 'data centers', 'data centres'], diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..98e3889bf 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -20,14 +20,28 @@ test('every catalog layer is in the voice manifest or explicitly voice-off', () const missing = catalogIds.filter( (id) => !manifestIds.includes(id) && !Object.hasOwn(VOICE_OFF_LAYERS, id), ); - assert.deepEqual(missing, [], `add a voice manifest entry for: ${missing.join(', ')}`); + assert.deepEqual( + missing, + [], + `add a voice manifest entry for: ${missing.join(', ')}`, + ); for (const id of Object.keys(VOICE_OFF_LAYERS)) { - assert.ok(catalogIds.includes(id), `${id} is voice-off but not in the catalog`); - assert.ok(!manifestIds.includes(id), `${id} is both voice-off and in the manifest`); + assert.ok( + catalogIds.includes(id), + `${id} is voice-off but not in the catalog`, + ); + assert.ok( + !manifestIds.includes(id), + `${id} is both voice-off and in the manifest`, + ); } const unknown = manifestIds.filter((id) => !catalogIds.includes(id)); assert.deepEqual(unknown, [], 'the manifest names only catalog layers'); - assert.equal(new Set(manifestIds).size, manifestIds.length, 'one entry per layer'); + assert.equal( + new Set(manifestIds).size, + manifestIds.length, + 'one entry per layer', + ); }); test('each layer declares a query or the reason it has none', () => { @@ -41,7 +55,8 @@ test('each layer declares a query or the reason it has none', () => { }); test('tool enums are generated from the manifest', () => { - const toggle = tool('set_layer_visibility').parameters.properties.layerId.enum; + const toggle = tool('set_layer_visibility').parameters.properties.layerId + .enum; assert.deepEqual(toggle, [...VOICE_TOGGLE_LAYER_IDS]); for (const id of [ 'transit', @@ -57,9 +72,13 @@ test('tool enums are generated from the manifest', () => { 'rocket-launches', 'bikeshare', 'local-adsb', + 'drone-airspace', ]) assert.ok(toggle.includes(id), `${id} is voice-toggleable`); - assert.ok(!toggle.includes('military-awareness'), 'Global Context is reached through set_context_mode'); + assert.ok( + !toggle.includes('military-awareness'), + 'Global Context is reached through set_context_mode', + ); assert.deepEqual( tool('show_data_layers_menu').parameters.properties.layerId.enum, [...VOICE_TOGGLE_LAYER_IDS], @@ -68,47 +87,98 @@ test('tool enums are generated from the manifest', () => { tool('get_entity_context').parameters.properties.layerId.enum, [...VOICE_CONTEXT_LAYER_IDS], ); - assert.ok(!VOICE_CONTEXT_LAYER_IDS.includes('telegeography-submarine-cables'), 'cables register no context records'); + assert.ok( + !VOICE_CONTEXT_LAYER_IDS.includes('telegeography-submarine-cables'), + 'cables register no context records', + ); assert.deepEqual( tool('analyst_query').parameters.properties.layers.items.enum, [...VOICE_QUERY_LAYER_IDS], ); assert.deepEqual(Object.keys(ANALYST_LAYERS), [...VOICE_QUERY_LAYER_IDS]); const panels = tool('set_panel_open').parameters.properties.panelId.enum; - assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel')); + assert.ok( + panels.includes('weather-panel') && panels.includes('recent-imagery-panel'), + ); }); test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => { const aliases = voiceLayerAliasMap(); for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id); const legacy = { - planes: 'flights', aircraft: 'flights', 'military flights': 'military', quakes: 'earthquakes', - 'space mission': 'rocket-launches', 'space missions': 'rocket-launches', missions: 'rocket-launches', - 'street traffic': 'traffic', cameras: 'cctv', 'internet radio': 'radio', 'radio stations': 'radio', - bikes: 'bikeshare', ais: 'ais-live-vessels', ships: 'ais-live-vessels', vessels: 'ais-live-vessels', - 'live vessels': 'ais-live-vessels', datacenters: 'local-datacenters', 'data centers': 'local-datacenters', - 'data centres': 'local-datacenters', dams: 'local-dams', 'submarine cables': 'telegeography-submarine-cables', - cables: 'telegeography-submarine-cables', telegeography: 'telegeography-submarine-cables', - firms: 'local-firms', fires: 'local-firms', 'active fires': 'local-firms', alpr: 'alpr-cameras', - 'alpr cameras': 'alpr-cameras', 'flock cameras': 'alpr-cameras', 'license plate readers': 'alpr-cameras', - 'license plate cameras': 'alpr-cameras', 'plate readers': 'alpr-cameras', 'local-adsb': 'local-adsb', - 'local adsb': 'local-adsb', 'local ads-b': 'local-adsb', 'my receiver': 'local-adsb', - 'my antenna': 'local-adsb', 'my sdr': 'local-adsb', + planes: 'flights', + aircraft: 'flights', + 'military flights': 'military', + quakes: 'earthquakes', + 'space mission': 'rocket-launches', + 'space missions': 'rocket-launches', + missions: 'rocket-launches', + 'street traffic': 'traffic', + cameras: 'cctv', + 'internet radio': 'radio', + 'radio stations': 'radio', + bikes: 'bikeshare', + ais: 'ais-live-vessels', + ships: 'ais-live-vessels', + vessels: 'ais-live-vessels', + 'live vessels': 'ais-live-vessels', + datacenters: 'local-datacenters', + 'data centers': 'local-datacenters', + 'data centres': 'local-datacenters', + dams: 'local-dams', + 'submarine cables': 'telegeography-submarine-cables', + cables: 'telegeography-submarine-cables', + telegeography: 'telegeography-submarine-cables', + firms: 'local-firms', + fires: 'local-firms', + 'active fires': 'local-firms', + alpr: 'alpr-cameras', + 'alpr cameras': 'alpr-cameras', + 'flock cameras': 'alpr-cameras', + 'license plate readers': 'alpr-cameras', + 'license plate cameras': 'alpr-cameras', + 'plate readers': 'alpr-cameras', + 'local-adsb': 'local-adsb', + 'local adsb': 'local-adsb', + 'local ads-b': 'local-adsb', + 'my receiver': 'local-adsb', + 'my antenna': 'local-adsb', + 'my sdr': 'local-adsb', }; - for (const [phrase, id] of Object.entries(legacy)) assert.equal(aliases.get(phrase), id, phrase); + for (const [phrase, id] of Object.entries(legacy)) + assert.equal(aliases.get(phrase), id, phrase); const added = { - buses: 'transit', trains: 'transit', hurricanes: 'weather-cyclones', cyclones: 'weather-cyclones', - 'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations', - 'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches', - 'street level': 'street-level', mapillary: 'street-level', - wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters', - 'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters', + buses: 'transit', + trains: 'transit', + hurricanes: 'weather-cyclones', + cyclones: 'weather-cyclones', + 'weather radar': 'weather-radar', + lightning: 'weather-lightning', + 'military bases': 'military-installations', + 'recent imagery': 'recent-imagery', + 'bike share': 'bikeshare', + 'rocket launches': 'rocket-launches', + 'street level': 'street-level', + mapillary: 'street-level', + wind: 'wind', + directions: 'directions', + 'fire perimeters': 'fire-perimeters', + 'wildfire perimeters': 'fire-perimeters', + 'burn areas': 'fire-perimeters', + 'drone restrictions': 'drone-airspace', + 'drone airspace': 'drone-airspace', + 'no-fly zones': 'drone-airspace', + drones: 'drone-airspace', }; - for (const [phrase, id] of Object.entries(added)) assert.equal(aliases.get(phrase), id, phrase); + for (const [phrase, id] of Object.entries(added)) + assert.equal(aliases.get(phrase), id, phrase); const claimed = new Map(); for (const entry of VOICE_LAYER_MANIFEST) for (const alias of entry.aliases) { - assert.ok(!claimed.has(alias), `"${alias}" claimed by ${claimed.get(alias)} and ${entry.id}`); + assert.ok( + !claimed.has(alias), + `"${alias}" claimed by ${claimed.get(alias)} and ${entry.id}`, + ); claimed.set(alias, entry.id); } }); From a275c2c63047964de9b2ba5241d7e6cb450edb40 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:39:15 +0000 Subject: [PATCH 02/29] test: pin voice schema and README layer count for drone airspace --- src/tooling/transitQa.test.mjs | 2 +- src/voice/actionSchemas.test.mjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 2ec277748..4e79bffbf 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -675,7 +675,7 @@ test('public transit documentation uses courtesy attribution and the approved gr ); assert.ok( readme.includes( - 'Twenty layers and map sources. **Seventeen have a keyless path.**', + 'Twenty-one layers and map sources. **Eighteen have a keyless path.**', ), ); assert.ok( diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d3b8e8a90..a62c292f7 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -37,7 +37,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. // Street Level adds its toggle enum values and generated alias hint. - '9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f', + // Drone Restrictions adds toggle/query enum values and spoken aliases. + '3ceef7975c1437eacb2c602bf9728829b299532b7dd95534cf7d9eb47115fa54', ); }); From 7a39d2d29b4ecd186b0c80c408b488d304c189cc Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:51:42 -0700 Subject: [PATCH 03/29] fix(traffic): give paired TomTom sides of one street a two-way share of dots TomTom draws a two-way street whose directions differ as two one_side lines on one centreline, and each got a one-way road's full share of simulated vehicles, so the street drew twice the dots of the same street as one full-coverage line or a two-way OSM road. A one_side line with an opposite-direction one_side line within 5 m of at least two of three points along it now gets a two-way direction's half share. On the decoded Austin flow fixture every one_side line is paired, and road length weighted by direction share falls from 553 to 417 km. Pairing a 16-tile area takes about 41 ms per road pass. Refs #1455. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++ src/layers/traffic/roadModes.js | 77 ++++++++++++++++++++++++--- src/layers/traffic/roadModes.test.mjs | 47 ++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 766823550..e882ef822 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5897,6 +5897,11 @@ easier to meet (detection is now on more often), but does not create it. TomTom draws only flow-tile lines with their own flow; roads without flow are not drawn and the status says so. A `full`-coverage flow line carries both travel directions, a `one_side` line the direction it is drawn in. + When an opposite-direction `one_side` line runs within 5 m of at least two + of three points a quarter, half and three quarters along a `one_side` line, + the two are one street's directions and each + gets a two-way direction's half share of dots, so the street is as dense as + one `full` line (#1455). OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line runs within 35 m in the same travel direction (30 degrees), tested every diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index 05b7ba4f7..e9220b635 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -35,6 +35,14 @@ const MIN_FILL_M = 40; * Same-class overlap keeps the full 35 m matcher radius. */ const CLASS_MISMATCH_RADIUS_M = 15; +/** + * Opposite-direction TomTom lines this close share one street: TomTom draws + * both directions of a two-way street a metre or two apart, and a divided + * carriageway's sides further apart. + */ +const PAIRED_SIDE_RADIUS_M = 5; +/** Arc-length positions sampled along a `one_side` line for its partner. */ +const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75]; const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']); const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']); @@ -85,20 +93,65 @@ export function isDrivableFlowSegment(segment) { ); } +/** + * Share of a `one_side` line's sample points (PAIRED_SIDE_SAMPLES) with an + * opposite-direction TomTom line beside them: TomTom draws a two-way street + * whose directions differ as two `one_side` lines on one centreline, while a + * divided carriageway's other side is further away than PAIRED_SIDE_RADIUS_M. + * @returns {number} Paired share in [0, 1]. + */ +function pairedSampleShare(segment, index) { + const xs = []; + const ys = []; + const cum = [0]; + for (const [lon, lat] of segment.coords) { + const [x, y] = index.project(lon, lat); + const i = xs.push(x) - 1; + ys.push(y); + if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1])); + } + const length = cum[cum.length - 1]; + if (!(length > 0)) return 0; + let paired = 0; + let i = 1; + for (const share of PAIRED_SIDE_SAMPLES) { + const target = share * length; + while (i < cum.length - 1 && cum[i] < target) i++; + const dx = xs[i] - xs[i - 1]; + const dy = ys[i] - ys[i - 1]; + const t = (target - cum[i - 1]) / (cum[i] - cum[i - 1] || 1); + const reverse = (Math.atan2(-dx, -dy) * 180) / Math.PI; + if ( + index.nearest( + xs[i - 1] + dx * t, + ys[i - 1] + dy * t, + reverse, + () => PAIRED_SIDE_RADIUS_M, + ).best + ) + paired++; + } + return paired / PAIRED_SIDE_SAMPLES.length; +} + /** * Convert decoded TomTom flow lines into road records that carry their own * flow. A line with `coverage: 'full'` describes both travel directions; - * `one_side` lines describe the direction they are drawn in. + * `one_side` lines describe the direction they are drawn in. A `one_side` + * line paired with the opposite direction on the same street gets that + * direction's half share of dots, as a two-way road's direction does. * @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments * @returns {Array} Road records (`directFlow: true`). */ export function flowSegmentsToRoads(segments) { - return (segments || []) - .filter( - (segment) => - segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), - ) - .map((segment) => ({ + const drivable = (segments || []).filter( + (segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), + ); + const sides = createFlowSegmentIndex( + drivable.filter((segment) => segment.coverage !== 'full'), + ); + return drivable.map((segment) => { + const road = { coordinates: segment.coords, type: FLOW_ROAD_TYPES[segment.roadType], drivable: true, @@ -110,7 +163,15 @@ export function flowSegmentsToRoads(segments) { oneway: segment.coverage === 'full' ? 0 : 1, flow: { level: segment.trafficLevel, closure: segment.closure === true }, directFlow: true, - })); + }; + if ( + segment.coverage !== 'full' && + sides && + pairedSampleShare(segment, sides) >= 0.5 + ) + road.densityWeight = 0.5; + return road; + }); } /** Cut a projected polyline to the arc-length interval [from, to]. */ diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 1d1cd4986..465d41496 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -116,6 +116,53 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', () assert.equal(roads.length, 1); assert.equal(roads[0].directFlow, true); }); +test("opposite TomTom sides of one street share a two-way street's dots", () => { + // About 1 m and 20 m north of `coordinates`, drawn westbound. + const back = (dLat) => ({ + ...flow, + coords: coordinates.map(([lon, lat]) => [lon, lat + dLat]).reverse(), + }); + const weights = (segments) => + flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1); + assert.deepEqual(weights([flow, back(0.00001)]), [0.5, 0.5]); + assert.deepEqual(weights([flow]), [1], 'a lone side keeps the one-way share'); + assert.deepEqual( + weights([flow, back(0.00018)]), + [1, 1], + 'a divided carriageway keeps one-way shares', + ); + assert.deepEqual( + weights([ + flow, + { + ...flow, + coords: coordinates.map(([lon, lat]) => [lon, lat + 0.00001]), + }, + ]), + [1, 1], + 'same-direction lines are not a pair', + ); + assert.deepEqual( + weights([{ ...flow, coverage: 'full' }, back(0.00001)]), + [1, 1], + 'a full-coverage line is not a side, and pairs with nothing', + ); +}); +test('a TomTom side paired along only part of its length keeps its share', () => { + // Westbound partners about 1 m north, from the start of `flow` to 30% and + // to 60% of its length: one or two of its three sample points. + const partner = (toLon) => ({ + ...flow, + coords: [ + [toLon, 30.26701], + [-97.744, 30.26701], + ], + }); + const weightOfFlow = (other) => + flowSegmentsToRoads([flow, other])[0].densityWeight ?? 1; + assert.equal(weightOfFlow(partner(-97.7434)), 1); + assert.equal(weightOfFlow(partner(-97.7428)), 0.5); +}); test('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 1cd4dde26df2f4bf33909fbf3894696399c8a339 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:30:45 -0800 Subject: [PATCH 04/29] docs(changelog): credit #1495 in the fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/1495-traffic-paired-sides.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1495-traffic-paired-sides.md diff --git a/changelog.d/1495-traffic-paired-sides.md b/changelog.d/1495-traffic-paired-sides.md new file mode 100644 index 000000000..4007daf8e --- /dev/null +++ b/changelog.d/1495-traffic-paired-sides.md @@ -0,0 +1,4 @@ +- Street Traffic gives the two TomTom `one_side` lines of one two-way street + a two-way direction's half share of simulated vehicles each, so the street + is no longer drawn twice as dense as the same street from OpenStreetMap + (quanticsoul4772, #1495). From 9caa0947b69489cc6975248245152074f8e275f6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:43:22 -0800 Subject: [PATCH 05/29] fix(traffic): keep a TomTom side from pairing with its own pieces A one_side line that doubles back within 5 m of itself, such as a turnaround drawn as one line, matched its own return leg and took a two-way half share with no partner. Flow index pieces now carry their source line, and a radiusFor of 0 leaves a piece out of nearest(), so pairing skips the line's own pieces. Co-Authored-By: Claude Opus 5.5 --- src/data/flowMatch.js | 6 ++++-- src/layers/traffic/roadModes.js | 3 ++- src/layers/traffic/roadModes.test.mjs | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/data/flowMatch.js b/src/data/flowMatch.js index c050f6917..cc4fe2280 100644 --- a/src/data/flowMatch.js +++ b/src/data/flowMatch.js @@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) { roadType, level, closure, + flow, }; const key = cellKey( Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M), @@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) { const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M; /** * Nearest direction-compatible segment within 35 m of a projected point. - * `radiusFor(segment)` may tighten the radius per segment (never widen it). + * `radiusFor(segment)` may tighten the radius per segment (never widen it); + * 0 leaves the segment out. */ function nearest(px, py, bearing, radiusFor = null) { const cx = Math.floor(px / CELL_SIZE_M); @@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) { candidate = true; // within radius, bearing not yet checked if (radiusFor) { const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg)); - if (d2 > radius * radius) continue; + if (!(radius > 0) || d2 > radius * radius) continue; } const diff = bearingDiffDeg(seg.bearing, bearing); if ( diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index e9220b635..a6eecc5b5 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -126,7 +126,8 @@ function pairedSampleShare(segment, index) { xs[i - 1] + dx * t, ys[i - 1] + dy * t, reverse, - () => PAIRED_SIDE_RADIUS_M, + // A line that doubles back beside itself is not its own pair. + (piece) => (piece.flow === segment ? 0 : PAIRED_SIDE_RADIUS_M), ).best ) paired++; diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 465d41496..406dc934b 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -163,6 +163,14 @@ test('a TomTom side paired along only part of its length keeps its share', () => assert.equal(weightOfFlow(partner(-97.7434)), 1); assert.equal(weightOfFlow(partner(-97.7428)), 0.5); }); +test('a TomTom side that doubles back beside itself is not its own pair', () => { + // East along `coordinates`, then back west about 1 m north. + const hairpin = { + ...flow, + coords: [...coordinates, [-97.742, 30.26701], [-97.744, 30.26701]], + }; + assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1); +}); test('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 2a629f0987f60f458f65688dd69f9a42480b18ce Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:30:20 +0530 Subject: [PATCH 06/29] fix(awareness): make the 250 km range ring readable on any ground The Global Context range ring was a Cesium ellipse outline at 1 px in rgba(98,181,255,0.72). WebGL ignores larger ellipse outline widths, so it could not be thickened, and it vanished over desert and snow and under the FLIR, NVG and CRT styles. Draw it as a polyline instead: a 4 px line with a 2 px Context-blue core inside a 1 px dark halo on each side (PolylineOutlineMaterialProperty). The halo gives luminance contrast on light and dark ground, which the monochrome styles keep. The ring sits at the same 2.5 km height and follows the subject on the same 250 m threshold. Positions come from a new awarenessRangeRingPositions helper that stays within 50 m of 250 km. Style values live in AWARENESS_RANGE_RING_STYLE (awareness/policy.js). Adds rangeRing.test.mjs (geometry at equator, Sahara, Greenland and the antimeridian; contrast; entity shape; follow threshold) and a CURRENT-STATE note. --- docs/CURRENT-STATE.md | 1 + scripts/format-scope.json | 1 + src/layers/awareness/policy.js | 15 +++ src/layers/awareness/rangeRing.test.mjs | 169 ++++++++++++++++++++++++ src/layers/awareness/rendering.js | 92 +++++++++++-- 5 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/layers/awareness/rangeRing.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be0718fd1..038d23fa0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,6 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 447dbf199..1ce21e6c0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1177,6 +1177,7 @@ "src/overlays/worldOverlayActionsView.test.mjs", "src/ui/stores/awarenessDirection.js", "src/layers/awareness/directionOverlay.test.mjs", + "src/layers/awareness/rangeRing.test.mjs", "src/hudReadouts.js", "src/ui/stores/hud.js", "src/ui/views/intelHudView.test.mjs", diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 22cc8eb98..b33da8433 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -44,6 +44,21 @@ export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000; export const CONTEXT_RIM_HEIGHT_M = 2500; +/** @constant The 250 km range ring around the Global Context subject. + * The old 1 px ellipse outline vanished over desert, snow and under + * the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps + * luminance contrast on light and dark ground alike, and luminance is what + * the monochrome styles keep. The core is the Context panel's blue; the halo + * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. */ + +export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ + color: 'rgba(98, 181, 255, 0.95)', + outlineColor: 'rgba(4, 12, 16, 0.8)', + width: 4, + outlineWidth: 1, + segments: 128, +}); + export const VESSEL_FOCUS_RADIUS_M = 3000; export const SOURCE_LABEL = { diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs new file mode 100644 index 000000000..074d449fe --- /dev/null +++ b/src/layers/awareness/rangeRing.test.mjs @@ -0,0 +1,169 @@ +// src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range +// ring is a haloed polyline that stays legible on light and dark ground, +// sits where the old ellipse outline did, and follows the subject. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; +import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; +import { awarenessRangeRingPositions, createRendering } from './rendering.js'; +import { createState } from './state.js'; + +/** WCAG relative luminance of a Cesium colour, ignoring alpha. */ +function luminance(color) { + const channel = (c) => + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + return ( + 0.2126 * channel(color.red) + + 0.7152 * channel(color.green) + + 0.0722 * channel(color.blue) + ); +} + +/** The rendering part over a bare entity collection, counting render asks. */ +function ringRendering() { + const state = createState({ services: {} }); + const entities = new Cesium.EntityCollection(); + state.viewer = { entities }; + let renders = 0; + const rendering = createRendering({ + state, + services: { + geometry: {}, + render: { + governorRequestRender() { + renders += 1; + }, + }, + }, + parts: {}, + }); + return { entities, rendering, renders: () => renders }; +} + +/** Surface distance from `center` to each ring position. */ +function surfaceDistances(center, positions) { + const from = Cesium.Cartographic.fromCartesian(center); + return positions.map((position) => { + const to = Cesium.Cartographic.fromCartesian(position); + return new Cesium.EllipsoidGeodesic(from, to).surfaceDistance; + }); +} + +for (const [place, lat, lon] of [ + ['equator', 0, 0], + ['Sahara', 27, 3], + ['Greenland', 72, -40], + ['antimeridian', 10, 179.9], +]) { + test(`ring positions stay 250 km from the subject at ${place}`, () => { + const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0); + const positions = awarenessRangeRingPositions( + center, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); + assert.equal(positions.length, AWARENESS_RANGE_RING_STYLE.segments + 1); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(positions[0], positions.at(-1), 0, 1e-6), + 'the ring closes on itself', + ); + // Within 100 m of 250 km on the WGS84 ellipsoid. + for (const distance of surfaceDistances(center, positions)) + assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100, `${distance} m`); + for (const position of positions) + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian(position).height - + CONTEXT_RIM_HEIGHT_M, + ) < 1e-3, + ); + }); +} + +test('the ring core and halo contrast on light and dark ground', () => { + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const halo = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.outlineColor, + ); + // Core against halo is the edge that survives bright desert and snow, and + // the monochrome styles that map everything to luminance. + const ratio = (luminance(core) + 0.05) / (luminance(halo) + 0.05); + assert.ok(ratio >= 4.5, `core:halo contrast ${ratio.toFixed(2)}`); + assert.ok(core.alpha >= 0.9 && halo.alpha >= 0.7); + // A visible halo on each side of a core at least 2 px wide. + const { width, outlineWidth } = AWARENESS_RANGE_RING_STYLE; + assert.ok(outlineWidth >= 1); + assert.ok(width - 2 * outlineWidth >= 2); +}); + +test('renderVisual draws one haloed polyline ring, not an ellipse outline', () => { + const { entities, rendering } = ringRendering(); + const subject = { + layerId: 'flights', + id: 'abc123', + position: Cesium.Cartesian3.fromDegrees(3, 27, 9000), + }; + rendering.renderVisual(subject); + assert.equal(entities.values.length, 1); + const [entity] = entities.values; + assert.equal(entity.ellipse, undefined); + const now = Cesium.JulianDate.now(); + assert.equal(entity.polyline.width.getValue(now), 4); + assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE); + const material = entity.polyline.material; + assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty); + assert.equal( + material.color.getValue(now).toCssColorString(), + Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ).toCssColorString(), + ); + assert.equal( + material.outlineWidth.getValue(now), + AWARENESS_RANGE_RING_STYLE.outlineWidth, + ); + const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); + for (const distance of surfaceDistances( + ground, + entity.polyline.positions.getValue(now), + )) + assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); +}); + +test('the ring follows the subject past 250 m and ignores smaller drift', () => { + const { entities, rendering, renders } = ringRendering(); + const subject = (lon) => ({ + layerId: 'flights', + id: 'abc123', + position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000), + }); + rendering.renderVisual(subject(3)); + const [entity] = entities.values; + const now = Cesium.JulianDate.now(); + const first = entity.polyline.positions.getValue(now); + const rendersAfterAdd = renders(); + + // ~100 m east: under the threshold, nothing is rebuilt or rendered. + rendering.renderVisual(subject(3.001)); + assert.equal(entity.polyline.positions.getValue(now), first); + assert.equal(renders(), rendersAfterAdd); + + // ~1 km east: the same entity moves to the new centre. + rendering.renderVisual(subject(3.01)); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0], entity); + const moved = entity.polyline.positions.getValue(now); + assert.notEqual(moved, first); + for (const distance of surfaceDistances( + Cesium.Cartesian3.fromDegrees(3.01, 27, 0), + moved, + )) + assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); + assert.equal(renders(), rendersAfterAdd + 1); + + rendering.clearVisual(); + assert.equal(entities.values.length, 0); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index f65def213..8cee87d80 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -10,9 +10,60 @@ import { AWARENESS_RADIUS_M, } from '../../data/militaryAwarenessEngine.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js'; -import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; +import { + AWARENESS_PAGE_SIZE, + AWARENESS_RANGE_RING_STYLE, + CONTEXT_RIM_HEIGHT_M, +} from './policy.js'; import { formatAwarenessDirection } from './presentation.js'; +/** + * A closed ring of positions `radiusM` along the surface from `groundCenter`, + * raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's + * radius of curvature in that direction (Euler's formula), which keeps the + * ring within a few metres of the true geodesic radius at 250 km. + * @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid. + * @param {number} radiusM Surface radius in metres. + * @param {number} heightM Height of the ring above the ellipsoid. + * @param {number} [segments] Chords around the ring. + * @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first. + */ +export function awarenessRangeRingPositions( + groundCenter, + radiusM, + heightM, + segments = AWARENESS_RANGE_RING_STYLE.segments, +) { + const { latitude, longitude } = + Cesium.Cartographic.fromCartesian(groundCenter); + const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii; + const e2 = 1 - (b * b) / (a * a); + const sinLat = Math.sin(latitude); + const cosLat = Math.cos(latitude); + const w = 1 - e2 * sinLat * sinLat; + const meridional = (a * (1 - e2)) / (w * Math.sqrt(w)); + const primeVertical = a / Math.sqrt(w); + const positions = []; + for (let i = 0; i <= segments; i++) { + const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; + const cosB = Math.cos(bearing); + const sinB = Math.sin(bearing); + const angular = + radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical); + const lat = Math.asin( + sinLat * Math.cos(angular) + cosLat * Math.sin(angular) * cosB, + ); + const lon = + longitude + + Math.atan2( + sinB * Math.sin(angular) * cosLat, + Math.cos(angular) - sinLat * Math.sin(lat), + ); + positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM)); + } + return positions; +} + export function createRendering({ state: layerState, services, @@ -292,15 +343,19 @@ export function createRendering({ ); const key = `${subject.layerId}:${subject.id}`; if (layerState.visual?.key === key) { - // Large ellipse geometry is more reliable as a ConstantPositionProperty. - // Move it only after a meaningful displacement so live tracking does not - // churn ten large geometries for sub-pixel motion every 750 ms. + // Move the ring only after a meaningful displacement so live tracking + // does not rebuild its geometry for sub-pixel motion every 750 ms. if ( Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >= 250 ) { + const positions = awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); for (const entity of layerState.visual.entities) - entity.position.setValue(groundCenter); + entity.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); @@ -311,17 +366,26 @@ export function createRendering({ const entities = []; + // A polyline, not an ellipse outline: WebGL draws ellipse outlines at + // 1 px whatever `outlineWidth` says, and the ring needs a halo. + const ring = AWARENESS_RANGE_RING_STYLE; entities.push( layerState.viewer.entities.add({ - position: groundCenter, - ellipse: { - semiMajorAxis: AWARENESS_RADIUS_M, - semiMinorAxis: AWARENESS_RADIUS_M, - fill: false, - outline: true, - outlineColor: - Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72), - height: CONTEXT_RIM_HEIGHT_M, + polyline: { + positions: new Cesium.ConstantProperty( + awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ), + ), + width: ring.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: Cesium.Color.fromCssColorString(ring.color), + outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor), + outlineWidth: ring.outlineWidth, + }), }, }), ); From 8a50705eeadef2f9177cd0c99ebf0b285de5c766 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:30:37 +0530 Subject: [PATCH 07/29] docs(changelog): add the range ring visibility fragment for #1518 --- changelog.d/1518-range-ring-visibility.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1518-range-ring-visibility.md diff --git a/changelog.d/1518-range-ring-visibility.md b/changelog.d/1518-range-ring-visibility.md new file mode 100644 index 000000000..64adf2115 --- /dev/null +++ b/changelog.d/1518-range-ring-visibility.md @@ -0,0 +1,4 @@ +- Draw the Global Context 250 km range ring as a 4 px line with a dark halo, + so it stays visible over desert, snow, ocean and forest and under the FLIR, + NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466, + #1518). From e80f583e663de3aeb22585ab2a41b3054cde7948 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:30:56 +0530 Subject: [PATCH 08/29] feat(awareness): add calm sonar pulses that flash the range ring and echo contacts Pulses: three dim rings grow from the Global Context subject to the 250 km range ring at constant speed, one every 3 s, each taking 9 s. Each fades in over its first 8% of travel, then fades as (1 - travel)^1.5 to about a third of its 60% peak, so it arrives at the ring dim but visible. They are 3 px in the ring's colours, as polylines with CallbackProperty positions and colours that share the ring's centre object, so they follow a moving subject. Ring: while pulses run, the 250 km ring rests unseen and flashes to full strength as each pulse reaches it, fading over 1.8 s. Without pulses (reduced motion, or a scene with no primitives or preRender hook) it stays on screen as before. Gradient: behind each pulse a radial band in the same blue fades inward over 35% of the radius. One flat, translucent, unpickable 250 km disc draws it with a small Fabric material. A preRender listener writes the pulse radii and opacities into two vec4 uniforms each frame. The disc (0.25 degree granularity, ~3.5 ms to build) is rebuilt only after the subject moves 2 km. Echoes: as a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, spreading outward as it fades over 1.4 s. Awareness publishes the field (centre, radius, pulse timing, echo styling) through the new src/awarenessEcho.js. The overlay reads it, so no layer's icon or colour is touched. Mapped installations have no detection bracket, so they do not echo. Rendering cost: pulses never hold continuous rendering. They ask for a frame every 80 ms, the Cyber sonar's parked cadence, so a parked Contacts view repaints at ~12.5 fps while a subject is shown. This changes the "parked Contacts stays quiet" behaviour on purpose. clearVisual stops the timer and removes the disc, the listener and the echo field. awarenessRangeRingPositions can now reuse a result array, and passes the WGS84 ellipsoid explicitly so a reused Cartesian never lands in the ellipsoid argument. Tuning lives in AWARENESS_RANGE_PULSE, AWARENESS_RANGE_ECHO and AWARENESS_RANGE_RING_STYLE. src/awarenessEcho.js is registered in the awareness-layer, application-components and application-layer-construction packages. Tests cover: - the pulse curve and calm rate; - the ring flash once per arrival, and the static ring without pulses; - the 80 ms frame cadence and its stop; - the partial-scene fallback; - the gradient disc: unpickable, uniform values, rebuild and cleanup; - per-frame evaluation, and the echo field following the subject; - awarenessEcho strength, rim wrap, layer filter and altitude handling; - the detection renderer's echo-bracket band and outward spread. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 3 + src/awarenessEcho.js | 104 ++++++ src/awarenessEcho.test.mjs | 84 +++++ src/data/detection.js | 36 ++ src/data/detectionAwarenessEcho.test.mjs | 104 ++++++ src/layers/awareness/policy.js | 57 +++- src/layers/awareness/rangeRing.test.mjs | 416 +++++++++++++++++++++-- src/layers/awareness/rendering.js | 311 +++++++++++++++-- 10 files changed, 1064 insertions(+), 56 deletions(-) create mode 100644 src/awarenessEcho.js create mode 100644 src/awarenessEcho.test.mjs create mode 100644 src/data/detectionAwarenessEcho.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 038d23fa0..46b012cf6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring is not shown on its own: it flashes to full strength as each pulse reaches it and fades over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses never hold continuous rendering: they ask for a frame every 80 ms, the Cyber sonar's parked cadence, so a parked Contacts view repaints at about 12.5 fps while a subject is shown, and at full rate only when something else already renders continuously. Under `prefers-reduced-motion`, or in a scene without primitives or a preRender hook, there are no pulses or echoes and the ring stays on screen. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1ce21e6c0..bbcdc7775 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1178,6 +1178,9 @@ "src/ui/stores/awarenessDirection.js", "src/layers/awareness/directionOverlay.test.mjs", "src/layers/awareness/rangeRing.test.mjs", + "src/awarenessEcho.js", + "src/awarenessEcho.test.mjs", + "src/data/detectionAwarenessEcho.test.mjs", "src/hudReadouts.js", "src/ui/stores/hud.js", "src/ui/views/intelHudView.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index de6b2e490..5650b759b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1677,6 +1677,7 @@ "src/data/distanceFormat.js", "src/i18n/marker.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", @@ -2227,6 +2228,7 @@ "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", "src/ui/radioPanelPlacement.js", @@ -3211,6 +3213,7 @@ "src/data/headingDegrees.js", "src/data/distanceFormat.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", "src/ui/imagerySplit.js", diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js new file mode 100644 index 000000000..16fe6cda4 --- /dev/null +++ b/src/awarenessEcho.js @@ -0,0 +1,104 @@ +/** + * @module awarenessEcho + * @description The Global Context sonar echo field. The awareness layer + * publishes it while its range pulses run; the detection overlay reads it to + * pulse the brackets of contacts a pulse front has just crossed. It holds no + * awareness policy of its own: the publisher passes radius, pulse timing and + * echo styling in, so the overlay never imports awareness internals. + */ +import * as Cesium from 'cesium'; + +/** + * @typedef {object} AwarenessEchoField + * @property {Cesium.Cartesian3} center Subject's ground point. The publisher + * may move it in place; it is read on every query. + * @property {number} radiusM Range the pulses grow to. + * @property {{count: number, periodMs: number, startFraction: number}} pulse + * @property {{fadeMs: number, layerIds: string[]}} echo + */ + +/** @type {AwarenessEchoField|null} */ +let field = null; +let layerIds = new Set(); +const groundScratch = new Cesium.Cartesian3(); + +/** + * Start (or replace) the echo field. + * @param {AwarenessEchoField} next + * @returns {void} + */ +export function setAwarenessEchoField(next) { + field = next; + layerIds = new Set(next.echo.layerIds); +} + +/** Stop all echoes. Safe when none were set. */ +export function clearAwarenessEchoField() { + field = null; + layerIds = new Set(); +} + +/** @returns {AwarenessEchoField|null} The field the overlay styles from. */ +export function getAwarenessEchoField() { + return field; +} + +/** + * How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches + * its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The + * strongest pulse wins. A front that has wrapped back to the centre still + * counts for the contact it last crossed, so rim contacts fade out instead of + * cutting off. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {number} distanceM Contact's distance from the subject. + * @param {number} radiusM Range the pulses grow to. + * @param {{count: number, periodMs: number, startFraction: number}} pulse + * @param {{fadeMs: number}} echo + * @returns {number} 0 to 1. + */ +export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { + if (!Number.isFinite(distanceM) || distanceM < 0 || distanceM > radiusM) + return 0; + // The phase at which a pulse front sits at this distance. + const touchPhase = Math.max( + 0, + (distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction), + ); + let strength = 0; + for (let index = 0; index < pulse.count; index++) { + const cycle = nowMs / pulse.periodMs + index / pulse.count; + let since = cycle - Math.floor(cycle) - touchPhase; + // Rounding at the exact crossing must not read as a whole period ago. + if (since < -1e-9) since += 1; + else if (since < 0) since = 0; + const elapsedMs = since * pulse.periodMs; + if (elapsedMs < echo.fadeMs) + strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2); + } + return strength; +} + +/** + * The echo for one contact of `layerId` at world `position`, or 0 when no + * field is set or the layer does not echo. Distance is measured between + * ground points, so an aircraft's altitude does not shift its echo. + * @param {string} layerId Owning layer of the contact. + * @param {Cesium.Cartesian3} position Contact's world position. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {number} 0 to 1. + */ +export function awarenessEchoAt(layerId, position, nowMs) { + if (!field || !position || !layerIds.has(layerId)) return 0; + const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( + position, + groundScratch, + ); + if (!ground) return 0; + return awarenessEchoStrength( + nowMs, + Cesium.Cartesian3.distance(field.center, ground), + field.radiusM, + field.pulse, + field.echo, + ); +} diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs new file mode 100644 index 000000000..9bc7e6cd6 --- /dev/null +++ b/src/awarenessEcho.test.mjs @@ -0,0 +1,84 @@ +// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar +// pulse front crosses a flight or vessel, its bracket echo lights and fades. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + awarenessEchoAt, + awarenessEchoStrength, + clearAwarenessEchoField, + getAwarenessEchoField, + setAwarenessEchoField, +} from './awarenessEcho.js'; + +const R = 250_000; +const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 }; +const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] }; +const strength = (nowMs, distanceM) => + awarenessEchoStrength(nowMs, distanceM, R, pulse, echo); +/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */ +const arrival = (distanceM) => + ((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) * + pulse.periodMs + + pulse.periodMs * 4; + +test('an echo lights as a pulse front reaches the contact, then fades', () => { + const d = 100_000; + const t0 = arrival(d); + assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6); + assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6); + assert.equal(strength(t0 + echo.fadeMs + 1, d), 0); + assert.equal(strength(t0 - 50, d), 0, 'dark just before the front'); + // Every pulse lights it once a period: `count` separate flashes in any + // one-period window that starts after a flash has faded. + let flashes = 0; + let wasLit = false; + const from = t0 + echo.fadeMs + 1; + for (let ms = 0; ms < pulse.periodMs; ms += 10) { + const lit = strength(from + ms, d) > 0.5; + if (lit && !wasLit) flashes++; + wasLit = lit; + } + assert.equal(flashes, pulse.count); +}); + +test('rim contacts fade out after the front wraps; outside the range never', () => { + const rim = R * 0.999; + assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2); + assert.ok(strength(pulse.periodMs * 5, 1000) > 0.99, 'inside the start ring'); + for (let ms = 0; ms < pulse.periodMs; ms += 50) + assert.equal(strength(ms, R + 1), 0); + assert.equal(strength(0, Number.NaN), 0); + assert.equal(strength(0, -1), 0); +}); + +test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => { + t.after(clearAwarenessEchoField); + const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0); + assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo'); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + setAwarenessEchoField({ center, radiusM: R, pulse, echo }); + assert.ok(getAwarenessEchoField()); + const d = Cesium.Cartesian3.distance(center, contact); + const t0 = arrival(d); + assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99); + assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99); + // Layers outside the echo list never light. + assert.equal(awarenessEchoAt('cctv', contact, t0), 0); + assert.equal(awarenessEchoAt('military-installations', contact, t0), 0); + // An aircraft 11 km up echoes with the ground point under it. + const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000); + assert.ok( + Math.abs( + awarenessEchoAt('flights', aloft, t0 + 300) - + awarenessEchoAt('flights', contact, t0 + 300), + ) < 0.01, + ); + // The field's centre is read in place, so a moved subject moves the echo. + Cesium.Cartesian3.clone(contact, center); + assert.ok(awarenessEchoAt('flights', contact, pulse.periodMs * 7) > 0.99); + assert.equal(awarenessEchoAt('flights', null, t0), 0); + clearAwarenessEchoField(); + assert.equal(getAwarenessEchoField(), null); + assert.equal(awarenessEchoAt('flights', contact, t0), 0); +}); diff --git a/src/data/detection.js b/src/data/detection.js index c2e63c3d9..018d3d348 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -53,6 +53,7 @@ import { isCyberContactSonarActive as isCyberSonarActive, isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; +import { awarenessEchoAt, getAwarenessEchoField } from '../awarenessEcho.js'; /** * @module detection @@ -103,6 +104,8 @@ const SPARSE_ZONE_FRACTION = 0.5; const LABEL_SOLVE_INTERVAL_MS = 125; /** Alpha bands retain bracket batching while approximating a continuous radial fade. */ const BRACKET_ALPHA_STEPS = 4; +/** Finer bands for Global Context echo brackets, whose fade is animated. */ +const ECHO_ALPHA_STEPS = 8; /** Stable per-layer candidate safety cap; independent of density and camera bearing. */ const LAYER_CANDIDATE_CAP = 2600; const LAYER_WEIGHTS = Object.freeze({ @@ -1335,6 +1338,10 @@ function _drawOverlay(frame) { const tiers = _theme.tiers || null; const colorFor = (key) => (tiers && tiers[key]) || _theme.line; const bracketPaths = new Map(); + // Global Context sonar echoes: a second bracket, banded by strength, around + // each flight or vessel a pulse front has just crossed. + const echoField = getAwarenessEchoField(); + const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null; const pathFor = (map, color, alpha) => { const band = Math.max( 1, @@ -1437,6 +1444,24 @@ function _drawOverlay(frame) { halfH, ); visibleCount++; + if (echoPaths) { + const echo = awarenessEchoAt(obj._layerId, obj.position, now); + if (echo > 0.01) { + // Starts on the contact's bracket and spreads outward as it fades. + const band = Math.max( + 1, + Math.ceil(echo * admissionAlpha * ECHO_ALPHA_STEPS), + ); + const spread = echoField.echo.spreadPx * (1 - echo); + appendCornerBracket( + (echoPaths[band] ??= new Path2D()), + sx, + sy, + halfW + spread, + halfH + spread, + ); + } + } if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; if (bracketAlpha >= 1) bracketOpacityCounts.full++; @@ -1576,6 +1601,17 @@ function _drawOverlay(frame) { _ctx.stroke(entry.path); } } + if (echoPaths) { + _ctx.lineWidth = echoField.echo.lineWidth; + _ctx.strokeStyle = echoField.echo.color; + for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) { + if (!echoPaths[band]) continue; + _ctx.globalAlpha = + fade * (band / ECHO_ALPHA_STEPS) * bracketPresentationOpacity; + _ctx.stroke(echoPaths[band]); + } + _ctx.lineWidth = bracketWidth; + } const renderEntries = _labelArbiter.renderEntries(candidateMap, now); const fadingCount = countFadingRenderEntries(renderEntries); diff --git a/src/data/detectionAwarenessEcho.test.mjs b/src/data/detectionAwarenessEcho.test.mjs new file mode 100644 index 000000000..4c34672d5 --- /dev/null +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -0,0 +1,104 @@ +// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse +// a contact's own detection bracket: a second bracket that starts on it and +// spreads outward as it fades. Exercises the renderer's actual echo block. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import * as Cesium from 'cesium'; +import { + awarenessEchoAt, + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../awarenessEcho.js'; + +const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); +const echoBlock = source.match( + /if \(echoPaths\) \{\s*const echo = awarenessEchoAt[\s\S]*?\n {6}\}\n/, +)?.[0]; + +const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 }; +const echo = { + fadeMs: 1400, + layerIds: ['flights', 'military', 'ais-live-vessels'], + spreadPx: 8, +}; + +/** Run the renderer's echo block for one contact and record the bracket. */ +function paintEcho(field, obj, now, { admissionAlpha = 1 } = {}) { + const brackets = []; + const state = { + echoPaths: field ? new Array(9) : null, + echoField: field, + ECHO_ALPHA_STEPS: 8, + awarenessEchoAt, + Path2D: class {}, + appendCornerBracket: (sink, sx, sy, halfW, halfH) => + brackets.push({ sink, sx, sy, halfW, halfH }), + obj, + now, + admissionAlpha, + sx: 100, + sy: 50, + halfW: 9, + halfH: 7, + }; + vm.runInNewContext(echoBlock, state); + const band = state.echoPaths?.findIndex((path) => path) ?? -1; + return { brackets, band }; +} + +test('the echo bracket pulses outward from the contact bracket and fades', (t) => { + assert.ok(echoBlock, 'renderer has an echo block'); + t.after(clearAwarenessEchoField); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + const field = { center, radiusM: 250_000, pulse, echo }; + setAwarenessEchoField(field); + const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000); + const ground = Cesium.Cartesian3.fromDegrees(3.5, 27, 0); + const d = Cesium.Cartesian3.distance(center, ground); + const arrival = + ((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) * + pulse.periodMs + + pulse.periodMs * 3; + const obj = { _layerId: 'flights', position }; + + // The moment the front arrives: on the bracket, strongest band. + const fresh = paintEcho(field, obj, arrival); + assert.equal(fresh.brackets.length, 1); + assert.ok(fresh.brackets[0].halfW - 9 < 0.01); + assert.equal(fresh.band, 8); + // Half way through the fade: dimmer and spread outward. + const half = paintEcho(field, obj, arrival + echo.fadeMs / 2); + assert.ok(half.brackets[0].halfW > 9 + echo.spreadPx * 0.5); + assert.ok(half.brackets[0].halfH > 7 + echo.spreadPx * 0.5); + assert.equal(half.brackets[0].sx, 100); + assert.ok(half.band < 8 && half.band >= 1); + // Keyhole fade carries over to the echo. + assert.ok(paintEcho(field, obj, arrival, { admissionAlpha: 0.25 }).band <= 2); + // Faded out, or between fronts: no echo bracket. + assert.equal( + paintEcho(field, obj, arrival + echo.fadeMs + 5).brackets.length, + 0, + ); +}); + +test('no echo field, or a layer that does not echo, draws nothing extra', (t) => { + assert.ok(echoBlock); + t.after(clearAwarenessEchoField); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0); + // No field: the renderer skips the echo block entirely. + assert.equal( + paintEcho(null, { _layerId: 'flights', position }, 0).brackets.length, + 0, + ); + const field = { center, radiusM: 250_000, pulse, echo }; + setAwarenessEchoField(field); + for (let now = 0; now < pulse.periodMs; now += 25) + for (const layerId of ['cctv', 'satellites', 'traffic']) + assert.equal( + paintEcho(field, { _layerId: layerId, position }, now).brackets.length, + 0, + ); +}); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index b33da8433..a0af9e3d7 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -49,7 +49,10 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps * luminance contrast on light and dark ground alike, and luminance is what * the monochrome styles keep. The core is the Context panel's blue; the halo - * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. */ + * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. + * While the sonar pulses run, the ring rests at `restOpacity` and flashes + * to full as each pulse reaches it, fading over `flashMs`. Without pulses + * (reduced motion, or a scene that cannot animate) it stays on screen. */ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ color: 'rgba(98, 181, 255, 0.95)', @@ -57,6 +60,58 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ width: 4, outlineWidth: 1, segments: 128, + flashMs: 1800, + restOpacity: 0, +}); + +/** @constant Sonar-style pulses that grow slowly from the subject to the + * range ring and are almost gone when they reach it. `count` pulses share + * one `periodMs`, evenly staggered, so a new one starts every + * `periodMs / count`. Each starts at `startFraction` of the radius, fades in + * over `fadeInFraction` of its travel, then fades as (1 - travel) to the + * `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse + * is dim but still visible when it reaches the ring and the ring flashes. Behind each line a radial gradient in the same blue fades + * inward over `gradientDepthFraction` of the radius, at most + * `gradientOpacity` strong. The gradient disc is rebuilt only after the + * subject moves `gradientRebuildM`, since a soft band 2 km off centre on a + * 250 km disc does not show. The gradient shader takes at most four pulses. + * The pulses do not hold continuous rendering: a parked Contacts view must + * not repaint every frame. They ask for a frame every `frameIntervalMs` + * instead, the cadence the Cyber sonar uses for parked scenes; when + * something else already renders continuously they update every frame. + * Skipped under prefers-reduced-motion. */ + +export const AWARENESS_RANGE_PULSE = Object.freeze({ + count: 3, + periodMs: 9000, + startFraction: 0.02, + fadeInFraction: 0.08, + fadeOutPower: 1.5, + endOpacityFraction: 0.35, + peakOpacity: 0.6, + width: 3, + outlineWidth: 1, + segments: 96, + gradientDepthFraction: 0.35, + gradientOpacity: 0.22, + gradientGranularityDeg: 0.25, + gradientRebuildM: 2000, + frameIntervalMs: 80, +}); + +/** @constant Sonar echoes on contact brackets. When a pulse front reaches a + * flight, military aircraft or AIS vessel in the window, the detection + * overlay draws a second bracket in `color` around that contact's own + * bracket. It starts on the bracket and spreads outward by up to `spreadPx` + * as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo; + * mapped installations have no detection bracket. */ + +export const AWARENESS_RANGE_ECHO = Object.freeze({ + layerIds: ['flights', 'military', 'ais-live-vessels'], + fadeMs: 1400, + color: 'rgb(98, 181, 255)', + spreadPx: 8, + lineWidth: 1.75, }); export const VESSEL_FOCUS_RADIUS_M = 3000; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 074d449fe..655bdc080 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -1,12 +1,29 @@ // src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range // ring is a haloed polyline that stays legible on light and dark ground, // sits where the old ellipse outline did, and follows the subject. +// Dim sonar pulses grow slowly from the subject to the ring, each with an +// inward-fading gradient behind it; the ring flashes as each one arrives and +// the overlay's echo field follows the subject. Under reduced motion the ring +// simply stays on screen. +import '../../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; -import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; -import { awarenessRangeRingPositions, createRendering } from './rendering.js'; +import { + AWARENESS_RANGE_ECHO, + AWARENESS_RANGE_PULSE, + AWARENESS_RANGE_RING_STYLE, + CONTEXT_RIM_HEIGHT_M, +} from './policy.js'; +import { + AWARENESS_PULSE_GRADIENT_MAX, + awarenessPulseState, + awarenessRangeRingPositions, + awarenessRingFlash, + createRendering, +} from './rendering.js'; +import { getAwarenessEchoField } from '../../awarenessEcho.js'; import { createState } from './state.js'; /** WCAG relative luminance of a Cesium colour, ignoring alpha. */ @@ -20,27 +37,68 @@ function luminance(color) { ); } -/** The rendering part over a bare entity collection, counting render asks. */ -function ringRendering() { +/** + * The rendering part over a bare entity collection, counting render asks and + * by reason. `reducedMotion` stubs the media query for + * the length of the test. + */ +function ringRendering(t, { reducedMotion = false } = {}) { + const previous = globalThis.matchMedia; + globalThis.matchMedia = (query) => ({ + matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion, + }); + t.after(() => { + globalThis.matchMedia = previous; + }); const state = createState({ services: {} }); const entities = new Cesium.EntityCollection(); - state.viewer = { entities }; - let renders = 0; + const scene = { + primitives: new Cesium.PrimitiveCollection(), + preRender: new Cesium.Event(), + }; + state.viewer = { entities, scene }; + const reasons = []; const rendering = createRendering({ state, services: { geometry: {}, render: { - governorRequestRender() { - renders += 1; + governorRequestRender(reason) { + reasons.push(reason); }, }, }, parts: {}, }); - return { entities, rendering, renders: () => renders }; + // A shown ring keeps a pulse timer; never leave one running past a test. + t.after(() => rendering.clearVisual()); + return { + state, + entities, + scene, + rendering, + renders: () => reasons.filter((r) => r === 'awareness-visual').length, + pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length, + }; } +/** The pulse gradient disc and the echo collection among scene primitives. */ +function pulsePrimitives(scene) { + const all = Array.from({ length: scene.primitives.length }, (_, i) => + scene.primitives.get(i), + ); + return { + disc: all.find((p) => p instanceof Cesium.Primitive), + count: all.length, + }; +} + +const flight = (lon) => ({ + layerId: 'flights', + id: 'abc123', + position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000), +}); + /** Surface distance from `center` to each ring position. */ function surfaceDistances(center, positions) { const from = Cesium.Cartographic.fromCartesian(center); @@ -99,15 +157,11 @@ test('the ring core and halo contrast on light and dark ground', () => { assert.ok(width - 2 * outlineWidth >= 2); }); -test('renderVisual draws one haloed polyline ring, not an ellipse outline', () => { - const { entities, rendering } = ringRendering(); - const subject = { - layerId: 'flights', - id: 'abc123', - position: Cesium.Cartesian3.fromDegrees(3, 27, 9000), - }; - rendering.renderVisual(subject); - assert.equal(entities.values.length, 1); +test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { + const { entities, rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.ok(entities.values.every((entity) => entity.ellipse === undefined)); const [entity] = entities.values; assert.equal(entity.ellipse, undefined); const now = Cesium.JulianDate.now(); @@ -115,12 +169,14 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', () = assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE); const material = entity.polyline.material; assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty); - assert.equal( - material.color.getValue(now).toCssColorString(), - Cesium.Color.fromCssColorString( - AWARENESS_RANGE_RING_STYLE.color, - ).toCssColorString(), + // The ring's hue is fixed; its opacity flashes with arriving pulses. + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, ); + const color = material.color.getValue(now); + assert.equal(color.red, core.red); + assert.equal(color.blue, core.blue); + assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9); assert.equal( material.outlineWidth.getValue(now), AWARENESS_RANGE_RING_STYLE.outlineWidth, @@ -133,13 +189,9 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', () = assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); }); -test('the ring follows the subject past 250 m and ignores smaller drift', () => { - const { entities, rendering, renders } = ringRendering(); - const subject = (lon) => ({ - layerId: 'flights', - id: 'abc123', - position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000), - }); +test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => { + const { entities, rendering, renders } = ringRendering(t); + const subject = flight; rendering.renderVisual(subject(3)); const [entity] = entities.values; const now = Cesium.JulianDate.now(); @@ -153,7 +205,7 @@ test('the ring follows the subject past 250 m and ignores smaller drift', () => // ~1 km east: the same entity moves to the new centre. rendering.renderVisual(subject(3.01)); - assert.equal(entities.values.length, 1); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); assert.equal(entities.values[0], entity); const moved = entity.polyline.positions.getValue(now); assert.notEqual(moved, first); @@ -163,7 +215,309 @@ test('the ring follows the subject past 250 m and ignores smaller drift', () => )) assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); assert.equal(renders(), rendersAfterAdd + 1); + // Each pulse is a circle around the NEW centre: one radius all the way round. + for (const pulse of entities.values.slice(1)) { + const radii = surfaceDistances( + Cesium.Cartesian3.fromDegrees(3.01, 27, 0), + pulse.polyline.positions.getValue(now), + ); + assert.ok(Math.max(...radii) - Math.min(...radii) < 100); + } rendering.clearVisual(); assert.equal(entities.values.length, 0); }); + +test('a pulse grows slowly from the subject and arrives dim at the ring', () => { + const { + periodMs, + count, + startFraction, + fadeInFraction, + peakOpacity, + endOpacityFraction, + } = AWARENESS_RANGE_PULSE; + const at = (phase) => awarenessPulseState(phase * periodMs, 0); + // Starts small and invisible, so nothing pops in at the centre. + assert.equal(at(0).radiusM, AWARENESS_RADIUS_M * startFraction); + assert.equal(at(0).alpha, 0); + // Dim: never brighter than `peakOpacity`, which sits well under the ring. + let brightest = 0; + for (let phase = 0; phase < 1; phase += 0.005) { + const { alpha } = at(phase); + assert.ok(alpha >= 0 && alpha <= peakOpacity, `alpha ${alpha} at ${phase}`); + brightest = Math.max(brightest, alpha); + } + assert.ok(Math.abs(brightest - at(fadeInFraction).alpha) < 0.01); + assert.ok(peakOpacity <= 0.6); + // Fading as it travels, but still visible when it reaches the ring, where + // the ring takes over with its flash. + assert.ok(at(0.5).alpha < at(0.25).alpha); + assert.ok(at(0.9).alpha < at(0.5).alpha); + assert.ok( + Math.abs(at(0.999).alpha - peakOpacity * endOpacityFraction) < 0.01, + ); + assert.ok(at(0.999).alpha >= 0.15, 'the arriving pulse is still visible'); + assert.ok(Math.abs(at(0.999).radiusM - AWARENESS_RADIUS_M) < 300); + // Constant speed: equal time steps add equal radius. + const step = at(0.4).radiusM - at(0.3).radiusM; + assert.ok(Math.abs(at(0.7).radiusM - at(0.6).radiusM - step) < 1e-6); + // Calm: a new pulse at most every 2.5 s, crossing 250 km in 8 s or more. + assert.ok(periodMs / count >= 2500); + assert.ok(periodMs >= 8000); + // Pulses are evenly staggered across one period, and the cycle repeats. + const near = (a, b) => + Math.abs(a.radiusM - b.radiusM) < 1e-6 && + Math.abs(a.alpha - b.alpha) < 1e-9; + for (let index = 1; index < count; index++) + assert.ok( + near( + awarenessPulseState(0, index), + awarenessPulseState((index / count) * periodMs, 0), + ), + ); + assert.ok( + near( + awarenessPulseState(periodMs * 7 + 123, 1), + awarenessPulseState(123, 1), + ), + ); +}); + +test('pulses ask for a frame every 80 ms instead of holding continuous rendering', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const { entities, rendering, pulseFrames } = ringRendering(t); + rendering.renderVisual(flight(3)); + // A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, + // never every display frame. + assert.equal(pulseFrames(), 0); + t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); + assert.equal(pulseFrames(), 10); + const now = Cesium.JulianDate.now(); + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + for (const pulse of entities.values.slice(1)) { + assert.equal(pulse.polyline.positions.isConstant, false); + assert.equal( + pulse.polyline.width.getValue(now), + AWARENESS_RANGE_PULSE.width, + ); + const color = pulse.polyline.material.color.getValue(now); + assert.ok(color.alpha >= 0 && color.alpha <= core.alpha); + assert.equal(color.red, core.red); + const radii = surfaceDistances( + Cesium.Cartesian3.fromDegrees(3, 27, 0), + pulse.polyline.positions.getValue(now), + ); + assert.ok( + Math.min(...radii) >= + AWARENESS_RADIUS_M * AWARENESS_RANGE_PULSE.startFraction - 100, + ); + assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100); + } + rendering.clearVisual(); + assert.equal(entities.values.length, 0); + t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); + assert.equal(pulseFrames(), 10, 'no frames once the ring is gone'); +}); + +test('a scene without primitives or preRender gets the static ring only', (t) => { + const { state, entities, rendering, pulseFrames } = ringRendering(t); + for (const scene of [{}, { primitives: { add() {} } }, undefined]) { + state.viewer = { entities, scene }; + rendering.clearVisual(); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); + } + rendering.clearVisual(); + assert.equal(pulseFrames(), 0); +}); + +test('a gradient disc behind the pulses fades inward and follows the subject', (t) => { + const { scene, rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); + const { disc, count } = pulsePrimitives(scene); + assert.equal(count, 1, 'the gradient disc'); + assert.ok(disc); + // Never a click or tap target, and see-through. + assert.equal(disc.allowPicking, false); + assert.equal(disc.appearance.translucent, true); + const { material } = disc.appearance; + assert.ok( + Math.abs( + material.uniforms.depth - AWARENESS_RANGE_PULSE.gradientDepthFraction, + ) < 1e-9, + ); + assert.ok( + Math.abs( + material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity, + ) < 1e-6, + ); + // Each frame writes the pulses' radius (as a fraction of the range) and + // opacity into the shader, from the same curve the lines use. + assert.equal(scene.preRender.numberOfListeners, 1); + const before = performance.now(); + scene.preRender.raiseEvent(scene); + const after = performance.now(); + const { radii, alphas } = material.uniforms; + for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) { + const radius = Cesium.Cartesian4.pack(radii, [])[index]; + const alpha = Cesium.Cartesian4.pack(alphas, [])[index]; + const low = awarenessPulseState(before, index); + const high = awarenessPulseState(after, index); + // A few milliseconds pass during the raise; allow for that drift. + assert.ok( + Math.abs(radius - low.radiusM / AWARENESS_RADIUS_M) < 0.01 || + Math.abs(radius - high.radiusM / AWARENESS_RADIUS_M) < 0.01, + ); + assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity); + assert.ok( + Math.abs(alpha - low.alpha) < 0.02 || Math.abs(alpha - high.alpha) < 0.02, + ); + } + // Unused shader slots stay empty. + assert.equal( + Cesium.Cartesian4.pack(alphas, [])[AWARENESS_PULSE_GRADIENT_MAX - 1], + 0, + ); + + // The disc is rebuilt only past `gradientRebuildM` (2 km), not on every + // 250 m step the lines take, and keeps its material. + rendering.renderVisual(flight(3.001)); + assert.equal(pulsePrimitives(scene).disc, disc); + rendering.renderVisual(flight(3.01)); // ~1 km: the lines move, the disc stays + assert.equal(pulsePrimitives(scene).disc, disc); + assert.ok(!disc.isDestroyed()); + rendering.renderVisual(flight(3.03)); // ~3 km from the disc's centre + assert.equal(pulsePrimitives(scene).count, 1); + const moved = pulsePrimitives(scene).disc; + assert.notEqual(moved, disc); + assert.ok(disc.isDestroyed()); + assert.equal(moved.appearance.material, material); + assert.equal(moved.allowPicking, false); + + rendering.clearVisual(); + assert.equal(scene.primitives.length, 0); + assert.equal(scene.preRender.numberOfListeners, 0); +}); + +test('pulses evaluate every frame without breaking, reusing their positions', (t) => { + const { entities, rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + for (const pulse of entities.values.slice(1)) { + const property = pulse.polyline.positions; + const first = property.getValue(Cesium.JulianDate.now()); + // Cesium calls this on every rendered frame; the second call is where a + // reused Cartesian once landed in the ellipsoid argument and threw. + for (let frame = 0; frame < 5; frame++) { + const positions = property.getValue(Cesium.JulianDate.now()); + assert.equal(positions, first, 'the same array is reused'); + assert.equal(positions.length, AWARENESS_RANGE_PULSE.segments + 1); + const radii = surfaceDistances(center, positions); + assert.ok(radii.every(Number.isFinite)); + assert.ok(Math.max(...radii) - Math.min(...radii) < 100); + } + } +}); + +test('reduced motion keeps the static ring and drops the pulses', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const { entities, scene, rendering, pulseFrames } = ringRendering(t, { + reducedMotion: true, + }); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); + t.mock.timers.tick(1000); + assert.equal(pulseFrames(), 0); + assert.equal(scene.primitives.length, 0); + assert.equal(scene.preRender.numberOfListeners, 0); +}); + +test('while pulses run the ring flashes as each one arrives, then rests', (t) => { + const { entities, rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + const ring = entities.values[0]; + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + assert.equal(ring.polyline.material.color.isConstant, false); + const { periodMs, count } = AWARENESS_RANGE_PULSE; + const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE; + // Full the moment a front reaches 250 km, gone `flashMs` later. + const arrival = periodMs * 6; // pulse 0 wraps here + assert.ok(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6); + assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2); + assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0); + assert.equal(restOpacity, 0); + // Once per arriving pulse: `count` flashes in any one-period window that + // starts after a flash has faded. + let flashes = 0; + let wasLit = false; + const from = arrival + flashMs + 1; + for (let ms = 0; ms < periodMs; ms += 10) { + const lit = awarenessRingFlash(from + ms) > 0.5; + if (lit && !wasLit) flashes++; + wasLit = lit; + } + assert.equal(flashes, count); + // The live colour tracks the flash at this instant. + const before = awarenessRingFlash(performance.now()); + const alpha = ring.polyline.material.color.getValue( + Cesium.JulianDate.now(), + ).alpha; + const after = awarenessRingFlash(performance.now()); + assert.ok( + alpha >= core.alpha * Math.min(before, after) - 0.02 && + alpha <= core.alpha * Math.max(before, after) + 0.02, + ); +}); + +test('without pulses the ring stays on screen at full strength', (t) => { + const { entities, rendering } = ringRendering(t, { reducedMotion: true }); + rendering.renderVisual(flight(3)); + const color = entities.values[0].polyline.material.color; + assert.equal(color.isConstant, true); + assert.equal( + color.getValue(Cesium.JulianDate.now()).toCssColorString(), + Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ).toCssColorString(), + ); + assert.equal(getAwarenessEchoField(), null); +}); + +test('the echo field follows the subject while pulses run and clears with the ring', (t) => { + const { rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + const field = getAwarenessEchoField(); + assert.ok(field); + assert.equal(field.radiusM, AWARENESS_RADIUS_M); + assert.equal(field.pulse, AWARENESS_RANGE_PULSE); + assert.equal(field.echo, AWARENESS_RANGE_ECHO); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + field.center, + Cesium.Cartesian3.fromDegrees(3, 27, 0), + 0, + 1e-3, + ), + ); + // ~1 km later the same field has moved with the subject. + rendering.renderVisual(flight(3.01)); + assert.equal(getAwarenessEchoField(), field); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + field.center, + Cesium.Cartesian3.fromDegrees(3.01, 27, 0), + 0, + 1e-3, + ), + ); + rendering.clearVisual(); + assert.equal(getAwarenessEchoField(), null); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8cee87d80..ca22bacbd 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -12,10 +12,17 @@ import { import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { AWARENESS_PAGE_SIZE, + AWARENESS_RANGE_ECHO, + AWARENESS_RANGE_PULSE, AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M, } from './policy.js'; import { formatAwarenessDirection } from './presentation.js'; +import { + awarenessEchoStrength, + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../../awarenessEcho.js'; /** * A closed ring of positions `radiusM` along the surface from `groundCenter`, @@ -26,6 +33,7 @@ import { formatAwarenessDirection } from './presentation.js'; * @param {number} radiusM Surface radius in metres. * @param {number} heightM Height of the ring above the ellipsoid. * @param {number} [segments] Chords around the ring. + * @param {Cesium.Cartesian3[]} [result] Array whose Cartesians are reused. * @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first. */ export function awarenessRangeRingPositions( @@ -33,6 +41,7 @@ export function awarenessRangeRingPositions( radiusM, heightM, segments = AWARENESS_RANGE_RING_STYLE.segments, + result = [], ) { const { latitude, longitude } = Cesium.Cartographic.fromCartesian(groundCenter); @@ -43,7 +52,8 @@ export function awarenessRangeRingPositions( const w = 1 - e2 * sinLat * sinLat; const meridional = (a * (1 - e2)) / (w * Math.sqrt(w)); const primeVertical = a / Math.sqrt(w); - const positions = []; + const positions = result; + positions.length = segments + 1; for (let i = 0; i <= segments; i++) { const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; const cosB = Math.cos(bearing); @@ -59,11 +69,136 @@ export function awarenessRangeRingPositions( sinB * Math.sin(angular) * cosLat, Math.cos(angular) - sinLat * Math.sin(lat), ); - positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM)); + positions[i] = Cesium.Cartesian3.fromRadians( + lon, + lat, + heightM, + Cesium.Ellipsoid.WGS84, + positions[i], + ); } return positions; } +/** + * Where pulse `index` is at `nowMs`: its radius and opacity. Pulses travel at + * constant speed from `startFraction` of the range to the full range, fade in + * over `fadeInFraction` of the way, then fade as (1 - travel) to the + * `fadeOutPower` down to `endOpacityFraction` of the peak, so each is dim + * but still visible when it reaches the range ring. The opacity already + * includes `peakOpacity`. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {number} index Pulse number, 0 to `count - 1`. + * @param {number} [radiusM] Range the pulses grow to. + * @param {object} [pulse] Timing and shape, see `AWARENESS_RANGE_PULSE`. + * @returns {{radiusM: number, alpha: number}} + */ +export function awarenessPulseState( + nowMs, + index, + radiusM = AWARENESS_RADIUS_M, + pulse = AWARENESS_RANGE_PULSE, +) { + const cycle = nowMs / pulse.periodMs + index / pulse.count; + const phase = cycle - Math.floor(cycle); + return { + radiusM: + radiusM * (pulse.startFraction + (1 - pulse.startFraction) * phase), + alpha: + pulse.peakOpacity * + Math.min(1, phase / pulse.fadeInFraction) * + (pulse.endOpacityFraction + + (1 - pulse.endOpacityFraction) * (1 - phase) ** pulse.fadeOutPower), + }; +} + +/** Whether the viewer asked the system for less motion. */ +function prefersReducedMotion() { + return ( + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? + false + ); +} + +/** + * How strongly the 250 km ring shows at `nowMs` while pulses run: 1 as a + * pulse front reaches it, fading over `flashMs`, so the ring appears once + * per arriving pulse instead of standing on screen. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {number} 0 to 1. + */ +export function awarenessRingFlash(nowMs) { + return awarenessEchoStrength( + nowMs, + AWARENESS_RADIUS_M, + AWARENESS_RADIUS_M, + AWARENESS_RANGE_PULSE, + { fadeMs: AWARENESS_RANGE_RING_STYLE.flashMs }, + ); +} + +/** Most pulses the gradient shader draws; its uniforms are vec4s. */ +export const AWARENESS_PULSE_GRADIENT_MAX = 4; + +// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at +// distance 0.5, so `r` is the fraction of the range. Behind each pulse line +// the band is strongest at the line and fades to nothing `depth` inward. +const PULSE_GRADIENT_SOURCE = ` +uniform vec4 color; +uniform vec4 radii; +uniform vec4 alphas; +uniform float depth; +czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float r = length(materialInput.st - vec2(0.5)) * 2.0; + float alpha = 0.0; + for (int i = 0; i < 4; i++) { + float inside = radii[i] - r; + if (inside >= 0.0 && inside <= depth) { + float t = 1.0 - inside / depth; + alpha += alphas[i] * t * t; + } + } + material.diffuse = color.rgb; + material.alpha = clamp(alpha * color.a, 0.0, 1.0); + return material; +} +`; + +/** + * The pulse gradient: one flat, translucent, unpickable disc the size of the + * range ring whose shader draws a band behind each pulse. Moving pulses only + * change its uniforms; the disc is rebuilt only when the subject moves. + * @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid. + * @param {Cesium.Material} material Shared pulse-gradient material. + * @returns {Cesium.Primitive} + */ +function createPulseGradient(groundCenter, material) { + return new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.EllipseGeometry({ + center: groundCenter, + semiMajorAxis: AWARENESS_RADIUS_M, + semiMinorAxis: AWARENESS_RADIUS_M, + height: CONTEXT_RIM_HEIGHT_M, + granularity: Cesium.Math.toRadians( + AWARENESS_RANGE_PULSE.gradientGranularityDeg, + ), + vertexFormat: + Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, + }), + }), + appearance: new Cesium.MaterialAppearance({ + material, + flat: true, + translucent: true, + faceForward: true, + }), + asynchronous: false, + allowPicking: false, + }); +} + export function createRendering({ state: layerState, services, @@ -323,6 +458,11 @@ export function createRendering({ } function clearVisual() { + globalThis.clearInterval(layerState.visual?.pulseTimer); + layerState.visual?.removePulseListener?.(); + if (layerState.visual?.gradient && layerState.viewer) + layerState.viewer.scene.primitives.remove(layerState.visual.gradient); + clearAwarenessEchoField(); if (layerState.visual?.entities && layerState.viewer) { for (const entity of layerState.visual.entities) layerState.viewer.entities.remove(entity); @@ -354,9 +494,26 @@ export function createRendering({ AWARENESS_RADIUS_M, CONTEXT_RIM_HEIGHT_M, ); - for (const entity of layerState.visual.entities) - entity.polyline.positions.setValue(positions); + // Pulse lines read `visual.center` every frame and follow on their + // own. The gradient disc is geometry, so it is rebuilt, but only + // every `gradientRebuildM`: a build costs a few milliseconds. + layerState.visual.ring.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); + const { gradient, gradientCenter } = layerState.visual; + if ( + gradient && + Cesium.Cartesian3.distance(gradientCenter, groundCenter) >= + AWARENESS_RANGE_PULSE.gradientRebuildM + ) { + const primitives = layerState.viewer.scene.primitives; + layerState.visual.gradient = primitives.add( + createPulseGradient(groundCenter, gradient.appearance.material), + ); + // Primitive.destroy leaves the material alone, so the new disc + // keeps sharing it. + primitives.remove(gradient); + Cesium.Cartesian3.clone(groundCenter, gradientCenter); + } // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); } @@ -365,35 +522,143 @@ export function createRendering({ clearVisual(); const entities = []; + const scene = layerState.viewer.scene; + // A viewer without a primitive collection or a preRender hook (headless + // tools, partial stubs) gets the static ring only, as the Cyber sonar + // scene does. So does a viewer who prefers reduced motion. + const animate = Boolean( + !prefersReducedMotion() && + scene?.primitives?.add && + scene?.preRender?.addEventListener, + ); // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. const ring = AWARENESS_RANGE_RING_STYLE; - entities.push( - layerState.viewer.entities.add({ - polyline: { - positions: new Cesium.ConstantProperty( - awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ), + const core = Cesium.Color.fromCssColorString(ring.color); + const halo = Cesium.Color.fromCssColorString(ring.outlineColor); + // While pulses run, the ring flashes as each one arrives and otherwise + // rests at `restOpacity`; without pulses it stays on screen. + const flashing = (base) => { + const color = base.clone(); + return new Cesium.CallbackProperty(() => { + const flash = awarenessRingFlash(performance.now()); + color.alpha = base.alpha * Math.max(ring.restOpacity, flash); + return color; + }, false); + }; + const ringEntity = layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.ConstantProperty( + awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, ), - width: ring.width, - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineOutlineMaterialProperty({ - color: Cesium.Color.fromCssColorString(ring.color), - outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor), - outlineWidth: ring.outlineWidth, + ), + width: ring.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: animate ? flashing(core) : core, + outlineColor: animate ? flashing(halo) : halo, + outlineWidth: ring.outlineWidth, + }), + }, + }); + entities.push(ringEntity); + + const center = Cesium.Cartesian3.clone(groundCenter); + let gradient = null; + let removePulseListener = null; + let pulseTimer = null; + if (animate) { + const pulse = AWARENESS_RANGE_PULSE; + for (let index = 0; index < pulse.count; index++) { + const positions = []; + const coreColor = core.clone(); + const haloColor = halo.clone(); + const stateAt = () => awarenessPulseState(performance.now(), index); + entities.push( + layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.CallbackProperty( + () => + awarenessRangeRingPositions( + center, + stateAt().radiusM, + CONTEXT_RIM_HEIGHT_M, + pulse.segments, + positions, + ), + false, + ), + width: pulse.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: new Cesium.CallbackProperty(() => { + coreColor.alpha = core.alpha * stateAt().alpha; + return coreColor; + }, false), + outlineColor: new Cesium.CallbackProperty(() => { + haloColor.alpha = halo.alpha * stateAt().alpha; + return haloColor; + }, false), + outlineWidth: pulse.outlineWidth, + }), + }, }), - }, - }), - ); + ); + } + const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX); + const uniforms = { + color: core.clone(), + radii: new Cesium.Cartesian4(), + alphas: new Cesium.Cartesian4(), + depth: pulse.gradientDepthFraction, + }; + uniforms.color.alpha = pulse.gradientOpacity; + const material = new Cesium.Material({ + fabric: { uniforms, source: PULSE_GRADIENT_SOURCE }, + translucent: true, + }); + gradient = scene.primitives.add( + createPulseGradient(groundCenter, material), + ); + const radii = [0, 0, 0, 0]; + const alphas = [0, 0, 0, 0]; + removePulseListener = scene.preRender.addEventListener(() => { + const nowMs = performance.now(); + for (let index = 0; index < count; index++) { + const state = awarenessPulseState(nowMs, index); + radii[index] = state.radiusM / AWARENESS_RADIUS_M; + alphas[index] = state.alpha; + } + Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); + Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); + }); + // The detection overlay pulses the brackets of flights and vessels a + // front crosses; it reads the centre in place, so it follows too. + setAwarenessEchoField({ + center, + radiusM: AWARENESS_RADIUS_M, + pulse, + echo: AWARENESS_RANGE_ECHO, + }); + pulseTimer = globalThis.setInterval( + () => governorRequestRender('awareness-pulse'), + pulse.frameIntervalMs, + ); + } layerState.visual = { key, entities, - center: Cesium.Cartesian3.clone(groundCenter), + ring: ringEntity, + center, + gradient, + gradientCenter: Cesium.Cartesian3.clone(groundCenter), + removePulseListener, + pulseTimer, }; governorRequestRender('awareness-visual'); } From 20770e52a9bed937f9c0901dbc50882931929826 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:31:02 +0530 Subject: [PATCH 09/29] docs(changelog): add the range ring sonar pulses fragment for #1519 --- changelog.d/1519-range-ring-sonar-pulses.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1519-range-ring-sonar-pulses.md diff --git a/changelog.d/1519-range-ring-sonar-pulses.md b/changelog.d/1519-range-ring-sonar-pulses.md new file mode 100644 index 000000000..f82d9e5c9 --- /dev/null +++ b/changelog.d/1519-range-ring-sonar-pulses.md @@ -0,0 +1,6 @@ +- The Global Context range ring pulses like a sonar: three dim rings with a + soft inward glow grow from the subject to 250 km, and the ring flashes as + each one arrives. Flights, military aircraft and AIS vessels a pulse crosses + get a brief echo around their brackets. While a subject is shown, a parked + Contacts view repaints about 12 times a second. Under reduced motion there + are no pulses and the ring stays on screen (manjunath22466, #1519). From 342d9605820a4621615d7f3d1acc85f33dd34763 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:14:20 +0400 Subject: [PATCH 10/29] fix(traffic): retain line identity across clipping --- src/layers/traffic/flowSource.js | 29 ++++++----- src/layers/traffic/roadModes.js | 7 ++- src/layers/traffic/roadModes.test.mjs | 75 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index f517c99bc..12e746e43 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -20,6 +20,19 @@ function tileFreshness(response) { }; } +/** + * Clip a flow line to a box. Every piece keeps its uncut line as `line`, so + * pieces of one line that doubles back are never paired with each other. + */ +function clipFlowSegment(segment, bbox) { + const line = segment.line || segment; + return clipTileLine(segment.coords, bbox).map((coords) => ({ + ...segment, + coords, + line, + })); +} + /** Own one bounded decoded flow cache; every request still passes the server tile budget. */ export function createFlowTileSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -41,12 +54,7 @@ export function createFlowTileSource({ entry?.release(); const segments = decodeFlowTile(bytes, z, x, y, { strict: true }) .filter(isDrivableFlowSegment) - .flatMap((segment) => - clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({ - ...segment, - coords, - })), - ); + .flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y))); if (mark) freshness.set(segments, mark); return segments; }, @@ -87,12 +95,9 @@ export function createFlowTileSource({ partial = result.partial; const segments = bounds.coverage ? result.tiles.flat() - : result.tiles.flat().flatMap((segment) => - clipTileLine(segment.coords, bounds).map((coords) => ({ - ...segment, - coords, - })), - ); + : result.tiles + .flat() + .flatMap((segment) => clipFlowSegment(segment, bounds)); const marks = result.tiles.map((tile) => freshness.get(tile) || {}); const times = marks .map((mark) => mark.fetchedAt) diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index a6eecc5b5..ce62f5e1f 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -112,6 +112,7 @@ function pairedSampleShare(segment, index) { } const length = cum[cum.length - 1]; if (!(length > 0)) return 0; + const line = segment.line || segment; let paired = 0; let i = 1; for (const share of PAIRED_SIDE_SAMPLES) { @@ -126,8 +127,10 @@ function pairedSampleShare(segment, index) { xs[i - 1] + dx * t, ys[i - 1] + dy * t, reverse, - // A line that doubles back beside itself is not its own pair. - (piece) => (piece.flow === segment ? 0 : PAIRED_SIDE_RADIUS_M), + // A line that doubles back beside itself, even clipped into pieces, + // is not its own pair. + (piece) => + (piece.flow.line || piece.flow) === line ? 0 : PAIRED_SIDE_RADIUS_M, ).best ) paired++; diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 406dc934b..228a9609c 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -1,10 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; import { flowSegmentsToRoads, resolveRoadMode, selectTrafficRoads, } from './roadModes.js'; +import { createFlowTileSource } from './flowSource.js'; import { createTrafficSource } from './source.js'; import { createTrafficLayer } from './index.js'; import { @@ -14,6 +16,7 @@ import { serializeStoredLayerState, parseStoredLayerState, } from '../../data/layerState.js'; +import { tileToBBox } from '../../data/tomtomTiles.js'; const coordinates = [ [-97.744, 30.267], @@ -171,6 +174,78 @@ test('a TomTom side that doubles back beside itself is not its own pair', () => }; assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1); }); +/** A flow tile of one-way Major roads; each line is [x, y, x, y, ...]. */ +function flowTile(lines) { + const writer = new PbfWriter(); + writer.writeMessage(3, writeFlowLayer, lines); + return writer.finish(); +} +function writeFlowLayer(lines, layer) { + layer.writeVarintField(15, 2); + layer.writeStringField(1, 'Traffic flow'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, 'traffic_level'); + layer.writeMessage(4, (_, value) => value.writeDoubleField(3, 0.6), null); + layer.writeStringField(3, 'road_type'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'Major road'), + null, + ); + layer.writeStringField(3, 'traffic_road_coverage'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'one_side'), + null, + ); + for (const xy of lines) { + // MoveTo one point, LineTo the rest, as zigzag deltas. + const geometry = [9]; + for (let i = 0; i < xy.length; i++) { + if (i === 2) geometry.push(2 | ((xy.length / 2 - 1) << 3)); + const delta = xy[i] - (i < 2 ? 0 : xy[i - 2]); + geometry.push((delta << 1) ^ (delta >> 31)); + } + layer.writeMessage( + 2, + (_, feature) => { + feature.writeVarintField(3, 2); + feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]); + feature.writePackedVarint(4, geometry); + }, + null, + ); + } +} +test('TomTom sides clipped by their tile and the view pair only with other lines', async () => { + // A one-way hairpin turning in the tile buffer, its arms one tile unit + // (about 2 m) apart, and a separate opposite pair. The tile clip cuts the + // hairpin into two arms; the view clip then cuts every line again. + const lines = [ + [500, 1000, 4300, 1000, 4300, 1001, 500, 1001], + [-200, 3000, 4300, 3000], + [4300, 3001, -200, 3001], + ]; + const source = createFlowTileSource({ + fetchImpl: async () => new Response(flowTile(lines)), + }); + const tile = tileToBBox(12, 935, 1686); + const west = tile.west + (tile.east - tile.west) * 0.2; + const { segments } = await source.fetchFlowDetail({ + ...tile, + west, + east: tile.east - 1e-9, + south: tile.south + 1e-9, + }); + assert.equal(segments.length, 4); + assert.ok( + segments.every((s) => s.coords.every(([lon]) => lon >= west - 1e-10)), + ); + assert.deepEqual( + flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1), + [1, 1, 0.5, 0.5], + ); +}); test('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 0c025987efc84cf89aa2ea42864cc61d5af7b80d Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:17:03 +0530 Subject: [PATCH 11/29] fix(awareness): bound the range ring pulses so a parked view goes quiet Review follow-up for the sonar pulses. Bounded repaint: the pulses used to ask for a frame every 80 ms from a setInterval for as long as a subject was shown, so a parked view repainted at 12.5 fps indefinitely. They now run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stands on screen at full strength, the pulse lines and gradient disc are hidden, echoes stop, and nothing asks for frames. A moving subject does not wake them, since a flying subject moves on every refresh. The lifecycle's existing camera pose signature wakes them. No beat with the Cyber sonar: the setInterval is replaced by a postRender-armed setTimeout on CYBER_SONAR_RENDER_INTERVAL_MS, as in cyberSonarScene. Every drawn frame, whoever asked for it, re-arms the request 80 ms later, so requests never land inside the detection lane's 80 ms gate. Reduced motion mid-session: a change listener on the media query rebuilds the visual, and the same-subject refresh path rebuilds when the preference no longer matches. The listener is removed in clearVisual. Readable edge: restOpacity goes from 0 to 0.4, so the 250 km ring stays visible between flashes, in screenshots and for low-vision viewers. The echo field takes an optional level so echoes rest with the pulses. CURRENT-STATE and the changelog fragment describe the bounded run and the dimmed ring. Tests: render requests stop after the bound and stay stopped for a minute; a camera move seen by the lifecycle wakes them for another bounded run; a foreign frame postpones the next request to 80 ms after it; reduced motion flipped by event and by a later refresh; the ring never drops below restOpacity; echoes scale with the level; the level fades in, holds, fades out and keeps its value when woken. --- changelog.d/1519-range-ring-sonar-pulses.md | 8 +- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 19 +- src/awarenessEcho.test.mjs | 25 ++ src/layers/awareness/lifecycle.js | 2 + src/layers/awareness/policy.js | 28 +- src/layers/awareness/rangeRing.test.mjs | 344 +++++++++++++++++--- src/layers/awareness/rendering.js | 149 +++++++-- 8 files changed, 491 insertions(+), 86 deletions(-) diff --git a/changelog.d/1519-range-ring-sonar-pulses.md b/changelog.d/1519-range-ring-sonar-pulses.md index f82d9e5c9..36ac2eb1c 100644 --- a/changelog.d/1519-range-ring-sonar-pulses.md +++ b/changelog.d/1519-range-ring-sonar-pulses.md @@ -1,6 +1,8 @@ - The Global Context range ring pulses like a sonar: three dim rings with a soft inward glow grow from the subject to 250 km, and the ring flashes as each one arrives. Flights, military aircraft and AIS vessels a pulse crosses - get a brief echo around their brackets. While a subject is shown, a parked - Contacts view repaints about 12 times a second. Under reduced motion there - are no pulses and the ring stays on screen (manjunath22466, #1519). + get a brief echo around their brackets. The pulses run for about 9 s after + a selection or camera move, dimming the ring between flashes, then rest + with the ring steady on screen, so a parked view stops repainting. Under + reduced motion there are no pulses and the ring stays on screen + (manjunath22466, #1519). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 46b012cf6..5b85500ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring is not shown on its own: it flashes to full strength as each pulse reaches it and fades over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses never hold continuous rendering: they ask for a frame every 80 ms, the Cyber sonar's parked cadence, so a parked Contacts view repaints at about 12.5 fps while a subject is shown, and at full rate only when something else already renders continuously. Under `prefers-reduced-motion`, or in a scene without primitives or a preRender hook, there are no pulses or echoes and the ring stays on screen. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index 16fe6cda4..5f8efba0f 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -15,6 +15,8 @@ import * as Cesium from 'cesium'; * @property {number} radiusM Range the pulses grow to. * @property {{count: number, periodMs: number, startFraction: number}} pulse * @property {{fadeMs: number, layerIds: string[]}} echo + * @property {(nowMs: number) => number} [level] How far the pulses are + * switched on, 0 to 1; echoes scale with it, so they rest with the pulses. */ /** @type {AwarenessEchoField|null} */ @@ -94,11 +96,16 @@ export function awarenessEchoAt(layerId, position, nowMs) { groundScratch, ); if (!ground) return 0; - return awarenessEchoStrength( - nowMs, - Cesium.Cartesian3.distance(field.center, ground), - field.radiusM, - field.pulse, - field.echo, + const level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; + return ( + level * + awarenessEchoStrength( + nowMs, + Cesium.Cartesian3.distance(field.center, ground), + field.radiusM, + field.pulse, + field.echo, + ) ); } diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index 9bc7e6cd6..f3bdec026 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -82,3 +82,28 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t) assert.equal(getAwarenessEchoField(), null); assert.equal(awarenessEchoAt('flights', contact, t0), 0); }); + +test('echoes scale with the field level and rest with the pulses', (t) => { + t.after(clearAwarenessEchoField); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + // ~100 km east of the subject, on the ground. + const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0); + let level = 1; + setAwarenessEchoField({ + center, + radiusM: R, + pulse, + echo, + level: () => level, + }); + const d = Cesium.Cartesian3.distance(center, contact); + const t0 = arrival(d); + const full = awarenessEchoAt('flights', contact, t0); + assert.ok(full > 0.99); + level = 0.5; + assert.ok( + Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9, + ); + level = 0; + assert.equal(awarenessEchoAt('flights', contact, t0), 0); +}); diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 925a3a9ca..27a7d0e55 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -62,6 +62,8 @@ export function createLifecycle({ if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; + // A camera move runs the range pulses again; parked, they rest. + parts.rendering.wakePulses(); } const decision = parts.model.awarenessRefreshDecision({ nowMs: now, diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index a0af9e3d7..66485c4eb 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,9 +50,11 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * luminance contrast on light and dark ground alike, and luminance is what * the monochrome styles keep. The core is the Context panel's blue; the halo * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. - * While the sonar pulses run, the ring rests at `restOpacity` and flashes - * to full as each pulse reaches it, fading over `flashMs`. Without pulses - * (reduced motion, or a scene that cannot animate) it stays on screen. */ + * While the sonar pulses run, the ring dims to `restOpacity` and flashes + * to full as each pulse reaches it, fading over `flashMs`. `restOpacity` + * keeps the 250 km edge readable between flashes, in screenshots and for + * low-vision viewers. Once the pulses rest, and without pulses (reduced + * motion, or a scene that cannot animate), it stays on screen at full. */ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ color: 'rgba(98, 181, 255, 0.95)', @@ -61,7 +63,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ outlineWidth: 1, segments: 128, flashMs: 1800, - restOpacity: 0, + restOpacity: 0.4, }); /** @constant Sonar-style pulses that grow slowly from the subject to the @@ -75,11 +77,16 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `gradientOpacity` strong. The gradient disc is rebuilt only after the * subject moves `gradientRebuildM`, since a soft band 2 km off centre on a * 250 km disc does not show. The gradient shader takes at most four pulses. - * The pulses do not hold continuous rendering: a parked Contacts view must - * not repaint every frame. They ask for a frame every `frameIntervalMs` - * instead, the cadence the Cyber sonar uses for parked scenes; when - * something else already renders continuously they update every frame. - * Skipped under prefers-reduced-motion. */ + * The pulses run for `activeMs` (three ring flashes) after a selection or + * a camera move, fading in and out over `settleMs`, then rest: the ring + * stays on screen, static, and nothing asks for frames, so a parked view + * goes quiet again. A moving subject does not wake them, since a flying + * subject moves on every refresh, but a camera that follows it does; the + * follow already renders every frame. While they run they never hold + * continuous rendering; each drawn frame arms one more request after the + * Cyber sonar's 80 ms cadence, so the two never beat. When something else + * already renders continuously they update every frame. Skipped under + * prefers-reduced-motion. */ export const AWARENESS_RANGE_PULSE = Object.freeze({ count: 3, @@ -96,7 +103,8 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ gradientOpacity: 0.22, gradientGranularityDeg: 0.25, gradientRebuildM: 2000, - frameIntervalMs: 80, + activeMs: 9000, + settleMs: 1200, }); /** @constant Sonar echoes on contact brackets. When a pulse front reaches a diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 655bdc080..8b56685f7 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -16,14 +16,18 @@ import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M, } from './policy.js'; +import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js'; import { AWARENESS_PULSE_GRADIENT_MAX, + awarenessPulseLevel, awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, createRendering, + wakeAwarenessPulses, } from './rendering.js'; import { getAwarenessEchoField } from '../../awarenessEcho.js'; +import { createLifecycle } from './lifecycle.js'; import { createState } from './state.js'; /** WCAG relative luminance of a Cesium colour, ignoring alpha. */ @@ -39,22 +43,48 @@ function luminance(color) { /** * The rendering part over a bare entity collection, counting render asks and - * by reason. `reducedMotion` stubs the media query for - * the length of the test. + * by reason. `reducedMotion` stubs the media query for the length of the + * test; `motion.set(on)` flips it later and tells its listeners, as the + * browser does. */ function ringRendering(t, { reducedMotion = false } = {}) { const previous = globalThis.matchMedia; - globalThis.matchMedia = (query) => ({ - matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion, - }); + const listeners = new Set(); + const query = { + get matches() { + return reducedMotion; + }, + addEventListener(type, listener) { + if (type === 'change') listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === 'change') listeners.delete(listener); + }, + }; + globalThis.matchMedia = (media) => + media === '(prefers-reduced-motion: reduce)' + ? query + : { matches: false, addEventListener() {}, removeEventListener() {} }; t.after(() => { globalThis.matchMedia = previous; }); + const motion = { + set(on) { + reducedMotion = on; + for (const listener of [...listeners]) listener({ matches: on }); + }, + /** Change the preference without a change event, as old browsers do. */ + setQuietly(on) { + reducedMotion = on; + }, + listeners: () => listeners.size, + }; const state = createState({ services: {} }); const entities = new Cesium.EntityCollection(); const scene = { primitives: new Cesium.PrimitiveCollection(), preRender: new Cesium.Event(), + postRender: new Cesium.Event(), }; state.viewer = { entities, scene }; const reasons = []; @@ -77,11 +107,42 @@ function ringRendering(t, { reducedMotion = false } = {}) { entities, scene, rendering, + motion, + reasons, renders: () => reasons.filter((r) => r === 'awareness-visual').length, pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length, }; } +/** + * A request-render scene on a fake clock: `performance.now()` and the + * timers advance together, and any render ask is drawn (preRender, then + * postRender) on the next millisecond, as Cesium's request-render mode does. + * `draw()` draws a frame someone else asked for. + */ +function frameLoop(t, ring) { + let clock = 1_000_000; + t.mock.method(performance, 'now', () => clock); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let drawn = 0; + const draw = () => { + drawn = ring.reasons.length; + ring.scene.preRender.raiseEvent(ring.scene); + ring.scene.postRender.raiseEvent(ring.scene); + }; + return { + now: () => clock, + draw, + advance(ms) { + for (let step = 0; step < ms; step++) { + clock++; + t.mock.timers.tick(1); + if (ring.reasons.length > drawn) draw(); + } + }, + }; +} + /** The pulse gradient disc and the echo collection among scene primitives. */ function pulsePrimitives(scene) { const all = Array.from({ length: scene.primitives.length }, (_, i) => @@ -284,15 +345,17 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () => ); }); -test('pulses ask for a frame every 80 ms instead of holding continuous rendering', (t) => { - t.mock.timers.enable({ apis: ['setInterval'] }); - const { entities, rendering, pulseFrames } = ringRendering(t); +test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => { + const ring = ringRendering(t); + const { entities, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - // A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, - // never every display frame. + // A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while + // the pulses run, never every display frame. assert.equal(pulseFrames(), 0); - t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); - assert.equal(pulseFrames(), 10); + loop.advance(1); // the frame the new ring asked for arms the first request + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); + assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const now = Cesium.JulianDate.now(); const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, @@ -316,15 +379,167 @@ test('pulses ask for a frame every 80 ms instead of holding continuous rendering ); assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100); } + const frames = pulseFrames(); rendering.clearVisual(); assert.equal(entities.values.length, 0); - t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); - assert.equal(pulseFrames(), 10, 'no frames once the ring is gone'); + assert.equal(ring.scene.postRender.numberOfListeners, 0); + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); + assert.equal(pulseFrames(), frames, 'no frames once the ring is gone'); +}); + +test('a parked view stops asking for frames once the pulses rest', (t) => { + const ring = ringRendering(t); + const { entities, scene, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; + // Long enough for three ring flashes, then quiet. + assert.ok(activeMs >= 3 * (periodMs / count)); + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const frames = pulseFrames(); + assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2); + // Parked: a minute later not one more request. + loop.advance(60_000); + assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); + // At rest the ring stands on screen at full, as without pulses, and the + // pulses, gradient and echoes are hidden. + const now = Cesium.JulianDate.now(); + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const [ringEntity, ...pulses] = entities.values; + assert.ok( + Math.abs( + ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, + ) < 1e-9, + ); + for (const pulse of pulses) + assert.equal(pulse.polyline.show.getValue(now), false); + assert.equal(pulsePrimitives(scene).disc.show, false); + assert.equal(getAwarenessEchoField().level(loop.now()), 0); +}); + +test('a camera move wakes resting pulses for another bounded run', (t) => { + const ring = ringRendering(t); + const { rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs } = AWARENESS_RANGE_PULSE; + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const rested = pulseFrames(); + // The camera moves: lifecycle wakes the pulses inside the frame it draws, + // and that frame arms the next request. Waking asks for none of its own. + rendering.wakePulses(); + assert.equal(pulseFrames(), rested); + loop.draw(); + loop.advance(activeMs + 500); + const woke = pulseFrames() - rested; + assert.ok(woke >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, `${woke}`); + loop.advance(60_000); + assert.equal(pulseFrames(), rested + woke, 'and rest again'); + // The subject moving does not wake them: a flying subject moves on every + // refresh, which would bring the endless repaint back. + rendering.renderVisual(flight(3.01)); + loop.advance(10_000); + assert.equal(pulseFrames(), rested + woke); +}); + +test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => { + const ring = ringRendering(t); + const { state, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs } = AWARENESS_RANGE_PULSE; + state.enabled = true; + state.viewer.camera = { + positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), + heading: 0, + pitch: -1, + roll: 0, + }; + const lifecycle = createLifecycle({ + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts: { + rendering, + model: { + awarenessRefreshDecision: () => ({ moving: false, refresh: false }), + awarenessNeedsContinuousRender: () => false, + }, + }, + }); + lifecycle.attachRuntimeListeners(); + t.after(() => lifecycle.detachRuntimeListeners()); + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const rested = pulseFrames(); + // Parked: frames other owners draw do not wake the pulses. + loop.draw(); + loop.advance(activeMs); + assert.equal(pulseFrames(), rested); + // The camera pans 50 km: the next frame wakes them for another run. + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + 3.5, + 20, + 800_000, + ); + loop.draw(); + loop.advance(activeMs + 500); + assert.ok( + pulseFrames() - rested >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, + ); + const woke = pulseFrames(); + loop.advance(60_000); + assert.equal(pulseFrames(), woke, 'and rest again once it parks'); +}); + +test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => { + const ring = ringRendering(t); + const { rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + loop.advance(1); + const frames = pulseFrames(); + // Another owner (the Cyber sonar, a data update) draws 67 ms after ours. + loop.advance(67); + loop.draw(); + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS - 1); + assert.equal(pulseFrames(), frames, 'no request 13 ms after that frame'); + loop.advance(1); + assert.equal(pulseFrames(), frames + 1, 'one request 80 ms after it'); +}); + +test('the pulse level fades in, holds, fades out and keeps its level when woken', () => { + const { activeMs, settleMs } = AWARENESS_RANGE_PULSE; + const wake = wakeAwarenessPulses( + { startMs: -Infinity, untilMs: -Infinity }, + 0, + ); + assert.equal(awarenessPulseLevel(0, wake), 0); + assert.ok(Math.abs(awarenessPulseLevel(settleMs / 2, wake) - 0.5) < 1e-9); + assert.equal(awarenessPulseLevel(activeMs / 2, wake), 1); + assert.ok( + Math.abs(awarenessPulseLevel(activeMs - settleMs / 4, wake) - 0.25) < 1e-9, + ); + assert.equal(awarenessPulseLevel(activeMs, wake), 0); + assert.equal(awarenessPulseLevel(activeMs * 10, wake), 0); + // Woken mid-fade-out: no jump back to full, then a fresh run. + const at = activeMs - settleMs / 4; + wakeAwarenessPulses(wake, at); + assert.ok(Math.abs(awarenessPulseLevel(at, wake) - 0.25) < 1e-9); + assert.equal(awarenessPulseLevel(at + settleMs, wake), 1); + assert.equal(awarenessPulseLevel(at + activeMs, wake), 0); }); test('a scene without primitives or preRender gets the static ring only', (t) => { const { state, entities, rendering, pulseFrames } = ringRendering(t); - for (const scene of [{}, { primitives: { add() {} } }, undefined]) { + for (const scene of [ + {}, + { primitives: { add() {} } }, + { primitives: { add() {} }, preRender: new Cesium.Event() }, + undefined, + ]) { state.viewer = { entities, scene }; rendering.clearVisual(); rendering.renderVisual(flight(3)); @@ -336,7 +551,9 @@ test('a scene without primitives or preRender gets the static ring only', (t) => }); test('a gradient disc behind the pulses fades inward and follows the subject', (t) => { - const { scene, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); const { disc, count } = pulsePrimitives(scene); @@ -357,26 +574,20 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( ) < 1e-6, ); // Each frame writes the pulses' radius (as a fraction of the range) and - // opacity into the shader, from the same curve the lines use. + // opacity into the shader, from the same curve the lines use, once the + // pulses have faded in. assert.equal(scene.preRender.numberOfListeners, 1); - const before = performance.now(); + loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); scene.preRender.raiseEvent(scene); - const after = performance.now(); + assert.equal(disc.show, true); const { radii, alphas } = material.uniforms; for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) { const radius = Cesium.Cartesian4.pack(radii, [])[index]; const alpha = Cesium.Cartesian4.pack(alphas, [])[index]; - const low = awarenessPulseState(before, index); - const high = awarenessPulseState(after, index); - // A few milliseconds pass during the raise; allow for that drift. - assert.ok( - Math.abs(radius - low.radiusM / AWARENESS_RADIUS_M) < 0.01 || - Math.abs(radius - high.radiusM / AWARENESS_RADIUS_M) < 0.01, - ); + const expected = awarenessPulseState(loop.now(), index); + assert.ok(Math.abs(radius - expected.radiusM / AWARENESS_RADIUS_M) < 1e-6); assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity); - assert.ok( - Math.abs(alpha - low.alpha) < 0.02 || Math.abs(alpha - high.alpha) < 0.02, - ); + assert.ok(Math.abs(alpha - expected.alpha) < 1e-6); } // Unused shader slots stay empty. assert.equal( @@ -425,7 +636,7 @@ test('pulses evaluate every frame without breaking, reusing their positions', (t }); test('reduced motion keeps the static ring and drops the pulses', (t) => { - t.mock.timers.enable({ apis: ['setInterval'] }); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); const { entities, scene, rendering, pulseFrames } = ringRendering(t, { reducedMotion: true, }); @@ -436,16 +647,58 @@ test('reduced motion keeps the static ring and drops the pulses', (t) => { assert.equal(pulseFrames(), 0); assert.equal(scene.primitives.length, 0); assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); +}); + +test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => { + const ring = ringRendering(t); + const { entities, scene, rendering, motion, state } = ring; + const loop = frameLoop(t, ring); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + loop.advance(1000); + // The OS setting changes while the same aircraft stays tracked. + motion.set(true); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.equal(scene.primitives.length, 0); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(getAwarenessEchoField(), null); + const frames = ring.pulseFrames(); + loop.advance(5000); + assert.equal(ring.pulseFrames(), frames); + motion.set(false); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.ok(getAwarenessEchoField()); + // One media-query listener per visual; none once the ring is gone. + assert.equal(motion.listeners(), 1); + rendering.clearVisual(); + assert.equal(motion.listeners(), 0); +}); + +test('a refresh after reduced motion changed rebuilds the visual too', (t) => { + const { entities, rendering, motion } = ringRendering(t); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + // No change event: the next same-subject refresh notices on its own. + motion.setQuietly(true); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { - const { entities, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { entities, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const ring = entities.values[0]; + const ringEntity = entities.values[0]; const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.equal(ring.polyline.material.color.isConstant, false); + assert.equal(ringEntity.polyline.material.color.isConstant, false); const { periodMs, count } = AWARENESS_RANGE_PULSE; const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE; // Full the moment a front reaches 250 km, gone `flashMs` later. @@ -453,7 +706,8 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => assert.ok(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6); assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2); assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0); - assert.equal(restOpacity, 0); + // Between flashes the 250 km edge stays readable, dimmed, not gone. + assert.ok(restOpacity >= 0.35 && restOpacity <= 0.5, `${restOpacity}`); // Once per arriving pulse: `count` flashes in any one-period window that // starts after a flash has faded. let flashes = 0; @@ -465,16 +719,18 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => wasLit = lit; } assert.equal(flashes, count); - // The live colour tracks the flash at this instant. - const before = awarenessRingFlash(performance.now()); - const alpha = ring.polyline.material.color.getValue( - Cesium.JulianDate.now(), - ).alpha; - const after = awarenessRingFlash(performance.now()); - assert.ok( - alpha >= core.alpha * Math.min(before, after) - 0.02 && - alpha <= core.alpha * Math.max(before, after) + 0.02, - ); + // Once the pulses have faded in, the live colour tracks the flash, and + // never drops below `restOpacity`. + loop.advance(AWARENESS_RANGE_PULSE.settleMs); + for (let step = 0; step < 40; step++) { + loop.advance(100); + const alpha = ringEntity.polyline.material.color.getValue( + Cesium.JulianDate.now(), + ).alpha; + const expected = + core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now())); + assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`); + } }); test('without pulses the ring stays on screen at full strength', (t) => { diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index ca22bacbd..b8197f60b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { + CYBER_SONAR_RENDER_INTERVAL_MS, createCyberSonarSampler, isCyberContactSonarActive as isCyberSonarActive, } from '../../cyberSonar.js'; @@ -112,12 +113,51 @@ export function awarenessPulseState( }; } +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + /** Whether the viewer asked the system for less motion. */ function prefersReducedMotion() { - return ( - globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? - false + return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false; +} + +/** + * How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over + * `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to + * `wake.untilMs`. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`. + * @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`. + * @returns {number} 0 to 1. + */ +export function awarenessPulseLevel( + nowMs, + wake, + pulse = AWARENESS_RANGE_PULSE, +) { + const level = Math.min( + (nowMs - wake.startMs) / pulse.settleMs, + (wake.untilMs - nowMs) / pulse.settleMs, ); + return Math.max(0, Math.min(1, level)); +} + +/** + * Run the pulses for `activeMs` from `nowMs`. A wake while they already run + * keeps their current level, so they never jump back to full mid-fade. + * @param {{startMs: number, untilMs: number}} wake Updated in place. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`. + * @returns {{startMs: number, untilMs: number}} `wake`. + */ +export function wakeAwarenessPulses( + wake, + nowMs, + pulse = AWARENESS_RANGE_PULSE, +) { + wake.startMs = + nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs; + wake.untilMs = nowMs + pulse.activeMs; + return wake; } /** @@ -457,9 +497,19 @@ export function createRendering({ presentDirection({ hidden: false, ring, heading, labels, markers }); } + /** + * Run the range pulses again for `activeMs`, after a camera move. Call it + * during a frame (lifecycle's preRender): that frame's postRender arms the + * next request, so waking asks for no extra frame of its own. + */ + function wakePulses() { + const wake = layerState.visual?.wake; + if (wake) wakeAwarenessPulses(wake, performance.now()); + } + function clearVisual() { - globalThis.clearInterval(layerState.visual?.pulseTimer); - layerState.visual?.removePulseListener?.(); + layerState.visual?.stopPulses?.(); + layerState.visual?.removeMotionListener?.(); if (layerState.visual?.gradient && layerState.viewer) layerState.viewer.scene.primitives.remove(layerState.visual.gradient); clearAwarenessEchoField(); @@ -482,7 +532,12 @@ export function createRendering({ 0, ); const key = `${subject.layerId}:${subject.id}`; - if (layerState.visual?.key === key) { + const reducedMotion = prefersReducedMotion(); + // A reduced-motion change rebuilds the visual, with or without pulses. + if ( + layerState.visual?.key === key && + layerState.visual.reducedMotion === reducedMotion + ) { // Move the ring only after a meaningful displacement so live tracking // does not rebuild its geometry for sub-pixel motion every 750 ms. if ( @@ -527,10 +582,19 @@ export function createRendering({ // tools, partial stubs) gets the static ring only, as the Cyber sonar // scene does. So does a viewer who prefers reduced motion. const animate = Boolean( - !prefersReducedMotion() && + !reducedMotion && scene?.primitives?.add && - scene?.preRender?.addEventListener, + scene?.preRender?.addEventListener && + scene?.postRender?.addEventListener, ); + // Pulses run from now until `wake.untilMs`, then rest. + const wake = animate + ? wakeAwarenessPulses( + { startMs: -Infinity, untilMs: -Infinity }, + performance.now(), + ) + : null; + const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0); // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. @@ -538,12 +602,13 @@ export function createRendering({ const core = Cesium.Color.fromCssColorString(ring.color); const halo = Cesium.Color.fromCssColorString(ring.outlineColor); // While pulses run, the ring flashes as each one arrives and otherwise - // rests at `restOpacity`; without pulses it stays on screen. + // dims to `restOpacity`; as they settle it returns to full and stays. const flashing = (base) => { const color = base.clone(); return new Cesium.CallbackProperty(() => { - const flash = awarenessRingFlash(performance.now()); - color.alpha = base.alpha * Math.max(ring.restOpacity, flash); + const nowMs = performance.now(); + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed)); return color; }, false); }; @@ -569,18 +634,26 @@ export function createRendering({ const center = Cesium.Cartesian3.clone(groundCenter); let gradient = null; - let removePulseListener = null; - let pulseTimer = null; + let stopPulses = null; if (animate) { const pulse = AWARENESS_RANGE_PULSE; for (let index = 0; index < pulse.count; index++) { const positions = []; const coreColor = core.clone(); const haloColor = halo.clone(); - const stateAt = () => awarenessPulseState(performance.now(), index); + const stateAt = () => { + const nowMs = performance.now(); + const state = awarenessPulseState(nowMs, index); + state.alpha *= levelAt(nowMs); + return state; + }; entities.push( layerState.viewer.entities.add({ polyline: { + show: new Cesium.CallbackProperty( + () => levelAt(performance.now()) > 0, + false, + ), positions: new Cesium.CallbackProperty( () => awarenessRangeRingPositions( @@ -626,16 +699,40 @@ export function createRendering({ ); const radii = [0, 0, 0, 0]; const alphas = [0, 0, 0, 0]; - removePulseListener = scene.preRender.addEventListener(() => { + const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); + const level = levelAt(nowMs); + // The disc is looked up each frame: a subject move swaps it. + const disc = layerState.visual?.gradient; + if (disc) disc.show = level > 0; for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; - alphas[index] = state.alpha; + alphas[index] = state.alpha * level; } Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); }); + // Each drawn frame, whoever asked for it, arms one request a Cyber + // sonar interval later, as the Cyber sonar scene does. Requests never + // land within 80 ms of a frame, so they cannot beat the detection + // lane's 80 ms gate, and they stop once the pulses rest. + let timer = null; + const removePostRender = scene.postRender.addEventListener(() => { + globalThis.clearTimeout(timer); + timer = null; + if (performance.now() < wake.untilMs) + timer = globalThis.setTimeout(() => { + timer = null; + governorRequestRender('awareness-pulse'); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + }); + stopPulses = () => { + removePreRender(); + removePostRender(); + globalThis.clearTimeout(timer); + timer = null; + }; // The detection overlay pulses the brackets of flights and vessels a // front crosses; it reads the centre in place, so it follows too. setAwarenessEchoField({ @@ -643,13 +740,17 @@ export function createRendering({ radiusM: AWARENESS_RADIUS_M, pulse, echo: AWARENESS_RANGE_ECHO, + level: levelAt, }); - pulseTimer = globalThis.setInterval( - () => governorRequestRender('awareness-pulse'), - pulse.frameIntervalMs, - ); } + // Turning reduced motion on or off mid-session rebuilds the visual. + const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY); + const onMotionChange = () => { + if (layerState.subject) renderVisual(layerState.subject); + }; + motionQuery?.addEventListener?.('change', onMotionChange); + layerState.visual = { key, entities, @@ -657,8 +758,11 @@ export function createRendering({ center, gradient, gradientCenter: Cesium.Cartesian3.clone(groundCenter), - removePulseListener, - pulseTimer, + reducedMotion, + wake, + stopPulses, + removeMotionListener: () => + motionQuery?.removeEventListener?.('change', onMotionChange), }; governorRequestRender('awareness-visual'); } @@ -672,5 +776,6 @@ export function createRendering({ updateDirectionOverlay, clearVisual, renderVisual, + wakePulses, }; } From 013cfdf91da93466853d022dd3b78570ee89c741 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:44:09 +0530 Subject: [PATCH 12/29] fix(awareness): keep range pulses out of picking and in the style palette Review follow-up for the sonar pulses (the non-blocking items). Unpickable pulses: the three pulse lines were polyline entities, which Cesium always picks, so near-miss and touch picks around them became unowned hits. They are now drawn by the pulse disc's shader, which already drew the gradient and is built with allowPicking false. Each line keeps the polyline outline material's look: 3 CSS px wide with a 1 px dark outline in the ring's colours, measured from the screen-space gradient of the disc's radius times czm_pixelRatio, as Cesium's contour material does. Without derivatives only the gradient band is drawn. Disc follow: with the lines on the disc, it is rebuilt after the subject moves 500 m instead of 2 km, so the pulses stay concentric with the ring. A resting disc is hidden and not rebuilt; it catches up when the pulses wake. Camera below the disc: the disc sits 2.5 km above the ellipsoid with faceForward, so from a lower camera it swept across the sky. It is now hidden while the camera is below that height. Echo palette: echo brackets keep the pulse blue on the default detection style and take the style's line colour under NVG, FLIR and CRT. Echo test: the detection renderer's echo maths moves into awarenessEchoBracket and awarenessEchoColor in src/awarenessEcho.js, tested directly instead of running a regex slice of detection.js through vm, and the one-sided spread assert is now two-sided. The positions reuse parameter of awarenessRangeRingPositions and the pulse segment count had no callers left and are removed. --- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 41 ++++++ src/data/detection.js | 35 +++-- src/data/detectionAwarenessEcho.test.mjs | 124 ++++++++--------- src/layers/awareness/policy.js | 19 ++- src/layers/awareness/rangeRing.test.mjs | 147 +++++++++++--------- src/layers/awareness/rendering.js | 169 ++++++++++++----------- 7 files changed, 303 insertions(+), 234 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5b85500ae..764d9df28 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index 5f8efba0f..ed197a517 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -109,3 +109,44 @@ export function awarenessEchoAt(layerId, position, nowMs) { ) ); } + +/** + * The echo bracket the detection overlay draws around one contact, or null + * when it has no echo. `band` is the alpha band, 1 to `steps`, scaled by the + * contact's own keyhole `admissionAlpha`; `spreadPx` is how far the echo + * sits outside the contact's bracket, 0 as the front arrives, growing to the + * field's `spreadPx` as it fades. + * @param {string} layerId Owning layer of the contact. + * @param {Cesium.Cartesian3} position Contact's world position. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {number} admissionAlpha The contact bracket's own fade, 0 to 1. + * @param {number} steps Alpha bands the overlay batches by. + * @returns {{band: number, spreadPx: number}|null} + */ +export function awarenessEchoBracket( + layerId, + position, + nowMs, + admissionAlpha, + steps, +) { + const echo = awarenessEchoAt(layerId, position, nowMs); + if (!(echo > 0.01)) return null; + return { + band: Math.max(1, Math.ceil(echo * admissionAlpha * steps)), + spreadPx: field.echo.spreadPx * (1 - echo), + }; +} + +/** + * The echo's stroke colour: the field's own colour on the default detection + * style, and the style's line colour under the others (NVG, FLIR, CRT), so + * an echo never stands out of a monochrome palette. + * @param {string} styleLine The active detection style's line colour. + * @param {boolean} defaultStyle Whether the default detection style is active. + * @returns {string|null} Null when no field is set. + */ +export function awarenessEchoColor(styleLine, defaultStyle) { + if (!field) return null; + return defaultStyle ? field.echo.color : styleLine; +} diff --git a/src/data/detection.js b/src/data/detection.js index 018d3d348..de896e046 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -53,7 +53,11 @@ import { isCyberContactSonarActive as isCyberSonarActive, isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; -import { awarenessEchoAt, getAwarenessEchoField } from '../awarenessEcho.js'; +import { + awarenessEchoBracket, + awarenessEchoColor, + getAwarenessEchoField, +} from '../awarenessEcho.js'; /** * @module detection @@ -1445,22 +1449,22 @@ function _drawOverlay(frame) { ); visibleCount++; if (echoPaths) { - const echo = awarenessEchoAt(obj._layerId, obj.position, now); - if (echo > 0.01) { - // Starts on the contact's bracket and spreads outward as it fades. - const band = Math.max( - 1, - Math.ceil(echo * admissionAlpha * ECHO_ALPHA_STEPS), - ); - const spread = echoField.echo.spreadPx * (1 - echo); + // Starts on the contact's bracket and spreads outward as it fades. + const echo = awarenessEchoBracket( + obj._layerId, + obj.position, + now, + admissionAlpha, + ECHO_ALPHA_STEPS, + ); + if (echo) appendCornerBracket( - (echoPaths[band] ??= new Path2D()), + (echoPaths[echo.band] ??= new Path2D()), sx, sy, - halfW + spread, - halfH + spread, + halfW + echo.spreadPx, + halfH + echo.spreadPx, ); - } } if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; @@ -1603,7 +1607,10 @@ function _drawOverlay(frame) { } if (echoPaths) { _ctx.lineWidth = echoField.echo.lineWidth; - _ctx.strokeStyle = echoField.echo.color; + _ctx.strokeStyle = awarenessEchoColor( + _theme.line, + _theme === THEME_MAP._default, + ); for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) { if (!echoPaths[band]) continue; _ctx.globalAlpha = diff --git a/src/data/detectionAwarenessEcho.test.mjs b/src/data/detectionAwarenessEcho.test.mjs index 4c34672d5..a9c4fecc3 100644 --- a/src/data/detectionAwarenessEcho.test.mjs +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -1,104 +1,96 @@ // src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse // a contact's own detection bracket: a second bracket that starts on it and -// spreads outward as it fades. Exercises the renderer's actual echo block. +// spreads outward as it fades, in the detection style's own colour. import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import vm from 'node:vm'; import * as Cesium from 'cesium'; import { - awarenessEchoAt, + awarenessEchoBracket, + awarenessEchoColor, clearAwarenessEchoField, setAwarenessEchoField, } from '../awarenessEcho.js'; +import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; -const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); -const echoBlock = source.match( - /if \(echoPaths\) \{\s*const echo = awarenessEchoAt[\s\S]*?\n {6}\}\n/, -)?.[0]; - +const STEPS = 8; const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 }; const echo = { fadeMs: 1400, layerIds: ['flights', 'military', 'ais-live-vessels'], spreadPx: 8, + color: 'rgb(98, 181, 255)', }; +const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); +const field = { center, radiusM: 250_000, pulse, echo }; -/** Run the renderer's echo block for one contact and record the bracket. */ -function paintEcho(field, obj, now, { admissionAlpha = 1 } = {}) { - const brackets = []; - const state = { - echoPaths: field ? new Array(9) : null, - echoField: field, - ECHO_ALPHA_STEPS: 8, - awarenessEchoAt, - Path2D: class {}, - appendCornerBracket: (sink, sx, sy, halfW, halfH) => - brackets.push({ sink, sx, sy, halfW, halfH }), - obj, - now, - admissionAlpha, - sx: 100, - sy: 50, - halfW: 9, - halfH: 7, - }; - vm.runInNewContext(echoBlock, state); - const band = state.echoPaths?.findIndex((path) => path) ?? -1; - return { brackets, band }; +/** The moment pulse 0's front reaches the ground under `position`. */ +function arrivalAt(position) { + const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position); + const d = Cesium.Cartesian3.distance(center, ground); + return ( + ((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) * + pulse.periodMs + + pulse.periodMs * 3 + ); } test('the echo bracket pulses outward from the contact bracket and fades', (t) => { - assert.ok(echoBlock, 'renderer has an echo block'); t.after(clearAwarenessEchoField); - const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); - const field = { center, radiusM: 250_000, pulse, echo }; setAwarenessEchoField(field); const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000); - const ground = Cesium.Cartesian3.fromDegrees(3.5, 27, 0); - const d = Cesium.Cartesian3.distance(center, ground); - const arrival = - ((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) * - pulse.periodMs + - pulse.periodMs * 3; - const obj = { _layerId: 'flights', position }; + const arrival = arrivalAt(position); + const at = (nowMs, admissionAlpha = 1) => + awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS); // The moment the front arrives: on the bracket, strongest band. - const fresh = paintEcho(field, obj, arrival); - assert.equal(fresh.brackets.length, 1); - assert.ok(fresh.brackets[0].halfW - 9 < 0.01); - assert.equal(fresh.band, 8); - // Half way through the fade: dimmer and spread outward. - const half = paintEcho(field, obj, arrival + echo.fadeMs / 2); - assert.ok(half.brackets[0].halfW > 9 + echo.spreadPx * 0.5); - assert.ok(half.brackets[0].halfH > 7 + echo.spreadPx * 0.5); - assert.equal(half.brackets[0].sx, 100); - assert.ok(half.band < 8 && half.band >= 1); + const fresh = at(arrival); + assert.ok(fresh); + assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`); + assert.equal(fresh.band, STEPS); + // Half way through the fade: dimmer and spread outward, but not past + // the field's spread. + const half = at(arrival + echo.fadeMs / 2); + assert.ok(half.spreadPx > echo.spreadPx * 0.5); + assert.ok(half.spreadPx < echo.spreadPx); + assert.ok(half.band < STEPS && half.band >= 1); // Keyhole fade carries over to the echo. - assert.ok(paintEcho(field, obj, arrival, { admissionAlpha: 0.25 }).band <= 2); + assert.ok(at(arrival, 0.25).band <= 2); // Faded out, or between fronts: no echo bracket. - assert.equal( - paintEcho(field, obj, arrival + echo.fadeMs + 5).brackets.length, - 0, - ); + assert.equal(at(arrival + echo.fadeMs + 5), null); }); test('no echo field, or a layer that does not echo, draws nothing extra', (t) => { - assert.ok(echoBlock); t.after(clearAwarenessEchoField); - const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0); - // No field: the renderer skips the echo block entirely. - assert.equal( - paintEcho(null, { _layerId: 'flights', position }, 0).brackets.length, - 0, - ); - const field = { center, radiusM: 250_000, pulse, echo }; + clearAwarenessEchoField(); + for (let now = 0; now < pulse.periodMs; now += 25) + assert.equal( + awarenessEchoBracket('flights', position, now, 1, STEPS), + null, + ); setAwarenessEchoField(field); for (let now = 0; now < pulse.periodMs; now += 25) for (const layerId of ['cctv', 'satellites', 'traffic']) assert.equal( - paintEcho(field, { _layerId: layerId, position }, now).brackets.length, - 0, + awarenessEchoBracket(layerId, position, now, 1, STEPS), + null, ); }); + +test('echoes take the detection style colour except on the default style', (t) => { + t.after(clearAwarenessEchoField); + assert.equal( + awarenessEchoColor(DETECTION_THEME_MAP._default.line, true), + null, + ); + setAwarenessEchoField(field); + assert.equal( + awarenessEchoColor(DETECTION_THEME_MAP._default.line, true), + echo.color, + ); + // CRT, NVG and FLIR keep their monochrome palettes. + for (const style of ['retro', 'surveillance', 'thermal']) { + const { line } = DETECTION_THEME_MAP[style]; + assert.equal(awarenessEchoColor(line, false), line, style); + } +}); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 66485c4eb..9acabb822 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -72,11 +72,17 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `periodMs / count`. Each starts at `startFraction` of the radius, fades in * over `fadeInFraction` of its travel, then fades as (1 - travel) to the * `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse - * is dim but still visible when it reaches the ring and the ring flashes. Behind each line a radial gradient in the same blue fades - * inward over `gradientDepthFraction` of the radius, at most - * `gradientOpacity` strong. The gradient disc is rebuilt only after the - * subject moves `gradientRebuildM`, since a soft band 2 km off centre on a - * 250 km disc does not show. The gradient shader takes at most four pulses. + * is dim but still visible when it reaches the ring and the ring flashes. + * Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark + * halo, in the ring's colours, as Cesium's polyline outline material draws. Behind each line a radial gradient in the same blue + * fades inward over `gradientDepthFraction` of the radius, at most + * `gradientOpacity` strong. One unpickable disc draws the lines and the + * gradient, so pulses never catch a click meant for a contact; its shader + * takes at most four pulses. The disc is rebuilt only after the subject + * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's + * centre, about a pixel when the ring fills the screen; while the pulses + * rest the hidden disc is left alone and catches up when they wake. The disc is hidden + * while the camera is below its height, where it would sweep the sky. * The pulses run for `activeMs` (three ring flashes) after a selection or * a camera move, fading in and out over `settleMs`, then rest: the ring * stays on screen, static, and nothing asks for frames, so a parked view @@ -98,11 +104,10 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ peakOpacity: 0.6, width: 3, outlineWidth: 1, - segments: 96, gradientDepthFraction: 0.35, gradientOpacity: 0.22, gradientGranularityDeg: 0.25, - gradientRebuildM: 2000, + gradientRebuildM: 500, activeMs: 9000, settleMs: 1200, }); diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 8b56685f7..54018f296 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -2,9 +2,11 @@ // ring is a haloed polyline that stays legible on light and dark ground, // sits where the old ellipse outline did, and follows the subject. // Dim sonar pulses grow slowly from the subject to the ring, each with an -// inward-fading gradient behind it; the ring flashes as each one arrives and -// the overlay's echo field follows the subject. Under reduced motion the ring -// simply stays on screen. +// inward-fading gradient behind it, drawn on one unpickable disc; the ring +// dims and flashes as each one arrives and the overlay's echo field follows +// the subject. The pulses run for a bounded time after a selection or camera +// move, then rest with the ring steady. Under reduced motion the ring simply +// stays on screen. import '../../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -221,8 +223,7 @@ test('the ring core and halo contrast on light and dark ground', () => { test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { const { entities, rendering } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); - assert.ok(entities.values.every((entity) => entity.ellipse === undefined)); + assert.equal(entities.values.length, 1); const [entity] = entities.values; assert.equal(entity.ellipse, undefined); const now = Cesium.JulianDate.now(); @@ -251,12 +252,13 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) }); test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => { - const { entities, rendering, renders } = ringRendering(t); + const { entities, scene, rendering, renders } = ringRendering(t); const subject = flight; rendering.renderVisual(subject(3)); const [entity] = entities.values; const now = Cesium.JulianDate.now(); const first = entity.polyline.positions.getValue(now); + const discBefore = pulsePrimitives(scene).disc; const rendersAfterAdd = renders(); // ~100 m east: under the threshold, nothing is rebuilt or rendered. @@ -266,7 +268,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift // ~1 km east: the same entity moves to the new centre. rendering.renderVisual(subject(3.01)); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(entities.values.length, 1); assert.equal(entities.values[0], entity); const moved = entity.polyline.positions.getValue(now); assert.notEqual(moved, first); @@ -276,14 +278,9 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift )) assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); assert.equal(renders(), rendersAfterAdd + 1); - // Each pulse is a circle around the NEW centre: one radius all the way round. - for (const pulse of entities.values.slice(1)) { - const radii = surfaceDistances( - Cesium.Cartesian3.fromDegrees(3.01, 27, 0), - pulse.polyline.positions.getValue(now), - ); - assert.ok(Math.max(...radii) - Math.min(...radii) < 100); - } + // The pulses are drawn on a disc, rebuilt around the NEW centre a + // kilometre on, so they stay concentric with the ring. + assert.notEqual(pulsePrimitives(scene).disc, discBefore); rendering.clearVisual(); assert.equal(entities.values.length, 0); @@ -356,29 +353,6 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con loop.advance(1); // the frame the new ring asked for arms the first request loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); - const now = Cesium.JulianDate.now(); - const core = Cesium.Color.fromCssColorString( - AWARENESS_RANGE_RING_STYLE.color, - ); - for (const pulse of entities.values.slice(1)) { - assert.equal(pulse.polyline.positions.isConstant, false); - assert.equal( - pulse.polyline.width.getValue(now), - AWARENESS_RANGE_PULSE.width, - ); - const color = pulse.polyline.material.color.getValue(now); - assert.ok(color.alpha >= 0 && color.alpha <= core.alpha); - assert.equal(color.red, core.red); - const radii = surfaceDistances( - Cesium.Cartesian3.fromDegrees(3, 27, 0), - pulse.polyline.positions.getValue(now), - ); - assert.ok( - Math.min(...radii) >= - AWARENESS_RADIUS_M * AWARENESS_RANGE_PULSE.startFraction - 100, - ); - assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100); - } const frames = pulseFrames(); rendering.clearVisual(); assert.equal(entities.values.length, 0); @@ -407,14 +381,13 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const [ringEntity, ...pulses] = entities.values; + const [ringEntity] = entities.values; assert.ok( Math.abs( ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, ) < 1e-9, ); - for (const pulse of pulses) - assert.equal(pulse.polyline.show.getValue(now), false); + scene.preRender.raiseEvent(scene); assert.equal(pulsePrimitives(scene).disc.show, false); assert.equal(getAwarenessEchoField().level(loop.now()), 0); }); @@ -573,6 +546,17 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity, ) < 1e-6, ); + // The pulse lines are drawn in the ring's colours and widths. + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const halo = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.outlineColor, + ); + assert.ok(material.uniforms.lineColor.equalsEpsilon(core, 1e-6)); + assert.ok(material.uniforms.haloColor.equalsEpsilon(halo, 1e-6)); + assert.equal(material.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width); + assert.equal(material.uniforms.haloWidth, AWARENESS_RANGE_PULSE.outlineWidth); // Each frame writes the pulses' radius (as a fraction of the range) and // opacity into the shader, from the same curve the lines use, once the // pulses have faded in. @@ -595,14 +579,14 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( 0, ); - // The disc is rebuilt only past `gradientRebuildM` (2 km), not on every - // 250 m step the lines take, and keeps its material. + // The disc is rebuilt only past `gradientRebuildM`, not on every 250 m + // step the ring takes, and keeps its material. rendering.renderVisual(flight(3.001)); assert.equal(pulsePrimitives(scene).disc, disc); - rendering.renderVisual(flight(3.01)); // ~1 km: the lines move, the disc stays + rendering.renderVisual(flight(3.004)); // ~400 m: the ring moves, the disc stays assert.equal(pulsePrimitives(scene).disc, disc); assert.ok(!disc.isDestroyed()); - rendering.renderVisual(flight(3.03)); // ~3 km from the disc's centre + rendering.renderVisual(flight(3.01)); // ~1 km from the disc's centre assert.equal(pulsePrimitives(scene).count, 1); const moved = pulsePrimitives(scene).disc; assert.notEqual(moved, disc); @@ -615,24 +599,55 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( assert.equal(scene.preRender.numberOfListeners, 0); }); -test('pulses evaluate every frame without breaking, reusing their positions', (t) => { - const { entities, rendering } = ringRendering(t); +test('a resting pulse disc is not rebuilt as the subject moves, and catches up on wake', (t) => { + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); - for (const pulse of entities.values.slice(1)) { - const property = pulse.polyline.positions; - const first = property.getValue(Cesium.JulianDate.now()); - // Cesium calls this on every rendered frame; the second call is where a - // reused Cartesian once landed in the ellipsoid argument and threw. - for (let frame = 0; frame < 5; frame++) { - const positions = property.getValue(Cesium.JulianDate.now()); - assert.equal(positions, first, 'the same array is reused'); - assert.equal(positions.length, AWARENESS_RANGE_PULSE.segments + 1); - const radii = surfaceDistances(center, positions); - assert.ok(radii.every(Number.isFinite)); - assert.ok(Math.max(...radii) - Math.min(...radii) < 100); - } - } + loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); + const resting = pulsePrimitives(scene).disc; + // A flying subject keeps moving while the pulses rest: no rebuilds. + for (let lon = 3.01; lon < 3.1; lon += 0.01) + rendering.renderVisual(flight(lon)); + assert.equal(pulsePrimitives(scene).disc, resting); + assert.ok(!resting.isDestroyed()); + // Woken, the disc moves to the subject at once. + rendering.wakePulses(); + const woken = pulsePrimitives(scene).disc; + assert.notEqual(woken, resting); + assert.ok(resting.isDestroyed()); + assert.equal(pulsePrimitives(scene).count, 1); + // Woken again without moving: the disc stays. + rendering.wakePulses(); + assert.equal(pulsePrimitives(scene).disc, woken); +}); + +test('pulses never catch a click: only the ring is an entity, and the disc is unpickable', (t) => { + const { entities, scene, rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + // The ring is #1518's entity; the pulses add no entity of their own. + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); + for (let i = 0; i < scene.primitives.length; i++) + assert.equal(scene.primitives.get(i).allowPicking, false); +}); + +test('a camera below the rim height hides the pulse disc instead of sweeping the sky', (t) => { + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); + const cameraAt = (height) => { + scene.camera = { + positionCartographic: new Cesium.Cartographic(0, 0, height), + }; + scene.preRender.raiseEvent(scene); + return pulsePrimitives(scene).disc.show; + }; + assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true); + assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view'); + assert.equal(cameraAt(800_000), true); }); test('reduced motion keeps the static ring and drops the pulses', (t) => { @@ -656,7 +671,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t const loop = frameLoop(t, ring); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(scene.primitives.length, 1); loop.advance(1000); // The OS setting changes while the same aircraft stays tracked. motion.set(true); @@ -670,7 +685,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t loop.advance(5000); assert.equal(ring.pulseFrames(), frames); motion.set(false); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(scene.primitives.length, 1); assert.ok(getAwarenessEchoField()); // One media-query listener per visual; none once the ring is gone. assert.equal(motion.listeners(), 1); @@ -681,12 +696,12 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t test('a refresh after reduced motion changed rebuilds the visual too', (t) => { const { entities, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(entities.values[0].polyline.material.color.isConstant, false); // No change event: the next same-subject refresh notices on its own. motion.setQuietly(true); rendering.renderVisual(flight(3)); assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index b8197f60b..b5069e973 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -34,7 +34,6 @@ import { * @param {number} radiusM Surface radius in metres. * @param {number} heightM Height of the ring above the ellipsoid. * @param {number} [segments] Chords around the ring. - * @param {Cesium.Cartesian3[]} [result] Array whose Cartesians are reused. * @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first. */ export function awarenessRangeRingPositions( @@ -42,7 +41,6 @@ export function awarenessRangeRingPositions( radiusM, heightM, segments = AWARENESS_RANGE_RING_STYLE.segments, - result = [], ) { const { latitude, longitude } = Cesium.Cartographic.fromCartesian(groundCenter); @@ -53,8 +51,7 @@ export function awarenessRangeRingPositions( const w = 1 - e2 * sinLat * sinLat; const meridional = (a * (1 - e2)) / (w * Math.sqrt(w)); const primeVertical = a / Math.sqrt(w); - const positions = result; - positions.length = segments + 1; + const positions = new Array(segments + 1); for (let i = 0; i <= segments; i++) { const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; const cosB = Math.cos(bearing); @@ -75,7 +72,6 @@ export function awarenessRangeRingPositions( lat, heightM, Cesium.Ellipsoid.WGS84, - positions[i], ); } return positions; @@ -177,37 +173,69 @@ export function awarenessRingFlash(nowMs) { ); } -/** Most pulses the gradient shader draws; its uniforms are vec4s. */ +/** Most pulses the pulse shader draws; its uniforms are vec4s. */ export const AWARENESS_PULSE_GRADIENT_MAX = 4; // Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at -// distance 0.5, so `r` is the fraction of the range. Behind each pulse line -// the band is strongest at the line and fades to nothing `depth` inward. +// distance 0.5, so `r` is the fraction of the range. Each pulse is a line +// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half +// on each side, as Cesium's polyline outline material draws it, with a band +// behind it that is strongest at the line and fades to nothing `depth` +// inward. Widths come from the screen-space gradient of `r`, as Cesium's +// contour material measures its lines; without derivatives the lines are +// left out and only the band shows. const PULSE_GRADIENT_SOURCE = ` uniform vec4 color; +uniform vec4 lineColor; +uniform vec4 haloColor; uniform vec4 radii; uniform vec4 alphas; uniform float depth; +uniform float lineWidth; +uniform float haloWidth; +vec4 over(vec4 top, vec4 under) { + float a = top.a + under.a * (1.0 - top.a); + if (a <= 0.0) return vec4(0.0); + return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a); +} czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); float r = length(materialInput.st - vec2(0.5)) * 2.0; - float alpha = 0.0; + float band = 0.0; for (int i = 0; i < 4; i++) { float inside = radii[i] - r; if (inside >= 0.0 && inside <= depth) { float t = 1.0 - inside / depth; - alpha += alphas[i] * t * t; + band += alphas[i] * t * t; } } - material.diffuse = color.rgb; - material.alpha = clamp(alpha * color.a, 0.0, 1.0); + vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0)); +#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives)) + // Change in r per CSS pixel: the gradient's length per device pixel, + // times the device pixels in one CSS pixel. + float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio; + float outerHalf = lineWidth * 0.5; + float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5; + for (int i = 0; i < 4; i++) { + float d = abs(radii[i] - r) / cssPx; + float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0); + if (cover <= 0.0) continue; + float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0); + vec4 pulse = mix(haloColor, lineColor, core); + result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result); + } +#endif + material.diffuse = result.rgb; + material.alpha = result.a; return material; } `; /** - * The pulse gradient: one flat, translucent, unpickable disc the size of the - * range ring whose shader draws a band behind each pulse. Moving pulses only + * The pulses: one flat, translucent, unpickable disc the size of the range + * ring whose shader draws each pulse line and the band behind it. Drawing + * them here rather than as entities keeps them out of picking, so clicks and + * taps near a pulse still reach the contact underneath. Moving pulses only * change its uniforms; the disc is rebuilt only when the subject moves. * @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid. * @param {Cesium.Material} material Shared pulse-gradient material. @@ -239,6 +267,11 @@ function createPulseGradient(groundCenter, material) { }); } +/** Camera height above the ellipsoid, or NaN when the scene cannot say. */ +function cameraHeightM(scene) { + return scene.camera?.positionCartographic?.height ?? Number.NaN; +} + export function createRendering({ state: layerState, services, @@ -504,7 +537,32 @@ export function createRendering({ */ function wakePulses() { const wake = layerState.visual?.wake; - if (wake) wakeAwarenessPulses(wake, performance.now()); + if (!wake) return; + wakeAwarenessPulses(wake, performance.now()); + followPulseDisc(); + } + + /** + * Rebuild the pulse disc around the subject once it is `gradientRebuildM` + * from the disc's centre. A build costs a few milliseconds, so a subject + * that moves less keeps the disc it has. + */ + function followPulseDisc() { + const { gradient, gradientCenter, center } = layerState.visual; + if ( + !gradient || + Cesium.Cartesian3.distance(gradientCenter, center) < + AWARENESS_RANGE_PULSE.gradientRebuildM + ) + return; + const primitives = layerState.viewer.scene.primitives; + layerState.visual.gradient = primitives.add( + createPulseGradient(center, gradient.appearance.material), + ); + // Primitive.destroy leaves the material alone, so the new disc keeps + // sharing it. + primitives.remove(gradient); + Cesium.Cartesian3.clone(center, gradientCenter); } function clearVisual() { @@ -549,26 +607,14 @@ export function createRendering({ AWARENESS_RADIUS_M, CONTEXT_RIM_HEIGHT_M, ); - // Pulse lines read `visual.center` every frame and follow on their - // own. The gradient disc is geometry, so it is rebuilt, but only - // every `gradientRebuildM`: a build costs a few milliseconds. + // The echo field reads `visual.center` in place and follows on its + // own. The pulse disc is geometry, so it is rebuilt, but only every + // `gradientRebuildM`: a build costs a few milliseconds. layerState.visual.ring.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); - const { gradient, gradientCenter } = layerState.visual; - if ( - gradient && - Cesium.Cartesian3.distance(gradientCenter, groundCenter) >= - AWARENESS_RANGE_PULSE.gradientRebuildM - ) { - const primitives = layerState.viewer.scene.primitives; - layerState.visual.gradient = primitives.add( - createPulseGradient(groundCenter, gradient.appearance.material), - ); - // Primitive.destroy leaves the material alone, so the new disc - // keeps sharing it. - primitives.remove(gradient); - Cesium.Cartesian3.clone(groundCenter, gradientCenter); - } + // A resting disc is hidden, so it catches up when the pulses wake. + if (performance.now() < layerState.visual.wake?.untilMs) + followPulseDisc(); // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); } @@ -637,57 +683,16 @@ export function createRendering({ let stopPulses = null; if (animate) { const pulse = AWARENESS_RANGE_PULSE; - for (let index = 0; index < pulse.count; index++) { - const positions = []; - const coreColor = core.clone(); - const haloColor = halo.clone(); - const stateAt = () => { - const nowMs = performance.now(); - const state = awarenessPulseState(nowMs, index); - state.alpha *= levelAt(nowMs); - return state; - }; - entities.push( - layerState.viewer.entities.add({ - polyline: { - show: new Cesium.CallbackProperty( - () => levelAt(performance.now()) > 0, - false, - ), - positions: new Cesium.CallbackProperty( - () => - awarenessRangeRingPositions( - center, - stateAt().radiusM, - CONTEXT_RIM_HEIGHT_M, - pulse.segments, - positions, - ), - false, - ), - width: pulse.width, - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineOutlineMaterialProperty({ - color: new Cesium.CallbackProperty(() => { - coreColor.alpha = core.alpha * stateAt().alpha; - return coreColor; - }, false), - outlineColor: new Cesium.CallbackProperty(() => { - haloColor.alpha = halo.alpha * stateAt().alpha; - return haloColor; - }, false), - outlineWidth: pulse.outlineWidth, - }), - }, - }), - ); - } const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX); const uniforms = { color: core.clone(), + lineColor: core.clone(), + haloColor: halo.clone(), radii: new Cesium.Cartesian4(), alphas: new Cesium.Cartesian4(), depth: pulse.gradientDepthFraction, + lineWidth: pulse.width, + haloWidth: pulse.outlineWidth, }; uniforms.color.alpha = pulse.gradientOpacity; const material = new Cesium.Material({ @@ -702,9 +707,13 @@ export function createRendering({ const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); const level = levelAt(nowMs); - // The disc is looked up each frame: a subject move swaps it. + // The disc is looked up each frame: a subject move swaps it. From a + // camera below the rim height it would sweep across the sky, so it + // is hidden there. const disc = layerState.visual?.gradient; - if (disc) disc.show = level > 0; + if (disc) + disc.show = + level > 0 && !(cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M); for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; From 0f56b3147b69749638ef9c28dfa99f4170de8661 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:04:33 +0530 Subject: [PATCH 13/29] fix(awareness): stop the subject echoing itself and cover the echo wiring Remaining review follow-ups for the sonar pulses. No self-echo: pulses are born 5 km out (startFraction of the radius), but contacts nearer than that, the subject included, still lit on every pulse birth. They now never echo. Just past the birth ring, an echo is scaled with the front's own fade-in, so a bracket no longer flashes before a visible front reaches it. The ring flash at the full radius is unchanged. Older Safari: the reduced-motion listener falls back to the media query's addListener / removeListener when addEventListener is missing, as other modules here already do. Echo wiring test: a detectionHost test now paints through the real detection lane with an echo field set, and checks the echo stroke takes the echo colour on the default style and each style's line colour under CRT, NVG and FLIR. CURRENT-STATE notes the 5 km no-echo zone. --- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 26 ++++++++----- src/awarenessEcho.test.mjs | 34 ++++++++++++++-- src/data/detectionHost.test.mjs | 52 +++++++++++++++++++++++++ src/layers/awareness/rangeRing.test.mjs | 49 +++++++++++++++++------ src/layers/awareness/rendering.js | 12 ++++-- 6 files changed, 146 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 764d9df28..c4e2b11bc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index ed197a517..ecf0f21a1 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -13,7 +13,7 @@ import * as Cesium from 'cesium'; * @property {Cesium.Cartesian3} center Subject's ground point. The publisher * may move it in place; it is read on every query. * @property {number} radiusM Range the pulses grow to. - * @property {{count: number, periodMs: number, startFraction: number}} pulse + * @property {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse * @property {{fadeMs: number, layerIds: string[]}} echo * @property {(nowMs: number) => number} [level] How far the pulses are * switched on, 0 to 1; echoes scale with it, so they rest with the pulses. @@ -50,22 +50,27 @@ export function getAwarenessEchoField() { * its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The * strongest pulse wins. A front that has wrapped back to the centre still * counts for the contact it last crossed, so rim contacts fade out instead of - * cutting off. + * cutting off. Nothing nearer than the birth ring `startFraction` out echoes, + * and within the pulse's `fadeInFraction` the echo is scaled down with it. * @param {number} nowMs Monotonic clock in milliseconds. * @param {number} distanceM Contact's distance from the subject. * @param {number} radiusM Range the pulses grow to. - * @param {{count: number, periodMs: number, startFraction: number}} pulse + * @param {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse * @param {{fadeMs: number}} echo * @returns {number} 0 to 1. */ export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { - if (!Number.isFinite(distanceM) || distanceM < 0 || distanceM > radiusM) + // Pulses are born `startFraction` out, so nothing nearer (the subject + // itself, its wingmen) is ever crossed; without this it lit on every birth. + if ( + !Number.isFinite(distanceM) || + distanceM < radiusM * pulse.startFraction || + distanceM > radiusM + ) return 0; // The phase at which a pulse front sits at this distance. - const touchPhase = Math.max( - 0, - (distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction), - ); + const touchPhase = + (distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction); let strength = 0; for (let index = 0; index < pulse.count; index++) { const cycle = nowMs / pulse.periodMs + index / pulse.count; @@ -77,7 +82,10 @@ export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { if (elapsedMs < echo.fadeMs) strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2); } - return strength; + // A front still fading in over `fadeInFraction` of its travel echoes no + // brighter than it shows. + const fadeIn = pulse.fadeInFraction ?? 0; + return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength; } /** diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index f3bdec026..846c630c4 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -45,7 +45,14 @@ test('an echo lights as a pulse front reaches the contact, then fades', () => { test('rim contacts fade out after the front wraps; outside the range never', () => { const rim = R * 0.999; assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2); - assert.ok(strength(pulse.periodMs * 5, 1000) > 0.99, 'inside the start ring'); + // Inside the ring pulses are born on, nothing is ever crossed: the + // subject and anything within 5 km never echo, at any moment. + const inner = R * pulse.startFraction; + for (let ms = 0; ms < pulse.periodMs; ms += 10) { + assert.equal(strength(ms, 0), 0, 'the subject itself'); + assert.equal(strength(ms, inner - 1), 0); + } + assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring'); for (let ms = 0; ms < pulse.periodMs; ms += 50) assert.equal(strength(ms, R + 1), 0); assert.equal(strength(0, Number.NaN), 0); @@ -74,9 +81,12 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t) awarenessEchoAt('flights', contact, t0 + 300), ) < 0.01, ); - // The field's centre is read in place, so a moved subject moves the echo. - Cesium.Cartesian3.clone(contact, center); - assert.ok(awarenessEchoAt('flights', contact, pulse.periodMs * 7) > 0.99); + // The field's centre is read in place, so a moved subject moves the echo: + // about 50 km farther, the contact lights at its new distance's moment. + Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center); + const moved = Cesium.Cartesian3.distance(center, contact); + assert.ok(Math.abs(moved - d) > 1000); + assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99); assert.equal(awarenessEchoAt('flights', null, t0), 0); clearAwarenessEchoField(); assert.equal(getAwarenessEchoField(), null); @@ -107,3 +117,19 @@ test('echoes scale with the field level and rest with the pulses', (t) => { level = 0; assert.equal(awarenessEchoAt('flights', contact, t0), 0); }); + +test('near the subject an echo is no brighter than the fading-in front', () => { + const fading = { ...pulse, fadeInFraction: 0.08 }; + const at = (distanceM) => + awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo); + // Born at 5 km, the front reaches full strength 8% of the way out. + const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction)); + assert.equal(at(R * fading.startFraction), 0); + const halfway = (R * fading.startFraction + full) / 2; + assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`); + assert.ok(at(full + 1) > 0.99); + assert.ok( + at(R * 0.999) > 0.99, + 'the rim, and so the ring flash, is unchanged', + ); +}); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 06bfe01af..2e3829c94 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -24,6 +24,10 @@ import { setOverlayEntries, } from '../overlays/worldOverlay.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; +import { + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../awarenessEcho.js'; import { composeLabel, resolveTier } from './detectionDraw.js'; test('detection diagnostics count rendered fading rows instead of absent selected identities', () => { @@ -627,6 +631,54 @@ test('surveillance military tier reaches the dedicated render target as shipped } }); +test('Global Context echo brackets reach the canvas in the detection style colour', () => { + const env = installEnvironment(); + // Fronts every millisecond with a long fade, so the contact is lit on + // whatever frame paints; a distinctive width picks the echo stroke out. + const echo = { + layerIds: ['military'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }; + const echoStrokes = () => + env.detectionCtx.calls + .filter(([name, , , width]) => name === 'stroke' && width === echo.lineWidth) + .map(([, , style]) => style); + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [militaryLayer()], () => {}); + setMode('DENSE'); + setDetectionStyle('normal'); + settleFrame(env); + assert.ok(echoStrokes().length > 0, 'the echo bracket is painted'); + assert.ok(echoStrokes().every((style) => style === echo.color)); + + // CRT, NVG and FLIR keep their own palettes. + for (const style of ['retro', 'surveillance', 'thermal']) { + env.detectionCtx.calls.length = 0; + setDetectionStyle(style); + settleFrame(env); + const strokes = echoStrokes(); + assert.ok(strokes.length > 0, style); + assert.ok( + strokes.every((stroke) => stroke === DETECTION_THEME_MAP[style].line), + `${style} echoes take the style line colour, got ${strokes}`, + ); + } + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + test('pathological detection paint holds alternate frames without freezing shared lanes', () => { // Opts into the telemetry banner: the held-frame assertions count banner paints. const env = installEnvironment({ search: '?detectDebug=1' }); diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 54018f296..01beac378 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -49,20 +49,30 @@ function luminance(color) { * test; `motion.set(on)` flips it later and tells its listeners, as the * browser does. */ -function ringRendering(t, { reducedMotion = false } = {}) { +function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { const previous = globalThis.matchMedia; const listeners = new Set(); - const query = { - get matches() { - return reducedMotion; - }, - addEventListener(type, listener) { - if (type === 'change') listeners.add(listener); - }, - removeEventListener(type, listener) { - if (type === 'change') listeners.delete(listener); - }, - }; + // `legacyQuery` is a MediaQueryList as Safari before 14 shipped it, with + // only addListener / removeListener. + const query = legacyQuery + ? { + get matches() { + return reducedMotion; + }, + addListener: (listener) => listeners.add(listener), + removeListener: (listener) => listeners.delete(listener), + } + : { + get matches() { + return reducedMotion; + }, + addEventListener(type, listener) { + if (type === 'change') listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === 'change') listeners.delete(listener); + }, + }; globalThis.matchMedia = (media) => media === '(prefers-reduced-motion: reduce)' ? query @@ -693,6 +703,21 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t assert.equal(motion.listeners(), 0); }); +test('a reduced-motion change reaches older Safari through addListener', (t) => { + const { entities, scene, rendering, motion, state } = ringRendering(t, { + legacyQuery: true, + }); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(scene.primitives.length, 1); + assert.equal(motion.listeners(), 1); + motion.set(true); + assert.equal(scene.primitives.length, 0); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); + rendering.clearVisual(); + assert.equal(motion.listeners(), 0); +}); + test('a refresh after reduced motion changed rebuilds the visual too', (t) => { const { entities, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index b5069e973..3e929752b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -758,7 +758,10 @@ export function createRendering({ const onMotionChange = () => { if (layerState.subject) renderVisual(layerState.subject); }; - motionQuery?.addEventListener?.('change', onMotionChange); + // Safari before 14 has only the older addListener / removeListener. + if (motionQuery?.addEventListener) + motionQuery.addEventListener('change', onMotionChange); + else motionQuery?.addListener?.(onMotionChange); layerState.visual = { key, @@ -770,8 +773,11 @@ export function createRendering({ reducedMotion, wake, stopPulses, - removeMotionListener: () => - motionQuery?.removeEventListener?.('change', onMotionChange), + removeMotionListener: () => { + if (motionQuery?.removeEventListener) + motionQuery.removeEventListener('change', onMotionChange); + else motionQuery?.removeListener?.(onMotionChange); + }, }; governorRequestRender('awareness-visual'); } From 686bc4fd0ed2b09c5cd30dbdeb8219ace53b49d4 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:25:30 +0530 Subject: [PATCH 14/29] fix(awareness): draw the 250 km range ring as an unpickable primitive The range ring was a Cesium entity, and entities are always picked, so a click or tap on its edge became an unowned hit: vessel selection read it as empty space and pickContact stopped its touch search there. A contact under the 250 km edge was hard to select. The ring is now a polyline primitive with allowPicking false, in the same PolylineOutline material and 4 px width the entity used, so it draws the same. The flash while pulses run writes the material's colour alphas from the pulses' per-frame update; under reduced motion the colours stay at full and no listener is added. A subject that moves 250 m rebuilds the primitive and keeps the material, so a flash carries on across the move. Viewers without a usable primitive collection (headless tools, stubs) still get a static entity. The ring's geometry options come from awarenessRingGeometryOptions, tested directly, and the tests now check that the awareness visual adds no entity and that every primitive it adds is unpickable, with and without reduced motion. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/policy.js | 2 + src/layers/awareness/rangeRing.test.mjs | 183 +++++++++++++----------- src/layers/awareness/rendering.js | 128 +++++++++++++---- 4 files changed, 203 insertions(+), 112 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c4e2b11bc..be0d26204 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 9acabb822..11fb866ae 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,6 +50,8 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * luminance contrast on light and dark ground alike, and luminance is what * the monochrome styles keep. The core is the Context panel's blue; the halo * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. + * It is drawn as an unpickable primitive, so clicks on its edge reach the + * contacts underneath. * While the sonar pulses run, the ring dims to `restOpacity` and flashes * to full as each pulse reaches it, fading over `flashMs`. `restOpacity` * keeps the 250 km edge readable between flashes, in screenshots and for diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 01beac378..f005d3b21 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -25,6 +25,7 @@ import { awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, + awarenessRingGeometryOptions, createRendering, wakeAwarenessPulses, } from './rendering.js'; @@ -155,15 +156,22 @@ function frameLoop(t, ring) { }; } -/** The pulse gradient disc and the echo collection among scene primitives. */ +/** The ring and the pulse disc among scene primitives. */ function pulsePrimitives(scene) { const all = Array.from({ length: scene.primitives.length }, (_, i) => scene.primitives.get(i), ); - return { - disc: all.find((p) => p instanceof Cesium.Primitive), - count: all.length, - }; + const ring = all.find( + (p) => p.appearance instanceof Cesium.PolylineMaterialAppearance, + ); + const discs = all.filter((p) => p instanceof Cesium.Primitive && p !== ring); + return { ring, disc: discs[0], count: discs.length }; +} + +/** The ring's material uniforms as the next drawn frame sets them. */ +function ringColors(scene) { + scene.preRender.raiseEvent(scene); + return pulsePrimitives(scene).ring.appearance.material.uniforms; } const flight = (lon) => ({ @@ -231,56 +239,65 @@ test('the ring core and halo contrast on light and dark ground', () => { }); test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { - const { entities, rendering } = ringRendering(t); + const { entities, scene, state, rendering } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - const [entity] = entities.values; - assert.equal(entity.ellipse, undefined); - const now = Cesium.JulianDate.now(); - assert.equal(entity.polyline.width.getValue(now), 4); - assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE); - const material = entity.polyline.material; - assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty); + // A polyline primitive, never an entity or an ellipse outline. + assert.equal(entities.values.length, 0); + const { ring } = pulsePrimitives(scene); + assert.ok(ring); + const geometry = ring.geometryInstances.geometry; + assert.ok(geometry instanceof Cesium.PolylineGeometry); + // 4 px wide, straight chords between surface-following positions, as the + // entity ring was drawn. + const options = awarenessRingGeometryOptions(state.visual.ringPositions); + assert.equal(options.width, 4); + assert.equal(options.width, AWARENESS_RANGE_RING_STYLE.width); + assert.equal(options.arcType, Cesium.ArcType.NONE); + assert.equal(options.positions, state.visual.ringPositions); + assert.equal( + options.vertexFormat, + Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + ); + const { material } = ring.appearance; + assert.equal(material.type, 'PolylineOutline'); // The ring's hue is fixed; its opacity flashes with arriving pulses. const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const color = material.color.getValue(now); + const { color, outlineWidth } = ringColors(scene); assert.equal(color.red, core.red); assert.equal(color.blue, core.blue); assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9); - assert.equal( - material.outlineWidth.getValue(now), - AWARENESS_RANGE_RING_STYLE.outlineWidth, - ); + assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth); const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); - for (const distance of surfaceDistances( - ground, - entity.polyline.positions.getValue(now), - )) + for (const distance of surfaceDistances(ground, state.visual.ringPositions)) assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); }); test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => { - const { entities, scene, rendering, renders } = ringRendering(t); + const { scene, state, rendering, renders } = ringRendering(t); const subject = flight; rendering.renderVisual(subject(3)); - const [entity] = entities.values; - const now = Cesium.JulianDate.now(); - const first = entity.polyline.positions.getValue(now); + const ringBefore = pulsePrimitives(scene).ring; + const first = state.visual.ringPositions; const discBefore = pulsePrimitives(scene).disc; const rendersAfterAdd = renders(); // ~100 m east: under the threshold, nothing is rebuilt or rendered. rendering.renderVisual(subject(3.001)); - assert.equal(entity.polyline.positions.getValue(now), first); + assert.equal(pulsePrimitives(scene).ring, ringBefore); + assert.equal(state.visual.ringPositions, first); assert.equal(renders(), rendersAfterAdd); - // ~1 km east: the same entity moves to the new centre. + // ~1 km east: the ring is rebuilt around the new centre, keeping its + // material, so a flash carries on. rendering.renderVisual(subject(3.01)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0], entity); - const moved = entity.polyline.positions.getValue(now); + const ringAfter = pulsePrimitives(scene).ring; + assert.notEqual(ringAfter, ringBefore); + assert.ok(ringBefore.isDestroyed()); + assert.equal(ringAfter.appearance.material, ringBefore.appearance.material); + assert.equal(ringAfter.allowPicking, false); + const moved = state.visual.ringPositions; assert.notEqual(moved, first); for (const distance of surfaceDistances( Cesium.Cartesian3.fromDegrees(3.01, 27, 0), @@ -293,7 +310,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift assert.notEqual(pulsePrimitives(scene).disc, discBefore); rendering.clearVisual(); - assert.equal(entities.values.length, 0); + assert.equal(scene.primitives.length, 0); }); test('a pulse grows slowly from the subject and arrives dim at the ring', () => { @@ -354,7 +371,7 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () => test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => { const ring = ringRendering(t); - const { entities, rendering, pulseFrames } = ring; + const { rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); // A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while @@ -365,7 +382,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const frames = pulseFrames(); rendering.clearVisual(); - assert.equal(entities.values.length, 0); + assert.equal(ring.scene.primitives.length, 0); assert.equal(ring.scene.postRender.numberOfListeners, 0); loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); assert.equal(pulseFrames(), frames, 'no frames once the ring is gone'); @@ -373,7 +390,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con test('a parked view stops asking for frames once the pulses rest', (t) => { const ring = ringRendering(t); - const { entities, scene, rendering, pulseFrames } = ring; + const { scene, rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; // Long enough for three ring flashes, then quiet. @@ -387,17 +404,10 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); // At rest the ring stands on screen at full, as without pulses, and the // pulses, gradient and echoes are hidden. - const now = Cesium.JulianDate.now(); const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const [ringEntity] = entities.values; - assert.ok( - Math.abs( - ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, - ) < 1e-9, - ); - scene.preRender.raiseEvent(scene); + assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9); assert.equal(pulsePrimitives(scene).disc.show, false); assert.equal(getAwarenessEchoField().level(loop.now()), 0); }); @@ -526,8 +536,10 @@ test('a scene without primitives or preRender gets the static ring only', (t) => state.viewer = { entities, scene }; rendering.clearVisual(); rendering.renderVisual(flight(3)); + // No usable primitive collection: the ring falls back to an entity. assert.equal(entities.values.length, 1); assert.equal(entities.values[0].polyline.positions.isConstant, true); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); } rendering.clearVisual(); assert.equal(pulseFrames(), 0); @@ -539,6 +551,7 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); + assert.equal(scene.primitives.length, 2, 'the ring and the pulse disc'); const { disc, count } = pulsePrimitives(scene); assert.equal(count, 1, 'the gradient disc'); assert.ok(disc); @@ -632,14 +645,17 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o assert.equal(pulsePrimitives(scene).disc, woken); }); -test('pulses never catch a click: only the ring is an entity, and the disc is unpickable', (t) => { - const { entities, scene, rendering } = ringRendering(t); - rendering.renderVisual(flight(3)); - // The ring is #1518's entity; the pulses add no entity of their own. - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); - for (let i = 0; i < scene.primitives.length; i++) - assert.equal(scene.primitives.get(i).allowPicking, false); +test('the ring and pulses never catch a click: no entities, every primitive unpickable', (t) => { + for (const reducedMotion of [false, true]) { + const { entities, scene, rendering } = ringRendering(t, { reducedMotion }); + rendering.renderVisual(flight(3)); + // Cesium always picks entities, so the awareness visual adds none. + assert.equal(entities.values.length, 0); + assert.ok(scene.primitives.length >= 1); + for (let i = 0; i < scene.primitives.length; i++) + assert.equal(scene.primitives.get(i).allowPicking, false); + rendering.clearVisual(); + } }); test('a camera below the rim height hides the pulse disc instead of sweeping the sky', (t) => { @@ -662,32 +678,30 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the test('reduced motion keeps the static ring and drops the pulses', (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); - const { entities, scene, rendering, pulseFrames } = ringRendering(t, { + const { scene, rendering, pulseFrames } = ringRendering(t, { reducedMotion: true, }); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); + assert.ok(pulsePrimitives(scene).ring); t.mock.timers.tick(1000); assert.equal(pulseFrames(), 0); - assert.equal(scene.primitives.length, 0); + assert.equal(pulsePrimitives(scene).count, 0, 'no pulse disc'); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); }); test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => { const ring = ringRendering(t); - const { entities, scene, rendering, motion, state } = ring; + const { scene, rendering, motion, state } = ring; const loop = frameLoop(t, ring); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); loop.advance(1000); // The OS setting changes while the same aircraft stays tracked. motion.set(true); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); - assert.equal(scene.primitives.length, 0); + assert.ok(pulsePrimitives(scene).ring); + assert.equal(pulsePrimitives(scene).count, 0); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); assert.equal(getAwarenessEchoField(), null); @@ -695,7 +709,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t loop.advance(5000); assert.equal(ring.pulseFrames(), frames); motion.set(false); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); assert.ok(getAwarenessEchoField()); // One media-query listener per visual; none once the ring is gone. assert.equal(motion.listeners(), 1); @@ -704,41 +718,43 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t }); test('a reduced-motion change reaches older Safari through addListener', (t) => { - const { entities, scene, rendering, motion, state } = ringRendering(t, { + const { scene, rendering, motion, state } = ringRendering(t, { legacyQuery: true, }); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); assert.equal(motion.listeners(), 1); motion.set(true); - assert.equal(scene.primitives.length, 0); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.equal(pulsePrimitives(scene).count, 0); + assert.equal(scene.preRender.numberOfListeners, 0); rendering.clearVisual(); assert.equal(motion.listeners(), 0); }); test('a refresh after reduced motion changed rebuilds the visual too', (t) => { - const { entities, rendering, motion } = ringRendering(t); + const { scene, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values[0].polyline.material.color.isConstant, false); + assert.equal(pulsePrimitives(scene).count, 1); // No change event: the next same-subject refresh notices on its own. motion.setQuietly(true); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.ok(pulsePrimitives(scene).ring); + assert.equal(pulsePrimitives(scene).count, 0); + assert.equal(scene.preRender.numberOfListeners, 0); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { const ring = ringRendering(t); - const { entities, rendering } = ring; + const { scene, rendering } = ring; const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const ringEntity = entities.values[0]; const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.equal(ringEntity.polyline.material.color.isConstant, false); + const halo = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.outlineColor, + ); const { periodMs, count } = AWARENESS_RANGE_PULSE; const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE; // Full the moment a front reaches 250 km, gone `flashMs` later. @@ -764,22 +780,23 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => loop.advance(AWARENESS_RANGE_PULSE.settleMs); for (let step = 0; step < 40; step++) { loop.advance(100); - const alpha = ringEntity.polyline.material.color.getValue( - Cesium.JulianDate.now(), - ).alpha; - const expected = - core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now())); - assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`); + const { color, outlineColor } = ringColors(scene); + const flash = Math.max(restOpacity, awarenessRingFlash(loop.now())); + const expected = core.alpha * flash; + assert.ok( + Math.abs(color.alpha - expected) < 1e-9, + `${color.alpha} vs ${expected}`, + ); + assert.ok(Math.abs(outlineColor.alpha - halo.alpha * flash) < 1e-9); } }); test('without pulses the ring stays on screen at full strength', (t) => { - const { entities, rendering } = ringRendering(t, { reducedMotion: true }); + const { scene, rendering } = ringRendering(t, { reducedMotion: true }); rendering.renderVisual(flight(3)); - const color = entities.values[0].polyline.material.color; - assert.equal(color.isConstant, true); + assert.equal(scene.preRender.numberOfListeners, 0); assert.equal( - color.getValue(Cesium.JulianDate.now()).toCssColorString(), + ringColors(scene).color.toCssColorString(), Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ).toCssColorString(), diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 3e929752b..5a5b5689b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -267,6 +267,47 @@ function createPulseGradient(groundCenter, material) { }); } +/** + * The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen + * pixels, straight chords between the given positions (they already follow + * the surface), in the material appearance's vertex format. + * @param {Cesium.Cartesian3[]} positions Ring positions. + * @returns {object} PolylineGeometry options. + */ +export function awarenessRingGeometryOptions(positions) { + return { + positions, + width: AWARENESS_RANGE_RING_STYLE.width, + arcType: Cesium.ArcType.NONE, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + }; +} + +/** + * The 250 km ring as an unpickable polyline primitive in the ring's outline + * material, so a click or tap on its edge reaches the contact underneath. + * Its positions are geometry, so a moved subject rebuilds it; the material + * is shared between builds so a flash keeps going. + * @param {Cesium.Cartesian3[]} positions Ring positions. + * @param {Cesium.Material} material Shared PolylineOutline material. + * @returns {Cesium.Primitive} + */ +function createRingPrimitive(positions, material) { + return new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry( + awarenessRingGeometryOptions(positions), + ), + }), + appearance: new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + asynchronous: false, + allowPicking: false, + }); +} + /** Camera height above the ellipsoid, or NaN when the scene cannot say. */ function cameraHeightM(scene) { return scene.camera?.positionCartographic?.height ?? Number.NaN; @@ -570,6 +611,12 @@ export function createRendering({ layerState.visual?.removeMotionListener?.(); if (layerState.visual?.gradient && layerState.viewer) layerState.viewer.scene.primitives.remove(layerState.visual.gradient); + if (layerState.visual?.ringPrimitive && layerState.viewer) { + layerState.viewer.scene.primitives.remove?.( + layerState.visual.ringPrimitive, + ); + governorRequestRender('awareness-visual'); + } clearAwarenessEchoField(); if (layerState.visual?.entities && layerState.viewer) { for (const entity of layerState.visual.entities) @@ -610,7 +657,15 @@ export function createRendering({ // The echo field reads `visual.center` in place and follows on its // own. The pulse disc is geometry, so it is rebuilt, but only every // `gradientRebuildM`: a build costs a few milliseconds. - layerState.visual.ring.polyline.positions.setValue(positions); + layerState.visual.ringPositions = positions; + const { ringPrimitive } = layerState.visual; + if (ringPrimitive) { + const primitives = layerState.viewer.scene.primitives; + layerState.visual.ringPrimitive = primitives.add( + createRingPrimitive(positions, ringPrimitive.appearance.material), + ); + primitives.remove(ringPrimitive); + } else layerState.visual.ring.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); // A resting disc is hidden, so it catches up when the pulses wake. if (performance.now() < layerState.visual.wake?.untilMs) @@ -647,36 +702,48 @@ export function createRendering({ const ring = AWARENESS_RANGE_RING_STYLE; const core = Cesium.Color.fromCssColorString(ring.color); const halo = Cesium.Color.fromCssColorString(ring.outlineColor); + const ringPositions = awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); // While pulses run, the ring flashes as each one arrives and otherwise // dims to `restOpacity`; as they settle it returns to full and stays. - const flashing = (base) => { - const color = base.clone(); - return new Cesium.CallbackProperty(() => { - const nowMs = performance.now(); - const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); - color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed)); - return color; - }, false); + const ringAlpha = (nowMs) => { + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + return 1 - levelAt(nowMs) * (1 - pulsed); }; - const ringEntity = layerState.viewer.entities.add({ - polyline: { - positions: new Cesium.ConstantProperty( - awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ), - ), - width: ring.width, - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineOutlineMaterialProperty({ - color: animate ? flashing(core) : core, - outlineColor: animate ? flashing(halo) : halo, - outlineWidth: ring.outlineWidth, - }), - }, - }); - entities.push(ringEntity); + let ringEntity = null; + let ringMaterial = null; + let ringPrimitive = null; + if (scene?.primitives?.add) { + ringMaterial = Cesium.Material.fromType('PolylineOutline', { + color: core.clone(), + outlineColor: halo.clone(), + outlineWidth: ring.outlineWidth, + }); + ringPrimitive = + scene.primitives.add( + createRingPrimitive(ringPositions, ringMaterial), + ) ?? null; + } + if (!ringPrimitive) { + // Headless tools and partial stubs have no primitive collection; they + // get the static ring as an entity. + ringEntity = layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.ConstantProperty(ringPositions), + width: ring.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: core, + outlineColor: halo, + outlineWidth: ring.outlineWidth, + }), + }, + }); + entities.push(ringEntity); + } const center = Cesium.Cartesian3.clone(groundCenter); let gradient = null; @@ -707,6 +774,9 @@ export function createRendering({ const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); const level = levelAt(nowMs); + const alpha = ringAlpha(nowMs); + ringMaterial.uniforms.color.alpha = core.alpha * alpha; + ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha; // The disc is looked up each frame: a subject move swaps it. From a // camera below the rim height it would sweep across the sky, so it // is hidden there. @@ -767,6 +837,8 @@ export function createRendering({ key, entities, ring: ringEntity, + ringPrimitive, + ringPositions, center, gradient, gradientCenter: Cesium.Cartesian3.clone(groundCenter), From 3c6c342b2b1c66b44ba5b56fa81321e3740afd8a Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 21:22:36 +0530 Subject: [PATCH 15/29] perf(awareness): skip echo work for the whole frame while pulses rest The detection overlay asked the echo field for every visible flight, military aircraft and vessel on every frame while a subject was selected, projecting each to the ground before learning the pulses were resting. It now checks awarenessEchoActive once per frame and builds no echo paths at rest, and awarenessEchoAt checks the level before the projection. A detectionHost test with six echo-eligible flights checks that a resting frame asks for the level fewer times than there are contacts and paints no echo; it fails on the per-contact path. The pulse and echo policy doc comments are attached to their constants again, and one long comment line is re-wrapped. --- src/awarenessEcho.js | 15 +++++++-- src/awarenessEcho.test.mjs | 6 ++++ src/data/detection.js | 4 ++- src/data/detectionHost.test.mjs | 54 +++++++++++++++++++++++++++++++++ src/layers/awareness/policy.js | 12 ++++---- 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index ecf0f21a1..f4c106ead 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -45,6 +45,17 @@ export function getAwarenessEchoField() { return field; } +/** + * Whether any echo can light at `nowMs`: a field is set and its pulses are + * running. The overlay checks this once per frame, so resting pulses cost no + * per-contact work. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {boolean} + */ +export function awarenessEchoActive(nowMs) { + return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0; +} + /** * How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches * its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The @@ -99,13 +110,13 @@ export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { */ export function awarenessEchoAt(layerId, position, nowMs) { if (!field || !position || !layerIds.has(layerId)) return 0; + const level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( position, groundScratch, ); if (!ground) return 0; - const level = field.level?.(nowMs) ?? 1; - if (!(level > 0)) return 0; return ( level * awarenessEchoStrength( diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index 846c630c4..b68e2eb96 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { + awarenessEchoActive, awarenessEchoAt, awarenessEchoStrength, clearAwarenessEchoField, @@ -114,8 +115,13 @@ test('echoes scale with the field level and rest with the pulses', (t) => { assert.ok( Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9, ); + assert.equal(awarenessEchoActive(t0), true); level = 0; assert.equal(awarenessEchoAt('flights', contact, t0), 0); + // At rest the overlay skips echoes for the whole frame. + assert.equal(awarenessEchoActive(t0), false); + clearAwarenessEchoField(); + assert.equal(awarenessEchoActive(t0), false); }); test('near the subject an echo is no brighter than the fading-in front', () => { diff --git a/src/data/detection.js b/src/data/detection.js index de896e046..e3cb41bfe 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -54,6 +54,7 @@ import { isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; import { + awarenessEchoActive, awarenessEchoBracket, awarenessEchoColor, getAwarenessEchoField, @@ -1344,7 +1345,8 @@ function _drawOverlay(frame) { const bracketPaths = new Map(); // Global Context sonar echoes: a second bracket, banded by strength, around // each flight or vessel a pulse front has just crossed. - const echoField = getAwarenessEchoField(); + // Resting pulses light nothing, so the per-contact echo work is skipped. + const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null; const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null; const pathFor = (map, color, alpha) => { const band = Math.max( diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 2e3829c94..47e35c48e 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -673,6 +673,60 @@ test('Global Context echo brackets reach the canvas in the detection style colou `${style} echoes take the style line colour, got ${strokes}`, ); } + + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + +test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => { + const env = installEnvironment(); + // Six echo-eligible flights on screen; the identity camera maps x/y + // straight to device coordinates. + const flights = Array.from({ length: 6 }, (_, index) => ({ + position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752), + sourceId: `flight-${index}`, + id: `ECHO${index}`, + metric: `FL${300 + index}`, + type: 'AIR', + })); + const layer = { id: 'flights', getDetectableObjects: () => flights }; + let levelCalls = 0; + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo: { + layerIds: ['flights'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }, + level: () => { + levelCalls++; + return 0; + }, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [layer], () => {}); + setMode('DENSE'); + settleFrame(env); + levelCalls = 0; + settleFrame(env); + const painted = getDetectionDiagnostics()?.visibleCount ?? flights.length; + assert.ok(painted >= 2, `contacts on screen: ${painted}`); + // One check per painted frame, however many contacts are on screen. + assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live'); + assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`); + assert.ok( + !env.detectionCtx.calls.some( + ([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)', + ), + 'no echoes while the pulses rest', + ); } finally { clearAwarenessEchoField(); env.cleanup(); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 11fb866ae..64e719160 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -76,15 +76,17 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse * is dim but still visible when it reaches the ring and the ring flashes. * Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark - * halo, in the ring's colours, as Cesium's polyline outline material draws. Behind each line a radial gradient in the same blue - * fades inward over `gradientDepthFraction` of the radius, at most + * halo, in the ring's colours, as Cesium's polyline outline material draws. + * Behind each line a radial gradient in the same blue fades inward over + * `gradientDepthFraction` of the radius, at most * `gradientOpacity` strong. One unpickable disc draws the lines and the * gradient, so pulses never catch a click meant for a contact; its shader * takes at most four pulses. The disc is rebuilt only after the subject * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's * centre, about a pixel when the ring fills the screen; while the pulses - * rest the hidden disc is left alone and catches up when they wake. The disc is hidden - * while the camera is below its height, where it would sweep the sky. + * rest the hidden disc is left alone and catches up when they wake. The + * disc is hidden while the camera is below its height, where it would sweep + * the sky. * The pulses run for `activeMs` (three ring flashes) after a selection or * a camera move, fading in and out over `settleMs`, then rest: the ring * stays on screen, static, and nothing asks for frames, so a parked view @@ -95,7 +97,6 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * Cyber sonar's 80 ms cadence, so the two never beat. When something else * already renders continuously they update every frame. Skipped under * prefers-reduced-motion. */ - export const AWARENESS_RANGE_PULSE = Object.freeze({ count: 3, periodMs: 9000, @@ -120,7 +121,6 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ * bracket. It starts on the bracket and spreads outward by up to `spreadPx` * as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo; * mapped installations have no detection bracket. */ - export const AWARENESS_RANGE_ECHO = Object.freeze({ layerIds: ['flights', 'military', 'ais-live-vessels'], fadeMs: 1400, From a851199f5b6aa1b14e387056b1a7879aa02cc455 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 22:50:32 +0530 Subject: [PATCH 16/29] fix(awareness): rest the range pulses unless the user moves the camera Review follow-ups for the sonar pulses. Wake on user input only: a camera move wakes the pulses only within 1.5 s of a pointer, wheel or touch on the Cesium canvas, where its camera controller listens. Tracking, the cockpit view, an orbit or a fly-to move the camera on every frame and no longer keep the pulses running. Keys don't count: the camera controller reads none. Below the disc: with the camera under 2.5 km the pulses rest completely, with no disc, ring flash, echoes or frame requests. Echo brightness: echoes scale by the contact bracket's drawn alpha, which includes the Cyber sonar sweep, rounded to the nearest band with no floor, so an echo is never brighter than its bracket. Disc follow: the pulse disc follows the subject by a model-matrix rotation about the Earth's centre and is rebuilt only every 20 km instead of every 500 m. Tests: a cap on burst length; teardown through disable(); the echo's spread and alpha on the canvas; echoes under the Cyber sonar never brighter than their bracket; waking by pointer, wheel, touch and drag but not by keys, app camera moves or stale input; the rest below the disc; and tighter asserts. CURRENT-STATE notes that terrain above 2.5 km still cuts through the disc and ring. --- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 19 +- src/data/detection.js | 2 +- src/data/detectionAwarenessEcho.test.mjs | 19 +- src/data/detectionHost.test.mjs | 134 +++++++++++++- src/layers/awareness/lifecycle.js | 41 ++++- src/layers/awareness/policy.js | 19 +- src/layers/awareness/rangeRing.test.mjs | 220 ++++++++++++++++++++--- src/layers/awareness/rendering.js | 70 ++++++-- src/layers/awareness/state.js | 4 + 10 files changed, 457 insertions(+), 73 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be0d26204..fd4789f60 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index f4c106ead..44a5e37fa 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -131,14 +131,16 @@ export function awarenessEchoAt(layerId, position, nowMs) { /** * The echo bracket the detection overlay draws around one contact, or null - * when it has no echo. `band` is the alpha band, 1 to `steps`, scaled by the - * contact's own keyhole `admissionAlpha`; `spreadPx` is how far the echo - * sits outside the contact's bracket, 0 as the front arrives, growing to the + * when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's + * strength times the contact bracket's own `bracketAlpha` rounded to the + * nearest band, so an echo is never drawn brighter than its bracket; one + * that rounds to nothing is not drawn. `spreadPx` is how far the echo sits + * outside the contact's bracket, 0 as the front arrives, growing to the * field's `spreadPx` as it fades. * @param {string} layerId Owning layer of the contact. * @param {Cesium.Cartesian3} position Contact's world position. * @param {number} nowMs Monotonic clock in milliseconds. - * @param {number} admissionAlpha The contact bracket's own fade, 0 to 1. + * @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1. * @param {number} steps Alpha bands the overlay batches by. * @returns {{band: number, spreadPx: number}|null} */ @@ -146,15 +148,14 @@ export function awarenessEchoBracket( layerId, position, nowMs, - admissionAlpha, + bracketAlpha, steps, ) { const echo = awarenessEchoAt(layerId, position, nowMs); if (!(echo > 0.01)) return null; - return { - band: Math.max(1, Math.ceil(echo * admissionAlpha * steps)), - spreadPx: field.echo.spreadPx * (1 - echo), - }; + const band = Math.min(steps, Math.round(echo * bracketAlpha * steps)); + if (!(band >= 1)) return null; + return { band, spreadPx: field.echo.spreadPx * (1 - echo) }; } /** diff --git a/src/data/detection.js b/src/data/detection.js index e3cb41bfe..36ed6372e 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1456,7 +1456,7 @@ function _drawOverlay(frame) { obj._layerId, obj.position, now, - admissionAlpha, + bracketAlpha, ECHO_ALPHA_STEPS, ); if (echo) diff --git a/src/data/detectionAwarenessEcho.test.mjs b/src/data/detectionAwarenessEcho.test.mjs index a9c4fecc3..852ea7f2b 100644 --- a/src/data/detectionAwarenessEcho.test.mjs +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -39,8 +39,8 @@ test('the echo bracket pulses outward from the contact bracket and fades', (t) = setAwarenessEchoField(field); const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000); const arrival = arrivalAt(position); - const at = (nowMs, admissionAlpha = 1) => - awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS); + const at = (nowMs, bracketAlpha = 1) => + awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS); // The moment the front arrives: on the bracket, strongest band. const fresh = at(arrival); @@ -53,8 +53,19 @@ test('the echo bracket pulses outward from the contact bracket and fades', (t) = assert.ok(half.spreadPx > echo.spreadPx * 0.5); assert.ok(half.spreadPx < echo.spreadPx); assert.ok(half.band < STEPS && half.band >= 1); - // Keyhole fade carries over to the echo. - assert.ok(at(arrival, 0.25).band <= 2); + // The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries + // over, and the echo is never drawn brighter than its bracket: a bracket + // too dim for even one band draws no echo. + assert.equal(at(arrival, 0.25).band, 2); + assert.equal(at(arrival, 0.05), null); + for (let alpha = 0.05; alpha <= 1; alpha += 0.05) { + const echoed = at(arrival + echo.fadeMs / 3, alpha); + if (echoed) + assert.ok( + echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9, + `band ${echoed.band} for a bracket at ${alpha}`, + ); + } // Faded out, or between fronts: no echo bracket. assert.equal(at(arrival + echo.fadeMs + 5), null); }); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 47e35c48e..f8858d602 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -54,8 +54,9 @@ class MockEvent { } class MockPath2D { - moveTo() {} - lineTo() {} + constructor() { this.points = []; } + moveTo(x, y) { this.points.push([x, y]); } + lineTo(x, y) { this.points.push([x, y]); } roundRect() {} arcTo() {} closePath() {} @@ -102,7 +103,7 @@ function mockContext(target, trace) { // set at some point during the frame. globalAlpha rides along for the same // reason: the backdrop feather is an alpha, not a colour. fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); }, - stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); }, + stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); }, fillRect(...args) { record('fillRect', ...args); }, fillText(...args) { record('fillText', ...args); }, drawImage(...args) { record('drawImage', ...args); }, @@ -680,6 +681,129 @@ test('Global Context echo brackets reach the canvas in the detection style colou } }); +test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => { + const env = installEnvironment(); + const echo = { + layerIds: ['military'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }; + // Fronts every millisecond keep the echo at full strength; a level of 0.5 + // halves it, so it sits 4 px out and draws at half the bracket's alpha. + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo, + level: () => 0.5, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [militaryLayer()], () => {}); + setMode('DENSE'); + setDetectionStyle('normal'); + settleFrame(env); + env.detectionCtx.calls.length = 0; + settleFrame(env); + const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke'); + const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth); + assert.ok(echoStroke, 'the echo bracket is painted'); + const bracketStroke = strokes.find(([, path, style, width]) => + path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color); + assert.ok(bracketStroke, 'the contact bracket is painted'); + const extent = (path) => { + const xs = path.points.map(([x]) => x); + const ys = path.points.map(([, y]) => y); + return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 }; + }; + const echoBox = extent(echoStroke[1]); + const bracketBox = extent(bracketStroke[1]); + assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3, + `echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`); + assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3); + const echoAlpha = echoStroke[4]; + const bracketAlpha = bracketStroke[4]; + assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`); + assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`); + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + +test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => { + const env = installEnvironment(); + // The cyber theme with its HUD open turns the contact sonar on; its sweep + // dims brackets it is not passing over. + env.document.documentElement = { + // The lowest sweep floor, so dimmed brackets drop a whole alpha band. + dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' }, + style: { setProperty() {}, removeProperty() {} }, + }; + const hud = env.document.createElement('div'); + hud.id = 'intel-hud'; + hud.classList = { contains: (name) => name === 'active' }; + env.document.body.appendChild(hud); + const echo = { + layerIds: ['military'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }; + const contact = { + id: 'military', + getDetectableObjects: () => [{ + position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752), + sourceId: 'sonar-pin', + id: 'SONAR', + metric: 'FL300', + type: 'AIR', + tier: 'military', + }], + }; + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo, + level: () => 1, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contact], () => {}); + setMode('DENSE'); + setDetectionStyle('normal'); + settleFrame(env); + let dimmed = 0; + let compared = 0; + for (let frame = 0; frame < 40; frame++) { + env.detectionCtx.calls.length = 0; + settleFrame(env); + const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke'); + const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth); + const bracketStroke = strokes.find(([, path, style, width]) => + path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color); + if (!bracketStroke) continue; + const bracketAlpha = bracketStroke[4]; + if (bracketAlpha < 0.99) dimmed++; + if (!echoStroke) continue; + compared++; + assert.ok( + echoStroke[4] <= bracketAlpha + 1e-9, + `echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`, + ); + } + assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame'); + assert.ok(compared > 0, 'an echo was painted'); + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => { const env = installEnvironment(); // Six echo-eligible flights on screen; the identity camera maps x/y @@ -716,8 +840,8 @@ test('resting Global Context pulses skip echo work for the whole frame, not per settleFrame(env); levelCalls = 0; settleFrame(env); - const painted = getDetectionDiagnostics()?.visibleCount ?? flights.length; - assert.ok(painted >= 2, `contacts on screen: ${painted}`); + const painted = getDetectionDiagnostics().visibleCount; + assert.equal(painted, flights.length, 'every flight is on screen'); // One check per painted frame, however many contacts are on screen. assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live'); assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`); diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 27a7d0e55..272b85edc 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -1,4 +1,5 @@ import { cameraPoseSignature } from '../../data/iconOrientation.js'; +import { AWARENESS_RANGE_PULSE } from './policy.js'; export function createLifecycle({ state: layerState, @@ -8,6 +9,26 @@ export function createLifecycle({ }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + /** + * Note the user handling the map's camera (pointer, wheel or touch on the + * Cesium canvas, where its camera controller listens), so a camera move the + * user made can wake the range pulses and one the app made (tracking, + * cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera + * controller reads none, and typing elsewhere must not count. + */ + function noteCameraInput(event) { + if (event?.type === 'pointermove' && !event.buttons) return; + layerState.lastCameraInputMs = Date.now(); + } + + const CAMERA_INPUT_EVENTS = [ + 'pointerdown', + 'pointermove', + 'pointerup', + 'wheel', + 'touchstart', + ]; + /** * Quantized camera-pose signature, or '' when the camera cannot report a full * pose yet. An unknown pose reads as PARKED, so a camera that is still coming @@ -50,6 +71,10 @@ export function createLifecycle({ 'gev:awareness-subject-cleared', layerState.subjectClearListener, ); + const input = layerState.viewer.canvas ?? layerState.viewer.scene?.canvas; + for (const type of CAMERA_INPUT_EVENTS) + input?.addEventListener?.(type, noteCameraInput, { passive: true }); + layerState.cameraInputTarget = input ?? null; layerState.preRenderRemover = layerState.viewer.scene.preRender.addEventListener(() => { if (!layerState.enabled) return; @@ -62,8 +87,14 @@ export function createLifecycle({ if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; - // A camera move runs the range pulses again; parked, they rest. - parts.rendering.wakePulses(); + // A camera move the user made runs the range pulses again; parked, + // or moved by the app (tracking, cockpit, orbit, a fly-to), they + // rest. + if ( + now - layerState.lastCameraInputMs < + AWARENESS_RANGE_PULSE.inputWakeMs + ) + parts.rendering.wakePulses(); } const decision = parts.model.awarenessRefreshDecision({ nowMs: now, @@ -110,6 +141,12 @@ export function createLifecycle({ 'gev:awareness-subject-cleared', layerState.subjectClearListener, ); + for (const type of CAMERA_INPUT_EVENTS) + layerState.cameraInputTarget?.removeEventListener?.( + type, + noteCameraInput, + ); + layerState.cameraInputTarget = null; } layerState.preRenderRemover?.(); layerState.preRenderRemover = null; diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 64e719160..1c1320768 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -81,18 +81,20 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `gradientDepthFraction` of the radius, at most * `gradientOpacity` strong. One unpickable disc draws the lines and the * gradient, so pulses never catch a click meant for a contact; its shader - * takes at most four pulses. The disc is rebuilt only after the subject - * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's - * centre, about a pixel when the ring fills the screen; while the pulses - * rest the hidden disc is left alone and catches up when they wake. The + * takes at most four pulses. The disc follows the subject by a model-matrix + * rotation, so it stays concentric with the ring, and is rebuilt only after + * the subject moves `gradientRebuildM` from where it was built; while the + * pulses rest the hidden disc is left alone and catches up when they wake. The * disc is hidden while the camera is below its height, where it would sweep * the sky. * The pulses run for `activeMs` (three ring flashes) after a selection or * a camera move, fading in and out over `settleMs`, then rest: the ring * stays on screen, static, and nothing asks for frames, so a parked view - * goes quiet again. A moving subject does not wake them, since a flying - * subject moves on every refresh, but a camera that follows it does; the - * follow already renders every frame. While they run they never hold + * goes quiet again. Only a camera move within `inputWakeMs` of the user + * handling the map (pointer, wheel or touch on the canvas, which covers a + * drag's inertia) wakes them; a moving subject and the app's own camera moves + * (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they + * rest too. While they run they never hold * continuous rendering; each drawn frame arms one more request after the * Cyber sonar's 80 ms cadence, so the two never beat. When something else * already renders continuously they update every frame. Skipped under @@ -110,9 +112,10 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ gradientDepthFraction: 0.35, gradientOpacity: 0.22, gradientGranularityDeg: 0.25, - gradientRebuildM: 500, + gradientRebuildM: 20000, activeMs: 9000, settleMs: 1200, + inputWakeMs: 1500, }); /** @constant Sonar echoes on contact brackets. When a pulse front reaches a diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index f005d3b21..17caa3bbc 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -133,10 +133,15 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { * postRender) on the next millisecond, as Cesium's request-render mode does. * `draw()` draws a frame someone else asked for. */ -function frameLoop(t, ring) { +function frameLoop(t, ring, { date = false } = {}) { let clock = 1_000_000; t.mock.method(performance, 'now', () => clock); - t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + t.mock.timers.enable({ + apis: date + ? ['setTimeout', 'setInterval', 'Date'] + : ['setTimeout', 'setInterval'], + now: clock, + }); let drawn = 0; const draw = () => { drawn = ring.reasons.length; @@ -168,6 +173,15 @@ function pulsePrimitives(scene) { return { ring, disc: discs[0], count: discs.length }; } +/** Where a disc built around `built` sits after its model matrix. */ +function discCenter(disc, built) { + return Cesium.Matrix4.multiplyByPoint( + disc.modelMatrix, + built, + new Cesium.Cartesian3(), + ); +} + /** The ring's material uniforms as the next drawn frame sets them. */ function ringColors(scene) { scene.preRender.raiseEvent(scene); @@ -239,13 +253,15 @@ test('the ring core and halo contrast on light and dark ground', () => { }); test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { - const { entities, scene, state, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { entities, scene, state, rendering } = ring; + frameLoop(t, ring); // freeze the clock: the pulses have not faded in yet rendering.renderVisual(flight(3)); // A polyline primitive, never an entity or an ellipse outline. assert.equal(entities.values.length, 0); - const { ring } = pulsePrimitives(scene); - assert.ok(ring); - const geometry = ring.geometryInstances.geometry; + const ringPrimitive = pulsePrimitives(scene).ring; + assert.ok(ringPrimitive); + const geometry = ringPrimitive.geometryInstances.geometry; assert.ok(geometry instanceof Cesium.PolylineGeometry); // 4 px wide, straight chords between surface-following positions, as the // entity ring was drawn. @@ -258,16 +274,17 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) options.vertexFormat, Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, ); - const { material } = ring.appearance; + const { material } = ringPrimitive.appearance; assert.equal(material.type, 'PolylineOutline'); - // The ring's hue is fixed; its opacity flashes with arriving pulses. + // The ring's hue is fixed; its opacity flashes with arriving pulses, and + // at the moment it appears, before the pulses fade in, it is at full. const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); const { color, outlineWidth } = ringColors(scene); assert.equal(color.red, core.red); assert.equal(color.blue, core.blue); - assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9); + assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`); assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth); const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); for (const distance of surfaceDistances(ground, state.visual.ringPositions)) @@ -305,9 +322,15 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift )) assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); assert.equal(renders(), rendersAfterAdd + 1); - // The pulses are drawn on a disc, rebuilt around the NEW centre a - // kilometre on, so they stay concentric with the ring. - assert.notEqual(pulsePrimitives(scene).disc, discBefore); + // The pulses are drawn on a disc that follows to the NEW centre by its + // model matrix, so they stay concentric with the ring without a rebuild. + assert.equal(pulsePrimitives(scene).disc, discBefore); + assert.ok( + Cesium.Cartesian3.distance( + discCenter(discBefore, Cesium.Cartesian3.fromDegrees(3, 27, 0)), + Cesium.Cartesian3.fromDegrees(3.01, 27, 0), + ) < 1, + ); rendering.clearVisual(); assert.equal(scene.primitives.length, 0); @@ -393,12 +416,18 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { const { scene, rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; - // Long enough for three ring flashes, then quiet. + // Long enough for three ring flashes, then quiet, and short: a burst is + // seconds, not minutes. assert.ok(activeMs >= 3 * (periodMs / count)); + assert.ok(activeMs <= 15_000, `${activeMs} ms burst`); rendering.renderVisual(flight(3)); loop.advance(activeMs + 500); const frames = pulseFrames(); assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2); + assert.ok( + frames <= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS + 2, + `${frames} frames in one burst`, + ); // Parked: a minute later not one more request. loop.advance(60_000); assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); @@ -440,9 +469,12 @@ test('a camera move wakes resting pulses for another bounded run', (t) => { test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => { const ring = ringRendering(t); const { state, rendering, pulseFrames } = ring; - const loop = frameLoop(t, ring); - const { activeMs } = AWARENESS_RANGE_PULSE; + const loop = frameLoop(t, ring, { date: true }); + const { activeMs, inputWakeMs } = AWARENESS_RANGE_PULSE; state.enabled = true; + state.viewer.canvas = new EventTarget(); + const touch = () => + state.viewer.canvas.dispatchEvent(new Event('pointerdown')); state.viewer.camera = { positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), heading: 0, @@ -471,7 +503,9 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera loop.draw(); loop.advance(activeMs); assert.equal(pulseFrames(), rested); - // The camera pans 50 km: the next frame wakes them for another run. + // The user drags the camera 50 km: the next frame wakes them for another + // run. + touch(); state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( 3.5, 20, @@ -485,6 +519,115 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera const woke = pulseFrames(); loop.advance(60_000); assert.equal(pulseFrames(), woke, 'and rest again once it parks'); + // The app moving the camera, with no hand on it, does not wake them: a + // tracking follow, the cockpit view, an orbit or a fly-to all move it on + // every frame. + const follow = (lon) => { + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + lon, + 20, + 800_000, + ); + loop.draw(); + loop.advance(500); + }; + for (let lon = 3.6; lon < 5.6; lon += 0.1) follow(lon); + assert.equal(pulseFrames(), woke, 'app camera moves do not wake them'); + // Input long past (beyond `inputWakeMs`) does not count either; a hover + // without a button pressed is not handling the camera. + touch(); + loop.advance(inputWakeMs + 100); + state.viewer.canvas.dispatchEvent( + Object.assign(new Event('pointermove'), { buttons: 0 }), + ); + follow(5.7); + assert.equal(pulseFrames(), woke, 'stale input does not wake them'); + // Each way of handling the map counts: a wheel, a touch, a drag; keys + // typed anywhere in the window do not. + const panWith = (handle, lon) => { + loop.advance(activeMs + 500); + const before = pulseFrames(); + handle(); + follow(lon); + loop.advance(activeMs); + return pulseFrames() > before; + }; + const onCanvas = (event) => () => state.viewer.canvas.dispatchEvent(event); + assert.equal(panWith(onCanvas(new Event('wheel')), 5.8), true, 'wheel'); + assert.equal(panWith(onCanvas(new Event('touchstart')), 5.9), true, 'touch'); + assert.equal( + panWith( + onCanvas(Object.assign(new Event('pointermove'), { buttons: 1 })), + 6.0, + ), + true, + 'drag', + ); + assert.equal( + panWith(() => window.dispatchEvent(new Event('keydown')), 6.1), + false, + 'keys do not count', + ); + // Detached, the canvas listeners are gone. + lifecycle.detachRuntimeListeners(); + touch(); + assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true); +}); + +test('disabling the layer tears the pulses down completely', (t) => { + const ring = ringRendering(t); + const { state, scene, rendering, motion, pulseFrames } = ring; + const loop = frameLoop(t, ring); + state.viewer.camera = { + positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), + heading: 0, + pitch: -1, + roll: 0, + }; + const lifecycle = createLifecycle({ + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts: { + rendering, + model: { + awarenessRefreshDecision: () => ({ moving: false, refresh: false }), + awarenessNeedsContinuousRender: () => false, + }, + panel: { + startAwarenessPageRotation() {}, + stopAwarenessPageRotation() {}, + ensurePanel() {}, + hidePanel() {}, + }, + dependencies: { + activateOperationalContext: () => true, + releaseOwnedDependencies: () => true, + }, + }, + }); + state.passive = true; + lifecycle.methods.enable(); + state.subject = flight(3); + rendering.renderVisual(state.subject); + loop.advance(1000); + assert.ok(pulseFrames() > 0); + assert.equal(scene.primitives.length, 2); + lifecycle.methods.disable(); + assert.equal(scene.primitives.length, 0, 'ring and disc removed'); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(motion.listeners(), 0, 'no media-query listener'); + assert.equal(getAwarenessEchoField(), null); + assert.equal(state.visual, null); + const frames = pulseFrames(); + loop.advance(60_000); + assert.equal( + pulseFrames(), + frames, + 'no awareness-pulse requests after disable', + ); }); test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => { @@ -602,14 +745,23 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( 0, ); - // The disc is rebuilt only past `gradientRebuildM`, not on every 250 m - // step the ring takes, and keeps its material. - rendering.renderVisual(flight(3.001)); - assert.equal(pulsePrimitives(scene).disc, disc); - rendering.renderVisual(flight(3.004)); // ~400 m: the ring moves, the disc stays - assert.equal(pulsePrimitives(scene).disc, disc); - assert.ok(!disc.isDestroyed()); - rendering.renderVisual(flight(3.01)); // ~1 km from the disc's centre + // The disc follows by its model matrix and is rebuilt only past + // `gradientRebuildM` (20 km), keeping its material. + const built = Cesium.Cartesian3.fromDegrees(3, 27, 0); + for (const lon of [3.001, 3.01, 3.1, 3.19]) { + rendering.renderVisual(flight(lon)); + assert.equal(pulsePrimitives(scene).disc, disc, `${lon}`); + assert.ok(!disc.isDestroyed()); + // Concentric with the ring wherever the subject went, within a metre + // across the ground; the ellipsoid curves away from the rotation's + // sphere by tens of metres in height at most. + const at = Cesium.Cartographic.fromCartesian(discCenter(disc, built)); + const want = Cesium.Cartographic.fromCartesian(ring.state.visual.center); + const across = new Cesium.EllipsoidGeodesic(at, want).surfaceDistance; + assert.ok(across < 1, `${across} m off at ${lon}`); + assert.ok(Math.abs(at.height) < 50, `${at.height} m off the ground`); + } + rendering.renderVisual(flight(3.25)); // ~25 km from where it was built assert.equal(pulsePrimitives(scene).count, 1); const moved = pulsePrimitives(scene).disc; assert.notEqual(moved, disc); @@ -629,12 +781,15 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o rendering.renderVisual(flight(3)); loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); const resting = pulsePrimitives(scene).disc; - // A flying subject keeps moving while the pulses rest: no rebuilds. - for (let lon = 3.01; lon < 3.1; lon += 0.01) + const restingMatrix = Cesium.Matrix4.clone(resting.modelMatrix); + // A flying subject keeps moving while the pulses rest: the hidden disc is + // neither moved nor rebuilt, even well past the rebuild distance. + for (let lon = 3.01; lon < 3.4; lon += 0.01) rendering.renderVisual(flight(lon)); assert.equal(pulsePrimitives(scene).disc, resting); assert.ok(!resting.isDestroyed()); - // Woken, the disc moves to the subject at once. + assert.ok(Cesium.Matrix4.equals(resting.modelMatrix, restingMatrix)); + // Woken, the disc catches up at once: rebuilt around the subject. rendering.wakePulses(); const woken = pulsePrimitives(scene).disc; assert.notEqual(woken, resting); @@ -673,6 +828,17 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the }; assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true); assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view'); + // Below the disc the pulses rest with it: the ring is steady at full, no + // echo lights and no frame is asked for. + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9); + assert.equal(getAwarenessEchoField().level(loop.now()), 0); + const frames = ring.pulseFrames(); + loop.draw(); + loop.advance(2000); + assert.equal(ring.pulseFrames(), frames, 'no requests below the rim'); assert.equal(cameraAt(800_000), true); }); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 5a5b5689b..2611dcbfb 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -308,6 +308,34 @@ function createRingPrimitive(positions, material) { }); } +/** + * The rotation about the Earth's centre that carries `from` onto `to`, as a + * model matrix, so a disc built around `from` sits around `to`. + * @param {Cesium.Cartesian3} from Where the disc was built. + * @param {Cesium.Cartesian3} to Where it should be. + * @param {Cesium.Matrix4} [result] + * @returns {Cesium.Matrix4} + */ +export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) { + const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()); + const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3()); + const sin = Cesium.Cartesian3.magnitude(axis); + if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result); + const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b)); + const rotation = Cesium.Matrix3.fromQuaternion( + Cesium.Quaternion.fromAxisAngle( + Cesium.Cartesian3.divideByScalar(axis, sin, axis), + angle, + ), + ); + return Cesium.Matrix4.fromRotationTranslation( + rotation, + Cesium.Cartesian3.ZERO, + result, + ); +} + /** Camera height above the ellipsoid, or NaN when the scene cannot say. */ function cameraHeightM(scene) { return scene.camera?.positionCartographic?.height ?? Number.NaN; @@ -584,18 +612,27 @@ export function createRendering({ } /** - * Rebuild the pulse disc around the subject once it is `gradientRebuildM` - * from the disc's centre. A build costs a few milliseconds, so a subject - * that moves less keeps the disc it has. + * Keep the pulse disc centred on the subject. Within `gradientRebuildM` of + * where it was built, the disc is rotated about the Earth's centre onto + * the subject by its model matrix, which keeps it concentric with the ring + * at no build cost; the ellipsoid curves a little differently from that + * sphere, a few tens of metres in height at 20 km. Further away it is + * rebuilt, which costs a few milliseconds. */ function followPulseDisc() { const { gradient, gradientCenter, center } = layerState.visual; + if (!gradient) return; if ( - !gradient || Cesium.Cartesian3.distance(gradientCenter, center) < - AWARENESS_RANGE_PULSE.gradientRebuildM - ) + AWARENESS_RANGE_PULSE.gradientRebuildM + ) { + gradient.modelMatrix = discFollowMatrix( + gradientCenter, + center, + gradient.modelMatrix, + ); return; + } const primitives = layerState.viewer.scene.primitives; layerState.visual.gradient = primitives.add( createPulseGradient(center, gradient.appearance.material), @@ -655,8 +692,8 @@ export function createRendering({ CONTEXT_RIM_HEIGHT_M, ); // The echo field reads `visual.center` in place and follows on its - // own. The pulse disc is geometry, so it is rebuilt, but only every - // `gradientRebuildM`: a build costs a few milliseconds. + // own. The pulse disc follows by its model matrix and is rebuilt only + // every `gradientRebuildM`: a build costs a few milliseconds. layerState.visual.ringPositions = positions; const { ringPrimitive } = layerState.visual; if (ringPrimitive) { @@ -695,7 +732,12 @@ export function createRendering({ performance.now(), ) : null; - const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0); + // From a camera below the rim height the disc would sweep across the sky, + // so it is hidden there, and the pulses rest with it: no flash, no + // echoes, no frames. + const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M; + const levelAt = (nowMs) => + wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0; // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. @@ -777,13 +819,9 @@ export function createRendering({ const alpha = ringAlpha(nowMs); ringMaterial.uniforms.color.alpha = core.alpha * alpha; ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha; - // The disc is looked up each frame: a subject move swaps it. From a - // camera below the rim height it would sweep across the sky, so it - // is hidden there. + // The disc is looked up each frame: a subject move swaps it. const disc = layerState.visual?.gradient; - if (disc) - disc.show = - level > 0 && !(cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M); + if (disc) disc.show = level > 0; for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; @@ -800,7 +838,7 @@ export function createRendering({ const removePostRender = scene.postRender.addEventListener(() => { globalThis.clearTimeout(timer); timer = null; - if (performance.now() < wake.untilMs) + if (performance.now() < wake.untilMs && !belowRim()) timer = globalThis.setTimeout(() => { timer = null; governorRequestRender('awareness-pulse'); diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index 5fd8bb912..4d7d0d6e7 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -59,6 +59,10 @@ export function createState({ services }) { lastCameraPoseSig: '', /** When the pose signature last changed bins (hysteresis anchor). */ lastCameraPoseChangeMs: 0, + /** When the viewer last handled the camera (pointer, wheel, touch, key). */ + lastCameraInputMs: Number.NEGATIVE_INFINITY, + /** Element the camera-input listeners are attached to. */ + cameraInputTarget: null, /** Whether the view currently counts as moving (hysteretic, not per-frame). */ cameraMoving: false, lastEvaluatedPosition: null, From 39a697aa3c90c5f8454434dab969669f3b5cfa23 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:34:10 -0800 Subject: [PATCH 17/29] fix(awareness): keep the range ring a full circle at the poles At or within about 1e-7 degrees of a pole the latitude/longitude destination formula cancels out, and the 128-point ring collapsed to three distinct points joined by chords of about 500 km. Step along the great circle in 3D from the centre's up, north and east unit vectors instead. Results at ordinary latitudes and across the antimeridian are unchanged. --- src/layers/awareness/rangeRing.test.mjs | 35 +++++++++++++++++++++++++ src/layers/awareness/rendering.js | 22 ++++++++++------ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 074d449fe..b17651090 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -81,6 +81,41 @@ for (const [place, lat, lon] of [ }); } +for (const [place, lat, lon] of [ + ['the North Pole', 90, 0], + ['the South Pole', -90, 0], + ['1e-7° from the North Pole', 90 - 1e-7, 45], + ['1e-8° from the South Pole', -90 + 1e-8, -120], +]) { + test(`the ring stays a full circle at ${place}`, () => { + const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0); + const segments = AWARENESS_RANGE_RING_STYLE.segments; + const positions = awarenessRangeRingPositions( + center, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); + assert.equal(positions.length, segments + 1); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(positions[0], positions.at(-1), 0, 1e-6), + 'the ring closes on itself', + ); + const distinct = new Set( + positions + .slice(0, segments) + .map(({ x, y, z }) => [x, y, z].map((v) => v.toFixed(1)).join()), + ); + assert.equal(distinct.size, segments, 'every vertex is its own point'); + // Ordinary chords are 2π × 250 km / 128 ≈ 12.3 km long. + for (let i = 0; i < segments; i++) { + const chord = Cesium.Cartesian3.distance(positions[i], positions[i + 1]); + assert.ok(chord > 11_500 && chord < 13_000, `chord ${i}: ${chord} m`); + } + for (const distance of surfaceDistances(center, positions)) + assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100, `${distance} m`); + }); +} + test('the ring core and halo contrast on light and dark ground', () => { const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8cee87d80..36d920850 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -40,9 +40,17 @@ export function awarenessRangeRingPositions( const e2 = 1 - (b * b) / (a * a); const sinLat = Math.sin(latitude); const cosLat = Math.cos(latitude); + const sinLon = Math.sin(longitude); + const cosLon = Math.cos(longitude); const w = 1 - e2 * sinLat * sinLat; const meridional = (a * (1 - e2)) / (w * Math.sqrt(w)); const primeVertical = a / Math.sqrt(w); + // Step along the great circle in 3D from the centre's up, north and east + // unit vectors. Unlike the latitude/longitude destination formula, this + // stays well conditioned at and next to the poles. + const up = [cosLat * cosLon, cosLat * sinLon, sinLat]; + const north = [-sinLat * cosLon, -sinLat * sinLon, cosLat]; + const east = [-sinLon, cosLon, 0]; const positions = []; for (let i = 0; i <= segments; i++) { const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; @@ -50,15 +58,13 @@ export function awarenessRangeRingPositions( const sinB = Math.sin(bearing); const angular = radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical); - const lat = Math.asin( - sinLat * Math.cos(angular) + cosLat * Math.sin(angular) * cosB, + const cosA = Math.cos(angular); + const sinA = Math.sin(angular); + const [x, y, z] = up.map( + (u, k) => u * cosA + (north[k] * cosB + east[k] * sinB) * sinA, ); - const lon = - longitude + - Math.atan2( - sinB * Math.sin(angular) * cosLat, - Math.cos(angular) - sinLat * Math.sin(lat), - ); + const lat = Math.atan2(z, Math.hypot(x, y)); + const lon = Math.atan2(y, x); positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM)); } return positions; From daba7f93a7b8a193ea1b8dd6f7e27ea64ee0afef Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:34:19 -0800 Subject: [PATCH 18/29] docs(awareness): state the range ring's measured ~50 m accuracy The ring is up to about 49 m off the geodesic radius at 45 degrees, not a few metres. Also attach the range ring style's JSDoc to its constant. --- src/layers/awareness/policy.js | 1 - src/layers/awareness/rendering.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index b33da8433..492bbc238 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,7 +50,6 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * luminance contrast on light and dark ground alike, and luminance is what * the monochrome styles keep. The core is the Context panel's blue; the halo * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. */ - export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ color: 'rgba(98, 181, 255, 0.95)', outlineColor: 'rgba(4, 12, 16, 0.8)', diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 36d920850..be20f9d50 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -21,7 +21,7 @@ import { formatAwarenessDirection } from './presentation.js'; * A closed ring of positions `radiusM` along the surface from `groundCenter`, * raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's * radius of curvature in that direction (Euler's formula), which keeps the - * ring within a few metres of the true geodesic radius at 250 km. + * ring within ~50 m of the true geodesic radius at 250 km. * @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid. * @param {number} radiusM Surface radius in metres. * @param {number} heightM Height of the ring above the ellipsoid. From fe3d17df02a002be7ba25b55c6816356c6baaefa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:37:57 -0800 Subject: [PATCH 19/29] fix(awareness): give the pulse material a named type so Cesium caches it once Cesium keeps every material type in a global cache for the page's lifetime. The pulse disc's fabric had no type, so each new animated visual (a subject switch, a reselect, reduced motion turned off) added a GUID-named entry that was never removed. Name the type, and give each material built from the cached template its own uniform objects back. --- src/layers/awareness/rangeRing.test.mjs | 30 +++++++++++++++++++++++++ src/layers/awareness/rendering.js | 14 +++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 17caa3bbc..db2758f56 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -774,6 +774,36 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( assert.equal(scene.preRender.numberOfListeners, 0); }); +test("switching subjects keeps one pulse material in Cesium's global cache", (t) => { + const ring = ringRendering(t); + const { scene, rendering } = ring; + frameLoop(t, ring); + // Cesium keeps every new material type for the page's lifetime, so an + // unnamed fabric (a fresh GUID type each time) would grow it per subject. + const cached = () => + Object.keys(Cesium.Material._materialCache._materials).length; + rendering.renderVisual({ ...flight(3), id: 'subject-0' }); + const first = pulsePrimitives(scene).disc.appearance.material; + const size = cached(); + let last = first; + for (let index = 1; index <= 6; index++) { + rendering.renderVisual({ ...flight(3 + index), id: `subject-${index}` }); + last = pulsePrimitives(scene).disc.appearance.material; + } + assert.equal(cached(), size, 'subject switches add no cache entries'); + rendering.clearVisual(); + rendering.renderVisual(flight(3)); + assert.equal(cached(), size, 'nor does a reselect'); + // A material built from the cached template still owns its uniform values. + const again = pulsePrimitives(scene).disc.appearance.material; + assert.notEqual(again, last); + assert.notEqual(again.uniforms.radii, last.uniforms.radii); + assert.notEqual(again.uniforms.alphas, first.uniforms.alphas); + assert.ok(again.uniforms.radii instanceof Cesium.Cartesian4); + assert.ok(again.uniforms.lineColor instanceof Cesium.Color); + assert.equal(again.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width); +}); + test('a resting pulse disc is not rebuilt as the subject moves, and catches up on wake', (t) => { const ring = ringRendering(t); const { scene, rendering } = ring; diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 2611dcbfb..969ffe5d5 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -176,6 +176,9 @@ export function awarenessRingFlash(nowMs) { /** Most pulses the pulse shader draws; its uniforms are vec4s. */ export const AWARENESS_PULSE_GRADIENT_MAX = 4; +/** The pulse disc's Cesium material type, one cached template per page. */ +const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient'; + // Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at // distance 0.5, so `r` is the fraction of the range. Each pulse is a line // `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half @@ -804,10 +807,19 @@ export function createRendering({ haloWidth: pulse.outlineWidth, }; uniforms.color.alpha = pulse.gradientOpacity; + // A named type: Cesium caches every material type for the page's + // lifetime, so an unnamed fabric (a fresh GUID each time) would leave + // one entry per new visual. Built from the cached template, the + // uniforms come back as plain copies; restore this visual's own. const material = new Cesium.Material({ - fabric: { uniforms, source: PULSE_GRADIENT_SOURCE }, + fabric: { + type: AWARENESS_PULSE_MATERIAL_TYPE, + uniforms, + source: PULSE_GRADIENT_SOURCE, + }, translucent: true, }); + Object.assign(material.uniforms, uniforms); gradient = scene.primitives.add( createPulseGradient(groundCenter, material), ); From 56a7cacbecfa8e1e6842172accac761d1596e5b1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:39:09 -0800 Subject: [PATCH 20/29] fix(awareness): ignore canvas input while the cockpit owns the camera The cockpit turns Cesium's camera controls off and moves the camera on its own, so a click, drag or wheel on the canvas moved nothing yet still counted as the user handling the map: the cockpit's next move woke a nine-second pulse burst. Input now counts only while the camera controls take it, and a gesture still pending when another owner takes the camera is dropped. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 15 +++- src/layers/awareness/policy.js | 2 +- src/layers/awareness/rangeRing.test.mjs | 93 +++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fd4789f60..d53e86a25 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 272b85edc..57903eecb 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -14,13 +14,22 @@ export function createLifecycle({ * Cesium canvas, where its camera controller listens), so a camera move the * user made can wake the range pulses and one the app made (tracking, * cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera - * controller reads none, and typing elsewhere must not count. + * controller reads none, and typing elsewhere must not count. Nor does + * input while the camera controller's inputs are off: the cockpit (or a + * gizmo drag, or a drawing tool) owns the camera then, and the user's + * gesture moves nothing. */ function noteCameraInput(event) { if (event?.type === 'pointermove' && !event.buttons) return; + if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); } + function cameraInputsEnabled() { + const controller = layerState.viewer?.scene?.screenSpaceCameraController; + return controller?.enableInputs !== false; + } + const CAMERA_INPUT_EVENTS = [ 'pointerdown', 'pointermove', @@ -79,6 +88,10 @@ export function createLifecycle({ layerState.viewer.scene.preRender.addEventListener(() => { if (!layerState.enabled) return; const now = Date.now(); + // Another owner took the camera: a gesture begun before it did must + // not wake the pulses on that owner's first move. + if (!cameraInputsEnabled()) + layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY; // "Did the camera move" uses the SAME quantized pose signature the fleet // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than // camera.changed, whose granularity is globally degraded by other layers diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 1c1320768..494ee20b1 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -92,7 +92,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * stays on screen, static, and nothing asks for frames, so a parked view * goes quiet again. Only a camera move within `inputWakeMs` of the user * handling the map (pointer, wheel or touch on the canvas, which covers a - * drag's inertia) wakes them; a moving subject and the app's own camera moves + * drag's inertia, while the camera controls take input) wakes them; a moving subject and the app's own camera moves * (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they * rest too. While they run they never hold * continuous rendering; each drawn frame arms one more request after the diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index db2758f56..5c85a23eb 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -574,6 +574,99 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true); }); +/** + * The lifecycle's camera-input listeners over `ring`, with a camera the test + * moves by longitude (`pan`) and a camera controller whose inputs it can + * switch off, as the cockpit view does. Pulses start resting. + */ +function inputLifecycle(t, ring) { + const { state, rendering } = ring; + const loop = frameLoop(t, ring, { date: true }); + state.enabled = true; + state.viewer.canvas = new EventTarget(); + state.viewer.scene.screenSpaceCameraController = { enableInputs: true }; + state.viewer.camera = { + positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), + heading: 0, + pitch: -1, + roll: 0, + }; + const lifecycle = createLifecycle({ + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts: { + rendering, + model: { + awarenessRefreshDecision: () => ({ moving: false, refresh: false }), + awarenessNeedsContinuousRender: () => false, + }, + }, + }); + lifecycle.attachRuntimeListeners(); + t.after(() => lifecycle.detachRuntimeListeners()); + rendering.renderVisual(flight(3)); + loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); + let lon = 3; + return { + loop, + lifecycle, + controller: state.viewer.scene.screenSpaceCameraController, + dispatch: (type, init = {}) => + state.viewer.canvas.dispatchEvent(Object.assign(new Event(type), init)), + pan(stepDeg = 0.1) { + lon += stepDeg; + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + lon, + 20, + 800_000, + ); + loop.draw(); + }, + /** Whether the pulses drew frames during the next `ms`. */ + pulsing(ms = AWARENESS_RANGE_PULSE.activeMs) { + const before = ring.pulseFrames(); + loop.advance(ms); + return ring.pulseFrames() > before; + }, + }; +} + +test('input the camera controller ignores, as in the cockpit, does not wake the pulses', (t) => { + const ring = ringRendering(t); + const { activeMs } = AWARENESS_RANGE_PULSE; + const { controller, dispatch, pan, pulsing, loop } = inputLifecycle(t, ring); + // The cockpit takes the camera: clicks, drags and wheels on the canvas + // move nothing, while the cockpit itself moves the camera every frame. + controller.enableInputs = false; + for (const [type, init] of [ + ['pointerdown', {}], + ['pointermove', { buttons: 1 }], + ['wheel', {}], + ['touchstart', {}], + ]) { + dispatch(type, init); + pan(); + assert.equal(pulsing(), false, `${type} in the cockpit`); + } + // A gesture that began just before the cockpit took the camera is + // dropped too, rather than waking the pulses on the cockpit's first move. + controller.enableInputs = true; + loop.advance(activeMs); + dispatch('pointerdown'); + controller.enableInputs = false; + pan(); + assert.equal(pulsing(), false, 'a gesture cut off by the cockpit'); + controller.enableInputs = true; + pan(); + assert.equal(pulsing(), false, 'and stays dropped once it hands back'); + // Back in the user's hands, a drag wakes them again. + dispatch('pointerdown'); + pan(); + assert.equal(pulsing(), true, 'a drag after the cockpit'); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring; From de634952ec9b5e3fea12a4f1e17315f3e260f651 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:40:30 -0800 Subject: [PATCH 21/29] fix(awareness): keep a long drag or touch counting as camera input Only canvas events refreshed the 1.5 s input window, and a touch only on touchstart. Cesium follows a drag that leaves the canvas at document level (in Firefox, and wherever there are no pointer events), so a long gesture kept moving the camera after the window closed, and the pulses rested mid-gesture. From a pointer or touch start on the canvas until its end or cancel, moves at document level now count too; the listeners go when the gesture ends or the layer detaches. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 31 ++++++++++++++++++++ src/layers/awareness/rangeRing.test.mjs | 39 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d53e86a25..b4ce97d81 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until it ends, even outside the canvas; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 57903eecb..042148c37 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -23,6 +23,36 @@ export function createLifecycle({ if (event?.type === 'pointermove' && !event.buttons) return; if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); + if (event?.type === 'pointerdown' || event?.type === 'touchstart') + followGesture(true); + } + + // A drag can leave the canvas, and Cesium follows it at document level + // (in Firefox, and wherever there are no pointer events). So does this, + // from a gesture's start on the canvas to its end, so a long gesture keeps + // the input window open the whole way. + const GESTURE_MOVE_EVENTS = ['pointermove', 'touchmove']; + const GESTURE_END_EVENTS = [ + 'pointerup', + 'pointercancel', + 'touchend', + 'touchcancel', + ]; + let followingGesture = false; + + function followGesture(on) { + if (followingGesture === on) return; + followingGesture = on; + const listen = on ? 'addEventListener' : 'removeEventListener'; + for (const type of GESTURE_MOVE_EVENTS) + globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true }); + for (const type of GESTURE_END_EVENTS) + globalThis.document?.[listen]?.(type, endGesture, { passive: true }); + } + + function endGesture(event) { + noteCameraInput(event); + followGesture(false); } function cameraInputsEnabled() { @@ -160,6 +190,7 @@ export function createLifecycle({ noteCameraInput, ); layerState.cameraInputTarget = null; + followGesture(false); } layerState.preRenderRemover?.(); layerState.preRenderRemover = null; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 5c85a23eb..a24794401 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -667,6 +667,45 @@ test('input the camera controller ignores, as in the cockpit, does not wake the assert.equal(pulsing(), true, 'a drag after the cockpit'); }); +test('a long drag or touch keeps the pulses awake after it leaves the canvas', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop, lifecycle } = inputLifecycle(t, ring); + const onDocument = (type, init = {}) => + document.dispatchEvent(Object.assign(new Event(type), init)); + // The gesture starts on the canvas and carries on past `inputWakeMs` + // outside it, where Cesium still follows it at document level. + const longGesture = (start, move, init) => { + dispatch(start, init); + for (let ms = 0; ms < inputWakeMs + 1000; ms += 250) { + loop.advance(250); + onDocument(move, init); + } + }; + for (const [start, move, end, init] of [ + ['pointerdown', 'pointermove', 'pointerup', { buttons: 1 }], + ['touchstart', 'touchmove', 'touchend', {}], + ['touchstart', 'touchmove', 'touchcancel', {}], + ]) { + longGesture(start, move, init); + pan(); + assert.equal(pulsing(), true, `${move} outside the canvas`); + // Once it ends, moves elsewhere are not handling the map. + onDocument(end); + loop.advance(inputWakeMs + 100); + onDocument(move, init); + pan(); + assert.equal(pulsing(), false, `${move} after ${end}`); + } + // Detaching mid-gesture drops the document listeners too. + dispatch('pointerdown'); + lifecycle.detachRuntimeListeners(); + loop.advance(inputWakeMs + 100); + const noted = ring.state.lastCameraInputMs; + onDocument('pointermove', { buttons: 1 }); + assert.equal(ring.state.lastCameraInputMs, noted); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring; From 02a6739466ec714cbc97472edf144c41ab77091e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:47:23 -0800 Subject: [PATCH 22/29] fix(awareness): follow a gesture to its last contact, and drop it on blur Lifting one finger of a pinch removed the document listeners, so the remaining finger's drag, which Cesium carries on with, stopped counting. A gesture now ends only when its last pointer lifts or no touches are left. And a gesture whose end never reaches the page (the window loses focus mid-drag, or the page is hidden) left the listeners attached, so a later unrelated drag counted as camera input; a blur or hidden page now stops following it and drops its pending input. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 21 +++++++ src/layers/awareness/rangeRing.test.mjs | 83 +++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b4ce97d81..80c7c2bac 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until it ends, even outside the canvas; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 042148c37..b2e7dfe30 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -23,6 +23,7 @@ export function createLifecycle({ if (event?.type === 'pointermove' && !event.buttons) return; if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); + if (event?.type === 'pointerdown') activePointers.add(event.pointerId); if (event?.type === 'pointerdown' || event?.type === 'touchstart') followGesture(true); } @@ -39,22 +40,42 @@ export function createLifecycle({ 'touchcancel', ]; let followingGesture = false; + const activePointers = new Set(); function followGesture(on) { if (followingGesture === on) return; followingGesture = on; + if (!on) activePointers.clear(); const listen = on ? 'addEventListener' : 'removeEventListener'; for (const type of GESTURE_MOVE_EVENTS) globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true }); for (const type of GESTURE_END_EVENTS) globalThis.document?.[listen]?.(type, endGesture, { passive: true }); + globalThis[listen]?.('blur', dropGesture); + globalThis.document?.[listen]?.('visibilitychange', dropGesture); } + // A gesture ends with its last contact: lifting one finger of a pinch + // leaves Cesium a one-finger drag. function endGesture(event) { noteCameraInput(event); + activePointers.delete(event?.pointerId); + if (activePointers.size || event?.touches?.length) return; followGesture(false); } + // Losing the window or the page mid-gesture means its end may never + // arrive here, so stop following it and drop its pending input. + function dropGesture(event) { + if ( + event?.type === 'visibilitychange' && + document.visibilityState !== 'hidden' + ) + return; + followGesture(false); + layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY; + } + function cameraInputsEnabled() { const controller = layerState.viewer?.scene?.screenSpaceCameraController; return controller?.enableInputs !== false; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index a24794401..94124d36a 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -706,6 +706,89 @@ test('a long drag or touch keeps the pulses awake after it leaves the canvas', ( assert.equal(ring.state.lastCameraInputMs, noted); }); +test('lifting one finger of a pinch leaves the other finger counting', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop } = inputLifecycle(t, ring); + const onDocument = (type, init = {}) => + document.dispatchEvent(Object.assign(new Event(type), init)); + // Cesium carries on with a one-finger drag after the other lifts. + const dragOn = (move, init) => { + for (let ms = 0; ms < inputWakeMs + 1000; ms += 250) { + loop.advance(250); + onDocument(move, init); + } + pan(); + return pulsing(); + }; + // Without pointer events: the touch list says who is still down. + dispatch('touchstart'); + onDocument('touchend', { touches: [{}] }); + assert.equal(dragOn('touchmove', {}), true, 'touch: one finger left'); + onDocument('touchend', { touches: [] }); + assert.equal(dragOn('touchmove', {}), false, 'touch: all lifted'); + // With pointer events: each contact has its own pointer id. + dispatch('pointerdown', { pointerId: 1 }); + dispatch('pointerdown', { pointerId: 2 }); + onDocument('pointerup', { pointerId: 1 }); + const drag = { pointerId: 2, buttons: 1 }; + assert.equal(dragOn('pointermove', drag), true, 'pointer: one left'); + onDocument('pointercancel', { pointerId: 2 }); + assert.equal(dragOn('pointermove', drag), false, 'pointer: all lifted'); +}); + +test('a gesture lost to a window blur or a hidden page stops counting', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop } = inputLifecycle(t, ring); + const visibility = Object.getOwnPropertyDescriptor( + document, + 'visibilityState', + ); + t.after(() => { + if (visibility) + Object.defineProperty(document, 'visibilityState', visibility); + else delete document.visibilityState; + }); + const setVisibility = (value) => + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => value, + }); + for (const [name, lose] of [ + ['blur', () => window.dispatchEvent(new Event('blur'))], + [ + 'hidden', + () => { + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + setVisibility('visible'); + }, + ], + ]) { + // The release happens outside the window, so no end ever arrives. + dispatch('pointerdown', { pointerId: 1 }); + lose(); + pan(); + assert.equal(pulsing(), false, `${name}: pending input dropped`); + loop.advance(inputWakeMs + 100); + const noted = ring.state.lastCameraInputMs; + document.dispatchEvent( + Object.assign(new Event('pointermove'), { pointerId: 1, buttons: 1 }), + ); + assert.equal(ring.state.lastCameraInputMs, noted, `${name}: no follow`); + } + // A page coming back into view is no reason to drop a gesture. + dispatch('pointerdown', { pointerId: 1 }); + document.dispatchEvent(new Event('visibilitychange')); + loop.advance(inputWakeMs + 100); + document.dispatchEvent( + Object.assign(new Event('pointermove'), { pointerId: 1, buttons: 1 }), + ); + pan(); + assert.equal(pulsing(), true, 'visible: still following'); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring; From 6bdedd852322a0e8838bcae2811a6613d20330ab Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:33:10 -0800 Subject: [PATCH 23/29] fix(drone-airspace): refuse empty or incompletely paged FAA snapshots An upstream answer whose zones all fail validation normalized to an empty list, and paging that stopped at the page limit while the service still flagged more records was accepted as complete. Both were cached as a fresh snapshot for an hour and replaced the last good one. Treat them as failed refreshes so stale-on-error keeps serving the previous snapshot. --- docs/CURRENT-STATE.md | 4 +- server/providers/droneAirspace.js | 13 ++++- src/data/droneAirspaceProxy.test.mjs | 77 ++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d24b793bc..259b92ce6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4100,7 +4100,9 @@ Fire Perimeters uses capped, timed server reads with stale-on-error caching and Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last -good payload as stale when a refresh fails. Client-supplied URLs are rejected. +good payload as stale when a refresh fails. An answer with no valid zone, or +paging that ends while the service still flags more records, counts as a +failed refresh. Client-supplied URLs are rejected. Polygons keep holes; pick ownership is deliberately unregistered so sibling cards still work over a zone. The row legend and selected card state that the layer is informational only and not a substitute for B4UFLY / LAANC. diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js index 731852c2e..3109d5984 100644 --- a/server/providers/droneAirspace.js +++ b/server/providers/droneAirspace.js @@ -61,8 +61,12 @@ export function droneAirspaceProxy({ return readResponseJsonCapped(response, cap, signal); } + // A snapshot is accepted only once the service says it is complete. A page + // limit reached while still flagged, a flagged page that adds nothing, or + // no valid zone at all is a failed refresh, so the last good snapshot stays. async function fetchZones() { const features = []; + let complete = false; for (let page = 0; page < MAX_PAGES; page++) { const url = page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`; @@ -70,10 +74,15 @@ export function droneAirspaceProxy({ if (!Array.isArray(payload?.features)) throw new Error('invalid_snapshot'); features.push(...payload.features); - if (!exceededTransferLimit(payload) || !payload.features.length) break; + if (!exceededTransferLimit(payload)) { + complete = true; + break; + } + if (!payload.features.length) break; } + if (!complete) throw new Error('incomplete_snapshot'); const rows = normalizeDroneAirspaceSnapshot({ features }); - if (!rows) throw new Error('invalid_snapshot'); + if (!rows?.length) throw new Error('invalid_snapshot'); return { fetchedAt: now(), rows }; } diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs index 52eb0dcc8..e56da166c 100644 --- a/src/data/droneAirspaceProxy.test.mjs +++ b/src/data/droneAirspaceProxy.test.mjs @@ -51,7 +51,7 @@ function install(options = {}, hook = 'configureServer') { } for (const hook of ['configureServer', 'configurePreviewServer']) { - test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => { + test(`${hook}: paging follows offsets and normalizes the combined snapshot`, async () => { const calls = []; const request = install( { @@ -62,7 +62,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { assert.equal(options.redirect, 'error'); return Response.json({ features: [feature(calls.length)], - exceededTransferLimit: true, + exceededTransferLimit: calls.length < 3, }); }, }, @@ -71,8 +71,8 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { 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.equal(calls.length, 5); + assert.equal(res.body.rows.length, 3); + assert.equal(calls.length, 3); assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')"); assert.equal(calls[0].searchParams.get('f'), 'geojson'); assert.equal(calls[0].searchParams.has('resultOffset'), false); @@ -80,6 +80,75 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { }); } +/** + * Serve one good snapshot, then let the upstream answer `bad` after the TTL: + * the bad answer must not replace it, so the client gets the last good rows + * marked stale, and a cold proxy answers 502 rather than caching the bad one. + */ +async function assertRejectedSnapshot(bad) { + let now = 1000; + let broken = false; + let calls = 0; + const request = install({ + now: () => now, + fetchImpl: async () => { + calls += 1; + return broken ? bad(calls) : Response.json({ features: [feature(7)] }); + }, + }); + const first = await request(); + assert.equal(first.status, 200); + broken = true; + now += 3_600_001; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, first.body.fetchedAt); + assert.deepEqual( + stale.body.rows.map((row) => row.stableId), + ['7'], + ); + calls = 0; + const cold = install({ fetchImpl: async () => bad(++calls) }); + const res = await cold(); + assert.equal(res.status, 502); + assert.equal(res.body.error, 'drone_airspace_unavailable'); +} + +test('an empty upstream snapshot keeps the last good one', () => + assertRejectedSnapshot(() => Response.json({ features: [] }))); + +test('a page whose every feature is invalid keeps the last good one', () => + assertRejectedSnapshot(() => + Response.json({ + features: [ + { + ...feature(8), + properties: { ...feature(8).properties, TYPE_CODE: 'W' }, + }, + { ...feature(9), geometry: null }, + ], + }), + )); + +test('paging that exhausts its page limit still flagged keeps the last good one', () => + assertRejectedSnapshot((call) => + Response.json({ + features: [feature(100 + call)], + exceededTransferLimit: true, + }), + )); + +test('a flagged page that adds no features keeps the last good one', () => + assertRejectedSnapshot((call) => + Response.json( + call % 2 + ? { features: [feature(100 + call)], exceededTransferLimit: true } + : { features: [], properties: { exceededTransferLimit: true } }, + ), + )); + test('a cached snapshot is reused until TTL, then refreshed', async () => { let now = 1000; let fetches = 0; From 8e63420f06f7787ed8c4f1bf6352b7bf712b031c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:34:01 -0800 Subject: [PATCH 24/29] fix(drone-airspace): show stale FAA snapshots as stale and cap them at a day The source dropped the proxy's stale flag and fetch time, so the layer row read nominal and freshly updated through an outage, and the proxy served its last snapshot indefinitely. Carry stale and fetchedAt through the source as Fire Perimeters does, date a stale snapshot by its fetch, and stop serving a snapshot more than 24 hours old. --- docs/CURRENT-STATE.md | 3 +- server/providers/droneAirspace.js | 6 ++- src/data/droneAirspaceProxy.test.mjs | 22 +++++++++++ src/layers/droneAirspace/index.js | 24 ++++++++++-- src/layers/droneAirspace/ownership.test.mjs | 37 +++++++++++++++++++ src/layers/droneAirspace/source.js | 41 ++++++++++++++------- src/layers/droneAirspace/source.test.mjs | 14 +++++++ 7 files changed, 129 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 259b92ce6..5b37341cb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4100,7 +4100,8 @@ Fire Perimeters uses capped, timed server reads with stale-on-error caching and Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last -good payload as stale when a refresh fails. An answer with no valid zone, or +good payload as stale for at most 24 hours when a refresh fails; the layer row +then reads STALE, dated by that snapshot's fetch. An answer with no valid zone, or paging that ends while the service still flags more records, counts as a failed refresh. Client-supplied URLs are rejected. Polygons keep holes; pick ownership is deliberately unregistered so sibling diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js index 3109d5984..7d9ead55c 100644 --- a/server/providers/droneAirspace.js +++ b/server/providers/droneAirspace.js @@ -34,6 +34,8 @@ const API_URL = const MAX_PAGES = 5; const MIB = 1024 * 1024; const SNAPSHOT_TTL_MS = 3_600_000; +/** Longest a failed refresh keeps serving the last snapshot, marked stale. */ +const STALE_MAX_MS = 24 * 60 * 60 * 1000; const exceededTransferLimit = (payload) => payload?.exceededTransferLimit === true || @@ -100,7 +102,9 @@ export function droneAirspaceProxy({ }); return { value: await promise, stale: false }; } catch (error) { - if (previous) return { value: previous.value, stale: true }; + // A failed refresh serves the last snapshot, marked stale, for a day. + if (previous && now() - previous.savedAt <= STALE_MAX_MS) + return { value: previous.value, stale: true }; throw error; } } diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs index e56da166c..52444cda0 100644 --- a/src/data/droneAirspaceProxy.test.mjs +++ b/src/data/droneAirspaceProxy.test.mjs @@ -213,3 +213,25 @@ test('a cold upstream failure is 502, not an empty snapshot', async () => { assert.equal(res.status, 502); assert.equal(res.body.error, 'drone_airspace_unavailable'); }); + +test('a stale snapshot is served for at most a day, then refused', async () => { + let now = 1_000_000_000; + let up = true; + const request = install({ + now: () => now, + fetchImpl: async () => { + if (!up) throw new Error('FAA unreachable'); + return Response.json({ features: [feature(7)] }); + }, + }); + const fresh = await request(); + assert.equal(fresh.status, 200); + up = false; + now += 23 * 60 * 60 * 1000; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + now += 2 * 60 * 60 * 1000; + assert.equal((await request()).status, 502, 'a day-old snapshot is refused'); +}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 6420af2bd..a031215d9 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -42,6 +42,7 @@ export function createDroneAirspaceLayer({ let _count = 0; let _lastUpdate = null; let _lastError = null; + let _stale = false; let _enabled = false; let _clickHandler = null; let _selectedId = null; @@ -153,6 +154,7 @@ export function createDroneAirspaceLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; _enabled = false; console.log('[Data:DroneAirspace] Initialized'); }, @@ -179,9 +181,21 @@ export function createDroneAirspaceLayer({ const request = new AbortController(); _request = request; try { - const rows = await source.getSnapshot({ signal: request.signal }); + // The source contract is getSnapshot; a source that can also say how + // fresh its rows are (the FAA proxy) offers getSnapshotDetail. + const snapshot = + typeof source.getSnapshotDetail === 'function' + ? await source.getSnapshotDetail({ signal: request.signal }) + : { rows: await source.getSnapshot({ signal: request.signal }) }; if (request.signal.aborted || _request !== request || !_enabled) return false; + const { rows } = snapshot; + _stale = snapshot.stale === true; + // A stale snapshot is as old as its fetch, not this poll. + const updatedAt = + _stale && Number.isFinite(snapshot.fetchedAt) + ? snapshot.fetchedAt + : Date.now(); const signature = JSON.stringify( rows @@ -190,7 +204,7 @@ export function createDroneAirspaceLayer({ ); if (signature === _snapshotSignature) { if (_selectedId) publishSelectedCard(); - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; return true; } @@ -239,7 +253,7 @@ export function createDroneAirspaceLayer({ _snapshotSignature = signature; if (_selectedId) publishSelectedCard(); _count = rows.length; - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; console.log( `[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`, @@ -251,6 +265,8 @@ export function createDroneAirspaceLayer({ console.warn('[Data:DroneAirspace] Fetch error:', e); _lastError = e?.message || msg('Restricted airspace source unavailable'); + // A failure now outranks the stale snapshot still drawn. + _stale = false; return false; } finally { if (_request === request) _request = null; @@ -273,6 +289,7 @@ export function createDroneAirspaceLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; }, getAnalystRecords(maxCount = 2000) { @@ -322,6 +339,7 @@ export function createDroneAirspaceLayer({ count: _count, lastUpdate: _lastUpdate, error: _lastError, + stale: _stale, }; }, }; diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index bc10318f7..6f5019309 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createDroneAirspaceLayer } from './index.js'; +import { layerFeedState } from '../../data/feedState.js'; function harness(source, { pick = () => null, cardHit = () => null } = {}) { const sources = []; @@ -159,3 +160,39 @@ test('the row legend names prohibited and restricted with the disclaimer', async assert.equal(legend[1].count, 1); assert.match(legend[0].blurb, /not a substitute/); }); + +test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clears it', async () => { + const fetchedAt = Date.now() - 3 * 60 * 60 * 1000; + let snapshot = { rows: [row], stale: true, fetchedAt }; + const h = harness({ + getSnapshot: async () => snapshot.rows, + getSnapshotDetail: async () => snapshot, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + assert.equal(h.layer.getStats().lastUpdate, fetchedAt); + assert.equal(layerFeedState(h.layer.getStats()), 'stale'); + snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.ok(h.layer.getStats().lastUpdate > fetchedAt); + assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); +}); + +test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => { + let fail = false; + const h = harness({ + getSnapshot: async () => [row], + getSnapshotDetail: async () => { + if (fail) throw new Error('FAA SUA HTTP 502'); + return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; + }, + }); + await h.layer.update(h.viewer); + assert.equal(layerFeedState(h.layer.getStats()), 'stale'); + fail = true; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); +}); diff --git a/src/layers/droneAirspace/source.js b/src/layers/droneAirspace/source.js index 17e67b3ae..bd5c4d40e 100644 --- a/src/layers/droneAirspace/source.js +++ b/src/layers/droneAirspace/source.js @@ -4,20 +4,35 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; export function createDroneAirspaceSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** + * The rows with how fresh they are: the proxy answers from its last + * snapshot, marked stale, while the FAA service is unreachable. + * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} + */ + async function getSnapshotDetail({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/drone-airspace', { signal }); + if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed drone airspace snapshot'); + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null, + }; + } + return { - async getSnapshot({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/drone-airspace', { signal }); - if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); - const payload = await readResponseJsonCapped( - response, - 80 * 1024 * 1024, - signal, - ); - signal?.throwIfAborted(); - if (!Array.isArray(payload?.rows)) - throw new Error('Malformed drone airspace snapshot'); - return payload.rows; + /** The zone rows (see getSnapshotDetail for staleness). */ + async getSnapshot(options = {}) { + return (await getSnapshotDetail(options)).rows; }, + getSnapshotDetail, }; } diff --git a/src/layers/droneAirspace/source.test.mjs b/src/layers/droneAirspace/source.test.mjs index 160fb1789..93644db1a 100644 --- a/src/layers/droneAirspace/source.test.mjs +++ b/src/layers/droneAirspace/source.test.mjs @@ -103,3 +103,17 @@ test('a malformed successful response is never accepted as an empty snapshot', a ); } }); + +test('the snapshot detail carries the proxy staleness and fetch time', async () => { + const fetchedAt = Date.UTC(2026, 10, 3, 12); + const rows = normalizeDroneAirspaceSnapshot(validPayload); + const source = createDroneAirspaceSource({ + fetchImpl: async () => Response.json({ rows, stale: true, fetchedAt }), + }); + assert.deepEqual(await source.getSnapshotDetail(), { + rows, + stale: true, + fetchedAt, + }); + assert.deepEqual(await source.getSnapshot(), rows); +}); From ca17412c7037739046aec23222f920ba70656d4c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:34:28 -0800 Subject: [PATCH 25/29] fix(drone-airspace): redraw a zone when only its boundary changes The refresh signature left out the polygons, and these rows carry no timestamp that moves with the geometry, so an amended boundary with the same attributes kept the old shape and card anchor until the layer was destroyed. Add a deterministic hash of each zone's rings to the signature. --- src/layers/droneAirspace/index.js | 21 ++++++++++++++++++++- src/layers/droneAirspace/ownership.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index a031215d9..d0eb5e60a 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -18,6 +18,20 @@ export function droneAirspaceColor(typeCode) { const ringPositions = (ring) => ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); +/** + * FNV-1a over a zone's rings: a short, deterministic fingerprint so a + * boundary change with unchanged attributes still alters the refresh signature. + */ +function geometryHash(polygons) { + const text = JSON.stringify(polygons); + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + const PICK_PREFIX = 'drone-airspace:'; const CARD_HOST_OPTIONS = Object.freeze({ cohortLimit: 1, @@ -197,9 +211,14 @@ export function createDroneAirspaceLayer({ ? snapshot.fetchedAt : Date.now(); + // FAA amends boundaries in its 8-week cycle without any attribute + // changing, so the geometry is part of the signature. const signature = JSON.stringify( rows - .map(({ polygons, ...facts }) => facts) + .map(({ polygons, ...facts }) => ({ + ...facts, + geometry: geometryHash(polygons), + })) .sort((a, b) => a.stableId.localeCompare(b.stableId)), ); if (signature === _snapshotSignature) { diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index 6f5019309..e058834ff 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -196,3 +196,23 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale', assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502'); assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); }); + +test('a boundary change alone redraws the zone; identical geometry does not', async () => { + const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]); + let rows = [row]; + const h = harness({ getSnapshot: async () => rows }); + await h.layer.update(h.viewer); + const [first] = h.sources[0].entities.values; + rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }]; + await h.layer.update(h.viewer); + assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept'); + rows = [{ ...row, polygons: [[moved]] }]; + await h.layer.update(h.viewer); + const [redrawn] = h.sources[0].entities.values; + assert.notEqual(redrawn, first, 'moved boundary must redraw'); + assert.notDeepEqual( + redrawn.polyline.positions.getValue(), + first.polyline.positions.getValue(), + ); + assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6); +}); From 585392cbcfc055ba8412a8ddd85b69a587f0acd0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:35:14 -0800 Subject: [PATCH 26/29] fix(drone-airspace): refuse cross-site requests to the FAA proxy A foreign Origin or Sec-Fetch-Site: cross-site request was answered and counted against the client's 60-a-minute quota, and could start a refresh. Run the keyless same-site checks the other map routes use before rate limiting and cache acquisition; reverse-proxy forwarding headers stay accepted. List the route with the keyless map routes in SECURITY.md. --- SECURITY.md | 2 +- docs/CURRENT-STATE.md | 5 ++- server/providers/droneAirspace.js | 5 +++ src/data/droneAirspaceProxy.test.mjs | 59 +++++++++++++++++++++++++++- 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 2527004fb..01abdbac0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,7 +81,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `