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);
}
});