Merge remote-tracking branch 'upstream/main' into pr871-fix

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Said Turksever committed 2026-10-28 15:15:50 +00:00
commit 87133e8dbf
1421 files changed
+215216 -12342

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']) {
+11 -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,
@@ -1137,12 +1138,14 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
@@ -1150,6 +1153,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'cyan',
},
@@ -1157,6 +1161,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
},
],
@@ -1186,6 +1191,7 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
@@ -1200,6 +1206,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
color: 'cyan',
},
@@ -1223,6 +1230,7 @@ export function createAnnotationEngine({
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
@@ -1237,6 +1245,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'red',
},
@@ -1250,6 +1259,7 @@ export function createAnnotationEngine({
{
type: 'route',
color: 'amber',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
@@ -1558,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) {
+4 -2
View File
@@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
const requestTimes = [];
globalThis.fetch = async () => {
globalThis.fetch = async (url) => {
if (String(url).endsWith('/status'))
return Response.json({ configured: true });
requestTimes.push(Date.now());
return httpFailure(500);
};
@@ -402,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
};
let overpassCall = 0;
globalThis.fetch = async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({
status: 'OK',
results: [{
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+27 -23
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/**
* Manual whiteboard drawing: the pure half.
*
@@ -18,6 +20,8 @@
* `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 });
/**
@@ -27,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) {
@@ -46,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);
}
/**
@@ -211,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);
@@ -284,20 +282,26 @@ export function closeRing(pairs) {
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
if (!session) return t('Pick a shape, then click the map.');
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
? t('Enter to place the pin, Esc to cancel.')
: t('Click where the pin goes.');
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (need > 0)
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
? t('Those points are in a line — move one off it to enclose an area.')
: t('That line has no length — click somewhere further away.');
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
n >= MAX_VERTICES
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
: '';
return t(
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
{ measure: formatMeasure(session), full },
);
}
+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 · /);
});
+42 -17
View File
@@ -30,12 +30,14 @@
* 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,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
@@ -48,8 +50,9 @@ import {
removeLastVertex,
} from './drawMode.js';
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'];
@@ -100,6 +103,17 @@ export function initDrawTool({ viewer, annotations }) {
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
// The board lives only in memory: switching language (a reload) asks first
// while it holds a lasting mark or a shape is half drawn.
const unregisterUnsavedWork = registerUnsavedWork(() => {
if (destroyed) return null;
const lasting = (annotations.list?.() || []).some(
(mark) => mark && mark.ttlMs == null && !mark.expiring,
);
return lasting || session?.vertices?.length > 0
? msg('drawings on the map')
: null;
});
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
@@ -197,10 +211,13 @@ export function initDrawTool({ viewer, annotations }) {
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
setHint(t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -236,11 +253,15 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
setHint(
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
);
return null;
}
};
@@ -258,7 +279,7 @@ export function initDrawTool({ viewer, annotations }) {
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
if (!destroyed && !active) setHint(t('Board cleared.'));
};
// ---- keys: only while drawing, never while typing in another field ----
@@ -324,7 +345,11 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
setHint(
t('{owner} is using the pointer — close it first.', {
owner: translateKnown(pointerOwnerName(pointerOwner())),
}),
);
return active;
}
}
@@ -408,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();
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
releaseSceneHandler();
releasePointer(lease);
lease = null;
+29 -1
View File
@@ -6,6 +6,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import { describeUnsavedWork } from '../i18n/index.js';
import {
isPointerFree,
pointerOwner,
@@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
// First in the file: tools left by later tests would answer the same registry.
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
const h = harness();
try {
let marks = [];
h.annotations.list = () => marks;
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
marks = [{ id: 'b', ttlMs: null, expiring: false }];
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
marks = [];
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
marks = [{ id: 'c', ttlMs: null, expiring: false }];
await h.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
} finally {
h.restore();
}
});
test('turning Draw on takes a lease and turning it off gives it back', async () => {
const h = harness();
try {
@@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
assert.match(h.element('draw-hint').textContent, /directions/);
// Named by its tool, not by the internal owner id.
assert.equal(
h.element('draw-hint').textContent,
'Directions is using the pointer — close it first.',
);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
+111 -2
View File
@@ -44,16 +44,123 @@ 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;
let lookupStarted;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
lookupStarted = Date.now();
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
@@ -61,7 +168,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
@@ -73,8 +179,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
ring: null,
error: 'region-timeout',
});
// Timed from the boundary lookup, so the bundled pack scans before it do not
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
assert.ok(
Date.now() - started < 1000,
Date.now() - lookupStarted < 2_000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
@@ -116,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 -5
View File
@@ -4,6 +4,12 @@ import {
getActiveTrackedReadoutId,
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
return t('{label} · Detailed outline unavailable', { label: label || '' });
}
/**
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.poly);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.path);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
});
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.leader);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
}
rec.anno = anno;
if (anno.outlineUnavailable && rec.parts.label) {
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
sizeCallout(rec.parts.label);
}
+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 = [];
+1
View File
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
streetLevelLayer: 'street-level',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
+18 -2
View File
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
catalog: {
...createLayerCatalog(layers, metadata(layers)),
getSourceAvailability: (id) =>
id === 'one'
? {
available: false,
reason: 'One: data source not configured',
}
: undefined,
},
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.equal(await dataManager.setEnabled('one', true), false);
assert.equal(dataManager.layers.get('one').initialized, false);
assert.equal(
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
'One: data source not configured',
);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
@@ -93,6 +108,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'street-level',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
@@ -101,7 +117,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[12]);
assert.equal(services.localAdsbLayer, layers[13]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+25 -36
View File
@@ -1,3 +1,4 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
@@ -23,40 +24,17 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
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';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
military: ['getSnapshot'],
vessels: ['getSnapshot'],
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
radio: ['getDirectory', 'recordClick'],
traffic: [
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
bikeshare: ['getStations'],
installations: ['getMappedSites', 'searchNearby'],
satellites: ['readGroup'],
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
@@ -83,6 +61,7 @@ export function createApplicationCatalog({
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -90,12 +69,8 @@ export function createApplicationCatalog({
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
if (
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
)
throw new TypeError(`Invalid catalog source: ${name}`);
}
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
@@ -105,7 +80,8 @@ export function createApplicationCatalog({
};
signal.addEventListener('abort', dispose, { once: true });
try {
militaryRegistry.configureSource(sources.military, { signal });
if (sourceComposition.isConfigured('military'))
militaryRegistry.configureSource(sources.military, { signal });
const flights = createApplicationFlights({
surface,
source: sources.flights,
@@ -144,10 +120,20 @@ export function createApplicationCatalog({
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAirQuality({ source: sources['air-quality'] }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic, surface }),
@@ -155,8 +141,8 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
vessels,
installations,
createApplicationAwareness({
@@ -197,6 +183,9 @@ export function createApplicationCatalog({
);
return Object.freeze({
...catalog,
getSourceAvailability: sourceComposition.createAvailabilityLookup(
catalog.layers,
),
militaryRegistry,
surface,
weatherClock,
+55 -2
View File
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -39,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, 29);
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'),
@@ -59,6 +60,8 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -116,7 +119,7 @@ test('invalid or already cancelled construction fails before classification can
assert.throws(
() =>
createApplicationCatalog({
sources: {},
sources: { flights: {} },
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}),
@@ -140,3 +143,53 @@ function fixtureSurface(signal) {
eventTarget: null,
});
}
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const sources = fixtureSources([], []);
delete sources.mapillary;
const options = {
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 33);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
catalog.get('street-level'),
);
assert.match(
catalog.get('street-level').getUIState().coverage.hint,
/No street-level imagery providers/,
);
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const replacement = createApplicationCatalog({
...options,
streetLevelProviders: [provider],
});
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
assert.deepEqual(replacement.metadata, catalog.metadata);
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
assert.deepEqual(
createApplicationCatalog({
...options,
sources: fixtureSources([], []),
streetLevelProviders: [],
}).get('street-level').providerIds,
[],
);
assert.throws(
() =>
createApplicationCatalog({
...options,
sources: { ...sources, mapillary: {} },
}),
/Mapillary source/,
);
});
+11 -3
View File
@@ -2,8 +2,13 @@ import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
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,
@@ -12,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, {
@@ -26,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());
@@ -41,10 +49,10 @@ export function createApplicationControls({
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
loaderStatus.textContent = t('Flying to Austin, TX...');
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
loaderStatus.textContent = t('Restoring shared view...');
}
return { styleManager, weatherEffects, cockpitCloudEffects };
+2
View File
@@ -13,6 +13,8 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
await dataManager.destroyAll();
+305
View File
@@ -0,0 +1,305 @@
/**
* Embed mode and views in the running app.
*
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
* welcome and setup prompts hidden. A page that frames it changes what it
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
*
* Annotations in a share link are drawn once the link has been restored,
* embedded or not.
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view';
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
const EMBED_READY_MESSAGE = 'gev:ready';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
satellite: 'satellites',
};
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
* frames it, or inline when a panel page loads the app into itself and sets
* `globalThis.GEV_EMBED_INLINE` first.
*/
export function isEmbedded(location = globalThis.location) {
return (
isEmbeddedInline() ||
new URLSearchParams(location?.search || '').get('embed') === '1'
);
}
/** Whether a panel page loaded the app into itself. */
export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/**
* Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them
* Cesium's render loop; while frames stop arriving, draw from a timer.
* Returns a function that stops it.
*/
export function keepPanelRendering(
viewer,
{ windowRef = globalThis.window, now = () => performance.now() } = {},
) {
let lastFrame = now();
let frameRequest = null;
const onFrame = () => {
lastFrame = now();
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer.
// Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => {
if (
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
};
}
const delay = (ms, signal) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Fly the camera to a view's camera and resolve when the flight ends. */
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
});
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
* app's own actions (`run(name, args)`); a failed step is reported and the
* rest still run. Resolves to the steps and whether each succeeded.
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
) {
const steps = [];
const act = async (name, args) => {
let result = null;
try {
result = await run(name, args);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
steps.push({
step: name,
ok: result?.ok !== false,
...(result?.error ? { error: result.error } : {}),
});
return result;
};
if (view.style) await act('set_visual_style', { style: view.style });
if (view.map) await act('set_map_stack', { stack: view.map });
const wanted = new Set(view.layers);
for (const layer of dataManager.getAll()) {
if (layer.enabled && !wanted.has(layer.id))
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
if (!view.follow) await act('stop_tracking', {});
// Annotations go first: drawing them may frame the marks, and the view's
// own camera should have the last word.
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
}
// A followed entity owns the camera, and a camera flight would end the
// follow, so fly to the view only while the entity is not there yet. It
// appears once its layer has data; retry until then.
const layerId = FOLLOW_LAYERS[view.follow.kind];
const follow = async () => {
try {
return (
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
true
);
} catch {
return false;
}
};
let followed = await follow();
if (!followed) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
if (signal?.aborted) break;
await delay(retryMs, signal);
followed = await follow();
}
}
steps.push({ step: 'follow', ok: followed });
if (followed && view.follow.cockpit)
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
return steps;
}
/**
* Install embed mode when the page asks for it, and draw any annotations the
* opening link carries. Returns a function that removes the message handler.
*/
export function installViews({
shell,
viewer,
dataManager,
run,
signal,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
});
if (!isEmbedded(location)) return () => {};
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
const stopRendering = isEmbeddedInline()
? keepPanelRendering(viewer, { windowRef })
: () => {};
// A framing page talks to the app across frames; an inline panel shares
// the page with it and talks through the page's own window.
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
// Answers go back only to the origin that asked. An inline panel posts to
// its own window, and a sandboxed framing page has no origin to name.
const post = (message, origin = '*') => {
if (peer && (peer !== windowRef || isEmbeddedInline()))
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
const { id = null } = event.data;
const replyOrigin =
isEmbeddedInline() || !event.origin || event.origin === 'null'
? '*'
: event.origin;
let view;
try {
view = createView(event.data.view);
} catch (error) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
},
replyOrigin,
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
});
return () => {
stopRendering();
windowRef.removeEventListener('message', onMessage);
};
}
+325
View File
@@ -0,0 +1,325 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyView,
installViews,
isEmbedded,
keepPanelRendering,
} from './embed.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
flights,
camera: {
flyTo(options) {
flights.push(options);
options.complete();
},
},
};
};
const fakeLayers = (enabled) => ({
getAll: () =>
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
id,
enabled: enabled.has(id),
})),
isEnabled: (id) => enabled.has(id),
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
calls.push([name, args]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
};
test('embed mode is opt-in by query parameter', () => {
assert.equal(isEmbedded({ search: '?embed=1' }), true);
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
});
test('a view is applied through the app actions in order', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
layers: ['ais-live-vessels'],
style: 'thermal',
map: 'osm',
annotations: [{ type: 'pin', target: 'Taipei 101' }],
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set(['earthquakes'])),
run,
});
assert.deepEqual(
calls.map(([name, args]) => [
name,
args.layerId ?? args.style ?? args.stack ?? null,
args.enabled ?? null,
]),
[
['set_visual_style', 'thermal', null],
['set_map_stack', 'osm', null],
['set_layer_visibility', 'earthquakes', false],
['set_layer_visibility', 'ais-live-vessels', true],
['stop_tracking', null, null],
['clear_annotations', null, null],
['annotate_map', null, null],
],
);
assert.equal(viewer.flights.length, 1);
assert.ok(steps.every((step) => step.ok));
assert.ok(steps.some((step) => step.step === 'camera'));
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
track_entity: () => ({ ok: ++tries >= 3 }),
});
const view = createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'aircraft', id: 'abc123' },
});
const steps = await applyView(view, {
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
retryMs: 1,
});
assert.equal(tries, 3);
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const targets = [];
const parent = {
postMessage: (message, target) => {
posted.push(message);
targets.push(target);
},
};
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = {
initialRestorePromise: Promise.resolve(),
clean: null,
setCleanView(value) {
this.clean = value;
},
};
const { calls, run } = recorder();
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(shell.clean, true);
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const send = (source, data, origin = 'https://host.example') => {
const event = new Event('message');
Object.assign(event, { source, data, origin });
windowRef.dispatchEvent(event);
};
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
send(parent, {
type: 'gev:view',
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
id: 2,
ok: false,
error: 'A view needs a camera lat and lon',
});
assert.equal(posted[2].id, 3);
assert.equal(posted[2].ok, true);
assert.ok(
calls.some(
([name, args]) =>
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
assert.deepEqual(targets, [
'*',
'https://host.example',
'https://host.example',
]);
remove();
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = (message) => posted.push(message);
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
globalThis.GEV_EMBED_INLINE = true;
try {
assert.equal(isEmbedded({ search: '' }), true);
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: recorder().run,
location: { search: '', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(posted[1].id, 7);
assert.equal(posted[1].ok, true);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
]);
});
test('a panel keeps drawing from a timer while animation frames stop', () => {
let clock = 0;
let tick = null;
let frame = null;
const windowRef = {
requestAnimationFrame: (fn) => ((frame = fn), 1),
cancelAnimationFrame: () => (frame = null),
setInterval: (fn) => ((tick = fn), 1),
clearInterval: () => (tick = null),
};
const drawn = [];
const viewer = {
resize: () => drawn.push('resize'),
render: () => drawn.push('render'),
};
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
// Frames arrive: the render loop draws, so the timer does nothing.
clock = 100;
frame();
tick();
assert.deepEqual(drawn, []);
// Frames stop, as in a panel its host reports hidden: the timer draws.
clock = 400;
tick();
assert.deepEqual(drawn, ['resize', 'render']);
stop();
assert.equal(tick, null);
assert.equal(frame, null);
});
test('a panel reports a render error in full and stops drawing', () => {
let listener = null;
let tick = null;
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const drawn = [];
const viewer = {
resize() {},
render: () => drawn.push('render'),
scene: {
renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
},
},
};
const logged = [];
const original = console.error;
console.error = (...args) => logged.push(args.join(' '));
try {
let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
tick();
assert.deepEqual(drawn, []);
stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
});
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const steps = await applyView(
createView({
camera: { lat: 32.7, lon: -117.2 },
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
}),
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
);
// Following worked at once, so no camera flight ended it.
assert.equal(viewer.flights.length, 0);
assert.deepEqual(calls.slice(-2), [
['track_entity', { query: 'ae1234', layerId: 'military' }],
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
]);
assert.ok(steps.every((step) => step.ok));
assert.throws(
() =>
createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'satellite', id: '25544', cockpit: true },
}),
/no cockpit view/,
);
});
+669
View File
@@ -0,0 +1,669 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const expand = document.getElementById('expand');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
// The server refuses the panel's requests without its key.
arguments: { ...args, key: config.panelKey },
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) {
held.set(path, loading);
loading.catch(() => held.delete(path));
}
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, absolute ones on this page's site
// (matched by scheme and host, since a host may use its own scheme), and
// ones under the app's address in the page, an https address for code
// that needs one (`config.appBaseUrl`).
const page = new URL(document.baseURI);
const appOrigin = new URL(config.appBaseUrl).origin;
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
const own =
url.origin === appOrigin ||
(url.protocol === page.protocol && url.host === page.host);
return own ? url.pathname + url.search : null;
}
const fileUrls = new Map();
/**
* A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. Files of the panel
* build never change and are kept; others, such as live camera frames
* that change address on every refresh, are not.
*/
function appFileUrl(path) {
if (fileUrls.has(path)) return fileUrls.get(path);
const loading = appFetch(path)
.then((response) => response.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
if (path.startsWith(config.panelBase)) {
fileUrls.set(path, loading);
loading.catch(() => fileUrls.delete(path));
}
return loading;
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appFileUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob))
.then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Some hosts drop the GPU contents of a panel's 2D canvases while they
// consider the panel off screen, and the overlays then cover the globe
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, options) {
return type === '2d'
? getContext.call(this, type, { ...options, willReadFrequently: true })
: getContext.call(this, type, options);
};
// Markup the app adds as HTML text would request its images from this
// page's site at once; hold their addresses until the files are here.
const htmlWithHeldImages = (html) =>
String(html).replace(
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
(tag, before, quote, value) =>
appPath(value) === null
? tag
: `${before}data-gev-src=${quote}${value}${quote}`,
);
const fillHeldImages = (root) => {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src');
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
};
const innerHtml = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHtml,
set(value) {
innerHtml.set.call(this, htmlWithHeldImages(value));
fillHeldImages(this);
},
});
const insertHtml = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (position, html) {
insertHtml.call(this, position, htmlWithHeldImages(html));
fillHeldImages(this.parentNode ?? this);
};
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appFileUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
window.GEV_EMBED_INLINE = true;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
// The app restores this view from its link, following included, so
// it needs applying again only for what links cannot carry: cockpit.
if (queued === view && !view.follow?.cockpit) queued = null;
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [prelude] = await Promise.all([
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
r.text(),
),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
// starts from memory. Workers request files themselves, which this page
// cannot answer, so the prelude, holding those files, runs ahead of it.
let workers;
Object.defineProperty(window, 'CESIUM_WORKERS', {
configurable: true,
get: () => workers,
set: (script) => {
workers = prelude + script;
},
});
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across pages and take one back when
// there are too many; say so rather than show no map.
document.addEventListener(
'webglcontextlost',
() => {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
// i18n-ignore: host-panel script, no app catalogs
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
document.body.appendChild(notice);
},
{ capture: true, once: true },
);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
// i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
// i18n-ignore: see above
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
if (message.method === 'ui/notifications/host-context-changed')
showDisplayMode(message.params);
});
// Fullscreen, where the host offers it, gives the globe the room.
let displayMode = 'inline';
const showDisplayMode = (context) => {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
// i18n-ignore: host-panel script, no app catalogs
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
request('ui/request-display-mode', {
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
}).then(
(result) => showDisplayMode({ displayMode: result?.mode }),
() => {},
);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
// i18n-ignore: product name sent to the MCP host
title: "God's Eye View",
version: '1.0.0',
},
protocolVersion: config.protocolVersion,
}).then(
(result) => {
showDisplayMode(result?.hostContext);
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(error) =>
say(
// i18n-ignore: host-panel script, no app catalogs
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
);
}
+6
View File
@@ -0,0 +1,6 @@
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire air-quality readings to the application overlay host. */
export function createApplicationAirQuality(options) {
return createAirQualityLayer({ overlayHost, ...options });
}
+2 -2
View File
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js';
import { CITY_POIS } from '../../cityPois.js';
import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
sprites,
activation,
overlays,
locations,
locations: { CITY_POIS },
picking,
terrain,
ground,
+3 -9
View File
@@ -1,12 +1,6 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
/**
* Construct one Directions layer using the application scene owners.
*
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
/** Construct Directions with application scene owners and an explicit route source. */
export function createApplicationDirections({ source }) {
return createApplicationDirectionsLayer({ source });
}
+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,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
}
+8 -8
View File
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
thumbnails: createThumbnailLoader(),
});
}
+42
View File
@@ -0,0 +1,42 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/**
* Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary
* source is supplied; else none.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
createKartaViewProvider(),
createPanoramaxProvider(),
];
}
/**
* Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty.
*/
export function createApplicationStreetLevel({
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
services: { sprites, picking, input, scenePick, render },
});
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
test('explicit providers replace the default source and an empty list removes it', () => {
const sources = { mapillary: {} };
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const layer = createApplicationStreetLevel({
sources,
providers: [provider],
});
assert.deepEqual(layer.providerIds, ['example']);
assert.deepEqual(
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
[],
);
assert.throws(
() => createApplicationStreetLevel({ sources }),
/Mapillary source/,
);
assert.throws(
() => createApplicationStreetLevel({ providers: null }),
/array/,
);
});
test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, [
'mapillary',
'google',
'kartaview',
'panoramax',
]);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, true);
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(
layer.getParams().providerSwitches,
'google-1*kartaview-0*panoramax-0',
);
// KartaView is keyless and free, but one source shows at a time.
layer.setParams({ providerSwitches: 'kartaview-1' });
assert.equal(layer.getParams().kartaview, true);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, false);
// As is Panoramax: lighting it puts KartaView out.
layer.setParams({ providerSwitches: 'panoramax-1' });
assert.equal(layer.getParams().panoramax, true);
assert.equal(layer.getParams().kartaview, false);
assert.equal(layer.getParams().mapillary, false);
// The form the app writes (the README and CHANGELOG quote it).
assert.equal(
layer.getParams().providerSwitches,
'google-0*kartaview-0*panoramax-1',
);
// 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']);
});
+14 -6
View File
@@ -14,11 +14,13 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
requestServices,
googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
@@ -41,7 +43,7 @@ export async function createApplicationScene({
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
loaderStatus.textContent = t('Configuring viewer...');
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
@@ -59,11 +61,12 @@ export async function createApplicationScene({
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
googleApiKey || googleTokens || cesiumToken
? t('Loading Google 3D Tiles...')
: t('Loading the keyless globe...');
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
googleTokens,
cesiumToken,
});
const tileset = photoreal.tileset;
@@ -88,12 +91,17 @@ export async function createApplicationScene({
tileError,
);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
loaderStatus.textContent = t(
'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
{
detail: tileErrorDetail,
},
);
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
loaderStatus.textContent = t('Initializing systems...');
const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender,
+116
View File
@@ -0,0 +1,116 @@
const contract = (layers, methods) =>
Object.freeze({
layers: Object.freeze(layers),
methods: Object.freeze(methods),
});
/** Acquisition contracts and their consumers; records remain family-specific. */
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
directions: contract(['directions'], ['getRoute']),
'recent-imagery': contract(
['recent-imagery'],
['searchHls', 'getThumbnail', 'getTileTemplate'],
),
flights: contract(['flights'], ['getSnapshot']),
military: contract(['military'], ['getSnapshot']),
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
cctv: contract(
['cctv'],
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
),
radio: contract(['radio'], ['getDirectory', 'recordClick']),
traffic: contract(
['traffic'],
[
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
),
bikeshare: contract(['bikeshare'], ['getStations']),
installations: contract(
['military-installations'],
['getMappedSites', 'searchNearby'],
),
satellites: contract(['satellites'], ['readGroup']),
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
alpr: contract(['alpr-cameras'], ['fetch']),
firms: contract(['local-firms'], ['getSnapshot']),
wind: contract(['wind'], ['getSnapshot']),
weather: contract(
['weather-radar', 'weather-satellite', 'weather-lightning'],
['getSnapshot'],
),
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'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. */
class SourceUnavailableError extends Error {
constructor(name) {
super(`Data source not configured: ${name}`);
this.name = 'SourceUnavailableError';
this.code = 'unavailable';
this.unavailable = true;
this.retryable = false;
}
}
/** Validate supplied contracts and keep omitted sources inert during construction. */
export function resolveCatalogSources(supplied = {}) {
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
throw new TypeError('Catalog sources must be an object');
const sources = { ...supplied };
const missing = new Set();
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
const source = sources[name];
if (source == null) {
missing.add(name);
const unavailable = () => {
throw new SourceUnavailableError(name);
};
sources[name] = Object.freeze({
label: name,
...Object.fromEntries(methods.map((method) => [method, unavailable])),
});
} else if (methods.some((method) => typeof source[method] !== 'function')) {
throw new TypeError(`Invalid catalog source: ${name}`);
}
}
return {
sources,
isConfigured: (name) => !missing.has(name),
createAvailabilityLookup(layers) {
const byId = new Map(layers.map((layer) => [layer.id, layer]));
const availability = new Map();
for (const [name, { layers: ids }] of Object.entries(
CATALOG_SOURCE_CONTRACTS,
)) {
for (const id of ids) {
const layer = byId.get(id);
if (!layer) throw new Error(`Missing source consumer: ${id}`);
availability.set(
id,
Object.freeze({
available: !missing.has(name),
source: name,
reason: missing.has(name)
? `${layer.name || id}: data source not configured`
: null,
}),
);
}
}
return (id) =>
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
},
};
}
+220
View File
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveCatalogSources,
CATALOG_SOURCE_CONTRACTS,
} from './sourceComposition.js';
import { createApplicationCatalog } from './constructCatalog.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function catalog(sources, t) {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal: lifetime.signal,
eventTarget: null,
}),
});
}
test('every acquisition source can be omitted without losing catalog membership', (t) => {
const baseline = catalog(createStandaloneLayerSources(), t);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
const sources = createStandaloneLayerSources({ [name]: null });
const actual = catalog(sources, t);
assert.deepEqual(actual.metadata, baseline.metadata, name);
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
assert.equal(actual.getSourceAvailability(id).available, false, id);
}
}
});
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, 33);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
for (const layer of actual.layers) manager.register(layer);
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
for (const id of ids) {
assert.equal(await manager.setEnabled(id, true), false, id);
assert.equal(manager.layers.get(id).initialized, false, id);
const row = manager.getAll().find((entry) => entry.id === id);
assert.equal(row.enabled, false);
assert.equal(row.stats.status, 'unavailable');
assert.match(row.stats.error, /data source not configured/);
}
}
});
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
const source = { getSnapshot: async () => [] };
assert.equal(
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
source,
);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
assert.throws(
() => resolveCatalogSources({ [name]: {} }),
/Invalid catalog source/,
);
}
const missing = resolveCatalogSources({}).sources.earthquakes;
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
});
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
const source = { getSnapshot: async () => [] };
const sources = createStandaloneLayerSources({
flights: source,
military: null,
});
assert.equal(sources.flights, source);
assert.equal(sources.military, null);
assert.equal(typeof sources.cctv.getCatalog, 'function');
assert.throws(
() => createStandaloneLayerSources({ fligts: source }),
/Unknown source/,
);
});
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(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('street-level'));
assert.equal(await manager.setEnabled('street-level', true), false);
assert.equal(manager.layers.get('street-level').initialized, false);
const row = manager.getAll()[0];
assert.equal(row.stats.status, 'unavailable');
assert.equal(row.stats.error, 'No street-level imagery providers configured');
assert.equal(await manager.setEnabled('street-level', false), true);
const configured = catalog(createStandaloneLayerSources(), t);
assert.equal(
configured.get('street-level').getSourceAvailability().available,
true,
);
});
test('availability lookup accepts frozen layers and uses their display names', () => {
const composition = resolveCatalogSources({});
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
.flatMap(({ layers }) => layers)
.map((id) =>
Object.freeze({
id,
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
}),
);
const lookup = composition.createAvailabilityLookup(layers);
assert.equal(
lookup('fire-perimeters').reason,
'Fire perimeters: data source not configured',
);
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
assert.equal(
manager.getAll()[0].stats.error,
'Fire perimeters: data source not configured',
);
});
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
throw new Error('Unexpected fallback acquisition');
});
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
const registry = actual.militaryRegistry;
assert.equal(actual.getSourceAvailability('flights').available, true);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(registry.isMilitaryIcao('abc123'), false);
registry.registerMilitaryIcaos([' ABC123 ']);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
assert.equal(registry.isMilitaryIcao('def456'), false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('military'));
assert.equal(await manager.setEnabled('military', true), false);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(fetches, 0);
});
-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);
});
+34 -1
View File
@@ -1,10 +1,13 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
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,
@@ -55,6 +58,16 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
viewer,
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
@@ -98,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);
@@ -118,7 +140,9 @@ export function createApplicationTools({
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
// A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline();
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
@@ -176,5 +200,14 @@ export function createApplicationTools({
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
defer(
installViews({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
}
+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,
+514
View File
@@ -0,0 +1,514 @@
/**
* @module cityPois
* @description The preset cities and their landmarks — plain data with no
* Cesium, so modules that only need the names (voice cards) can import it.
*/
/**
* Points of Interest per city.
* Each city has 5 POIs; the first is the default fly-to landmark.
*
* Field reference:
* alt — RANGE (distance from target in meters), NOT absolute altitude
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
* pitch — camera tilt in degrees (negative = looking down)
* buildingHeight — estimated height of landmark center above ground (meters)
*/
export const CITY_POIS = {
austin: {
name: 'Austin',
groundElevation: 150, // meters above WGS84 ellipsoid
viewBounds: {
southwest: { lat: 30.1, lng: -97.95 },
northeast: { lat: 30.52, lng: -97.55 },
},
pois: [
{
name: 'Texas State Capitol',
lat: 30.2747,
lon: -97.7403,
alt: 550,
pitch: -28,
heading: 180,
buildingHeight: 35,
},
{
name: 'Frost Bank Tower',
lat: 30.2674,
lon: -97.7434,
alt: 550,
pitch: -22,
heading: 30,
buildingHeight: 80,
},
{
name: 'Pennybacker Bridge',
lat: 30.3451,
lon: -97.7951,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 40,
},
{
name: 'The Jenga Tower',
lat: 30.2642,
lon: -97.75,
alt: 500,
pitch: -18,
heading: 45,
buildingHeight: 60,
},
{
name: 'UT Tower',
lat: 30.2862,
lon: -97.7394,
alt: 500,
pitch: -22,
heading: 180,
buildingHeight: 50,
},
],
},
sf: {
name: 'San Francisco',
groundElevation: 15,
viewBounds: {
southwest: { lat: 37.7, lng: -122.53 },
northeast: { lat: 37.84, lng: -122.35 },
},
pois: [
{
name: 'Golden Gate Bridge',
lat: 37.8199,
lon: -122.4783,
alt: 1400,
pitch: -20,
heading: 45,
buildingHeight: 100,
},
{
name: 'Transamerica Pyramid',
lat: 37.7952,
lon: -122.4028,
alt: 500,
pitch: -25,
heading: 30,
buildingHeight: 85,
},
{
name: 'Salesforce Tower',
lat: 37.7897,
lon: -122.3972,
alt: 680,
pitch: -25,
heading: 330,
buildingHeight: 100,
},
{
name: 'Alcatraz Island',
lat: 37.8267,
lon: -122.423,
alt: 800,
pitch: -30,
heading: 0,
buildingHeight: 20,
},
{
name: 'Coit Tower',
lat: 37.8024,
lon: -122.4058,
alt: 420,
pitch: -30,
heading: 45,
buildingHeight: 30,
},
],
},
nyc: {
name: 'New York',
groundElevation: 10,
viewBounds: {
southwest: { lat: 40.477, lng: -74.259 },
northeast: { lat: 40.918, lng: -73.7 },
},
pois: [
{
name: 'Statue of Liberty',
lat: 40.6892,
lon: -74.0445,
alt: 450,
pitch: -25,
heading: 315,
buildingHeight: 45,
},
{
name: 'Empire State Building',
lat: 40.7484,
lon: -73.9857,
alt: 850,
pitch: -12,
heading: 30,
buildingHeight: 130,
buildingBounds: { height: 443, width: 130, depth: 75 },
},
{
name: 'One World Trade Center',
lat: 40.7127,
lon: -74.0134,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 170,
},
{
name: 'Brooklyn Bridge',
lat: 40.7061,
lon: -73.9969,
alt: 850,
pitch: -25,
heading: 45,
buildingHeight: 40,
},
{
name: 'Chrysler Building',
lat: 40.7516,
lon: -73.9755,
alt: 700,
pitch: -20,
heading: 225,
buildingHeight: 100,
},
],
},
tokyo: {
name: 'Tokyo',
groundElevation: 40,
viewBounds: {
southwest: { lat: 35.52, lng: 139.55 },
northeast: { lat: 35.9, lng: 139.92 },
},
pois: [
{
name: 'Tokyo Tower',
lat: 35.6586,
lon: 139.7454,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 110,
},
{
name: 'Tokyo Skytree',
lat: 35.7101,
lon: 139.8107,
alt: 900,
pitch: -25,
heading: 30,
buildingHeight: 200,
},
{
name: 'Imperial Palace',
lat: 35.6852,
lon: 139.7528,
alt: 900,
pitch: -35,
heading: 0,
buildingHeight: 20,
},
{
name: 'Senso-ji Temple',
lat: 35.7148,
lon: 139.7967,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 25,
},
{
name: 'Mode Gakuen Cocoon Tower',
lat: 35.6929,
lon: 139.6925,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 70,
},
],
},
london: {
name: 'London',
groundElevation: 15,
viewBounds: {
southwest: { lat: 51.28, lng: -0.51 },
northeast: { lat: 51.7, lng: 0.33 },
},
pois: [
{
name: 'Tower Bridge',
lat: 51.5055,
lon: -0.0754,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 65,
},
{
name: 'The Shard',
lat: 51.5045,
lon: -0.0865,
alt: 850,
pitch: -20,
heading: 0,
buildingHeight: 100,
},
{
name: 'Big Ben / Parliament',
lat: 51.5007,
lon: -0.1246,
alt: 600,
pitch: -25,
heading: 180,
buildingHeight: 50,
},
{
name: "St. Paul's Cathedral",
lat: 51.5138,
lon: -0.0984,
alt: 400,
pitch: -30,
heading: 270,
buildingHeight: 55,
},
{
name: 'The Gherkin',
lat: 51.5145,
lon: -0.0803,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 60,
},
],
},
paris: {
name: 'Paris',
groundElevation: 35,
viewBounds: {
southwest: { lat: 48.815, lng: 2.224 },
northeast: { lat: 48.902, lng: 2.47 },
},
pois: [
{
name: 'Eiffel Tower',
lat: 48.8584,
lon: 2.2945,
alt: 750,
pitch: -25,
heading: 315,
buildingHeight: 150,
},
{
name: 'Arc de Triomphe',
lat: 48.8738,
lon: 2.295,
alt: 400,
pitch: -28,
heading: 45,
buildingHeight: 25,
},
{
name: 'Notre-Dame',
lat: 48.853,
lon: 2.3499,
alt: 400,
pitch: -25,
heading: 225,
buildingHeight: 35,
},
{
name: 'Sacré-Cœur',
lat: 48.8867,
lon: 2.3431,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 40,
},
{
name: 'Louvre Pyramid',
lat: 48.8606,
lon: 2.3376,
alt: 500,
pitch: -35,
heading: 0,
buildingHeight: 10,
},
],
},
dubai: {
name: 'Dubai',
groundElevation: 5,
viewBounds: {
southwest: { lat: 24.95, lng: 54.9 },
northeast: { lat: 25.35, lng: 55.55 },
},
pois: [
{
name: 'Burj Khalifa',
lat: 25.1972,
lon: 55.2744,
alt: 600,
pitch: -20,
heading: 200,
buildingHeight: 270,
},
{
name: 'Burj Al Arab',
lat: 25.1412,
lon: 55.1853,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 100,
},
{
name: 'Palm Jumeirah',
lat: 25.1124,
lon: 55.139,
alt: 1200,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Dubai Frame',
lat: 25.235,
lon: 55.3003,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 75,
},
{
name: 'Museum of the Future',
lat: 25.2197,
lon: 55.2806,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 35,
},
],
},
dc: {
name: 'Washington DC',
groundElevation: 10,
viewBounds: {
southwest: { lat: 38.79, lng: -77.12 },
northeast: { lat: 38.995, lng: -76.91 },
},
pois: [
{
name: 'US Capitol',
lat: 38.8897,
lon: -77.0091,
alt: 550,
pitch: -25,
heading: 270,
buildingHeight: 45,
},
{
name: 'Washington Monument',
lat: 38.8895,
lon: -77.0353,
alt: 500,
pitch: -30,
heading: 0,
buildingHeight: 85,
},
{
name: 'Lincoln Memorial',
lat: 38.8893,
lon: -77.0502,
alt: 400,
pitch: -25,
heading: 90,
buildingHeight: 20,
},
{
name: 'Pentagon',
lat: 38.8711,
lon: -77.0559,
alt: 800,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Jefferson Memorial',
lat: 38.8814,
lon: -77.0365,
alt: 400,
pitch: -30,
heading: 0,
buildingHeight: 25,
},
],
},
tallinn: {
name: 'Tallinn',
groundElevation: 15,
viewBounds: {
southwest: { lat: 59.36, lng: 24.6 },
northeast: { lat: 59.52, lng: 24.92 },
},
pois: [
{
name: 'Viru Square',
lat: 59.4366,
lon: 24.7527,
alt: 450,
pitch: -28,
heading: 60,
buildingHeight: 25,
},
{
name: 'Old Town / Raekoja plats',
lat: 59.4372,
lon: 24.7452,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 20,
},
{
name: 'Teatri väljak',
lat: 59.4344,
lon: 24.7514,
alt: 400,
pitch: -25,
heading: 220,
buildingHeight: 25,
},
{
name: 'Port of Tallinn',
lat: 59.4445,
lon: 24.7675,
alt: 700,
pitch: -30,
heading: 90,
buildingHeight: 20,
},
{
name: 'Ülemiste',
lat: 59.421,
lon: 24.792,
alt: 600,
pitch: -28,
heading: 45,
buildingHeight: 30,
},
],
},
};
+2 -1
View File
@@ -8,7 +8,8 @@
// say nothing about whether a destination is framed well.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CITY_POIS, LOCATIONS } from './locations.js';
import { CITY_POIS } from './cityPois.js';
import { LOCATIONS } from './locations.js';
const DESTINATIONS = Object.entries(CITY_POIS);
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
+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;
+679 -191
View File
File diff suppressed because it is too large. Load diff
+68 -5
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);
@@ -83,11 +92,65 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/** Format the cockpit Context scope without overstating installation coverage. */
/** The English emergency line; see formatCockpitEmergency. */
const ENGLISH_EMERGENCY = Object.freeze({
squawk: ({ squawk, label }) => `SQUAWK ${squawk} · ${label}`,
adsb: ({ priority, label }) =>
`ADS-B ${priority ? 'PRIORITY' : 'EMERGENCY'} · ${label}`,
});
/**
* HUD wording for a tracked aircraft's transponder emergency, or null.
* States what was broadcast, never a conclusion about the flight: the Mode A
* code when it names the status, else the ADS-B field it came from (an
* unrelated squawk is dropped).
* The cockpit passes its own formatter so the page shows it in the active
* language; this module stays i18n-free.
* @param {{label?: string, squawk?: string|null,
* source?: string, severity?: string}|null|undefined} emergency
* @param {{squawk: Function, adsb: Function}} [format] Builds the line from
* `{ name, label, squawk }` and `{ name, label, priority }`, where
* `label` is the upper-cased `name`.
* @returns {string|null}
*/
export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) {
const label = String(emergency?.label || '')
.trim()
.toUpperCase();
if (!label) return null;
const name = String(emergency.label).trim();
if (emergency.source === 'squawk' && emergency.squawk)
return format.squawk({ name, label, squawk: emergency.squawk });
return format.adsb({
name,
label,
priority: emergency.severity === 'priority',
});
}
/** The English Context scope line; see formatCockpitContextScope. */
const ENGLISH_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
installations: ({ scope, coverage }) =>
`${scope} · INSTALLATIONS ${coverage}`,
});
/**
* Format the cockpit Context scope without overstating installation coverage.
* @param {string|number} subjectLabel
* @param {number} radiusM
* @param {string|null} [installationCoverage]
* @param {{window: Function, installations: Function}} [format] Builds the
* line from `{ label, radiusKm }` (radiusKm is null when unknown) and
* `{ scope, coverage }`.
* @returns {string}
*/
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
format = ENGLISH_CONTEXT_SCOPE,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
@@ -97,12 +160,12 @@ export function formatCockpitContextScope(
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
? Math.round(radiusM / 1000)
: null;
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
const base = format.window({ label: normalizedLabel, radiusKm });
return coverage ? format.installations({ scope: base, coverage }) : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
+28
View File
@@ -13,7 +13,9 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -154,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);
@@ -364,3 +374,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
+206 -53
View File
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => { globalThis.document = previousDocument; });
afterEach(() => {
globalThis.document = previousDocument;
});
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
@@ -22,23 +24,60 @@ function fixture() {
this.className = '';
this._text = '';
this.classList = {
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
add: (...names) => {
this.className = [
...new Set([
...this.className.split(/\s+/).filter(Boolean),
...names,
]),
].join(' ');
},
};
}
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
get firstElementChild() { return this.children[0] || null; }
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
set textContent(value) { this.replaceChildren(); this._text = String(value); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
get isConnected() {
return this === document.body || Boolean(this.parentNode?.isConnected);
}
get firstElementChild() {
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
);
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
insertBefore(node, anchor) {
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
assert.ok(
anchor === null || anchor.parentNode === this,
'insert anchor belongs to its parent',
);
if (node === anchor) return node;
node.remove();
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
const index =
anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
@@ -49,7 +88,10 @@ function fixture() {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
replaceChildren(...nodes) {
@@ -59,20 +101,32 @@ function fixture() {
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
return (
this.tagName === 'BUTTON' &&
this.dataset.signalLayer != null &&
this.dataset.signalId != null
);
}
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
return selector.startsWith('.')
? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
for (let node = this; node; node = node.parentNode)
if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
return this.children.flatMap((child) => [
...(child.matches(selector) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus() {
assert.ok(this.isConnected, 'a focus destination must still be connected');
assert.ok(
this.isConnected,
'a focus destination must still be connected',
);
focusCalls += 1;
document.activeElement = this;
}
@@ -85,7 +139,11 @@ function fixture() {
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
controller.briefTabs = [
new Node('button'),
new Node('button'),
new Node('button'),
];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
@@ -93,64 +151,131 @@ function fixture() {
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
document.body.append(
controller.signalToggle,
controller.signalList,
...controller.briefTabs,
display,
radio,
outside,
);
const selected = [];
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
const click = event => controller.handleSignalClick(event);
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
};
return { controller, document, buttons, display, radio, outside, selected, click, tab,
losses: () => focusLosses, focusCalls: () => focusCalls };
return {
controller,
document,
buttons,
display,
radio,
outside,
selected,
click,
tab,
losses: () => focusLosses,
focusCalls: () => focusCalls,
};
}
const item = (id, overrides = {}) => ({
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
key: `flight:flights:${id}`,
tone: 'nearby',
title: `Flight ${id}`,
detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id },
timestamp: 1_700_000_000_000,
...overrides,
});
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
const render = (f, items) => {
f.controller.signalItems = items;
f.controller.renderCockpitSignals();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
cohorts: [
{
id: 'flights',
count: 1,
nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }],
},
],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
const target = f.buttons()[1]; target.focus();
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
for (let tick = 0; tick < 8; tick += 1)
f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
assert.equal(
f.focusCalls(),
calls,
'refresh must preserve focus without repeatedly restoring it',
);
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture(); render(f, [item('a')]);
const target = f.buttons()[0]; target.focus();
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
const f = fixture();
render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
detail: 'CURRENT',
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0], target);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
assert.equal(
f.controller.signalList.children[0].className,
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
const target = f.buttons()[1]; target.focus();
const f = fixture();
render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
render(f, order.map((id) => item(id)));
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
for (const order of [
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
]) {
render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
}
@@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const target = f.buttons()[1]; target.focus();
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
const f = fixture();
render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
f.controller.pushCockpitSignal(
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
const event = {
target: target.children[0],
prevented: false,
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const removed = f.buttons()[1]; removed.focus();
const f = fixture();
render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(f.focusCalls(), calls + 1);
f.tab(); f.tab(); f.tab();
f.tab();
f.tab();
f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
assert.equal(
f.document.activeElement,
f.display,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(f.document.activeElement, f.radio);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0]; removed.focus();
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
const f = fixture();
render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(removed.isConnected, false);
+22 -5
View File
@@ -3,21 +3,40 @@ export const COCKPIT_VISION_MODES = Object.freeze([
'crt',
'nvg',
'thermal',
'anime',
'noir',
'snow',
]);
const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro',
nvg: 'surveillance',
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
/** Normalize a requested Cockpit vision mode to the inherited preset entry. */
const COCKPIT_MODE_BY_STYLE = Object.freeze({
normal: 'optical',
retro: 'crt',
surveillance: 'nvg',
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
export function normalizeCockpitVisionMode(mode) {
return COCKPIT_VISION_MODES.includes(mode) ? mode : 'optical';
}
/** Start Cockpit on the equivalent supported map preset. */
export function cockpitVisionModeForStyle(style) {
return COCKPIT_MODE_BY_STYLE[String(style || '').toLowerCase()] || 'optical';
}
/** Settle pending map-style crossfades and return their intended final intensities. */
export function captureCockpitVisionBaseline(stages, transitions) {
const baseline = {};
@@ -34,12 +53,10 @@ export function captureCockpitVisionBaseline(stages, transitions) {
* Apply Cockpit-only stage intensities without changing any shader parameters.
* Returns the temporary style whose parameters should be shown, or null.
*/
export function applyCockpitVisionStageIntensities(stages, mode, restore = {}) {
export function applyCockpitVisionStageIntensities(stages, mode) {
const next = normalizeCockpitVisionMode(mode);
if (next === 'optical') {
for (const [name, intensity] of Object.entries(restore)) {
if (stages[name]) stages[name].uniforms.intensity = intensity;
}
for (const stage of Object.values(stages)) stage.uniforms.intensity = 0;
return null;
}
+75 -23
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
applyCockpitVisionStageIntensities,
captureCockpitVisionBaseline,
cockpitVisionModeForStyle,
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
} from './cockpitVisionPolicy.js';
@@ -14,12 +15,31 @@ const createStages = () => ({
thermal: { uniforms: { intensity: 0, heat: 0.8 } },
});
test('Cockpit vision order exposes inherited, CRT, NVG, FLIR, and NOIR modes', () => {
assert.deepEqual(COCKPIT_VISION_MODES, ['optical', 'crt', 'nvg', 'thermal', 'noir']);
test('Cockpit vision order mirrors the unique map preset order', () => {
assert.deepEqual(COCKPIT_VISION_MODES, [
'optical',
'crt',
'nvg',
'thermal',
'anime',
'noir',
'snow',
]);
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
});
test('Cockpit entry selects the vision mode equivalent to the active map style', () => {
assert.equal(cockpitVisionModeForStyle('normal'), 'optical');
assert.equal(cockpitVisionModeForStyle('retro'), 'crt');
assert.equal(cockpitVisionModeForStyle('surveillance'), 'nvg');
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
});
test('Cockpit settles pending map crossfades before a temporary preset takes ownership', () => {
const stages = createStages();
const transitions = new Map([
@@ -29,49 +49,81 @@ test('Cockpit settles pending map crossfades before a temporary preset takes own
const restore = captureCockpitVisionBaseline(stages, transitions);
assert.deepEqual(restore, { noir: 1, retro: 0, surveillance: 0, thermal: 0 });
assert.equal(transitions.size, 0);
applyCockpitVisionStageIntensities(stages, 'nvg', restore);
applyCockpitVisionStageIntensities(stages, 'nvg');
assert.equal(stages.surveillance.uniforms.intensity, 1);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 1);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
});
test('returning from a temporary preset restores the exact inherited intensities', () => {
test('Normal clears every temporary and inherited visual stage', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
applyCockpitVisionStageIntensities(stages, 'thermal');
applyCockpitVisionStageIntensities(stages, 'optical');
assert.deepEqual(
Object.fromEntries(Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity])),
restore,
Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
),
{ noir: 0, retro: 0, surveillance: 0, thermal: 0 },
);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
test('temporary styles replace each other without changing the inherited restore snapshot', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt', restore), 'retro');
applyCockpitVisionStageIntensities(stages, 'thermal');
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt'), 'retro');
assert.equal(stages.retro.uniforms.intensity, 1);
assert.equal(stages.noir.uniforms.intensity, 0);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 0.72);
assert.equal(stages.retro.uniforms.intensity, 0.18);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
test('NOIR is a temporary Cockpit override and inherited restores the captured map style', () => {
test('NOIR is a temporary Cockpit override and Normal clears it', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir', restore), 'noir');
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir'), 'noir');
assert.equal(stages.noir.uniforms.intensity, 1);
assert.equal(stages.retro.uniforms.intensity, 0);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 0.72);
assert.equal(stages.retro.uniforms.intensity, 0.18);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
+276
View File
@@ -0,0 +1,276 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import path from 'node:path';
import {
codexAuthPath,
preferCodexOAuth,
readCodexOAuthAccessToken,
resolveCodexExecutable,
startCodexChatGptLogin,
} from '../server/providers/openai/codex-auth.js';
const nowMs = Date.UTC(2026, 8, 27);
const jwt = (payload) =>
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
const auth = (token, extras = {}) =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
...extras,
});
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
assert.equal(preferCodexOAuth({}), false);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
assert.throws(
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
/must be true or false/,
);
});
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
assert.equal(
codexAuthPath({
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
home: '/home/fixture',
}),
path.resolve('/tmp/custom-auth.json'),
);
assert.equal(
codexAuthPath({
environment: { CODEX_HOME: '/tmp/codex-home' },
home: '/home/fixture',
}),
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
);
assert.equal(
codexAuthPath({ environment: {}, home: '/home/fixture' }),
path.join('/home/fixture', '.codex', 'auth.json'),
);
});
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
const current = jwt({ exp: nowMs / 1000 + 3600 });
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(current),
}),
current,
);
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
}),
current,
'GEV does not require or consume the vendor refresh token',
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
() => auth(current, { auth_mode: 'apikey' }),
() =>
JSON.stringify({
OPENAI_API_KEY: 'sk-fixture',
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
}),
() => auth('opaque-token'),
() => auth('header.not-json.signature'),
() => auth(jwt({ exp: 'tomorrow' })),
() => {
throw new Error('missing');
},
]) {
assert.throws(
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
/ChatGPT sign-in is unavailable/,
);
}
});
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
assert.throws(
() => {
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(jwt({ exp })),
});
},
(error) => {
assert.match(error.message, /sign-in has expired/);
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
return true;
},
);
}
const noExp = jwt({ sub: 'fixture' });
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
assert.equal(
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs }),
}),
noExp,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
}),
/sign-in has expired/,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => {
throw new Error('missing');
},
}),
/sign-in is unavailable/,
);
});
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
// Built with path.join so the stub matches the resolver on Windows too,
// where the separators are backslashes.
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate === installed) return;
throw new Error('missing');
},
}),
installed,
);
assert.equal(checked.length, 1);
const child = new EventEmitter();
let unref = 0;
child.unref = () => {
unref += 1;
};
const calls = [];
const pending = startCodexChatGptLogin({
executable: '/fixture/codex',
home: '/home/fixture',
environment: {
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
OPENAI_API_KEY: 'fixture-api-key',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
},
spawnImpl(command, args, options) {
calls.push({ command, args, options });
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
const login = await pending;
assert.equal(unref, 1);
assert.equal(calls[0].command, '/fixture/codex');
assert.deepEqual(calls[0].args, ['login']);
assert.equal(calls[0].options.detached, true);
assert.equal(calls[0].options.stdio, 'ignore');
assert.deepEqual(
calls[0].options.env,
{
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
CODEX_HOME: path.join('/home/fixture', '.codex'),
},
'provider secrets stay out of the login process',
);
child.emit('exit', 1);
assert.deepEqual(await login.completion, { exitCode: 1 });
});
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
await assert.rejects(
startCodexChatGptLogin({
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
home: '/home/fixture',
executable: 'codex',
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
}),
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
);
});
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
const child = new EventEmitter();
const environment = { CODEX_HOME: './fixture-codex' };
const login = await startCodexChatGptLogin({
environment,
executable: 'codex',
spawnImpl(_command, _args, options) {
assert.equal(
options.env.CODEX_HOME,
path.resolve(environment.CODEX_HOME),
);
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
assert.equal(
environment.CODEX_HOME,
'./fixture-codex',
'caller environment is unchanged',
);
child.emit('exit', 0);
assert.deepEqual(await login.completion, { exitCode: 0 });
});
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
child.emit('error', new Error('private-token-fixture')),
);
return child;
},
}),
(error) =>
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
!error.message.includes('private-token-fixture'),
);
const running = new EventEmitter();
let cancelled = 0;
running.kill = (signal) => {
assert.equal(signal, 'SIGTERM');
cancelled += 1;
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
return running;
},
});
login.cancel();
assert.equal(cancelled, 1);
assert.deepEqual(await login.completion, { exitCode: null });
});
+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');
});
+32 -1
View File
@@ -1,3 +1,5 @@
import { msg } from './i18n/marker.js';
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set([
'military-awareness',
@@ -9,6 +11,12 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
);
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
/** Return whether an origin represents a direct user choice on this route. */
export function isExplicitUserIntentOrigin(origin, layerId = null) {
return origin === 'user' || origin === 'voice';
@@ -336,7 +344,30 @@ export function contextLayerEnableBlockReason({
return null;
}
const label = String(layerName || change.layerId || 'that layer');
return `Space Missions isolates replay data. Exit the mode to enable ${label}.`;
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
}
/**
* The layer name from a Space Missions refusal, so the UI can rebuild the
* sentence in its language.
* @param {string} reason
* @returns {{layer: string}|null} The blocked layer's name, or null when the
* reason is not a Space Missions refusal.
*/
export function parseContextLayerBlockReason(reason) {
if (
typeof reason !== 'string' ||
!reason.startsWith(BLOCK_REASON_PREFIX) ||
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
)
return null;
return {
layer: reason.slice(
BLOCK_REASON_PREFIX.length,
reason.length - BLOCK_REASON_SUFFIX.length,
),
};
}
/**
+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();
+281
View File
@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
} from '../server/providers/common/rate-limit.js';
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
import {
googlePlacesContextProxy,
GOOGLE_DEFAULT_PER_MIN,
} from '../server/providers/places/google.js';
// Both limiters are cached at module scope on first use, so the env value that
// matters is the one in place when a route is first exercised — restoring the
// variable afterwards does not rebuild the limiter. Each route is therefore
// driven by exactly one test here; a second test wanting a different cap would
// need its own process, not just a different env value.
/** Set an env var for one test and put the old value back afterwards. */
function env(t, name, value) {
const old = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
t.after(() => {
if (old === undefined) delete process.env[name];
else process.env[name] = old;
});
}
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
restart: async () => {},
});
return routes;
}
function request(
handler,
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
) {
return new Promise((resolve, reject) => {
const req = Readable.from([]);
Object.assign(req, {
method,
url,
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
socket: { remoteAddress },
});
const headers = {};
const res = {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
end(body = '') {
resolve({ status: this.statusCode, headers, body: String(body) });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('an unset throttle resolves to the documented default, not to unlimited', () => {
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
// anyone who could reach it mint tokens on the operator's key without bound.
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
assert.equal(resolvePerMinuteCap('', 30), 30);
assert.equal(resolvePerMinuteCap(' ', 30), 30);
assert.ok(
typeof makeCostRateLimiter(undefined, 30) === 'function',
'an unconfigured cost endpoint must still be throttled',
);
});
test('an explicit 0 is the documented way to run unthrottled', () => {
assert.equal(resolvePerMinuteCap('0', 30), 0);
assert.equal(
makeCostRateLimiter('0', 30),
null,
'operators who mean "no throttle" must still be able to say so',
);
});
test('a configured value wins, and a fractional one floors', () => {
assert.equal(resolvePerMinuteCap('45', 30), 45);
assert.equal(resolvePerMinuteCap(45, 30), 45);
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
});
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
assert.equal(
typeof makeCostRateLimiter(fraction, 30),
'function',
`${fraction} must still throttle`,
);
}
// Only an explicit zero turns the limiter off.
assert.equal(makeCostRateLimiter('0', 30), null);
assert.equal(makeCostRateLimiter('0.0', 30), null);
});
test('an unreadable value keeps the guard instead of removing it', () => {
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
// Reading either as "unlimited" would disarm the spend guard on a typo —
// the opposite of what the operator was reaching for when they typed it.
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
assert.equal(
resolvePerMinuteCap(typo, 30),
30,
`${typo} must fall back to the default, not to unlimited`,
);
}
});
test('the default cap clears the app’s own demand with room to spare', () => {
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
// tab, plus one token mint per voice session. The cap has to sit well above
// that or it would throttle the app rather than an abuser.
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
assert.ok(
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
);
});
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ value: 'fixture-ephemeral' });
});
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
statuses.push((await request(handler)).status);
}
assert.deepEqual(
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler);
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ output_text: 'five word hud summary here' });
});
// Its own client address: the limiter is cached at module scope and shared
// across the OpenAI routes, so the realtime case above has already spent
// 127.0.0.1's budget inside this process.
const from = '10.9.9.9';
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
statuses.push(
(await request(handleHudSummary, { remoteAddress: from })).status,
);
assert.deepEqual(
statuses,
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary HUD use',
);
const overLimit = await request(handleHudSummary, { remoteAddress: from });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
// The keyless HUD answer is deliberately NOT throttled: it is composed
// locally, spends nothing upstream, and capping it would only take the free
// HUD away from an unconfigured install. Same exhausted client, no key.
delete process.env.OPENAI_API_KEY;
const free = await request(handleHudSummary, { remoteAddress: from });
assert.equal(free.status, 200, 'the free path answers past a spent budget');
assert.equal(JSON.parse(free.body).configured, false);
assert.equal(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
true,
'the free path reaches no provider',
);
});
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
let upstreamCalls = 0;
const handler = install(
googlePlacesContextProxy({
resolveApiKey: () => 'fixture-google-server-key',
fetchImpl: async () => {
upstreamCalls += 1;
return Response.json({ places: [] });
},
}),
).get('/api/google/nearby-places');
const url = '/?lat=37.77493&lon=-122.41942';
const statuses = [];
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
statuses.push((await request(handler, { url, method: 'GET' })).status);
}
assert.ok(
statuses.every((s) => s === 200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler, { url, method: 'GET' });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
// Every error response on this route carries places[]; the client reads it
// unconditionally, so a throttled reply must not be the one shape that omits it.
assert.deepEqual(JSON.parse(overLimit.body).places, []);
assert.equal(
upstreamCalls,
GOOGLE_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches Google, or it still costs money',
);
});
test('the code defaults match the caps the Pinokio build ships', () => {
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
// this repo suggested elsewhere had already drifted away from them. Now that
// they are the server's defaults, the packaged app and an unconfigured
// checkout must agree, or a Pinokio user's throttle silently stops matching
// the one everybody else gets. If you deliberately want them to differ, this
// assertion is the place to say so.
const shipped = readPinokioEnvironment(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'pinokio',
'_ENVIRONMENT',
),
);
assert.equal(
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
OPENAI_DEFAULT_PER_MIN,
);
assert.equal(
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
GOOGLE_DEFAULT_PER_MIN,
);
});
+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 });
}
}
+11 -1
View File
@@ -2,6 +2,8 @@
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
import { getLocale, t } from './i18n/index.js';
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
@@ -11,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) {
if (!expand || !lightbox || !close) return false;
const overlay = lightbox.parentElement;
// Cesium writes these two texts in English once, when it builds the credit
// display, and never rewrites them; English keeps Cesium's own text.
if (getLocale() !== 'en') {
expand.textContent = t('Data attribution');
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
if (title) title.textContent = t('Data provided by:');
}
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
expand.setAttribute('role', 'button');
expand.setAttribute('tabindex', '0');
@@ -19,7 +29,7 @@ export function configureCreditKeyboardAccess(root = document) {
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', 'Close data attribution');
close.setAttribute('aria-label', t('Close data attribution'));
if (expand.dataset.gevKeyboardReady === 'true') return true;
expand.dataset.gevKeyboardReady = 'true';
+35
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
import { setLocale } from './i18n/index.js';
function fakeElement() {
const attributes = new Map();
@@ -186,3 +187,37 @@ test('credit keyboard setup is idempotent and safely declines incomplete markup'
assert.equal(f.overlay.listenerCount('click'), 1);
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
});
function textFixture() {
const f = fixture();
const title = fakeElement();
title.textContent = 'Data provided by:';
f.expand.textContent = 'Data attribution';
f.lightbox.querySelector = (selector) =>
selector === '.cesium-credit-lightbox-title' ? title : f.close;
return { ...f, title };
}
test('English keeps Cesium attribution texts', () => {
setLocale('en');
const f = textFixture();
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, 'Data attribution');
assert.equal(f.title.textContent, 'Data provided by:');
});
for (const [locale, link, title] of [
['ru', 'Источники данных', 'Данные предоставлены:'],
['es', 'Atribución de datos', 'Datos proporcionados por:'],
])
test(`${locale}: Cesium attribution link and lightbox title are translated`, () => {
setLocale(locale);
try {
const f = textFixture();
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, link);
assert.equal(f.title.textContent, title);
} finally {
setLocale('en');
}
});
+4 -22
View File
@@ -58,30 +58,18 @@ export function normalizeAdminName(value) {
const PACKS = {
countries: {
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/countries.json', {
with: { type: 'json' },
}),
},
admin1: {
url: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
importJson: () =>
import('./local_data/natural_earth/states_provinces.json', {
with: { type: 'json' },
}),
},
counties: {
url: new URL(
'./local_data/us_census_counties/counties.json',
import.meta.url,
),
importJson: () =>
import('./local_data/us_census_counties/counties.json', {
with: { type: 'json' },
}),
},
};
@@ -135,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) {
@@ -297,7 +285,7 @@ function addKey(index, key, entry, tier) {
const loadAdmin1 = createRetryableLoader(async () => {
packLoads.admin1 += 1;
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
const pack = await loadBundledJson(PACKS.admin1.url);
const decimals = pack.meta?.decimals ?? 3;
const index = new Map();
for (const feature of pack.features || []) {
@@ -323,10 +311,7 @@ const loadAdmin1 = createRetryableLoader(async () => {
const loadCounties = createRetryableLoader(async () => {
packLoads.counties += 1;
const pack = await loadBundledJson(
PACKS.counties.url,
PACKS.counties.importJson,
);
const pack = await loadBundledJson(PACKS.counties.url);
const decimals = pack.meta?.decimals ?? 4;
const byFull = new Map();
const byName = new Map();
@@ -348,10 +333,7 @@ const loadCounties = createRetryableLoader(async () => {
const loadCountries = createRetryableLoader(async () => {
packLoads.countries += 1;
const pack = await loadBundledJson(
PACKS.countries.url,
PACKS.countries.importJson,
);
const pack = await loadBundledJson(PACKS.countries.url);
const index = new Map();
for (const feature of pack.features || []) {
const entry = {
+37 -8
View File
@@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
.toLowerCase();
const latitude = finiteNumber(aircraft?.lat);
const longitude = finiteNumber(aircraft?.lon);
if (!hex || latitude === null || longitude === null) return null;
if (
!hex ||
latitude === null ||
longitude === null ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
return null;
const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground';
@@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
hex,
String(aircraft?.flight || aircraft?.r || '').trim() || null,
null,
Math.max(0, nowSeconds - seenPosition),
Math.max(0, nowSeconds - seen),
Number.isFinite(seenPosition)
? Math.max(0, nowSeconds - seenPosition)
: null,
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
longitude,
latitude,
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
@@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) {
: Math.floor(
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
);
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
.filter(Boolean);
return { time: nowSeconds, states };
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
const states = [];
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
// status travels alongside them, keyed only to admitted aircraft.
const admitted = new Set();
const statuses = [];
for (const row of aircraft) {
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
if (!state) continue;
states.push(state);
// The live contract admits the first valid vector for each ICAO. Its
// status must come from that same row, never an invalid or later duplicate.
const id = state[0];
if (admitted.has(id)) continue;
admitted.add(id);
if (typeof row.emergency === 'string')
statuses.push([id, row.emergency.trim().toLowerCase()]);
}
const emergencyByIcao = Object.fromEntries(statuses);
return {
time: nowSeconds,
states,
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
};
}
+13 -11
View File
@@ -7,6 +7,8 @@
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
*/
import { msg } from '../i18n/marker.js';
const HELI = new Set([
'EC20',
'EC25',
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
/** Readable name of each class, for cards and readouts (English data). */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
light: msg('Light aircraft'),
glider: msg('Glider'),
turboprop: msg('Turboprop'),
airliner: msg('Airliner'),
widebody: msg('Widebody'),
quadjet: msg('Four-engine jet'),
helicopter: msg('Helicopter'),
fastjet: msg('Fast jet'),
bizjet: msg('Business jet'),
uav: msg('Drone'),
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
+98
View File
@@ -0,0 +1,98 @@
/**
* Transponder emergency status for one aircraft, from the Mode A squawk and
* the ADS-B emergency/priority field.
*
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
* squawk only.
*
* The status is what the transponder broadcast in THIS poll, never a finding:
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
* broadcast, which does not confirm an incident or its resolution.
*/
import { msg } from '../i18n/marker.js';
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444';
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = {
general: { label: msg('General emergency'), severity: 'emergency' },
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
nordo: { label: msg('Radio failure'), severity: 'emergency' },
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
lifeguard: {
label: msg('Lifeguard (medical priority)'),
severity: 'priority',
},
};
/**
* A Mode A code as its four octal digits, else null.
* @param {unknown} value
* @returns {string|null}
*/
export function normalizeSquawk(value) {
const code = String(value ?? '').trim();
return /^[0-7]{4}$/.test(code) ? code : null;
}
/**
* Emergency status from one observation. Higher severity wins; an ADS-B
* emergency wins a tie. A
* 7x00 squawk counts even when the ADS-B field says `none`.
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
* 'squawk' when the Mode A code itself names the status (so it can be
* quoted), else 'ads-b'.
*/
export function aircraftEmergency({ squawk, emergency } = {}) {
const code = normalizeSquawk(squawk);
const reported = String(emergency ?? '')
.trim()
.toLowerCase();
const fromAdsb = Object.hasOwn(KINDS, reported);
const squawkKind = SQUAWK_KINDS[code] ?? null;
const kind =
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
? reported
: squawkKind;
if (!kind) return null;
return {
kind,
...KINDS[kind],
squawk: code,
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
};
}
/**
* How a summary may phrase the status without flattening it into an incident:
* quote the code when the Mode A squawk itself names the status, otherwise
* attribute it to the ADS-B emergency field.
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
* or 'ADS-B reports general emergency'; null when there is no status.
*/
export function emergencyBroadcastPhrase(status) {
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
const label = KINDS[status.kind].label.toLowerCase();
return status.source === 'squawk' && status.squawk
? `broadcasting squawk ${status.squawk} (${label})`
: `ADS-B reports ${label}`;
}
/**
* True when the aircraft is broadcasting an emergency (not a priority status).
* @param {{severity?: string}|null|undefined} status
* @returns {boolean}
*/
export function isEmergency(status) {
return status?.severity === 'emergency';
}
+95
View File
@@ -0,0 +1,95 @@
// src/data/aircraftEmergency.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
aircraftEmergency,
emergencyBroadcastPhrase,
isEmergency,
normalizeSquawk,
} from './aircraftEmergency.js';
test('the three ICAO emergency squawks map to their kinds', () => {
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
const general = aircraftEmergency({ squawk: ' 7700 ' });
assert.deepEqual(general, {
kind: 'general',
label: 'General emergency',
severity: 'emergency',
squawk: '7700',
source: 'squawk',
});
});
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
assert.equal(aircraftEmergency(), null);
assert.equal(normalizeSquawk('7800'), null);
assert.equal(normalizeSquawk(7700), '7700');
assert.equal(normalizeSquawk('770'), null);
});
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
assert.equal(minfuel.kind, 'minfuel');
assert.equal(minfuel.severity, 'priority');
assert.equal(minfuel.source, 'ads-b');
assert.equal(minfuel.squawk, '4521');
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
'squawk',
);
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
'squawk',
'a code that names the status stays quotable',
);
assert.equal(
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
'ads-b',
);
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
});
test('priority statuses are reported but are not emergencies', () => {
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
assert.equal(
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
false,
);
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
assert.equal(isEmergency(null), false);
});
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(
emergencyBroadcastPhrase(
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
),
'ADS-B reports general emergency',
);
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
'ADS-B reports lifeguard (medical priority)',
);
assert.equal(emergencyBroadcastPhrase(null), null);
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
});
test('an emergency squawk outranks an ADS-B priority', () => {
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
'general',
);
assert.equal(
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
'unlawful',
);
});
+524
View File
@@ -0,0 +1,524 @@
import * as Cesium from 'cesium';
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
import { FlightRecords } from '../layers/flights/records.js';
import { MilitaryFlightRecords } from '../layers/military/records.js';
import { mapAnalystRecord as mapFlight } from './flights.js';
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
import { createAnalystEngine } from './analystEngine.js';
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
import { createGevActionRunner } from '../voice/gevActions.js';
const NOW_MS = 1800000000000;
test('fallback status belongs to the first valid admitted aircraft row', () => {
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
for (const status of [undefined, 'general', 'none']) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [
{ ...valid, lat: 100, emergency: 'general' },
{ ...valid, emergency: status },
{ ...valid, lat: null, emergency: 'general' },
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
],
});
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.records[0].emergency, status);
assert.equal(snapshot.records[0].latitude, 30);
}
});
function civilStore() {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
approxDistanceKm: () => 0,
});
}
const receiveContext = {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
};
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
const store = civilStore();
const aircraft = {
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
seen: 3,
seen_pos: 1,
};
for (const [status, expectedKind, flagged] of [
['general', 'general', true],
['minfuel', 'minfuel', false],
['none', null, false],
]) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [{ ...aircraft, emergency: status }],
});
assert.equal(
payload.states[0].length,
18,
'fallback vectors retain the OpenSky shape',
);
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
const result = mapFlight(
aircraft.hex,
store.receive(observation, receiveContext).meta,
);
assert.equal(result.emergency, flagged);
assert.equal(result.emergencyKind, expectedKind);
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
}
});
test('changed status without contact time retains general contact, including voice projection', async () => {
const store = civilStore();
const [observation] = readsbSnapshot(
{
ac: [
{
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS, now: NOW_MS },
).records;
store.receive(observation, receiveContext);
const meta = store.receive(
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
receiveContext,
).meta;
const record = mapFlight(observation.id, meta);
assert.equal(record.emergencyKind, 'nordo');
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const flights = {
id: 'flights',
source: 'fixture',
getStats: () => ({ count: 1 }),
getAnalystRecords: () => [record],
};
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionCartographic: {
height: 300000,
latitude: 0.52,
longitude: -1.71,
},
},
},
styleManager: {
getContextModeState: () => ({ mode: null, active: false }),
getCockpitState: () => ({ active: false }),
},
dataManager: {
layers: new Map([['flights', { module: flights }]]),
isEnabled: (id) => id === 'flights',
getAll: () => [
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
],
},
});
try {
const result = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'emergency', op: 'eq', value: true }],
});
assert.equal(result.ok, true);
assert.equal(result.items[0].emergency, true);
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
assert.equal(result.items[0].emergencySource, 'squawk');
assert.equal(
result.items[0].emergencyBroadcast,
'broadcasting squawk 7600 (radio failure)',
);
assert.equal(
Object.hasOwn(result.items[0], 'time'),
false,
'general contact is not a generic incident timestamp',
);
} finally {
runner.dispose?.();
}
});
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
const OPENSKY = {
time: NOW_MS / 1000,
states: [
[
'a1b2c3',
'SWA696 ',
'United States',
NOW_MS / 1000 - 6,
NOW_MS / 1000 - 3,
-97.67,
30.19,
3000,
false,
150,
180,
-5,
null,
3040,
'7700',
false,
0,
4,
],
],
};
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
const ADSB_LOL = {
ac: [
{
hex: 'ae01ce',
flight: 'RCH123 ',
t: 'C17',
lat: 31.1,
lon: -97.4,
alt_baro: 18000,
gs: 300,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 1.5,
seen_pos: 2,
},
],
};
function civilAnalystRecord() {
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
const store = new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0,
});
const { meta } = store.receive(row, {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
return mapFlight(row.id, meta);
}
function militaryAnalystRecord() {
const [row] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
});
const { meta } = store.receive(row, {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
});
return mapMilitary(row.id, meta);
}
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
const civil = civilAnalystRecord();
const military = militaryAnalystRecord();
assert.equal(civil.emergency, true);
assert.equal(civil.emergencyKind, 'general');
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergencySource, 'squawk');
assert.equal(
civil.emergencyBroadcast,
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
assert.equal(military.emergency, true);
assert.equal(military.emergencyKind, 'general');
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
assert.equal(military.emergencySource, 'ads-b');
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
});
test('the analyst can filter emergencies by source', async () => {
const records = {
flights: [civilAnalystRecord()],
military: [militaryAnalystRecord()],
};
const engine = createAnalystEngine({
getRecords: (key) => records[key] || [],
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
});
const query = (value) =>
engine.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
filters: [
{ field: 'emergency', op: 'eq', value: true },
{ field: 'emergencySource', op: 'eq', value },
],
});
const bySquawk = await query('squawk');
assert.deepEqual(
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
);
const byAdsb = await query('ads-b');
assert.deepEqual(
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['ae01ce', 'ADS-B reports general emergency']],
);
});
test('military status changes retain only the latest known general contact', () => {
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (v) => v,
});
const [observation] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const context = {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
};
const unknown = mapMilitary(
observation.id,
store.receive({ ...observation, contactTimeMs: null }, context).meta,
);
assert.equal(unknown.lastContactAtMs, null);
store.receive(observation, context);
const changed = mapMilitary(
observation.id,
store.receive(
{
...observation,
contactTimeMs: null,
squawk: '7600',
emergency: 'none',
},
context,
).meta,
);
assert.equal(changed.emergencyKind, 'nordo');
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
});
test('fallback missing contact age retains known contact through the real converter', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
const first = receive(row);
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
});
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
assert.equal(receive(row).emergency.kind, 'general');
assert.equal(
receive({ ...row, emergency: undefined }).emergency?.kind,
'general',
);
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
receive(row);
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
receive(row);
assert.equal(
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
null,
);
});
function renderHarness(factory) {
const flightState = { records: { data: new Map() }, _trackedIcao: null };
const rendering = factory({
flightState,
services: {
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
aircraftPresentation: {},
camera: {},
focus: {},
recession: {},
},
parts: {},
});
return { flightState, rendering };
}
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
for (const factory of [civilRendering, militaryRendering]) {
const { flightState, rendering } = renderHarness(factory);
const normal = rendering._fleetBillboardColor('civil', {});
assert.equal(
rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ emergency: 'minfuel' }),
}),
normal,
);
const red = rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.ok(red.red > 0.9 && red.green < 0.5);
flightState.records.data.set('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.equal(rendering._modelColor('civil'), red);
if (rendering._cockpitContactColor)
assert.equal(rendering._cockpitContactColor('civil'), red);
flightState._trackedIcao = 'civil';
assert.equal(
rendering._modelColor('civil'),
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal);
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
});
flightState.records = records;
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {},
queries: {},
});
for (const [emergency, red] of [
['general', true],
['none', false],
['general', true],
]) {
const snapshot = readsbSnapshot(
{
ac: [
{
hex: 'abc123',
lat: 30,
lon: -97,
alt_baro: 10000,
squawk: '1200',
emergency,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
}
});
+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`,
);
});
}
+5 -2
View File
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
}
/**
* Apply the already-composed aircraft alpha to an ambient glTF model without
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
* @param {object} input
* @param {object} input.model
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
model.color.red === baseColor.red &&
model.color.green === baseColor.green &&
model.color.blue === baseColor.blue
)
return 0;
model.color = baseColor.withAlpha(alpha);
+12
View File
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
assert.equal(model.colorBlendAmount, 0.9);
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
});
test('model hue changes at constant alpha, then clears without redundant writes', () => {
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
const model = {color:color(1,1,1).withAlpha(0.65)};
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
assert.equal(model.color.green,0.2);
assert.equal(model.color.alpha,0.65);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
assert.equal(model.color.green,1);
});
+1 -1
View File
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
courseDeg: 214.0,
shipType: 'Cargo',
destination: 'OAKLAND',
navStatus: null, // /api/ais-live does not surface NavigationalStatus
navStatus: null, // /api/vessels does not surface NavigationalStatus
});
});
+6 -3
View File
@@ -1,10 +1,13 @@
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
import { createAisStreamSource } from '../sources/live/standalone.js';
import { createVesselSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
source: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may serve
// from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
+30 -2
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' });
@@ -1668,7 +1696,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
});
test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js');
const { createVesselSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false });
const requests = [];
aisLiveVesselsLayer.setSource({
@@ -1688,7 +1716,7 @@ test('vessel selection passes the opaque source reference to optional history',
} finally {
harness.cleanup();
_setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource());
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
+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');
});
+6 -22
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']);
@@ -249,6 +229,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) {
const actions = terminateOwned('key-absent');
status = 'missing-key';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'AISSTREAM_API_KEY is not set';
reconnectAttempt = 0;
keyFingerprint = null;
@@ -257,6 +238,7 @@ export function createAisWatchdog(options = {}) {
if (env.hasTransport === false) {
const actions = terminateOwned('no-transport');
status = 'unsupported';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'Node WebSocket transport is unavailable';
return actions;
}
@@ -329,6 +311,7 @@ export function createAisWatchdog(options = {}) {
if (!silenceWatchArmed) return [];
const silentFor = monoNow - silenceSinceMono;
if (silentFor >= recycleAfterMs) {
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
const actions = terminateOwned('silent');
scheduleRetry('transport');
@@ -401,6 +384,7 @@ export function createAisWatchdog(options = {}) {
function onClose(eventGeneration) {
if (!ownsGeneration(eventGeneration)) return [];
release();
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport');
return [];
@@ -450,7 +434,7 @@ export function createAisWatchdog(options = {}) {
return generation;
}
/** Status metadata for /api/ais-live. */
/** Status metadata for /api/vessels. */
function snapshot() {
const monoNow = clock.mono();
const wallNow = clock.wall();
+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);
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { directionToHeading } from './directionText.js';
test('conflicting facing directions cannot manufacture one heading', () => {
for (const bare of [false, true]) {
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
assert.equal(directionToHeading('NB northbound', bare), 0);
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
}
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
assert.equal(directionToHeading('West / SR-24', true), 270);
});
test('travel tokens retain precedence over quadrant streets and bare words', () => {
for (const [text, heading] of [
['I-5 NB at NE 45th St', 0],
['SR 520 EB at NE 148th', 90],
['US 13 SB @ SW Front St', 180],
]) {
for (const bare of [false, true])
assert.equal(directionToHeading(text, bare), heading);
}
assert.equal(
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
270,
);
assert.equal(directionToHeading('NE near West St', true), 45);
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
});
+862 -182
View File
File diff suppressed because it is too large. Load diff
+677 -7
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' },
@@ -174,11 +345,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
const eng = makeEngine();
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
const first = await eng.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const r = await eng.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: true }],
});
assert.equal(r.count, 1);
assert.equal(r.items[0].id, 'GND1');
assert.equal(r.coverage.followUp, true);
assert.equal(r.scopeLabel, first.scopeLabel);
assert.equal(r.display.scope, first.display.scope);
assert.equal(r.coverage.scope, first.coverage.scope);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
@@ -200,6 +455,171 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: region lookup receives and obeys the owning action signal', async () => {
const controller = new AbortController();
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
);
return null;
},
getViewContext: () => ({
lat: 30.27,
lon: -97.74,
viewRadiusKm: 150,
}),
});
const pending = eng.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
await Promise.resolve();
controller.abort();
const result = await pending;
assert.equal(receivedSignal, controller.signal);
assert.equal(result.code, 'CANCELLED');
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' },
@@ -262,20 +682,132 @@ test('helpers: haversine sanity + scope radius', () => {
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getRecordCoverage: () => ({ total: 3000, truncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const layer = result.coverage.layersQueried[0];
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
});
test('an uncapped cohort is complete and carries no caveat', async () => {
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
assert.equal(result.display.caveat, undefined);
assert.equal(result.display.scope, 'anywhere in the loaded data');
});
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
const engine = makeEngine();
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
assert.equal(field.ok, false);
assert.equal(field.code, 'UNKNOWN_FIELD');
assert.ok(field.allowed.includes('altitudeM'));
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
assert.equal(op.code, 'BAD_OPERATOR');
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
assert.equal(num.code, 'BAD_VALUE');
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
assert.equal(scope.code, 'UNKNOWN_SCOPE');
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
assert.equal(sort.code, 'UNKNOWN_FIELD');
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
});
test('a typed boolean filter still matches exactly', async () => {
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
});
test('distance honours sortDir: desc is farthest first', async () => {
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
assert.equal(far.items[0].id, 'N123');
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
assert.equal(near.items[0].id, 'SWA1');
});
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
const r = await makeEngine().query({ followUp: true, filters: [] });
assert.equal(r.ok, false);
assert.equal(r.code, 'NO_RESULT_CONTEXT');
});
test('follow-ups never rewrite the remembered rows', async () => {
const engine = makeEngine();
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
await engine.query({ followUp: true, sortBy: 'distance' });
const again = await engine.query({ followUp: true });
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
assert.ok(again.items.every((item) => item.distanceKm === undefined));
});
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
let releaseSlow;
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
let generation = 0;
const slowGeneration = ++generation;
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
const newGeneration = ++generation;
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
assert.equal(fresh.ok, true);
releaseSlow();
const stale = await slow;
assert.equal(stale.code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
});
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
getRecords: () => rows,
getLayerSnapshot: () => snapshot,
isWarming: () => warming,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
assert.equal(zero.ok, true);
assert.equal(zero.count, 0);
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
});
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
const now = Date.parse('2026-09-23T12:00:00Z');
const engine = createAnalystEngine({
now: () => now,
getRecords: () => [
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
],
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
assert.equal(age.items[0].ageHours, 2);
assert.equal(age.display.window, 'last 24 h');
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
@@ -293,3 +825,141 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
const engine = createAnalystEngine({
getRecords: () => rows,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
assert.equal(result.count, 200_000);
assert.equal(result.items[0].frp, 199_999);
assert.equal(result.summary.frpMin, 0);
});
test('an enabled but empty layer says why on screen', async () => {
const engine = createAnalystEngine({
getRecords: () => [],
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
getEmptyNote: () => 'Fly below 400 km to a covered region',
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['transit'] });
assert.equal(result.ok, true);
assert.equal(result.count, 0);
assert.equal(result.coverage.layersQueried[0].status, 'empty');
assert.match(result.display.caveat, /transit: Fly below 400 km/);
});
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
assert.equal(r.ok, false);
assert.equal(r.code, 'BAD_SCOPE');
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
assert.equal(ok.ok, true);
});
test('a field copied with its hinted unit still resolves', async () => {
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
assert.equal(r.ok, true);
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
});
test('a written unit converts to the field unit or is refused', async () => {
const engine = makeEngine();
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
assert.equal(feet.ok, true);
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
assert.equal(wrong.code, 'BAD_UNIT');
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
assert.equal(unitless.code, 'BAD_UNIT');
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
assert.equal(hinted.ok, true);
});
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
const engine = makeEngine();
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
assert.equal(text.ok, true);
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
for (const value of ['', ' ', true, 'high', '12k'])
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
});
test('any supplied centre must be a real coordinate', async () => {
const engine = makeEngine();
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
});
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
const engine = createAnalystEngine({
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
assert.equal(none.ok, false);
assert.equal(none.code, 'NOT_READY');
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
assert.equal(down.code, 'FEED_UNAVAILABLE');
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
assert.equal(partial.ok, true);
assert.equal(partial.partial, true);
assert.deepEqual(partial.unanswered, ['earthquakes']);
assert.match(partial.display.caveat, /earthquakes error/);
});
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
const engine = makeEngine();
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
for (const item of first.items) {
item.altitudeM = -1;
item.id = 'MUTATED';
}
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
});
test('ranking 250k rows stays within a budget and yields to the page', async () => {
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const started = performance.now();
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
const elapsed = performance.now() - started;
clearInterval(timer);
assert.equal(result.count, 250_000);
assert.equal(result.items[0].frp, 100_002);
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
assert.ok(ticks > 0, 'the scan yielded between slices');
let current = true;
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
current = false;
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
});
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
const r = await engine.query({ layers: ['local-firms'], followUp: true });
assert.equal(r.ok, false);
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
* short way round, so points either side of 180° stay close.
*/
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
let dLonDeg = lon2 - lon1;
dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
+112 -24
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']),
});
// Known English from public/events/bhote-koshi-2026/event.json, translated at
// display (whole or by " · " parts). Anything else shows as authored.
const EVENT_PACK_TEXT = new Set([
// Field reports: our labels (credits after " · " stay) and their statuses.
msg('Rasuwagadhi witness video'),
msg('Bhote Koshi valley footage'),
msg('Flood footage'),
msg('Compilation and casualty claims'),
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
msg('SOURCE GEOCONFIRMED'),
// Reconstruction caveat.
msg(
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
),
// Evidence titles that are more than a place name.
msg('Immediate collapse viewpoint'),
msg('Collapse viewpoint'),
msg('Debris-dammed lake'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Gyirong border gate'),
msg('Border gate'),
msg('Timure evidence cluster'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Dandagaun viewpoint'),
msg('Bidur - Trishuli bridge'),
msg('Bidur bridge'),
msg('Devighat / Taadi Khola Bridge'),
msg('Devighat bridge'),
]);
/**
* Display text for an authored event-pack string.
* @param {unknown} value
* @returns {string}
*/
export function bhoteEventText(value) {
const text = value == null ? '' : String(value);
if (EVENT_PACK_TEXT.has(text)) return t(text);
if (!text.includes(' · ')) return text;
return text
.split(' · ')
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
.join(' · ');
}
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
prior?.();
} finally {
if (facebookSdkReady(globalRef.FB)) finish();
else finish(new Error('Facebook player API did not initialize.'));
else finish(new Error(msg('Facebook player API did not initialize.')));
}
};
globalRef.fbAsyncInit = ready;
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('Facebook player API could not load.')),
() => finish(new Error(msg('Facebook player API could not load.'))),
{ once: true },
);
timer =
globalRef.setTimeout?.(
() => finish(new Error('Facebook player API timed out.')),
() => finish(new Error(msg('Facebook player API timed out.'))),
EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => {
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
};
const ready = () => {
if (xWidgetsReady(globalRef.twttr)) finish();
else finish(new Error('X embed API did not initialize.'));
else finish(new Error(msg('X embed API did not initialize.')));
};
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
if (!script) {
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('X embed could not load.')),
() => finish(new Error(msg('X embed could not load.'))),
{
once: true,
},
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
},
);
timer = setTimeout(
() => finish(new Error('X embed timed out.')),
() => finish(new Error(msg('X embed timed out.'))),
EMBED_TIMEOUT_MS,
);
}).catch((error) => {
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
return _xWidgetsPromise;
}
// Outcomes a trimmed YouTube clip reports to the card status.
const YOUTUBE_PHASE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
timeout: msg('TIMEOUT'),
blocked: msg('BLOCKED'),
});
function providerLabel(provider) {
return (
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
t('SOURCE')
);
}
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
globalRef.setTimeout?.(() => {
if (!session.active || session.ready) return;
session.error = new Error(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
msg(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
),
);
session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = 'LOADING X POST…';
current.status.textContent = t('LOADING X POST…');
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
);
if (token !== generation || record !== current) return;
if (!widget)
throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
position();
}
}
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
);
};
if (session.ready) session.onReady();
else if (session.error) session.onError(session.error);
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
const leader = documentRef.createElementNS(
'http://www.w3.org/2000/svg',
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = String(
observation?.shortTitle || observation?.title || 'Witness source',
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'OPEN ORIGINAL ↗';
link.textContent = t('OPEN ORIGINAL ↗');
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
}
position();
},
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
},
});
}
+266 -73
View File
@@ -19,11 +19,13 @@ import {
SOURCE_PATH_BEAT_IDS,
} from './bhoteKoshiShotPaths.js';
import {
bhoteEventText,
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -384,13 +386,94 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
return Math.max(0, Math.min(beats.length - 1, current + delta));
}
// Evidence phases from the event pack, shown as the beat meta prefix.
const EVIDENCE_PHASE_LABELS = Object.freeze({
initiation: msg('INITIATION'),
damming: msg('DAMMING'),
'secondary failure': msg('SECONDARY FAILURE'),
'border arrival': msg('BORDER ARRIVAL'),
propagation: msg('PROPAGATION'),
'downstream passage': msg('DOWNSTREAM PASSAGE'),
consequence: msg('CONSEQUENCE'),
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
});
/** Display label for an evidence phase; unknown phases show as authored. */
function evidencePhaseLabel(phase) {
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
if (label) return t(label);
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
}
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** 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 });
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();
}
/**
* Localized name and date of an imagery endpoint for display.
* @param {{label?: string, observedAt?: string}} entry
* @returns {{name: string, date: string}}
*/
export function imageryEndpointLabel(entry) {
const label = String(entry?.label || '');
const split = label.lastIndexOf(' · ');
const authoredName = split >= 0 ? label.slice(0, split) : label;
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
let name = authoredName;
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
const match = pattern.exec(authoredName);
if (match) name = t(message, { year: match[1] });
}
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
}
function imageryEndpointText(entry) {
const { name, date } = imageryEndpointLabel(entry);
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
return count > 1
? tn(
count,
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
)
: '';
}
/** Interpolate headings across north using the shortest angular path. */
@@ -641,7 +724,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
}
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
ctx.fillText('NO PREVIEW', 20, 30);
ctx.fillText(t('NO PREVIEW'), 20, 30);
}
function ellipsizeEvidenceText(value, limit) {
@@ -650,6 +733,19 @@ function ellipsizeEvidenceText(value, limit) {
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
}
// The pack names the kind of source in `media.publisher` (event.json); the
// frame shows it in the UI language. Any other publisher shows as given.
const EVIDENCE_PUBLISHER_TEXT = new Set([
msg('YouTube source'),
msg('Facebook source'),
]);
/** An evidence item's publisher as its frame shows it. */
function evidencePublisherText(publisher) {
const text = String(publisher);
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
}
/** Repaint one stable frame with a poster or the current source-video frame. */
export function updateEvidenceFrame(
canvas,
@@ -704,25 +800,78 @@ export function updateEvidenceFrame(
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
ctx.fillText(
ellipsizeEvidenceText(
observation.shortTitle || observation.title,
bhoteEventText(observation.shortTitle || observation.title),
layout.titleChars,
).toUpperCase(),
16,
infoY + 27,
);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
const publisher = String(
observation.media?.publisher || 'SOURCE LINK',
const publisher = (
observation.media?.publisher
? evidencePublisherText(observation.media.publisher)
: t('SOURCE LINK')
).toUpperCase();
ctx.fillText(
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
fitEvidenceSourceLine(
ctx,
publisher,
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
canvas.width - 32,
),
16,
infoY + 53,
);
return true;
}
// The smallest the source line's font may shrink to before the publisher is
// shortened instead.
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
/**
* Set the font for the «{publisher} / OPEN» line and return a line that fits
* `maxWidth`. Translations run longer: first shrink the font (to 80%), then
* shorten the publisher; «/ OPEN» always stays whole.
* @param {CanvasRenderingContext2D} ctx
* @param {string} publisher Display publisher, already upper-cased.
* @param {string} font CSS font with a px size.
* @param {number} maxWidth
* @returns {string}
*/
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
const line = (limit) =>
t('{publisher} / OPEN', {
publisher: ellipsizeEvidenceText(publisher, limit),
});
let limit = 24;
let text = line(limit);
ctx.font = font;
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
const width = () => Number(ctx.measureText(text)?.width) || 0;
if (width() <= maxWidth) return text;
const size = /(\d+(?:\.\d+)?)px/.exec(font);
if (size) {
const base = Number(size[1]);
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
// Width scales with the font size; step down until it fits or bottoms out.
let next = Math.max(
floor,
Math.floor(((base * maxWidth) / width()) * 10) / 10,
);
for (;;) {
ctx.font = font.replace(size[0], `${next}px`);
if (width() <= maxWidth || next <= floor) break;
next = Math.max(floor, next - 0.5);
}
}
while (width() > maxWidth && limit > 4) {
limit = Math.min(limit, publisher.length) - 1;
text = line(limit);
}
return text;
}
/** Compose source imagery and caveats into one stable renderer-owned frame. */
export function createEvidenceFrame(observation, poster = null, options = {}) {
const canvas = evidenceFrameCanvas(observation);
@@ -813,28 +962,40 @@ function collectPanelReferences(panel) {
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
@@ -842,44 +1003,44 @@ function createPanel(event, handlers) {
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">▶ PLAY</button>
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
<button type="button" data-action="story-replay">↺ FULL STORY</button>
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
<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"> 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);
refs.beforeDate.textContent = event.imagery.before.label;
refs.afterDate.textContent = event.imagery.after.label;
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = event.reconstruction.caveat;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
@@ -887,8 +1048,8 @@ function createPanel(event, handlers) {
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = report.label;
status.textContent = report.status;
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
@@ -1205,7 +1366,7 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.show?.({
observation: {
title: witness.title,
shortTitle: 'Rasuwagadhi witness',
shortTitle: t('Rasuwagadhi witness'),
media: { sourceUrl: witness.sourceUrl },
},
anchor,
@@ -2320,7 +2481,10 @@ export function createBhoteKoshiEventLayer({
collisionGroup: 'ambient-card',
zIndex: 60,
interactive: true,
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
accessibilityLabel: t(
'Open original source for {source}; capture time unverified',
{ source: bhoteEventText(observation.title) },
),
activate: () => openExternal(observation.media.sourceUrl),
minDistance: 0,
maxDistance: 220000,
@@ -2353,7 +2517,7 @@ export function createBhoteKoshiEventLayer({
position: evidencePositionGetter(index, true),
cullPosition: anchor,
variant: 'label',
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
details: [],
accent: WORLD_OVERLAY_STYLE.accent,
priority: 80_000 - index,
@@ -2559,11 +2723,14 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
t(
'A historical reference {before} percent, B post-event {after} percent',
{ before: beforePercent, after: afterPercent },
),
onChange: setSplit,
});
}
@@ -2595,7 +2762,7 @@ export function createBhoteKoshiEventLayer({
setElementProperty(
button,
'textContent',
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
}
@@ -2961,13 +3128,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.progressInput,
'aria-label',
sceneDirected
? 'Seek Nepal scene clock'
: 'Schematic downstream progression',
? t('Seek Nepal scene clock')
: t('Schematic downstream progression'),
);
setElementProperty(
_panelRefs.timelineLabel,
'textContent',
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
);
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
setElementProperty(
@@ -2980,22 +3147,22 @@ export function createBhoteKoshiEventLayer({
);
const playLabel = sceneDirected
? _sceneActionPending && _sceneActionPending.type !== 'scene'
? '… PLAYING SHOT'
: '▶ PLAY SHOT'
? t('… PLAYING SHOT')
: t('▶ PLAY SHOT')
: _playing
? 'Ⅱ PAUSE'
? t('Ⅱ PAUSE')
: _progress >= 1
? _cinematicReplayArmed
? '↺ REPLAY CINEMATIC'
: '↺ REPLAY'
: '▶ PLAY';
? t('↺ REPLAY CINEMATIC')
: t('↺ REPLAY')
: t('▶ PLAY');
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
setElementProperty(
_panelRefs.playSceneButton,
'textContent',
_sceneActionPending?.type === 'scene'
? '… PLAYING SCENE'
: '▶ PLAY SCENE',
? t('… PLAYING SCENE')
: t('▶ PLAY SCENE'),
);
setElementProperty(
_panelRefs.playSceneButton,
@@ -3011,19 +3178,24 @@ export function createBhoteKoshiEventLayer({
setElementAttribute(
_panelRefs.playSceneButton,
'aria-label',
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
t('Continue {scene} from next shot', {
scene:
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('current scene'),
}),
);
setElementProperty(
_panelRefs.storyReplayButton,
'textContent',
'↺ FULL STORY',
t('↺ FULL STORY'),
);
if (!sceneDirected) {
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
setElementAttribute(
_panelRefs.storyReplayButton,
'aria-label',
'Replay the full reconstruction',
t('Replay the full reconstruction'),
);
}
const beatIndex = currentEvidenceIndex();
@@ -3047,18 +3219,26 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatTitle,
'textContent',
sceneDirected
? _sceneContext?.shotTitle || 'SCENE SHOT'
: observation?.shortTitle || observation?.title || 'EVIDENCE',
? _sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('SCENE SHOT')
: observation?.shortTitle || observation?.title
? bhoteEventText(observation.shortTitle || observation.title)
: t('EVIDENCE'),
);
const sourceCount = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
const beatMeta = [
String(observation?.phase || 'EVIDENCE').toUpperCase(),
'TIME UNVERIFIED',
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
evidencePhaseLabel(observation?.phase),
t('TIME UNVERIFIED'),
sourceCount > 1
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
: '',
observation?.imageryCoverage === 'outside'
? t('OUTSIDE IMAGERY SWIPE')
: '',
]
.filter(Boolean)
.join(' / ');
@@ -3066,7 +3246,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatMeta,
'textContent',
sceneDirected
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
? t('{scene} / AUTHORED SHOT', {
scene: String(
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('NEPAL FLOOD INCIDENT'),
).toUpperCase(),
})
: beatMeta,
);
setElementProperty(
@@ -3093,18 +3279,23 @@ export function createBhoteKoshiEventLayer({
_panelRefs.playButton,
'aria-label',
sceneDirected
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
: 'Play reconstruction',
? t('Play {shot}', {
shot:
_sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('current scene shot'),
})
: t('Play reconstruction'),
);
setElementAttribute(
_panelRefs.previousButton,
'aria-label',
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
);
setElementAttribute(
_panelRefs.nextButton,
'aria-label',
sceneDirected ? 'Load next scene shot' : 'Next story beat',
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
);
setElementProperty(
_panelRefs.openSourceButton,
@@ -3153,8 +3344,8 @@ export function createBhoteKoshiEventLayer({
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
? _event?.reconstruction?.caveat || ''
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
? bhoteEventText(_event?.reconstruction?.caveat || '')
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
}
if (_panelRefs?.imageryCloudNote)
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
@@ -3717,7 +3908,9 @@ export function createBhoteKoshiEventLayer({
count: _enabled ? 1 : 0,
status: _enabled ? 'nominal' : 'idle',
source: 'Vantor + GeoPera',
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
coverage: _enabled
? t('Rasuwa · 26 Aug 2026')
: t('Event reconstruction'),
};
},
getPlaybackState() {
+120
View File
@@ -0,0 +1,120 @@
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
// a long translation shrinks to fit the portrait card, action word whole.
// English is drawn at full size.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
const eventUrl = new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
// JetBrains Mono advances every glyph by 0.6 em.
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
const measure = (text, font) => text.length * 0.6 * fontPx(font);
function drawSourceLine(observation, publisher) {
const layout = evidenceCardLayout(observation);
const drawn = [];
const context = {
font: '',
beginPath() {},
clearRect() {},
moveTo() {},
lineTo() {},
stroke() {},
fillRect() {},
drawImage() {},
measureText(text) {
return { width: measure(String(text), this.font) };
},
fillText(value, x) {
drawn.push({ text: String(value), font: this.font, x });
},
};
const canvas = {
width: layout.canvasWidth,
height: layout.canvasHeight,
_bhoteEvidenceLayout: layout,
getContext: () => context,
};
const subject = publisher
? { ...observation, media: { ...observation.media, publisher } }
: observation;
assert.equal(updateEvidenceFrame(canvas, subject), true);
const line = drawn.find((entry) => entry.text.includes(' / '));
assert.ok(line, 'source line drawn');
return { ...line, maxWidth: layout.canvasWidth - 32 };
}
async function portraitObservations() {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const portrait = event.evidenceSpine.filter(
(o) => evidenceCardLayout(o).kind === 'portrait',
);
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
const facebook = portrait.find(
(o) => o.media?.publisher === 'Facebook source',
);
assert.ok(youtube, 'a portrait YouTube card exists');
return [youtube, facebook].filter(Boolean);
}
test('English source lines draw unchanged on every card', async () => {
setLocale('en');
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
for (const observation of event.evidenceSpine) {
const line = drawSourceLine(observation);
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
assert.match(
line.text,
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
);
}
});
test('ru/es source lines fit the portrait card with the action word whole', async () => {
const observations = await portraitObservations();
try {
for (const [locale, action] of [
['ru', '/ ОТКРЫТЬ'],
['es', '/ ABRIR'],
]) {
setLocale(locale);
for (const observation of observations) {
const line = drawSourceLine(observation);
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
assert.ok(
measure(line.text, line.font) <= line.maxWidth,
`${locale}: «${line.text}» at ${line.font} overflows`,
);
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
}
}
setLocale('ru');
const ru = drawSourceLine(observations[0]);
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
} finally {
setLocale('en');
}
});
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
const [observation] = await portraitObservations();
try {
setLocale('ru');
const line = drawSourceLine(
observation,
'A very long regional broadcaster',
);
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
assert.equal(fontPx(line.font), 21 * 0.8);
} finally {
setLocale('en');
}
});
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
// The publisher on the evidence frame ("YouTube source") is pack text shown
// in the UI language; English stays "YOUTUBE SOURCE / OPEN".
import { setLocale } from '../i18n/index.js';
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
const eventUrl = new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
function captureFrameText(observation) {
const originalDocument = globalThis.document;
const text = [];
globalThis.document = {
createElement() {
const context = {
beginPath() {},
clearRect() {},
moveTo() {},
lineTo() {},
stroke() {},
fillRect() {},
fillText(value) {
text.push(String(value));
},
drawImage() {},
};
return { width: 0, height: 0, getContext: () => context };
},
};
try {
assert.ok(createEvidenceFrame(observation));
} finally {
globalThis.document = originalDocument;
}
return text;
}
test('evidence card source line is fully in the UI language (es, ru)', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const youtube = event.evidenceSpine.find(
(o) => o.media?.publisher === 'YouTube source',
);
const facebook = event.evidenceSpine.find(
(o) => o.media?.publisher === 'Facebook source',
);
assert.ok(youtube && facebook);
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const observation of [youtube, facebook]) {
const lines = captureFrameText(observation);
const sourceLine = lines.find((line) => line.includes(' / '));
assert.ok(sourceLine, 'source line drawn');
assert.doesNotMatch(
sourceLine,
/SOURCE/,
`${locale}: English "SOURCE" left in "${sourceLine}"`,
);
}
}
} finally {
setLocale('en');
}
});
test('evidence card source line keeps its English wording', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const youtube = event.evidenceSpine.find(
(o) => o.media?.publisher === 'YouTube source',
);
setLocale('en');
const sourceLine = captureFrameText(youtube).find((line) =>
line.includes(' / '),
);
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
});
+61 -30
View File
@@ -12,8 +12,9 @@ import {
} from './dataCredits.js';
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';
@@ -34,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;
@@ -51,23 +51,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
const TRIGGER_SOURCE_URL =
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
// Descriptive pin titles translate at display (same entries as the event
// panel); place names stay Latin, anything else shows as authored.
const PLACE_TITLE_TEXT = new Map(
[
msg('Collapse viewpoint'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Border gate'),
msg('Dandagaun viewpoint'),
msg('Bidur bridge'),
msg('Devighat bridge'),
].map((text) => [text.toUpperCase(), text]),
);
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
}
const INCIDENT = Object.freeze({
lat: 28.3632333,
lon: 85.4423639,
heightM: 5200,
title: 'BHOTE KOSHI FLOOD INCIDENT',
subtitle: 'NEPAL · 26 AUG 2026',
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
subtitle: msg('NEPAL · 26 AUG 2026'),
});
const TRIGGER_RECORD = Object.freeze({
lat: 28.271,
lon: 85.515,
heightM: 4200,
title: 'USGS INITIAL REPORT',
title: msg('USGS INITIAL REPORT'),
details: Object.freeze([
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
msg('26 AUG 2026 · 08:37 NPT'),
msg('28.2710° N · 85.5150° E'),
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
]),
});
const TRIGGER_APPROACH_CAMERA = Object.freeze({
@@ -90,11 +109,13 @@ const NEPAL = Object.freeze({
lat: 28.3949,
lon: 84.124,
heightM: 6800,
// i18n-ignore: country name, shown as is
title: 'NEPAL',
});
const NEARBY_CITIES = Object.freeze([
Object.freeze({
id: 'kathmandu',
// i18n-ignore: city name
title: 'KATHMANDU',
lat: 27.7017,
lon: 85.3206,
@@ -103,6 +124,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'bhaktapur',
// i18n-ignore: city name
title: 'BHAKTAPUR',
lat: 27.673,
lon: 85.43,
@@ -111,6 +133,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'dhulikhel',
// i18n-ignore: city name
title: 'DHULIKHEL',
lat: 27.6221,
lon: 85.5428,
@@ -394,8 +417,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: subject.title,
details: [subject.subtitle],
title: t(subject.title),
details: [t(subject.subtitle)],
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -536,8 +559,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: TRIGGER_RECORD.title,
details: [...TRIGGER_RECORD.details],
title: t(TRIGGER_RECORD.title),
details: TRIGGER_RECORD.details.map((line) => t(line)),
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -549,8 +572,9 @@ export function createBhoteKoshiLocatorLayer({
protected: true,
active: true,
interactive: true,
accessibilityLabel:
accessibilityLabel: t(
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
),
activate: () => {
const opened = window.open(
TRIGGER_SOURCE_URL,
@@ -815,7 +839,7 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'label',
paintLane: 'ambient-label',
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
details: [],
accent: INCIDENT_ACCENT,
sourceAlpha: progress,
@@ -1121,57 +1145,67 @@ export function createBhoteKoshiLocatorLayer({
chips: [
{
id: 'nepal-context',
// i18n-ignore: country name
label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
title:
title: t(
'Show the Nepal context callout without a border or country fill',
),
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-regional',
// i18n-ignore: river name (Bhote Koshi)
label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
title:
title: t(
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
),
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
},
{
id: 'bhote-koshi-city-context',
label: 'CITIES',
label: t('CITIES'),
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
title:
title: t(
'Keep the regional incident context and introduce nearby city labels',
),
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-incident-places',
label: 'PLACES',
label: t('PLACES'),
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
title:
title: t(
'Introduce the reconstruction scene key places from trigger to downstream',
),
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
},
{
id: 'bhote-koshi-flood-path',
label: 'PATH',
label: t('PATH'),
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
title:
title: t(
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
),
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
},
{
id: 'bhote-koshi-path-overview',
label: 'OVERVIEW',
label: t('OVERVIEW'),
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
title: 'Keep the completed flood path and location labels visible',
title: t(
'Keep the completed flood path and location labels visible',
),
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
},
{
id: 'bhote-koshi-trigger-record',
label: 'RECORD',
label: t('RECORD'),
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
title:
title: t(
'Reveal the trigger record while making a slow camera approach',
),
params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
@@ -1213,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({
},
};
}
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
export default bhoteKoshiLocatorLayer;
+408 -121
View File
@@ -1,9 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import {
INCIDENT_OVERVIEW_HOLD_SEC,
INCIDENT_OVERVIEW_PLACES,
} from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { setLocale } from '../i18n/index.js';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
@@ -29,8 +33,12 @@ function viewerFixture() {
return {
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
add(source) {
added.push(source);
},
remove(source) {
removed.push(source);
},
},
_test: { added, removed },
};
@@ -38,7 +46,9 @@ function viewerFixture() {
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
scheduleFrame() {
return 1;
},
cancelFrame() {},
};
}
@@ -58,24 +68,68 @@ function overlayHostFixture() {
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[88, 30],
]),
[
[80, 26],
[88, 26],
[88, 30],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[80, 26],
]),
[
[80, 26],
[88, 26],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
['bad', 27],
]),
[],
);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
const main = [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
[80, 26],
];
const island = [
[82, 25],
[83, 25],
[82, 25],
];
assert.equal(
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
main,
);
assert.equal(
largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}),
main,
);
assert.deepEqual(
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
[],
);
});
test('Bhote Koshi locator reveals a border progressively', () => {
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0), [
positions[0],
positions[0],
]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const nepal = publication.entries.find(
({ id }) => id === 'nepal-context-callout',
);
const incident = publication.entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
});
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(
overlayHost._test.clears.at(-1),
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
);
assert.equal(layer.getStats().status, 'idle');
});
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
assert.deepEqual(
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
[
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
],
);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(
overlayHost._test.publications
.at(-1)
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
await layer.destroy();
});
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
scheduleFrame(callback) {
pulseFrame = callback;
return 1;
},
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const event = JSON.parse(
await readFile(
new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
),
'utf8',
),
);
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
assert.equal(
source.entities.values.filter(
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
).length,
1,
);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(
source.entities
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
.show.getValue(),
true,
);
assert.equal(
labels
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
.sourceAlpha(),
1,
);
assert.equal(
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
false,
);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
assert.equal(
source.entities.values.some((entity) =>
entity.id.startsWith('bhote-koshi-place-'),
),
false,
);
await layer.destroy();
});
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => 0,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const titles = new Map(
overlayHost._test.publications
.at(-1)
.entries.map(({ id, title }) => [id, title]),
);
assert.equal(
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
'01 · ВИД НА ОБРУШЕНИЕ',
);
assert.equal(
titles.get('bhote-koshi-place-gyirong-border-gate'),
'04 · ПОГРАНПЕРЕХОД',
);
assert.equal(
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
);
assert.equal(
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
'14 · МОСТ BIDUR',
);
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
await layer.destroy();
});
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
const trigger = labels.find(
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
);
const bidur = labels.find(
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
);
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
assert.ok(
path.polyline.material.color
.getValue()
.equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
),
);
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(
path.polyline.positions.getValue(),
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat),
),
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
});
await layer.destroy();
});
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.ok(
Cesium.Cartesian3.equals(
path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
),
);
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
source.entities.getById(
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
});
await layer.destroy();
});
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.deepEqual(
source.entities.values.map(({ id }) => id),
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
moveStart,
moveEnd,
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
cancelDelay(id) {
timers.delete(id);
},
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_clock: createPlaybackClock({
isRunning: () => false,
timingForShot: () => null,
onProgress() {},
}),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
assert.equal(
flights.length,
oldFlights.length,
'no post-Stop approach or orbit',
);
assert.equal(
layer.getStats().count,
1,
'Stop preserves the visible locator',
);
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
for (const flight of oldFlights) {
flight.cancel();
flight.complete();
}
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
assert.equal(
flights.length,
count + 1,
'successor retains its own orbit',
);
} finally {
await layer.destroy();
}
});
}
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
const incident = entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
const kathmandu = entries.find(
({ id }) => id === 'bhote-koshi-city-kathmandu',
);
const bhaktapur = entries.find(
({ id }) => id === 'bhote-koshi-city-bhaktapur',
);
const dhulikhel = entries.find(
({ id }) => id === 'bhote-koshi-city-dhulikhel',
);
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.ok(
[kathmandu, bhaktapur, dhulikhel].every(
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
),
);
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
});
await layer.destroy();
});
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
const ids = viewer._test.added[0].entities.values.map(
(entity) => entity.id,
);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('nepal-border-highlight'),
undefined,
);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
assert.deepEqual(
overlayHost._test.publications[0].entries.map(({ id }) => id),
['nepal-context-callout'],
);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
});
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
);
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
await layer.destroy();
});
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
],
});
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
-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;
+22 -8
View File
@@ -4,24 +4,38 @@
* The browser fetches the file from `url` — a `new URL('./….json',
* import.meta.url)` written in the calling module, which Vite serves as-is in
* dev and emits as an asset in the production build. Under Node (node:test)
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
* JSON-attributed import runs instead.
* that URL is a `file:` URL, which `fetch` cannot read, so the file is read
* from disk instead.
*
* The attributed import cannot serve the browser: the Vite dev server answers
* it with a JavaScript module, which the browser rejects for a JSON import.
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
* serve the browser (the Vite dev server answers it with a JavaScript module,
* which the browser rejects), and a literal `import('./….json')` makes the
* production build emit a JavaScript copy of every pack that the browser
* never loads.
*
* @module data/bundledJson
*/
/**
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
* @param {() => Promise<{default: any}>} importJson - The caller's literal
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<any>} The parsed JSON.
*/
export async function loadBundledJson(url, importJson, { signal } = {}) {
if (url.protocol === 'file:') return (await importJson()).default;
export async function loadBundledJson(url, { signal } = {}) {
signal?.throwIfAborted();
if (url.protocol === 'file:') {
// Node's built-in, reached without an import, so the browser build never
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
// read is synchronous so the result settles as promptly as the cached
// module import it replaces; callers already memoize each pack.
const getBuiltinModule = globalThis.process?.getBuiltinModule;
if (typeof getBuiltinModule !== 'function')
throw new Error(
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
);
const { readFileSync } = getBuiltinModule('node:fs');
return JSON.parse(readFileSync(url, 'utf8'));
}
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
+57 -10
View File
@@ -2,13 +2,14 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadBundledJson } from './bundledJson.js';
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
const importMarine = () =>
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
const MARINE = new URL(
'./local_data/natural_earth/marine.json',
import.meta.url,
);
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
const fetchMock = t.mock.method(globalThis, 'fetch');
const pack = await loadBundledJson(MARINE, importMarine);
const pack = await loadBundledJson(MARINE);
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
assert.equal(fetchMock.mock.callCount(), 0);
});
@@ -21,16 +22,62 @@ test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
headers: { 'content-type': 'application/json' },
});
});
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
const importJson = () => assert.fail('the browser path must not import JSON');
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
const url = new URL(
'http://localhost/src/data/local_data/natural_earth/marine.json',
);
assert.deepEqual(await loadBundledJson(url), { features: [] });
assert.deepEqual(requests, [url.href]);
});
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
t.mock.method(
globalThis,
'fetch',
async () => new Response('', { status: 404 }),
);
await assert.rejects(
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
loadBundledJson(new URL('http://localhost/missing.json')),
/HTTP 404/,
);
});
test('bundled JSON: an aborted load rejects before reading', async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
name: 'AbortError',
});
});
test('bundled JSON: no data pack is imported as a module', async () => {
// A literal JSON import in app code makes the production build emit a
// JavaScript copy of the pack beside the fetched JSON, which the browser
// never loads. Tests are not bundled, so they may still import JSON.
const { spawnSync } = await import('node:child_process');
const result = spawnSync(
'git',
[
'grep',
'-nE',
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
'--',
'src',
':!*.test.mjs',
],
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
);
// git grep exits 1 when nothing matches.
assert.equal(result.status, 1, result.stdout);
});
test('bundled JSON: an older Node says which version is needed', async (t) => {
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
const original = process.getBuiltinModule;
t.after(() => {
process.getBuiltinModule = original;
});
process.getBuiltinModule = undefined;
await assert.rejects(loadBundledJson(MARINE), {
message: /needs Node 24\.14 or newer to read marine\.json/,
});
});
+15
View File
@@ -0,0 +1,15 @@
import * as Cesium from 'cesium';
/** Camera range with breathing room in both viewport dimensions. */
export function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera?.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
+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(
+247
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';
@@ -128,6 +129,14 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
sources.slice(0, 3).map((s) => s.id),
['env-0', 'env-1', 'env-2'],
);
assert.equal(sources[0].pack, 'env');
const [trimmed] = getSources.trimmedPacks();
assert.deepEqual(
{ ...trimmed, region: undefined },
{ pack: 'env', available: 12, served: 9, region: undefined },
);
assert.equal(trimmed.region.south, 30);
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
@@ -135,3 +144,241 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
Object.assign(process.env, saved);
}
});
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
const { prioritizeSources } =
await import('../../server/providers/cctv/normalize.js');
const cameras = Array.from({ length: 5 }, (_, i) => ({
id: `c${i}`,
lat: i,
lon: 0,
}));
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
assert.deepEqual(
kept.map((camera) => camera.id),
['c0', 'c1'],
);
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);
}
});
+683
View File
@@ -0,0 +1,683 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
CATALONIA_CAMERAS_URL,
CATALONIA_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
import {
parseCataloniaXml,
normalizeCataloniaImageUrl,
cataloniaCameraName,
loadCataloniaSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/**
* 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('<?xml'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One `<gml:featureMember>` block matching the real feed's shape. */
function cataloniaFeature({
fid,
lon,
lat,
carretera = '',
municipi = '',
pk = '',
link = '',
font = '',
}) {
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
return (
`<gml:featureMember><cite:cameres fid="${fid}">` +
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
`</gml:Point></cite:geom>` +
`<cite:carretera>${carretera}</cite:carretera>` +
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
`<cite:link>${link}</cite:link>` +
`<cite:font>${font}</cite:font>` +
`</cite:cameres></gml:featureMember>`
);
}
function cataloniaFeed(features) {
return (
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
features.join('') +
'</wfs:FeatureCollection>'
);
}
test('parseCataloniaXml extracts every field from a feature block', () => {
const xml = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
municipi: 'Nus Trinitat',
pk: '0.50',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]);
const rows = parseCataloniaXml(xml);
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
lon: 2.1849528,
lat: 41.45989301,
carretera: 'C-58',
municipi: 'Nus Trinitat',
pk: '0.50',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
});
});
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
// RenderService always 302s to a hardcoded http:// Location (which
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
// setup fails the handshake with Node's fetch regardless — TransitCamera
// over plain http is the only transport that actually works here, so we
// build that URL from RenderService's sctidcam id instead of registering
// the redirector.
assert.equal(
normalizeCataloniaImageUrl(
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
),
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
);
assert.equal(
normalizeCataloniaImageUrl(
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
),
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
'an already-https RenderService link is rewritten the same way',
);
assert.equal(
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
'',
'a RenderService link with no sctidcam id is rejected',
);
});
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
assert.equal(
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
'https://www.bcn.cat/transit/imatges/a.gif',
);
assert.equal(
normalizeCataloniaImageUrl(
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
),
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
);
assert.equal(
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
'',
'a look-alike host is rejected',
);
assert.equal(
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
'',
'any host outside the allowlist is rejected',
);
assert.equal(normalizeCataloniaImageUrl(''), '');
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
});
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
// The hostname matches the allowlist, so only an explicit userinfo check
// stops catalog data injecting credentials into the frame request.
for (const link of [
'https://user:[email protected]/transit/imatges/a.gif',
'http://[email protected]/transit/imatges/a.gif',
'https://:[email protected]/it_terrassa/cam02.jpeg',
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
]) {
assert.equal(normalizeCataloniaImageUrl(link), '', link);
}
});
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
// An allowlisted hostname alone would let catalog data aim the frame
// request at another service or path on that host.
for (const link of [
'http://www.bcn.cat:8080/transit/imatges/a.gif',
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
'http://www.bcn.cat/admin/a.gif',
'http://www.bcn.cat/transit/imatges/',
'http://www.bcn.cat/transit/imatges/sub/a.gif',
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
'https://emap.terrassa.cat/other/cam02.jpeg',
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
]) {
assert.equal(normalizeCataloniaImageUrl(link), '', link);
}
});
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
for (const camId of [
'../../etc/passwd',
'..%2F..%2Fetc%2Fpasswd',
'nc87.png',
'nc87',
'nc87.gif/../x.gif',
'nc87.gif&nom=other.gif',
'nc-87.gif',
]) {
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
}
assert.equal(
normalizeCataloniaImageUrl(
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
),
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
);
});
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
assert.equal(decodeNumericEntities('Llu&#237;s'), 'Lluís');
assert.equal(decodeNumericEntities('Llu&#xED;s &#x1F6A6;'), 'Lluís 🚦');
assert.equal(decodeNumericEntities('Llu&#XeD;s'), 'Lluís');
for (const raw of [
'a&#99999999999999999999999;b',
'a&#1114112;b',
'a&#x110000;b',
'a&#xFFFFFFFFFFFFFFFFFFFF;b',
'a&#55296;b',
'a&#xDFFF;b',
'a&#0;b',
]) {
assert.equal(decodeNumericEntities(raw), raw, raw);
}
});
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
assert.equal(
cataloniaCameraName('C-58', 'Nus Trinitat'),
'C-58 (Nus Trinitat)',
);
assert.equal(
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
'Plaça Urquinaona (Barcelona)',
);
assert.equal(
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
'Barcelona Ronda Litoral',
);
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
});
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
const xml = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-1',
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
municipi: 'Nus Trinitat',
pk: '0.50',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: 'cameres.fid-2',
lon: '2.17228212',
lat: '41.38890733',
carretera: 'Plaça Urquinaona',
municipi: 'Barcelona',
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
font: 'IMI',
}),
cataloniaFeature({
fid: 'cameres.fid-3',
lon: '2.0111',
lat: '41.5636',
carretera: 'Cam02',
municipi: 'Terrassa',
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
font: 'Terrassa',
}),
// Rejected: link host not in the allowlist.
cataloniaFeature({
fid: 'cameres.fid-4',
lon: '2.0',
lat: '41.5',
link: 'https://example.com/fake.gif',
font: 'SCT',
}),
// Rejected: outside the Catalonia bounding box.
cataloniaFeature({
fid: 'cameres.fid-5',
lon: '-3.7',
lat: '40.4',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
font: 'SCT',
}),
]);
return new Response(xml, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
const cameras = await loadCataloniaSourcesFromOpenData();
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
assert.ok(
CATALONIA_CAMERAS_URL.startsWith('https://'),
'the catalog is fetched over https',
);
assert.deepEqual(
cameras.map((camera) => camera.id).sort(),
[
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
'cat-sct-nc87',
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
],
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
);
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
assert.equal(sct.provider, 'Servei Català de Trànsit');
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
assert.equal(
sct.url,
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
);
assert.equal(sct.groundElevationM, 80);
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
const bcn = cameras.find(
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
);
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
assert.equal(bcn.city, 'Barcelona');
assert.equal(bcn.groundElevationM, 20);
const terrassa = cameras.find(
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
);
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
assert.equal(terrassa.groundElevationM, 270);
});
test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
}
});
test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered or
// downgraded. Its body is a live stream, so the test also proves the
// refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// An HTTP error body is released too.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
const validFeed = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-1',
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]);
// A body that declares a size over the cap is refused rather than buffered,
// even though its actual content would parse.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(validFeed, {
headers: {
'Content-Type': 'application/xml',
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
// So is a body with no declared size that only turns out too long while
// streaming — a valid feed padded past the cap.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized =
validFeed + ' '.repeat(CATALONIA_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 loadCataloniaSourcesFromOpenData(), []);
});
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_CATALONIA_ENABLED: undefined,
CCTV_CATALONIA_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
if (String(url) !== CATALONIA_CAMERAS_URL) {
return new Response('unavailable', { status: 503 });
}
const xml = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-1',
lon: '2.1849528',
lat: '41.45989301',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]);
return new Response(xml, { status: 200 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['cat-sct-nc87'],
);
process.env.CCTV_CATALONIA_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
* under one upstream run's `fid` prefix. */
function snapshotFeed(fidPrefix) {
return cataloniaFeed([
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_386f`,
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_b48`,
lon: '2.18',
lat: '41.46',
carretera: 'C-31',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_1c2`,
lon: '2.17228212',
lat: '41.38890733',
carretera: 'Plaça Urquinaona',
municipi: 'Barcelona',
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
font: 'IMI',
}),
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_1c3`,
lon: '2.0111',
lat: '41.5636',
municipi: 'Terrassa',
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 &#13;',
font: 'Terrassa',
}),
]);
}
async function loadFromFeed(t, xml) {
t.mock.method(globalThis, 'fetch', async () => {
return new Response(xml, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
try {
return await loadCataloniaSourcesFromOpenData();
} finally {
t.mock.restoreAll();
}
}
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
// The fid embeds the upstream server-start stamp: the PR fixture's
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
assert.deepEqual(ids(before), [
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
'cat-sct-nc85',
'cat-sct-nc87',
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
]);
assert.deepEqual(ids(after), ids(before));
});
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
// different fids.
const row = (fid, camId) =>
cataloniaFeature({
fid,
lon: '2.18',
lat: '41.46',
carretera: 'C-31',
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
font: 'SCT',
});
const cameras = await loadFromFeed(
t,
cataloniaFeed([
row('cameres.fid-x_1', 'nc85.gif'),
row('cameres.fid-x_2', 'cb5.gif'),
row('cameres.fid-x_3', 'nc85.gif'),
row('cameres.fid-x_4', 'cb5.gif'),
row('cameres.fid-x_5', 'cb10.gif'),
]),
);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'cat-sct-cb10',
'cat-sct-cb5',
'cat-sct-nc85',
]);
});
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
const cameras = await loadFromFeed(
t,
cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-x_1',
lon: '2.17',
lat: '41.39',
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
font: 'IMI',
}),
cataloniaFeature({
fid: 'cameres.fid-x_2',
lon: '2.0111',
lat: '41.5636',
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
font: 'Terrassa',
}),
cataloniaFeature({
fid: 'cameres.fid-x_3',
lon: '2.18',
lat: '41.46',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
font: 'SCT',
}),
cataloniaFeature({
fid: 'cameres.fid-x_4',
lon: '2.18',
lat: '41.46',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]),
);
assert.deepEqual(
cameras.map((camera) => [camera.id, camera.url]),
[
[
'cat-sct-nc87',
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
],
],
);
});
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
const cameras = await loadFromFeed(
t,
cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-x_1',
lon: '2.18',
lat: '41.46',
carretera: 'C-31 &#99999999999999999999;',
municipi: 'Bad&#xD800;',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: 'cameres.fid-x_2',
lon: '2.18',
lat: '41.46',
carretera: 'Pla&#xE7;a Catalunya',
municipi: 'Llinars del Vall&#232;s',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]),
);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
assert.equal(cameras.length, 2);
assert.equal(
byId.get('cat-sct-nc85').name,
'C-31 &#99999999999999999999; (Bad&#xD800;)',
'an invalid reference is kept as written',
);
assert.equal(
byId.get('cat-sct-nc87').name,
'Plaça Catalunya (Llinars del Vallès)',
);
});
+3 -1
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
DEFAULT_CATALONIA_MAX_SOURCES,
DRIVEBC_WEBCAMS_URL,
} from '../../server/providers/cctv/constants.js';
import {
@@ -221,7 +222,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
);
});
test('default per-pack camera caps fit inside the default catalog cap', () => {
test('default caps of the original packs fit inside the default catalog cap', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
@@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => {
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
DEFAULT_NSW_MAX_SOURCES +
DEFAULT_CATALONIA_MAX_SOURCES +
1;
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
+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);
});
Loaded 100 of 1421 files, more files were not shown because too many files have changed in this diff. Show more