Merge branch 'main' into fix/houston-cctv-pack

This commit is contained in:
Sameh Khamis committed 2026-11-03 15:01:13 -08:00
commit 09bdc314d0
65 files changed
+5080 -97

No files matched your search

+2
View File
@@ -40,6 +40,7 @@ How to read this:
| **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)" | | **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)" |
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **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)" | | **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`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, 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 | "Prohibited & restricted airspace: FAA Special Use Airspace" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **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 | | **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 | | **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 |
@@ -192,6 +193,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. - **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. - **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. - **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. - **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 (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
+2 -1
View File
@@ -424,7 +424,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
## 🛰️ What's on the Globe ## 🛰️ What's on the Globe
Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth | | Layer | What you get | Source | Auth |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
@@ -449,6 +449,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🔥 **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 paid token raises the allowance) | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; 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 | 🟢 | | 🌬️ **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 | 🟢 | | 🌦️ **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 | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
+1 -1
View File
@@ -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. - **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **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. - **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 `<img>`/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 `<img>`/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 `<style>` elements. - **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 `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
@@ -0,0 +1,8 @@
- Add a Prohibited & Restricted Airspace 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, altitude range, times
of use and agency, and the row legend states the layer is informational only
and does not include the UAS Facility Map or UAS flight restrictions. Off by
default; share-link token `8` (mvanhorn, #1086).
+4
View File
@@ -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).
@@ -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).
@@ -0,0 +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. 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).
+42
View File
@@ -98,6 +98,27 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own `src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations. implementations.
## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
share-link token `8`), 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, an altitude range built from the
`LOWER_*`/`UPPER_*` value, unit and reference fields (for example `SFC to
5,000 ft MSL` or `20,000 ft MSL to Unlimited`), 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 is off for this layer for now
(`VOICE_OFF_LAYERS`: the Realtime prompt has no room), so neither voice nor the
analyst can toggle or query it. The layer covers FAA prohibited and restricted
special-use airspace only, not the UAS Facility Map / LAANC grids, National
Security UAS Flight Restrictions or TFRs; the label and legend say so.
See the live-layers table below.
Vessel sources can report healthy empty coverage using a current, complete Vessel sources can report healthy empty coverage using a current, complete
snapshot with zero raw rows, a healthy transport and a positive last-message snapshot with zero raw rows, a healthy transport and a positive last-message
time. This clears obsolete contacts and settles startup without manufacturing time. This clears obsolete contacts and settles startup without manufacturing
@@ -4069,6 +4090,7 @@ its criteria cannot be silently ignored.
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | | Fire Perimeters 🔥 | 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) |
| Prohibited & Restricted Airspace 🚫 | 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) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4096,6 +4118,20 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
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 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 ignored. The route uses the keyless
same-site checks: a cross-site browser request gets `403` before it spends the
per-client quota or starts a refresh, and the app still works behind a reverse
proxy.
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 docked, collapsible right-rail panel (`#street-level-panel`, Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts 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 collapsed. It opens itself only when the user, voice or a tool switches the
@@ -5016,6 +5052,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`. - 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. - 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. - 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 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. - 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. - 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. - 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.
@@ -6102,6 +6139,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 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 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. 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 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 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 runs within 35 m in the same travel direction (30 degrees), tested every
+2
View File
@@ -176,6 +176,7 @@
"./layers/perimeters": "./src/layers/perimeters/index.js", "./layers/perimeters": "./src/layers/perimeters/index.js",
"./layers/gnss": "./src/layers/gnss/index.js", "./layers/gnss": "./src/layers/gnss/index.js",
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js", "./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
"./layers/drone-airspace": "./src/layers/droneAirspace/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js", "./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js", "./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js", "./layers/satellites": "./src/layers/satellites/index.js",
@@ -255,6 +256,7 @@
"./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js",
"./layers/gnss/source": "./src/layers/gnss/source.js", "./layers/gnss/source": "./src/layers/gnss/source.js",
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
"./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js",
"./layers/firms/source": "./src/layers/firms/source.js", "./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js", "./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js", "./layers/launches/source": "./src/layers/launches/source.js",
+17 -1
View File
@@ -405,6 +405,15 @@
"src/layers/perimeters/cards.test.mjs", "src/layers/perimeters/cards.test.mjs",
"src/layers/perimeters/inciweb.test.mjs", "src/layers/perimeters/inciweb.test.mjs",
"src/layers/perimeters/ownership.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/layers/droneAirspace/docs.test.mjs",
"src/data/firmsHeatmap.js", "src/data/firmsHeatmap.js",
"src/data/fireAnchors.js", "src/data/fireAnchors.js",
"src/layers/firms/anchors.js", "src/layers/firms/anchors.js",
@@ -685,6 +694,7 @@
"src/app/layers/radio.js", "src/app/layers/radio.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/perimeters.js", "src/app/layers/perimeters.js",
"src/app/layers/droneAirspace.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/satellites.js", "src/app/layers/satellites.js",
@@ -870,6 +880,7 @@
"src/layers/weather/imageryOrder.test.mjs", "src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js", "server/providers/cyclones.js",
"server/providers/firePerimeters.js", "server/providers/firePerimeters.js",
"server/providers/droneAirspace.js",
"src/data/cyclonesProxy.test.mjs", "src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs", "src/data/firePerimetersProxy.test.mjs",
"server/providers/gnssIntegrity.js", "server/providers/gnssIntegrity.js",
@@ -894,6 +905,7 @@
"src/layers/ghostMaps/records.test.mjs", "src/layers/ghostMaps/records.test.mjs",
"src/layers/ghostMaps/lifecycle.test.mjs", "src/layers/ghostMaps/lifecycle.test.mjs",
"scripts/qa-ghostmaps.mjs", "scripts/qa-ghostmaps.mjs",
"src/data/droneAirspaceProxy.test.mjs",
"src/layers/cyclones/index.js", "src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js", "src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js", "src/layers/cyclones/labels.js",
@@ -1185,6 +1197,9 @@
"src/overlays/worldOverlayActionsView.test.mjs", "src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js", "src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs", "src/layers/awareness/directionOverlay.test.mjs",
"src/awarenessEcho.js",
"src/awarenessEcho.test.mjs",
"src/data/detectionAwarenessEcho.test.mjs",
"src/hudReadouts.js", "src/hudReadouts.js",
"src/ui/stores/hud.js", "src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs", "src/ui/views/intelHudView.test.mjs",
@@ -1224,5 +1239,6 @@
"src/voice/areaActions.test.mjs", "src/voice/areaActions.test.mjs",
"src/voice/imageryActions.test.mjs", "src/voice/imageryActions.test.mjs",
"src/voice/osmActions.test.mjs", "src/voice/osmActions.test.mjs",
"src/voice/realtimeProtocol.test.mjs" "src/voice/realtimeProtocol.test.mjs",
"src/layers/awareness/rangeRing.test.mjs"
] ]
+51 -1
View File
@@ -1684,6 +1684,7 @@
"src/data/distanceFormat.js", "src/data/distanceFormat.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/awarenessEcho.js",
"src/cockpitMath.js", "src/cockpitMath.js",
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/militaryAwarenessEngine.js", "src/data/militaryAwarenessEngine.js",
@@ -2234,6 +2235,7 @@
"src/ui/placeNames.js", "src/ui/placeNames.js",
"src/cyberSonarScene.js", "src/cyberSonarScene.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/awarenessEcho.js",
"src/hudLayouts.js", "src/hudLayouts.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
"src/ui/radioPanelPlacement.js", "src/ui/radioPanelPlacement.js",
@@ -2891,6 +2893,11 @@
"src/layers/ghostMaps/records.js", "src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js", "src/layers/ghostMaps/source.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/layers/cctv/sourcePolicy.js",
"src/sources/reference.js", "src/sources/reference.js",
"src/search/nominatim.js", "src/search/nominatim.js",
@@ -3233,6 +3240,7 @@
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
"src/data/distanceFormat.js", "src/data/distanceFormat.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/awarenessEcho.js",
"src/data/approxDistance.js", "src/data/approxDistance.js",
"src/data/bhoteKoshiEvent.js", "src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js", "src/ui/imagerySplit.js",
@@ -3633,6 +3641,11 @@
"src/layers/ghostMaps/records.js", "src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js", "src/layers/ghostMaps/source.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/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js", "src/search/nominatim.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
@@ -4339,7 +4352,9 @@
"src/layers/submarineCables/bundledSource.js", "src/layers/submarineCables/bundledSource.js",
"src/layers/gnss/source.js", "src/layers/gnss/source.js",
"src/layers/airQuality/model.js", "src/layers/airQuality/model.js",
"src/layers/airQuality/source.js" "src/layers/airQuality/source.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js"
], ],
"external": [] "external": []
}, },
@@ -4719,6 +4734,41 @@
], ],
"external": [] "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/i18n/marker.js",
"src/layers/droneAirspace/cards.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
"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",
"src/i18n/marker.js"
],
"external": []
},
"tools": { "tools": {
"exports": ["./tools"], "exports": ["./tools"],
"modules": [ "modules": [
+157
View File
@@ -0,0 +1,157 @@
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.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_VAL',
'UPPER_UOM',
'UPPER_CODE',
'LOWER_VAL',
'LOWER_UOM',
'LOWER_CODE',
'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;
/** 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 ||
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);
}
// 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}`;
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)) {
complete = true;
break;
}
if (!payload.features.length) break;
}
if (!complete) throw new Error('incomplete_snapshot');
const rows = normalizeDroneAirspaceSnapshot({ features });
if (!rows?.length) 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) {
// 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;
}
}
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));
};
// Keyless and needed to render the layer: refuse cross-site browsers
// before they spend a client's quota or start a refresh, but keep the
// documented reverse-proxy deployment working.
if (admitSameSiteKeyless(req, res)) return;
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);
},
};
}
+2
View File
@@ -28,6 +28,7 @@ import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js'; import { firePerimetersProxy } from './firePerimeters.js';
import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { ghostMapsProxy } from './ghostMaps.js'; import { ghostMapsProxy } from './ghostMaps.js';
import { droneAirspaceProxy } from './droneAirspace.js';
import { cycloneProxy } from './cyclones.js'; import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js'; import { windProxy } from './wind.js';
@@ -79,6 +80,7 @@ function localProviderPlugins({
firePerimetersProxy(), firePerimetersProxy(),
gnssIntegrityProxy(), gnssIntegrityProxy(),
ghostMapsProxy(), ghostMapsProxy(),
droneAirspaceProxy(),
keySetupEndpoint(), keySetupEndpoint(),
]; ];
} }
+4
View File
@@ -29,6 +29,7 @@ import { createApplicationAirQuality } from './layers/airQuality.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js'; import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js';
@@ -133,6 +134,9 @@ export function createApplicationCatalog({
windowMs: sources['gnss-integrity'].windowMs, windowMs: sources['gnss-integrity'].windowMs,
}), }),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationAlpr({ surface, source: sources.alpr }), createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({ createApplicationStreetLevel({
surface, surface,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal, signal: b.signal,
surface: fixtureSurface(b.signal), surface: fixtureSurface(b.signal),
}); });
assert.equal(first.layers.length, 36); assert.equal(first.layers.length, 37);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual( assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'), first.metadata.find(({ id }) => id === 'osm-places'),
@@ -67,6 +67,7 @@ test('catalogs construct distinct layers and classification from their supplied
assert.ok(first.get('fire-perimeters')); assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity')); assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('drone-airspace'));
assert.ok(first.get('transit')); assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id); const order = first.layers.map(({ id }) => id);
assert.deepEqual( assert.deepEqual(
@@ -160,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal), surface: fixtureSurface(lifetime.signal),
}; };
const catalog = createApplicationCatalog(options); const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 36); assert.equal(catalog.layers.length, 37);
assert.deepEqual(catalog.get('street-level').providerIds, []); assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal( assert.equal(
catalogControlServices(catalog).streetLevelLayer, catalogControlServices(catalog).streetLevelLayer,
+22
View File
@@ -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,
});
}
+1
View File
@@ -49,6 +49,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'air-quality': contract(['air-quality'], ['getSnapshot']), 'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']), cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
}); });
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t); const actual = catalog({}, t);
assert.equal(actual.layers.length, 36); assert.equal(actual.layers.length, 37);
const manager = new LayerLifecycle( const manager = new LayerLifecycle(
{}, {},
{ getSourceAvailability: actual.getSourceAvailability }, { getSourceAvailability: actual.getSourceAvailability },
+172
View File
@@ -0,0 +1,172 @@
/**
* @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, 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.
*/
/** @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;
}
/**
* 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
* 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. 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, fadeInFraction?: number}} pulse
* @param {{fadeMs: number}} echo
* @returns {number} 0 to 1.
*/
export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) {
// 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 =
(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);
}
// 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;
}
/**
* 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 level = field.level?.(nowMs) ?? 1;
if (!(level > 0)) return 0;
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
position,
groundScratch,
);
if (!ground) return 0;
return (
level *
awarenessEchoStrength(
nowMs,
Cesium.Cartesian3.distance(field.center, ground),
field.radiusM,
field.pulse,
field.echo,
)
);
}
/**
* 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`, 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} 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}
*/
export function awarenessEchoBracket(
layerId,
position,
nowMs,
bracketAlpha,
steps,
) {
const echo = awarenessEchoAt(layerId, position, nowMs);
if (!(echo > 0.01)) return null;
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
if (!(band >= 1)) return null;
return { band, 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;
}
+141
View File
@@ -0,0 +1,141 @@
// 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 {
awarenessEchoActive,
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);
// 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);
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:
// 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);
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,
);
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', () => {
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',
);
});
+7
View File
@@ -181,6 +181,13 @@ export const DATA_CREDITS = [
' · Incident information: ' + ' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>', '<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
}, },
{
key: 'faa-sua',
html:
'Prohibited & restricted airspace: ' +
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
' (prohibited and restricted zones; informational only)',
},
OSM_CREDIT, OSM_CREDIT,
OPENMAPTILES_CREDIT, OPENMAPTILES_CREDIT,
{ {
+45
View File
@@ -53,6 +53,12 @@ import {
isCyberContactSonarActive as isCyberSonarActive, isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive, isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js'; } from '../cyberSonar.js';
import {
awarenessEchoActive,
awarenessEchoBracket,
awarenessEchoColor,
getAwarenessEchoField,
} from '../awarenessEcho.js';
/** /**
* @module detection * @module detection
@@ -103,6 +109,8 @@ const SPARSE_ZONE_FRACTION = 0.5;
const LABEL_SOLVE_INTERVAL_MS = 125; const LABEL_SOLVE_INTERVAL_MS = 125;
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */ /** Alpha bands retain bracket batching while approximating a continuous radial fade. */
const BRACKET_ALPHA_STEPS = 4; 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. */ /** Stable per-layer candidate safety cap; independent of density and camera bearing. */
const LAYER_CANDIDATE_CAP = 2600; const LAYER_CANDIDATE_CAP = 2600;
const LAYER_WEIGHTS = Object.freeze({ const LAYER_WEIGHTS = Object.freeze({
@@ -1335,6 +1343,11 @@ function _drawOverlay(frame) {
const tiers = _theme.tiers || null; const tiers = _theme.tiers || null;
const colorFor = (key) => (tiers && tiers[key]) || _theme.line; const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map(); 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.
// 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 pathFor = (map, color, alpha) => {
const band = Math.max( const band = Math.max(
1, 1,
@@ -1437,6 +1450,24 @@ function _drawOverlay(frame) {
halfH, halfH,
); );
visibleCount++; visibleCount++;
if (echoPaths) {
// Starts on the contact's bracket and spreads outward as it fades.
const echo = awarenessEchoBracket(
obj._layerId,
obj.position,
now,
bracketAlpha,
ECHO_ALPHA_STEPS,
);
if (echo)
appendCornerBracket(
(echoPaths[echo.band] ??= new Path2D()),
sx,
sy,
halfW + echo.spreadPx,
halfH + echo.spreadPx,
);
}
if (obj.type === 'AIR') if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++; if (bracketAlpha >= 1) bracketOpacityCounts.full++;
@@ -1576,6 +1607,20 @@ function _drawOverlay(frame) {
_ctx.stroke(entry.path); _ctx.stroke(entry.path);
} }
} }
if (echoPaths) {
_ctx.lineWidth = echoField.echo.lineWidth;
_ctx.strokeStyle = awarenessEchoColor(
_theme.line,
_theme === THEME_MAP._default,
);
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 renderEntries = _labelArbiter.renderEntries(candidateMap, now);
const fadingCount = countFadingRenderEntries(renderEntries); const fadingCount = countFadingRenderEntries(renderEntries);
+107
View File
@@ -0,0 +1,107 @@
// 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, in the detection style's own colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoBracket,
awarenessEchoColor,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
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 };
/** 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) => {
t.after(clearAwarenessEchoField);
setAwarenessEchoField(field);
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
const arrival = arrivalAt(position);
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);
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);
// 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);
});
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
t.after(clearAwarenessEchoField);
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
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(
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);
}
});
+233 -3
View File
@@ -24,6 +24,10 @@ import {
setOverlayEntries, setOverlayEntries,
} from '../overlays/worldOverlay.js'; } from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { composeLabel, resolveTier } from './detectionDraw.js'; import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => { test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
@@ -50,8 +54,9 @@ class MockEvent {
} }
class MockPath2D { class MockPath2D {
moveTo() {} constructor() { this.points = []; }
lineTo() {} moveTo(x, y) { this.points.push([x, y]); }
lineTo(x, y) { this.points.push([x, y]); }
roundRect() {} roundRect() {}
arcTo() {} arcTo() {}
closePath() {} closePath() {}
@@ -98,7 +103,7 @@ function mockContext(target, trace) {
// set at some point during the frame. globalAlpha rides along for the same // set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour. // reason: the backdrop feather is an alpha, not a colour.
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); }, 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); }, fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); }, fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); }, drawImage(...args) { record('drawImage', ...args); },
@@ -627,6 +632,231 @@ 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('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
// 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;
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`);
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();
}
});
test('pathological detection paint holds alternate frames without freezing shared lanes', () => { test('pathological detection paint holds alternate frames without freezing shared lanes', () => {
// Opts into the telemetry banner: the held-frame assertions count banner paints. // Opts into the telemetry banner: the held-frame assertions count banner paints.
const env = installEnvironment({ search: '?detectDebug=1' }); const env = installEnvironment({ search: '?detectDebug=1' });
+292
View File
@@ -0,0 +1,292 @@
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', headers = {}) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.status ??= this.statusCode;
this.body = JSON.parse(body);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging follows offsets 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: calls.length < 3,
});
},
},
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, 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);
assert.equal(calls[1].searchParams.get('resultOffset'), '1');
});
}
/**
* 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;
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');
});
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');
});
test('a cross-site page is refused before it spends quota or reaches FAA', async () => {
let fetches = 0;
const request = install({
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature()] });
},
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, origin: 'https://evil.example' },
{ host, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refusals than the 60/min per-client quota, all from one peer.
for (let i = 0; i < 64; i++) {
const headers = refused[i % refused.length];
const res = await request('/', 'GET', '10.0.0.5', headers);
assert.equal(res.status, 403, JSON.stringify(headers));
}
assert.equal(fetches, 0, 'refusals cost no upstream call');
const app = await request('/', 'GET', '10.0.0.5', {
host,
origin: `http://${host}`,
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200, 'the app keeps its full quota');
assert.equal(fetches, 1);
});
test('the app served behind a reverse proxy still gets the zones', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
const host = 'gev.example.com';
const res = await request('/', 'GET', '127.0.0.1', {
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
'x-forwarded-proto': 'https',
});
assert.equal(res.status, 200);
assert.equal(res.body.rows.length, 1);
});
+4 -2
View File
@@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) {
roadType, roadType,
level, level,
closure, closure,
flow,
}; };
const key = cellKey( const key = cellKey(
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M), 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; const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
/** /**
* Nearest direction-compatible segment within 35 m of a projected point. * 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) { function nearest(px, py, bearing, radiusFor = null) {
const cx = Math.floor(px / CELL_SIZE_M); const cx = Math.floor(px / CELL_SIZE_M);
@@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) {
candidate = true; // within radius, bearing not yet checked candidate = true; // within radius, bearing not yet checked
if (radiusFor) { if (radiusFor) {
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg)); 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); const diff = bearingDiffDeg(seg.bearing, bearing);
if ( if (
+5
View File
@@ -598,6 +598,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
optionOwner: 'cctv', optionOwner: 'cctv',
}), }),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({
id: 'drone-airspace',
token: '8',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({ Object.freeze({
id: 'fire-perimeters', id: 'fire-perimeters',
+29 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => { test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true); assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 34); assert.equal(REGISTERED_LAYER_IDS.length, 35);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34); assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,18 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
} }
assert.equal(nextLayerStateToken(), '8'); assert.equal(nextLayerStateToken(), '9');
assert.equal( assert.equal(
nextLayerStateToken({ nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '8', alpha: '9',
bravo: '9', bravo: '00',
}), }),
'00', '01',
); );
const digitsExhausted = { const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])), ...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
}; };
assert.equal(nextLayerStateToken(digitsExhausted), '00'); assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal( assert.equal(
@@ -305,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal( assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
future: '8', future: '9',
next: '9', next: '00',
}), }),
true, true,
); );
@@ -316,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00', future: '00',
}), }),
/next free token 8/, /next free token 9/,
); );
const beforeLastDigit = { ...digitsExhausted }; const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9']; delete beforeLastDigit['prior-9'];
@@ -339,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws( assert.throws(
() => () =>
validateLayerStateAllocations( validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' }, { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' }, { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
), ),
/next free token 9/, /next free token 00/,
); );
assert.throws( assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }), () => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2694,6 +2694,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
); );
}); });
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
assert.deepEqual(decoded.enabledLayerIds, [
'drone-airspace',
'fire-perimeters',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token,
'8',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => { test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1')); const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [ assert.deepEqual(decoded.enabledLayerIds, [
+29 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) }, { ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS }, { ...LAYER_STATE_TOKEN_RESERVATIONS },
); );
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34); assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
assert.deepEqual( assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS }, { ...LAYER_STATE_TOKEN_RESERVATIONS },
{ {
@@ -139,6 +139,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
's2-ghostmaps': '5', 's2-ghostmaps': '5',
'sea-marks': '6', 'sea-marks': '6',
'ski-pistes': '7', 'ski-pistes': '7',
'drone-airspace': '8',
}, },
); );
assert.throws( assert.throws(
@@ -164,9 +165,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/, /Duplicate layer-state token reservation/,
); );
assert.equal( assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '8']]) parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
.future, .future,
'8', '9',
); );
assert.throws( assert.throws(
() => () =>
@@ -184,14 +185,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => { test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase( withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '8']] }, { rows: [...reservationRows, ['retired-layer', '9']] },
(cwd) => { (cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd); const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '9'); assert.equal(nextLayerStateToken(published), '00');
assert.equal( assert.equal(
validateLayerStateAllocations(published, { validateLayerStateAllocations(published, {
...published, ...published,
newcomer: '9', newcomer: '00',
}), }),
true, true,
); );
@@ -199,15 +200,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() => () =>
validateLayerStateAllocations(published, { validateLayerStateAllocations(published, {
...published, ...published,
competing: '8', competing: '9',
}), }),
/next free token 9/, /next free token 00/,
); );
assert.throws( assert.throws(
() => () =>
validateLayerStateAllocations(published, { validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '8', newcomer: '9',
}), }),
/changed or removed: retired-layer/, /changed or removed: retired-layer/,
); );
@@ -215,8 +216,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
); );
}); });
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => { test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => { withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts')); mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join( const fixtureChecker = path.join(
cwd, cwd,
@@ -237,8 +238,8 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
writeCodecFixture( writeCodecFixture(
cwd, cwd,
[...reservationRows, ['pr-b', '8']], [...reservationRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '8' }], [{ id: 'pr-b', token: '9' }],
); );
const beforeRebase = runCheck(); const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1); assert.equal(beforeRebase.status, 1);
@@ -246,10 +247,10 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
writeCodecFixture( writeCodecFixture(
cwd, cwd,
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']], [...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
[ [
{ id: 'pr-a', token: '8' }, { id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '8' }, { id: 'pr-b', token: '9' },
], ],
); );
const staleRebase = runCheck(); const staleRebase = runCheck();
@@ -257,16 +258,16 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd); const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '9'); assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...reservationRows, ['pr-a', '8']]; const validBaselineRows = [...reservationRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]); writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
const helper = spawnSync( const helper = spawnSync(
process.execPath, process.execPath,
[realpathSync(fixtureNext), 'pr-b'], [realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' }, { cwd, encoding: 'utf8' },
); );
assert.equal(helper.status, 0, helper.stderr); assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 9/); assert.match(helper.stdout, /pr-b: 00/);
assert.deepEqual( assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows, validBaselineRows,
@@ -274,20 +275,20 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
); );
writeCodecFixture( writeCodecFixture(
cwd, cwd,
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']], [...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
[ [
{ id: 'pr-a', token: '8' }, { id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' }, { id: 'pr-b', token: '00' },
], ],
); );
const corrected = runCheck(); const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr); assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /35 published, 1 new/); assert.match(corrected.stdout, /36 published, 1 new/);
}); });
}); });
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]); const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([ const allDigits = parseLayerStateTokenReservations([
...reservationRows, ...reservationRows,
...digitRows, ...digitRows,
@@ -339,7 +340,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 () => { 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-')); const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try { try {
const priorDigits = [...'89'].map((digit) => [ const priorDigits = [...'9'].map((digit) => [
`qa-prior-digit-${digit}`, `qa-prior-digit-${digit}`,
digit, digit,
]); ]);
@@ -376,7 +377,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]); const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state); codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 79); assert.equal(params.get('l').length, 81);
const restored = codec.decodeLayerStateParams(params); const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options); assert.deepEqual(restored?.options, state.options);
@@ -394,7 +395,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
}, },
(cwd) => { (cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd); const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 35); assert.equal(Object.keys(published).length, 36);
assert.equal(published['retired-layer'], '00'); assert.equal(published['retired-layer'], '00');
assert.throws( assert.throws(
() => () =>
@@ -7,6 +7,7 @@
["bikeshare", "b"], ["bikeshare", "b"],
["cctv", "c"], ["cctv", "c"],
["directions", "n"], ["directions", "n"],
["drone-airspace", "8"],
["earthquakes", "e"], ["earthquakes", "e"],
["fire-perimeters", "2"], ["fire-perimeters", "2"],
["flights", "f"], ["flights", "f"],
+13 -1
View File
@@ -3306,5 +3306,17 @@
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود", "counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
"Search cancelled": "أُلغي البحث", "Search cancelled": "أُلغي البحث",
"Sea Marks": "علامات بحرية", "Sea Marks": "علامات بحرية",
"Ski Pistes": "مسارات التزلج" "Ski Pistes": "مسارات التزلج",
"{lower} to {upper}": "{lower} إلى {upper}",
"Up to {upper}": "حتى {upper}",
"From {lower}": "من {lower}",
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
"Unnamed zone": "منطقة بلا اسم",
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
"Prohibited (P)": "محظور (P)",
"Restricted (R)": "مقيّد (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
"Prohibited": "محظور",
"Restricted": "مقيّد",
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد"
} }
+12
View File
@@ -2907,6 +2907,18 @@
"STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache", "STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache",
"routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig", "routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig",
"route proxy error": "Fehler des Routen-Proxys", "route proxy error": "Fehler des Routen-Proxys",
"{lower} to {upper}": "{lower} bis {upper}",
"Up to {upper}": "Bis {upper}",
"From {lower}": "Ab {lower}",
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
"Unnamed zone": "Unbenannte Zone",
"Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
"Prohibited (P)": "Sperrgebiet (P)",
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
"Prohibited": "Sperrgebiet",
"Restricted": "Flugbeschränkungsgebiet",
"Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete",
"scene|PLAY": "START", "scene|PLAY": "START",
"AUTHORING": "ERSTELLUNG", "AUTHORING": "ERSTELLUNG",
"Create and edit scenes": "Szenen erstellen und bearbeiten", "Create and edit scenes": "Szenen erstellen und bearbeiten",
+12
View File
@@ -2983,6 +2983,18 @@
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.", "Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"{lower} to {upper}": "{lower} a {upper}",
"Up to {upper}": "Hasta {upper}",
"From {lower}": "Desde {lower}",
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
"Unnamed zone": "Zona sin nombre",
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
"Prohibited (P)": "Prohibido (P)",
"Restricted (R)": "Restringido (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
"Prohibited": "Prohibido",
"Restricted": "Restringido",
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas", "route proxy error": "error del proxy de rutas",
"AUTHORING": "CREACIÓN", "AUTHORING": "CREACIÓN",
+12
View File
@@ -2977,6 +2977,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
"Malformed cyclone snapshot": "Instantané cyclonique mal formé", "Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique", "Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"{lower} to {upper}": "{lower} à {upper}",
"Up to {upper}": "Jusqu’à {upper}",
"From {lower}": "À partir de {lower}",
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
"Unnamed zone": "Zone sans nom",
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
"Prohibited (P)": "Zone interdite (P)",
"Restricted (R)": "Zone réglementée (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
"Prohibited": "Zone interdite",
"Restricted": "Zone réglementée",
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
"scene|PLAY": "LIRE", "scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION", "AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes", "Create and edit scenes": "Créer et modifier des scènes",
+12
View File
@@ -2977,6 +2977,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido", "Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout", "Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
"{lower} to {upper}": "da {lower} a {upper}",
"Up to {upper}": "Fino a {upper}",
"From {lower}": "Da {lower}",
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
"Unnamed zone": "Zona senza nome",
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
"Prohibited (P)": "Zona proibita (P)",
"Restricted (R)": "Zona regolamentata (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
"Prohibited": "Zona proibita",
"Restricted": "Zona regolamentata",
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
"scene|PLAY": "AVVIA", "scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE", "AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene", "Create and edit scenes": "Crea e modifica le scene",
+12
View File
@@ -2837,6 +2837,18 @@
"route proxy error": "ルート検索プロキシでエラーが発生しました", "route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"{lower} to {upper}": "{lower}~{upper}",
"Up to {upper}": "{upper}まで",
"From {lower}": "{lower}から",
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
"Unnamed zone": "名称なしのゾーン",
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
"Prohibited (P)": "飛行禁止区域 (P)",
"Restricted (R)": "制限区域 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
"Prohibited": "飛行禁止区域",
"Restricted": "制限区域",
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
"scene|PLAY": "再生", "scene|PLAY": "再生",
"AUTHORING": "作成", "AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集", "Create and edit scenes": "シーンを作成・編集",
+12
View File
@@ -2977,6 +2977,18 @@
"Don't show this again": "Não mostrar novamente", "Don't show this again": "Não mostrar novamente",
"ESC to dismiss": "ESC para fechar", "ESC to dismiss": "ESC para fechar",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.",
"{lower} to {upper}": "{lower} a {upper}",
"Up to {upper}": "Até {upper}",
"From {lower}": "A partir de {lower}",
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
"Unnamed zone": "Zona sem nome",
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
"Prohibited (P)": "Área proibida (P)",
"Restricted (R)": "Área restrita (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
"Prohibited": "Área proibida",
"Restricted": "Área restrita",
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
"scene|PLAY": "INICIAR", "scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO", "AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas", "Create and edit scenes": "Criar e editar cenas",
+12
View File
@@ -3053,6 +3053,18 @@
"Voice replies appear here.": "Здесь появляются голосовые ответы.", "Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.", "Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"{lower} to {upper}": "от {lower} до {upper}",
"Up to {upper}": "До {upper}",
"From {lower}": "От {lower}",
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
"Unnamed zone": "Зона без названия",
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
"Prohibited (P)": "Запретная зона (P)",
"Restricted (R)": "Зона ограничения (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
"Prohibited": "Запретная зона",
"Restricted": "Зона ограничения",
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов", "route proxy error": "ошибка прокси маршрутов",
"AUTHORING": "РЕДАКТОР", "AUTHORING": "РЕДАКТОР",
+12
View File
@@ -3045,6 +3045,18 @@
"Don't show this again": "Більше не показувати", "Don't show this again": "Більше не показувати",
"ESC to dismiss": "ESC — приховати", "ESC to dismiss": "ESC — приховати",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
"{lower} to {upper}": "від {lower} до {upper}",
"Up to {upper}": "До {upper}",
"From {lower}": "Від {lower}",
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
"Unnamed zone": "Зона без назви",
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
"Prohibited (P)": "Заборонена зона (P)",
"Restricted (R)": "Зона обмеження (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
"Prohibited": "Заборонена зона",
"Restricted": "Зона обмеження",
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
"Malformed cyclone snapshot": "Некоректний знімок циклонів", "Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів", "Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ", "scene|PLAY": "СТАРТ",
+12
View File
@@ -2843,6 +2843,18 @@
"other": "{count} 个地标" "other": "{count} 个地标"
}, },
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起",
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
"Unnamed zone": "未命名区域",
"Restricted airspace source unavailable": "受限空域数据源不可用",
"Prohibited (P)": "禁区 (P)",
"Restricted (R)": "限制区 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
"Prohibited": "禁区",
"Restricted": "限制区",
"Prohibited & Restricted Airspace": "禁区与限制区空域",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多", "routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错", "route proxy error": "路线代理出错",
"AUTHORING": "创作", "AUTHORING": "创作",
+12
View File
@@ -2845,6 +2845,18 @@
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯", "route proxy error": "路線代理出錯",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起",
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
"Unnamed zone": "未命名區域",
"Restricted airspace source unavailable": "限制空域資料來源無法使用",
"Prohibited (P)": "禁航區 (P)",
"Restricted (R)": "限航區 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
"Prohibited": "禁航區",
"Restricted": "限航區",
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
"AUTHORING": "創作", "AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景", "Create and edit scenes": "創建和編輯場景",
"SCENE": "場景", "SCENE": "場景",
+104
View File
@@ -1,4 +1,5 @@
import { cameraPoseSignature } from '../../data/iconOrientation.js'; import { cameraPoseSignature } from '../../data/iconOrientation.js';
import { AWARENESS_RANGE_PULSE } from './policy.js';
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
@@ -8,6 +9,86 @@ export function createLifecycle({
}) { }) {
const { holdContinuousRender, releaseContinuousRender } = services.render; 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. 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();
if (event?.type === 'pointerdown') activePointers.add(event.pointerId);
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;
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;
}
const CAMERA_INPUT_EVENTS = [
'pointerdown',
'pointermove',
'pointerup',
'wheel',
'touchstart',
];
/** /**
* Quantized camera-pose signature, or '' when the camera cannot report a full * 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 * pose yet. An unknown pose reads as PARKED, so a camera that is still coming
@@ -50,10 +131,18 @@ export function createLifecycle({
'gev:awareness-subject-cleared', 'gev:awareness-subject-cleared',
layerState.subjectClearListener, 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.preRenderRemover =
layerState.viewer.scene.preRender.addEventListener(() => { layerState.viewer.scene.preRender.addEventListener(() => {
if (!layerState.enabled) return; if (!layerState.enabled) return;
const now = Date.now(); 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 // "Did the camera move" uses the SAME quantized pose signature the fleet
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
// camera.changed, whose granularity is globally degraded by other layers // camera.changed, whose granularity is globally degraded by other layers
@@ -62,6 +151,14 @@ export function createLifecycle({
if (poseSig !== layerState.lastCameraPoseSig) { if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now; layerState.lastCameraPoseChangeMs = now;
// 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({ const decision = parts.model.awarenessRefreshDecision({
nowMs: now, nowMs: now,
@@ -108,6 +205,13 @@ export function createLifecycle({
'gev:awareness-subject-cleared', 'gev:awareness-subject-cleared',
layerState.subjectClearListener, layerState.subjectClearListener,
); );
for (const type of CAMERA_INPUT_EVENTS)
layerState.cameraInputTarget?.removeEventListener?.(
type,
noteCameraInput,
);
layerState.cameraInputTarget = null;
followGesture(false);
} }
layerState.preRenderRemover?.(); layerState.preRenderRemover?.();
layerState.preRenderRemover = null; layerState.preRenderRemover = null;
+87
View File
@@ -44,6 +44,93 @@ export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
export const CONTEXT_RIM_HEIGHT_M = 2500; 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.
* 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
* 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)',
outlineColor: 'rgba(4, 12, 16, 0.8)',
width: 4,
outlineWidth: 1,
segments: 128,
flashMs: 1800,
restOpacity: 0.4,
});
/** @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.
* 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 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. 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, 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
* 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,
startFraction: 0.02,
fadeInFraction: 0.08,
fadeOutPower: 1.5,
endOpacityFraction: 0.35,
peakOpacity: 0.6,
width: 3,
outlineWidth: 1,
gradientDepthFraction: 0.35,
gradientOpacity: 0.22,
gradientGranularityDeg: 0.25,
gradientRebuildM: 20000,
activeMs: 9000,
settleMs: 1200,
inputWakeMs: 1500,
});
/** @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; export const VESSEL_FOCUS_RADIUS_M = 3000;
export const SOURCE_LABEL = { export const SOURCE_LABEL = {
File diff suppressed because it is too large. Load diff
+594 -22
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { import {
CYBER_SONAR_RENDER_INTERVAL_MS,
createCyberSonarSampler, createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive, isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js'; } from '../../cyberSonar.js';
@@ -10,8 +11,339 @@ import {
AWARENESS_RADIUS_M, AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js'; } from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.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 { formatAwarenessDirection } from './presentation.js';
import {
awarenessEchoStrength,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../../awarenessEcho.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 ~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.
* @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 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;
const cosB = Math.cos(bearing);
const sinB = Math.sin(bearing);
const angular =
radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical);
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 lat = Math.atan2(z, Math.hypot(x, y));
const lon = Math.atan2(y, x);
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
}
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),
};
}
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/** Whether the viewer asked the system for less motion. */
function prefersReducedMotion() {
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;
}
/**
* 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 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
// 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 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;
band += alphas[i] * t * t;
}
}
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 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.
* @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,
});
}
/**
* 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,
});
}
/**
* 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;
}
export function createRendering({ export function createRendering({
state: layerState, state: layerState,
@@ -271,7 +603,62 @@ export function createRendering({
presentDirection({ hidden: false, ring, heading, labels, markers }); 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) return;
wakeAwarenessPulses(wake, performance.now());
followPulseDisc();
}
/**
* 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 (
Cesium.Cartesian3.distance(gradientCenter, center) <
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),
);
// Primitive.destroy leaves the material alone, so the new disc keeps
// sharing it.
primitives.remove(gradient);
Cesium.Cartesian3.clone(center, gradientCenter);
}
function clearVisual() { function clearVisual() {
layerState.visual?.stopPulses?.();
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) { if (layerState.visual?.entities && layerState.viewer) {
for (const entity of layerState.visual.entities) for (const entity of layerState.visual.entities)
layerState.viewer.entities.remove(entity); layerState.viewer.entities.remove(entity);
@@ -291,17 +678,39 @@ export function createRendering({
0, 0,
); );
const key = `${subject.layerId}:${subject.id}`; const key = `${subject.layerId}:${subject.id}`;
if (layerState.visual?.key === key) { const reducedMotion = prefersReducedMotion();
// Large ellipse geometry is more reliable as a ConstantPositionProperty. // A reduced-motion change rebuilds the visual, with or without pulses.
// Move it only after a meaningful displacement so live tracking does not if (
// churn ten large geometries for sub-pixel motion every 750 ms. 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 ( if (
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >= Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
250 250
) { ) {
for (const entity of layerState.visual.entities) const positions = awarenessRangeRingPositions(
entity.position.setValue(groundCenter); groundCenter,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
// The echo field reads `visual.center` in place and follows on its
// 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) {
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); 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)
followPulseDisc();
// Content actually changed — buy exactly one frame instead of holding. // Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual'); governorRequestRender('awareness-visual');
} }
@@ -310,26 +719,188 @@ export function createRendering({
clearVisual(); clearVisual();
const entities = []; const entities = [];
const scene = layerState.viewer.scene;
entities.push( // A viewer without a primitive collection or a preRender hook (headless
layerState.viewer.entities.add({ // tools, partial stubs) gets the static ring only, as the Cyber sonar
position: groundCenter, // scene does. So does a viewer who prefers reduced motion.
ellipse: { const animate = Boolean(
semiMajorAxis: AWARENESS_RADIUS_M, !reducedMotion &&
semiMinorAxis: AWARENESS_RADIUS_M, scene?.primitives?.add &&
fill: false, scene?.preRender?.addEventListener &&
outline: true, scene?.postRender?.addEventListener,
outlineColor:
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
height: CONTEXT_RIM_HEIGHT_M,
},
}),
); );
// Pulses run from now until `wake.untilMs`, then rest.
const wake = animate
? wakeAwarenessPulses(
{ startMs: -Infinity, untilMs: -Infinity },
performance.now(),
)
: null;
// 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.
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 ringAlpha = (nowMs) => {
const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
return 1 - levelAt(nowMs) * (1 - pulsed);
};
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;
let stopPulses = null;
if (animate) {
const pulse = AWARENESS_RANGE_PULSE;
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;
// 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: {
type: AWARENESS_PULSE_MATERIAL_TYPE,
uniforms,
source: PULSE_GRADIENT_SOURCE,
},
translucent: true,
});
Object.assign(material.uniforms, uniforms);
gradient = scene.primitives.add(
createPulseGradient(groundCenter, material),
);
const radii = [0, 0, 0, 0];
const alphas = [0, 0, 0, 0];
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.
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 * 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 && !belowRim())
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({
center,
radiusM: AWARENESS_RADIUS_M,
pulse,
echo: AWARENESS_RANGE_ECHO,
level: levelAt,
});
}
// 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);
};
// Safari before 14 has only the older addListener / removeListener.
if (motionQuery?.addEventListener)
motionQuery.addEventListener('change', onMotionChange);
else motionQuery?.addListener?.(onMotionChange);
layerState.visual = { layerState.visual = {
key, key,
entities, entities,
center: Cesium.Cartesian3.clone(groundCenter), ring: ringEntity,
ringPrimitive,
ringPositions,
center,
gradient,
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
reducedMotion,
wake,
stopPulses,
removeMotionListener: () => {
if (motionQuery?.removeEventListener)
motionQuery.removeEventListener('change', onMotionChange);
else motionQuery?.removeListener?.(onMotionChange);
},
}; };
governorRequestRender('awareness-visual'); governorRequestRender('awareness-visual');
} }
@@ -343,5 +914,6 @@ export function createRendering({
updateDirectionOverlay, updateDirectionOverlay,
clearVisual, clearVisual,
renderVisual, renderVisual,
wakePulses,
}; };
} }
+4
View File
@@ -59,6 +59,10 @@ export function createState({ services }) {
lastCameraPoseSig: '', lastCameraPoseSig: '',
/** When the pose signature last changed bins (hysteresis anchor). */ /** When the pose signature last changed bins (hysteresis anchor). */
lastCameraPoseChangeMs: 0, 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). */ /** Whether the view currently counts as moving (hysteretic, not per-frame). */
cameraMoving: false, cameraMoving: false,
lastEvaluatedPosition: null, lastEvaluatedPosition: null,
+96
View File
@@ -0,0 +1,96 @@
/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */
import { t } from '../../i18n/index.js';
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<Array<Array<[number, number]>>>} 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.lowerAltitude;
const upper = row.upperAltitude;
if (lower && upper) return t('{lower} to {upper}', { lower, upper });
if (upper) return t('Up to {upper}', { upper });
if (lower) return t('From {lower}', { 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 ? t(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(t('Informational only · not for flight planning'));
const type = row.type ? t(row.type) : 'SUA';
const title = `${type.toUpperCase()} · ${row.name || t('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',
};
}
+74
View File
@@ -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',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
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 to 18,000 ft MSL · 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',
lowerAltitude: null,
upperAltitude: 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'));
});
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js';
import { ANALYST_LAYERS } from '../../data/analystEngine.js';
const read = (path) =>
readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
const currentState = read('docs/CURRENT-STATE.md');
const section = currentState.slice(
currentState.indexOf('## Prohibited & Restricted Airspace'),
currentState.indexOf('\n## ', currentState.indexOf('## Prohibited')) + 1,
);
const tokens = JSON.parse(read('src/data/layerStateTokenReservations.json'));
test('CURRENT-STATE names the reserved share-link token', () => {
const token = JSON.stringify(tokens).match(
/\["drone-airspace","([^"]+)"\]/,
)?.[1];
assert.ok(token, 'drone-airspace has a reserved token');
assert.match(section, new RegExp(`share-link token \`${token}\``));
});
test('CURRENT-STATE matches the layer being outside voice and the analyst', () => {
assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'drone-airspace'));
assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'drone-airspace'));
assert.doesNotMatch(section, /Voice can toggle/);
assert.match(section, /Voice is off for this layer for now/);
const analyst = currentState.match(/Analyst records come on demand[^\n]*/)[0];
assert.doesNotMatch(analyst, /drone/i);
});
test('CURRENT-STATE states what the layer does not cover', () => {
assert.match(section, /UAS Facility Map/);
assert.match(section, /LAANC/);
assert.match(section, /TFRs/);
});
+366
View File
@@ -0,0 +1,366 @@
import * as Cesium from 'cesium';
import { msg, t } from '../../i18n/index.js';
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));
/**
* 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,
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 _stale = false;
let _enabled = false;
let _clickHandler = null;
let _selectedId = null;
let _selectedCardId = null;
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
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: 'Prohibited & Restricted Airspace',
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;
_stale = false;
_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 {
// 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();
// 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,
geometry: geometryHash(polygons),
}))
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = updatedAt;
_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 = updatedAt;
_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 || msg('Restricted airspace source unavailable');
// A failure now outranks the stale snapshot still drawn.
_stale = false;
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;
_stale = false;
},
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: t('Prohibited (P)'), color: droneAirspaceAccent('P') },
{ label: t('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: t(
'FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).',
),
}
: {}),
})),
};
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
};
},
};
return layer;
}
+218
View File
@@ -0,0 +1,218 @@
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 = [];
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',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
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/);
});
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');
});
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);
});
+135
View File
@@ -0,0 +1,135 @@
/**
* 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.
*/
import { msg } from '../../i18n/marker.js';
const TYPE_LABELS = Object.freeze({
P: msg('Prohibited'),
R: msg('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;
};
/** FAA marks line breaks in TIMESOFUSE with `%`; read them as `; ` separators. */
const timesOfUseOrNull = (value) =>
textOrNull(
typeof value === 'string'
? value.replace(/\s*;?\s*%\s*/g, '; ').replace(/^[;\s]+|[;\s]+$/g, '')
: value,
);
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;
}
/**
* One SUA altitude limit from its VAL/UOM/CODE triple, in aviation notation:
* `SFC`, `500 ft AGL`, `5,000 ft MSL`, `FL180` or `Unlimited`. The *_DESC
* fields are inclusion qualifiers (TI/TNI/ANI), not altitudes. Returns null
* when the triple does not describe a limit.
*/
export function droneAirspaceAltitude(value, uom, code) {
const unit = textOrNull(uom)?.toUpperCase() ?? null;
const reference = textOrNull(code)?.toUpperCase() ?? null;
if (reference === 'UNLTD') return 'Unlimited';
const number = Number(value);
if (value == null || value === '' || !Number.isFinite(number) || number < 0)
return null;
if (unit === 'FL' || reference === 'STD') return `FL${Math.round(number)}`;
const feet = `${Math.round(number).toLocaleString('en-US')} ft`;
if (reference === 'SFC') return number === 0 ? 'SFC' : `${feet} AGL`;
if (reference === 'MSL') return `${feet} MSL`;
return unit === 'FT' || unit == null ? feet : null;
}
/** 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,
lowerAltitude: droneAirspaceAltitude(
properties.LOWER_VAL,
properties.LOWER_UOM,
properties.LOWER_CODE,
),
upperAltitude: droneAirspaceAltitude(
properties.UPPER_VAL,
properties.UPPER_UOM,
properties.UPPER_CODE,
),
timesOfUse: timesOfUseOrNull(properties.TIMESOFUSE),
agency: textOrNull(properties.CONT_AGENT),
city: textOrNull(properties.CITY),
state: textOrNull(properties.STATE),
polygons,
});
}
return rows;
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
droneAirspaceAltitude,
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',
UPPER_VAL: '18000',
UPPER_UOM: 'FT',
UPPER_CODE: 'MSL',
LOWER_DESC: null,
LOWER_VAL: '0',
LOWER_UOM: 'FT',
LOWER_CODE: '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',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
timesOfUse: 'CONTINUOUS',
agency: 'WASHINGTON CENTER',
city: 'WASHINGTON',
state: 'DC',
polygons: [[ring]],
});
assert.equal(rows[1].type, 'Restricted');
assert.equal(rows[1].typeCode, 'R');
});
test('altitudes come from VAL/UOM/CODE, not the TI/TNI/ANI qualifiers', () => {
// P-40 and R-2508 as the FAA service publishes them.
assert.equal(droneAirspaceAltitude('0', 'FT', 'SFC'), 'SFC');
assert.equal(droneAirspaceAltitude('5000', 'FT', 'MSL'), '5,000 ft MSL');
assert.equal(droneAirspaceAltitude('20000', 'FT', 'MSL'), '20,000 ft MSL');
assert.equal(droneAirspaceAltitude('-9998', null, 'UNLTD'), 'Unlimited');
assert.equal(droneAirspaceAltitude('1500', 'FT', 'SFC'), '1,500 ft AGL');
assert.equal(droneAirspaceAltitude('180', 'FL', 'STD'), 'FL180');
assert.equal(droneAirspaceAltitude('10000', 'FT', null), '10,000 ft');
assert.equal(droneAirspaceAltitude(null, null, null), null);
assert.equal(droneAirspaceAltitude('-9998', 'FT', 'MSL'), null);
const [row] = normalizeDroneAirspaceSnapshot({
features: [
feature({
properties: {
NAME: 'R-2508',
TYPE_CODE: 'R',
OBJECTID: 400,
UPPER_DESC: null,
UPPER_VAL: '-9998',
UPPER_UOM: null,
UPPER_CODE: 'UNLTD',
LOWER_VAL: '20000',
LOWER_UOM: 'FT',
LOWER_CODE: 'MSL',
},
}),
],
});
assert.equal(row.lowerAltitude, '20,000 ft MSL');
assert.equal(row.upperAltitude, 'Unlimited');
});
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);
});
test("FAA's % line-break markers in times of use read as separators", () => {
const timesOfUse = (TIMESOFUSE) =>
normalizeDroneAirspaceSnapshot({
features: [
feature({
properties: {
NAME: 'R-2508',
TYPE_CODE: 'R',
OBJECTID: 400,
TIMESOFUSE,
},
}),
],
})[0].timesOfUse;
// The shape 56 live records carry: a marker after the semicolon.
assert.equal(
timesOfUse(
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; %OTHER TIMES BY NOTAM',
),
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; OTHER TIMES BY NOTAM',
);
assert.equal(
timesOfUse('SR - SS DAILY %OTHER TIMES BY NOTAM'),
'SR - SS DAILY; OTHER TIMES BY NOTAM',
);
assert.equal(timesOfUse('%CONTINUOUS%'), 'CONTINUOUS');
assert.equal(timesOfUse(' % '), null);
});
+38
View File
@@ -0,0 +1,38 @@
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),
} = {}) {
/**
* 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 {
/** The zone rows (see getSnapshotDetail for staleness). */
async getSnapshot(options = {}) {
return (await getSnapshotDetail(options)).rows;
},
getSnapshotDetail,
};
}
+119
View File
@@ -0,0 +1,119 @@
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/,
);
}
});
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);
});
+17 -12
View File
@@ -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. */ /** Own one bounded decoded flow cache; every request still passes the server tile budget. */
export function createFlowTileSource({ export function createFlowTileSource({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
@@ -41,12 +54,7 @@ export function createFlowTileSource({
entry?.release(); entry?.release();
const segments = decodeFlowTile(bytes, z, x, y, { strict: true }) const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
.filter(isDrivableFlowSegment) .filter(isDrivableFlowSegment)
.flatMap((segment) => .flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y)));
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
...segment,
coords,
})),
);
if (mark) freshness.set(segments, mark); if (mark) freshness.set(segments, mark);
return segments; return segments;
}, },
@@ -87,12 +95,9 @@ export function createFlowTileSource({
partial = result.partial; partial = result.partial;
const segments = bounds.coverage const segments = bounds.coverage
? result.tiles.flat() ? result.tiles.flat()
: result.tiles.flat().flatMap((segment) => : result.tiles
clipTileLine(segment.coords, bounds).map((coords) => ({ .flat()
...segment, .flatMap((segment) => clipFlowSegment(segment, bounds));
coords,
})),
);
const marks = result.tiles.map((tile) => freshness.get(tile) || {}); const marks = result.tiles.map((tile) => freshness.get(tile) || {});
const times = marks const times = marks
.map((mark) => mark.fetchedAt) .map((mark) => mark.fetchedAt)
+73 -8
View File
@@ -35,6 +35,14 @@ const MIN_FILL_M = 40;
* Same-class overlap keeps the full 35 m matcher radius. * Same-class overlap keeps the full 35 m matcher radius.
*/ */
const CLASS_MISMATCH_RADIUS_M = 15; 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_FLOW = new Set(['Motorway', 'International road']);
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']); const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
@@ -85,20 +93,69 @@ 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;
const line = segment.line || segment;
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,
// 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++;
}
return paired / PAIRED_SIDE_SAMPLES.length;
}
/** /**
* Convert decoded TomTom flow lines into road records that carry their own * Convert decoded TomTom flow lines into road records that carry their own
* flow. A line with `coverage: 'full'` describes both travel directions; * 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 * @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
* @returns {Array<object>} Road records (`directFlow: true`). * @returns {Array<object>} Road records (`directFlow: true`).
*/ */
export function flowSegmentsToRoads(segments) { export function flowSegmentsToRoads(segments) {
return (segments || []) const drivable = (segments || []).filter(
.filter( (segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
(segment) => );
segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), const sides = createFlowSegmentIndex(
) drivable.filter((segment) => segment.coverage !== 'full'),
.map((segment) => ({ );
return drivable.map((segment) => {
const road = {
coordinates: segment.coords, coordinates: segment.coords,
type: FLOW_ROAD_TYPES[segment.roadType], type: FLOW_ROAD_TYPES[segment.roadType],
drivable: true, drivable: true,
@@ -110,7 +167,15 @@ export function flowSegmentsToRoads(segments) {
oneway: segment.coverage === 'full' ? 0 : 1, oneway: segment.coverage === 'full' ? 0 : 1,
flow: { level: segment.trafficLevel, closure: segment.closure === true }, flow: { level: segment.trafficLevel, closure: segment.closure === true },
directFlow: 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]. */ /** Cut a projected polyline to the arc-length interval [from, to]. */
+130
View File
@@ -1,10 +1,12 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PbfWriter } from 'pbf';
import { import {
flowSegmentsToRoads, flowSegmentsToRoads,
resolveRoadMode, resolveRoadMode,
selectTrafficRoads, selectTrafficRoads,
} from './roadModes.js'; } from './roadModes.js';
import { createFlowTileSource } from './flowSource.js';
import { createTrafficSource } from './source.js'; import { createTrafficSource } from './source.js';
import { createTrafficLayer } from './index.js'; import { createTrafficLayer } from './index.js';
import { import {
@@ -14,6 +16,7 @@ import {
serializeStoredLayerState, serializeStoredLayerState,
parseStoredLayerState, parseStoredLayerState,
} from '../../data/layerState.js'; } from '../../data/layerState.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
const coordinates = [ const coordinates = [
[-97.744, 30.267], [-97.744, 30.267],
@@ -116,6 +119,133 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', ()
assert.equal(roads.length, 1); assert.equal(roads.length, 1);
assert.equal(roads[0].directFlow, true); 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('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);
});
/** 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', () => { test('bearing and distance reject nearby crossing and separate OSM roads', () => {
const parallel = { const parallel = {
...main, ...main,
+2
View File
@@ -1,6 +1,7 @@
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
import { createEcccAirQualitySource } from '../layers/airQuality/source.js'; import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js';
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
import { createAdsbGnssSource } from '../layers/gnss/source.js'; import { createAdsbGnssSource } from '../layers/gnss/source.js';
@@ -10,6 +11,7 @@ export function createReferenceSources() {
earthquakes: createUsgsEarthquakeSource(), earthquakes: createUsgsEarthquakeSource(),
'air-quality': createEcccAirQualitySource(), 'air-quality': createEcccAirQualitySource(),
'fire-perimeters': createWfigsPerimeterSource(), 'fire-perimeters': createWfigsPerimeterSource(),
'drone-airspace': createDroneAirspaceSource(),
cables: createBundledCableSource(), cables: createBundledCableSource(),
'gnss-integrity': createAdsbGnssSource(), 'gnss-integrity': createAdsbGnssSource(),
}; };
+3
View File
@@ -16,17 +16,20 @@ test('reference factories retain compatibility without starting acquisition or s
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'fire-perimeters', 'fire-perimeters',
'drone-airspace',
'cables', 'cables',
'gnss-integrity', 'gnss-integrity',
]); ]);
assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first.earthquakes, second.earthquakes);
assert.notEqual(first['air-quality'], second['air-quality']); assert.notEqual(first['air-quality'], second['air-quality']);
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
assert.notEqual(first['drone-airspace'], second['drone-airspace']);
assert.notEqual(first.cables, second.cables); assert.notEqual(first.cables, second.cables);
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']); assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first.earthquakes.getSnapshot, 'function');
assert.equal(typeof first['air-quality'].getSnapshot, 'function'); assert.equal(typeof first['air-quality'].getSnapshot, 'function');
assert.equal(typeof first['fire-perimeters'].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(typeof first.cables.fetch, 'function');
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function'); assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
assert.equal(requests, 0); assert.equal(requests, 0);
+1
View File
@@ -15,6 +15,7 @@ test('data providers have both hooks; credential editing stays development-only'
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
assert.ok(plugins.some(({ name }) => name === 'ghostmaps')); assert.ok(plugins.some(({ name }) => name === 'ghostmaps'));
assert.ok(plugins.some(({ name }) => name === 'drone-airspace'));
for (const plugin of plugins) { for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') { if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined); assert.equal(plugin.configurePreviewServer, undefined);
+1 -1
View File
@@ -695,7 +695,7 @@ test('public transit documentation uses courtesy attribution and the approved gr
); );
assert.ok( assert.ok(
readme.includes( readme.includes(
'Twenty layers and map sources. **Eighteen have a keyless path.**', 'Twenty-one layers and map sources. **Nineteen have a keyless path.**',
), ),
); );
assert.ok( assert.ok(
+1
View File
@@ -35,6 +35,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'recent-imagery': msg('Recent Imagery'), 'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'), 'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'), 'gnss-integrity': msg('GNSS Integrity'),
'drone-airspace': msg('Prohibited & Restricted Airspace'),
's2-ghostmaps': msg('S2 GhostMaps'), 's2-ghostmaps': msg('S2 GhostMaps'),
wind: msg('Wind'), wind: msg('Wind'),
'weather-radar': msg('Rain radar'), 'weather-radar': msg('Rain radar'),
+1
View File
@@ -61,6 +61,7 @@ const PANEL_GROUPS = [
ids: [ ids: [
'alpr-cameras', 'alpr-cameras',
'military-installations', 'military-installations',
'drone-airspace',
'local-datacenters', 'local-datacenters',
'telegeography-submarine-cables', 'telegeography-submarine-cables',
'local-dams', 'local-dams',
+4
View File
@@ -453,6 +453,10 @@ const layers = [
export const VOICE_OFF_LAYERS = Object.freeze({ export const VOICE_OFF_LAYERS = Object.freeze({
'bhote-koshi-2026': 'scene-driven event layer (control_scene)', 'bhote-koshi-2026': 'scene-driven event layer (control_scene)',
'bhote-koshi-locator': 'scene-driven event layer (control_scene)', 'bhote-koshi-locator': 'scene-driven event layer (control_scene)',
// The Realtime prompt is at its 52,000-character budget; voice toggle and
// query for this layer wait until there is room.
'drone-airspace':
'informational airspace layer; no room in the prompt budget',
}); });
const TYPE_NAMES = Object.freeze({ const TYPE_NAMES = Object.freeze({