From ffb332472d00e21aceb6d24e4f3ae97c8dbcff78 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:35:29 +0000 Subject: [PATCH] feat(layers): add FAA drone restrictions airspace layer Show US prohibited and restricted special-use airspace from the keyless FAA SUA FeatureServer as an off-by-default Infrastructure layer, with share-link token 3 and an informational-only disclaimer. --- CHANGELOG.md | 8 + DATA_SOURCES.md | 2 + README.md | 3 +- docs/CURRENT-STATE.md | 25 +- package.json | 2 + scripts/format-scope.json | 11 + scripts/package-boundaries.json | 32 ++ server/providers/droneAirspace.js | 135 ++++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 4 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/droneAirspace.js | 22 ++ src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 7 + src/data/droneAirspaceProxy.test.mjs | 146 +++++++++ src/data/layerState.js | 5 + src/data/layerState.test.mjs | 34 +- src/data/layerStateTokenLedger.test.mjs | 62 ++-- src/data/layerStateTokenReservations.json | 1 + src/firstRunExperience.test.mjs | 5 +- src/layers/droneAirspace/cards.js | 93 ++++++ src/layers/droneAirspace/cards.test.mjs | 74 +++++ src/layers/droneAirspace/index.js | 326 ++++++++++++++++++++ src/layers/droneAirspace/ownership.test.mjs | 161 ++++++++++ src/layers/droneAirspace/records.js | 97 ++++++ src/layers/droneAirspace/records.test.mjs | 95 ++++++ src/layers/droneAirspace/source.js | 23 ++ src/layers/droneAirspace/source.test.mjs | 105 +++++++ src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/tooling/previewServing.test.mjs | 1 + src/ui/layerPanel.js | 1 + src/voice/layerManifest.js | 24 ++ src/voice/layerManifest.test.mjs | 128 ++++++-- 35 files changed, 1573 insertions(+), 74 deletions(-) create mode 100644 server/providers/droneAirspace.js create mode 100644 src/app/layers/droneAirspace.js create mode 100644 src/data/droneAirspaceProxy.test.mjs create mode 100644 src/layers/droneAirspace/cards.js create mode 100644 src/layers/droneAirspace/cards.test.mjs create mode 100644 src/layers/droneAirspace/index.js create mode 100644 src/layers/droneAirspace/ownership.test.mjs create mode 100644 src/layers/droneAirspace/records.js create mode 100644 src/layers/droneAirspace/records.test.mjs create mode 100644 src/layers/droneAirspace/source.js create mode 100644 src/layers/droneAirspace/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..90402cad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- Add a Drone Restrictions layer for FAA prohibited (P) and restricted (R) + special-use airspace. The keyless `/api/drone-airspace` proxy pages the + public SUA FeatureServer, keeps a one-hour snapshot, and serves stale data + when a refresh fails. Zones draw as ground polygons (red prohibited, orange + restricted), selection shows name, altitudes, times of use and agency, and + the row legend states the layer is informational only. Off by default; + share-link token `3`. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b8eac358..2aa957e2b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,6 +35,7 @@ How to read this: | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | +| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Drone Restrictions layer: US prohibited (P) and restricted (R) special-use airspace polygons, altitude limits, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Drone restrictions: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | @@ -98,6 +99,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. +- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` uses the shared Nominatim forward-search queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries. - **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit. diff --git a/README.md b/README.md index 5d97dfb2f..6b8043e5a 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-one layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -310,6 +310,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| 🚫 **Drone Restrictions** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitudes and times of use on select — informational only, not for flight planning | FAA SUA | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..2fc40751a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## Drone Restrictions — FAA special-use airspace + +The Infrastructure group includes **Drone Restrictions** (`drone-airspace`, +share-link token `3`), off by default. `/api/drone-airspace` pages the public +FAA Special Use Airspace FeatureServer for `TYPE_CODE` P (prohibited) and R +(restricted), simplifies geometry, caches for an hour, and serves the last +good snapshot as stale when a refresh fails. The globe draws ground-clamped +outlines with a translucent fill: red for prohibited, orange for restricted. +Selecting a zone shows name, altitudes, times of use and controlling agency, +plus a notice that the layer is informational only and not a substitute for +official UAS flight-planning tools. Pick ownership is unregistered so sibling +layer cards still work over a zone. Voice can toggle the layer and query the +loaded records. See the live-layers table below. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing @@ -335,7 +349,7 @@ Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. -Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. +Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, drone restrictions, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no @@ -3070,9 +3084,18 @@ its criteria cannot be silently ignored. | Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +| Drone Restrictions 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +Drone Restrictions is off by default (share-link token `3`) and sits in +Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA +FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last +good payload as stale when a refresh fails. Client-supplied URLs are rejected. +Polygons keep holes; pick ownership is deliberately unregistered so sibling +cards still work over a zone. The row legend and selected card state that the +layer is informational only and not a substitute for B4UFLY / LAANC. + Street Level is a collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts collapsed. It opens itself only when the user, voice or a tool switches the diff --git a/package.json b/package.json index d9bf6cf8a..ca8a972ce 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "./layers/recent-imagery": "./src/layers/recentImagery/index.js", "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", + "./layers/drone-airspace": "./src/layers/droneAirspace/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -238,6 +239,7 @@ "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", + "./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..0d72c1096 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -371,6 +371,14 @@ "src/layers/perimeters/cards.test.mjs", "src/layers/perimeters/inciweb.test.mjs", "src/layers/perimeters/ownership.test.mjs", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/cards.js", + "src/layers/droneAirspace/records.test.mjs", + "src/layers/droneAirspace/source.test.mjs", + "src/layers/droneAirspace/cards.test.mjs", + "src/layers/droneAirspace/ownership.test.mjs", "src/data/firmsHeatmap.js", "src/data/fireAnchors.js", "src/layers/firms/anchors.js", @@ -643,6 +651,7 @@ "src/app/layers/radio.js", "src/app/layers/earthquakes.js", "src/app/layers/perimeters.js", + "src/app/layers/droneAirspace.js", "src/app/layers/cctv.js", "src/app/layers/firms.js", "src/app/layers/satellites.js", @@ -824,8 +833,10 @@ "src/layers/weather/imageryOrder.test.mjs", "server/providers/cyclones.js", "server/providers/firePerimeters.js", + "server/providers/droneAirspace.js", "src/data/cyclonesProxy.test.mjs", "src/data/firePerimetersProxy.test.mjs", + "src/data/droneAirspaceProxy.test.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 497be5b08..ed1fb5c4d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2023,6 +2023,11 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/droneAirspace.js", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2602,6 +2607,11 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/droneAirspace.js", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3097,6 +3107,8 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", "src/layers/submarineCables/bundledSource.js" ], "external": [] @@ -3317,6 +3329,26 @@ ], "external": [] }, + "drone-airspace": { + "exports": ["./layers/drone-airspace"], + "modules": [ + "src/layers/droneAirspace/index.js", + "src/sources/httpBody.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js" + ], + "external": ["cesium"] + }, + "drone-airspace-source": { + "exports": ["./layers/drone-airspace/source"], + "modules": [ + "src/layers/droneAirspace/records.js", + "src/sources/httpBody.js", + "src/layers/droneAirspace/source.js" + ], + "external": [] + }, "tools": { "exports": ["./tools"], "modules": [ diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js new file mode 100644 index 000000000..3a1905fc9 --- /dev/null +++ b/server/providers/droneAirspace.js @@ -0,0 +1,135 @@ +import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js'; +import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and +// R (restricted) only — the small, always-in-force slice of the national +// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a +// payload small enough to refresh (~330 KB for the current P+R set). +const API_URL = + 'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' + + 'Special_Use_Airspace/FeatureServer/0/query?' + + new URLSearchParams({ + where: "TYPE_CODE IN ('P','R')", + outFields: [ + 'NAME', + 'TYPE_CODE', + 'UPPER_DESC', + 'LOWER_DESC', + 'TIMESOFUSE', + 'CONT_AGENT', + 'CITY', + 'STATE', + 'OBJECTID', + ].join(','), + maxAllowableOffset: '0.001', + outSR: '4326', + f: 'geojson', + }).toString(); + +const MAX_PAGES = 5; +const MIB = 1024 * 1024; +const SNAPSHOT_TTL_MS = 3_600_000; + +const exceededTransferLimit = (payload) => + payload?.exceededTransferLimit === true || + payload?.properties?.exceededTransferLimit === true; + +/** Fixed-origin, bounded FAA SUA routes for dev and preview. */ +export function droneAirspaceProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 }); + + async function upstream(url, cap, timeout) { + const signal = AbortSignal.timeout(timeout); + const response = await fetchImpl(url, { + signal, + redirect: 'error', + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error('upstream_unavailable'); + } + return readResponseJsonCapped(response, cap, signal); + } + + async function fetchZones() { + const features = []; + for (let page = 0; page < MAX_PAGES; page++) { + const url = + page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`; + const payload = await upstream(url, 8 * MIB, 30_000); + if (!Array.isArray(payload?.features)) + throw new Error('invalid_snapshot'); + features.push(...payload.features); + if (!exceededTransferLimit(payload) || !payload.features.length) break; + } + const rows = normalizeDroneAirspaceSnapshot({ features }); + if (!rows) throw new Error('invalid_snapshot'); + return { fetchedAt: now(), rows }; + } + + async function acquire(key, ttl, load) { + const previous = cache.get(key); + if (previous && now() - previous.savedAt < ttl) + return { value: previous.value, stale: false }; + if (!inFlight.has(key) && inFlight.size >= 256) + throw Object.assign(new Error('busy'), { status: 429 }); + try { + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + const value = await load(); + cache.set(key, { value, savedAt: now() }); + return value; + }); + return { value: await promise, stale: false }; + } catch (error) { + if (previous) return { value: previous.value, stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(stale ? { 'X-Data-Stale': 'true' } : {}), + }); + res.end(JSON.stringify(value)); + }; + if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); + try { + const { value, stale } = await acquire( + 'zones', + SNAPSHOT_TTL_MS, + fetchZones, + ); + json(200, stale ? { ...value, stale: true } : value, stale); + } catch (error) { + json(error.status === 429 ? 429 : 502, { + error: 'drone_airspace_unavailable', + }); + } + } + + return { + name: 'drone-airspace', + configureServer({ middlewares }) { + middlewares.use('/api/drone-airspace', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/drone-airspace', handler); + }, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 9acbade31..e976455ed 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; +import { droneAirspaceProxy } from './droneAirspace.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) { cycloneProxy(), mapillaryProxy(), firePerimetersProxy(), + droneAirspaceProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 80315fe85..fe36dd76a 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; +import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -120,6 +121,9 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), + createApplicationDroneAirspace({ + source: sources['drone-airspace'], + }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f42e82c7c..e298580ad 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 30); + assert.equal(first.layers.length, 31); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); + assert.ok(first.get('drone-airspace')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 30); + assert.equal(catalog.layers.length, 31); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js new file mode 100644 index 000000000..f3472fec4 --- /dev/null +++ b/src/app/layers/droneAirspace.js @@ -0,0 +1,22 @@ +import * as Cesium from 'cesium'; +import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire FAA special-use airspace into the application catalog. */ +export function createApplicationDroneAirspace(options) { + return createDroneAirspaceLayer({ + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 14c864ac5..c9f79e407 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 44a7c8fa0..cf8646e25 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 30); + assert.equal(actual.layers.length, 31); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..2e4a8148e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -160,6 +160,13 @@ export const DATA_CREDITS = [ ' · Incident information: ' + 'InciWeb', }, + { + key: 'faa-sua', + html: + 'Drone restrictions: ' + + 'FAA Special Use Airspace' + + ' (prohibited and restricted zones; informational only)', + }, OSM_CREDIT, OPENMAPTILES_CREDIT, { diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs new file mode 100644 index 000000000..52eb0dcc8 --- /dev/null +++ b/src/data/droneAirspaceProxy.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js'; + +const feature = (id = 310, type = 'P') => ({ + properties: { + NAME: `${type}-${id}`, + TYPE_CODE: type, + OBJECTID: id, + UPPER_DESC: 'TNI', + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-77, 38], + [-76, 38], + [-76, 39], + [-77, 38], + ], + ], + }, +}); + +function install(options = {}, hook = 'configureServer') { + let handler; + const plugin = droneAirspaceProxy(options); + assert.equal(typeof plugin.configureServer, 'function'); + assert.equal(typeof plugin.configurePreviewServer, 'function'); + plugin[hook]({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/drone-airspace'); + handler = callback; + }, + }, + }); + return async (url = '/', method = 'GET', peer = 'local') => { + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = JSON.parse(body); + }, + }; + await handler({ url, method, socket: { remoteAddress: peer } }, res); + return res; + }; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => { + const calls = []; + const request = install( + { + now: () => 1234, + fetchImpl: async (url, options) => { + calls.push(new URL(url)); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.redirect, 'error'); + return Response.json({ + features: [feature(calls.length)], + exceededTransferLimit: true, + }); + }, + }, + hook, + ); + const res = await request('/?url=https://invalid.example&resultOffset=999'); + assert.equal(res.status, 200); + assert.equal(res.body.fetchedAt, 1234); + assert.equal(res.body.rows.length, 5); + assert.equal(calls.length, 5); + assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')"); + assert.equal(calls[0].searchParams.get('f'), 'geojson'); + assert.equal(calls[0].searchParams.has('resultOffset'), false); + assert.equal(calls[1].searchParams.get('resultOffset'), '1'); + }); +} + +test('a cached snapshot is reused until TTL, then refreshed', async () => { + let now = 1000; + let fetches = 0; + const request = install({ + now: () => now, + fetchImpl: async () => { + fetches += 1; + return Response.json({ features: [feature(fetches)] }); + }, + }); + const first = await request(); + now += 3_599_000; + const cached = await request(); + now += 2000; + const refreshed = await request(); + assert.equal(first.body.rows[0].stableId, '1'); + assert.equal(cached.body.rows[0].stableId, '1'); + assert.equal(refreshed.body.rows[0].stableId, '2'); + assert.equal(fetches, 2); +}); + +test('a failed refresh serves the last good snapshot as stale', async () => { + let now = 1000; + let fail = false; + const request = install({ + now: () => now, + fetchImpl: async () => { + if (fail) return new Response('nope', { status: 503 }); + return Response.json({ features: [feature(7)] }); + }, + }); + const first = await request(); + fail = true; + now += 3_600_001; + const stale = await request(); + assert.equal(first.status, 200); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.rows[0].stableId, '7'); +}); + +test('method, unknown routes and rate limits are rejected', async () => { + const request = install({ + fetchImpl: async () => Response.json({ features: [feature()] }), + }); + assert.equal((await request('/', 'POST')).status, 405); + assert.equal((await request('/nope')).status, 404); + const limited = install({ + fetchImpl: async () => Response.json({ features: [feature()] }), + }); + let last; + for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9'); + assert.equal(last.status, 429); +}); + +test('a cold upstream failure is 502, not an empty snapshot', async () => { + const request = install({ + fetchImpl: async () => new Response('nope', { status: 503 }), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.equal(res.body.error, 'drone_airspace_unavailable'); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index 373eac20d..bc5c660ca 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -595,6 +595,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ optionOwner: 'cctv', }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), + Object.freeze({ + id: 'drone-airspace', + token: '3', + disposition: 'enabled-only', + }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), Object.freeze({ id: 'fire-perimeters', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 08120a604..0fbdcf000 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 29); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); + assert.equal(REGISTERED_LAYER_IDS.length, 30); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,7 +242,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '3'); + assert.equal(nextLayerStateToken(), '4'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '3', - next: '4', + future: '4', + next: '5', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 3/, + /next free token 4/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' }, ), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2639,6 +2639,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s ); }); +test('drone restrictions uses digit 3 without colliding with fire perimeters', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2')); + assert.deepEqual(decoded.enabledLayerIds, [ + 'drone-airspace', + 'fire-perimeters', + ]); + assert.equal( + LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token, + '3', + ); + assert.deepEqual( + decodeLayerStateParams(new URLSearchParams(encode(decoded))), + decoded, + ); +}); + test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1')); assert.deepEqual(decoded.enabledLayerIds, [ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 5a02d85cf..56b2e1582 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' }, + { + ...LEGACY_LAYER_STATE_TOKENS, + 'street-level': '0', + 'drone-airspace': '3', + }, ); assert.throws( () => parseLayerStateTokenReservations(reservationRows.slice(1)), @@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '4']]) .future, - '3', + '4', ); assert.throws( () => @@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '3']] }, + { rows: [...reservationRows, ['retired-layer', '4']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); + assert.equal(nextLayerStateToken(published), '5'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '4', + newcomer: '5', }), true, ); @@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '3', + competing: '4', }), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '3', + newcomer: '4', }), /changed or removed: retired-layer/, ); @@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { +test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '3']], - [{ id: 'pr-b', token: '3' }], + [...reservationRows, ['pr-b', '4']], + [{ id: 'pr-b', token: '4' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '4']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '4' }, ], ); const staleRebase = runCheck(); @@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); - const validBaselineRows = [...reservationRows, ['pr-a', '3']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); + assert.equal(nextLayerStateToken(published), '5'); + const validBaselineRows = [...reservationRows, ['pr-a', '4']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 4/); + assert.match(helper.stdout, /pr-b: 5/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '5']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '4' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '5' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /30 published, 1 new/); + assert.match(corrected.stdout, /31 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'3456789'].map((digit) => [ + const priorDigits = [...'456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 69); + assert.equal(params.get('l').length, 71); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 30); + assert.equal(Object.keys(published).length, 31); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index eb49eed27..6024f5248 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -6,6 +6,7 @@ ["bikeshare", "b"], ["cctv", "c"], ["directions", "n"], + ["drone-airspace", "3"], ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 49711163f..cd8efce77 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. // Street Level adds toggle enum values and the generated alias hint. - assert.equal(block.length, 29802, 'serialized tool schema length drifted'); + // Drone Restrictions adds toggle/query enum values and spoken aliases. + assert.equal(block.length, 29988, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed', + 'e3605286e8b91c07f29b687cb3cf0e9400fef92225fbc7efeaa35451274df571', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/droneAirspace/cards.js b/src/layers/droneAirspace/cards.js new file mode 100644 index 000000000..eb6cc6433 --- /dev/null +++ b/src/layers/droneAirspace/cards.js @@ -0,0 +1,93 @@ +/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */ + +export const DRONE_AIRSPACE_OVERLAY_SOURCE_ID = 'drone-airspace'; + +/** Fill/line CSS color for a zone by TYPE_CODE. */ +export function droneAirspaceAccent(typeCode) { + return typeCode === 'P' ? '#ff3b30' : '#ff7a00'; +} + +/** Shoelace area (degree², sign dropped) — relative sizes only. */ +function ringArea(ring) { + let sum = 0; + for (let i = 0; i < ring.length - 1; i++) { + const [x1, y1] = ring[i]; + const [x2, y2] = ring[i + 1]; + sum += x1 * y2 - x2 * y1; + } + return Math.abs(sum / 2); +} + +/** + * Anchor point for the zone card: the centroid of the largest polygon's + * outer ring. Degrees, so callers own the Cartesian conversion. + * @param {Array>>} polygons + * @returns {{lon: number, lat: number}} + */ +export function droneAirspaceAnchorDegrees(polygons) { + let best = null; + let bestArea = -1; + for (const rings of polygons) { + const outer = rings[0]; + const area = ringArea(outer); + if (area > bestArea) { + bestArea = area; + best = outer; + } + } + let lon = 0; + let lat = 0; + const count = best.length - 1; + for (let i = 0; i < count; i++) { + lon += best[i][0]; + lat += best[i][1]; + } + return { lon: lon / count, lat: lat / count }; +} + +function altitudeLine(row) { + const lower = row.lowerDesc; + const upper = row.upperDesc; + if (lower && upper) return `${lower} – ${upper}`; + if (upper) return `up to ${upper}`; + if (lower) return `from ${lower}`; + return null; +} + +function placeLine(row) { + if (row.city && row.state) return `${row.city}, ${row.state}`; + return row.city || row.state || null; +} + +/** + * Build the overlay-host entry for one selected zone. The caller supplies + * `position` (Cartesian) separately — this model stays JSON-safe for tests. + * @param {Object} row - Normalized zone row (see records.js). + * @returns {Object} World-overlay entry without `position`. + */ +export function buildDroneAirspaceCard(row) { + const facts = [row.type || row.typeCode].filter(Boolean); + const altitude = altitudeLine(row); + if (altitude) facts.push(altitude); + if (row.timesOfUse) facts.push(row.timesOfUse); + + const details = [facts.join(' · ')]; + const place = placeLine(row); + if (place) details.push(place); + if (row.agency) details.push(row.agency); + details.push('Informational only · not for flight planning'); + + const title = `${(row.type || 'SUA').toUpperCase()} · ${row.name || 'Unnamed zone'}`; + return { + id: `drone-airspace-card:${row.stableId}`, + selected: true, + interactive: false, + title, + details, + accent: droneAirspaceAccent(row.typeCode), + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + }; +} diff --git a/src/layers/droneAirspace/cards.test.mjs b/src/layers/droneAirspace/cards.test.mjs new file mode 100644 index 000000000..e8d58c37c --- /dev/null +++ b/src/layers/droneAirspace/cards.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + droneAirspaceAnchorDegrees, + droneAirspaceAccent, + buildDroneAirspaceCard, +} from './cards.js'; + +const square = [ + [-77.2, 38.8], + [-77.0, 38.8], + [-77.0, 39.0], + [-77.2, 39.0], + [-77.2, 38.8], +]; +const tiny = [ + [-76.01, 38.0], + [-76.0, 38.0], + [-76.0, 38.01], + [-76.01, 38.0], +]; + +test('the card anchors at the centroid of the largest polygon', () => { + const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]); + assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9); + assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9); +}); + +test('a prohibited zone card names the type, altitudes and the disclaimer', () => { + const card = buildDroneAirspaceCard({ + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: 'WASHINGTON CENTER', + city: 'WASHINGTON', + state: 'DC', + }); + assert.equal(card.id, 'drone-airspace-card:310'); + assert.equal(card.title, 'PROHIBITED · P-56'); + assert.deepEqual(card.details, [ + 'Prohibited · SFC – TNI · CONTINUOUS', + 'WASHINGTON, DC', + 'WASHINGTON CENTER', + 'Informational only · not for flight planning', + ]); + assert.equal(card.accent, droneAirspaceAccent('P')); + assert.equal(card.interactive, false); + assert.equal(card.selected, true); +}); + +test('missing attributes degrade to available facts instead of placeholders', () => { + const card = buildDroneAirspaceCard({ + stableId: 'x', + name: null, + typeCode: 'R', + type: 'Restricted', + lowerDesc: null, + upperDesc: null, + timesOfUse: null, + agency: null, + city: null, + state: null, + }); + assert.equal(card.title, 'RESTRICTED · Unnamed zone'); + assert.deepEqual(card.details, [ + 'Restricted', + 'Informational only · not for flight planning', + ]); + assert.equal(card.accent, droneAirspaceAccent('R')); +}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js new file mode 100644 index 000000000..d7b4a40c8 --- /dev/null +++ b/src/layers/droneAirspace/index.js @@ -0,0 +1,326 @@ +import * as Cesium from 'cesium'; +import { + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + droneAirspaceAnchorDegrees, + buildDroneAirspaceCard, + droneAirspaceAccent, +} from './cards.js'; +export { normalizeDroneAirspaceSnapshot } from './records.js'; +export { createDroneAirspaceSource } from './source.js'; +export * from './cards.js'; + +/** Fill/line color for a zone by TYPE_CODE. */ +export function droneAirspaceColor(typeCode) { + return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode)); +} + +const ringPositions = (ring) => + ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); + +const PICK_PREFIX = 'drone-airspace:'; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); + +/** Own one drone-restriction display, its refresh lifecycle, and click selection. */ +export function createDroneAirspaceLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, +} = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Drone airspace requires a snapshot source'); + let _viewer = null; + let _request = null; + let _snapshotSignature = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + let _clickHandler = null; + let _selectedId = null; + let _selectedCardId = null; + /** @type {Map} */ + const _rowById = new Map(); + + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function publishSelectedCard() { + if (!canSelect()) return; + const row = _selectedId ? _rowById.get(_selectedId) : null; + if (!row) { + _selectedId = null; + _selectedCardId = null; + overlayHost.setEntries( + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const card = { + ...buildDroneAirspaceCard(row), + position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat), + }; + _selectedCardId = card.id; + overlayHost.setEntries( + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + } + + /** Resolve a scene pick to one of this layer's zone ids, or null. */ + function pickedZoneId(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0]; + return _rowById.has(zoneId) ? zoneId : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + // Deliberately NOT registered in the pick-ownership registry: sibling + // layers yield to owned picks before their own overlay-card hit-tests, + // so claiming these large ground polygons would make FIRMS/vessel/CCTV + // cards inert anywhere over a zone. Unowned, our picks read as + // "empty space" to siblings — the same contract as fire perimeters. + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) return; + const picked = _viewer.scene.pick(click.position); + const zoneId = picked ? pickedZoneId(picked) : null; + if (zoneId) { + _selectedId = zoneId; + publishSelectedCard(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return; + } + if (_selectedId) { + _selectedId = null; + publishSelectedCard(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + } + + function clearSelection() { + _selectedId = null; + _selectedCardId = null; + if (overlayHost) { + overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID); + overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false); + } + } + + const layer = { + id: 'drone-airspace', + name: 'Drone Restrictions', + icon: '🚫', + source: 'FAA SUA', + updateInterval: 3600000, + + init(viewer) { + if (_viewer) + throw new Error('Drone airspace layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('drone-airspace'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + console.log('[Data:DroneAirspace] Initialized'); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true); + installClickHandler(); + }, + + disable() { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + removeClickHandler(); + clearSelection(); + }, + + async update() { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const rows = await source.getSnapshot({ signal: request.signal }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + + const signature = JSON.stringify( + rows + .map(({ polygons, ...facts }) => facts) + .sort((a, b) => a.stableId.localeCompare(b.stableId)), + ); + if (signature === _snapshotSignature) { + if (_selectedId) publishSelectedCard(); + _lastUpdate = Date.now(); + _lastError = null; + return true; + } + const nextEntities = []; + _rowById.clear(); + for (const row of rows) { + const color = droneAirspaceColor(row.typeCode); + for (const [index, rings] of row.polygons.entries()) { + const [outer, ...holes] = rings; + const outerPositions = ringPositions(outer); + nextEntities.push( + new Cesium.Entity({ + id: `drone-airspace:${row.stableId}:${index}`, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + outerPositions, + holes.map( + (hole) => + new Cesium.PolygonHierarchy(ringPositions(hole)), + ), + ), + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.25), + ), + }, + polyline: { + positions: outerPositions, + clampToGround: true, + width: 2, + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.9), + ), + }, + }), + ); + } + const { polygons, ...facts } = row; + _rowById.set(row.stableId, { + ...facts, + anchor: droneAirspaceAnchorDegrees(polygons), + }); + } + + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); + _snapshotSignature = signature; + if (_selectedId) publishSelectedCard(); + _count = rows.length; + _lastUpdate = Date.now(); + _lastError = null; + console.log( + `[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`, + ); + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:DroneAirspace] Fetch error:', e); + _lastError = e?.message || 'Drone airspace source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + removeClickHandler(); + clearSelection(); + _rowById.clear(); + _snapshotSignature = null; + _viewer = null; + _enabled = false; + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _count = 0; + _lastUpdate = null; + _lastError = null; + }, + + getAnalystRecords(maxCount = 2000) { + if (!_dataSource || !_dataSource.show) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const row of _rowById.values()) { + if (result.length >= limit) break; + const { anchor, stableId, ...facts } = row; + result.push({ + id: stableId, + ...facts, + lat: anchor.lat, + lon: anchor.lon, + }); + } + return result; + }, + + getRowControls() { + const bands = [ + { label: 'Prohibited (P)', color: droneAirspaceAccent('P') }, + { label: 'Restricted (R)', color: droneAirspaceAccent('R') }, + ]; + return { + chips: [], + legend: bands.map((band, index) => ({ + ...band, + count: [..._rowById.values()].filter( + (row) => droneAirspaceAccent(row.typeCode) === band.color, + ).length, + ...(index === 0 + ? { + blurb: + 'FAA special-use airspace. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).', + } + : {}), + })), + }; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + }; + }, + }; + return layer; +} diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs new file mode 100644 index 000000000..ae10184f2 --- /dev/null +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDroneAirspaceLayer } from './index.js'; + +function harness(source, { pick = () => null, cardHit = () => null } = {}) { + const sources = []; + const overlay = { entries: new Map(), visible: null }; + const clicks = { handler: null, destroyed: 0 }; + const owners = new Map(); + const viewer = { + scene: { pick }, + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createDroneAirspaceLayer({ + source, + overlayHost: { + setEntries(sourceId, entries) { + overlay.entries.set(sourceId, entries); + }, + setVisible(sourceId, visible) { + overlay.visible = visible; + }, + clearSource(sourceId) { + overlay.entries.delete(sourceId); + }, + hitTest: (x, y, options) => cardHit(x, y, options), + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(callback) { + clicks.handler = callback; + }, + destroy() { + clicks.destroyed += 1; + clicks.handler = null; + }, + }), + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (layerId, pickedId) => + String(pickedId).startsWith('other-layer:'), + registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate), + unregisterPickOwner: (layerId) => owners.delete(layerId), + }, + pointer: { isPointerFree: () => true }, + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, overlay, clicks, owners }; +} + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; +const row = { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: null, + city: 'WASHINGTON', + state: 'DC', + polygons: [[ring]], +}; + +test('each zone polygon renders as one filled entity with its outline', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] }, + ], + }); + assert.equal(await h.layer.update(h.viewer), true); + const entities = h.sources[0].entities.values; + assert.equal(entities.length, 3); + assert.equal(entities[0].id, 'drone-airspace:310:0'); + assert.ok(entities[0].polygon, 'zone fill missing'); + assert.ok(entities[0].polyline, 'zone outline missing'); + assert.equal(h.layer.getStats().count, 2); +}); + +test('clicking a zone publishes its card; empty space clears it', async () => { + let pickResult = null; + const h = harness( + { getSnapshot: async () => [row] }, + { pick: () => pickResult }, + ); + await h.layer.update(h.viewer); + assert.equal(typeof h.clicks.handler, 'function', 'click handler missing'); + assert.equal(h.owners.size, 0); + + pickResult = { id: 'drone-airspace:310:0' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + const entries = h.overlay.entries.get('drone-airspace'); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'drone-airspace-card:310'); + assert.equal(entries[0].title, 'PROHIBITED · P-56'); + assert.ok(entries[0].position, 'card must carry a world anchor'); + + pickResult = { id: 'other-layer:aircraft-1' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal( + h.overlay.entries.get('drone-airspace')?.length, + 1, + 'sibling-layer picks must not clear the selection', + ); + + pickResult = null; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0); +}); + +test('late refresh cannot publish after disable or destroy', async () => { + for (const action of ['disable', 'destroy']) { + let resolve, signal; + const h = harness({ + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = h.layer.update(h.viewer); + h.layer[action](h.viewer); + assert.equal(signal.aborted, true); + if (action === 'disable') h.layer.enable(h.viewer); + resolve([row]); + assert.equal(await pending, false); + assert.equal(h.layer.getStats().count, 0); + h.layer.destroy(h.viewer); + } +}); + +test('the row legend names prohibited and restricted with the disclaimer', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: '400', typeCode: 'R', type: 'Restricted' }, + ], + }); + await h.layer.update(h.viewer); + const legend = h.layer.getRowControls().legend; + assert.equal(legend[0].label, 'Prohibited (P)'); + assert.equal(legend[1].label, 'Restricted (R)'); + assert.equal(legend[0].count, 1); + assert.equal(legend[1].count, 1); + assert.match(legend[0].blurb, /not a substitute/); +}); diff --git a/src/layers/droneAirspace/records.js b/src/layers/droneAirspace/records.js new file mode 100644 index 000000000..c1d5f73e2 --- /dev/null +++ b/src/layers/droneAirspace/records.js @@ -0,0 +1,97 @@ +/** + * Normalize an FAA Special Use Airspace feed, skipping individually invalid + * features. Only a payload that is not a feature collection at all rejects + * the whole snapshot: a single degenerate polygon must never blank the layer. + */ + +const TYPE_LABELS = Object.freeze({ + P: 'Prohibited', + R: 'Restricted', +}); + +const finiteOrNull = (value) => (Number.isFinite(value) ? value : null); +const textOrNull = (value) => { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text ? text : null; +}; + +function validRing(ring) { + if (!Array.isArray(ring) || ring.length < 4) return false; + for (const position of ring) { + if (!Array.isArray(position) || position.length < 2) return false; + const [lon, lat] = position; + if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false; + if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false; + } + return true; +} + +/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an + * array of rings). Returns null on malformed geometry, [] when empty. */ +function normalizePolygons(geometry) { + if (!geometry || typeof geometry !== 'object') return null; + let polygons; + if (geometry.type === 'Polygon') polygons = [geometry.coordinates]; + else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates; + else return null; + if (!Array.isArray(polygons)) return null; + const result = []; + for (const rings of polygons) { + if (!Array.isArray(rings)) return null; + if (!rings.length) continue; + if (!rings.every(validRing)) return null; + result.push(rings); + } + return result; +} + +/** Human label for a TYPE_CODE, or null when the code is not in the pack. */ +export function droneAirspaceTypeLabel(typeCode) { + return TYPE_LABELS[typeCode] || null; +} + +export function normalizeDroneAirspaceSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const feature of geojson.features) { + const properties = feature?.properties; + if ( + !properties || + typeof properties !== 'object' || + Array.isArray(properties) + ) + continue; + const typeCode = textOrNull(properties.TYPE_CODE); + const type = droneAirspaceTypeLabel(typeCode); + if (!type) continue; + const polygons = normalizePolygons(feature.geometry); + if (polygons === null || !polygons.length) continue; + const objectId = finiteOrNull(Number(properties.OBJECTID)); + const featureId = + feature.id == null || feature.id === '' ? null : String(feature.id); + const stableId = + objectId != null + ? String(objectId) + : featureId && !ids.has(featureId) + ? featureId + : null; + if (stableId == null || ids.has(stableId)) continue; + ids.add(stableId); + rows.push({ + stableId, + name: textOrNull(properties.NAME), + typeCode, + type, + lowerDesc: textOrNull(properties.LOWER_DESC), + upperDesc: textOrNull(properties.UPPER_DESC), + timesOfUse: textOrNull(properties.TIMESOFUSE), + agency: textOrNull(properties.CONT_AGENT), + city: textOrNull(properties.CITY), + state: textOrNull(properties.STATE), + polygons, + }); + } + return rows; +} diff --git a/src/layers/droneAirspace/records.test.mjs b/src/layers/droneAirspace/records.test.mjs new file mode 100644 index 000000000..4c8c1a215 --- /dev/null +++ b/src/layers/droneAirspace/records.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + droneAirspaceTypeLabel, + normalizeDroneAirspaceSnapshot, +} from './records.js'; + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; + +function feature(overrides = {}) { + return { + id: 310, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + NAME: 'P-56', + TYPE_CODE: 'P', + UPPER_DESC: 'TNI', + LOWER_DESC: 'SFC', + TIMESOFUSE: 'CONTINUOUS', + CONT_AGENT: 'WASHINGTON CENTER', + CITY: 'WASHINGTON', + STATE: 'DC', + OBJECTID: 310, + ...overrides.properties, + }, + ...overrides, + }; +} + +test('a valid snapshot yields one row per prohibited or restricted zone', () => { + const rows = normalizeDroneAirspaceSnapshot({ + features: [ + feature(), + feature({ + id: 400, + properties: { + NAME: 'R-2508', + TYPE_CODE: 'R', + OBJECTID: 400, + }, + }), + ], + }); + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: 'WASHINGTON CENTER', + city: 'WASHINGTON', + state: 'DC', + polygons: [[ring]], + }); + assert.equal(rows[1].type, 'Restricted'); + assert.equal(rows[1].typeCode, 'R'); +}); + +test('MOA, warning and advisory codes are dropped, as are bad geometries', () => { + const rows = normalizeDroneAirspaceSnapshot({ + features: [ + feature({ properties: { TYPE_CODE: 'MOA', OBJECTID: 1 } }), + feature({ properties: { TYPE_CODE: 'W', OBJECTID: 2 } }), + feature({ properties: { TYPE_CODE: 'A', OBJECTID: 3 } }), + feature({ geometry: null, properties: { OBJECTID: 4 } }), + feature({ + geometry: { type: 'Point', coordinates: [-77, 38] }, + properties: { OBJECTID: 5 }, + }), + feature(), + ], + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'P-56'); +}); + +test('duplicate ids and a missing collection reject or skip as documented', () => { + const dup = normalizeDroneAirspaceSnapshot({ + features: [feature(), feature({ id: 311 })], + }); + assert.equal(dup.length, 1); + assert.equal(normalizeDroneAirspaceSnapshot({}), null); + assert.equal(normalizeDroneAirspaceSnapshot(null), null); + assert.equal(droneAirspaceTypeLabel('P'), 'Prohibited'); + assert.equal(droneAirspaceTypeLabel('R'), 'Restricted'); + assert.equal(droneAirspaceTypeLabel('MOA'), null); +}); diff --git a/src/layers/droneAirspace/source.js b/src/layers/droneAirspace/source.js new file mode 100644 index 000000000..17e67b3ae --- /dev/null +++ b/src/layers/droneAirspace/source.js @@ -0,0 +1,23 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +/** Request a normalized snapshot through the bounded, same-origin FAA proxy. */ +export function createDroneAirspaceSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/drone-airspace', { signal }); + if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed drone airspace snapshot'); + return payload.rows; + }, + }; +} diff --git a/src/layers/droneAirspace/source.test.mjs b/src/layers/droneAirspace/source.test.mjs new file mode 100644 index 000000000..160fb1789 --- /dev/null +++ b/src/layers/droneAirspace/source.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDroneAirspaceSource } from './source.js'; +import { normalizeDroneAirspaceSnapshot } from './records.js'; +import { droneAirspaceProxy } from '../../../server/providers/droneAirspace.js'; + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; +const validPayload = { + features: [ + { + id: 310, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + NAME: 'P-56', + TYPE_CODE: 'P', + OBJECTID: 310, + }, + }, + ], +}; + +test('a successful response yields normalized zone rows', async () => { + let requested; + const source = createDroneAirspaceSource({ + fetchImpl: async (url) => { + requested = String(url); + return Response.json({ + rows: normalizeDroneAirspaceSnapshot(validPayload), + }); + }, + }); + const rows = await source.getSnapshot(); + assert.equal(rows.length, 1); + assert.equal(rows[0].stableId, '310'); + assert.equal(requested, '/api/drone-airspace'); +}); + +test('a truncated response pages until the feed is complete', async () => { + const pageRing = (id) => ({ + id, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { NAME: `P-${id}`, TYPE_CODE: 'P', OBJECTID: id }, + }); + const requests = []; + let handler; + const plugin = droneAirspaceProxy({ + fetchImpl: async (url) => { + requests.push(new URL(String(url)).searchParams.get('resultOffset')); + const page = requests.length; + return Response.json({ + features: [pageRing(page)], + properties: { exceededTransferLimit: page < 3 }, + }); + }, + }); + plugin.configureServer({ + middlewares: { + use: (_path, callback) => { + handler = callback; + }, + }, + }); + let payload; + await handler( + { url: '/', method: 'GET' }, + { + writeHead(status) { + assert.equal(status, 200); + }, + end(body) { + payload = JSON.parse(body); + }, + }, + ); + const rows = payload.rows; + assert.deepEqual( + rows.map((row) => row.stableId), + ['1', '2', '3'], + ); + assert.deepEqual(requests, [null, '1', '2']); +}); + +test('an upstream failure surfaces its HTTP status', async () => { + const source = createDroneAirspaceSource({ + fetchImpl: async () => ({ ok: false, status: 503 }), + }); + await assert.rejects(source.getSnapshot(), /FAA SUA HTTP 503/); +}); + +test('a malformed successful response is never accepted as an empty snapshot', async () => { + for (const payload of [{}, { rows: null }, { rows: {} }]) { + const source = createDroneAirspaceSource({ + fetchImpl: async () => Response.json(payload), + }); + await assert.rejects( + source.getSnapshot(), + /Malformed drone airspace snapshot/, + ); + } +}); diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..302e15303 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,5 +1,6 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; +import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; /** Construct the existing reference feeds independently of application setup. */ @@ -7,6 +8,7 @@ export function createReferenceSources() { return { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), + 'drone-airspace': createDroneAirspaceSource(), cables: createBundledCableSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..735b5c72e 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -15,13 +15,16 @@ test('reference factories retain compatibility without starting acquisition or s assert.deepEqual(Object.keys(first), [ 'earthquakes', 'fire-perimeters', + 'drone-airspace', 'cables', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); + assert.notEqual(first['drone-airspace'], second['drone-airspace']); assert.notEqual(first.cables, second.cables); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); + assert.equal(typeof first['drone-airspace'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); assert.equal(requests, 0); }); diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index d6eb80e20..b88b4e1f8 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs'; test('data providers have both hooks; credential editing stays development-only', () => { const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); + assert.ok(plugins.some(({ name }) => name === 'drone-airspace')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index e4c240f23..ecbf42cca 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -39,6 +39,7 @@ const PANEL_GROUPS = [ ids: [ 'alpr-cameras', 'military-installations', + 'drone-airspace', 'local-datacenters', 'telegeography-submarine-cables', 'local-dams', diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1e348ee41..facc90e32 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -327,6 +327,30 @@ const layers = [ context: true, query: { fields: { name: ['t'], class: ['t'], kind: ['t'] } }, }, + { + id: 'drone-airspace', + aliases: [ + 'drone restrictions', + 'drone airspace', + 'no-fly zones', + 'no fly zones', + 'drones', + ], + context: false, + query: { + fields: { + name: ['t'], + typeCode: ['t'], + type: ['t'], + lowerDesc: ['t'], + upperDesc: ['t'], + timesOfUse: ['t'], + agency: ['t'], + city: ['t'], + state: ['t'], + }, + }, + }, { id: 'local-datacenters', aliases: ['datacenters', 'data centers', 'data centres'], diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..98e3889bf 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -20,14 +20,28 @@ test('every catalog layer is in the voice manifest or explicitly voice-off', () const missing = catalogIds.filter( (id) => !manifestIds.includes(id) && !Object.hasOwn(VOICE_OFF_LAYERS, id), ); - assert.deepEqual(missing, [], `add a voice manifest entry for: ${missing.join(', ')}`); + assert.deepEqual( + missing, + [], + `add a voice manifest entry for: ${missing.join(', ')}`, + ); for (const id of Object.keys(VOICE_OFF_LAYERS)) { - assert.ok(catalogIds.includes(id), `${id} is voice-off but not in the catalog`); - assert.ok(!manifestIds.includes(id), `${id} is both voice-off and in the manifest`); + assert.ok( + catalogIds.includes(id), + `${id} is voice-off but not in the catalog`, + ); + assert.ok( + !manifestIds.includes(id), + `${id} is both voice-off and in the manifest`, + ); } const unknown = manifestIds.filter((id) => !catalogIds.includes(id)); assert.deepEqual(unknown, [], 'the manifest names only catalog layers'); - assert.equal(new Set(manifestIds).size, manifestIds.length, 'one entry per layer'); + assert.equal( + new Set(manifestIds).size, + manifestIds.length, + 'one entry per layer', + ); }); test('each layer declares a query or the reason it has none', () => { @@ -41,7 +55,8 @@ test('each layer declares a query or the reason it has none', () => { }); test('tool enums are generated from the manifest', () => { - const toggle = tool('set_layer_visibility').parameters.properties.layerId.enum; + const toggle = tool('set_layer_visibility').parameters.properties.layerId + .enum; assert.deepEqual(toggle, [...VOICE_TOGGLE_LAYER_IDS]); for (const id of [ 'transit', @@ -57,9 +72,13 @@ test('tool enums are generated from the manifest', () => { 'rocket-launches', 'bikeshare', 'local-adsb', + 'drone-airspace', ]) assert.ok(toggle.includes(id), `${id} is voice-toggleable`); - assert.ok(!toggle.includes('military-awareness'), 'Global Context is reached through set_context_mode'); + assert.ok( + !toggle.includes('military-awareness'), + 'Global Context is reached through set_context_mode', + ); assert.deepEqual( tool('show_data_layers_menu').parameters.properties.layerId.enum, [...VOICE_TOGGLE_LAYER_IDS], @@ -68,47 +87,98 @@ test('tool enums are generated from the manifest', () => { tool('get_entity_context').parameters.properties.layerId.enum, [...VOICE_CONTEXT_LAYER_IDS], ); - assert.ok(!VOICE_CONTEXT_LAYER_IDS.includes('telegeography-submarine-cables'), 'cables register no context records'); + assert.ok( + !VOICE_CONTEXT_LAYER_IDS.includes('telegeography-submarine-cables'), + 'cables register no context records', + ); assert.deepEqual( tool('analyst_query').parameters.properties.layers.items.enum, [...VOICE_QUERY_LAYER_IDS], ); assert.deepEqual(Object.keys(ANALYST_LAYERS), [...VOICE_QUERY_LAYER_IDS]); const panels = tool('set_panel_open').parameters.properties.panelId.enum; - assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel')); + assert.ok( + panels.includes('weather-panel') && panels.includes('recent-imagery-panel'), + ); }); test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => { const aliases = voiceLayerAliasMap(); for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id); const legacy = { - planes: 'flights', aircraft: 'flights', 'military flights': 'military', quakes: 'earthquakes', - 'space mission': 'rocket-launches', 'space missions': 'rocket-launches', missions: 'rocket-launches', - 'street traffic': 'traffic', cameras: 'cctv', 'internet radio': 'radio', 'radio stations': 'radio', - bikes: 'bikeshare', ais: 'ais-live-vessels', ships: 'ais-live-vessels', vessels: 'ais-live-vessels', - 'live vessels': 'ais-live-vessels', datacenters: 'local-datacenters', 'data centers': 'local-datacenters', - 'data centres': 'local-datacenters', dams: 'local-dams', 'submarine cables': 'telegeography-submarine-cables', - cables: 'telegeography-submarine-cables', telegeography: 'telegeography-submarine-cables', - firms: 'local-firms', fires: 'local-firms', 'active fires': 'local-firms', alpr: 'alpr-cameras', - 'alpr cameras': 'alpr-cameras', 'flock cameras': 'alpr-cameras', 'license plate readers': 'alpr-cameras', - 'license plate cameras': 'alpr-cameras', 'plate readers': 'alpr-cameras', 'local-adsb': 'local-adsb', - 'local adsb': 'local-adsb', 'local ads-b': 'local-adsb', 'my receiver': 'local-adsb', - 'my antenna': 'local-adsb', 'my sdr': 'local-adsb', + planes: 'flights', + aircraft: 'flights', + 'military flights': 'military', + quakes: 'earthquakes', + 'space mission': 'rocket-launches', + 'space missions': 'rocket-launches', + missions: 'rocket-launches', + 'street traffic': 'traffic', + cameras: 'cctv', + 'internet radio': 'radio', + 'radio stations': 'radio', + bikes: 'bikeshare', + ais: 'ais-live-vessels', + ships: 'ais-live-vessels', + vessels: 'ais-live-vessels', + 'live vessels': 'ais-live-vessels', + datacenters: 'local-datacenters', + 'data centers': 'local-datacenters', + 'data centres': 'local-datacenters', + dams: 'local-dams', + 'submarine cables': 'telegeography-submarine-cables', + cables: 'telegeography-submarine-cables', + telegeography: 'telegeography-submarine-cables', + firms: 'local-firms', + fires: 'local-firms', + 'active fires': 'local-firms', + alpr: 'alpr-cameras', + 'alpr cameras': 'alpr-cameras', + 'flock cameras': 'alpr-cameras', + 'license plate readers': 'alpr-cameras', + 'license plate cameras': 'alpr-cameras', + 'plate readers': 'alpr-cameras', + 'local-adsb': 'local-adsb', + 'local adsb': 'local-adsb', + 'local ads-b': 'local-adsb', + 'my receiver': 'local-adsb', + 'my antenna': 'local-adsb', + 'my sdr': 'local-adsb', }; - for (const [phrase, id] of Object.entries(legacy)) assert.equal(aliases.get(phrase), id, phrase); + for (const [phrase, id] of Object.entries(legacy)) + assert.equal(aliases.get(phrase), id, phrase); const added = { - buses: 'transit', trains: 'transit', hurricanes: 'weather-cyclones', cyclones: 'weather-cyclones', - 'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations', - 'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches', - 'street level': 'street-level', mapillary: 'street-level', - wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters', - 'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters', + buses: 'transit', + trains: 'transit', + hurricanes: 'weather-cyclones', + cyclones: 'weather-cyclones', + 'weather radar': 'weather-radar', + lightning: 'weather-lightning', + 'military bases': 'military-installations', + 'recent imagery': 'recent-imagery', + 'bike share': 'bikeshare', + 'rocket launches': 'rocket-launches', + 'street level': 'street-level', + mapillary: 'street-level', + wind: 'wind', + directions: 'directions', + 'fire perimeters': 'fire-perimeters', + 'wildfire perimeters': 'fire-perimeters', + 'burn areas': 'fire-perimeters', + 'drone restrictions': 'drone-airspace', + 'drone airspace': 'drone-airspace', + 'no-fly zones': 'drone-airspace', + drones: 'drone-airspace', }; - for (const [phrase, id] of Object.entries(added)) assert.equal(aliases.get(phrase), id, phrase); + for (const [phrase, id] of Object.entries(added)) + assert.equal(aliases.get(phrase), id, phrase); const claimed = new Map(); for (const entry of VOICE_LAYER_MANIFEST) for (const alias of entry.aliases) { - assert.ok(!claimed.has(alias), `"${alias}" claimed by ${claimed.get(alias)} and ${entry.id}`); + assert.ok( + !claimed.has(alias), + `"${alias}" claimed by ${claimed.get(alias)} and ${entry.id}`, + ); claimed.set(alias, entry.id); } });