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.
This commit is contained in:
Matt Van Horn committed 2026-10-12 19:35:29 +00:00
1 parent 8b8ccc9a81
commit ffb332472d
35 files changed
+1573 -74

No files matched your search

+8
View File
@@ -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.
+2
View File
@@ -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.
+2 -1
View File
@@ -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 | 🟢 |
+24 -1
View File
@@ -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
+2
View File
@@ -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",
+11
View File
@@ -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",
+32
View File
@@ -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": [
+135
View File
@@ -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);
},
};
}
+2
View File
@@ -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(),
];
}
+4
View File
@@ -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,
+3 -2
View File
@@ -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,
+22
View File
@@ -0,0 +1,22 @@
import * as Cesium from 'cesium';
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire FAA special-use airspace into the application catalog. */
export function createApplicationDroneAirspace(options) {
return createDroneAirspaceLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+1
View File
@@ -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']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 30);
assert.equal(actual.layers.length, 31);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+7
View File
@@ -160,6 +160,13 @@ export const DATA_CREDITS = [
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
{
key: 'faa-sua',
html:
'Drone restrictions: ' +
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
' (prohibited and restricted zones; informational only)',
},
OSM_CREDIT,
OPENMAPTILES_CREDIT,
{
+146
View File
@@ -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');
});
+5
View File
@@ -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',
+25 -9
View File
@@ -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, [
+33 -29
View File
@@ -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(
() =>
@@ -6,6 +6,7 @@
["bikeshare", "b"],
["cctv", "c"],
["directions", "n"],
["drone-airspace", "3"],
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
+3 -2
View File
@@ -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');
+93
View File
@@ -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<Array<Array<[number, number]>>>} polygons
* @returns {{lon: number, lat: number}}
*/
export function droneAirspaceAnchorDegrees(polygons) {
let best = null;
let bestArea = -1;
for (const rings of polygons) {
const outer = rings[0];
const area = ringArea(outer);
if (area > bestArea) {
bestArea = area;
best = outer;
}
}
let lon = 0;
let lat = 0;
const count = best.length - 1;
for (let i = 0; i < count; i++) {
lon += best[i][0];
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
}
function altitudeLine(row) {
const lower = row.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',
};
}
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
droneAirspaceAnchorDegrees,
droneAirspaceAccent,
buildDroneAirspaceCard,
} from './cards.js';
const square = [
[-77.2, 38.8],
[-77.0, 38.8],
[-77.0, 39.0],
[-77.2, 39.0],
[-77.2, 38.8],
];
const tiny = [
[-76.01, 38.0],
[-76.0, 38.0],
[-76.0, 38.01],
[-76.01, 38.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9);
});
test('a prohibited zone card names the type, altitudes and the disclaimer', () => {
const card = buildDroneAirspaceCard({
stableId: '310',
name: 'P-56',
typeCode: 'P',
type: 'Prohibited',
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'));
});
+326
View File
@@ -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<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
function publishSelectedCard() {
if (!canSelect()) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
_selectedCardId = null;
overlayHost.setEntries(
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const card = {
...buildDroneAirspaceCard(row),
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
};
_selectedCardId = card.id;
overlayHost.setEntries(
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
}
/** Resolve a scene pick to one of this layer's zone ids, or null. */
function pickedZoneId(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0];
return _rowById.has(zoneId) ? zoneId : null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a zone. Unowned, our picks read as
// "empty space" to siblings — the same contract as fire perimeters.
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) return;
const picked = _viewer.scene.pick(click.position);
const zoneId = picked ? pickedZoneId(picked) : null;
if (zoneId) {
_selectedId = zoneId;
publishSelectedCard();
return;
}
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
}
if (_selectedId) {
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
}
function clearSelection() {
_selectedId = null;
_selectedCardId = null;
if (overlayHost) {
overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID);
overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false);
}
}
const layer = {
id: 'drone-airspace',
name: '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;
}
+161
View File
@@ -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/);
});
+97
View File
@@ -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;
}
+95
View File
@@ -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);
});
+23
View File
@@ -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;
},
};
}
+105
View File
@@ -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/,
);
}
});
+2
View File
@@ -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(),
};
}
+3
View File
@@ -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);
});
+1
View File
@@ -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);
+1
View File
@@ -39,6 +39,7 @@ const PANEL_GROUPS = [
ids: [
'alpr-cameras',
'military-installations',
'drone-airspace',
'local-datacenters',
'telegeography-submarine-cables',
'local-dams',
+24
View File
@@ -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'],
+99 -29
View File
@@ -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);
}
});