Merge branch 'main' into fix/houston-cctv-pack

This commit is contained in:
Sameh Khamis committed 2026-11-03 15:01:13 -08:00
commit 09bdc314d0
65 files changed
+5080 -97

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',
);
});
+7
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,
{
+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);
});
+4 -2
View File
@@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) {
roadType,
level,
closure,
flow,
};
const key = cellKey(
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
@@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) {
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
/**
* Nearest direction-compatible segment within 35 m of a projected point.
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
* `radiusFor(segment)` may tighten the radius per segment (never widen it);
* 0 leaves the segment out.
*/
function nearest(px, py, bearing, radiusFor = null) {
const cx = Math.floor(px / CELL_SIZE_M);
@@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) {
candidate = true; // within radius, bearing not yet checked
if (radiusFor) {
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
if (d2 > radius * radius) continue;
if (!(radius > 0) || d2 > radius * radius) continue;
}
const diff = bearingDiffDeg(seg.bearing, bearing);
if (
+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
@@ -3306,5 +3306,17 @@
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
"Search cancelled": "أُلغي البحث",
"Sea Marks": "علامات بحرية",
"Ski Pistes": "مسارات التزلج"
"Ski Pistes": "مسارات التزلج",
"{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;
+87
View File
@@ -44,6 +44,93 @@ export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
export const CONTEXT_RIM_HEIGHT_M = 2500;
/** @constant The 250 km range ring around the Global Context subject.
* The old 1 px ellipse outline vanished over desert, snow and under
* 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.
* 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;
export const SOURCE_LABEL = {
File diff suppressed because it is too large. Load diff
+594 -22
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';
@@ -10,8 +11,339 @@ import {
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.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`,
* raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's
* radius of curvature in that direction (Euler's formula), which keeps the
* ring within ~50 m of the true geodesic radius at 250 km.
* @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid.
* @param {number} radiusM Surface radius in metres.
* @param {number} heightM Height of the ring above the ellipsoid.
* @param {number} [segments] Chords around the ring.
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
*/
export function awarenessRangeRingPositions(
groundCenter,
radiusM,
heightM,
segments = AWARENESS_RANGE_RING_STYLE.segments,
) {
const { latitude, longitude } =
Cesium.Cartographic.fromCartesian(groundCenter);
const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii;
const e2 = 1 - (b * b) / (a * a);
const sinLat = Math.sin(latitude);
const cosLat = Math.cos(latitude);
const sinLon = Math.sin(longitude);
const cosLon = Math.cos(longitude);
const w = 1 - e2 * sinLat * sinLat;
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
const primeVertical = a / Math.sqrt(w);
// Step along the great circle in 3D from the centre's up, north and east
// unit vectors. Unlike the latitude/longitude destination formula, this
// stays well conditioned at and next to the poles.
const up = [cosLat * cosLon, cosLat * sinLon, sinLat];
const north = [-sinLat * cosLon, -sinLat * sinLon, cosLat];
const east = [-sinLon, cosLon, 0];
const positions = [];
for (let i = 0; i <= segments; i++) {
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
const cosB = Math.cos(bearing);
const sinB = Math.sin(bearing);
const angular =
radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical);
const cosA = Math.cos(angular);
const sinA = Math.sin(angular);
const [x, y, z] = up.map(
(u, k) => u * cosA + (north[k] * cosB + east[k] * sinB) * sinA,
);
const lat = Math.atan2(z, Math.hypot(x, y));
const lon = Math.atan2(y, x);
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
}
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,
@@ -271,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);
@@ -291,17 +678,39 @@ export function createRendering({
0,
);
const key = `${subject.layerId}:${subject.id}`;
if (layerState.visual?.key === key) {
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
// Move it only after a meaningful displacement so live tracking does not
// churn ten large geometries for sub-pixel motion every 750 ms.
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 (
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
250
) {
for (const entity of layerState.visual.entities)
entity.position.setValue(groundCenter);
const positions = awarenessRangeRingPositions(
groundCenter,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
// 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');
}
@@ -310,26 +719,188 @@ export function createRendering({
clearVisual();
const entities = [];
entities.push(
layerState.viewer.entities.add({
position: groundCenter,
ellipse: {
semiMajorAxis: AWARENESS_RADIUS_M,
semiMinorAxis: AWARENESS_RADIUS_M,
fill: false,
outline: true,
outlineColor:
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
height: CONTEXT_RIM_HEIGHT_M,
},
}),
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;
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(ringPositions),
width: ring.width,
arcType: Cesium.ArcType.NONE,
material: new Cesium.PolylineOutlineMaterialProperty({
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');
}
@@ -343,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);
});
+17 -12
View File
@@ -20,6 +20,19 @@ function tileFreshness(response) {
};
}
/**
* Clip a flow line to a box. Every piece keeps its uncut line as `line`, so
* pieces of one line that doubles back are never paired with each other.
*/
function clipFlowSegment(segment, bbox) {
const line = segment.line || segment;
return clipTileLine(segment.coords, bbox).map((coords) => ({
...segment,
coords,
line,
}));
}
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
export function createFlowTileSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -41,12 +54,7 @@ export function createFlowTileSource({
entry?.release();
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
.filter(isDrivableFlowSegment)
.flatMap((segment) =>
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
...segment,
coords,
})),
);
.flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y)));
if (mark) freshness.set(segments, mark);
return segments;
},
@@ -87,12 +95,9 @@ export function createFlowTileSource({
partial = result.partial;
const segments = bounds.coverage
? result.tiles.flat()
: result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
: result.tiles
.flat()
.flatMap((segment) => clipFlowSegment(segment, bounds));
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
const times = marks
.map((mark) => mark.fetchedAt)
+73 -8
View File
@@ -35,6 +35,14 @@ const MIN_FILL_M = 40;
* Same-class overlap keeps the full 35 m matcher radius.
*/
const CLASS_MISMATCH_RADIUS_M = 15;
/**
* Opposite-direction TomTom lines this close share one street: TomTom draws
* both directions of a two-way street a metre or two apart, and a divided
* carriageway's sides further apart.
*/
const PAIRED_SIDE_RADIUS_M = 5;
/** Arc-length positions sampled along a `one_side` line for its partner. */
const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
@@ -85,20 +93,69 @@ export function isDrivableFlowSegment(segment) {
);
}
/**
* Share of a `one_side` line's sample points (PAIRED_SIDE_SAMPLES) with an
* opposite-direction TomTom line beside them: TomTom draws a two-way street
* whose directions differ as two `one_side` lines on one centreline, while a
* divided carriageway's other side is further away than PAIRED_SIDE_RADIUS_M.
* @returns {number} Paired share in [0, 1].
*/
function pairedSampleShare(segment, index) {
const xs = [];
const ys = [];
const cum = [0];
for (const [lon, lat] of segment.coords) {
const [x, y] = index.project(lon, lat);
const i = xs.push(x) - 1;
ys.push(y);
if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1]));
}
const length = cum[cum.length - 1];
if (!(length > 0)) return 0;
const line = segment.line || segment;
let paired = 0;
let i = 1;
for (const share of PAIRED_SIDE_SAMPLES) {
const target = share * length;
while (i < cum.length - 1 && cum[i] < target) i++;
const dx = xs[i] - xs[i - 1];
const dy = ys[i] - ys[i - 1];
const t = (target - cum[i - 1]) / (cum[i] - cum[i - 1] || 1);
const reverse = (Math.atan2(-dx, -dy) * 180) / Math.PI;
if (
index.nearest(
xs[i - 1] + dx * t,
ys[i - 1] + dy * t,
reverse,
// A line that doubles back beside itself, even clipped into pieces,
// is not its own pair.
(piece) =>
(piece.flow.line || piece.flow) === line ? 0 : PAIRED_SIDE_RADIUS_M,
).best
)
paired++;
}
return paired / PAIRED_SIDE_SAMPLES.length;
}
/**
* Convert decoded TomTom flow lines into road records that carry their own
* flow. A line with `coverage: 'full'` describes both travel directions;
* `one_side` lines describe the direction they are drawn in.
* `one_side` lines describe the direction they are drawn in. A `one_side`
* line paired with the opposite direction on the same street gets that
* direction's half share of dots, as a two-way road's direction does.
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
* @returns {Array<object>} Road records (`directFlow: true`).
*/
export function flowSegmentsToRoads(segments) {
return (segments || [])
.filter(
(segment) =>
segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
)
.map((segment) => ({
const drivable = (segments || []).filter(
(segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
);
const sides = createFlowSegmentIndex(
drivable.filter((segment) => segment.coverage !== 'full'),
);
return drivable.map((segment) => {
const road = {
coordinates: segment.coords,
type: FLOW_ROAD_TYPES[segment.roadType],
drivable: true,
@@ -110,7 +167,15 @@ export function flowSegmentsToRoads(segments) {
oneway: segment.coverage === 'full' ? 0 : 1,
flow: { level: segment.trafficLevel, closure: segment.closure === true },
directFlow: true,
}));
};
if (
segment.coverage !== 'full' &&
sides &&
pairedSampleShare(segment, sides) >= 0.5
)
road.densityWeight = 0.5;
return road;
});
}
/** Cut a projected polyline to the arc-length interval [from, to]. */
+130
View File
@@ -1,10 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PbfWriter } from 'pbf';
import {
flowSegmentsToRoads,
resolveRoadMode,
selectTrafficRoads,
} from './roadModes.js';
import { createFlowTileSource } from './flowSource.js';
import { createTrafficSource } from './source.js';
import { createTrafficLayer } from './index.js';
import {
@@ -14,6 +16,7 @@ import {
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
const coordinates = [
[-97.744, 30.267],
@@ -116,6 +119,133 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', ()
assert.equal(roads.length, 1);
assert.equal(roads[0].directFlow, true);
});
test("opposite TomTom sides of one street share a two-way street's dots", () => {
// About 1 m and 20 m north of `coordinates`, drawn westbound.
const back = (dLat) => ({
...flow,
coords: coordinates.map(([lon, lat]) => [lon, lat + dLat]).reverse(),
});
const weights = (segments) =>
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1);
assert.deepEqual(weights([flow, back(0.00001)]), [0.5, 0.5]);
assert.deepEqual(weights([flow]), [1], 'a lone side keeps the one-way share');
assert.deepEqual(
weights([flow, back(0.00018)]),
[1, 1],
'a divided carriageway keeps one-way shares',
);
assert.deepEqual(
weights([
flow,
{
...flow,
coords: coordinates.map(([lon, lat]) => [lon, lat + 0.00001]),
},
]),
[1, 1],
'same-direction lines are not a pair',
);
assert.deepEqual(
weights([{ ...flow, coverage: 'full' }, back(0.00001)]),
[1, 1],
'a full-coverage line is not a side, and pairs with nothing',
);
});
test('a TomTom side paired along only part of its length keeps its share', () => {
// Westbound partners about 1 m north, from the start of `flow` to 30% and
// to 60% of its length: one or two of its three sample points.
const partner = (toLon) => ({
...flow,
coords: [
[toLon, 30.26701],
[-97.744, 30.26701],
],
});
const weightOfFlow = (other) =>
flowSegmentsToRoads([flow, other])[0].densityWeight ?? 1;
assert.equal(weightOfFlow(partner(-97.7434)), 1);
assert.equal(weightOfFlow(partner(-97.7428)), 0.5);
});
test('a TomTom side that doubles back beside itself is not its own pair', () => {
// East along `coordinates`, then back west about 1 m north.
const hairpin = {
...flow,
coords: [...coordinates, [-97.742, 30.26701], [-97.744, 30.26701]],
};
assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1);
});
/** A flow tile of one-way Major roads; each line is [x, y, x, y, ...]. */
function flowTile(lines) {
const writer = new PbfWriter();
writer.writeMessage(3, writeFlowLayer, lines);
return writer.finish();
}
function writeFlowLayer(lines, layer) {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'Traffic flow');
layer.writeVarintField(5, 4096);
layer.writeStringField(3, 'traffic_level');
layer.writeMessage(4, (_, value) => value.writeDoubleField(3, 0.6), null);
layer.writeStringField(3, 'road_type');
layer.writeMessage(
4,
(_, value) => value.writeStringField(1, 'Major road'),
null,
);
layer.writeStringField(3, 'traffic_road_coverage');
layer.writeMessage(
4,
(_, value) => value.writeStringField(1, 'one_side'),
null,
);
for (const xy of lines) {
// MoveTo one point, LineTo the rest, as zigzag deltas.
const geometry = [9];
for (let i = 0; i < xy.length; i++) {
if (i === 2) geometry.push(2 | ((xy.length / 2 - 1) << 3));
const delta = xy[i] - (i < 2 ? 0 : xy[i - 2]);
geometry.push((delta << 1) ^ (delta >> 31));
}
layer.writeMessage(
2,
(_, feature) => {
feature.writeVarintField(3, 2);
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
feature.writePackedVarint(4, geometry);
},
null,
);
}
}
test('TomTom sides clipped by their tile and the view pair only with other lines', async () => {
// A one-way hairpin turning in the tile buffer, its arms one tile unit
// (about 2 m) apart, and a separate opposite pair. The tile clip cuts the
// hairpin into two arms; the view clip then cuts every line again.
const lines = [
[500, 1000, 4300, 1000, 4300, 1001, 500, 1001],
[-200, 3000, 4300, 3000],
[4300, 3001, -200, 3001],
];
const source = createFlowTileSource({
fetchImpl: async () => new Response(flowTile(lines)),
});
const tile = tileToBBox(12, 935, 1686);
const west = tile.west + (tile.east - tile.west) * 0.2;
const { segments } = await source.fetchFlowDetail({
...tile,
west,
east: tile.east - 1e-9,
south: tile.south + 1e-9,
});
assert.equal(segments.length, 4);
assert.ok(
segments.every((s) => s.coords.every(([lon]) => lon >= west - 1e-10)),
);
assert.deepEqual(
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1),
[1, 1, 0.5, 0.5],
);
});
test('bearing and distance reject nearby crossing and separate OSM roads', () => {
const parallel = {
...main,
+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({