Merge remote-tracking branch 'upstream/main' into fix/houston-cctv-pack

# Conflicts:
#	.env.example
#	README.md
#	docs/CURRENT-STATE.md
#	server/providers/cctv/catalog.js
#	server/providers/cctv/normalize.js
#	server/providers/cctv/sources.js
#	src/data/dataCredits.js
This commit is contained in:
Matt Van Horn committed 2026-10-27 05:09:29 -07:00
commit f2e722c4bf
913 files changed
+81152 -21200

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import * as Cesium from 'cesium';
import {
feedProvenanceEnvelope,
layerSnapshot,
layerSnapshots,
} from '../data/layerSnapshot.js';
import { contextModeWord } from '../contextModePolicy.js';
export const TRACKABLE_FAMILIES = Object.freeze([
{ layerId: 'flights', kind: 'aircraft' },
{ layerId: 'military', kind: 'aircraft' },
{ layerId: 'ais-live-vessels', kind: 'vessel' },
{ layerId: 'satellites', kind: 'satellite' },
]);
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
{ field: 'mode', emptyAs: 'off' },
{ field: 'entering', emptyAs: null },
{ field: 'priorMode', emptyAs: null },
]);
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
'context',
'contextRollback',
]);
/**
* Translate context-mode payloads into the public tool vocabulary while
* retaining the internal id beside each translated field.
*/
export function withContextModeVocabulary(state) {
if (!state || typeof state !== 'object') return state;
let out = state;
const mutable = () => {
if (out === state) out = { ...state };
return out;
};
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
if (!(field in state)) continue;
const internal = state[field] ?? null;
const target = mutable();
target[field] = contextModeWord(internal, { emptyAs });
target[`${field}Internal`] = internal;
}
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
const nested = state[field];
if (!nested || typeof nested !== 'object') continue;
const translated = withContextModeVocabulary(nested);
if (translated !== nested) mutable()[field] = translated;
}
return out;
}
/** Read tracked entities from their canonical layer owners. */
export function collectTrackedEntities(dataManager) {
const tracked = [];
for (const family of TRACKABLE_FAMILIES) {
const module = dataManager?.layers?.get(family.layerId)?.module;
if (!module) continue;
try {
const info =
family.kind === 'vessel'
? module.getSelectedInfo?.()
: module.getTrackedInfo?.();
if (info)
tracked.push({
kind: family.kind,
layerId: family.layerId,
...info,
});
} catch {
// A not-yet-ready layer contributes no tracked observation.
}
}
return tracked;
}
/** Read the active Contacts window from its owning awareness layer. */
export function activeContactsWindow(dataManager) {
try {
const layer = dataManager?.layers?.get('military-awareness')?.module;
return (
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
);
} catch {
return null;
}
}
/**
* Build the model-free fast World Packet from existing application owners.
*
* This performs no network requests and intentionally mirrors the historical
* get_current_view_state payload so voice can delegate without behavior drift.
*/
export function getWorldPacket(options = {}) {
const {
viewer,
styleManager,
dataManager,
sceneDirector = null,
now: requestedNow,
} = options ?? {};
// A default parameter only covers undefined; null or NaN would age every
// feed against the epoch and report stale data as "just now".
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
if (!viewer?.camera?.positionWC)
throw new TypeError('World Packet requires a viewer camera');
if (!dataManager || typeof dataManager.getAll !== 'function')
throw new TypeError('World Packet requires a data manager');
const cartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
// Read the roster once. Voice must not rewalk disabled layers: preserving the
// current enabled-only view keeps the model's context budget bounded.
const rows = dataManager.getAll();
const enabledRows = rows.filter((layer) => layer.enabled);
const contactsWindow = activeContactsWindow(dataManager);
const contextState =
typeof styleManager?.getContextModeState === 'function'
? withContextModeVocabulary(styleManager.getContextModeState())
: null;
return {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: Cesium.Math.toDegrees(cartographic.latitude),
longitude: Cesium.Math.toDegrees(cartographic.longitude),
heightM: cartographic.height,
},
style: styleManager?.activeStyle || 'normal',
context: contextState
? {
...contextState,
...(contactsWindow ? { contactsWindow } : {}),
}
: null,
cockpit:
typeof styleManager?.getCockpitState === 'function'
? styleManager.getCockpitState()
: null,
controls:
typeof styleManager?.getControlState === 'function'
? styleManager.getControlState()
: null,
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
tracked: collectTrackedEntities(dataManager),
disabledLayerCount: rows.length - enabledRows.length,
// Preserve get_current_view_state's existing compact enabled-only shape.
layers: enabledRows.map((layer) => {
const snapshot = layerSnapshot(layer, { now });
return {
id: layer.id,
name: layer.name,
enabled: layer.enabled,
count: layer.stats?.count || 0,
error: layer.stats?.error || null,
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
};
}),
feedProvenance: feedProvenanceEnvelope(
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
{ now },
),
};
}
+335
View File
@@ -0,0 +1,335 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getWorldPacket } from './worldPacket.js';
import { createGevActionRunner } from '../voice/gevActions.js';
function withoutVoicePresentation(result) {
const packet = { ...result };
delete packet.say;
delete packet.display;
delete packet.schedule;
return packet;
}
function harness() {
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
const viewer = {
trackedEntity: undefined,
clock: { onTick: { addEventListener: () => () => {} } },
scene: {
canvas: {
clientWidth: 1200,
clientHeight: 800,
addEventListener() {},
removeEventListener() {},
},
globe: { getHeight: () => 0 },
tweens: [],
},
camera: {
positionWC: position,
positionCartographic: Cesium.Cartographic.fromCartesian(position),
heading: 0,
pitch: Cesium.Math.toRadians(-45),
moveEnd: { addEventListener: () => () => {} },
cancelFlight() {},
lookAtTransform() {},
},
};
const rows = [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 3,
source: 'fixture',
lastUpdate: null,
},
},
];
const dataManager = {
layers: new Map([['traffic', { module: null }]]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'normal',
getContextModeState: () => ({ mode: 'flights' }),
getCockpitState: () => null,
getControlState: () => ({ hud: { visible: 'auto' } }),
};
return { viewer, dataManager, styleManager };
}
test('World Packet preserves get_current_view_state parity', async () => {
const { viewer, dataManager, styleManager } = harness();
const packet = getWorldPacket({ viewer, dataManager, styleManager });
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
assert.equal(packet.context.mode, 'contacts');
assert.equal(packet.context.modeInternal, 'flights');
assert.equal(packet.layers[0].feedState, 'nominal');
assert.equal(packet.feedProvenance.overall, 'nominal');
});
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
const { viewer, dataManager, styleManager } = harness();
dataManager.getAll = () => [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
];
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(packet.layers[0].count, 0);
assert.notEqual(packet.layers[0].feedState, 'nominal');
assert.notEqual(packet.feedProvenance.overall, 'nominal');
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
});
// The 2026-10 main voice contract omits disabled layers from the model payload.
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
const { viewer, dataManager, styleManager } = harness();
const rows = dataManager.getAll();
rows.push({
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
});
let reads = 0;
dataManager.getAll = () => {
reads += 1;
return rows;
};
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
assert.equal(packet.disabledLayerCount, 1);
assert.deepEqual(
packet.layers.map((layer) => layer.id),
['traffic'],
);
assert.equal(packet.feedProvenance.overall, 'nominal');
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
});
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
// Exercises every packet field: context vocabulary and Contacts window,
// cockpit, controls, scene playback, tracked families (one not ready),
// enabled and disabled layers, and an aged and an unavailable feed.
function pinnedHarness() {
const { viewer } = harness();
const rows = [
{
id: 'flights',
name: 'Flights',
enabled: true,
stats: {
count: 12,
source: 'fixture-adsb',
lastUpdate: PINNED_NOW - 90_000,
},
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
},
];
const module = (methods) => ({ module: methods });
const dataManager = {
layers: new Map([
[
'flights',
module({
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
}),
],
[
'ais-live-vessels',
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
],
[
'satellites',
module({
getTrackedInfo: () => {
throw new Error('not ready');
},
}),
],
[
'military-awareness',
module({
getContextSnapshot: () => ({ snapshot: true }),
contactsWindowFromSnapshot: (snapshot) =>
snapshot ? { windowMin: 30, count: 4 } : null,
}),
],
]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'night-vision',
getContextModeState: () => ({
mode: 'flights',
entering: null,
priorMode: null,
}),
getCockpitState: () => ({ active: false }),
getControlState: () => ({ hud: { visible: 'auto' } }),
};
const sceneDirector = {
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
};
return { viewer, dataManager, styleManager, sceneDirector };
}
// Captured from main's get_current_view_state before the extraction
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
// minus the speech envelope. Key order is part of the contract.
const PRE_EXTRACTION_PAYLOAD = {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: 30.259999999999994,
longitude: -97.74,
heightM: 499.99999999901877,
},
style: 'night-vision',
context: {
mode: 'contacts',
entering: null,
priorMode: null,
modeInternal: 'flights',
enteringInternal: null,
priorModeInternal: null,
contactsWindow: { windowMin: 30, count: 4 },
},
cockpit: { active: false },
controls: { hud: { visible: 'auto' } },
scenePlayback: { playing: false, sceneId: 'fixture' },
tracked: [
{
kind: 'aircraft',
layerId: 'flights',
icao24: 'abc123',
callsign: 'TEST1',
},
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
],
disabledLayerCount: 1,
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
count: 12,
error: null,
feedState: 'nominal',
source: 'fixture-adsb',
lastUpdate: 1792238310000,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
count: 0,
error: 'fixture unavailable',
feedState: 'unavailable',
source: 'fixture',
lastUpdate: 0,
},
],
feedProvenance: {
overall: 'unavailable',
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
feedState: 'nominal',
source: 'fixture-adsb',
count: 12,
lastUpdate: 1792238310000,
ageSec: 90,
ageLabel: '1m ago',
error: null,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
feedState: 'unavailable',
source: 'fixture',
count: 0,
lastUpdate: 0,
ageSec: null,
ageLabel: null,
error: 'fixture unavailable',
},
],
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
},
};
function assertPinnedPayload(actual) {
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
// deepEqual ignores key order; the serialized form does not.
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
}
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
assertPinnedPayload(packet);
});
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
const runner = createGevActionRunner(pinnedHarness());
const voice = await runner('get_current_view_state');
assertPinnedPayload(withoutVoicePresentation(voice));
});
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
for (const now of [null, Number.NaN, '2026-10-17']) {
const packet = getWorldPacket({ ...pinnedHarness(), now });
const flights = packet.feedProvenance.layers.find(
(layer) => layer.id === 'flights',
);
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
}
});
test('World Packet rejects null options with the camera requirement', () => {
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
});
+87
View File
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
assert.deepEqual(geocodes, []);
});
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
for (const types of [['locality', 'political'], ['natural_feature']]) {
const { resolve, geocodes } = harness({
lat: -28.0167,
lng: 153.4,
label: 'Gold Coast, Australia',
name: 'Gold Coast',
types,
});
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
assert.equal(resolved?.source, 'geocode');
assert.equal(resolved.lat, -28.0167);
assert.equal(resolved.lon, 153.4);
assert.equal(resolved.naturalRegion, undefined);
const outline = await resolved.resolveOutline();
assert.equal(outline?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Gold Coast']);
}
});
test('natural annotation outlines require a geocoded anchor', async () => {
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
const miss = harness();
assert.equal(await miss.resolve('the Alps', viewer), null);
assert.ok(miss.geocodes.includes('the Alps'));
}
const anchored = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types: ['natural_feature'],
});
const resolved = await anchored.resolve('the Alps', {});
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.naturalRegion, 'Alps');
assert.ok(outline.ring.length >= 8);
});
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
const { resolve, geocodes } = harness({
lat: 45.6336,
lng: -74.86493,
label: 'Canadian Shield',
name: 'Canadian Shield',
types: ['natural_feature'],
});
const resolved = await resolve(
'Canadian Shield',
viewerAt(45.6336, -74.86493),
);
assert.equal(resolved?.source, 'geocode');
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Canadian Shield']);
});
test('geocoded natural outlines respect district and around intent', async () => {
for (const extra of [
{ entityKind: 'district' },
{ intent: 'around_the_thing' },
]) {
const { resolve, geocodes } = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types:
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
});
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
assert.ok(geocodes.includes('the Alps'));
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
}
});
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
const controller = new AbortController();
const { resolve, geocodes } = harness();
const pending = resolve('the Alps', viewerAt(46.5, 10), {
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(geocodes, []);
});
test('country asks and geocoded country scopes use the bundled outline', async () => {
const direct = harness();
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
+2 -10
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -1567,16 +1568,7 @@ async function fetchRoute(coordPairs, mode, signal, service) {
}
function greatCircleM(a, b) {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleMeters(a, b);
}
function formatDistance(m) {
+11 -15
View File
@@ -20,6 +20,8 @@ import { t, tn } from '../i18n/index.js';
* `drawTool.js`.
*/
import { greatCircleMeters } from '../geoDistance.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
@@ -29,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
*/
export const MAX_VERTICES = 512;
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
export const MIN_VERTEX_SEPARATION_M = 0.5;
const MIN_VERTEX_SEPARATION_M = 0.5;
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
export const MIN_PATH_LENGTH_M = 1;
export const MIN_AREA_M2 = 1;
const MIN_PATH_LENGTH_M = 1;
const MIN_AREA_M2 = 1;
/** @param {string} shape @returns {'area'|'line'|'pin'} */
export function normalizeShape(shape) {
@@ -48,15 +50,8 @@ export function createDrawSession(shape = 'area') {
}
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
function greatCircleM(a, b) {
return greatCircleMeters(a, b);
}
/**
@@ -213,9 +208,10 @@ export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
+15
View File
@@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const lat = 999.6 / metresPerDegree;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat });
const metres = pathLengthM(line.vertices);
assert.ok(
metres >= 999.5 && metres < 1000,
`length should round to 1000 m, got ${metres}`,
);
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
+11 -11
View File
@@ -30,6 +30,7 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
@@ -51,7 +52,7 @@ import {
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
@@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
+106
View File
@@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.source, 'natural-earth');
assert.equal(region.error, undefined);
}
});
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const miss = geocoderFor(null);
assert.equal(
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
null,
);
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
const inside = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['natural_feature'],
});
const region = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
inside,
);
assert.equal(region?.source, 'natural-earth');
assert.equal(region.name, 'Cordillera Oriental');
assert.ok(region.ring.length >= 8);
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
});
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const [name, lat, lng] of [
['Cordillera Oriental', 30.27, -97.74],
['Canadian Shield', 45.6336, -74.86493],
]) {
const geocoder = geocoderFor({
lat,
lng,
label: name,
name,
types: ['natural_feature'],
});
assert.equal(
await resolveRegionRingForQuery(name, undefined, geocoder),
null,
);
assert.deepEqual(geocoder.calls, [name]);
}
});
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: async (point) => {
lookups.push(point);
return [];
},
}),
});
const geo = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['locality'],
});
const resolved = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
geo,
);
assert.equal(
resolved,
null,
'an unavailable city boundary is not a natural-region outline',
);
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
});
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const controller = new AbortController();
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
const later = new AbortController();
await assert.rejects(
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
async geocode() {
later.abort();
return { place: null };
},
}),
{ name: 'AbortError' },
);
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
@@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
]) {
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
assert.equal(region?.name, name, query);
assert.equal(region.source, 'bundled-admin');
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
}
assert.deepEqual(geocoder.calls, [], 'no geocoding');
+41 -21
View File
@@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Ms the analyst region lookup waits on geocode + admin boundary. */
export const REGION_FALLBACK_BUDGET_MS = 3_000;
const REGION_FALLBACK_BUDGET_MS = 3_000;
/** Entity facts that rule out a state/county reading of the ask. */
const NON_ADMIN_ENTITY_KINDS = new Set([
@@ -103,18 +103,6 @@ export function createAnnotationResolver({
cacheWrite(cache, key, null);
}
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
function linkAbort(controller, externalSignal) {
if (!externalSignal) return () => {};
if (externalSignal.aborted) {
controller.abort();
return () => {};
}
const onAbort = () => controller.abort();
externalSignal.addEventListener('abort', onAbort, { once: true });
return () => externalSignal.removeEventListener('abort', onAbort);
}
/**
* Resolve a single annotation target to a normalized world anchor.
*
@@ -444,6 +432,7 @@ export function createAnnotationResolver({
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
scope === 'country' ||
@@ -469,6 +458,7 @@ export function createAnnotationResolver({
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
() => null,
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
const neCentroid = ringCentroid(ne.ring);
@@ -2004,8 +1994,8 @@ export function createAnnotationResolver({
/**
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
* a name-only entry point that reuses this module's boundary machinery
* without the annotation pipeline. Natural Earth pack first (offline,
* instant; largest-area match is correct for a global name-only ask), then
* without the annotation pipeline. Unique Natural Earth names resolve first
* (offline); duplicate names require a geocoded containment anchor, then
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
* Overpass, so repeat asks are instant). Returns null when the name doesn't
* resolve to a region-like boundary — the analyst engine reports that
@@ -2020,7 +2010,7 @@ export function createAnnotationResolver({
* @param {AbortSignal} [signal]
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
@@ -2037,16 +2027,24 @@ export function createAnnotationResolver({
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
if (ne?.polygons?.length) {
signal?.throwIfAborted();
if (ne?.candidates === 1 && ne.polygons?.length) {
// Largest ring carries the query scope; multi-ring regions (Andes) keep
// their main cordillera — good enough for containment counting.
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
if (ring?.length >= 3)
return { name: ne.name, ring, source: 'natural-earth' };
}
// Bundled states/provinces/counties by name (offline); the main part only,
// like the Natural Earth rung above.
const admin = await findAdminArea(q).catch(() => null);
if (admin) return { name: admin.name, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return {
name: admin.name,
ring: [...admin.ring],
source: 'bundled-admin',
};
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
@@ -2067,8 +2065,26 @@ export function createAnnotationResolver({
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
signal?.throwIfAborted();
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (
scope === 'auto' ||
(scope === 'compound' && geo.types?.includes('natural_feature'))
) {
const natural = await lookupNaturalRegionOutline(
q,
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (natural)
return {
name: natural.name,
ring: natural.ring,
source: 'natural-earth',
};
}
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
if (scope === 'state' || scope === 'county' || scope === 'country') {
const admin = await findAdminAreaAt(
@@ -2077,12 +2093,16 @@ export function createAnnotationResolver({
geo.lon,
scope,
).catch(() => null);
if (admin) return { name: q, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
signal?.throwIfAborted();
if (fp?.ring?.length >= 3)
return { name: q, ring: fp.ring, source: 'remote-boundary' };
return null;
}
+11
View File
@@ -219,6 +219,17 @@ test('the separate viewer export imports without constructing a browser viewer',
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
const { applicationViewerOptions } = await import('./viewer.js');
const { StarrySky } = await import('../sky/starrySky.js');
const creditContainer = {};
const options = applicationViewerOptions(creditContainer);
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
assert.ok(options.skyBox instanceof StarrySky);
assert.equal(options.creditContainer, creditContainer);
options.skyBox.destroy();
});
function pinchFixture({ zoomEventTypes } = {}) {
const handlers = new Set();
const relayed = [];
+2
View File
@@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
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 { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -126,6 +127,7 @@ export function createApplicationCatalog({
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 32);
assert.equal(first.layers.length, 33);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -61,6 +61,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('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -154,7 +155,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, 32);
assert.equal(catalog.layers.length, 33);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+8 -1
View File
@@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.js';
/** Construct the existing controls and camera presentation. */
/**
* Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
loaderStatus,
@@ -13,6 +17,7 @@ export function createApplicationControls({
catalog,
placeSearch,
defer,
initialShareCode = null,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -27,6 +32,8 @@ export function createApplicationControls({
requestServices: operations.requests,
mapStackController,
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
+3 -3
View File
@@ -15,9 +15,9 @@ import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
export const EMBED_VIEW_MESSAGE = 'gev:view';
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
export const EMBED_READY_MESSAGE = 'gev:ready';
const EMBED_VIEW_MESSAGE = 'gev:view';
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
const EMBED_READY_MESSAGE = 'gev:ready';
const FOLLOW_LAYERS = {
aircraft: 'flights',
+23
View File
@@ -0,0 +1,23 @@
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
export function createApplicationGhostMaps(options) {
return createGhostMapsLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+1 -11
View File
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
services: {
sprites,
picking,
input,
scenePick,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
services: { sprites, picking, input, scenePick, render },
});
}
+14
View File
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
test('every built-in provider is exported, so hosts can choose their own list', async () => {
const google = await import('gods-eye-view/layers/street-level/google');
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
const layer = createApplicationStreetLevel({
providers: [
google.createGoogleProvider({ getApiKey: () => null }),
kartaview.createKartaViewProvider(),
panoramax.createPanoramaxProvider(),
],
});
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
});
+2 -1
View File
@@ -48,12 +48,13 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
/** An omitted provider is unavailable, never an authoritative empty dataset. */
export class SourceUnavailableError extends Error {
class SourceUnavailableError extends Error {
constructor(name) {
super(`Data source not configured: ${name}`);
this.name = 'SourceUnavailableError';
+65 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 32);
assert.equal(actual.layers.length, 33);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
@@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej
);
});
test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => {
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
throw new Error('Unexpected standalone acquisition');
});
const missing = catalog({}, t);
assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: missing.getSourceAvailability },
);
manager.register(missing.get('s2-ghostmaps'));
assert.equal(await manager.setEnabled('s2-ghostmaps', true), false);
assert.equal(manager.layers.get('s2-ghostmaps').initialized, false);
assert.equal(
createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'],
null,
);
let reads = 0;
let subscriptions = 0;
let unsubscribed = 0;
const source = {
getCatalog: async () => {
reads++;
return { commit: 'a'.repeat(40), products: [] };
},
getFile: async () => {
throw new Error('Empty catalog must not read files');
},
subscribe: () => {
subscriptions++;
return () => {
unsubscribed++;
};
},
};
const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source });
assert.equal(supplied['s2-ghostmaps'], source);
const configured = catalog(supplied, t);
assert.equal(
configured.getSourceAvailability('s2-ghostmaps').available,
true,
);
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
globalThis.document = { addEventListener() {}, removeEventListener() {} };
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'document', previous);
else delete globalThis.document;
});
const layer = configured.get('s2-ghostmaps');
t.after(() => layer.destroy());
layer.init({ scene: { requestRender() {} } });
layer.enable();
await layer.update();
layer.disable();
assert.equal(reads, 1);
assert.equal(subscriptions, 1);
assert.equal(unsubscribed, 1);
assert.equal(layer.getStats().commit, 'a'.repeat(40));
assert.equal(fetches, 0);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
-2
View File
@@ -1,2 +0,0 @@
// Compatibility entry; source contracts are owned by sources/.
export { createSourceSlot } from '../sources/sourceSlot.js';
-24
View File
@@ -1,24 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSourceSlot } from './sourceSlot.js';
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
let finish;
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
const pending = slot.source.read();
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
old();
finish('stale');
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(await slot.source.read(), 'replacement');
assert.equal(slot.source.attribution.name, 'Replacement');
stop();
assert.throws(() => slot.source.read(), /not configured/);
});
test('an invalid replacement leaves the working source intact', async () => {
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
assert.throws(() => slot.configure({}), TypeError);
assert.equal(await slot.source.read(), 42);
});
+10
View File
@@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
@@ -110,6 +111,15 @@ export function createApplicationTools({
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
// alive in reused per-pass arrays; trim them after each disable.
defer(
installLayerReleaseTrim({
scene: viewer.scene,
dataManager,
requestRender: governorRequestRender,
}),
);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
+23 -6
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -102,11 +103,9 @@ export function installTrackpadPinchZoom(
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(container, {
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
timeline: false,
animation: false,
baseLayerPicker: false,
@@ -119,12 +118,30 @@ export function createApplicationViewer({ container, creditContainer }) {
selectionIndicator: false,
infoBox: false,
baseLayer: false,
// Replaces the blocky Tycho-2 default. An option, not `skyBox: false`,
// which would also stop CesiumWidget from creating the Sun and Moon.
skyBox: new StarrySky(),
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
});
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(
container,
applicationViewerOptions(creditContainer),
);
try {
viewer.targetFrameRate = 60;
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
// Keep Cesium's distance-aware zoom and collision limits, with less travel
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
-46
View File
@@ -1,51 +1,5 @@
import * as Cesium from 'cesium';
/**
* Camera presets for notable locations.
* Phase 1 default: fly to Austin, TX on load.
*/
export const CAMERA_PRESETS = {
austin: {
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-35),
roll: 0.0,
},
},
sf: {
destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000),
orientation: {
heading: Cesium.Math.toRadians(30),
pitch: Cesium.Math.toRadians(-30),
roll: 0.0,
},
},
nyc: {
destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200),
orientation: {
heading: Cesium.Math.toRadians(-20),
pitch: Cesium.Math.toRadians(-30),
roll: 0.0,
},
},
};
/**
* Fly the camera to a preset location with a smooth animation.
*/
export function flyToPreset(viewer, presetName, duration = 3.0) {
const preset = CAMERA_PRESETS[presetName];
if (!preset) return;
viewer.camera.flyTo({
destination: preset.destination,
orientation: preset.orientation,
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
}
/**
* Set camera to Austin on load with a cinematic fly-in.
* @returns {Function} Cancels the pending or active startup flight.
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js';
/** Minimum eye height above the rendered surface for a usable view. */
export const MIN_EYE_CLEARANCE_M = 120;
/** Retries while tiles stream in; sampling fails until the mesh exists. */
export const GUARD_ATTEMPTS = 8;
export const GUARD_INTERVAL_MS = 600;
const GUARD_ATTEMPTS = 8;
const GUARD_INTERVAL_MS = 600;
/** Ignore sub-metre noise rather than nudging the camera forever. */
const GUARD_EPSILON_M = 2;
+3 -4
View File
@@ -1,7 +1,6 @@
import { LayerBindings } from './ui/layerBindings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict';
@@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
@@ -262,7 +261,7 @@ test('deferred search releases only after its final authority check', () => {
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
});
@@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => {
});
test('vessel and fire layers announce valid clicks and never fly cameras', () => {
for (const [label, source] of [['vessels', vessels], ['fires', firms]]) {
for (const source of [vessels, firms]) {
assert.match(source, /requestWorldFocus\(\{/);
assert.doesNotMatch(source, /camera\.flyTo/);
}
+300 -12
View File
@@ -4,7 +4,7 @@
*
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
* starts (the shared UI navigation policy releases this owner before the new
* destination mutates the camera). fly_route is a
* continuous motion in the same slot: a cinematic dolly along an existing
@@ -13,14 +13,24 @@
* Orbit circles the current screen-center ground target holding range and
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
* transform is cleared on every stop so manual control returns cleanly).
* Globe rotation turns the world-frame camera about its screen-up axis through
* Earth's center, preserving range and framing. Left/right name the visible
* globe motion, opposite to the camera's travel around Earth.
*/
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} from './celestialRing.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
@@ -952,6 +962,7 @@ let _viewer = null;
let _getTarget = null;
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
let _tickRemover = null;
let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
@@ -1162,7 +1173,11 @@ function onTick() {
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
m.kind === 'rotate_globe'
) {
const degS = TILT_DEG_S[m.speed];
const stepRad =
m.mode === 'once'
@@ -1172,7 +1187,38 @@ function onTick() {
(scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale;
if (m.kind === 'tilt') {
if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
// Cesium rotates both position and basis about the reference-frame
// origin. The captured screen-up axis keeps left/right horizontal even
// in a tilted or rolled view; a positive angle moves the globe right.
const angle = m.direction === 'left' ? -stepRad : stepRad;
if (globeRotationFitsKeyhole() !== true) {
interruptCameraMotion('globe-framing-changed');
return;
}
if (
!globeRotationHasClearance(cam.positionWC) ||
Math.abs(
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
) > 0.01
) {
interruptCameraMotion('globe-surface-limit');
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
const next = cam.pitch + (up ? stepRad : -stepRad);
@@ -1206,6 +1252,133 @@ function onTick() {
}
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const { minimumRadius, maximumRadius } = ellipsoid;
const radius = Cesium.Cartesian3.magnitude(position);
return (
Number.isFinite(minimumRadius) &&
minimumRadius > 0 &&
Number.isFinite(maximumRadius) &&
maximumRadius >= minimumRadius &&
Number.isFinite(radius) &&
radius > maximumRadius * (1 + 1e-12)
);
}
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
function globeRotationFitsKeyhole(camera = _viewer.camera) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
return null;
const canvas = _viewer.scene.canvas;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const frustum = camera.frustum;
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0 ||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
!Number.isFinite(frustum.near) ||
frustum.near <= 0 ||
!Number.isFinite(frustum.far) ||
frustum.far <= frustum.near ||
frustum.xOffset !== 0 ||
frustum.yOffset !== 0
)
return null;
try {
const cam = camera;
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
const sphereRadius = ellipsoid.maximumRadius;
if (!Number.isFinite(distance) || distance <= 0) return null;
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
if (!radiusPx) return null;
const keyhole = getKeyholeGeometry(width, height);
// The known nadir reset probe uses the same exact cone below; projecting
// through SceneTransforms would read the live scene.camera instead.
if (
camera === _viewer.camera &&
!projectEarthDiscToViewport(_viewer, width, height)
) {
// A near-surface horizon view cannot fit even when pointed at Earth;
// an orbital camera looking away is unsupported, not a close-up offer.
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
? false
: null;
}
// The shared disc is an overlay approximation off-axis. Bound the sphere
// against the existing keyhole cone. Canvas-edge clipping does not change
// framing during rigid globe rotation and is not a clearance condition,
// without changing the UI projection or defining another keyhole/mask.
const toCenter = Cesium.Cartesian3.negate(
cam.positionWC,
new Cesium.Cartesian3(),
);
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
const focalX =
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
if (
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
focalX <= 0 ||
focalY <= 0
)
return null;
const halfAngle = Math.asin(sphereRadius / distance);
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
const framed = centerAngle + halfAngle <= keyholeAngle;
if (!framed) return false;
// Projection alone does not prove that a tiny globe is within depth planes.
if (
depth - sphereRadius < frustum.near ||
depth + sphereRadius > frustum.far
)
return null;
return true;
} catch {
return null;
}
}
/** Check the unchanged reset destination without moving the live camera. */
function globeResetFitsKeyhole() {
try {
const camera = Cesium.Camera.clone(_viewer.camera);
const { longitude, latitude } = _viewer.camera.positionCartographic;
camera.setView({
destination: Cesium.Cartesian3.fromRadians(
longitude,
latitude,
GLOBE_VIEW.heightM,
),
orientation: {
heading: 0,
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
roll: 0,
},
endTransform: Cesium.Matrix4.IDENTITY,
});
return globeRotationFitsKeyhole(camera) === true;
} catch {
return false;
}
}
/** Release only globe rotation when a new camera intent takes ownership. */
export function interruptGlobeRotation(reason = 'camera-intent') {
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
return { wasActive: false, reason, leveled: false };
}
/**
* Wire the module to the viewer once (idempotent). The view-target getter is
* optional so a non-voice caller (the Directions FLY chip) can arm route
@@ -1218,24 +1391,42 @@ function onTick() {
* call brings none, so a callback from the old viewer can never answer for the
* new one.
*/
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
export function initCameraVerbs(
viewer,
getViewTargetCartesian = null,
{ isCockpitActive } = {},
) {
const nextTarget =
typeof getViewTargetCartesian === 'function'
? getViewTargetCartesian
: null;
if (_viewer === viewer) {
if (nextTarget) _getTarget = nextTarget;
if (typeof isCockpitActive === 'function')
_isCockpitActive = isCockpitActive;
return;
}
// A globe motion belongs to the viewer whose world pose it captured.
if (_active?.kind === 'rotate_globe')
interruptCameraMotion('viewer-replaced');
_getTarget = nextTarget;
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
// Cockpit can acquire then clear tracking before the next clock tick.
// Yield on acquisition itself without changing legacy camera motions.
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
_inputRemovers = [];
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'wheel']) {
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
const mode =
args.mode === 'continuous' ||
(motion === 'rotate_globe' && args.mode === undefined)
? 'continuous'
: 'once';
if (motion === 'stop') {
const wasActiveBeforeHandoff = Boolean(_active);
const stop = () => {
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
}
}
if (
motion === 'orbit' &&
_viewer?.trackedEntity &&
typeof runNavigation !== 'function'
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
_viewer?.trackedEntity
) {
// The follow camera owns a tracked view; a lookAt orbit fights it frame
// by frame and stop rips the camera out (field finding). Honest refusal
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
return {
ok: false,
action: 'move_camera',
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
};
}
let globePose;
if (motion === 'rotate_globe') {
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
return {
ok: false,
action: 'move_camera',
error: 'Globe rotation requires a 3D view.',
};
}
const cam = _viewer.camera;
globePose = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
right: Cesium.Cartesian3.clone(cam.rightWC),
};
if (_isCockpitActive?.())
return {
ok: false,
action: 'move_camera',
reason: 'camera-owned',
error: 'Exit Cockpit before rotating the globe.',
};
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!(
Number.isFinite(ellipsoid.minimumRadius) &&
ellipsoid.minimumRadius > 0 &&
Number.isFinite(ellipsoid.maximumRadius) &&
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error:
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
};
const fits = globeRotationFitsKeyhole();
if (fits === null)
return {
ok: false,
action: 'move_camera',
reason: 'unsupported-view',
error: 'Globe rotation requires a supported perspective view.',
};
if (!fits) {
const canReset = globeResetFitsKeyhole();
return {
ok: false,
action: 'move_camera',
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
error: canReset
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
};
}
if (!globeRotationHasClearance(globePose.position))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
const start = () => {
interruptCameraMotion('replaced');
const state = {
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
speed,
mode,
};
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles.
Cesium.Cartesian3.clone(globePose.position, cam.position);
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
Cesium.Cartesian3.clone(globePose.up, cam.up);
Cesium.Cartesian3.clone(globePose.right, cam.right);
state.rotationAxis = globePose.up;
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
+1 -1
View File
@@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({
* @param {Object} [after={}] Ownership immediately after the await.
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
*/
export function mergeCctvEnableOwnership(before = {}, after = {}) {
function mergeCctvEnableOwnership(before = {}, after = {}) {
return {
trackedEntity: before.trackedEntity || after.trackedEntity || null,
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
-1
View File
@@ -1,7 +1,6 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
runCctvLayerEnableFocus,
runCctvLayerEnableTransition,
-1
View File
@@ -1,7 +1,6 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
CCTV_WORLD_CLICK_FOCUS_DURATION_SEC,
CCTV_FOCUS_REQUEST_EVENT,
+5 -18
View File
@@ -1,5 +1,6 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from './geoDistance.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
@@ -127,23 +128,9 @@ function compileShader(gl, type, source) {
}
function greatCircleM(a, b) {
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
return greatCircleMeters(
{ lat: a?.latitude, lon: a?.longitude },
{ lat: b?.latitude, lon: b?.longitude },
);
}
@@ -187,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
*/
export class CockpitCloudEffectsController {
class CockpitCloudEffectsController {
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
this.weatherService = weatherService;
this.viewer = viewer;
+9
View File
@@ -6,6 +6,15 @@ export function normalizeHeading(value) {
return ((value % 360) + 360) % 360;
}
/**
* Three-digit heading readout, 000–359.
* Rounding 359.5° and above lands on north (000), not 360.
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
export function slewHeading(current, target, maxStepDeg) {
const from = normalizeHeading(current);
+9
View File
@@ -15,6 +15,7 @@ import {
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(30), '030');
});
test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.6), '000');
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
test('great-circle bearing resolves cardinal directions', () => {
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
+2 -2
View File
@@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
uiSource.indexOf('_syncContactsDetection() {'),
uiSource.indexOf('/** Apply a temporary cockpit-only'),
);
assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js');
assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source');
assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click');
assert.match(helper, /active: this\._contextMode === 'flights'/);
assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/);
@@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'),
uiSource.indexOf('_syncIrBoost() {'),
);
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js');
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source');
assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection');
});
+9 -3
View File
@@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js';
import { ContextControls } from './ui/contextControls.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
// - session bookkeeping ran AFTER the exit early-return, so the compensating
@@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp
// - the right-rail entry ignored the activation result entirely.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const src = readShellSource();
@@ -97,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout'
assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/);
});
test('Clear All during the Contacts settle stops the snapshot coming back', () => {
const restore = _restoreContextSession.toString();
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(');
const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff);
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff);
assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles');
assert.ok(fanout > cancelled, 'and before the snapshot fans out');
});
test('context restore preserves its exact pending target after a failed transition', () => {
const restore = _restoreContextSession.toString();
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
-5
View File
@@ -1,4 +1,3 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
@@ -8,13 +7,11 @@ import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
@@ -32,8 +29,6 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const syncSource = _syncContextModeButtons.toString();
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
+3 -2
View File
@@ -1,4 +1,3 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const css = readStylesheet(path.join(ROOT, 'style.css'));
const ui = readShellSource();
/*
* Required-attribution keep-out pin.
@@ -247,6 +245,9 @@ function ownBoxEntries() {
const compound = lastCompound(part);
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
if (compound.includes('::')) continue; // a pseudo-element is its own box
// The phone shell is its own layout; mobileShell.test.mjs pins its
// attribution clearance.
if (part.startsWith('body.mobile-shell')) continue;
entries.push({ rule, part });
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
}
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
export function pointInRing(ring, lat, lon) {
function pointInRing(ring, lat, lon) {
if (!Array.isArray(ring) || ring.length < 3) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
+17
View File
@@ -0,0 +1,17 @@
/**
* Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B).
*
* Cesium gives each Model a dynamic environment map: it renders the sky around
* the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour
* (`colorBlendAmount`), so that lighting contributes little; without it they
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false,
});
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
/**
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
* map (a sky render and GPU readback per model). The preload calls only warm
* the glTF cache and are never added to the scene, so they are left out:
* drawn models are the calls that set a minimum pixel size.
*/
const SOURCES = [
{
name: 'flights',
source: readLayerSource(new URL('./flights.js', import.meta.url)),
},
{
name: 'militaryFlights',
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
},
{
name: 'localAdsb',
source: readFileSync(
new URL('../layers/localAdsb/models.js', import.meta.url),
'utf8',
),
},
];
test('the shared option turns the dynamic environment map off', () => {
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
});
for (const { name, source } of SOURCES) {
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
const calls = [
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
]
.map((match) => match[1])
.filter((options) => /\bminimumPixelSize:/.test(options));
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
for (const [index, options] of calls.entries())
assert.match(
options,
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
);
});
}
+28
View File
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
}
});
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
Object.defineProperty(globalThis, 'matchMedia', {
configurable: true,
value: () => ({ matches: true }),
});
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
const tile = { primitive: { isCesium3DTileset: true } };
const harness = installWireHarness(tile);
let drills = 0;
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
drills++;
assert.deepEqual([limit, width, height], [8, 14, 14]);
return [tile, { id: 'a1b2c3' }];
};
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(drills, 1);
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
} finally {
harness.cleanup();
unregisterPickOwner('flights');
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
else delete globalThis.matchMedia;
}
});
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
const harness = installWireHarness({ id: 'atx-cam-3' });
+1 -1
View File
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
* @param {Object} body
* @returns {string|null}
*/
export function aisEnvelopeMmsi(envelope, body = {}) {
function aisEnvelopeMmsi(envelope, body = {}) {
const metadata = envelope?.MetaData || envelope?.Metadata || {};
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
if (raw === null || raw === undefined) return null;
+195
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
AISSTREAM_STALE_MS,
aisStreamRows,
ingestAisStreamEnvelope,
readAisTrack,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
ingestAisStreamEnvelope(
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
);
// AISStream's MetaData carries the last known position on every message,
// static data included.
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 244690000,
Name: 'EXAMPLE VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
},
MetaData: {
MMSI: '244690000',
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('244690000');
assert.equal(row.speed, 12.4);
assert.equal(row.course, 86.7);
assert.equal(row.heading, 87);
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
assert.equal(row.destination, 'ROTTERDAM');
assert.equal(row.imo, '9876543');
});
// AISStream carries both Class B parts; the unused part has Valid: false.
const classBStatic = (part, value) => ({
MessageID: 24,
RepeatIndicator: 0,
Valid: true,
Reserved: 0,
PartNumber: part === 'B',
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
ReportB: {
Valid: part === 'B',
ShipType: part === 'B' ? value : 0,
VendorIDName: '',
VenderIDModel: 0,
VenderIDSerial: 0,
CallSign: '',
Dimension: { A: 0, B: 0, C: 0, D: 0 },
FixType: 0,
Spare: 0,
},
});
for (const [index, scenario] of [
{
type: 'ShipStaticData',
initial: [
{
Name: 'OLD VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
],
updates: [
{
Name: 'NEW VESSEL',
Type: 80,
Destination: 'HAMBURG',
ImoNumber: 9876544,
},
],
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
expected: {
name: 'NEW VESSEL',
type: '80',
destination: 'HAMBURG',
imo: '9876544',
},
},
{
type: 'StaticDataReport',
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
empty: classBStatic('A', ''),
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
},
].entries()) {
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
const mmsi = String(244690100 + index);
const start = Date.parse('2026-09-17T20:00:00Z');
let now = start;
t.mock.method(Date, 'now', () => now);
const position = (
lat,
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
) => {
const envelope = positionReport(mmsi, body);
Object.assign(envelope.MetaData, {
latitude: lat,
time_utc: new Date(now).toISOString(),
});
return ingestAisStreamEnvelope(envelope);
};
const sendStatic = (body) =>
ingestAisStreamEnvelope({
MessageType: scenario.type,
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
ShipName: 'STALE HEADER',
latitude: 54,
longitude: 7,
time_utc: new Date(now).toISOString(),
},
});
position(53.40879);
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
now += 60_000;
position(53.41879);
const before = rowFor(mmsi);
const track = readAisTrack(mmsi);
assert.equal(track.length, 2);
now += 5 * 60_000;
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
// Empty or omitted static fields must not erase known metadata, including
// the cached values used when the next actual position arrives.
assert.equal(sendStatic(scenario.empty), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
if (scenario.type === 'StaticDataReport') {
sendStatic({
...classBStatic('A', ''),
ReportA: { Valid: false, Name: 'INVALID NAME' },
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
});
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
}
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
assert.equal(
rowFor(mmsi),
undefined,
'static traffic must not renew position freshness',
);
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
const next = rowFor(mmsi);
for (const [key, value] of Object.entries(scenario.expected))
assert.equal(next[key], value, key);
assert.equal(next.speed, 0);
assert.equal(next.course, 0);
assert.equal(next.heading, 0);
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
assert.equal(readAisTrack(mmsi).length, 3);
});
}
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
const mmsi = '244690200';
const staticReport = (body) => ({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
const extended = positionReport(mmsi, {});
extended.MessageType = 'ExtendedClassBPositionReport';
extended.Message = {
ExtendedClassBPositionReport: {
UserID: Number(mmsi),
Type: 80,
Sog: 12,
Cog: 86,
},
};
extended.MetaData.ShipName = 'NEW VESSEL';
ingestAisStreamEnvelope(extended);
const before = rowFor(mmsi);
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
+1 -21
View File
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
authProbeMs: 3_600_000,
});
/**
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
* pre-watchdog meaning so a keyless install still reads as "feature off".
*/
export const AIS_WATCHDOG_STATUSES = Object.freeze([
'idle',
'missing-key',
'unsupported',
'connecting',
'live',
'stale',
'reconnecting',
'down',
'auth-failed',
]);
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
export const AIS_FAILURE_KINDS = Object.freeze([
'transport',
'auth',
'rate-limit',
]);
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
+25
View File
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
} from './alprCameras.js';
import { createAlprTileSource } from '../layers/alpr/source.js';
import { DATA_CREDITS } from './dataCredits.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import {
clearSelectedEntityContextForLayer,
getContextStore,
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
}
});
// The voice model reads the label as data; the voice card translates it.
test('the camera label stays English data in a translated interface', async () => {
const h = cameraHarness();
try {
setLocale('ru');
h.setFetch(async () =>
cameraResponse([
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
]),
);
await alprCamerasLayer.update();
h.click(h.source.entities.values[0].id);
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Камера ALPR',
);
} finally {
setLocale('en');
h.restore();
}
});
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
+20 -75
View File
@@ -27,6 +27,7 @@
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
import { greatCircleKm } from '../geoDistance.js';
/**
* Layers the engine understands, with the fields queries may reference,
@@ -61,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
/** Derived per-record field for layers with a time field (hours before now). */
const AGE_FIELD = 'ageHours';
/** Spatial scope kinds `query()` accepts. */
export const ANALYST_SCOPE_KINDS = Object.freeze([
const ANALYST_SCOPE_KINDS = Object.freeze([
'view',
'region',
'radius',
@@ -73,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
* @param {string[]} layerKeys Queried layer ids.
* @returns {string[]} Field names, common fields first.
*/
export function analystFieldsFor(layerKeys) {
function analystFieldsFor(layerKeys) {
const fields = new Set(COMMON_FIELDS);
for (const key of layerKeys) {
const layer = ANALYST_LAYERS[key];
@@ -98,55 +99,9 @@ function toEpochMs(value) {
return Date.parse(text);
}
const EARTH_R_KM = 6371;
/** Great-circle distance in km. */
/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */
export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
*/
export function applyFilter(records, filter, { time = false } = {}) {
const { field, op } = filter || {};
if (!field || !op) return records;
const value = time ? toEpochMs(filter.value) : filter.value;
return records.filter((r) => {
const got = time ? toEpochMs(r[field]) : r[field];
if (got === null || got === undefined || (time && !Number.isFinite(got)))
return false;
if (time && (op === 'eq' || op === 'neq'))
return (got === value) === (op === 'eq');
switch (op) {
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return false;
}
});
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
@@ -174,28 +129,6 @@ export function applyScope(records, scope, resolved) {
return records;
}
/** Numeric summary for the narration layer. */
function summarize(items, sortField) {
const summary = { count: items.length };
if (sortField && items.length) {
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
// the engine's argument limit.
let min = Infinity;
let max = -Infinity;
for (const item of items) {
const value = Number(item[sortField]);
if (!Number.isFinite(value)) continue;
if (value < min) min = value;
if (value > max) max = value;
}
if (Number.isFinite(min)) {
summary[`${sortField}Min`] = min;
summary[`${sortField}Max`] = max;
}
}
return summary;
}
const OPS_BY_TYPE = Object.freeze({
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
@@ -220,7 +153,7 @@ const COMMON_UNITS = Object.freeze({
* @param {string} field Field name.
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
*/
export function analystFieldType(layerKeys, field) {
function analystFieldType(layerKeys, field) {
if (!analystFieldsFor(layerKeys).includes(field)) return null;
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
for (const key of layerKeys) {
@@ -888,6 +821,7 @@ export function createAnalystEngine(providers) {
followUp && !spec.scope ? lastResult.distanceOf || null : null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
let scopeSource = rememberedScope?.source || null;
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
@@ -897,8 +831,16 @@ export function createAnalystEngine(providers) {
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
const region = await providers.resolveRegionRing(scope.name, signal);
if (!isCurrent()) return cancelled();
let region;
try {
region = await providers.resolveRegionRing(scope.name, signal);
} catch (error) {
// Cancellation belongs to the query owner, not an error's name: a
// resource-local AbortError from a current lookup still propagates.
if (signal?.aborted || !isCurrent()) return cancelled();
throw error;
}
if (signal?.aborted || !isCurrent()) return cancelled();
if (region?.error === 'region-timeout') {
return {
ok: false,
@@ -917,6 +859,7 @@ export function createAnalystEngine(providers) {
}
resolvedScope = region;
scopeNote = `region:${region.name}`;
scopeSource = region.source || null;
scopeLabel = `over ${region.name}`;
scopeDetail = scopeLabel;
} else if (scope.kind === 'radius') {
@@ -1056,6 +999,7 @@ export function createAnalystEngine(providers) {
layersQueried,
records: totals,
scope: scopeNote,
...(scopeSource ? { scopeSource } : {}),
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
@@ -1088,6 +1032,7 @@ export function createAnalystEngine(providers) {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
source: committedCoverage.scopeSource || null,
note: committedCoverage.note || null,
},
};
+303
View File
@@ -1,6 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
// Stub world: a square "Texland" region, flights + ships + fires around it.
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
assert.ok(r.coverage.scope.includes('Texland'));
});
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async () => ({ ...TEXLAND, source }),
});
const result = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.coverage.scopeSource, source);
}
});
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
const lookups = [];
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
lookups.push(name);
return { ...TEXLAND, source: 'natural-earth' };
},
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const airborne = await engine.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: false }],
});
const military = await engine.query({
followUp: true,
filters: [{ field: 'military', op: 'eq', value: true }],
});
assert.equal(first.count, 3);
assert.equal(airborne.count, 2);
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
for (const result of [first, airborne, military]) {
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.scopeLabel, 'over Texland');
assert.equal(result.coverage.scopeSource, 'natural-earth');
}
assert.deepEqual(lookups, ['Texland']);
});
test('analyst: an explicit replacement region replaces retained provenance', async () => {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: {
name,
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
source: 'remote-boundary',
},
});
await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({
followUp: true,
scope: { kind: 'region', name: 'Northern Texland' },
});
const followUp = await engine.query({ followUp: true });
for (const result of [replacement, followUp]) {
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
assert.equal(result.coverage.scope, 'region:Northern Texland');
assert.equal(result.coverage.scopeSource, 'remote-boundary');
}
});
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
for (const scope of [
{ kind: 'anywhere' },
{ kind: 'view' },
{ kind: 'radius', km: 150 },
{ kind: 'region', name: 'Uncredited Texland' },
]) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => ({
...TEXLAND,
name,
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({ followUp: true, scope });
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.scopeSource, 'natural-earth');
for (const result of [replacement, followUp]) {
assert.equal(result.ok, true, scope.kind);
assert.equal(result.count, 3, scope.kind);
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
}
}
});
for (const [label, committedScope, scopeSource] of [
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
['window without a boundary source', 'window:presented', null],
]) {
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
const committedSnapshot = {
id: 'flights', enabled: true, feedState: 'stale',
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
};
let reads = 0;
let lookups = 0;
const engine = createAnalystEngine({
getRecords: () => { reads++; return FLIGHTS; },
getLayerSnapshot: () => ({
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
}),
resolveRegionRing: async () => {
lookups++;
return { ...TEXLAND, source: 'natural-earth' };
},
});
await engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{
beforeCommit: async (result) => {
assert.equal(result.count, 3);
assert.equal(result.coverage.scopeSource, 'natural-earth');
const { scopeSource: _originalSource, ...coverage } = result.coverage;
return {
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
scopeLabel: 'the presented cohort',
scopeDetail: 'the presented cohort window',
coverage: {
...coverage,
scope: committedScope,
...(scopeSource ? { scopeSource } : {}),
feedProvenance: { layers: [committedSnapshot] },
},
};
},
},
);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.count, 1);
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
assert.equal(followUp.scopeLabel, 'the presented cohort');
assert.equal(followUp.display.scope, 'the presented cohort window');
assert.equal(followUp.coverage.scope, committedScope);
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
});
}
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
assert.equal(result.cancelled, true);
});
for (const [label, reason, trackAuthority] of [
['default abort reason', undefined, false],
['custom error reason', new Error('operator stopped'), false],
['primitive reason and revoked authority', 'operator stopped', true],
]) {
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
const started = Promise.withResolvers();
const geocode = Promise.withResolvers();
const controller = new AbortController();
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
method,
async () => { throw new Error('offline region query reached feature source'); },
])),
});
const placeSearch = {
geocode(name, { signal }) {
lookups.push(name);
assert.equal(signal, controller.signal);
started.resolve();
return geocode.promise;
},
};
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
name, signal, placeSearch, { budgetMs: Infinity },
),
});
const first = await engine.query({
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
});
assert.equal(first.ok, true);
assert.equal(first.coverage.scopeSource, 'bundled-admin');
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
let commits = 0;
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
{
signal: controller.signal,
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
beforeCommit: async () => { commits++; },
},
);
await started.promise;
controller.abort(reason);
// Let an actual late geocode result cross the resolver's post-await check.
geocode.resolve({ place: {
lat: 5, lng: -73, label: 'Cordillera Oriental',
name: 'Cordillera Oriental', types: ['natural_feature'],
} });
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
assert.equal(commits, 0);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
assert.equal(followUp.scopeLabel, first.scopeLabel);
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
});
}
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
const lookup = Promise.withResolvers();
let current = true;
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: lookup.promise,
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ isCurrent: () => current },
);
current = false;
lookup.reject(new Error('late lookup failed'));
assert.equal((await pending).code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
assert.equal(followUp.coverage.scope, first.coverage.scope);
});
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
const controller = new AbortController();
const lookup = Promise.withResolvers();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: () => lookup.promise,
});
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{ signal: controller.signal },
);
controller.abort();
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
assert.equal((await pending).code, 'CANCELLED');
assert.equal(engine.hasMemory(), false);
});
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
const controller = new AbortController();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
throw error;
},
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
await assert.rejects(
engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ signal: controller.signal },
),
(thrown) => thrown === error,
);
assert.equal(controller.signal.aborted, false);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
});
}
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
+7 -3
View File
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
const year = part({ year: 'numeric' });
// Year-first languages (2021年10月16日) keep their own order and units.
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
if (whole.startsWith(year)) return whole;
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
}
/**
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
+1 -5
View File
@@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
'bhote-koshi-locator-callout';
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
@@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700;
const INCIDENT_CALLOUT_GAP_MS = 200;
const CALLOUT_REVEAL_DURATION_MS = 1200;
const CALLOUT_PULSE_DURATION_MS = 1800;
const CALLOUT_DOT_SIZE_PX = 7;
const CALLOUT_RING_MIN_SIZE_PX = 16;
const CALLOUT_RING_SIZE_RANGE_PX = 14;
const CITY_CALLOUT_START_DELAY_MS = 350;
@@ -1248,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({
},
};
}
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
export default bhoteKoshiLocatorLayer;
-1
View File
@@ -23,4 +23,3 @@ export {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from '../layers/bikeshare/index.js';
export default layer;
+36
View File
@@ -77,6 +77,11 @@ import {
CCTV_FOCUS_REQUEST_EVENT,
activateCctvCameraFromWorldClick,
} from '../cctvFocusRequest.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
path.join(
@@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
}
});
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
const record = (id, feedType) => ({
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
billboard: {},
coverageEntities: [],
projection: null,
});
const idleVideo = record('idle-hls', 'hls');
const idleStill = record('idle-image', 'image');
const activeVideo = record('active-hls', 'hls');
const viewer = { entities: { add: (entity) => entity } };
try {
// Disabled keeps the active video camera from opening a real projection;
// icon colors are assigned either way.
_setCctvCoverageStateForTest({
viewer,
records: [idleVideo, idleStill, activeVideo],
activeCameraId: activeVideo.camera.id,
enabled: false,
coverageMode: 'off',
});
refreshCoverageStyles();
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
assert.deepEqual(
+222
View File
@@ -6,6 +6,7 @@ import {
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
assert.equal(kept.available, 5);
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
});
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
const envSources = (prefix, count = 3) =>
JSON.stringify(
Array.from({ length: count }, (_, i) => ({
id: `${prefix}-${i}`,
name: `${prefix} ${i}`,
lat: 30 + i * 0.01,
lon: -97,
url: `https://example.test/${prefix}-${i}.jpg`,
})),
);
/** Let a background refresh that needs no timers run to completion. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
};
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
const saved = { ...process.env };
try {
// Live packs run alongside the env pack, so one slow upstream can hold
// the refresh open.
process.env.CCTV_FORCE_AUSTIN = '1';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old');
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'log', () => {});
const hung = [];
let hang = false;
t.mock.method(globalThis, 'fetch', () =>
hang
? new Promise((_, reject) => {
hung.push(() => reject(new Error('upstream gave up')));
})
: Promise.reject(new Error('offline')),
);
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
assert.deepEqual(
(await getSources()).map((s) => s.id),
['old-0', 'old-1', 'old-2'],
);
// Past the TTL, with every live pack's upstream hanging.
now += CCTV_SOURCE_CACHE_MS + 1;
hang = true;
process.env.CCTV_SOURCES_JSON = envSources('new');
let timer;
const served = await Promise.race([
getSources(),
new Promise((resolve) => {
timer = setTimeout(() => resolve('waited'), 200);
}),
]);
clearTimeout(timer);
assert.notEqual(
served,
'waited',
'a stale request waited for the background refresh',
);
assert.deepEqual(
served.map((s) => s.id),
['old-0', 'old-1', 'old-2'],
);
// Requests during the refresh share it rather than starting another.
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
await pause();
const inFlight = hung.length;
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
await getSources();
await getSources();
await pause();
assert.equal(hung.length, inFlight, 'only one refresh ran');
// When the slow pack gives up, the refreshed catalog replaces the old one.
hang = false;
for (const giveUp of hung) giveUp();
for (let i = 0; i < 50; i += 1) {
const ids = (await getSources()).map((s) => s.id);
if (ids[0] === 'new-0') break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(
(await getSources()).map((s) => s.id),
['new-0', 'new-1', 'new-2'],
);
} finally {
restoreEnv(saved);
}
});
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
const saved = { ...process.env };
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
process.env.CCTV_PREFER_AUSTIN = '0';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
process.env.CCTV_MAX_SOURCES = '8';
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
// Nine cameras over a cap of eight make every refresh warn; throwing from
// that warning is how this test makes a refresh reject.
let failRefresh = false;
let failedRefreshes = 0;
const warnings = [];
t.mock.method(console, 'warn', (...args) => {
const text = args.join(' ');
if (failRefresh && text.includes('exceeds cap')) {
failedRefreshes += 1;
throw new Error('refresh broke');
}
warnings.push(text);
});
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const old = await getSources();
assert.equal(old.length, 8);
// Past the TTL the refresh throws: the old catalog is still served.
now += CCTV_SOURCE_CACHE_MS + 1;
failRefresh = true;
assert.equal(await getSources(), old);
await settle();
assert.equal(failedRefreshes, 1);
assert.equal(
warnings.filter((w) => w.includes('background catalog refresh failed'))
.length,
1,
);
// The failure is not retried on every request until the next TTL.
assert.equal(await getSources(), old);
assert.equal(await getSources(), old);
await settle();
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
// After it, a refresh that works replaces the catalog. With no live packs
// the refresh finishes before the request that started it returns.
now += CCTV_SOURCE_CACHE_MS + 1;
failRefresh = false;
process.env.CCTV_SOURCES_JSON = envSources('new');
await getSources();
await settle();
const fresh = await getSources();
assert.deepEqual(
fresh.map((s) => s.id),
['new-0', 'new-1', 'new-2'],
);
// Trim notes stay with the list they describe, so a request served the
// old list during the refresh never reports the new list's notes.
assert.deepEqual(
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
[['env', 9, 8]],
);
assert.deepEqual(getSources.trimmedPacks(fresh), []);
assert.deepEqual(getSources.trimmedPacks(), []);
assert.deepEqual(unhandled, []);
} finally {
process.off('unhandledRejection', onUnhandled);
restoreEnv(saved);
}
});
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
const saved = { ...process.env };
try {
process.env.CCTV_FORCE_AUSTIN = '1';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old');
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
let fetches = 0;
t.mock.method(globalThis, 'fetch', () => {
fetches += 1;
return Promise.reject(new Error('offline'));
});
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const old = await getSources();
// Past the TTL every pack comes back empty, as in an upstream outage.
now += CCTV_SOURCE_CACHE_MS + 1;
process.env.CCTV_SOURCES_JSON = '[]';
assert.equal(await getSources(), old);
for (let i = 0; i < 50; i += 1) {
if (warnings.some((w) => w.includes('source refresh returned empty')))
break;
await settle();
}
assert.ok(
warnings.some((w) => w.includes('serving 3 stale cameras')),
'the empty refresh kept the old catalog',
);
// Until the next TTL, requests are served without touching any upstream.
const fetchesAfterRefresh = fetches;
assert.equal(await getSources(), old);
assert.equal(await getSources(), old);
await settle();
assert.equal(
fetches,
fetchesAfterRefresh,
'an empty refresh reran at once',
);
} finally {
restoreEnv(saved);
}
});
+202
View File
@@ -1,11 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
TARKTEE_IMAGES_URL,
TARKTEE_LOCATIONS_URL,
TARKTEE_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
parseTarkteeDatexImages,
parseTarkteeDatexLocations,
} from '../../server/providers/cctv/sources.js';
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
@@ -84,3 +96,193 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
const images = parseTarkteeDatexImages(xml);
assert.equal(images.size, 0);
});
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
quiet(t);
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
<predefinedLocation id="loc-1" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
<predefinedLocation id="loc-2" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
</predefinedLocationContainer>
</payloadPublication>
</d2LogicalModel>`;
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
</payloadPublication>
</d2LogicalModel>`;
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init?.redirect]);
const body =
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(seen, [
[TARKTEE_LOCATIONS_URL, 'manual'],
[TARKTEE_IMAGES_URL, 'manual'],
]);
assert.equal(cameras.length, 1);
assert.equal(cameras[0].id, 'ee-tarktee-42');
assert.equal(cameras[0].name, 'Tallinn Ringtee');
});
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
quiet(t);
const cancelled = { locations: false, images: false };
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
if (isLocations) cancelled.locations = true;
else cancelled.images = true;
},
});
return new Response(body, {
status: isLocations ? 302 : 200,
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
});
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
});
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
for (const delayedSibling of [false, true]) {
await t.test(
`${failedUrl}, delayed sibling: ${delayedSibling}`,
async (t) => {
quiet(t);
let cancelled = false;
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
cancelled = true;
},
}),
);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === failedUrl) throw new TypeError('fetch failed');
if (delayedSibling) await new Promise(setImmediate);
return response;
});
try {
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
cancelled,
true,
'fulfilled sibling body is cancelled',
);
} finally {
await response.body.cancel();
}
},
);
}
}
});
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
quiet(t);
let siblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
return new Response('', { status: 503 });
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
siblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
siblingCancelled,
true,
'sibling 200 stream is cancelled on 503',
);
let overflowSiblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
const padded =
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
return new Response(padded, {
status: 200,
headers: { 'Content-Length': String(padded.length) },
});
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
overflowSiblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
overflowSiblingCancelled,
true,
'sibling stream is cancelled when the other feed exceeds the byte cap',
);
});
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
const timeouts = [];
const timeout = AbortSignal.timeout;
t.mock.method(AbortSignal, 'timeout', (ms) => {
timeouts.push(ms);
return timeout.call(AbortSignal, ms);
});
t.mock.method(console, 'log', () => {});
t.mock.method(
globalThis,
'fetch',
async (url) =>
new Response(
String(url).includes('roadCameraLocations')
? SAMPLE_LOCATIONS_XML
: SAMPLE_IMAGES_XML,
),
);
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(timeouts, [30_000, 30_000]);
assert.equal(cameras.length, 1);
});
+376
View File
@@ -0,0 +1,376 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
floridaCameraId,
floridaCameraToSource,
loadFloridaSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
FLORIDA_CCTV_URL,
FLORIDA_MAX_CATALOG_BYTES,
FLORIDA_MAX_CATALOG_PAGES,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
isLikelyFloridaCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
/** A response with a live body, plus a flag that flips when it is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One FL511 feature, shaped like the live FeatureServer payload. */
const feature = (overrides = {}) => ({
attributes: {
OBJECTID_1: 6,
ID: '1220',
DESCRIPT: 'I-75 @ MM 346.5 SB',
COUNTY: 'Marion',
HIGHWAY: 'I-75',
DIRECTION: 'S',
LATITUDE: 29.107363,
LONGITUDE: -82.188334,
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
...overrides,
},
});
/** One catalog page of `count` distinct cameras, channels from `start`. */
const page = (start, count, extra = {}) => ({
features: Array.from({ length: count }, (_, i) =>
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
),
...extra,
});
const offsetOf = (url) =>
Number(new URL(String(url)).searchParams.get('resultOffset'));
/** Lift the pack cap for one test so every loaded camera comes back. */
const uncapped = (t) => {
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
t.after(() => {
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
});
};
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
const source = floridaCameraToSource(feature());
assert.equal(source.id, 'fl-9431');
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
assert.equal(source.city, 'Marion County');
assert.equal(source.cityId, 'florida');
assert.equal(source.provider, 'FL511');
assert.equal(source.lat, 29.107363);
assert.equal(source.lon, -82.188334);
assert.equal(source.headingDeg, 180);
assert.equal(
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
90,
);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.feedType, 'image');
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
assert.equal(source.url, source.snapshotUrl);
assert.equal(source.sourceKind, 'fl511-open-data');
assert.match(source.license, /personal use only/);
assert.match(source.license, /written consent/);
});
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
const source = floridaCameraToSource(
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
});
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
const dropped = [
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
feature({ LATITUDE: 0, LONGITUDE: 0 }),
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
feature({ LATITUDE: '', LONGITUDE: '' }),
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
feature({
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
}),
feature({ IMAGE: 'not a url' }),
{ attributes: null },
null,
];
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
});
test('camera ids come from the image channel, not the repeating ID column', () => {
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
const marion = floridaCameraToSource(feature());
const broward = floridaCameraToSource(
feature({
DESCRIPT: 'I-95 SB before E Cypress Rd',
COUNTY: 'Broward',
LATITUDE: 26.22006,
LONGITUDE: -80.13676,
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
}),
);
assert.equal(marion.id, 'fl-9431');
assert.equal(broward.id, 'fl-6351');
// Any resolution suffix keys the same way.
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
// A filename without a channel falls back to a stable path slug.
assert.equal(
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
'fl-us-1-20at-20sr-5',
);
assert.equal(floridaCameraId('not a url'), null);
});
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
const inside = [
[24.5551, -81.78], // Key West
[30.4213, -87.2169], // Pensacola
[30.99, -84.0], // Georgia line
[25.7617, -80.1918], // Miami
];
const outside = [
[33.749, -84.388], // Atlanta
[25.0443, -77.3504], // Nassau
[0, 0],
[-81.78, 24.5551], // swapped
[NaN, -81],
];
for (const [lat, lon] of inside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
for (const [lat, lon] of outside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
});
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
calls.push([offsetOf(url), init.redirect]);
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
// Last page (no exceededTransferLimit): two new cameras and a repeat.
return Response.json(page(1999, 3));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(calls, [
[0, 'manual'],
[2000, 'manual'],
]);
assert.equal(cameras.length, 2002);
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
});
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const offsets = [];
t.mock.method(globalThis, 'fetch', async (url) => {
const params = new URL(String(url)).searchParams;
// Offset paging is only stable over a fixed order on the object id.
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
const offset = offsetOf(url);
offsets.push(offset);
// A layer whose max record count was lowered to 1,000.
return Response.json(
page(offset, offset < 2000 ? 1000 : 500, {
exceededTransferLimit: offset < 2000,
}),
);
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(offsets, [0, 1000, 2000]);
assert.equal(cameras.length, 2500);
});
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
uncapped(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
assert.equal(cameras.length, 2000);
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
});
test('a redirect on any page drops the whole pack and releases it', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras' },
});
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: redirected.response,
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
});
test('a failed later page keeps the pages already read', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
uncapped(t);
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: failed.response,
);
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
// A network error on a later page keeps them too.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
throw new Error('network down');
});
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
});
test('an oversized catalog page is refused', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(JSON.stringify(page(0, 1)), {
headers: {
'Content-Type': 'application/json',
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
},
}),
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
// So is a body that only turns out too long while streaming.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
});
/**
* Serve a two-camera FL511 page to the Florida endpoint and an empty payload
* to every other pack, so one catalog refresh exercises the registration
* without reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
return Response.json([]);
});
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Put process.env back exactly as it was. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (key in saved) continue;
delete process.env[key];
}
Object.assign(process.env, saved);
};
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_FLORIDA_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
'the catalog refresh invokes the Florida loader',
);
assert.deepEqual(
sources
.filter((s) => s.cityId === 'florida')
.map((s) => s.id)
.sort(),
['fl-0', 'fl-1'],
'Florida cameras reach the served catalog through the registered lane',
);
} finally {
restoreEnv(saved);
}
});
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_FLORIDA_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'florida'),
[],
);
} finally {
restoreEnv(saved);
}
});
+2 -2
View File
@@ -17,7 +17,7 @@
*/
const EARTH_RADIUS_M = 6371000;
export const PLANE_VERT_ASPECT = 16 / 9;
const PLANE_VERT_ASPECT = 16 / 9;
export const SUPPORT_KEYS = Object.freeze([
'bl',
'bm',
@@ -128,7 +128,7 @@ export function planeSupportPoints(pose) {
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
* @returns {number}
*/
export function supportAltitude(capAltM, row, dims) {
function supportAltitude(capAltM, row, dims) {
return capAltM + row * dims.upVert;
}
+1 -1
View File
@@ -316,7 +316,7 @@ export function createCalibrationGizmo({
}
const mount = positions.mount;
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(
const { forwardHoriz } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
+464
View File
@@ -0,0 +1,464 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
hkCameraName,
hkCameraToSource,
hkImageUrlFromKey,
loadHkSourcesFromOpenData,
normalizeHkImageUrl,
parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
HK_IMAGE_ORIGIN,
HK_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
prioritizeSources,
} from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<image-list>'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Transport Department `<image>` block as a parsed row. */
const row = (overrides = {}) => ({
key: 'BC101F',
region: 'New Territories',
district: 'North',
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
latitude: '22.508',
longitude: '114.078',
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
...overrides,
});
/** Fixture XML matching the published English locations schema. */
const fixtureXml = (...records) => {
const images = records
.map(
(record) => `
<image>
<key>${record.key}</key>
<region>${record.region}</region>
<district>${record.district}</district>
<description>${record.description}</description>
<latitude>${record.latitude}</latitude>
<longitude>${record.longitude}</longitude>
<url>${record.url}</url>
</image>`,
)
.join('');
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
};
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
const source = hkCameraToSource(row());
assert.equal(source.id, 'hk-bc101f');
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
assert.equal(source.cityId, 'hk');
assert.equal(source.provider, 'Transport Department');
assert.equal(source.lat, 22.508);
assert.equal(source.lon, 114.078);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'hk-td-open-data');
assert.equal(
source.license,
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
);
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
});
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
assert.equal(
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
null,
);
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
assert.equal(hkCameraToSource(row({ key: '' })), null);
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
assert.equal(
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
null,
);
assert.equal(
hkCameraToSource(
row({
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
}),
),
null,
);
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
assert.equal(hkCameraToSource(null), null);
});
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
assert.equal(
hkImageUrlFromKey('bc101f'),
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
);
assert.equal(hkImageUrlFromKey(''), null);
assert.equal(hkImageUrlFromKey('bad/key'), null);
assert.equal(
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
'https://tdcctv.data.one.gov.hk/H429F.JPG',
);
assert.equal(
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
'https://tdcctv.data.one.gov.hk/H429F.JPG',
);
assert.equal(normalizeHkImageUrl(''), null);
assert.equal(normalizeHkImageUrl('not a url'), null);
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
hkCameraName(
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
'hk-h429f',
),
'Aberdeen Praya Road near Fish Market [H429F]',
);
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
});
test('the XML parser reads each image block', () => {
const rows = parseHkCameraLocationsXml(
fixtureXml(
row(),
row({
key: 'H429F',
region: 'Hong Kong Island',
district: 'Southern',
description: 'Aberdeen Praya Road near Fish Market [H429F]',
latitude: '22.24845',
longitude: '114.1505',
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
}),
),
);
assert.equal(rows.length, 2);
assert.equal(rows[0].key, 'BC101F');
assert.equal(rows[1].key, 'H429F');
assert.equal(rows[1].latitude, '22.24845');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return new Response(
fixtureXml(
row(),
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
row({
key: 'H210F',
region: 'Hong Kong Island',
district: 'Wan Chai',
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
latitude: '22.2747',
longitude: '114.17936',
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
}),
row({ url: 'https://evil.example/x.jpg' }),
),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
const cameras = await loadHkSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest Central/TST first: Wan Chai before the northern New Territories.
['hk-h210f', 'hk-bc101f'],
);
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
});
test('the unselected label is the description, trimmed to the code width', () => {
assert.equal(
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
'SAN TIN HIGHWAY [BC101F]',
);
const long = hkCameraToSource(
row({
description:
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.xml' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = fixtureXml(row());
return new Response(body, {
headers: {
'Content-Type': 'application/xml',
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[
lane('austin', 250),
lane('calgary', 215),
lane('hk', DEFAULT_HK_MAX_SOURCES),
],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['calgary', 10],
['hk', 10],
],
);
assert.deepEqual(
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
);
});
/**
* Serve the HK catalog to the HK endpoint and an empty payload to every other
* pack, so one catalog refresh exercises the registration without reaching the
* network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.includes('Traffic_Camera_Locations_En.xml')) {
return new Response(
fixtureXml(
row(),
row({
key: 'H210F',
region: 'Hong Kong Island',
district: 'Wan Chai',
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
latitude: '22.2747',
longitude: '114.17936',
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
}),
),
{ headers: { 'Content-Type': 'application/xml' } },
);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the HK lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_HK_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_HK_CAMERAS_URL),
'the catalog refresh invokes the HK loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
['hk-h210f', 'hk-bc101f'],
'HK cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HK_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_HK_CAMERAS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'hk'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
assert.ok(
DEFAULT_HK_MAX_SOURCES >= 1013,
'default cap should keep nearly all published TD cameras',
);
assert.ok(
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
'a single pack must not outgrow the catalog-wide ceiling alone',
);
const places = HK_ANCHORS.map(
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
);
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
});
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
const cameras = [
hkCameraToSource(
row({
key: 'H001F',
region: 'Hong Kong Island',
district: 'Central & Western',
description: 'Central harbour [H001F]',
latitude: '22.2819',
longitude: '114.158',
}),
),
hkCameraToSource(
row({
key: 'ST101F',
region: 'New Territories',
district: 'Sha Tin',
description: 'Sha Tin Road [ST101F]',
latitude: '22.3827',
longitude: '114.19',
}),
),
hkCameraToSource(
row({
key: 'K999F',
region: 'Kowloon',
district: 'Yau Tsim Mong',
description: 'Far Kowloon filler [K999F]',
latitude: '22.32',
longitude: '114.18',
}),
),
].filter(Boolean);
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
'hk-h001f',
'hk-st101f',
]);
});
+3 -3
View File
@@ -96,7 +96,7 @@ export function incumbentRankKm(
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
/** Robust pool-distance percentile used to scale the screen-space term. */
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
/** True only when both viewport dimensions are finite positive pixels. */
export function hasFiniteCctvViewport(viewW, viewH) {
@@ -174,8 +174,8 @@ export function blendCenterRankKm(
// viewport is bucketed into this grid and every occupied cell gets its best
// candidate before global rank fills the rest. Owner-tunable together with
// the budgets above.
export const CCTV_CARD_GRID_COLS = 5;
export const CCTV_CARD_GRID_ROWS = 4;
const CCTV_CARD_GRID_COLS = 5;
const CCTV_CARD_GRID_ROWS = 4;
/**
* Screen-space distribution pass (owner round 2, item C). Buckets the
+263 -3
View File
@@ -33,6 +33,14 @@ const VIDEO = {
feedType: 'video',
url: 'https://cams.upstream.invalid/live.mp4',
};
const WEBM = {
id: 'webm-fixture',
name: 'WebM fixture',
lat: 30.27,
lon: -97.74,
feedType: 'webm',
url: 'https://cams.upstream.invalid/live.webm',
};
const MJPEG = {
id: 'mjpeg-fixture',
name: 'MJPEG fixture',
@@ -69,7 +77,10 @@ function recordingResponse() {
body: '',
writeHead(status, headers) {
res.statusCode = status;
res.headers = headers || {};
res.headers = { ...res.headers, ...headers };
},
setHeader(name, value) {
res.headers[name] = value;
},
write(chunk) {
chunks.push(Buffer.from(chunk));
@@ -105,6 +116,7 @@ function mount(t, respond) {
STILL,
STILL_ALIAS,
VIDEO,
WEBM,
MJPEG,
]);
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
@@ -143,9 +155,9 @@ function mount(t, respond) {
});
assert.ok(handler, 'the CCTV provider must mount a middleware');
const call = async (path) => {
const call = async (path, headers = {}) => {
const { res, done } = recordingResponse();
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
await handler({ url: path, headers, method: 'GET', on() {} }, res);
await done;
return res;
};
@@ -268,3 +280,251 @@ test('/frame does not follow a still camera off its origin', async (t) => {
[STILL.url],
);
});
/** An upstream Response whose body records whether it was cancelled. */
function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) {
const state = { cancelled: false };
const response = new Response(
new ReadableStream(
{
pull(controller) {
controller.enqueue(new TextEncoder().encode(body));
controller.close();
},
cancel() {
state.cancelled = true;
},
},
{ highWaterMark: 0 },
),
{ status, headers },
);
return { response, state };
}
test('/media refuses an upstream type that does not match the feed kind', async (t) => {
let current;
const app = mount(t, () => current.response);
for (const [camera, contentType, body] of [
[VIDEO, 'text/html', '<!doctype html><p>x</p>'],
[VIDEO, 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
[VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'],
[VIDEO, 'video/webm', 'video'],
[VIDEO, undefined, 'video'],
[WEBM, 'video/mp4', 'video'],
[MJPEG, 'multipart/x-mixed-replace', '--frame'],
[MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'],
[MJPEG, 'image/jpeg', 'jpeg'],
[MJPEG, 'text/html; boundary=frame', '<!doctype html><p>x</p>'],
]) {
current = trackedUpstream({
headers: contentType === undefined ? {} : { 'content-type': contentType },
body,
});
const label = `${camera.id}: ${contentType}`;
const res = await app.call(`/media/${camera.id}`);
assert.equal(res.statusCode, 502, label);
assert.equal(res.headers['Content-Type'], 'application/json', label);
assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' });
assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`);
const health = await app.health(camera.id);
assert.equal(health.status, 'degraded', label);
assert.equal(health.message, 'Unexpected media type', label);
}
});
test('/media relays each feed kind under its canonical type, including a partial response', async (t) => {
let current;
const app = mount(t, () => current);
for (const [camera, upstream, expected] of [
[VIDEO, { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, 'video/mp4'],
[
VIDEO,
{
'content-type': 'video/mp4',
'content-range': 'bytes 0-4/100',
'content-length': '5',
'accept-ranges': 'bytes',
},
'video/mp4',
],
[WEBM, { 'content-type': 'video/webm' }, 'video/webm'],
[
MJPEG,
{ 'content-type': 'multipart/x-mixed-replace;boundary="frame"' },
'multipart/x-mixed-replace; boundary=frame',
],
[
MJPEG,
{ 'content-type': 'multipart/x-mixed-replace; boundary="a b"' },
'multipart/x-mixed-replace; boundary="a b"',
],
]) {
const status = upstream['content-range'] ? 206 : 200;
current = upstreamResponse({ status, headers: upstream, body: 'bytes' });
const res = await app.call(`/media/${camera.id}`);
assert.equal(res.statusCode, status, camera.id);
assert.equal(res.headers['Content-Type'], expected);
assert.equal(res.body, 'bytes');
if (status === 206)
assert.equal(res.headers['Content-Range'], 'bytes 0-4/100');
assert.equal((await app.health(camera.id)).status, 'ok');
}
});
test('/media does not follow a live camera off its origin', async (t) => {
const app = mount(t, ({ url }) =>
url === VIDEO.url
? upstreamResponse({
status: 302,
headers: { location: 'https://elsewhere.invalid/live.mp4' },
body: '',
})
: upstreamResponse({
headers: { 'content-type': 'video/mp4' },
body: 'elsewhere',
}),
);
const res = await app.call(`/media/${VIDEO.id}`);
assert.equal(res.statusCode, 502);
assert.doesNotMatch(res.body, /elsewhere/);
assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]);
});
test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => {
const app = mount(t, ({ url }) =>
upstreamResponse({
headers: {
'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg',
},
body: 'bytes',
}),
);
const host = 'localhost:5173';
const paths = [
'/sources',
'/health',
`/stream/${STILL.id}`,
`/frame/${STILL.id}`,
`/media/${VIDEO.id}`,
];
for (const headers of [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://elsewhere.example' },
{ host, origin: 'null' },
])
for (const path of paths) {
const res = await app.call(path, headers);
assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`);
}
assert.deepEqual(app.requests, [], 'a refused request reached an upstream');
for (const headers of [
{ host, 'sec-fetch-site': 'same-origin' },
{ host, origin: `http://${host}` },
{},
]) {
const frame = await app.call(`/frame/${STILL.id}`, headers);
assert.equal(frame.statusCode, 200);
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
const media = await app.call(`/media/${VIDEO.id}`, headers);
assert.equal(media.statusCode, 200);
assert.equal(media.headers['Content-Type'], 'video/mp4');
}
});
test('/frame falls back past a non-raster upstream and Street View frame', async (t) => {
let streetViewType;
const app = mount(t, ({ url }) =>
url.startsWith('https://maps.googleapis.com/')
? trackedUpstream({
headers:
streetViewType === undefined
? {}
: { 'content-type': streetViewType },
body: 'street-view',
}).response
: upstreamResponse({
headers: { 'content-type': 'image/svg+xml' },
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
}),
);
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
for (const type of [
'image/svg+xml',
'text/html',
'application/xml',
undefined,
]) {
streetViewType = type;
const res = await app.call(`/frame/${STILL.id}`);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type));
assert.doesNotMatch(res.body, /street-view/);
}
streetViewType = 'image/JPEG; charset=x';
const res = await app.call(`/frame/${STILL.id}`);
assert.equal(res.headers['X-CCTV-Source'], 'streetview');
assert.equal(res.headers['Content-Type'], 'image/jpeg');
assert.equal(res.body, 'street-view');
});
test('a forwarded same-origin request reaches every CCTV route but not the Street View fallback', async (t) => {
const app = mount(t, ({ url }) => {
if (url.startsWith('https://maps.googleapis.com/'))
return trackedUpstream({
headers: { 'content-type': 'image/jpeg' },
body: 'street-view',
}).response;
return url === VIDEO.url
? upstreamResponse({ headers: { 'content-type': 'video/mp4' } })
: upstreamResponse({ status: 503, body: '' });
});
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
const host = 'localhost:5173';
const streetViewCalls = () =>
app.requests.filter(({ url }) =>
url.startsWith('https://maps.googleapis.com/'),
).length;
for (const forwarded of [
{ 'x-forwarded-for': '203.0.113.9' },
{ via: '1.1 proxy' },
{ forwarded: 'for=203.0.113.9' },
{ 'x-real-ip': '203.0.113.9' },
{ 'x-forwarded-for': '' },
{ 'x-forwarded-for': ' ' },
{ 'cf-connecting-ip': '' },
]) {
const headers = { host, 'sec-fetch-site': 'same-origin', ...forwarded };
const label = JSON.stringify(forwarded);
assert.equal((await app.call('/sources', headers)).statusCode, 200, label);
const media = await app.call(`/media/${VIDEO.id}`, headers);
assert.equal(media.statusCode, 200, label);
assert.equal(media.headers['Content-Type'], 'video/mp4', label);
const frame = await app.call(`/frame/${STILL.id}`, headers);
assert.equal(frame.statusCode, 200, label);
assert.equal(frame.headers['X-CCTV-Source'], 'synthetic', label);
assert.equal(frame.headers['Content-Type'], 'image/svg+xml', label);
// Cross-site stays refused whatever the forwarding headers say.
for (const path of ['/sources', `/frame/${STILL.id}`, `/media/${VIDEO.id}`])
assert.equal(
(
await app.call(path, {
...forwarded,
host,
'sec-fetch-site': 'cross-site',
})
).statusCode,
403,
`${label} ${path}`,
);
}
assert.equal(streetViewCalls(), 0, 'a forwarded request spent the key');
const direct = await app.call(`/frame/${STILL.id}`, {
host,
'sec-fetch-site': 'same-origin',
});
assert.equal(direct.headers['X-CCTV-Source'], 'streetview');
assert.equal(direct.body, 'street-view');
assert.equal(streetViewCalls(), 1);
});
+54
View File
@@ -259,3 +259,57 @@ test('rejected snapshot responses abort the upstream download', async () => {
assert.equal(signal.aborted, true);
}
});
test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => {
const frame = (contentType, body = 'frame') => {
const state = { cancelled: false };
const headers = contentType === null ? {} : { 'Content-Type': contentType };
const response = new Response(new ReadableStream({
pull(controller) {
controller.enqueue(new TextEncoder().encode(body));
controller.close();
},
cancel() {
state.cancelled = true;
},
}, { highWaterMark: 0 }), { status: 200, headers });
return { response, state };
};
for (const [contentType, canonical] of [
['image/jpeg', 'image/jpeg'],
['image/JPEG; charset=x', 'image/jpeg'],
[' Image/PNG ', 'image/png'],
['image/gif', 'image/gif'],
['image/webp;q=1', 'image/webp'],
['image/avif', 'image/avif'],
]) {
const { response } = frame(contentType);
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
fetchImpl: async () => response,
});
assert.equal(result?.contentType, canonical, contentType);
assert.equal(result.body.toString(), 'frame');
}
for (const [contentType, body] of [
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['Image/SVG+XML; charset=utf-8', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['text/html', '<!doctype html><p>x</p>'],
['text/html; charset=utf-8', '<!doctype html><p>x</p>'],
['application/xml', '<x/>'],
['text/xml', '<x/>'],
[null, 'frame'],
['', 'frame'],
['image', 'frame'],
['image/', 'frame'],
['image/jpeg/x', 'frame'],
['image/jpeg, text/html', 'frame'],
['application/octet-stream', 'frame'],
]) {
const { response, state } = frame(contentType, body);
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
fetchImpl: async () => response,
});
assert.equal(result, null, String(contentType));
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
}
});
+114
View File
@@ -0,0 +1,114 @@
// Camera catalogs are pinned to their agency's image directory. The pin is a
// parsed-URL check, not a string prefix, so a path that only starts with the
// directory before dot segments, an encoded separator, credentials or another
// port are resolved never counts as inside it, and a redirect on a pinned host
// cannot leave the directory either.
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadTflSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { pinnedCameraUrl } from '../../server/providers/cctv/normalize.js';
const BUCKET = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
/** One available JamCam whose frame is at `imageUrl`. */
const jamCam = (n, imageUrl) => ({
id: `JamCams_00001.0000${n}`,
commonName: `JamCam ${n}`,
lat: 51.5072,
lon: -0.1276,
additionalProperties: [
{ key: 'available', value: 'true' },
{ key: 'imageUrl', value: imageUrl },
],
});
test('TfL JamCams keep only frames inside the official bucket, by parsed URL', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const accepted = [
[`${BUCKET}00001.00001.jpg`, `${BUCKET}00001.00001.jpg`],
[`${BUCKET}00001.00002.jpg?t=1`, `${BUCKET}00001.00002.jpg?t=1`],
[
'https://s3-eu-west-1.amazonaws.com:443/jamcams.tfl.gov.uk/00001.00003.jpg',
`${BUCKET}00001.00003.jpg`,
],
];
const rejected = [
`${BUCKET}../other/x.jpg`,
`${BUCKET}%2e%2e/other/x.jpg`,
`${BUCKET}%2E%2E/other/x.jpg`,
`${BUCKET}.%2e/other/x.jpg`,
`${BUCKET}..%2fother/x.jpg`,
`${BUCKET}..%5Cother/x.jpg`,
`${BUCKET}..\\other\\x.jpg`,
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk.other/x.jpg',
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk-other/x.jpg',
'https://s3-eu-west-1.amazonaws.com/other/jamcams.tfl.gov.uk/x.jpg',
'https://user:[email protected]/jamcams.tfl.gov.uk/x.jpg',
'https://s3-eu-west-1.amazonaws.com:8443/jamcams.tfl.gov.uk/x.jpg',
'http://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/x.jpg',
'https://s3-eu-west-1.amazonaws.com.other.example/jamcams.tfl.gov.uk/x.jpg',
'not a url',
];
t.mock.method(globalThis, 'fetch', async () =>
Response.json(
[...accepted.map(([url]) => url), ...rejected].map((url, i) =>
jamCam(i + 1, url),
),
),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.url),
accepted.map(([, normalized]) => normalized),
);
});
test('a redirect on a pinned camera host stays inside the pinned directory', async () => {
const redirectTo = (location) => async (url) =>
url === `${BUCKET}00001.00001.jpg`
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/jamcams.tfl.gov.uk.other/x.jpg',
'/other/x.jpg',
'/jamcams.tfl.gov.uk/../other/x.jpg',
'/jamcams.tfl.gov.uk/%2e%2e/other/x.jpg',
'/jamcams.tfl.gov.uk/..%2fother/x.jpg',
])
assert.equal(
await fetchWithinHost(
`${BUCKET}00001.00001.jpg`,
{},
redirectTo(location),
),
null,
location,
);
const followed = await fetchWithinHost(
`${BUCKET}00001.00001.jpg`,
{},
redirectTo('/jamcams.tfl.gov.uk/00001.00002.jpg'),
);
assert.equal(await followed.text(), 'frame');
// A host with no pinned directory keeps the same-origin rule alone.
const unpinned = await fetchWithinHost(
'https://cams.example/a/frame.jpg',
{},
async (url) =>
url === 'https://cams.example/a/frame.jpg'
? new Response(null, { status: 302, headers: { location: '/b/x.jpg' } })
: new Response('frame'),
);
assert.equal(await unpinned.text(), 'frame');
});
test('a pin prefix without a trailing slash still names a directory', () => {
assert.equal(
pinnedCameraUrl('https://h.example/camsX/a.jpg', 'https://h.example/cams'),
null,
);
});
+342
View File
@@ -0,0 +1,342 @@
import { afterEach, beforeEach, test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadTaiwanSources,
parseTaiwanCctvXml,
resetTaiwanSourcesCache,
taiwanCameraToSource,
taiwanFrame,
TAIWAN_FREEWAY_CCTV_URL,
TAIWAN_MAX_CATALOG_BYTES,
TAIWAN_RETRY_AFTER_FAILURE_MS,
} from '../../server/providers/cctv/taiwan.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
// Settings that would steer the loader or the catalog away from the mocks.
const ISOLATED_ENV = [
'CCTV_SOURCES_FILE',
'CCTV_SOURCES_JSON',
'CCTV_PREFER_AUSTIN',
'CCTV_FORCE_AUSTIN',
'CCTV_TAIWAN_ENABLED',
'CCTV_TAIWAN_URL',
'CCTV_TAIWAN_MAX_SOURCES',
];
let savedEnv = {};
beforeEach(() => {
resetTaiwanSourcesCache();
savedEnv = Object.fromEntries(
ISOLATED_ENV.map((key) => [key, process.env[key]]),
);
for (const key of ISOLATED_ENV) delete process.env[key];
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function cctvXml(...blocks) {
return `<?xml version='1.0' encoding='utf-8'?>
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
<AuthorityCode>NFB</AuthorityCode>
<CCTVs>${blocks.join('')}</CCTVs>
</CCTVList>`;
}
function block({
id = 'CCTV-N1-S-0.000-M',
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
lat = '25.1229931',
lon = '121.735695',
} = {}) {
return `<CCTV>
<CCTVID>${id}</CCTVID>
<VideoStreamURL>${stream}</VideoStreamURL>
<PositionLon>${lon}</PositionLon>
<PositionLat>${lat}</PositionLat>
<RoadName>國道1號</RoadName>
<RoadDirection>S</RoadDirection>
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
<LocationMile>0K+000</LocationMile>
</CCTV>`;
}
test('parses the MOTC 2.0 table and registers the same camera still', () => {
const [row] = parseTaiwanCctvXml(cctvXml(block()));
const camera = taiwanCameraToSource(row);
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
assert.equal(camera.lat, 25.1229931);
assert.equal(camera.lon, 121.735695);
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
assert.equal(camera.cityId, 'taiwan');
assert.equal(camera.feedType, 'image');
assert.equal(camera.headingConfidence, 'low');
assert.equal(
camera.url,
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
);
assert.equal(camera.snapshotUrl, camera.url);
assert.match(camera.license, /Open Government Data License, Taiwan/);
});
test('each camera server maps its stream path to its own still path only', () => {
assert.equal(
taiwanFrame(
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
).frameUrl,
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
);
assert.equal(
taiwanFrame(
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
).frameUrl,
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
);
assert.equal(
taiwanFrame(
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
).frameUrl,
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
);
});
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
for (const stream of [
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
'http://127.0.0.1/private',
'',
]) {
assert.equal(taiwanFrame(stream), null, stream);
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
assert.equal(taiwanCameraToSource(row), null, stream);
}
});
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
for (const [lat, lon] of [
['121.735695', '25.1229931'],
['35.68', '139.76'],
['', '121.7'],
['NaN', '121.7'],
]) {
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
}
assert.equal(taiwanCameraToSource(null), null);
});
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
const [row] = parseTaiwanCctvXml(
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&amp;(北)' })),
);
const camera = taiwanCameraToSource(row);
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
});
function mockCatalog(t, xml) {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
requested.push(String(url));
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
assert.equal(options.redirect, 'manual');
return new Response(xml, {
headers: { 'content-type': 'application/xml' },
});
}
return Response.json([]);
});
return requested;
}
test('loader deduplicates cameras that share a camera number', async (t) => {
mockCatalog(
t,
cctvXml(
block(),
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
block({
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
lat: '24.1',
lon: '120.6',
}),
),
);
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
'tw-freeway-cctvc-495',
'tw-freeway-cctvn-10000',
]);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) {
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(cctvXml(block()), {
status,
headers: status === 302 ? { location: 'https://evil.test/' } : {},
}),
);
resetTaiwanSourcesCache();
assert.deepEqual(await loadTaiwanSources(), []);
}
});
async function runCatalogWithTaiwanFlag(t, flag) {
const requested = mockCatalog(t, cctvXml(block()));
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
}
test('the Taiwan pack is in the catalog by default', async (t) => {
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
assert.equal(
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
'taiwan',
);
});
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
});
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(block()) + padding),
);
assert.deepEqual(await loadTaiwanSources(), []);
resetTaiwanSourcesCache();
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('fetch failed');
});
assert.deepEqual(await loadTaiwanSources(), []);
});
test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(console, 'warn', () => {});
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
throw new TypeError('fetch failed');
});
t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 1, 'the second refresh made no request');
Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
);
await loadTaiwanSources();
assert.equal(calls, 2, 'the backoff ends after an hour');
});
test('a failed download serves the last good table', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(block())),
);
const first = await loadTaiwanSources();
assert.deepEqual(
first.map((c) => c.id),
['tw-freeway-cctvn-10000'],
);
fetchMock.mock.mockImplementation(
async () => new Response('', { status: 503 }),
);
assert.deepEqual(
(await loadTaiwanSources()).map((c) => c.id),
['tw-freeway-cctvn-10000'],
);
});
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
t.mock.method(console, 'log', () => {});
const rows = Array.from({ length: 3 }, (_, i) =>
block({
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
}),
);
t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(...rows)),
);
for (const [value, expected] of [
['0', 1],
['2', 2],
['5000', 3],
['abc', 3],
]) {
resetTaiwanSourcesCache();
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
const sources = await loadTaiwanSources();
assert.equal(sources.length, expected, value);
assert.equal(sources.available, 3, value);
}
});
test('names fall back to the published ID when the row has no road details', () => {
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
.replace('<LocationMile>0K+000</LocationMile>', '')
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
assert.equal(camera.name, 'CCTV-N3-N-100-M');
});
test('numeric character references in the table are decoded', () => {
const xml = cctvXml(block()).replace(
'<RoadName>國道1號</RoadName>',
'<RoadName>&#22283;&#x9053;1號</RoadName>',
);
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
});
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
const camera = taiwanCameraToSource(
parseTaiwanCctvXml(
cctvXml(
block({
stream:
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
}),
),
)[0],
);
assert.equal(
camera.id,
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
);
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
});
+621 -1
View File
@@ -2,11 +2,24 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
applyVegvesenGroundHeights,
clearKartverketHeightCache,
fetchKartverketHeights,
kartverketBackoffMs,
loadVegvesenSourcesFromOpenData,
vegvesenCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_VEGVESEN_CCTV_URL,
KARTVERKET_HEIGHT_URL,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_CONCURRENCY,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_POINTS,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_USER_AGENT,
CCTV_SOURCE_CACHE_MS,
VEGVESEN_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
});
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
clearKartverketHeightCache();
t.after(clearKartverketHeightCache);
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
});
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
// The catalog first; the rest is the Kartverket height lookup.
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
// Two unique positions: one Kartverket request.
assert.equal(requested.length, 2);
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
// Nearest-to-anchor first: Oslo leads.
assert.deepEqual(
cameras.map((c) => c.id),
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
);
});
});
/** Answer a Kartverket request the way the live API does: echo every point,
* with the height `heightFor(x, y)` returns (null = outside coverage). */
const kartverketResponse = (url, heightFor) => {
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
});
};
/** Cameras at distinct Norwegian positions. */
const cameraAt = (i) => ({
id: `no-vegvesen-${i}`,
lat: 60 + i * 0.001,
lon: 7 + i * 0.001,
groundElevationM: 150,
});
const withHeightsEnv = (t) => {
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
delete process.env.CCTV_VEGVESEN_HEIGHTS;
clearKartverketHeightCache();
t.after(() => {
clearKartverketHeightCache();
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
};
const sentPoints = (url) =>
JSON.parse(new URL(url).searchParams.get('punkter'));
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push(String(url));
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
// Redirects are refused, so a request can never leave the fixed host.
assert.equal(init.redirect, 'error');
// Reply in reverse order: matching must not depend on list position.
const response = kartverketResponse(url, (x) =>
x > 7.04 ? null : Math.round((x - 7) * 10000),
);
const body = await response.json();
body.punkter.reverse();
return Response.json(body);
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
// One pass, no retries: 20 + 20 + 10.
assert.deepEqual(
requested.map((url) => sentPoints(url).length),
[KARTVERKET_MAX_POINTS, 20, 10],
);
for (const url of requested) {
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
}
assert.equal(heights.size, 50);
assert.equal(heights.get('7.010000,60.010000'), 100);
// Past x = 7.04 the API has no height: recorded as null, never as 0.
assert.equal(heights.get('7.049000,60.049000'), null);
});
test('only the points a request asked about are recorded', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
...points.map(([x, y]) => ({ x, y, z: 10 })),
{ x: 7.5, y: 60.5, z: 999 }, // never asked
],
});
});
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
});
test('either water marker counts as the surface', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const [a, b, c] = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
{
x: a[0],
y: a[1],
z: -547.1,
datakilde: 'dybdekurver',
terreng: null,
},
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
{
x: c[0],
y: c[1],
z: -0.2,
datakilde: 'dtm1',
terreng: 'ÅpentOmråde',
},
],
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
]);
assert.equal(heights.get('7.000000,60.000000'), 0);
assert.equal(heights.get('7.001000,60.001000'), 0);
// A quay just below the datum on land data stays as measured.
assert.equal(heights.get('7.002000,60.002000'), -0.2);
});
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
const zs = [-20, -20.5, 2500, 2500.5];
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
cameraAt(3),
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
]);
assert.equal(requested.length, 1);
assert.equal(sentPoints(requested[0]).length, 4);
assert.deepEqual(
[0, 1, 2, 3].map((i) =>
heights.get(
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
),
),
[-20, null, 2500, null],
);
});
test('no more than eight requests are in flight', async (t) => {
withHeightsEnv(t);
let inFlight = 0;
let peak = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return kartverketResponse(url, () => 1);
});
const { heights } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(heights.size, 400);
assert.equal(peak, KARTVERKET_CONCURRENCY);
});
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('busy', {
status: 429,
headers: { 'Retry-After': '120' },
});
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
// The first wave is already in flight when the first failure lands; nothing
// after it starts, and no point is resent.
assert.equal(calls, KARTVERKET_CONCURRENCY);
assert.equal(heights.size, 0);
assert.deepEqual(failure, {
status: 429,
retryAfter: '120',
deadline: false,
});
});
/** A fetch that never answers, and rejects only when its signal aborts. */
const hangingFetch = (url, init) => {
assert.ok(init.signal instanceof AbortSignal);
return new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason));
});
};
test('the lookup deadline bounds a hanging service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const started = Date.now();
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 30,
});
const elapsed = Date.now() - started;
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
assert.equal(heights.size, 0);
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
});
test('one hanging request times out on its own, as a failing service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 60_000,
requestTimeoutMs: 20,
});
assert.deepEqual(failure, {
status: null,
retryAfter: null,
deadline: false,
});
});
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', (url, init) => {
calls += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve(kartverketResponse(url, () => 1)),
40,
);
init.signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(init.signal.reason);
});
});
});
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
cameraAt(i),
),
{ timeoutMs: 100 },
);
assert.equal(failure?.deadline, true);
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
});
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
withHeightsEnv(t);
const answers = {
503: () => new Response('down', { status: 503 }),
'not json': () =>
new Response('<html>maintenance</html>', {
headers: { 'Content-Type': 'text/html' },
}),
oversized: () =>
Response.json({
punkter: [],
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
}),
};
for (const [label, answer] of Object.entries(answers)) {
clearKartverketHeightCache();
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return answer();
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
assert.equal(heights.size, 0, label);
assert.equal(failure?.deadline, false, label);
assert.equal(failure?.status, label === '503' ? 503 : null, label);
}
});
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
sentPoints(url)[0][0] === 7
? new Response('bad point', { status: 400 })
: kartverketResponse(url, () => 42),
);
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
assert.equal(heights.size, 40);
assert.equal(heights.get('7.000000,60.000000'), null);
assert.equal(heights.get('7.020000,60.020000'), 42);
});
test('a point a 200 answer leaves out is cached as no height', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url).slice(1); // drop the first point
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
});
});
const cameras = [cameraAt(0), cameraAt(1)];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[0].groundElevationM, 150);
// The next refresh asks nothing: the left-out point is a cached miss.
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
const now = Date.parse('2026-10-06T12:00:00Z');
assert.equal(kartverketBackoffMs('120', now), 120_000);
assert.equal(kartverketBackoffMs('5', now), 60_000);
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
assert.equal(
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
300_000,
);
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
});
test('heights are cached, and misses are not asked again until they expire', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(sentPoints(url).length);
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
});
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
assert.deepEqual(
cameras.map((c) => c.groundElevationM),
[1043.3, 1043.3, 150],
);
// The next refresh asks nothing: heights and the miss are both cached.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
now,
}),
2,
);
assert.deepEqual(asked, [3]);
// Once the miss expires, only that point is asked again.
clock += KARTVERKET_NULL_TTL_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
assert.deepEqual(asked, [3, 1]);
});
test('after a failure no lookup starts until the backoff ends', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('offline');
});
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 1);
// The default backoff outlasts the catalog refresh, so the next refresh
// and every one inside the backoff cost nothing.
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
clock += CCTV_SOURCE_CACHE_MS;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
assert.equal(fetchMock.mock.callCount(), 1);
// After it, the failed point is asked again.
clock += 2;
fetchMock.mock.mockImplementation(async (url) =>
kartverketResponse(url, () => 321),
);
const later = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
assert.equal(later[0].groundElevationM, 321);
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a 429 backs off for its Retry-After, not the default', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const retryAfterS = 50 * 60;
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () =>
new Response('busy', {
status: 429,
headers: { 'Retry-After': String(retryAfterS) },
}),
);
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
t.mock.method(globalThis, 'fetch', hangingFetch);
// The lookup runs out of time; that is not a failing service.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0)], {
now,
timeoutMs: 10,
waitMs: 1000,
}),
0,
);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 64),
);
clock += CCTV_SOURCE_CACHE_MS;
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
assert.equal(cameras[0].groundElevationM, 64);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('overlapping refreshes share one lookup', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 3);
});
await Promise.all([
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
]);
assert.equal(fetchMock.mock.callCount(), 1);
release();
});
test('a warm cache does not wait for a few new cameras', async (t) => {
withHeightsEnv(t);
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 500),
);
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 600);
});
const refresh = [...cameras, cameraAt(20)];
const started = Date.now();
assert.equal(
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
20,
);
assert.ok(Date.now() - started < 1000);
assert.equal(fetchMock.mock.callCount(), 1);
// The background lookup fills the cache for the next refresh.
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [...cameras, cameraAt(20)];
assert.equal(await applyVegvesenGroundHeights(next), 21);
assert.equal(next[20].groundElevationM, 600);
});
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 9),
);
const cameras = [
{ id: 'no-coordinates' },
null,
{ id: 'strings', lat: '60', lon: '7' },
cameraAt(0),
];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[3].groundElevationM, 9);
});
test('a lookup started before a cache clear cannot write after it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 111);
});
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
clearKartverketHeightCache();
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 222),
);
const fresh = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
assert.equal(fresh[0].groundElevationM, 222);
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const later = [cameraAt(0)];
await applyVegvesenGroundHeights(later, { waitMs: 0 });
assert.equal(later[0].groundElevationM, 222);
});
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 5),
);
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 0);
});
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 812);
});
const first = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
assert.equal(first[0].groundElevationM, 150);
release();
// The lookup kept running and filled the cache for the next refresh.
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
assert.equal(next[0].groundElevationM, 812);
});
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return kartverketResponse(url, () => 987.6);
}
return Response.json({ type: 'FeatureCollection', features: [feature()] });
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(
cameras.map((c) => [c.id, c.groundElevationM]),
[['no-vegvesen-3000063_1', 987.6]],
);
});
test('the loader budgets the height wait from its own start', async (t) => {
withHeightsEnv(t);
const realNow = Date.now.bind(Date);
let offset = 0;
t.mock.method(Date, 'now', () => realNow() + offset);
t.mock.method(globalThis, 'fetch', (url, init) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return hangingFetch(url, init);
}
// The catalog fetch uses up all but 10 ms of the lane's budget.
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
return Promise.resolve(
Response.json({ type: 'FeatureCollection', features: [feature()] }),
);
});
const started = performance.now();
const cameras = await loadVegvesenSourcesFromOpenData();
assert.ok(performance.now() - started < 1000);
assert.equal(cameras[0].groundElevationM, 150);
});
@@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import flightsLayer, {
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
-1
View File
@@ -1,7 +1,6 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
/**
* Cockpit 3D aircraft policy.
+1 -1
View File
@@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) {
for (const id of track.epochs.keys())
if (!used.has(id)) track.epochs.delete(id);
}
export function validFix(fix) {
function validFix(fix) {
return (
Number.isFinite(fix?.t) &&
fix.t >= 0 &&
+2 -1
View File
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
+71 -10
View File
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
* @type {{ key: string, html: string }[]}
*/
export const OSM_CREDIT = {
const OSM_CREDIT = {
key: 'openstreetmap',
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
};
export const OPENMAPTILES_CREDIT = {
const OPENMAPTILES_CREDIT = {
key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
@@ -239,7 +239,8 @@ export const DATA_CREDITS = [
html:
'CCTV cameras &amp; frames (Ontario): ' +
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
'· provided as is, without warranty; actual road conditions may differ',
},
{
key: 'fintraffic-cctv',
@@ -257,12 +258,28 @@ export const DATA_CREDITS = [
'Road-camera stills (Lithuania, when enabled): ' +
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
},
{
key: 'taiwan-cctv',
html:
'Freeway camera stills (Taiwan): ' +
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
'CCTV static data (MOTC 2.0), released under the ' +
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'florida-cctv',
html:
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
'Individual, personal use only under the ' +
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
"any other use needs FDOT's express written consent",
},
{
key: 'catalonia-cctv',
html:
@@ -280,12 +297,28 @@ export const DATA_CREDITS = [
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'houston-cctv',
html:
'CCTV cameras &amp; frames (Houston): ' +
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>',
},
{
key: 'hk-cctv',
html:
'Traffic snapshot cameras (Hong Kong): ' +
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
},
{
key: 'vegvesen-cctv',
html:
'Road cameras (Norway): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
'distributed by Statens vegvesen · camera ground heights © ' +
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
'modified: heights over water set to 0 m and rounded to 0.1 m',
},
{
key: 'nyc-cctv',
@@ -301,17 +334,30 @@ export const DATA_CREDITS = [
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
},
{
key: 'houston-cctv',
html:
'CCTV cameras &amp; frames (Houston): ' +
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>',
key: 'mapillary',
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
},
{
key: 'google-street-view',
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
},
{
key: 'kartaview',
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
},
{
key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
},
{
key: 'gbfs',
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
},
{
key: 'urbansharing-gbfs',
@@ -330,7 +376,7 @@ export const DATA_CREDITS = [
{
key: 'jcdecaux-gbfs',
html:
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
},
{
@@ -389,6 +435,13 @@ export const DATA_CREDITS = [
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'sky-background',
html:
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
'(Hoffleit &amp; Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
},
{
key: 'dams',
html: 'Dams: Open Infrastructure Map',
@@ -401,6 +454,14 @@ export const DATA_CREDITS = [
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
},
{
key: 's2-ghostmaps',
html:
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
'Shown as published; reports are S2 assessments, not verified fact.',
},
{
key: 'drivebc-cctv',
html:
+13
View File
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
'google-street-view',
'kartaview',
'panoramax',
])
assert.ok(
DATA_CREDITS.some((entry) => entry.key === key),
`${key} has no credit`,
);
});
-1
View File
@@ -43,7 +43,6 @@ import {
detectionHorizontalSector,
labelBudgetFor,
normalizeAllocationStrategy,
normalizeProfile,
profileForDensity,
viewScaleForAltitude,
} from './detectionPolicy.js';
+1 -1
View File
@@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) {
}
/** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) {
function compareCohortContenders(a, b) {
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
+1 -2
View File
@@ -5,7 +5,6 @@
* module so density cannot drift into contradictory mode semantics again.
*/
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([
@@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
return 'front';
}
export const VIEW_SCALE_BUDGETS = Object.freeze({
const VIEW_SCALE_BUDGETS = Object.freeze({
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
+1 -5
View File
@@ -24,7 +24,7 @@ import * as input from './inputOwnership.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
/** The shared scene owners every Directions instance runs on. */
export const directionsServices = Object.freeze({
const directionsServices = Object.freeze({
credits,
render,
sprites,
@@ -49,15 +49,11 @@ export {
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
FLIGHT_PROGRESS_MS,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
createDirectionsLayer,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
+47
View File
@@ -846,9 +846,13 @@ test('route numbers follow the page language and never break from their unit', (
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(994), '990\u00a0m');
assert.equal(routeDistanceText(996), '1.0\u00a0km');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(59.4), '59\u00a0s');
assert.equal(routeDurationText(59.6), '1\u00a0min');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
@@ -872,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', (
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
});
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
t.after(() => setLocale('en'));
const cases = [
[
'ru',
'Въезжайте по съезду плавно налево на I-95',
'Сверните на съезд резко направо',
'Займите левую полосу',
],
[
'es',
'Toma la rampa ligeramente a la izquierda hacia I-95',
'Toma la salida bruscamente a la derecha',
'Toma el carril izquierdo',
],
[
'zh-CN',
'向左前方驶入匝道,进入 I-95',
'从出口向右后方驶出',
'使用左转车道',
],
];
for (const [locale, ramp, exit, lane] of cases) {
setLocale(locale);
assert.equal(
directionsInstruction({
type: 'on ramp',
modifier: 'slight left',
name: 'I-95',
}),
ramp,
);
assert.equal(
directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }),
exit,
);
assert.equal(
directionsInstruction({ type: 'use lane', modifier: 'sharp left' }),
lane,
);
}
});
-1
View File
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
} = {}) {
return createApplicationEarthquakes({ source, ...options });
}
export default createEarthquakesLayer();
+33 -10
View File
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
const cached = cache.get(key);
if (cached !== undefined) return cached;
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
// into March, and the fire's age would describe a detection that never happened.
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
cache.set(key, 0);
return 0;
}
const timeText = String(time ?? '').trim();
if (!/^\d{1,4}$/.test(timeText)) {
cache.set(key, 0);
return 0;
}
const hhmm = timeText.padStart(4, '0');
const year = Number(date.slice(0, 4));
const month = Number(date.slice(5, 7));
const day = Number(date.slice(8, 10));
const hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4));
const valid =
Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day) &&
Number.isFinite(hours) &&
Number.isFinite(minutes);
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms);
return ms;
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hours > 23 ||
minutes > 59
) {
cache.set(key, 0);
return 0;
}
const ms = Date.UTC(year, month - 1, day, hours, minutes);
const parsed = new Date(ms);
const result =
parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() === month - 1 &&
parsed.getUTCDate() === day
? ms
: 0;
cache.set(key, result);
return result;
}
/**
+36 -4
View File
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
const cache = new Map();
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
assert.equal(
parseAcquisitionMs('2026-07-16', '1006', cache),
Date.UTC(2026, 6, 16, 10, 6),
);
assert.equal(cache.size, 1);
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
assert.equal(
parseAcquisitionMs('2026-07-16', '1006', cache),
Date.UTC(2026, 6, 16, 10, 6),
);
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
});
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
for (const [date, time] of [
['2026-02-30', '1200'],
['2026-04-31', '1200'],
['2026-13-01', '1200'],
['2026-07-16', '2400'],
['2026-07-16', '1260'],
]) {
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
const [fire] = adaptFirmsRecords([
proxyRecord({ acqDate: date, acqTime: time }),
]);
assert.equal(fire.acqMs, 0, `${date}:${time}`);
assert.equal(fire.lat, 38.99488);
}
const [leap] = adaptFirmsRecords([
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
]);
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
});
test('daynight "D" → night false', () => {
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
assert.equal(fire.night, false);
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
proxyRecord({ lat: 40.1 }),
]);
assert.equal(fires.length, 2);
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
assert.deepEqual(
fires.map((f) => f.index),
[0, 1],
);
});
test('non-finite frp/brightness → 0; empty input → []', () => {
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
const [fire] = adaptFirmsRecords([
proxyRecord({ frp: 'n/a', brightness: undefined }),
]);
assert.equal(fire.frp, 0);
assert.equal(fire.brightness, 0);
assert.deepEqual(adaptFirmsRecords([]), []);
+15
View File
@@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
assert.equal(card.details[0], 'high · N20');
});
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
const crossed = buildFireCard(
{ fire: fire({ frp: 9.96 }), position: {} },
NOW,
);
assert.equal(crossed.title, '▲ 10 MW');
const below = buildFireCard(
{ fire: fire({ frp: 9.94 }), position: {} },
NOW,
);
assert.equal(below.title, '▲ 9.9 MW');
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
+24
View File
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { getSelectedEntityContext } from './contextStore.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
}
});
// The voice model reads these labels as data (track_entity, get_entity_context,
// pointer_context); the voice card translates them where it shows them.
test('fire labels stay English data in a translated interface', () => {
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
try {
setLocale('ru');
h.click();
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
// The card's pattern must match what the producer actually writes.
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Пожар · FRP 1520 МВт',
);
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
} finally {
h.cleanup();
setLocale('en');
}
});
test('a click on neither sprite nor card clears without moving the camera', () => {
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
try {
+11
View File
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
}
/**
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
* @param {number} frp Megawatts.
* @returns {string}
*/
export function formatFrpMw(frp) {
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
return Number(frp).toFixed(digits);
}
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '')
+9
View File
@@ -6,6 +6,7 @@ import {
FIRMS_AMBIENT_COHORT_LIMIT,
FIRMS_OVERLAY_SOURCE_ID,
satelliteShortName,
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
assert.equal(formatFrpMw(9.96), '10');
assert.equal(formatFrpMw(9.94), '9.9');
assert.equal(formatFrpMw(0.8), '0.8');
assert.equal(formatFrpMw(1520.27), '1520');
assert.equal(formatFrpMw(210.4), '210');
});
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
assert.equal(satelliteShortName('N20'), 'N20');
assert.equal(satelliteShortName('N21'), 'N21');
-6
View File
@@ -1,6 +0,0 @@
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
export { tilesForBounds } from './tomtomTiles.js';
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
const source = createFlowTileSource();
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
source;
-162
View File
@@ -1,162 +0,0 @@
// src/data/flowTiles.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
decodeFlowTile,
fetchFlowForBounds,
tilesForBounds,
getFlowSessionStats,
resetFlowTileCache,
} from './flowTiles.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16).
const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf');
const FIXTURE_TILE = { z: 12, x: 935, y: 1686 };
function loadFixture() {
return fs.readFileSync(FIXTURE);
}
// ── decodeFlowTile against the real fixture ─────────────────
test('fixture decode: more than 50 flow segments', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
assert.ok(segments.length > 50, `got ${segments.length}`);
});
test('fixture decode: every trafficLevel is within [0, 1]', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
for (const s of segments) {
assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`);
assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`);
}
});
test('fixture decode: all coordinates land in downtown Austin', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
for (const s of segments) {
assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short');
for (const [lon, lat] of s.coords) {
assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`);
assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`);
}
}
});
test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found');
});
test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
for (const s of segments) {
assert.equal(typeof s.roadType, 'string');
assert.equal(typeof s.closure, 'boolean');
}
// The fixture carries real closures — closure decoding is exercised, not vacuous.
assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture');
});
test('decode of a non-MVT buffer returns [] (defensive)', () => {
assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []);
});
// ── tilesForBounds (re-exported slippy math) ────────────────
test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => {
const tiles = tilesForBounds({
south: 30.2672 - 0.001, north: 30.2672 + 0.001,
west: -97.7431 - 0.001, east: -97.7431 + 0.001,
}, 12);
assert.ok(
tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686),
`fixture tile missing: ${JSON.stringify(tiles)}`
);
});
// ── fetchFlowForBounds (stubbed fetch: cache + abort) ───────
/** Bounds fully inside the fixture tile. */
const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 };
function stubFetch(impl) {
const original = globalThis.fetch;
globalThis.fetch = impl;
return () => { globalThis.fetch = original; };
}
test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => {
resetFlowTileCache();
const calls = [];
const restore = stubFetch(async (url) => {
calls.push(String(url));
return new Response(loadFixture(), {
status: 200,
headers: { 'Content-Type': 'application/x-protobuf' },
});
});
try {
const segments = await fetchFlowForBounds(FIXTURE_BOUNDS);
assert.equal(calls.length, 1);
assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/);
assert.ok(segments.length > 50);
assert.ok(getFlowSessionStats().tilesFetched >= 1);
} finally {
restore();
}
});
test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => {
resetFlowTileCache();
let calls = 0;
const restore = stubFetch(async () => {
calls += 1;
return new Response(loadFixture(), { status: 200 });
});
try {
const first = await fetchFlowForBounds(FIXTURE_BOUNDS);
const second = await fetchFlowForBounds(FIXTURE_BOUNDS);
assert.equal(calls, 1, 'second call must be served from the decode cache');
assert.equal(second.length, first.length);
} finally {
restore();
}
});
test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => {
resetFlowTileCache();
const restore = stubFetch(async (url, opts) => {
// Mimic real fetch abort semantics.
if (opts?.signal?.aborted) {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
}
return new Response(loadFixture(), { status: 200 });
});
try {
const controller = new AbortController();
controller.abort();
await assert.rejects(
fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }),
(err) => err.name === 'AbortError'
);
} finally {
restore();
}
});
test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => {
resetFlowTileCache();
const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 }));
try {
await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS));
} finally {
restore();
}
});
+58
View File
@@ -304,3 +304,61 @@ test('GBFS rejects redirects and caps bodies through both server hooks', async (
globalThis.fetch = originalFetch;
}
});
test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => {
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
const originalFetch = globalThis.fetch;
try {
for (const hook of ['configureServer', 'configurePreviewServer']) {
let handler;
gbfsProxy()[hook]({
middlewares: {
use(_path, callback) {
handler = callback;
},
},
});
const request = async (response) => {
globalThis.fetch = async () => response;
const res = {};
await handler(
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
{
writeHead(code, headers) {
Object.assign(res, { status: code, headers });
},
end(value) {
res.body = value;
},
},
);
return res;
};
for (const [contentType, body] of [
['text/html', '<!doctype html><p>x</p>'],
['application/json', '<!doctype html><p>x</p>'],
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['application/json', ''],
]) {
const res = await request(
new Response(body, { headers: { 'Content-Type': contentType } }),
);
assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`);
assert.equal(res.headers['Content-Type'], 'application/json');
assert.equal(typeof JSON.parse(res.body).error, 'string');
assert.doesNotMatch(res.body, /doctype|svg/);
}
const res = await request(
new Response(' { "data" : { "stations" : [] } } ', {
headers: { 'Content-Type': 'text/html; charset=iso-8859-1' },
}),
);
assert.equal(res.status, 200);
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
assert.equal(res.headers['Cache-Control'], 'no-store');
assert.equal(res.body, STATION_BODY);
}
} finally {
globalThis.fetch = originalFetch;
}
});
+103 -24
View File
@@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
/** Most files on the globe at once. */
export const MAX_IMPORTED_FILES = 12;
/** Most entities one file may create before it is refused. */
@@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000;
/** Most vertices (points of every line, ring and marker) in one file. */
export const MAX_IMPORT_POSITIONS = 250_000;
/** Most normalized style definitions and local image links in one KML/KMZ. */
export const MAX_KML_LINKS = 2_000;
const MAX_KML_LINKS = 2_000;
/** Longest plain-text name kept from a file's features. */
export const MAX_IMPORT_NAME_LENGTH = 120;
const MAX_IMPORT_NAME_LENGTH = 120;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
@@ -1040,26 +1040,104 @@ export function detachImportedTime(dataSource) {
return dataSource;
}
function parseXml(text, what) {
/** Repair only the first KML opening tag, scanning its delimiter once. */
export function repairKmlRootNamespaces(value, what = 'KML') {
const text = String(value);
// Skip XML comments and processing instructions without treating their
// literal '<kml' text as an opening root tag.
let start = 0;
for (;;) {
start = text.indexOf('<', start);
if (start < 0) return text;
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
let quote = null;
let subsetDepth = 0;
let end = start + 9;
for (; end < text.length; end++) {
const char = text[end];
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") quote = char;
else if (char === '<' && text.startsWith('<!--', end)) {
// Comments and processing instructions inside the internal subset
// carry no declaration syntax; jump past them as a whole.
const skip = text.indexOf('-->', end + 4);
if (skip < 0) return text;
end = skip + 2;
} else if (char === '<' && text.startsWith('<?', end)) {
const skip = text.indexOf('?>', end + 2);
if (skip < 0) return text;
end = skip + 1;
} else if (char === '[') subsetDepth++;
else if (char === ']') subsetDepth--;
else if (char === '>' && subsetDepth === 0) break;
}
if (end === text.length) return text;
start = end + 1;
continue;
}
const close = text.startsWith('<!--', start)
? '-->'
: text.startsWith('<?', start)
? '?>'
: null;
if (!close) break;
const end = text.indexOf(close, start + 2);
if (end < 0) return text; // The XML parser reports an unterminated preamble.
start = end + close.length;
}
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
if (!match) return text;
let quote = null;
let end = -1;
for (let index = start + match[0].length; index < text.length; index++) {
const char = text[index];
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === '>') {
end = index + 1;
break;
}
}
if (end < 0)
throw new GeoImportError(
`That ${what} file is not valid XML.`,
msg('That {format} file is not valid XML.'),
{ format: what },
);
// Walk the root's attributes in order so quoted values are never read as
// declarations. Anything the walk cannot tokenize is kept verbatim for the
// XML parser to judge.
const seen = new Set();
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
let position = start + match[0].length;
let root = text.slice(start, position);
for (;;) {
attribute.lastIndex = position;
const found = attribute.exec(text);
if (!found || attribute.lastIndex > end) break;
position = attribute.lastIndex;
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
if (prefix && seen.has(prefix)) continue;
if (prefix) seen.add(prefix);
root += found[0];
}
root += text.slice(position, end);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/(\/?)>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
);
return text.slice(0, start) + root + text.slice(end);
}
/** Parse KML or GPX with the shared namespace repairs and XML validation. */
export function parseXml(text, what) {
// Match the namespace repairs Cesium applies to ordinary exported KML.
if (what !== 'GPX')
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
const seen = new Set();
root = root.replace(
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
(declaration, prefix) => {
if (seen.has(prefix)) return '';
seen.add(prefix);
return declaration;
},
);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
);
return root;
});
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
const doc = new DOMParser().parseFromString(
String(text).replace(/^/, ''),
'application/xml',
@@ -1073,7 +1151,8 @@ function parseXml(text, what) {
return doc;
}
function decodeXml(bytes) {
/** Decode XML bytes, honouring a UTF-16 byte-order mark. */
export function decodeXml(bytes) {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
? 'utf-16le'
+91
View File
@@ -1,9 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import { spawnSync } from 'node:child_process';
import * as Cesium from 'cesium';
import {
GeoImportError,
repairKmlRootNamespaces,
loadGeoFile,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
assert.ok(track.polyline.clampToGround.getValue());
});
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
const text =
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
const result = repairKmlRootNamespaces(text);
assert.match(result, /note="a > b"/);
assert.match(result, /xmlns:x="first"/);
assert.doesNotMatch(result, /second/);
assert.match(
result,
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
);
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
assert.match(
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
/XMLSchema-instance"\/>$/,
);
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
});
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
catch (error) { if (error.name !== 'GeoImportError') throw error; }
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
catch (error) { if (error.name !== 'GeoImportError') throw error; }
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
const child = spawnSync(
process.execPath,
['--input-type=module', '-e', source],
{ timeout: 5000, encoding: 'utf8' },
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
});
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
const input =
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
assert.equal(
repairKmlRootNamespaces(input),
input.replace(' xmlns:gx="b"', ''),
);
});
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
const input =
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
assert.equal(
repairKmlRootNamespaces(input),
input.replace(' xmlns:gx="b"', ''),
);
});
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
for (const prolog of [
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
]) {
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
assert.equal(
repairKmlRootNamespaces(input),
input.replace(
' xmlns:gx="b">',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
),
prolog,
);
}
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
});
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
const input =
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
const result = repairKmlRootNamespaces(input);
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
assert.match(
repairKmlRootNamespaces(xsi),
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
);
});
+1 -1
View File
@@ -1,5 +1,5 @@
// Inspect encoded headers before allowing the browser to decode local images.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
/** Width and height from a supported raster header; no image is decoded. */
+2 -3
View File
@@ -35,14 +35,14 @@
import { PbfReader } from 'pbf';
/** VehiclePosition.VehicleStopStatus enum → label. */
export const VEHICLE_STOP_STATUS = Object.freeze({
const VEHICLE_STOP_STATUS = Object.freeze({
0: 'INCOMING_AT',
1: 'STOPPED_AT',
2: 'IN_TRANSIT_TO',
});
/** VehiclePosition.OccupancyStatus enum → label. */
export const OCCUPANCY_STATUS = Object.freeze({
const OCCUPANCY_STATUS = Object.freeze({
0: 'EMPTY',
1: 'MANY_SEATS_AVAILABLE',
2: 'FEW_SEATS_AVAILABLE',
@@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000;
export const GTFS_MAX_STRING_CHARS = 256;
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
/**
* Read a length-delimited string, refusing one that is implausibly long.
+4 -1
View File
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
[
'src/layers/vessels/selection.js',
'pickContact(viewer.scene, click.position)',
],
];
for (const [file, firstPick] of guarded) {
const source = read(file);
-46
View File
@@ -1,46 +0,0 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
-42
View File
@@ -1,42 +0,0 @@
// src/data/issPass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch
test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 });
assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Independent re-check: elevation at the reported peak really is the max-ish
// and ≥ threshold; one minute before rise it is below threshold.
const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg);
assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5);
const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg);
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
assert.equal(pass, null);
});
+6 -21
View File
@@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
* out-of-grammar ID is rejected outright, because half an address is a
* DIFFERENT aircraft, not a shorter name for the same one.
*/
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
/**
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
* complete reserved one-character space plus every two-character base-36
@@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) {
});
}
function stringOption(key, token, defaultValue) {
return Object.freeze({
key,
token,
defaultValue,
normalize: (value) => {
if (typeof value === 'number' && Number.isFinite(value))
return String(value).trim().toLowerCase();
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return normalized ? normalized : null;
},
encode: (value) => String(value),
decode: (value) =>
typeof value === 'string' && value.trim()
? value.trim().toLowerCase()
: null,
});
}
function enumOption(
key,
token,
@@ -675,6 +655,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'x',
disposition: 'enabled-only',
}),
Object.freeze({
id: 's2-ghostmaps',
token: '5',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'satellites',
token: 's',
+145 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 31);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ 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(), '5');
assert.equal(nextLayerStateToken(), '6');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '5',
bravo: '6',
alpha: '6',
bravo: '7',
}),
'7',
'8',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '5',
next: '6',
future: '6',
next: '7',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 5/,
/next free token 6/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
),
/next free token 6/,
/next free token 7/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated
coordinator.destroy();
}
});
function enabledLayerParam(sink) {
return new URLSearchParams(encode(sink.provider())).get('l');
}
test('Clear All drops a saved layer a context mode had already switched off', async () => {
const manager = productionManager();
const storage = memoryStorage();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'flights',
]);
// Context isolation is a temporary, passive takeover: the saved choice stays.
await manager.setEnabled('earthquakes', false, { origin: 'programmatic' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'flights',
]);
const updatesBefore = sink.updates;
const result = await manager.clearSelectedLayers({ origin: 'user' });
assert.deepEqual(result.targetIds, ['flights']);
assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh');
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers');
assert.deepEqual(
parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY))
.enabledLayerIds,
[],
'a reload restores nothing',
);
coordinator.destroy();
});
test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => {
let manager;
manager = productionManager({
// Contacts-style owner: its own teardown releases a borrowed dependency
// without awaiting it, which supersedes Clear All's turn for that layer.
'military-awareness': {
disable() {
void manager.setEnabled('flights', false, { origin: 'programmatic' });
return true;
},
},
});
const storage = memoryStorage();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('military-awareness', true, { origin: 'user' });
const result = await manager.clearSelectedLayers({ origin: 'user' });
assert.equal(
result.items.find((item) => item.id === 'flights')?.superseded,
true,
'the fixture reproduces the superseded flights turn',
);
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
test('Clear All keeps a layer a newer explicit request turned back on', async () => {
const manager = productionManager();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, {
storage: memoryStorage(),
});
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
const clearing = manager.clearSelectedLayers({ origin: 'user' });
// A direct request issued during the batch owns its layer.
const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' });
await Promise.all([clearing, reenable]);
assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
]);
assert.equal(enabledLayerParam(sink), 'e');
coordinator.destroy();
});
test('a passive clear never rewrites saved layers', async () => {
const manager = productionManager();
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('flights', false, { origin: 'programmatic' });
const writes = storage.writes.length;
await manager.clearSelectedLayers({ origin: 'programmatic' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']);
assert.equal(storage.writes.length, writes);
coordinator.destroy();
});
test('Clear All cancels a saved restore still waiting to start', async () => {
const gate = deferred();
const manager = productionManager();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, {
storage: memoryStorage(),
restoreGate: gate.promise,
});
const params = new URLSearchParams('v=2&l=e.f');
const restore = coordinator.start({
shareLayerState: decodeLayerStateParams(params),
});
await manager.clearSelectedLayers({ origin: 'user' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
+67 -5
View File
@@ -148,6 +148,36 @@ export class LayerStateCoordinator {
return this.restorePromise;
}
/**
* Replace the running layer state with a share opened later in this tab
* (hash navigation). Every in-flight restore and pending shared Follow is
* superseded; the new state restores through the same passive share origin
* as start-up, so recipient-local preferences are never written. A share
* without a layer payload restores the defaults, as a reload of it would.
*/
applyShareLayerState({
shareLayerState = null,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed) return Promise.resolve([]);
for (const controller of this._restoreControllers.values())
controller.abort('superseded-by-share-navigation');
this._revokePendingTrackingWatch('superseded-by-share-navigation');
const selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
this._source = selected ? 'share' : 'legacy-share';
this._shareCreatedAtMs =
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
this.restorePromise = this._restoreSelectedState(
LAYER_RESTORE_ORIGINS.share,
);
return this.restorePromise;
}
get source() {
return this._source;
}
@@ -220,6 +250,30 @@ export class LayerStateCoordinator {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'selection-cleared') {
// Only layers a newer request turned back on survive Clear All. Any
// other saved layer would reappear from the share link or a reload.
const stillEnabled = new Set(change.enabledLayerIds || []);
const dropped = this._durableState.enabledLayerIds.filter(
(id) => !stillEnabled.has(id),
);
if (dropped.length === 0) return;
for (const layerId of dropped) {
this._restoreControllers
.get(layerId)
?.abort('superseded-by-explicit-visibility');
}
if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
this._commitExplicit({
...this._durableState,
enabledLayerIds: this._durableState.enabledLayerIds.filter((id) =>
stillEnabled.has(id),
),
});
return;
}
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
@@ -312,15 +366,19 @@ export class LayerStateCoordinator {
}
async _restoreSelectedState(origin) {
// One controller set per run: a superseded run must not clear the newer
// run's controllers when it settles.
const controllers = new Map();
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
controllers.set(entry.id, new AbortController());
}
this._restoreControllers = controllers;
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const controller = controllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
@@ -370,7 +428,7 @@ export class LayerStateCoordinator {
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
const results = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
@@ -387,9 +445,13 @@ export class LayerStateCoordinator {
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
// A run superseded by a newer share keeps the newer run's results.
if (this._restoreControllers === controllers)
this.lastRestoreResults = results;
return results.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
if (this._restoreControllers === controllers)
this._restoreControllers.clear();
this._notifyDurableState();
}
}
+29 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'street-level': '0',
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
},
);
assert.throws(
@@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
.future,
'5',
'6',
);
assert.throws(
() =>
@@ -181,14 +182,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', '5']] },
{ rows: [...reservationRows, ['retired-layer', '6']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '6');
assert.equal(nextLayerStateToken(published), '7');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '6',
newcomer: '7',
}),
true,
);
@@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '5',
competing: '6',
}),
/next free token 6/,
/next free token 7/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '5',
newcomer: '6',
}),
/changed or removed: retired-layer/,
);
@@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '5']],
[{ id: 'pr-b', token: '5' }],
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '5' },
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
],
);
const staleRebase = runCheck();
@@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '6');
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
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: 6/);
assert.match(helper.stdout, /pr-b: 7/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /32 published, 1 new/);
assert.match(corrected.stdout, /33 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -336,7 +337,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 = [...'56789'].map((digit) => [
const priorDigits = [...'6789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -373,7 +374,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, 73);
assert.equal(params.get('l').length, 75);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -391,7 +392,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, 32);
assert.equal(Object.keys(published).length, 33);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -20,6 +20,7 @@
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
+69 -17
View File
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
/**
* LayerLifecycle — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe.
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
* listener methods. Without one, periodic work retains its headless behavior.
*/
export class LayerLifecycle {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
{
allowQaRegistration = false,
getSourceAvailability,
visibilityTarget = null,
} = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -528,6 +535,13 @@ export class LayerLifecycle {
}
}
_stopUpdateLoop(entry) {
clearInterval(entry.intervalId);
entry.intervalId = null;
entry.removeVisibilityListener?.();
entry.removeVisibilityListener = null;
}
_armUpdateLoop(layerId, entry) {
const configuredRefreshInterval = Number(entry.module.refreshInterval);
const updateInterval = Number(entry.module.updateInterval);
@@ -538,9 +552,45 @@ export class LayerLifecycle {
? updateInterval
: 0;
if (refreshInterval > 0) {
entry.intervalId = setInterval(() => {
const visibilityDocument = this._visibilityTarget;
let intervalId = null;
let dueAt = 0;
const canPoll = () =>
!visibilityDocument?.hidden &&
visibilityDocument?.visibilityState !== 'hidden' &&
entry.enabled &&
!entry.destroying &&
!entry.refreshing &&
entry.intervalId === intervalId;
// dueAt and the interval phase share one origin, so every interval tick
// is a due poll. A visibility catch-up re-arms the interval at the new
// deadline; otherwise the old phase would skip its next tick.
const schedule = () => {
clearInterval(intervalId);
dueAt = Date.now() + refreshInterval;
intervalId = setInterval(onInterval, refreshInterval);
entry.intervalId = intervalId;
};
const onInterval = () => {
if (!canPoll()) return;
dueAt = Date.now() + refreshInterval;
void this._runPeriodicUpdate(layerId, entry);
}, refreshInterval);
};
const onVisibilityChange = () => {
if (!canPoll() || Date.now() < dueAt) return;
schedule();
void this._runPeriodicUpdate(layerId, entry);
};
schedule();
visibilityDocument?.addEventListener?.(
'visibilitychange',
onVisibilityChange,
);
entry.removeVisibilityListener = () =>
visibilityDocument?.removeEventListener?.(
'visibilitychange',
onVisibilityChange,
);
} else if (updateInterval === 0) {
entry.intervalId = setInterval(() => {
if (!entry.enabled) return;
@@ -772,8 +822,7 @@ export class LayerLifecycle {
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
settleLifecycle();
if (!entry.enabled && entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
this._publishActivity({ type: 'status' });
if (!compensated) {
@@ -833,8 +882,7 @@ export class LayerLifecycle {
}
if (signal?.aborted) return finishCancelledDisable('disable');
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
entry.lifecycleUncertain = false;
@@ -845,8 +893,7 @@ export class LayerLifecycle {
let abortCleanup = null;
const cancelEnable = () => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Disable immediately so modules with their own AbortController (Radio)
// cancel pending update work at the same turn boundary. A second
@@ -876,8 +923,7 @@ export class LayerLifecycle {
// a later abort cannot revoke a successful retry.
signal?.removeEventListener('abort', cancelEnable);
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
await abortCleanup;
let cleanupConfirmed = false;
@@ -918,8 +964,7 @@ export class LayerLifecycle {
};
const finishFailedEnable = async (phase, error) => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
let cleanupConfirmed = false;
try {
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
// Always clear any stale interval before assigning a new one, so we never
// orphan a running timer and end up double-polling.
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Manager-owned periodic refresh work has one normalized loading/error
@@ -1606,6 +1650,15 @@ export class LayerLifecycle {
}
}
}
// Clear All owns the whole selection, not only the layers it switched off
// itself. A layer already off for a passive reason (isolated by a context
// mode, released by a dependency owner, or superseded mid-batch) gets no
// OFF of its own here, so publish the set that is still on afterwards.
this._notifyListeners({
type: 'selection-cleared',
origin,
enabledLayerIds: [...this.getEnabledLayerIds()],
});
const items = targetIds.map((id) => {
const state = this.getLayerLifecycleState(id) || {
enabled: false,
@@ -2158,8 +2211,7 @@ export class LayerLifecycle {
return false;
}
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
}
+12 -1
View File
@@ -2,7 +2,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import {
createLocalGeoJsonLayer as createLayer,
mapAnalystRecord,
} from './localGeojsonCore.js';
/** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) =>
createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost,
});
const DC_RAW = {
id: '1176042553',
Loaded 100 of 913 files, more files were not shown because too many files have changed in this diff. Show more