Merge branch 'main' into feat/typed-ai-chat

# Conflicts:
#	src/i18n/locales/ar.json
This commit is contained in:
Sameh Khamis committed 2026-11-03 15:45:43 -08:00
commit b6967a3dd9
69 files changed
+5296 -125

No files matched your search

+4
View File
@@ -29,6 +29,7 @@ import { createApplicationAirQuality } from './layers/airQuality.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
@@ -133,6 +134,9 @@ export function createApplicationCatalog({
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 36);
assert.equal(first.layers.length, 37);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'),
@@ -67,6 +67,7 @@ test('catalogs construct distinct layers and classification from their supplied
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('drone-airspace'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -160,7 +161,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, 36);
assert.equal(catalog.layers.length, 37);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+22
View File
@@ -0,0 +1,22 @@
import * as Cesium from 'cesium';
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire FAA special-use airspace into the application catalog. */
export function createApplicationDroneAirspace(options) {
return createDroneAirspaceLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+1
View File
@@ -49,6 +49,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 36);
assert.equal(actual.layers.length, 37);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+172
View File
@@ -0,0 +1,172 @@
/**
* @module awarenessEcho
* @description The Global Context sonar echo field. The awareness layer
* publishes it while its range pulses run; the detection overlay reads it to
* pulse the brackets of contacts a pulse front has just crossed. It holds no
* awareness policy of its own: the publisher passes radius, pulse timing and
* echo styling in, so the overlay never imports awareness internals.
*/
import * as Cesium from 'cesium';
/**
* @typedef {object} AwarenessEchoField
* @property {Cesium.Cartesian3} center Subject's ground point. The publisher
* may move it in place; it is read on every query.
* @property {number} radiusM Range the pulses grow to.
* @property {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
* @property {{fadeMs: number, layerIds: string[]}} echo
* @property {(nowMs: number) => number} [level] How far the pulses are
* switched on, 0 to 1; echoes scale with it, so they rest with the pulses.
*/
/** @type {AwarenessEchoField|null} */
let field = null;
let layerIds = new Set();
const groundScratch = new Cesium.Cartesian3();
/**
* Start (or replace) the echo field.
* @param {AwarenessEchoField} next
* @returns {void}
*/
export function setAwarenessEchoField(next) {
field = next;
layerIds = new Set(next.echo.layerIds);
}
/** Stop all echoes. Safe when none were set. */
export function clearAwarenessEchoField() {
field = null;
layerIds = new Set();
}
/** @returns {AwarenessEchoField|null} The field the overlay styles from. */
export function getAwarenessEchoField() {
return field;
}
/**
* Whether any echo can light at `nowMs`: a field is set and its pulses are
* running. The overlay checks this once per frame, so resting pulses cost no
* per-contact work.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {boolean}
*/
export function awarenessEchoActive(nowMs) {
return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0;
}
/**
* How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches
* its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The
* strongest pulse wins. A front that has wrapped back to the centre still
* counts for the contact it last crossed, so rim contacts fade out instead of
* cutting off. Nothing nearer than the birth ring `startFraction` out echoes,
* and within the pulse's `fadeInFraction` the echo is scaled down with it.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} distanceM Contact's distance from the subject.
* @param {number} radiusM Range the pulses grow to.
* @param {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
* @param {{fadeMs: number}} echo
* @returns {number} 0 to 1.
*/
export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) {
// Pulses are born `startFraction` out, so nothing nearer (the subject
// itself, its wingmen) is ever crossed; without this it lit on every birth.
if (
!Number.isFinite(distanceM) ||
distanceM < radiusM * pulse.startFraction ||
distanceM > radiusM
)
return 0;
// The phase at which a pulse front sits at this distance.
const touchPhase =
(distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction);
let strength = 0;
for (let index = 0; index < pulse.count; index++) {
const cycle = nowMs / pulse.periodMs + index / pulse.count;
let since = cycle - Math.floor(cycle) - touchPhase;
// Rounding at the exact crossing must not read as a whole period ago.
if (since < -1e-9) since += 1;
else if (since < 0) since = 0;
const elapsedMs = since * pulse.periodMs;
if (elapsedMs < echo.fadeMs)
strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2);
}
// A front still fading in over `fadeInFraction` of its travel echoes no
// brighter than it shows.
const fadeIn = pulse.fadeInFraction ?? 0;
return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength;
}
/**
* The echo for one contact of `layerId` at world `position`, or 0 when no
* field is set or the layer does not echo. Distance is measured between
* ground points, so an aircraft's altitude does not shift its echo.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {number} 0 to 1.
*/
export function awarenessEchoAt(layerId, position, nowMs) {
if (!field || !position || !layerIds.has(layerId)) return 0;
const level = field.level?.(nowMs) ?? 1;
if (!(level > 0)) return 0;
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
position,
groundScratch,
);
if (!ground) return 0;
return (
level *
awarenessEchoStrength(
nowMs,
Cesium.Cartesian3.distance(field.center, ground),
field.radiusM,
field.pulse,
field.echo,
)
);
}
/**
* The echo bracket the detection overlay draws around one contact, or null
* when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's
* strength times the contact bracket's own `bracketAlpha` rounded to the
* nearest band, so an echo is never drawn brighter than its bracket; one
* that rounds to nothing is not drawn. `spreadPx` is how far the echo sits
* outside the contact's bracket, 0 as the front arrives, growing to the
* field's `spreadPx` as it fades.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1.
* @param {number} steps Alpha bands the overlay batches by.
* @returns {{band: number, spreadPx: number}|null}
*/
export function awarenessEchoBracket(
layerId,
position,
nowMs,
bracketAlpha,
steps,
) {
const echo = awarenessEchoAt(layerId, position, nowMs);
if (!(echo > 0.01)) return null;
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
if (!(band >= 1)) return null;
return { band, spreadPx: field.echo.spreadPx * (1 - echo) };
}
/**
* The echo's stroke colour: the field's own colour on the default detection
* style, and the style's line colour under the others (NVG, FLIR, CRT), so
* an echo never stands out of a monochrome palette.
* @param {string} styleLine The active detection style's line colour.
* @param {boolean} defaultStyle Whether the default detection style is active.
* @returns {string|null} Null when no field is set.
*/
export function awarenessEchoColor(styleLine, defaultStyle) {
if (!field) return null;
return defaultStyle ? field.echo.color : styleLine;
}
+141
View File
@@ -0,0 +1,141 @@
// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar
// pulse front crosses a flight or vessel, its bracket echo lights and fades.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoActive,
awarenessEchoAt,
awarenessEchoStrength,
clearAwarenessEchoField,
getAwarenessEchoField,
setAwarenessEchoField,
} from './awarenessEcho.js';
const R = 250_000;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] };
const strength = (nowMs, distanceM) =>
awarenessEchoStrength(nowMs, distanceM, R, pulse, echo);
/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */
const arrival = (distanceM) =>
((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 4;
test('an echo lights as a pulse front reaches the contact, then fades', () => {
const d = 100_000;
const t0 = arrival(d);
assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6);
assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6);
assert.equal(strength(t0 + echo.fadeMs + 1, d), 0);
assert.equal(strength(t0 - 50, d), 0, 'dark just before the front');
// Every pulse lights it once a period: `count` separate flashes in any
// one-period window that starts after a flash has faded.
let flashes = 0;
let wasLit = false;
const from = t0 + echo.fadeMs + 1;
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
const lit = strength(from + ms, d) > 0.5;
if (lit && !wasLit) flashes++;
wasLit = lit;
}
assert.equal(flashes, pulse.count);
});
test('rim contacts fade out after the front wraps; outside the range never', () => {
const rim = R * 0.999;
assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2);
// Inside the ring pulses are born on, nothing is ever crossed: the
// subject and anything within 5 km never echo, at any moment.
const inner = R * pulse.startFraction;
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
assert.equal(strength(ms, 0), 0, 'the subject itself');
assert.equal(strength(ms, inner - 1), 0);
}
assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring');
for (let ms = 0; ms < pulse.periodMs; ms += 50)
assert.equal(strength(ms, R + 1), 0);
assert.equal(strength(0, Number.NaN), 0);
assert.equal(strength(0, -1), 0);
});
test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => {
t.after(clearAwarenessEchoField);
const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo');
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
setAwarenessEchoField({ center, radiusM: R, pulse, echo });
assert.ok(getAwarenessEchoField());
const d = Cesium.Cartesian3.distance(center, contact);
const t0 = arrival(d);
assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99);
assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99);
// Layers outside the echo list never light.
assert.equal(awarenessEchoAt('cctv', contact, t0), 0);
assert.equal(awarenessEchoAt('military-installations', contact, t0), 0);
// An aircraft 11 km up echoes with the ground point under it.
const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000);
assert.ok(
Math.abs(
awarenessEchoAt('flights', aloft, t0 + 300) -
awarenessEchoAt('flights', contact, t0 + 300),
) < 0.01,
);
// The field's centre is read in place, so a moved subject moves the echo:
// about 50 km farther, the contact lights at its new distance's moment.
Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center);
const moved = Cesium.Cartesian3.distance(center, contact);
assert.ok(Math.abs(moved - d) > 1000);
assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99);
assert.equal(awarenessEchoAt('flights', null, t0), 0);
clearAwarenessEchoField();
assert.equal(getAwarenessEchoField(), null);
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
});
test('echoes scale with the field level and rest with the pulses', (t) => {
t.after(clearAwarenessEchoField);
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
// ~100 km east of the subject, on the ground.
const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0);
let level = 1;
setAwarenessEchoField({
center,
radiusM: R,
pulse,
echo,
level: () => level,
});
const d = Cesium.Cartesian3.distance(center, contact);
const t0 = arrival(d);
const full = awarenessEchoAt('flights', contact, t0);
assert.ok(full > 0.99);
level = 0.5;
assert.ok(
Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9,
);
assert.equal(awarenessEchoActive(t0), true);
level = 0;
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
// At rest the overlay skips echoes for the whole frame.
assert.equal(awarenessEchoActive(t0), false);
clearAwarenessEchoField();
assert.equal(awarenessEchoActive(t0), false);
});
test('near the subject an echo is no brighter than the fading-in front', () => {
const fading = { ...pulse, fadeInFraction: 0.08 };
const at = (distanceM) =>
awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo);
// Born at 5 km, the front reaches full strength 8% of the way out.
const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction));
assert.equal(at(R * fading.startFraction), 0);
const halfway = (R * fading.startFraction + full) / 2;
assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`);
assert.ok(at(full + 1) > 0.99);
assert.ok(
at(R * 0.999) > 0.99,
'the rim, and so the ring flash, is unchanged',
);
});
+403
View File
@@ -0,0 +1,403 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
houstonCameraName,
houstonCameraToSource,
loadHoustonSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_CAMERAS_URL,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_IMAGE_ORIGIN,
HOUSTON_IMAGE_URL,
HOUSTON_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyHoustonCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const DOWNTOWN_ID = '1002';
const GALLERIA_ID = '2001';
/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */
const row = (overrides = {}) => ({
location: '10 EAST @ SAN JACINTO',
id: DOWNTOWN_ID,
lat: '29.76824',
lng: '-95.355711',
dir: 'South',
fc: '6',
fp: '300',
url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')",
...overrides,
});
test('a Houston row maps to a source on the pinned HTTPS frame host', () => {
const source = houstonCameraToSource(row());
assert.equal(source.id, `houston-${DOWNTOWN_ID}`);
assert.equal(source.name, '10 EAST @ SAN JACINTO');
assert.equal(source.city, 'Houston');
assert.equal(source.cityId, 'houston');
assert.equal(source.provider, 'Houston TranStar');
assert.equal(source.lat, 29.76824);
assert.equal(source.lon, -95.355711);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'houston-transtar');
assert.equal(
source.license,
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
);
assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN));
// dir South = the camera sits south of I-10; the nearest lanes run east.
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, 90);
});
test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => {
const source = houstonCameraToSource(
row({ url: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('zero ids, off-city geometry and unusable ids are dropped', () => {
assert.equal(houstonCameraToSource(row({ id: '0' })), null);
assert.equal(houstonCameraToSource(row({ id: '00' })), null);
assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(houstonCameraToSource(row({ id: '12' })), null);
assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null);
// Dallas: a plausible lat/lon, but not a Houston camera.
assert.equal(
houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })),
null,
);
assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null);
assert.equal(houstonCameraToSource(null), null);
// 3-digit and 5-digit snapshot ids stay.
assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102');
assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711');
});
test('dir is the side of the road: it gives a low-confidence travel heading', () => {
// TranStar's map reads dir as "Traffic closest to the camera is traveling":
// South -> Eastbound, North -> Westbound, West -> Southbound, East -> Northbound.
const expected = { South: 90, North: 270, West: 180, East: 0, south: 90 };
for (const [dir, heading] of Object.entries(expected)) {
const source = houstonCameraToSource(row({ dir }));
assert.equal(source.headingDeg, heading, `dir ${dir}`);
assert.equal(source.headingConfidence, 'low', `dir ${dir}`);
assert.equal(source.pitchDeg, -18, `dir ${dir}`);
assert.equal(source.fovDeg, 44, `dir ${dir}`);
assert.equal(source.rangeM, 145, `dir ${dir}`);
assert.equal(source.mountHeightM, 8, `dir ${dir}`);
}
const none = houstonCameraToSource(row({ dir: 'None' }));
assert.equal(none.headingConfidence, 'low');
assert.ok(Number.isFinite(none.headingDeg));
assert.equal(
none.headingDeg,
houstonCameraToSource(row({ dir: '' })).headingDeg,
);
for (const dir of ['Northeast', 'constructor', '__proto__', undefined]) {
assert.equal(
houstonCameraToSource(row({ dir })).headingDeg,
none.headingDeg,
`dir ${dir} falls back to the id hash`,
);
}
});
test('a nameless row still gets a label', () => {
assert.equal(
houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'),
'45 GULF @ WEST DALLAS',
);
assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102');
assert.equal(houstonCameraName({}, ''), 'Houston Camera');
});
test('the unselected label is the interchange, trimmed to the code width', () => {
assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO');
const long = houstonCameraToSource(
row({
location:
'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.headers.Accept, init.redirect]);
return Response.json({
cameras: [
row(),
row({ location: '10 EAST @ SAN JACINTO (duplicate)' }),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'North',
}),
row({ url: 'https://evil.example/x.jpg', id: '0' }),
],
});
});
const cameras = await loadHoustonSourcesFromOpenData();
assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'nearest-to-anchor first: Uptown sits closer to its pin than downtown does',
);
assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.js' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify({ cameras: [row()] });
return new Response(body, {
headers: {
'Content-Type': 'application/javascript',
'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
// Valid JSON with a usable camera, so only the byte cap can refuse it.
const oversized = JSON.stringify({
cameras: [row()],
padding: 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024),
});
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/javascript' } },
);
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_HOUSTON_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES;
else process.env.CCTV_HOUSTON_MAX_SOURCES = saved;
});
process.env.CCTV_HOUSTON_MAX_SOURCES = '8';
const rows = [
...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) =>
row({
id: String(3000 + i),
lat: String(lat),
lng: '-94.80',
location: `Galveston ${i}`,
dir: 'East',
}),
),
...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) =>
row({
id: String(4000 + i),
lat: String(lat),
lng: '-95.3698',
location: `Downtown ${i}`,
dir: 'North',
}),
),
];
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ cameras: rows }),
);
const cameras = await loadHoustonSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.name.slice(0, 8)),
[
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Galvesto',
'Galvesto',
],
'downtown fills first; the Galveston anchor still keeps a share',
);
});
/**
* Serve the Houston catalog to the Houston endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === HOUSTON_CAMERAS_URL) {
return Response.json({
cameras: [
row(),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'West',
}),
],
});
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Houston lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HOUSTON_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(HOUSTON_CAMERAS_URL),
'the catalog refresh invokes the Houston loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston').map((s) => s.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'Houston cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
for (const value of [undefined, '0']) {
if (value === undefined) delete process.env.CCTV_HOUSTON_ENABLED;
else process.env.CCTV_HOUSTON_ENABLED = value;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(HOUSTON_CAMERAS_URL),
false,
'the pack reaches TranStar only after an explicit opt-in',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston'),
[],
);
}
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
});
test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => {
assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown
assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston
assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH
assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas
assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyHoustonCoordinate(0, 0), false);
assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false);
});
+2
View File
@@ -34,6 +34,8 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'ontario 511': 3 * 60 * 1000,
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
txdot: 3 * 60 * 1000,
// TranStar says snapshots update about every 3 minutes.
'houston transtar': 3 * 60 * 1000,
// Digitraffic publishes a new weathercam frame on each station's
// collectionInterval, 600 s for every station sampled; polling faster only
// re-fetches the same JPEG.
+1
View File
@@ -639,6 +639,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Houston TranStar' }), 180_000);
// Digitraffic publishes one weathercam frame per 600 s collection interval.
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
assert.equal(
+15
View File
@@ -181,6 +181,13 @@ export const DATA_CREDITS = [
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
{
key: 'faa-sua',
html:
'Prohibited & restricted airspace: ' +
'<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,
{
@@ -311,6 +318,14 @@ export const DATA_CREDITS = [
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'houston-cctv',
html:
'CCTV cameras &amp; frames (Houston, when enabled): ' +
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>. ' +
"Copying, distributing or framing its images needs TxDOT's written permission under the " +
'<a href="https://traffic.houstontranstar.org/disclaimer.aspx" target="_blank" rel="noopener">TranStar Content Usage terms</a>',
},
{
key: 'hk-cctv',
html:
+12
View File
@@ -129,6 +129,18 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /observe posted signage/);
});
test("the Houston camera credit names TranStar's terms and the permission they require", () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'houston-cctv');
assert.ok(credit, 'Houston TranStar camera frames must be credited');
assert.match(credit.html, /\(Houston, when enabled\)/);
assert.ok(
credit.html.includes(
'href="https://traffic.houstontranstar.org/disclaimer.aspx"',
),
);
assert.match(credit.html, /TxDOT's written permission/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
+45
View File
@@ -53,6 +53,12 @@ import {
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
import {
awarenessEchoActive,
awarenessEchoBracket,
awarenessEchoColor,
getAwarenessEchoField,
} from '../awarenessEcho.js';
/**
* @module detection
@@ -103,6 +109,8 @@ const SPARSE_ZONE_FRACTION = 0.5;
const LABEL_SOLVE_INTERVAL_MS = 125;
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
const BRACKET_ALPHA_STEPS = 4;
/** Finer bands for Global Context echo brackets, whose fade is animated. */
const ECHO_ALPHA_STEPS = 8;
/** Stable per-layer candidate safety cap; independent of density and camera bearing. */
const LAYER_CANDIDATE_CAP = 2600;
const LAYER_WEIGHTS = Object.freeze({
@@ -1335,6 +1343,11 @@ function _drawOverlay(frame) {
const tiers = _theme.tiers || null;
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map();
// Global Context sonar echoes: a second bracket, banded by strength, around
// each flight or vessel a pulse front has just crossed.
// Resting pulses light nothing, so the per-contact echo work is skipped.
const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null;
const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null;
const pathFor = (map, color, alpha) => {
const band = Math.max(
1,
@@ -1437,6 +1450,24 @@ function _drawOverlay(frame) {
halfH,
);
visibleCount++;
if (echoPaths) {
// Starts on the contact's bracket and spreads outward as it fades.
const echo = awarenessEchoBracket(
obj._layerId,
obj.position,
now,
bracketAlpha,
ECHO_ALPHA_STEPS,
);
if (echo)
appendCornerBracket(
(echoPaths[echo.band] ??= new Path2D()),
sx,
sy,
halfW + echo.spreadPx,
halfH + echo.spreadPx,
);
}
if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
@@ -1576,6 +1607,20 @@ function _drawOverlay(frame) {
_ctx.stroke(entry.path);
}
}
if (echoPaths) {
_ctx.lineWidth = echoField.echo.lineWidth;
_ctx.strokeStyle = awarenessEchoColor(
_theme.line,
_theme === THEME_MAP._default,
);
for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) {
if (!echoPaths[band]) continue;
_ctx.globalAlpha =
fade * (band / ECHO_ALPHA_STEPS) * bracketPresentationOpacity;
_ctx.stroke(echoPaths[band]);
}
_ctx.lineWidth = bracketWidth;
}
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
const fadingCount = countFadingRenderEntries(renderEntries);
+107
View File
@@ -0,0 +1,107 @@
// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse
// a contact's own detection bracket: a second bracket that starts on it and
// spreads outward as it fades, in the detection style's own colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoBracket,
awarenessEchoColor,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
const STEPS = 8;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = {
fadeMs: 1400,
layerIds: ['flights', 'military', 'ais-live-vessels'],
spreadPx: 8,
color: 'rgb(98, 181, 255)',
};
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
const field = { center, radiusM: 250_000, pulse, echo };
/** The moment pulse 0's front reaches the ground under `position`. */
function arrivalAt(position) {
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position);
const d = Cesium.Cartesian3.distance(center, ground);
return (
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 3
);
}
test('the echo bracket pulses outward from the contact bracket and fades', (t) => {
t.after(clearAwarenessEchoField);
setAwarenessEchoField(field);
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
const arrival = arrivalAt(position);
const at = (nowMs, bracketAlpha = 1) =>
awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS);
// The moment the front arrives: on the bracket, strongest band.
const fresh = at(arrival);
assert.ok(fresh);
assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`);
assert.equal(fresh.band, STEPS);
// Half way through the fade: dimmer and spread outward, but not past
// the field's spread.
const half = at(arrival + echo.fadeMs / 2);
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
assert.ok(half.spreadPx < echo.spreadPx);
assert.ok(half.band < STEPS && half.band >= 1);
// The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries
// over, and the echo is never drawn brighter than its bracket: a bracket
// too dim for even one band draws no echo.
assert.equal(at(arrival, 0.25).band, 2);
assert.equal(at(arrival, 0.05), null);
for (let alpha = 0.05; alpha <= 1; alpha += 0.05) {
const echoed = at(arrival + echo.fadeMs / 3, alpha);
if (echoed)
assert.ok(
echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9,
`band ${echoed.band} for a bracket at ${alpha}`,
);
}
// Faded out, or between fronts: no echo bracket.
assert.equal(at(arrival + echo.fadeMs + 5), null);
});
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
t.after(clearAwarenessEchoField);
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
clearAwarenessEchoField();
for (let now = 0; now < pulse.periodMs; now += 25)
assert.equal(
awarenessEchoBracket('flights', position, now, 1, STEPS),
null,
);
setAwarenessEchoField(field);
for (let now = 0; now < pulse.periodMs; now += 25)
for (const layerId of ['cctv', 'satellites', 'traffic'])
assert.equal(
awarenessEchoBracket(layerId, position, now, 1, STEPS),
null,
);
});
test('echoes take the detection style colour except on the default style', (t) => {
t.after(clearAwarenessEchoField);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
null,
);
setAwarenessEchoField(field);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
echo.color,
);
// CRT, NVG and FLIR keep their monochrome palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
const { line } = DETECTION_THEME_MAP[style];
assert.equal(awarenessEchoColor(line, false), line, style);
}
});
+233 -3
View File
@@ -24,6 +24,10 @@ import {
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
@@ -50,8 +54,9 @@ class MockEvent {
}
class MockPath2D {
moveTo() {}
lineTo() {}
constructor() { this.points = []; }
moveTo(x, y) { this.points.push([x, y]); }
lineTo(x, y) { this.points.push([x, y]); }
roundRect() {}
arcTo() {}
closePath() {}
@@ -98,7 +103,7 @@ function mockContext(target, trace) {
// set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour.
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
@@ -627,6 +632,231 @@ test('surveillance military tier reaches the dedicated render target as shipped
}
});
test('Global Context echo brackets reach the canvas in the detection style colour', () => {
const env = installEnvironment();
// Fronts every millisecond with a long fade, so the contact is lit on
// whatever frame paints; a distinctive width picks the echo stroke out.
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const echoStrokes = () =>
env.detectionCtx.calls
.filter(([name, , , width]) => name === 'stroke' && width === echo.lineWidth)
.map(([, , style]) => style);
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
assert.ok(echoStrokes().length > 0, 'the echo bracket is painted');
assert.ok(echoStrokes().every((style) => style === echo.color));
// CRT, NVG and FLIR keep their own palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
env.detectionCtx.calls.length = 0;
setDetectionStyle(style);
settleFrame(env);
const strokes = echoStrokes();
assert.ok(strokes.length > 0, style);
assert.ok(
strokes.every((stroke) => stroke === DETECTION_THEME_MAP[style].line),
`${style} echoes take the style line colour, got ${strokes}`,
);
}
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => {
const env = installEnvironment();
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
// Fronts every millisecond keep the echo at full strength; a level of 0.5
// halves it, so it sits 4 px out and draws at half the bracket's alpha.
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 0.5,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
assert.ok(echoStroke, 'the echo bracket is painted');
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
assert.ok(bracketStroke, 'the contact bracket is painted');
const extent = (path) => {
const xs = path.points.map(([x]) => x);
const ys = path.points.map(([, y]) => y);
return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 };
};
const echoBox = extent(echoStroke[1]);
const bracketBox = extent(bracketStroke[1]);
assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3,
`echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`);
assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3);
const echoAlpha = echoStroke[4];
const bracketAlpha = bracketStroke[4];
assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`);
assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => {
const env = installEnvironment();
// The cyber theme with its HUD open turns the contact sonar on; its sweep
// dims brackets it is not passing over.
env.document.documentElement = {
// The lowest sweep floor, so dimmed brackets drop a whole alpha band.
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
style: { setProperty() {}, removeProperty() {} },
};
const hud = env.document.createElement('div');
hud.id = 'intel-hud';
hud.classList = { contains: (name) => name === 'active' };
env.document.body.appendChild(hud);
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const contact = {
id: 'military',
getDetectableObjects: () => [{
position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752),
sourceId: 'sonar-pin',
id: 'SONAR',
metric: 'FL300',
type: 'AIR',
tier: 'military',
}],
};
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 1,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contact], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
let dimmed = 0;
let compared = 0;
for (let frame = 0; frame < 40; frame++) {
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
if (!bracketStroke) continue;
const bracketAlpha = bracketStroke[4];
if (bracketAlpha < 0.99) dimmed++;
if (!echoStroke) continue;
compared++;
assert.ok(
echoStroke[4] <= bracketAlpha + 1e-9,
`echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`,
);
}
assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame');
assert.ok(compared > 0, 'an echo was painted');
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => {
const env = installEnvironment();
// Six echo-eligible flights on screen; the identity camera maps x/y
// straight to device coordinates.
const flights = Array.from({ length: 6 }, (_, index) => ({
position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752),
sourceId: `flight-${index}`,
id: `ECHO${index}`,
metric: `FL${300 + index}`,
type: 'AIR',
}));
const layer = { id: 'flights', getDetectableObjects: () => flights };
let levelCalls = 0;
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo: {
layerIds: ['flights'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
},
level: () => {
levelCalls++;
return 0;
},
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [layer], () => {});
setMode('DENSE');
settleFrame(env);
levelCalls = 0;
settleFrame(env);
const painted = getDetectionDiagnostics().visibleCount;
assert.equal(painted, flights.length, 'every flight is on screen');
// One check per painted frame, however many contacts are on screen.
assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live');
assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`);
assert.ok(
!env.detectionCtx.calls.some(
([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)',
),
'no echoes while the pulses rest',
);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('pathological detection paint holds alternate frames without freezing shared lanes', () => {
// Opts into the telemetry banner: the held-frame assertions count banner paints.
const env = installEnvironment({ search: '?detectDebug=1' });
+292
View File
@@ -0,0 +1,292 @@
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', headers = {}) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.status ??= this.statusCode;
this.body = JSON.parse(body);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging follows offsets 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: calls.length < 3,
});
},
},
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, 3);
assert.equal(calls.length, 3);
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');
});
}
/**
* Serve one good snapshot, then let the upstream answer `bad` after the TTL:
* the bad answer must not replace it, so the client gets the last good rows
* marked stale, and a cold proxy answers 502 rather than caching the bad one.
*/
async function assertRejectedSnapshot(bad) {
let now = 1000;
let broken = false;
let calls = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
calls += 1;
return broken ? bad(calls) : Response.json({ features: [feature(7)] });
},
});
const first = await request();
assert.equal(first.status, 200);
broken = true;
now += 3_600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(
stale.body.rows.map((row) => row.stableId),
['7'],
);
calls = 0;
const cold = install({ fetchImpl: async () => bad(++calls) });
const res = await cold();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
}
test('an empty upstream snapshot keeps the last good one', () =>
assertRejectedSnapshot(() => Response.json({ features: [] })));
test('a page whose every feature is invalid keeps the last good one', () =>
assertRejectedSnapshot(() =>
Response.json({
features: [
{
...feature(8),
properties: { ...feature(8).properties, TYPE_CODE: 'W' },
},
{ ...feature(9), geometry: null },
],
}),
));
test('paging that exhausts its page limit still flagged keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json({
features: [feature(100 + call)],
exceededTransferLimit: true,
}),
));
test('a flagged page that adds no features keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json(
call % 2
? { features: [feature(100 + call)], exceededTransferLimit: true }
: { features: [], properties: { exceededTransferLimit: true } },
),
));
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');
});
test('a stale snapshot is served for at most a day, then refused', async () => {
let now = 1_000_000_000;
let up = true;
const request = install({
now: () => now,
fetchImpl: async () => {
if (!up) throw new Error('FAA unreachable');
return Response.json({ features: [feature(7)] });
},
});
const fresh = await request();
assert.equal(fresh.status, 200);
up = false;
now += 23 * 60 * 60 * 1000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
now += 2 * 60 * 60 * 1000;
assert.equal((await request()).status, 502, 'a day-old snapshot is refused');
});
test('a cross-site page is refused before it spends quota or reaches FAA', async () => {
let fetches = 0;
const request = install({
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature()] });
},
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, origin: 'https://evil.example' },
{ host, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refusals than the 60/min per-client quota, all from one peer.
for (let i = 0; i < 64; i++) {
const headers = refused[i % refused.length];
const res = await request('/', 'GET', '10.0.0.5', headers);
assert.equal(res.status, 403, JSON.stringify(headers));
}
assert.equal(fetches, 0, 'refusals cost no upstream call');
const app = await request('/', 'GET', '10.0.0.5', {
host,
origin: `http://${host}`,
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200, 'the app keeps its full quota');
assert.equal(fetches, 1);
});
test('the app served behind a reverse proxy still gets the zones', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
const host = 'gev.example.com';
const res = await request('/', 'GET', '127.0.0.1', {
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
'x-forwarded-proto': 'https',
});
assert.equal(res.status, 200);
assert.equal(res.body.rows.length, 1);
});
+5
View File
@@ -598,6 +598,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: '8',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({
id: 'fire-perimeters',
+29 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 34);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
assert.equal(REGISTERED_LAYER_IDS.length, 35);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,18 +242,18 @@ 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(), '8');
assert.equal(nextLayerStateToken(), '9');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '8',
bravo: '9',
alpha: '9',
bravo: '00',
}),
'00',
'01',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
@@ -305,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '8',
next: '9',
future: '9',
next: '00',
}),
true,
);
@@ -316,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 8/,
/next free token 9/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -339,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
),
/next free token 9/,
/next free token 00/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2694,6 +2694,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
assert.deepEqual(decoded.enabledLayerIds, [
'drone-airspace',
'fire-perimeters',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token,
'8',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
+29 -28
View File
@@ -128,7 +128,7 @@ 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, 34);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -139,6 +139,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
's2-ghostmaps': '5',
'sea-marks': '6',
'ski-pistes': '7',
'drone-airspace': '8',
},
);
assert.throws(
@@ -164,9 +165,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
.future,
'8',
'9',
);
assert.throws(
() =>
@@ -184,14 +185,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', '8']] },
{ rows: [...reservationRows, ['retired-layer', '9']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '9');
assert.equal(nextLayerStateToken(published), '00');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '9',
newcomer: '00',
}),
true,
);
@@ -199,15 +200,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '8',
competing: '9',
}),
/next free token 9/,
/next free token 00/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '8',
newcomer: '9',
}),
/changed or removed: retired-layer/,
);
@@ -215,8 +216,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -237,8 +238,8 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '8']],
[{ id: 'pr-b', token: '8' }],
[...reservationRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -246,10 +247,10 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '8' },
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
@@ -257,16 +258,16 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '9');
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
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: 9/);
assert.match(helper.stdout, /pr-b: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -274,20 +275,20 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '9' },
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /35 published, 1 new/);
assert.match(corrected.stdout, /36 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -339,7 +340,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 = [...'89'].map((digit) => [
const priorDigits = [...'9'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -376,7 +377,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, 79);
assert.equal(params.get('l').length, 81);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -394,7 +395,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, 35);
assert.equal(Object.keys(published).length, 36);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -7,6 +7,7 @@
["bikeshare", "b"],
["cctv", "c"],
["directions", "n"],
["drone-airspace", "8"],
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
+13 -1
View File
@@ -3377,5 +3377,17 @@
"The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في إعدادات المزوّد.",
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. حاول مجددًا بعد {seconds} ث.",
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول لهذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات."
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
"{lower} to {upper}": "{lower} إلى {upper}",
"Up to {upper}": "حتى {upper}",
"From {lower}": "من {lower}",
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
"Unnamed zone": "منطقة بلا اسم",
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
"Prohibited (P)": "محظور (P)",
"Restricted (R)": "مقيّد (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
"Prohibited": "محظور",
"Restricted": "مقيّد",
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد"
}
+12
View File
@@ -2907,6 +2907,18 @@
"STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache",
"routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig",
"route proxy error": "Fehler des Routen-Proxys",
"{lower} to {upper}": "{lower} bis {upper}",
"Up to {upper}": "Bis {upper}",
"From {lower}": "Ab {lower}",
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
"Unnamed zone": "Unbenannte Zone",
"Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
"Prohibited (P)": "Sperrgebiet (P)",
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
"Prohibited": "Sperrgebiet",
"Restricted": "Flugbeschränkungsgebiet",
"Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete",
"scene|PLAY": "START",
"AUTHORING": "ERSTELLUNG",
"Create and edit scenes": "Szenen erstellen und bearbeiten",
+12
View File
@@ -2983,6 +2983,18 @@
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"{lower} to {upper}": "{lower} a {upper}",
"Up to {upper}": "Hasta {upper}",
"From {lower}": "Desde {lower}",
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
"Unnamed zone": "Zona sin nombre",
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
"Prohibited (P)": "Prohibido (P)",
"Restricted (R)": "Restringido (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
"Prohibited": "Prohibido",
"Restricted": "Restringido",
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas",
"AUTHORING": "CREACIÓN",
+12
View File
@@ -2977,6 +2977,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"{lower} to {upper}": "{lower} à {upper}",
"Up to {upper}": "Jusqu’à {upper}",
"From {lower}": "À partir de {lower}",
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
"Unnamed zone": "Zone sans nom",
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
"Prohibited (P)": "Zone interdite (P)",
"Restricted (R)": "Zone réglementée (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
"Prohibited": "Zone interdite",
"Restricted": "Zone réglementée",
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
"scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes",
+12
View File
@@ -2977,6 +2977,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
"{lower} to {upper}": "da {lower} a {upper}",
"Up to {upper}": "Fino a {upper}",
"From {lower}": "Da {lower}",
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
"Unnamed zone": "Zona senza nome",
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
"Prohibited (P)": "Zona proibita (P)",
"Restricted (R)": "Zona regolamentata (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
"Prohibited": "Zona proibita",
"Restricted": "Zona regolamentata",
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
"scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene",
+12
View File
@@ -2837,6 +2837,18 @@
"route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"{lower} to {upper}": "{lower}~{upper}",
"Up to {upper}": "{upper}まで",
"From {lower}": "{lower}から",
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
"Unnamed zone": "名称なしのゾーン",
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
"Prohibited (P)": "飛行禁止区域 (P)",
"Restricted (R)": "制限区域 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
"Prohibited": "飛行禁止区域",
"Restricted": "制限区域",
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
"scene|PLAY": "再生",
"AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集",
+12
View File
@@ -2977,6 +2977,18 @@
"Don't show this again": "Não mostrar novamente",
"ESC to dismiss": "ESC para fechar",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.",
"{lower} to {upper}": "{lower} a {upper}",
"Up to {upper}": "Até {upper}",
"From {lower}": "A partir de {lower}",
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
"Unnamed zone": "Zona sem nome",
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
"Prohibited (P)": "Área proibida (P)",
"Restricted (R)": "Área restrita (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
"Prohibited": "Área proibida",
"Restricted": "Área restrita",
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
"scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas",
+12
View File
@@ -3053,6 +3053,18 @@
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"{lower} to {upper}": "от {lower} до {upper}",
"Up to {upper}": "До {upper}",
"From {lower}": "От {lower}",
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
"Unnamed zone": "Зона без названия",
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
"Prohibited (P)": "Запретная зона (P)",
"Restricted (R)": "Зона ограничения (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
"Prohibited": "Запретная зона",
"Restricted": "Зона ограничения",
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов",
"AUTHORING": "РЕДАКТОР",
+12
View File
@@ -3045,6 +3045,18 @@
"Don't show this again": "Більше не показувати",
"ESC to dismiss": "ESC — приховати",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
"{lower} to {upper}": "від {lower} до {upper}",
"Up to {upper}": "До {upper}",
"From {lower}": "Від {lower}",
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
"Unnamed zone": "Зона без назви",
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
"Prohibited (P)": "Заборонена зона (P)",
"Restricted (R)": "Зона обмеження (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
"Prohibited": "Заборонена зона",
"Restricted": "Зона обмеження",
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ",
+12
View File
@@ -2843,6 +2843,18 @@
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起",
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
"Unnamed zone": "未命名区域",
"Restricted airspace source unavailable": "受限空域数据源不可用",
"Prohibited (P)": "禁区 (P)",
"Restricted (R)": "限制区 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
"Prohibited": "禁区",
"Restricted": "限制区",
"Prohibited & Restricted Airspace": "禁区与限制区空域",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错",
"AUTHORING": "创作",
+12
View File
@@ -2845,6 +2845,18 @@
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起",
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
"Unnamed zone": "未命名區域",
"Restricted airspace source unavailable": "限制空域資料來源無法使用",
"Prohibited (P)": "禁航區 (P)",
"Restricted (R)": "限航區 (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
"Prohibited": "禁航區",
"Restricted": "限航區",
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
"AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景",
"SCENE": "場景",
+104
View File
@@ -1,4 +1,5 @@
import { cameraPoseSignature } from '../../data/iconOrientation.js';
import { AWARENESS_RANGE_PULSE } from './policy.js';
export function createLifecycle({
state: layerState,
@@ -8,6 +9,86 @@ export function createLifecycle({
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
/**
* Note the user handling the map's camera (pointer, wheel or touch on the
* Cesium canvas, where its camera controller listens), so a camera move the
* user made can wake the range pulses and one the app made (tracking,
* cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera
* controller reads none, and typing elsewhere must not count. Nor does
* input while the camera controller's inputs are off: the cockpit (or a
* gizmo drag, or a drawing tool) owns the camera then, and the user's
* gesture moves nothing.
*/
function noteCameraInput(event) {
if (event?.type === 'pointermove' && !event.buttons) return;
if (!cameraInputsEnabled()) return;
layerState.lastCameraInputMs = Date.now();
if (event?.type === 'pointerdown') activePointers.add(event.pointerId);
if (event?.type === 'pointerdown' || event?.type === 'touchstart')
followGesture(true);
}
// A drag can leave the canvas, and Cesium follows it at document level
// (in Firefox, and wherever there are no pointer events). So does this,
// from a gesture's start on the canvas to its end, so a long gesture keeps
// the input window open the whole way.
const GESTURE_MOVE_EVENTS = ['pointermove', 'touchmove'];
const GESTURE_END_EVENTS = [
'pointerup',
'pointercancel',
'touchend',
'touchcancel',
];
let followingGesture = false;
const activePointers = new Set();
function followGesture(on) {
if (followingGesture === on) return;
followingGesture = on;
if (!on) activePointers.clear();
const listen = on ? 'addEventListener' : 'removeEventListener';
for (const type of GESTURE_MOVE_EVENTS)
globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true });
for (const type of GESTURE_END_EVENTS)
globalThis.document?.[listen]?.(type, endGesture, { passive: true });
globalThis[listen]?.('blur', dropGesture);
globalThis.document?.[listen]?.('visibilitychange', dropGesture);
}
// A gesture ends with its last contact: lifting one finger of a pinch
// leaves Cesium a one-finger drag.
function endGesture(event) {
noteCameraInput(event);
activePointers.delete(event?.pointerId);
if (activePointers.size || event?.touches?.length) return;
followGesture(false);
}
// Losing the window or the page mid-gesture means its end may never
// arrive here, so stop following it and drop its pending input.
function dropGesture(event) {
if (
event?.type === 'visibilitychange' &&
document.visibilityState !== 'hidden'
)
return;
followGesture(false);
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
}
function cameraInputsEnabled() {
const controller = layerState.viewer?.scene?.screenSpaceCameraController;
return controller?.enableInputs !== false;
}
const CAMERA_INPUT_EVENTS = [
'pointerdown',
'pointermove',
'pointerup',
'wheel',
'touchstart',
];
/**
* Quantized camera-pose signature, or '' when the camera cannot report a full
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
@@ -50,10 +131,18 @@ export function createLifecycle({
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
const input = layerState.viewer.canvas ?? layerState.viewer.scene?.canvas;
for (const type of CAMERA_INPUT_EVENTS)
input?.addEventListener?.(type, noteCameraInput, { passive: true });
layerState.cameraInputTarget = input ?? null;
layerState.preRenderRemover =
layerState.viewer.scene.preRender.addEventListener(() => {
if (!layerState.enabled) return;
const now = Date.now();
// Another owner took the camera: a gesture begun before it did must
// not wake the pulses on that owner's first move.
if (!cameraInputsEnabled())
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
// "Did the camera move" uses the SAME quantized pose signature the fleet
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
// camera.changed, whose granularity is globally degraded by other layers
@@ -62,6 +151,14 @@ export function createLifecycle({
if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now;
// A camera move the user made runs the range pulses again; parked,
// or moved by the app (tracking, cockpit, orbit, a fly-to), they
// rest.
if (
now - layerState.lastCameraInputMs <
AWARENESS_RANGE_PULSE.inputWakeMs
)
parts.rendering.wakePulses();
}
const decision = parts.model.awarenessRefreshDecision({
nowMs: now,
@@ -108,6 +205,13 @@ export function createLifecycle({
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
for (const type of CAMERA_INPUT_EVENTS)
layerState.cameraInputTarget?.removeEventListener?.(
type,
noteCameraInput,
);
layerState.cameraInputTarget = null;
followGesture(false);
}
layerState.preRenderRemover?.();
layerState.preRenderRemover = null;
+74 -1
View File
@@ -49,13 +49,86 @@ export const CONTEXT_RIM_HEIGHT_M = 2500;
* the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps
* luminance contrast on light and dark ground alike, and luminance is what
* the monochrome styles keep. The core is the Context panel's blue; the halo
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. */
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side.
* It is drawn as an unpickable primitive, so clicks on its edge reach the
* contacts underneath.
* While the sonar pulses run, the ring dims to `restOpacity` and flashes
* to full as each pulse reaches it, fading over `flashMs`. `restOpacity`
* keeps the 250 km edge readable between flashes, in screenshots and for
* low-vision viewers. Once the pulses rest, and without pulses (reduced
* motion, or a scene that cannot animate), it stays on screen at full. */
export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
color: 'rgba(98, 181, 255, 0.95)',
outlineColor: 'rgba(4, 12, 16, 0.8)',
width: 4,
outlineWidth: 1,
segments: 128,
flashMs: 1800,
restOpacity: 0.4,
});
/** @constant Sonar-style pulses that grow slowly from the subject to the
* range ring and are almost gone when they reach it. `count` pulses share
* one `periodMs`, evenly staggered, so a new one starts every
* `periodMs / count`. Each starts at `startFraction` of the radius, fades in
* over `fadeInFraction` of its travel, then fades as (1 - travel) to the
* `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse
* is dim but still visible when it reaches the ring and the ring flashes.
* Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark
* halo, in the ring's colours, as Cesium's polyline outline material draws.
* Behind each line a radial gradient in the same blue fades inward over
* `gradientDepthFraction` of the radius, at most
* `gradientOpacity` strong. One unpickable disc draws the lines and the
* gradient, so pulses never catch a click meant for a contact; its shader
* takes at most four pulses. The disc follows the subject by a model-matrix
* rotation, so it stays concentric with the ring, and is rebuilt only after
* the subject moves `gradientRebuildM` from where it was built; while the
* pulses rest the hidden disc is left alone and catches up when they wake. The
* disc is hidden while the camera is below its height, where it would sweep
* the sky.
* The pulses run for `activeMs` (three ring flashes) after a selection or
* a camera move, fading in and out over `settleMs`, then rest: the ring
* stays on screen, static, and nothing asks for frames, so a parked view
* goes quiet again. Only a camera move within `inputWakeMs` of the user
* handling the map (pointer, wheel or touch on the canvas, which covers a
* drag's inertia, while the camera controls take input) wakes them; a moving subject and the app's own camera moves
* (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they
* rest too. While they run they never hold
* continuous rendering; each drawn frame arms one more request after the
* Cyber sonar's 80 ms cadence, so the two never beat. When something else
* already renders continuously they update every frame. Skipped under
* prefers-reduced-motion. */
export const AWARENESS_RANGE_PULSE = Object.freeze({
count: 3,
periodMs: 9000,
startFraction: 0.02,
fadeInFraction: 0.08,
fadeOutPower: 1.5,
endOpacityFraction: 0.35,
peakOpacity: 0.6,
width: 3,
outlineWidth: 1,
gradientDepthFraction: 0.35,
gradientOpacity: 0.22,
gradientGranularityDeg: 0.25,
gradientRebuildM: 20000,
activeMs: 9000,
settleMs: 1200,
inputWakeMs: 1500,
});
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a
* flight, military aircraft or AIS vessel in the window, the detection
* overlay draws a second bracket in `color` around that contact's own
* bracket. It starts on the bracket and spreads outward by up to `spreadPx`
* as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo;
* mapped installations have no detection bracket. */
export const AWARENESS_RANGE_ECHO = Object.freeze({
layerIds: ['flights', 'military', 'ais-live-vessels'],
fadeMs: 1400,
color: 'rgb(98, 181, 255)',
spreadPx: 8,
lineWidth: 1.75,
});
export const VESSEL_FOCUS_RADIUS_M = 3000;
File diff suppressed because it is too large. Load diff
+519 -17
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
CYBER_SONAR_RENDER_INTERVAL_MS,
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
@@ -12,10 +13,17 @@ import {
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import {
AWARENESS_PAGE_SIZE,
AWARENESS_RANGE_ECHO,
AWARENESS_RANGE_PULSE,
AWARENESS_RANGE_RING_STYLE,
CONTEXT_RIM_HEIGHT_M,
} from './policy.js';
import { formatAwarenessDirection } from './presentation.js';
import {
awarenessEchoStrength,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../../awarenessEcho.js';
/**
* A closed ring of positions `radiusM` along the surface from `groundCenter`,
@@ -70,6 +78,273 @@ export function awarenessRangeRingPositions(
return positions;
}
/**
* Where pulse `index` is at `nowMs`: its radius and opacity. Pulses travel at
* constant speed from `startFraction` of the range to the full range, fade in
* over `fadeInFraction` of the way, then fade as (1 - travel) to the
* `fadeOutPower` down to `endOpacityFraction` of the peak, so each is dim
* but still visible when it reaches the range ring. The opacity already
* includes `peakOpacity`.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} index Pulse number, 0 to `count - 1`.
* @param {number} [radiusM] Range the pulses grow to.
* @param {object} [pulse] Timing and shape, see `AWARENESS_RANGE_PULSE`.
* @returns {{radiusM: number, alpha: number}}
*/
export function awarenessPulseState(
nowMs,
index,
radiusM = AWARENESS_RADIUS_M,
pulse = AWARENESS_RANGE_PULSE,
) {
const cycle = nowMs / pulse.periodMs + index / pulse.count;
const phase = cycle - Math.floor(cycle);
return {
radiusM:
radiusM * (pulse.startFraction + (1 - pulse.startFraction) * phase),
alpha:
pulse.peakOpacity *
Math.min(1, phase / pulse.fadeInFraction) *
(pulse.endOpacityFraction +
(1 - pulse.endOpacityFraction) * (1 - phase) ** pulse.fadeOutPower),
};
}
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/** Whether the viewer asked the system for less motion. */
function prefersReducedMotion() {
return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false;
}
/**
* How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over
* `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to
* `wake.untilMs`.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`.
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
* @returns {number} 0 to 1.
*/
export function awarenessPulseLevel(
nowMs,
wake,
pulse = AWARENESS_RANGE_PULSE,
) {
const level = Math.min(
(nowMs - wake.startMs) / pulse.settleMs,
(wake.untilMs - nowMs) / pulse.settleMs,
);
return Math.max(0, Math.min(1, level));
}
/**
* Run the pulses for `activeMs` from `nowMs`. A wake while they already run
* keeps their current level, so they never jump back to full mid-fade.
* @param {{startMs: number, untilMs: number}} wake Updated in place.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
* @returns {{startMs: number, untilMs: number}} `wake`.
*/
export function wakeAwarenessPulses(
wake,
nowMs,
pulse = AWARENESS_RANGE_PULSE,
) {
wake.startMs =
nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs;
wake.untilMs = nowMs + pulse.activeMs;
return wake;
}
/**
* How strongly the 250 km ring shows at `nowMs` while pulses run: 1 as a
* pulse front reaches it, fading over `flashMs`, so the ring appears once
* per arriving pulse instead of standing on screen.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {number} 0 to 1.
*/
export function awarenessRingFlash(nowMs) {
return awarenessEchoStrength(
nowMs,
AWARENESS_RADIUS_M,
AWARENESS_RADIUS_M,
AWARENESS_RANGE_PULSE,
{ fadeMs: AWARENESS_RANGE_RING_STYLE.flashMs },
);
}
/** Most pulses the pulse shader draws; its uniforms are vec4s. */
export const AWARENESS_PULSE_GRADIENT_MAX = 4;
/** The pulse disc's Cesium material type, one cached template per page. */
const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient';
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half
// on each side, as Cesium's polyline outline material draws it, with a band
// behind it that is strongest at the line and fades to nothing `depth`
// inward. Widths come from the screen-space gradient of `r`, as Cesium's
// contour material measures its lines; without derivatives the lines are
// left out and only the band shows.
const PULSE_GRADIENT_SOURCE = `
uniform vec4 color;
uniform vec4 lineColor;
uniform vec4 haloColor;
uniform vec4 radii;
uniform vec4 alphas;
uniform float depth;
uniform float lineWidth;
uniform float haloWidth;
vec4 over(vec4 top, vec4 under) {
float a = top.a + under.a * (1.0 - top.a);
if (a <= 0.0) return vec4(0.0);
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
}
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
float r = length(materialInput.st - vec2(0.5)) * 2.0;
float band = 0.0;
for (int i = 0; i < 4; i++) {
float inside = radii[i] - r;
if (inside >= 0.0 && inside <= depth) {
float t = 1.0 - inside / depth;
band += alphas[i] * t * t;
}
}
vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0));
#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives))
// Change in r per CSS pixel: the gradient's length per device pixel,
// times the device pixels in one CSS pixel.
float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio;
float outerHalf = lineWidth * 0.5;
float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5;
for (int i = 0; i < 4; i++) {
float d = abs(radii[i] - r) / cssPx;
float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0);
if (cover <= 0.0) continue;
float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0);
vec4 pulse = mix(haloColor, lineColor, core);
result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result);
}
#endif
material.diffuse = result.rgb;
material.alpha = result.a;
return material;
}
`;
/**
* The pulses: one flat, translucent, unpickable disc the size of the range
* ring whose shader draws each pulse line and the band behind it. Drawing
* them here rather than as entities keeps them out of picking, so clicks and
* taps near a pulse still reach the contact underneath. Moving pulses only
* change its uniforms; the disc is rebuilt only when the subject moves.
* @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid.
* @param {Cesium.Material} material Shared pulse-gradient material.
* @returns {Cesium.Primitive}
*/
function createPulseGradient(groundCenter, material) {
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.EllipseGeometry({
center: groundCenter,
semiMajorAxis: AWARENESS_RADIUS_M,
semiMinorAxis: AWARENESS_RADIUS_M,
height: CONTEXT_RIM_HEIGHT_M,
granularity: Cesium.Math.toRadians(
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
),
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
}),
appearance: new Cesium.MaterialAppearance({
material,
flat: true,
translucent: true,
faceForward: true,
}),
asynchronous: false,
allowPicking: false,
});
}
/**
* The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen
* pixels, straight chords between the given positions (they already follow
* the surface), in the material appearance's vertex format.
* @param {Cesium.Cartesian3[]} positions Ring positions.
* @returns {object} PolylineGeometry options.
*/
export function awarenessRingGeometryOptions(positions) {
return {
positions,
width: AWARENESS_RANGE_RING_STYLE.width,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
};
}
/**
* The 250 km ring as an unpickable polyline primitive in the ring's outline
* material, so a click or tap on its edge reaches the contact underneath.
* Its positions are geometry, so a moved subject rebuilds it; the material
* is shared between builds so a flash keeps going.
* @param {Cesium.Cartesian3[]} positions Ring positions.
* @param {Cesium.Material} material Shared PolylineOutline material.
* @returns {Cesium.Primitive}
*/
function createRingPrimitive(positions, material) {
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.PolylineGeometry(
awarenessRingGeometryOptions(positions),
),
}),
appearance: new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
asynchronous: false,
allowPicking: false,
});
}
/**
* The rotation about the Earth's centre that carries `from` onto `to`, as a
* model matrix, so a disc built around `from` sits around `to`.
* @param {Cesium.Cartesian3} from Where the disc was built.
* @param {Cesium.Cartesian3} to Where it should be.
* @param {Cesium.Matrix4} [result]
* @returns {Cesium.Matrix4}
*/
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
const sin = Cesium.Cartesian3.magnitude(axis);
if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result);
const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b));
const rotation = Cesium.Matrix3.fromQuaternion(
Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.divideByScalar(axis, sin, axis),
angle,
),
);
return Cesium.Matrix4.fromRotationTranslation(
rotation,
Cesium.Cartesian3.ZERO,
result,
);
}
/** Camera height above the ellipsoid, or NaN when the scene cannot say. */
function cameraHeightM(scene) {
return scene.camera?.positionCartographic?.height ?? Number.NaN;
}
export function createRendering({
state: layerState,
services,
@@ -328,7 +603,62 @@ export function createRendering({
presentDirection({ hidden: false, ring, heading, labels, markers });
}
/**
* Run the range pulses again for `activeMs`, after a camera move. Call it
* during a frame (lifecycle's preRender): that frame's postRender arms the
* next request, so waking asks for no extra frame of its own.
*/
function wakePulses() {
const wake = layerState.visual?.wake;
if (!wake) return;
wakeAwarenessPulses(wake, performance.now());
followPulseDisc();
}
/**
* Keep the pulse disc centred on the subject. Within `gradientRebuildM` of
* where it was built, the disc is rotated about the Earth's centre onto
* the subject by its model matrix, which keeps it concentric with the ring
* at no build cost; the ellipsoid curves a little differently from that
* sphere, a few tens of metres in height at 20 km. Further away it is
* rebuilt, which costs a few milliseconds.
*/
function followPulseDisc() {
const { gradient, gradientCenter, center } = layerState.visual;
if (!gradient) return;
if (
Cesium.Cartesian3.distance(gradientCenter, center) <
AWARENESS_RANGE_PULSE.gradientRebuildM
) {
gradient.modelMatrix = discFollowMatrix(
gradientCenter,
center,
gradient.modelMatrix,
);
return;
}
const primitives = layerState.viewer.scene.primitives;
layerState.visual.gradient = primitives.add(
createPulseGradient(center, gradient.appearance.material),
);
// Primitive.destroy leaves the material alone, so the new disc keeps
// sharing it.
primitives.remove(gradient);
Cesium.Cartesian3.clone(center, gradientCenter);
}
function clearVisual() {
layerState.visual?.stopPulses?.();
layerState.visual?.removeMotionListener?.();
if (layerState.visual?.gradient && layerState.viewer)
layerState.viewer.scene.primitives.remove(layerState.visual.gradient);
if (layerState.visual?.ringPrimitive && layerState.viewer) {
layerState.viewer.scene.primitives.remove?.(
layerState.visual.ringPrimitive,
);
governorRequestRender('awareness-visual');
}
clearAwarenessEchoField();
if (layerState.visual?.entities && layerState.viewer) {
for (const entity of layerState.visual.entities)
layerState.viewer.entities.remove(entity);
@@ -348,7 +678,12 @@ export function createRendering({
0,
);
const key = `${subject.layerId}:${subject.id}`;
if (layerState.visual?.key === key) {
const reducedMotion = prefersReducedMotion();
// A reduced-motion change rebuilds the visual, with or without pulses.
if (
layerState.visual?.key === key &&
layerState.visual.reducedMotion === reducedMotion
) {
// Move the ring only after a meaningful displacement so live tracking
// does not rebuild its geometry for sub-pixel motion every 750 ms.
if (
@@ -360,9 +695,22 @@ export function createRendering({
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
for (const entity of layerState.visual.entities)
entity.polyline.positions.setValue(positions);
// The echo field reads `visual.center` in place and follows on its
// own. The pulse disc follows by its model matrix and is rebuilt only
// every `gradientRebuildM`: a build costs a few milliseconds.
layerState.visual.ringPositions = positions;
const { ringPrimitive } = layerState.visual;
if (ringPrimitive) {
const primitives = layerState.viewer.scene.primitives;
layerState.visual.ringPrimitive = primitives.add(
createRingPrimitive(positions, ringPrimitive.appearance.material),
);
primitives.remove(ringPrimitive);
} else layerState.visual.ring.polyline.positions.setValue(positions);
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
// A resting disc is hidden, so it catches up when the pulses wake.
if (performance.now() < layerState.visual.wake?.untilMs)
followPulseDisc();
// Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual');
}
@@ -371,35 +719,188 @@ export function createRendering({
clearVisual();
const entities = [];
const scene = layerState.viewer.scene;
// A viewer without a primitive collection or a preRender hook (headless
// tools, partial stubs) gets the static ring only, as the Cyber sonar
// scene does. So does a viewer who prefers reduced motion.
const animate = Boolean(
!reducedMotion &&
scene?.primitives?.add &&
scene?.preRender?.addEventListener &&
scene?.postRender?.addEventListener,
);
// Pulses run from now until `wake.untilMs`, then rest.
const wake = animate
? wakeAwarenessPulses(
{ startMs: -Infinity, untilMs: -Infinity },
performance.now(),
)
: null;
// From a camera below the rim height the disc would sweep across the sky,
// so it is hidden there, and the pulses rest with it: no flash, no
// echoes, no frames.
const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M;
const levelAt = (nowMs) =>
wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0;
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
const ring = AWARENESS_RANGE_RING_STYLE;
entities.push(
layerState.viewer.entities.add({
const core = Cesium.Color.fromCssColorString(ring.color);
const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
const ringPositions = awarenessRangeRingPositions(
groundCenter,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
// While pulses run, the ring flashes as each one arrives and otherwise
// dims to `restOpacity`; as they settle it returns to full and stays.
const ringAlpha = (nowMs) => {
const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
return 1 - levelAt(nowMs) * (1 - pulsed);
};
let ringEntity = null;
let ringMaterial = null;
let ringPrimitive = null;
if (scene?.primitives?.add) {
ringMaterial = Cesium.Material.fromType('PolylineOutline', {
color: core.clone(),
outlineColor: halo.clone(),
outlineWidth: ring.outlineWidth,
});
ringPrimitive =
scene.primitives.add(
createRingPrimitive(ringPositions, ringMaterial),
) ?? null;
}
if (!ringPrimitive) {
// Headless tools and partial stubs have no primitive collection; they
// get the static ring as an entity.
ringEntity = layerState.viewer.entities.add({
polyline: {
positions: new Cesium.ConstantProperty(
awarenessRangeRingPositions(
groundCenter,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
),
),
positions: new Cesium.ConstantProperty(ringPositions),
width: ring.width,
arcType: Cesium.ArcType.NONE,
material: new Cesium.PolylineOutlineMaterialProperty({
color: Cesium.Color.fromCssColorString(ring.color),
outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor),
color: core,
outlineColor: halo,
outlineWidth: ring.outlineWidth,
}),
},
}),
);
});
entities.push(ringEntity);
}
const center = Cesium.Cartesian3.clone(groundCenter);
let gradient = null;
let stopPulses = null;
if (animate) {
const pulse = AWARENESS_RANGE_PULSE;
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
const uniforms = {
color: core.clone(),
lineColor: core.clone(),
haloColor: halo.clone(),
radii: new Cesium.Cartesian4(),
alphas: new Cesium.Cartesian4(),
depth: pulse.gradientDepthFraction,
lineWidth: pulse.width,
haloWidth: pulse.outlineWidth,
};
uniforms.color.alpha = pulse.gradientOpacity;
// A named type: Cesium caches every material type for the page's
// lifetime, so an unnamed fabric (a fresh GUID each time) would leave
// one entry per new visual. Built from the cached template, the
// uniforms come back as plain copies; restore this visual's own.
const material = new Cesium.Material({
fabric: {
type: AWARENESS_PULSE_MATERIAL_TYPE,
uniforms,
source: PULSE_GRADIENT_SOURCE,
},
translucent: true,
});
Object.assign(material.uniforms, uniforms);
gradient = scene.primitives.add(
createPulseGradient(groundCenter, material),
);
const radii = [0, 0, 0, 0];
const alphas = [0, 0, 0, 0];
const removePreRender = scene.preRender.addEventListener(() => {
const nowMs = performance.now();
const level = levelAt(nowMs);
const alpha = ringAlpha(nowMs);
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
// The disc is looked up each frame: a subject move swaps it.
const disc = layerState.visual?.gradient;
if (disc) disc.show = level > 0;
for (let index = 0; index < count; index++) {
const state = awarenessPulseState(nowMs, index);
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
alphas[index] = state.alpha * level;
}
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
});
// Each drawn frame, whoever asked for it, arms one request a Cyber
// sonar interval later, as the Cyber sonar scene does. Requests never
// land within 80 ms of a frame, so they cannot beat the detection
// lane's 80 ms gate, and they stop once the pulses rest.
let timer = null;
const removePostRender = scene.postRender.addEventListener(() => {
globalThis.clearTimeout(timer);
timer = null;
if (performance.now() < wake.untilMs && !belowRim())
timer = globalThis.setTimeout(() => {
timer = null;
governorRequestRender('awareness-pulse');
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
stopPulses = () => {
removePreRender();
removePostRender();
globalThis.clearTimeout(timer);
timer = null;
};
// The detection overlay pulses the brackets of flights and vessels a
// front crosses; it reads the centre in place, so it follows too.
setAwarenessEchoField({
center,
radiusM: AWARENESS_RADIUS_M,
pulse,
echo: AWARENESS_RANGE_ECHO,
level: levelAt,
});
}
// Turning reduced motion on or off mid-session rebuilds the visual.
const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY);
const onMotionChange = () => {
if (layerState.subject) renderVisual(layerState.subject);
};
// Safari before 14 has only the older addListener / removeListener.
if (motionQuery?.addEventListener)
motionQuery.addEventListener('change', onMotionChange);
else motionQuery?.addListener?.(onMotionChange);
layerState.visual = {
key,
entities,
center: Cesium.Cartesian3.clone(groundCenter),
ring: ringEntity,
ringPrimitive,
ringPositions,
center,
gradient,
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
reducedMotion,
wake,
stopPulses,
removeMotionListener: () => {
if (motionQuery?.removeEventListener)
motionQuery.removeEventListener('change', onMotionChange);
else motionQuery?.removeListener?.(onMotionChange);
},
};
governorRequestRender('awareness-visual');
}
@@ -413,5 +914,6 @@ export function createRendering({
updateDirectionOverlay,
clearVisual,
renderVisual,
wakePulses,
};
}
+4
View File
@@ -59,6 +59,10 @@ export function createState({ services }) {
lastCameraPoseSig: '',
/** When the pose signature last changed bins (hysteresis anchor). */
lastCameraPoseChangeMs: 0,
/** When the viewer last handled the camera (pointer, wheel, touch, key). */
lastCameraInputMs: Number.NEGATIVE_INFINITY,
/** Element the camera-input listeners are attached to. */
cameraInputTarget: null,
/** Whether the view currently counts as moving (hysteretic, not per-frame). */
cameraMoving: false,
lastEvaluatedPosition: null,
+96
View File
@@ -0,0 +1,96 @@
/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */
import { t } from '../../i18n/index.js';
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.lowerAltitude;
const upper = row.upperAltitude;
if (lower && upper) return t('{lower} to {upper}', { lower, upper });
if (upper) return t('Up to {upper}', { upper });
if (lower) return t('From {lower}', { 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 ? t(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(t('Informational only · not for flight planning'));
const type = row.type ? t(row.type) : 'SUA';
const title = `${type.toUpperCase()} · ${row.name || t('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',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
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 to 18,000 ft MSL · 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',
lowerAltitude: null,
upperAltitude: 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'));
});
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js';
import { ANALYST_LAYERS } from '../../data/analystEngine.js';
const read = (path) =>
readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
const currentState = read('docs/CURRENT-STATE.md');
const section = currentState.slice(
currentState.indexOf('## Prohibited & Restricted Airspace'),
currentState.indexOf('\n## ', currentState.indexOf('## Prohibited')) + 1,
);
const tokens = JSON.parse(read('src/data/layerStateTokenReservations.json'));
test('CURRENT-STATE names the reserved share-link token', () => {
const token = JSON.stringify(tokens).match(
/\["drone-airspace","([^"]+)"\]/,
)?.[1];
assert.ok(token, 'drone-airspace has a reserved token');
assert.match(section, new RegExp(`share-link token \`${token}\``));
});
test('CURRENT-STATE matches the layer being outside voice and the analyst', () => {
assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'drone-airspace'));
assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'drone-airspace'));
assert.doesNotMatch(section, /Voice can toggle/);
assert.match(section, /Voice is off for this layer for now/);
const analyst = currentState.match(/Analyst records come on demand[^\n]*/)[0];
assert.doesNotMatch(analyst, /drone/i);
});
test('CURRENT-STATE states what the layer does not cover', () => {
assert.match(section, /UAS Facility Map/);
assert.match(section, /LAANC/);
assert.match(section, /TFRs/);
});
+366
View File
@@ -0,0 +1,366 @@
import * as Cesium from 'cesium';
import { msg, t } from '../../i18n/index.js';
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));
/**
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
* boundary change with unchanged attributes still alters the refresh signature.
*/
function geometryHash(polygons) {
const text = JSON.stringify(polygons);
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
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 _stale = false;
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: 'Prohibited & Restricted Airspace',
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;
_stale = false;
_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 {
// The source contract is getSnapshot; a source that can also say how
// fresh its rows are (the FAA proxy) offers getSnapshotDetail.
const snapshot =
typeof source.getSnapshotDetail === 'function'
? await source.getSnapshotDetail({ signal: request.signal })
: { rows: await source.getSnapshot({ signal: request.signal }) };
if (request.signal.aborted || _request !== request || !_enabled)
return false;
const { rows } = snapshot;
_stale = snapshot.stale === true;
// A stale snapshot is as old as its fetch, not this poll.
const updatedAt =
_stale && Number.isFinite(snapshot.fetchedAt)
? snapshot.fetchedAt
: Date.now();
// FAA amends boundaries in its 8-week cycle without any attribute
// changing, so the geometry is part of the signature.
const signature = JSON.stringify(
rows
.map(({ polygons, ...facts }) => ({
...facts,
geometry: geometryHash(polygons),
}))
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = updatedAt;
_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 = updatedAt;
_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 || msg('Restricted airspace source unavailable');
// A failure now outranks the stale snapshot still drawn.
_stale = false;
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;
_stale = false;
},
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: t('Prohibited (P)'), color: droneAirspaceAccent('P') },
{ label: t('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: t(
'FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).',
),
}
: {}),
})),
};
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
};
},
};
return layer;
}
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDroneAirspaceLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.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',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
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/);
});
test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clears it', async () => {
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
let snapshot = { rows: [row], stale: true, fetchedAt };
const h = harness({
getSnapshot: async () => snapshot.rows,
getSnapshotDetail: async () => snapshot,
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
});
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => [row],
getSnapshotDetail: async () => {
if (fail) throw new Error('FAA SUA HTTP 502');
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
},
});
await h.layer.update(h.viewer);
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
fail = true;
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502');
assert.equal(layerFeedState(h.layer.getStats()), 'degraded');
});
test('a boundary change alone redraws the zone; identical geometry does not', async () => {
const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]);
let rows = [row];
const h = harness({ getSnapshot: async () => rows });
await h.layer.update(h.viewer);
const [first] = h.sources[0].entities.values;
rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }];
await h.layer.update(h.viewer);
assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept');
rows = [{ ...row, polygons: [[moved]] }];
await h.layer.update(h.viewer);
const [redrawn] = h.sources[0].entities.values;
assert.notEqual(redrawn, first, 'moved boundary must redraw');
assert.notDeepEqual(
redrawn.polyline.positions.getValue(),
first.polyline.positions.getValue(),
);
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
});
+135
View File
@@ -0,0 +1,135 @@
/**
* 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.
*/
import { msg } from '../../i18n/marker.js';
const TYPE_LABELS = Object.freeze({
P: msg('Prohibited'),
R: msg('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;
};
/** FAA marks line breaks in TIMESOFUSE with `%`; read them as `; ` separators. */
const timesOfUseOrNull = (value) =>
textOrNull(
typeof value === 'string'
? value.replace(/\s*;?\s*%\s*/g, '; ').replace(/^[;\s]+|[;\s]+$/g, '')
: value,
);
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;
}
/**
* One SUA altitude limit from its VAL/UOM/CODE triple, in aviation notation:
* `SFC`, `500 ft AGL`, `5,000 ft MSL`, `FL180` or `Unlimited`. The *_DESC
* fields are inclusion qualifiers (TI/TNI/ANI), not altitudes. Returns null
* when the triple does not describe a limit.
*/
export function droneAirspaceAltitude(value, uom, code) {
const unit = textOrNull(uom)?.toUpperCase() ?? null;
const reference = textOrNull(code)?.toUpperCase() ?? null;
if (reference === 'UNLTD') return 'Unlimited';
const number = Number(value);
if (value == null || value === '' || !Number.isFinite(number) || number < 0)
return null;
if (unit === 'FL' || reference === 'STD') return `FL${Math.round(number)}`;
const feet = `${Math.round(number).toLocaleString('en-US')} ft`;
if (reference === 'SFC') return number === 0 ? 'SFC' : `${feet} AGL`;
if (reference === 'MSL') return `${feet} MSL`;
return unit === 'FT' || unit == null ? feet : null;
}
/** 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,
lowerAltitude: droneAirspaceAltitude(
properties.LOWER_VAL,
properties.LOWER_UOM,
properties.LOWER_CODE,
),
upperAltitude: droneAirspaceAltitude(
properties.UPPER_VAL,
properties.UPPER_UOM,
properties.UPPER_CODE,
),
timesOfUse: timesOfUseOrNull(properties.TIMESOFUSE),
agency: textOrNull(properties.CONT_AGENT),
city: textOrNull(properties.CITY),
state: textOrNull(properties.STATE),
polygons,
});
}
return rows;
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
droneAirspaceAltitude,
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',
UPPER_VAL: '18000',
UPPER_UOM: 'FT',
UPPER_CODE: 'MSL',
LOWER_DESC: null,
LOWER_VAL: '0',
LOWER_UOM: 'FT',
LOWER_CODE: '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',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
timesOfUse: 'CONTINUOUS',
agency: 'WASHINGTON CENTER',
city: 'WASHINGTON',
state: 'DC',
polygons: [[ring]],
});
assert.equal(rows[1].type, 'Restricted');
assert.equal(rows[1].typeCode, 'R');
});
test('altitudes come from VAL/UOM/CODE, not the TI/TNI/ANI qualifiers', () => {
// P-40 and R-2508 as the FAA service publishes them.
assert.equal(droneAirspaceAltitude('0', 'FT', 'SFC'), 'SFC');
assert.equal(droneAirspaceAltitude('5000', 'FT', 'MSL'), '5,000 ft MSL');
assert.equal(droneAirspaceAltitude('20000', 'FT', 'MSL'), '20,000 ft MSL');
assert.equal(droneAirspaceAltitude('-9998', null, 'UNLTD'), 'Unlimited');
assert.equal(droneAirspaceAltitude('1500', 'FT', 'SFC'), '1,500 ft AGL');
assert.equal(droneAirspaceAltitude('180', 'FL', 'STD'), 'FL180');
assert.equal(droneAirspaceAltitude('10000', 'FT', null), '10,000 ft');
assert.equal(droneAirspaceAltitude(null, null, null), null);
assert.equal(droneAirspaceAltitude('-9998', 'FT', 'MSL'), null);
const [row] = normalizeDroneAirspaceSnapshot({
features: [
feature({
properties: {
NAME: 'R-2508',
TYPE_CODE: 'R',
OBJECTID: 400,
UPPER_DESC: null,
UPPER_VAL: '-9998',
UPPER_UOM: null,
UPPER_CODE: 'UNLTD',
LOWER_VAL: '20000',
LOWER_UOM: 'FT',
LOWER_CODE: 'MSL',
},
}),
],
});
assert.equal(row.lowerAltitude, '20,000 ft MSL');
assert.equal(row.upperAltitude, 'Unlimited');
});
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);
});
test("FAA's % line-break markers in times of use read as separators", () => {
const timesOfUse = (TIMESOFUSE) =>
normalizeDroneAirspaceSnapshot({
features: [
feature({
properties: {
NAME: 'R-2508',
TYPE_CODE: 'R',
OBJECTID: 400,
TIMESOFUSE,
},
}),
],
})[0].timesOfUse;
// The shape 56 live records carry: a marker after the semicolon.
assert.equal(
timesOfUse(
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; %OTHER TIMES BY NOTAM',
),
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; OTHER TIMES BY NOTAM',
);
assert.equal(
timesOfUse('SR - SS DAILY %OTHER TIMES BY NOTAM'),
'SR - SS DAILY; OTHER TIMES BY NOTAM',
);
assert.equal(timesOfUse('%CONTINUOUS%'), 'CONTINUOUS');
assert.equal(timesOfUse(' % '), null);
});
+38
View File
@@ -0,0 +1,38 @@
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),
} = {}) {
/**
* The rows with how fresh they are: the proxy answers from its last
* snapshot, marked stale, while the FAA service is unreachable.
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
*/
async function getSnapshotDetail({ 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 {
rows: payload.rows,
stale: payload.stale === true,
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
};
}
return {
/** The zone rows (see getSnapshotDetail for staleness). */
async getSnapshot(options = {}) {
return (await getSnapshotDetail(options)).rows;
},
getSnapshotDetail,
};
}
+119
View File
@@ -0,0 +1,119 @@
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/,
);
}
});
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
const fetchedAt = Date.UTC(2026, 10, 3, 12);
const rows = normalizeDroneAirspaceSnapshot(validPayload);
const source = createDroneAirspaceSource({
fetchImpl: async () => Response.json({ rows, stale: true, fetchedAt }),
});
assert.deepEqual(await source.getSnapshotDetail(), {
rows,
stale: true,
fetchedAt,
});
assert.deepEqual(await source.getSnapshot(), rows);
});
+2
View File
@@ -1,6 +1,7 @@
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js';
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
import { createAdsbGnssSource } from '../layers/gnss/source.js';
@@ -10,6 +11,7 @@ export function createReferenceSources() {
earthquakes: createUsgsEarthquakeSource(),
'air-quality': createEcccAirQualitySource(),
'fire-perimeters': createWfigsPerimeterSource(),
'drone-airspace': createDroneAirspaceSource(),
cables: createBundledCableSource(),
'gnss-integrity': createAdsbGnssSource(),
};
+3
View File
@@ -16,17 +16,20 @@ test('reference factories retain compatibility without starting acquisition or s
'earthquakes',
'air-quality',
'fire-perimeters',
'drone-airspace',
'cables',
'gnss-integrity',
]);
assert.notEqual(first.earthquakes, second.earthquakes);
assert.notEqual(first['air-quality'], second['air-quality']);
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
assert.notEqual(first['drone-airspace'], second['drone-airspace']);
assert.notEqual(first.cables, second.cables);
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
assert.equal(typeof first['air-quality'].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(typeof first['gnss-integrity'].getSnapshot, 'function');
assert.equal(requests, 0);
+1
View File
@@ -15,6 +15,7 @@ test('data providers have both hooks; credential editing stays development-only'
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
assert.ok(plugins.some(({ name }) => name === 'ghostmaps'));
assert.ok(plugins.some(({ name }) => name === 'drone-airspace'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
+1 -1
View File
@@ -695,7 +695,7 @@ test('public transit documentation uses courtesy attribution and the approved gr
);
assert.ok(
readme.includes(
'Twenty layers and map sources. **Eighteen have a keyless path.**',
'Twenty-one layers and map sources. **Nineteen have a keyless path.**',
),
);
assert.ok(
+1
View File
@@ -35,6 +35,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'),
'drone-airspace': msg('Prohibited & Restricted Airspace'),
's2-ghostmaps': msg('S2 GhostMaps'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
+1
View File
@@ -61,6 +61,7 @@ const PANEL_GROUPS = [
ids: [
'alpr-cameras',
'military-installations',
'drone-airspace',
'local-datacenters',
'telegeography-submarine-cables',
'local-dams',
+4
View File
@@ -453,6 +453,10 @@ const layers = [
export const VOICE_OFF_LAYERS = Object.freeze({
'bhote-koshi-2026': 'scene-driven event layer (control_scene)',
'bhote-koshi-locator': 'scene-driven event layer (control_scene)',
// The Realtime prompt is at its 52,000-character budget; voice toggle and
// query for this layer wait until there is room.
'drone-airspace':
'informational airspace layer; no room in the prompt budget',
});
const TYPE_NAMES = Object.freeze({