mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 12:54:01 +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
@@ -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