mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
8b8ccc9a81
commit
ffb332472d
35 files changed
+1573
-74
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -39,6 +39,7 @@ const PANEL_GROUPS = [
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
'drone-airspace',
|
||||
'local-datacenters',
|
||||
'telegeography-submarine-cables',
|
||||
'local-dams',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user