feat(layers): add FAA drone restrictions airspace layer

Show US prohibited and restricted special-use airspace from the keyless
FAA SUA FeatureServer as an off-by-default Infrastructure layer, with
share-link token 3 and an informational-only disclaimer.
This commit is contained in:
Matt Van Horn committed 2026-10-12 19:35:29 +00:00
1 parent 8b8ccc9a81
commit ffb332472d
35 files changed
+1573 -74

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */
export const DRONE_AIRSPACE_OVERLAY_SOURCE_ID = 'drone-airspace';
/** Fill/line CSS color for a zone by TYPE_CODE. */
export function droneAirspaceAccent(typeCode) {
return typeCode === 'P' ? '#ff3b30' : '#ff7a00';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
sum += x1 * y2 - x2 * y1;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the zone card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons
* @returns {{lon: number, lat: number}}
*/
export function droneAirspaceAnchorDegrees(polygons) {
let best = null;
let bestArea = -1;
for (const rings of polygons) {
const outer = rings[0];
const area = ringArea(outer);
if (area > bestArea) {
bestArea = area;
best = outer;
}
}
let lon = 0;
let lat = 0;
const count = best.length - 1;
for (let i = 0; i < count; i++) {
lon += best[i][0];
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
}
function altitudeLine(row) {
const lower = row.lowerDesc;
const upper = row.upperDesc;
if (lower && upper) return `${lower} – ${upper}`;
if (upper) return `up to ${upper}`;
if (lower) return `from ${lower}`;
return null;
}
function placeLine(row) {
if (row.city && row.state) return `${row.city}, ${row.state}`;
return row.city || row.state || null;
}
/**
* Build the overlay-host entry for one selected zone. The caller supplies
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
* @param {Object} row - Normalized zone row (see records.js).
* @returns {Object} World-overlay entry without `position`.
*/
export function buildDroneAirspaceCard(row) {
const facts = [row.type || row.typeCode].filter(Boolean);
const altitude = altitudeLine(row);
if (altitude) facts.push(altitude);
if (row.timesOfUse) facts.push(row.timesOfUse);
const details = [facts.join(' · ')];
const place = placeLine(row);
if (place) details.push(place);
if (row.agency) details.push(row.agency);
details.push('Informational only · not for flight planning');
const title = `${(row.type || 'SUA').toUpperCase()} · ${row.name || 'Unnamed zone'}`;
return {
id: `drone-airspace-card:${row.stableId}`,
selected: true,
interactive: false,
title,
details,
accent: droneAirspaceAccent(row.typeCode),
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
};
}
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
droneAirspaceAnchorDegrees,
droneAirspaceAccent,
buildDroneAirspaceCard,
} from './cards.js';
const square = [
[-77.2, 38.8],
[-77.0, 38.8],
[-77.0, 39.0],
[-77.2, 39.0],
[-77.2, 38.8],
];
const tiny = [
[-76.01, 38.0],
[-76.0, 38.0],
[-76.0, 38.01],
[-76.01, 38.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9);
});
test('a prohibited zone card names the type, altitudes and the disclaimer', () => {
const card = buildDroneAirspaceCard({
stableId: '310',
name: 'P-56',
typeCode: 'P',
type: 'Prohibited',
lowerDesc: 'SFC',
upperDesc: 'TNI',
timesOfUse: 'CONTINUOUS',
agency: 'WASHINGTON CENTER',
city: 'WASHINGTON',
state: 'DC',
});
assert.equal(card.id, 'drone-airspace-card:310');
assert.equal(card.title, 'PROHIBITED · P-56');
assert.deepEqual(card.details, [
'Prohibited · SFC – TNI · CONTINUOUS',
'WASHINGTON, DC',
'WASHINGTON CENTER',
'Informational only · not for flight planning',
]);
assert.equal(card.accent, droneAirspaceAccent('P'));
assert.equal(card.interactive, false);
assert.equal(card.selected, true);
});
test('missing attributes degrade to available facts instead of placeholders', () => {
const card = buildDroneAirspaceCard({
stableId: 'x',
name: null,
typeCode: 'R',
type: 'Restricted',
lowerDesc: null,
upperDesc: null,
timesOfUse: null,
agency: null,
city: null,
state: null,
});
assert.equal(card.title, 'RESTRICTED · Unnamed zone');
assert.deepEqual(card.details, [
'Restricted',
'Informational only · not for flight planning',
]);
assert.equal(card.accent, droneAirspaceAccent('R'));
});
+326
View File
@@ -0,0 +1,326 @@
import * as Cesium from 'cesium';
import {
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
droneAirspaceAnchorDegrees,
buildDroneAirspaceCard,
droneAirspaceAccent,
} from './cards.js';
export { normalizeDroneAirspaceSnapshot } from './records.js';
export { createDroneAirspaceSource } from './source.js';
export * from './cards.js';
/** Fill/line color for a zone by TYPE_CODE. */
export function droneAirspaceColor(typeCode) {
return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
const PICK_PREFIX = 'drone-airspace:';
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
/** Own one drone-restriction display, its refresh lifecycle, and click selection. */
export function createDroneAirspaceLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Drone airspace requires a snapshot source');
let _viewer = null;
let _request = null;
let _snapshotSignature = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
let _selectedId = null;
let _selectedCardId = null;
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
function publishSelectedCard() {
if (!canSelect()) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
_selectedCardId = null;
overlayHost.setEntries(
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const card = {
...buildDroneAirspaceCard(row),
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
};
_selectedCardId = card.id;
overlayHost.setEntries(
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
}
/** Resolve a scene pick to one of this layer's zone ids, or null. */
function pickedZoneId(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0];
return _rowById.has(zoneId) ? zoneId : null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a zone. Unowned, our picks read as
// "empty space" to siblings — the same contract as fire perimeters.
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) return;
const picked = _viewer.scene.pick(click.position);
const zoneId = picked ? pickedZoneId(picked) : null;
if (zoneId) {
_selectedId = zoneId;
publishSelectedCard();
return;
}
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
}
if (_selectedId) {
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
}
function clearSelection() {
_selectedId = null;
_selectedCardId = null;
if (overlayHost) {
overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID);
overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false);
}
}
const layer = {
id: 'drone-airspace',
name: 'Drone Restrictions',
icon: '🚫',
source: 'FAA SUA',
updateInterval: 3600000,
init(viewer) {
if (_viewer)
throw new Error('Drone airspace layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('drone-airspace');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
console.log('[Data:DroneAirspace] Initialized');
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
installClickHandler();
},
disable() {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
removeClickHandler();
clearSelection();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const rows = await source.getSnapshot({ signal: request.signal });
if (request.signal.aborted || _request !== request || !_enabled)
return false;
const signature = JSON.stringify(
rows
.map(({ polygons, ...facts }) => facts)
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastError = null;
return true;
}
const nextEntities = [];
_rowById.clear();
for (const row of rows) {
const color = droneAirspaceColor(row.typeCode);
for (const [index, rings] of row.polygons.entries()) {
const [outer, ...holes] = rings;
const outerPositions = ringPositions(outer);
nextEntities.push(
new Cesium.Entity({
id: `drone-airspace:${row.stableId}:${index}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
outerPositions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(ringPositions(hole)),
),
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.25),
),
},
polyline: {
positions: outerPositions,
clampToGround: true,
width: 2,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.9),
),
},
}),
);
}
const { polygons, ...facts } = row;
_rowById.set(row.stableId, {
...facts,
anchor: droneAirspaceAnchorDegrees(polygons),
});
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_snapshotSignature = signature;
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = Date.now();
_lastError = null;
console.log(
`[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`,
);
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:DroneAirspace] Fetch error:', e);
_lastError = e?.message || 'Drone airspace source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
removeClickHandler();
clearSelection();
_rowById.clear();
_snapshotSignature = null;
_viewer = null;
_enabled = false;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_count = 0;
_lastUpdate = null;
_lastError = null;
},
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const row of _rowById.values()) {
if (result.length >= limit) break;
const { anchor, stableId, ...facts } = row;
result.push({
id: stableId,
...facts,
lat: anchor.lat,
lon: anchor.lon,
});
}
return result;
},
getRowControls() {
const bands = [
{ label: 'Prohibited (P)', color: droneAirspaceAccent('P') },
{ label: 'Restricted (R)', color: droneAirspaceAccent('R') },
];
return {
chips: [],
legend: bands.map((band, index) => ({
...band,
count: [..._rowById.values()].filter(
(row) => droneAirspaceAccent(row.typeCode) === band.color,
).length,
...(index === 0
? {
blurb:
'FAA special-use airspace. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).',
}
: {}),
})),
};
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
};
},
};
return layer;
}
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDroneAirspaceLayer } from './index.js';
function harness(source, { pick = () => null, cardHit = () => null } = {}) {
const sources = [];
const overlay = { entries: new Map(), visible: null };
const clicks = { handler: null, destroyed: 0 };
const owners = new Map();
const viewer = {
scene: { pick },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createDroneAirspaceLayer({
source,
overlayHost: {
setEntries(sourceId, entries) {
overlay.entries.set(sourceId, entries);
},
setVisible(sourceId, visible) {
overlay.visible = visible;
},
clearSource(sourceId) {
overlay.entries.delete(sourceId);
},
hitTest: (x, y, options) => cardHit(x, y, options),
},
screenSpaceEventHandlerFactory: () => ({
setInputAction(callback) {
clicks.handler = callback;
},
destroy() {
clicks.destroyed += 1;
clicks.handler = null;
},
}),
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (layerId, pickedId) =>
String(pickedId).startsWith('other-layer:'),
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
unregisterPickOwner: (layerId) => owners.delete(layerId),
},
pointer: { isPointerFree: () => true },
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, overlay, clicks, owners };
}
const ring = [
[-77.04, 38.89],
[-77.03, 38.89],
[-77.03, 38.9],
[-77.04, 38.89],
];
const row = {
stableId: '310',
name: 'P-56',
typeCode: 'P',
type: 'Prohibited',
lowerDesc: 'SFC',
upperDesc: 'TNI',
timesOfUse: 'CONTINUOUS',
agency: null,
city: 'WASHINGTON',
state: 'DC',
polygons: [[ring]],
};
test('each zone polygon renders as one filled entity with its outline', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] },
],
});
assert.equal(await h.layer.update(h.viewer), true);
const entities = h.sources[0].entities.values;
assert.equal(entities.length, 3);
assert.equal(entities[0].id, 'drone-airspace:310:0');
assert.ok(entities[0].polygon, 'zone fill missing');
assert.ok(entities[0].polyline, 'zone outline missing');
assert.equal(h.layer.getStats().count, 2);
});
test('clicking a zone publishes its card; empty space clears it', async () => {
let pickResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{ pick: () => pickResult },
);
await h.layer.update(h.viewer);
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
assert.equal(h.owners.size, 0);
pickResult = { id: 'drone-airspace:310:0' };
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('drone-airspace');
assert.equal(entries.length, 1);
assert.equal(entries[0].id, 'drone-airspace-card:310');
assert.equal(entries[0].title, 'PROHIBITED · P-56');
assert.ok(entries[0].position, 'card must carry a world anchor');
pickResult = { id: 'other-layer:aircraft-1' };
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(
h.overlay.entries.get('drone-airspace')?.length,
1,
'sibling-layer picks must not clear the selection',
);
pickResult = null;
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0);
});
test('late refresh cannot publish after disable or destroy', async () => {
for (const action of ['disable', 'destroy']) {
let resolve, signal;
const h = harness({
getSnapshot(options) {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
h.layer.destroy(h.viewer);
}
});
test('the row legend names prohibited and restricted with the disclaimer', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: '400', typeCode: 'R', type: 'Restricted' },
],
});
await h.layer.update(h.viewer);
const legend = h.layer.getRowControls().legend;
assert.equal(legend[0].label, 'Prohibited (P)');
assert.equal(legend[1].label, 'Restricted (R)');
assert.equal(legend[0].count, 1);
assert.equal(legend[1].count, 1);
assert.match(legend[0].blurb, /not a substitute/);
});
+97
View File
@@ -0,0 +1,97 @@
/**
* Normalize an FAA Special Use Airspace feed, skipping individually invalid
* features. Only a payload that is not a feature collection at all rejects
* the whole snapshot: a single degenerate polygon must never blank the layer.
*/
const TYPE_LABELS = Object.freeze({
P: 'Prohibited',
R: 'Restricted',
});
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
const textOrNull = (value) => {
if (typeof value !== 'string') return null;
const text = value.trim();
return text ? text : null;
};
function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return false;
for (const position of ring) {
if (!Array.isArray(position) || position.length < 2) return false;
const [lon, lat] = position;
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
}
return true;
}
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
* array of rings). Returns null on malformed geometry, [] when empty. */
function normalizePolygons(geometry) {
if (!geometry || typeof geometry !== 'object') return null;
let polygons;
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
else return null;
if (!Array.isArray(polygons)) return null;
const result = [];
for (const rings of polygons) {
if (!Array.isArray(rings)) return null;
if (!rings.length) continue;
if (!rings.every(validRing)) return null;
result.push(rings);
}
return result;
}
/** Human label for a TYPE_CODE, or null when the code is not in the pack. */
export function droneAirspaceTypeLabel(typeCode) {
return TYPE_LABELS[typeCode] || null;
}
export function normalizeDroneAirspaceSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
continue;
const typeCode = textOrNull(properties.TYPE_CODE);
const type = droneAirspaceTypeLabel(typeCode);
if (!type) continue;
const polygons = normalizePolygons(feature.geometry);
if (polygons === null || !polygons.length) continue;
const objectId = finiteOrNull(Number(properties.OBJECTID));
const featureId =
feature.id == null || feature.id === '' ? null : String(feature.id);
const stableId =
objectId != null
? String(objectId)
: featureId && !ids.has(featureId)
? featureId
: null;
if (stableId == null || ids.has(stableId)) continue;
ids.add(stableId);
rows.push({
stableId,
name: textOrNull(properties.NAME),
typeCode,
type,
lowerDesc: textOrNull(properties.LOWER_DESC),
upperDesc: textOrNull(properties.UPPER_DESC),
timesOfUse: textOrNull(properties.TIMESOFUSE),
agency: textOrNull(properties.CONT_AGENT),
city: textOrNull(properties.CITY),
state: textOrNull(properties.STATE),
polygons,
});
}
return rows;
}
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
droneAirspaceTypeLabel,
normalizeDroneAirspaceSnapshot,
} from './records.js';
const ring = [
[-77.04, 38.89],
[-77.03, 38.89],
[-77.03, 38.9],
[-77.04, 38.89],
];
function feature(overrides = {}) {
return {
id: 310,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
NAME: 'P-56',
TYPE_CODE: 'P',
UPPER_DESC: 'TNI',
LOWER_DESC: 'SFC',
TIMESOFUSE: 'CONTINUOUS',
CONT_AGENT: 'WASHINGTON CENTER',
CITY: 'WASHINGTON',
STATE: 'DC',
OBJECTID: 310,
...overrides.properties,
},
...overrides,
};
}
test('a valid snapshot yields one row per prohibited or restricted zone', () => {
const rows = normalizeDroneAirspaceSnapshot({
features: [
feature(),
feature({
id: 400,
properties: {
NAME: 'R-2508',
TYPE_CODE: 'R',
OBJECTID: 400,
},
}),
],
});
assert.equal(rows.length, 2);
assert.deepEqual(rows[0], {
stableId: '310',
name: 'P-56',
typeCode: 'P',
type: 'Prohibited',
lowerDesc: 'SFC',
upperDesc: 'TNI',
timesOfUse: 'CONTINUOUS',
agency: 'WASHINGTON CENTER',
city: 'WASHINGTON',
state: 'DC',
polygons: [[ring]],
});
assert.equal(rows[1].type, 'Restricted');
assert.equal(rows[1].typeCode, 'R');
});
test('MOA, warning and advisory codes are dropped, as are bad geometries', () => {
const rows = normalizeDroneAirspaceSnapshot({
features: [
feature({ properties: { TYPE_CODE: 'MOA', OBJECTID: 1 } }),
feature({ properties: { TYPE_CODE: 'W', OBJECTID: 2 } }),
feature({ properties: { TYPE_CODE: 'A', OBJECTID: 3 } }),
feature({ geometry: null, properties: { OBJECTID: 4 } }),
feature({
geometry: { type: 'Point', coordinates: [-77, 38] },
properties: { OBJECTID: 5 },
}),
feature(),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].name, 'P-56');
});
test('duplicate ids and a missing collection reject or skip as documented', () => {
const dup = normalizeDroneAirspaceSnapshot({
features: [feature(), feature({ id: 311 })],
});
assert.equal(dup.length, 1);
assert.equal(normalizeDroneAirspaceSnapshot({}), null);
assert.equal(normalizeDroneAirspaceSnapshot(null), null);
assert.equal(droneAirspaceTypeLabel('P'), 'Prohibited');
assert.equal(droneAirspaceTypeLabel('R'), 'Restricted');
assert.equal(droneAirspaceTypeLabel('MOA'), null);
});
+23
View File
@@ -0,0 +1,23 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/** Request a normalized snapshot through the bounded, same-origin FAA proxy. */
export function createDroneAirspaceSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/drone-airspace', { signal });
if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed drone airspace snapshot');
return payload.rows;
},
};
}
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDroneAirspaceSource } from './source.js';
import { normalizeDroneAirspaceSnapshot } from './records.js';
import { droneAirspaceProxy } from '../../../server/providers/droneAirspace.js';
const ring = [
[-77.04, 38.89],
[-77.03, 38.89],
[-77.03, 38.9],
[-77.04, 38.89],
];
const validPayload = {
features: [
{
id: 310,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
NAME: 'P-56',
TYPE_CODE: 'P',
OBJECTID: 310,
},
},
],
};
test('a successful response yields normalized zone rows', async () => {
let requested;
const source = createDroneAirspaceSource({
fetchImpl: async (url) => {
requested = String(url);
return Response.json({
rows: normalizeDroneAirspaceSnapshot(validPayload),
});
},
});
const rows = await source.getSnapshot();
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, '310');
assert.equal(requested, '/api/drone-airspace');
});
test('a truncated response pages until the feed is complete', async () => {
const pageRing = (id) => ({
id,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { NAME: `P-${id}`, TYPE_CODE: 'P', OBJECTID: id },
});
const requests = [];
let handler;
const plugin = droneAirspaceProxy({
fetchImpl: async (url) => {
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
const page = requests.length;
return Response.json({
features: [pageRing(page)],
properties: { exceededTransferLimit: page < 3 },
});
},
});
plugin.configureServer({
middlewares: {
use: (_path, callback) => {
handler = callback;
},
},
});
let payload;
await handler(
{ url: '/', method: 'GET' },
{
writeHead(status) {
assert.equal(status, 200);
},
end(body) {
payload = JSON.parse(body);
},
},
);
const rows = payload.rows;
assert.deepEqual(
rows.map((row) => row.stableId),
['1', '2', '3'],
);
assert.deepEqual(requests, [null, '1', '2']);
});
test('an upstream failure surfaces its HTTP status', async () => {
const source = createDroneAirspaceSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(source.getSnapshot(), /FAA SUA HTTP 503/);
});
test('a malformed successful response is never accepted as an empty snapshot', async () => {
for (const payload of [{}, { rows: null }, { rows: {} }]) {
const source = createDroneAirspaceSource({
fetchImpl: async () => Response.json(payload),
});
await assert.rejects(
source.getSnapshot(),
/Malformed drone airspace snapshot/,
);
}
});