mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into fix/houston-cctv-pack
This commit is contained in:
commit
09bdc314d0
65 files changed
+5080
-97
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": "المجال الجوي المحظور والمقيّد"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "シーンを作成・編集",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "РЕДАКТОР",
|
||||
|
||||
@@ -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": "СТАРТ",
|
||||
|
||||
@@ -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": "创作",
|
||||
|
||||
@@ -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": "場景",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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]. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -61,6 +61,7 @@ const PANEL_GROUPS = [
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
'drone-airspace',
|
||||
'local-datacenters',
|
||||
'telegeography-submarine-cables',
|
||||
'local-dams',
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user