Merge origin/main into german/street-level-slim

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-19 19:56:09 +04:00
commit a536e7879c
56 files changed
+3271 -244

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/);
});
+376
View File
@@ -0,0 +1,376 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
floridaCameraId,
floridaCameraToSource,
loadFloridaSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
FLORIDA_CCTV_URL,
FLORIDA_MAX_CATALOG_BYTES,
FLORIDA_MAX_CATALOG_PAGES,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
isLikelyFloridaCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
/** A response with a live body, plus a flag that flips when it is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One FL511 feature, shaped like the live FeatureServer payload. */
const feature = (overrides = {}) => ({
attributes: {
OBJECTID_1: 6,
ID: '1220',
DESCRIPT: 'I-75 @ MM 346.5 SB',
COUNTY: 'Marion',
HIGHWAY: 'I-75',
DIRECTION: 'S',
LATITUDE: 29.107363,
LONGITUDE: -82.188334,
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
...overrides,
},
});
/** One catalog page of `count` distinct cameras, channels from `start`. */
const page = (start, count, extra = {}) => ({
features: Array.from({ length: count }, (_, i) =>
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
),
...extra,
});
const offsetOf = (url) =>
Number(new URL(String(url)).searchParams.get('resultOffset'));
/** Lift the pack cap for one test so every loaded camera comes back. */
const uncapped = (t) => {
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
t.after(() => {
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
});
};
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
const source = floridaCameraToSource(feature());
assert.equal(source.id, 'fl-9431');
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
assert.equal(source.city, 'Marion County');
assert.equal(source.cityId, 'florida');
assert.equal(source.provider, 'FL511');
assert.equal(source.lat, 29.107363);
assert.equal(source.lon, -82.188334);
assert.equal(source.headingDeg, 180);
assert.equal(
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
90,
);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.feedType, 'image');
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
assert.equal(source.url, source.snapshotUrl);
assert.equal(source.sourceKind, 'fl511-open-data');
assert.match(source.license, /personal use only/);
assert.match(source.license, /written consent/);
});
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
const source = floridaCameraToSource(
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
});
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
const dropped = [
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
feature({ LATITUDE: 0, LONGITUDE: 0 }),
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
feature({ LATITUDE: '', LONGITUDE: '' }),
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
feature({
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
}),
feature({ IMAGE: 'not a url' }),
{ attributes: null },
null,
];
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
});
test('camera ids come from the image channel, not the repeating ID column', () => {
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
const marion = floridaCameraToSource(feature());
const broward = floridaCameraToSource(
feature({
DESCRIPT: 'I-95 SB before E Cypress Rd',
COUNTY: 'Broward',
LATITUDE: 26.22006,
LONGITUDE: -80.13676,
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
}),
);
assert.equal(marion.id, 'fl-9431');
assert.equal(broward.id, 'fl-6351');
// Any resolution suffix keys the same way.
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
// A filename without a channel falls back to a stable path slug.
assert.equal(
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
'fl-us-1-20at-20sr-5',
);
assert.equal(floridaCameraId('not a url'), null);
});
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
const inside = [
[24.5551, -81.78], // Key West
[30.4213, -87.2169], // Pensacola
[30.99, -84.0], // Georgia line
[25.7617, -80.1918], // Miami
];
const outside = [
[33.749, -84.388], // Atlanta
[25.0443, -77.3504], // Nassau
[0, 0],
[-81.78, 24.5551], // swapped
[NaN, -81],
];
for (const [lat, lon] of inside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
for (const [lat, lon] of outside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
});
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
calls.push([offsetOf(url), init.redirect]);
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
// Last page (no exceededTransferLimit): two new cameras and a repeat.
return Response.json(page(1999, 3));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(calls, [
[0, 'manual'],
[2000, 'manual'],
]);
assert.equal(cameras.length, 2002);
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
});
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const offsets = [];
t.mock.method(globalThis, 'fetch', async (url) => {
const params = new URL(String(url)).searchParams;
// Offset paging is only stable over a fixed order on the object id.
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
const offset = offsetOf(url);
offsets.push(offset);
// A layer whose max record count was lowered to 1,000.
return Response.json(
page(offset, offset < 2000 ? 1000 : 500, {
exceededTransferLimit: offset < 2000,
}),
);
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(offsets, [0, 1000, 2000]);
assert.equal(cameras.length, 2500);
});
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
uncapped(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
assert.equal(cameras.length, 2000);
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
});
test('a redirect on any page drops the whole pack and releases it', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras' },
});
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: redirected.response,
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
});
test('a failed later page keeps the pages already read', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
uncapped(t);
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: failed.response,
);
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
// A network error on a later page keeps them too.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
throw new Error('network down');
});
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
});
test('an oversized catalog page is refused', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(JSON.stringify(page(0, 1)), {
headers: {
'Content-Type': 'application/json',
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
},
}),
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
// So is a body that only turns out too long while streaming.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
});
/**
* Serve a two-camera FL511 page to the Florida endpoint and an empty payload
* to every other pack, so one catalog refresh exercises the registration
* without reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
return Response.json([]);
});
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Put process.env back exactly as it was. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (key in saved) continue;
delete process.env[key];
}
Object.assign(process.env, saved);
};
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_FLORIDA_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
'the catalog refresh invokes the Florida loader',
);
assert.deepEqual(
sources
.filter((s) => s.cityId === 'florida')
.map((s) => s.id)
.sort(),
['fl-0', 'fl-1'],
'Florida cameras reach the served catalog through the registered lane',
);
} finally {
restoreEnv(saved);
}
});
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_FLORIDA_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'florida'),
[],
);
} finally {
restoreEnv(saved);
}
});
+8
View File
@@ -263,6 +263,14 @@ export const DATA_CREDITS = [
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'florida-cctv',
html:
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
'Individual, personal use only under the ' +
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
"any other use needs FDOT's express written consent",
},
{
key: 'catalonia-cctv',
html:
+6 -2
View File
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
const headers = {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
};
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
...headers,
Authorization: 'Token secret',
});
});
+4 -2
View File
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
}
export function launchLibraryRecentUrl(end) {
const start = new Date(end.getTime() - 30 * 86400000);
// Stable minute boundaries let different installations share LL2's cache.
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
url.searchParams.set('net__gte', start.toISOString());
url.searchParams.set('net__lte', end.toISOString());
url.searchParams.set('net__lte', roundedEnd.toISOString());
url.searchParams.set('limit', '100');
url.searchParams.set('mode', 'detailed');
return url;
+3 -40
View File
@@ -5,6 +5,9 @@
*/
import { t, msg } from '../i18n/index.js';
import { normalizeVesselType } from './vesselTypes.js';
export { normalizeVesselType };
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */
@@ -43,31 +46,6 @@ const TYPE_STYLES = [
];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
@@ -104,21 +82,6 @@ export function displayVesselType(type) {
return key ? t(key) : normalized;
}
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d{1,2}$/.test(text)) return text;
const code = Number(text);
if (code <= 0) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
/** AIS ship type → CSS hex hue for the billboard chevron. */
export function vesselTypeCss(type) {
return styleForType(type).css;
+2
View File
@@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => {
assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker');
assert.equal(normalizeVesselType('0'), '');
assert.equal(normalizeVesselType('25'), 'OTHER');
assert.equal(normalizeVesselType('100'), '');
assert.equal(normalizeVesselType('255'), '');
assert.equal(normalizeVesselType(undefined), '');
});
+47
View File
@@ -0,0 +1,47 @@
/**
* @module vesselTypes
* @description AIS ship-type codes (ITU-R M.1371) to family names, with no
* browser or i18n dependencies so the server-side query tools can use it.
*/
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged. Code 0
* ("not available") and the reserved codes 100 to 255 name no type and
* resolve to "".
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d+$/.test(text)) return text;
const code = Number(text);
if (code <= 0 || code >= 100) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
+2 -1
View File
@@ -842,7 +842,8 @@
"Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave se usa una simulación)",
"Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial",
"More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)",
"Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales",
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "El acceso público a misiones espaciales no necesita clave. Déjalo en blanco para el uso estándar; los tokens de pago solo aumentan los límites de solicitudes.",
"Optional paid token — public access works without one": "Token opcional de pago — el acceso público funciona sin él",
"Scene action failed": "La acción de escena falló",
"Project exported": "Proyecto exportado",
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
+2 -1
View File
@@ -74,7 +74,8 @@
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
"More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)",
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token).",
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Общедоступные данные космических миссий не требуют ключа. Для обычного использования оставьте поле пустым; платные токены только повышают лимиты запросов.",
"Optional paid token — public access works without one": "Необязательный платный токен — публичный доступ работает без него",
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
"Live ships, worldwide": "Суда в реальном времени по всему миру",
+4 -2
View File
@@ -215,7 +215,7 @@ export function stripKeylessBasemapFromHash(hash) {
}
}
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
function updateCloudVoiceAuthControl(auth, busy = false) {
const mode = readStoredCloudVoiceAuthMode();
@@ -259,7 +259,9 @@ function buildRow(documentRef, key) {
tier.title =
key.tier === 'metered'
? t('Metered — a billing-enabled account')
: t('Free key — register, paste, done');
: key.tier === 'paid'
? t('Optional paid token — public access works without one')
: t('Free key — register, paste, done');
head.append(led, title, tier);
if (key.clientExposed) {
const exposed = documentRef.createElement('span');
+7 -4
View File
@@ -29,7 +29,8 @@ export const KEY_SETUP_APPEND_HEADER =
/**
* Provider credentials, in display order — most magic per
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
* billing-enabled account, 'free' (🟡) is a register-and-paste key, and
* 'paid' (🔴) is an optional paid higher-rate token.
* `clientExposed` marks the keys that are injected into the browser
* bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count.
@@ -119,10 +120,12 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
unlocks: msg('Higher space-missions request allowance'),
getUrl: 'https://thespacedevs.com',
unlocks: msg(
'Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.',
),
getUrl: 'https://thespacedevs.com/supportus',
envVars: Object.freeze(['LL2_API_TOKEN']),
tier: 'free',
tier: 'paid',
}),
]);
+1
View File
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
stale: layerState._stale,
};
},
+11 -3
View File
@@ -168,9 +168,16 @@ export function createIngestion({
async function performMissionUpdate(token) {
try {
ensureActiveTleLookup(token);
const payload = await source.getLaunches({
signal: layerState._sourceController.signal,
});
const { payload, stale } = source.getLaunchSnapshot
? await source.getLaunchSnapshot({
signal: layerState._sourceController.signal,
})
: {
payload: await source.getLaunches({
signal: layerState._sourceController.signal,
}),
stale: false,
};
const launches = parts.model.normalizeRocketLaunches(payload);
if (
!layerState._enabled ||
@@ -214,6 +221,7 @@ export function createIngestion({
parts.panel.renderMissionPanel();
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
+315
View File
@@ -0,0 +1,315 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import * as Cesium from 'cesium';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createIngestion } from './ingestion.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
const noop = () => {};
async function fixture({ legacySource = false } = {}) {
const launch = {
id: 'owned-launch',
name: 'Owned mission fixture',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
const mode = { cache: 'HIT', results: [launch], hold: false };
let arrival;
let release;
const server = http.createServer((req, res) => {
if (req.url === '/api/celestrak/active') {
res.end('');
return;
}
assert.equal(req.url, '/api/launches');
const send = () => {
if (res.destroyed) return;
res.writeHead(200, {
'Content-Type': 'application/json',
'X-GEV-Cache': mode.cache,
});
res.end(JSON.stringify({ results: mode.results }));
};
if (mode.hold) {
release = send;
arrival?.();
} else send();
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const launchSource = createLaunchSource({
fetchImpl: (url, options) => {
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
return fetch(new URL(url, origin), options);
},
});
const source = legacySource
? {
getLaunches: launchSource.getLaunches,
getActiveTle: launchSource.getActiveTle,
}
: launchSource;
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
state._enabled = true;
state._lifecycleToken = 1;
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
});
const viewer = {
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
await viewer.dataSources.add(state._dataSource);
// Only presentation boundaries are inert. Source, normalization, state,
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
const parts = {
panel: {
createMissionPanel: noop,
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
},
replay: { stopMissionReplay: noop },
selection: {
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
overlays: {
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
name: record.name,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.model = createModel(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const current = () => {
const stats = parts.controls.methods.getStats();
return {
stats,
feed: layerFeedState(stats),
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
enabled: state._enabled,
};
};
return {
mode,
state,
source,
current,
viewer,
init: () => parts.lifecycle.methods.init(viewer),
update: () => parts.ingestion.methods.update(),
disable: () => parts.lifecycle.methods.disable(),
destroy: () => parts.lifecycle.methods.destroy(viewer),
holdArrival: () =>
new Promise((resolve) => {
arrival = resolve;
}),
release: () => release?.(),
async close() {
await parts.lifecycle.methods.destroy(viewer);
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
},
};
}
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
const f = await fixture();
try {
await f.update();
const value = f.current();
assert.equal(value.feed, 'nominal');
assert.equal(value.stats.count, 1);
assert.deepEqual(value.rendered, ['owned-launch']);
assert.equal(value.stats.error, null);
assert.ok(value.stats.lastUpdate);
} finally {
await f.close();
}
});
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
await f.update();
const value = f.current();
assert.equal(value.stats.count, 1);
assert.deepEqual(value.rendered, ['owned-launch']);
assert.equal(value.feed, 'stale');
} finally {
await f.close();
}
});
test('a fresh recovery clears stale launch provenance', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
const stale = f.current();
f.mode.cache = 'MISS';
await f.update();
const recovered = f.current();
assert.equal(recovered.feed, 'nominal');
assert.equal(recovered.stats.count, 1);
assert.equal(recovered.stats.error, null);
assert.equal(stale.feed, 'stale');
} finally {
await f.close();
}
});
test('an empty stale snapshot still identifies its fallback provenance', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
f.mode.results = [];
await f.update();
assert.equal(f.current().stats.count, 0);
assert.equal(f.current().feed, 'stale');
} finally {
await f.close();
}
});
test('getLaunches-only injected sources remain supported', async () => {
const f = await fixture({ legacySource: true });
try {
await f.update();
assert.equal(f.current().feed, 'nominal');
assert.deepEqual(f.current().rendered, ['owned-launch']);
} finally {
await f.close();
}
});
test('disable cancels a pending stale response without committing it', async () => {
const f = await fixture();
try {
await f.update();
const healthy = f.current();
f.mode.cache = 'STALE-ERROR';
f.mode.hold = true;
const arrived = f.holdArrival();
const pending = f.update();
await arrived;
await f.disable();
f.release();
await pending;
const value = f.current();
assert.equal(value.enabled, false);
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
assert.equal(value.feed, 'nominal');
assert.equal(f.state._sourceController.signal.aborted, true);
} finally {
await f.close();
}
});
test('hiding retained stale records does not relabel them as fresh', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
await f.disable();
assert.equal(f.current().feed, 'stale');
assert.equal(f.current().stats.count, 1);
} finally {
await f.close();
}
});
test('destroy clears the previous feed state and records', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
await f.destroy();
const value = f.current();
assert.equal(value.stats.count, 0);
assert.equal(value.stats.lastUpdate, null);
assert.equal(value.feed, 'nominal');
assert.equal(value.enabled, false);
} finally {
await f.close();
}
});
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
await f.destroy();
await f.init();
const value = f.current();
assert.equal(value.feed, 'nominal');
assert.equal(value.stats.count, 0);
assert.equal(value.stats.lastUpdate, null);
assert.equal(value.stats.error, null);
assert.equal(value.enabled, false);
assert.equal(f.viewer.dataSources.length, 1);
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
const click = f.state._clickHandler;
const move = f.state._moveHandler;
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
assert.equal(
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
'function',
);
await f.destroy();
assert.equal(click.isDestroyed(), true);
assert.equal(move.isDestroyed(), true);
assert.equal(f.viewer.dataSources.length, 0);
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
} finally {
await f.close();
}
});
+2
View File
@@ -20,6 +20,7 @@ export function createLifecycle({
viewer.dataSources.add(layerState._dataSource);
layerState._count = 0;
layerState._lastUpdate = null;
layerState._stale = false;
layerState._lastError = null;
layerState._orbitMatches = 0;
parts.overlays.clearMissionOverlaySources();
@@ -160,6 +161,7 @@ export function createLifecycle({
layerState._count = 0;
layerState._orbitMatches = 0;
layerState._lastUpdate = null;
layerState._stale = false;
layerState._activeTleText = null;
layerState._activeTlePromise = null;
layerState._activeTlePromiseToken = 0;
+2
View File
@@ -19,6 +19,8 @@ export function createState({ services }) {
state._lastError = null;
state._stale = false;
state._orbitMatches = 0;
state._clickHandler = null;
+3 -4
View File
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { TRACKABLE_FAMILIES } from './agent/worldPacket.js';
import { setLocale } from './i18n/index.js';
import {
COCKPIT_REFUSAL_NOUNS,
@@ -68,10 +69,8 @@ const NOUN_LITERAL =
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
function trackableKinds() {
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/);
assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js');
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
// This is shared with voice; test the exported owner, not its file position.
return TRACKABLE_FAMILIES.map(({ kind }) => kind);
}
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
+7
View File
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
@@ -23,6 +24,12 @@ function fixture(name, overrides = {}, preview = false) {
const logs = [];
const deps = {
readResponseTextCapped, coalesceProxyRequest,
// The rate-limited proxies reach for these; give them the real ones so a
// fixture request is throttled exactly as production would be.
makeRateLimiter, clientKey,
// terrain.js gates cross-site requests first; fixture requests carry no
// browser headers, so admit them like a loopback tool.
admitSameSiteKeyless: () => false,
path, process: { cwd: () => '/fixture', env: {} },
fsp: {
readFile: async () => { throw new Error('cache absent'); },
+368
View File
@@ -0,0 +1,368 @@
// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates.
//
// Every distinct 5dp lon/lat is its own cache key, its own permanent line in
// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things
// bound that: WGS-84 range at parse, an entry ceiling on the map, a size
// ceiling on the file that is read back, and a per-client request limiter.
//
// These cases drive the real exported route handler. `fetch` is stubbed per
// test and restored after, so a case that reaches upstream fails loudly.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { terrainHeightsProxy } from '../server/providers/terrain.js';
import { admitSameSite } from '../server/providers/common/same-site.js';
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(path, handler) {
routes.set(path, handler);
},
},
});
return routes;
}
function request(
handler,
{ url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {},
) {
return new Promise((resolve, reject) => {
const headers = new Map();
const req = {
method: 'GET',
url,
headers: reqHeaders,
socket: { remoteAddress },
};
const res = {
statusCode: 200,
headersSent: false,
setHeader(name, value) {
headers.set(String(name).toLowerCase(), String(value));
},
writeHead(status, hdrs = {}) {
this.statusCode = status;
this.headersSent = true;
for (const [name, value] of Object.entries(hdrs))
headers.set(String(name).toLowerCase(), String(value));
},
end(body = '') {
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({
statusCode: this.statusCode,
headers: Object.fromEntries(headers),
body: parsed,
});
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
function stubFetch(t, impl) {
const original = globalThis.fetch;
globalThis.fetch = impl;
t.after(() => {
globalThis.fetch = original;
});
}
/**
* Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order.
* Records which point strings were actually asked for, so a test can assert on
* cache misses rather than on internal state.
*/
function stubTerrainUpstream(t) {
const asked = [];
stubFetch(t, async (url) => {
const points = decodeURIComponent(
new URL(String(url)).searchParams.get('points') || '',
)
.split(';')
.filter(Boolean);
asked.push(...points);
return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) });
});
return asked;
}
test('a coordinate off the globe is not a cache key', () => {
// Finite but not on Earth — the old guard accepted every one of these.
for (const raw of [
'0,91',
'0,-91',
'181,0',
'-181,0',
'1e9,1e9',
'0,90.00001',
])
assert.equal(parseTerrainPoints(raw), null, raw);
// The poles and the antimeridian are real places and must still parse.
for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26'])
assert.ok(parseTerrainPoints(raw), raw);
});
test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => {
let upstreamCalls = 0;
stubFetch(t, async () => {
upstreamCalls += 1;
return new Response('[]', { status: 200 });
});
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const refused = await request(route, { url: '/?points=0,91' });
assert.equal(refused.statusCode, 400);
assert.equal(
upstreamCalls,
0,
'an impossible coordinate must not cost an upstream call',
);
});
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
stubFetch(t, async () => new Response('[]', { status: 200 }));
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const mixed = await request(route, {
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
});
assert.equal(mixed.statusCode, 400);
});
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
const asked = stubTerrainUpstream(t);
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
'/api/terrain/heights',
);
const point = (i) => `-97.7${i}000,30.26000`;
for (let i = 1; i <= 4; i += 1)
await request(route, { url: `/?points=${point(i)}` });
assert.equal(asked.length, 4, 'four distinct points, four upstream lookups');
// The newest point is still cached...
await request(route, { url: `/?points=${point(4)}` });
assert.equal(asked.length, 4, 'a cached point costs nothing');
// ...and the oldest was evicted, so it costs one refetch — never a wrong
// answer, because terrain does not move.
await request(route, { url: `/?points=${point(1)}` });
assert.equal(asked.length, 5, 'the evicted point was asked for again');
assert.equal(asked[4], point(1));
});
test('an over-large cache file is not read back into memory', async (t) => {
const asked = stubTerrainUpstream(t);
const point = '-97.74000,30.26000';
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
const cachePath = path.join(dir, 'terrain-heights.json');
t.after(() => rm(dir, { recursive: true, force: true }));
// A cache file holding the answer, but larger than the ceiling allows.
const padded = {
version: 2,
points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } },
_pad: 'x'.repeat(4096),
};
await writeFile(cachePath, JSON.stringify(padded), 'utf8');
const route = install(
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }),
).get('/api/terrain/heights');
await request(route, { url: `/?points=${point}` });
assert.deepEqual(
asked,
[point],
'the oversized file was skipped, so the point was a miss',
);
// The same file under a ceiling that admits it IS used, which is what proves
// the previous assertion came from the size guard and not a broken path.
const admitted = install(
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }),
).get('/api/terrain/heights');
await request(admitted, { url: `/?points=${point}` });
assert.deepEqual(asked, [point], 'a cache within the ceiling is still read');
});
test('the route refuses a flood of requests from one client', async (t) => {
stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
let refusedAt = 0;
for (let i = 1; i <= 95; i += 1) {
// A different point each time: distinct keys are the growth path.
const lon = (-97.7 - i / 1000).toFixed(5);
const answer = await request(route, { url: `/?points=${lon},30.26` });
if (answer.statusCode === 429) {
refusedAt = i;
break;
}
}
assert.equal(refusedAt, 91, 'the 91st request in a minute is refused');
});
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://evil.example' }, // fetch from another page
{ host, origin: 'null' }, // sandboxed frame
// Behind a reverse proxy the browser's own Sec-Fetch-Site still decides.
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refused requests than the 90/min quota, each a distinct new key.
for (let i = 0; i < 100; i += 1) {
const reqHeaders = refused[i % refused.length];
const answer = await request(route, {
url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`,
reqHeaders,
});
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
}
assert.deepEqual(asked, [], 'nothing reached upstream or the cache');
// The visitor's own app still has its full quota afterwards, and a
// loopback tool that sends no browser headers is admitted too.
const app = await request(route, {
url: '/?points=-97.74,30.26',
reqHeaders: { host, 'sec-fetch-site': 'same-origin' },
});
assert.equal(app.statusCode, 200);
const tool = await request(route, { url: '/?points=-97.75,30.26' });
assert.equal(tool.statusCode, 200);
assert.equal(asked.length, 2);
});
test('the app served behind a reverse proxy still gets terrain', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const host = 'globe.example.lan';
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
// cost-bearing routes refuse these; terrain is keyless and needed to render,
// so a 403 here would drop every chunk to the geoid fallback.
const proxied = [
{
host,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
},
{
host,
'sec-fetch-site': 'same-origin',
'cf-connecting-ip': '203.0.113.9',
},
{
host,
'sec-fetch-site': 'same-origin',
forwarded: 'for=203.0.113.9;proto=https',
via: '1.1 caddy',
},
// TLS terminated at the proxy: the page origin is https, the hop is http.
{
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
];
for (const [i, reqHeaders] of proxied.entries()) {
const answer = await request(route, {
url: `/?points=${(-97.8 - i / 1000).toFixed(5)},30.26`,
reqHeaders,
});
assert.equal(answer.statusCode, 200, JSON.stringify(reqHeaders));
}
assert.equal(asked.length, proxied.length);
// Proxied or not, a cross-site page is still refused before any work.
for (const reqHeaders of [
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
{
host,
'x-forwarded-proto': 'https',
origin: 'https://evil.example',
},
// A forwarded scheme does not make an http Origin match an https page.
{ host, 'x-forwarded-proto': 'https', origin: `http://${host}` },
]) {
const answer = await request(route, {
url: '/?points=-97.9,30.26',
reqHeaders,
});
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
}
assert.equal(asked.length, proxied.length, 'refusals cost no upstream call');
});
test('cost-bearing routes still refuse proxied requests', () => {
// The keyless exemption is scoped to terrain; the shared gate is unchanged.
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
end(body) {
this.body = body;
},
};
const refused = admitSameSite(
{
headers: {
host: 'globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
},
socket: {},
},
res,
);
assert.equal(refused, true);
assert.equal(res.statusCode, 403);
assert.deepEqual(JSON.parse(res.body), {
error: 'Proxied requests are not accepted',
});
});
test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
for (let i = 0; i < 90; i += 1)
await request(route, {
url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`,
remoteAddress: '198.51.100.7',
});
const exhausted = await request(route, {
url: '/?points=-99.5,30.26',
remoteAddress: '198.51.100.7',
});
assert.equal(exhausted.statusCode, 429);
const neighbour = await request(route, {
url: '/?points=-99.6,30.26',
remoteAddress: '198.51.100.8',
});
assert.notEqual(neighbour.statusCode, 429);
});
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import http from 'node:http';
import { launchLibraryRecentUrl } from '../data/spaceProviderRequests.js';
import {
launchLibraryRequestHeaders,
rocketLaunchesProxy,
} from '../../server/providers/space/launch-library.js';
import { keySetupStatus } from '../keySetupCore.mjs';
import { initKeySetup } from '../keySetup.js';
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
const first = new Date('2026-10-17T16:20:00.001Z');
const last = new Date('2026-10-17T16:20:59.999Z');
assert.equal(
launchLibraryRecentUrl(first).href,
launchLibraryRecentUrl(last).href,
);
assert.equal(first.toISOString(), '2026-10-17T16:20:00.001Z');
assert.equal(last.toISOString(), '2026-10-17T16:20:59.999Z');
});
for (const [date, end] of [
['2026-10-17T16:20:00.001Z', '2026-10-17T16:20:00.000Z'],
['2026-10-17T16:20:59.999Z', '2026-10-17T16:20:00.000Z'],
['2026-10-17T16:21:00.000Z', '2026-10-17T16:21:00.000Z'],
['2026-01-01T00:00:00.001Z', '2026-01-01T00:00:00.000Z'],
]) {
test(`LL2 bounds retain the 30-day UTC window at ${date}`, () => {
const url = launchLibraryRecentUrl(new Date(date));
assert.equal(url.origin, 'https://ll.thespacedevs.com');
assert.equal(url.pathname, '/2.3.0/launches/');
assert.equal(url.searchParams.get('net__lte'), end);
assert.equal(
Date.parse(end) - Date.parse(url.searchParams.get('net__gte')),
30 * 86400000,
);
assert.equal(url.searchParams.get('limit'), '100');
assert.equal(url.searchParams.get('mode'), 'detailed');
});
}
test('LL2 headers identify the app and preserve optional server-only authorization', () => {
const publicHeaders = launchLibraryRequestHeaders('');
assert.match(publicHeaders['User-Agent'], /^gods-eye-view.*\/1\.0 /);
assert.ok(
publicHeaders['User-Agent'].includes(
'https://github.com/bilawalsidhu/gods-eye-view',
),
);
assert.equal(publicHeaders.Accept, 'application/json');
assert.equal(publicHeaders.Authorization, undefined);
const paidHeaders = launchLibraryRequestHeaders(' owned-fixture-token ');
assert.equal(paidHeaders.Authorization, 'Token owned-fixture-token');
assert.equal(paidHeaders['User-Agent'], publicHeaders['User-Agent']);
});
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
const created = [];
const node = () => {
const value = {
dataset: {},
children: [],
append(...children) {
this.children.push(...children);
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
remove() {},
};
created.push(value);
return value;
};
const rows = node();
const root = node();
root.querySelector = (selector) =>
selector === '[data-key-setup-rows]' ? rows : null;
const chip = node();
const status = keySetupStatus({});
status.keys = status.keys.filter((key) =>
['launch-library', 'mapillary'].includes(key.id),
);
const setup = await initKeySetup({
documentRef: {
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
createElement: node,
addEventListener() {},
removeEventListener() {},
},
fetchImpl: async () => ({ ok: true, json: async () => status }),
});
assert.ok(setup);
try {
const ll2 = rows.children.find(
(row) => row.dataset.keyId === 'launch-library',
);
const free = rows.children.find((row) => row.dataset.keyId === 'mapillary');
const tier = (row) =>
row.children[0].children.find(
(child) => child.className === 'key-setup-tier',
);
assert.match(tier(ll2).title, /optional paid token/i);
assert.match(ll2.children[1].textContent, /leave blank/i);
assert.match(ll2.children[1].textContent, /no key/i);
assert.equal(tier(free).title, 'Free key — register, paste, done');
const serialized = JSON.stringify(status);
assert.ok(!serialized.includes('owned-fixture-token'));
} finally {
setup.destroy();
}
});
test('real LL2 proxy sends cacheable headers and bounds, retaining its memory cache', async (t) => {
for (const method of ['readFile', 'stat'])
t.mock.method(fsp, method, async () => {
throw new Error('owned fixture has no disk cache');
});
t.mock.method(fsp, 'mkdir', async () => {});
t.mock.method(fsp, 'writeFile', async () => {});
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url: new URL(url), options });
return new Response(
JSON.stringify({ results: [{ id: 'owned-launch-fixture' }] }),
);
});
let handler;
rocketLaunchesProxy().configureServer({
middlewares: {
use(route, value) {
assert.equal(route, '/api/launches');
handler = value;
},
},
});
async function request() {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = body;
},
};
await handler({ method: 'GET' }, res);
return res;
}
const first = await request();
const second = await request();
assert.equal(first.status, 200);
assert.equal(second.body, first.body);
assert.equal(second.headers['X-GEV-Cache'], 'HIT');
assert.equal(calls.length, 1);
assert.match(calls[0].options.headers['User-Agent'], /gods-eye-view/);
assert.match(calls[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
assert.match(calls[0].url.searchParams.get('net__gte'), /:00\.000Z$/);
});
for (const preview of [false, true]) {
test(`LL2 proxy sends its identifying headers over real owned HTTP in ${preview ? 'preview' : 'development'}`, async (t) => {
for (const method of ['readFile', 'stat'])
t.mock.method(fsp, method, async () => {
throw new Error('no owned cache');
});
t.mock.method(fsp, 'mkdir', async () => {});
t.mock.method(fsp, 'writeFile', async () => {});
const received = [];
const upstream = http.createServer((req, res) => {
received.push({
url: new URL(req.url, 'http://fixture.invalid'),
headers: req.headers,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ results: [{ id: 'owned-wire-fixture' }] }));
});
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const originalFetch = globalThis.fetch;
t.mock.method(globalThis, 'fetch', (url, options) => {
const parsed = new URL(url);
assert.equal(parsed.origin, 'https://ll.thespacedevs.com');
return originalFetch(
`http://127.0.0.1:${upstream.address().port}${parsed.pathname}${parsed.search}`,
options,
);
});
let handler;
const plugin = rocketLaunchesProxy();
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(_route, value) {
handler = value;
},
},
});
const proxy = http.createServer((req, res) => {
void handler(req, res);
});
await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve));
try {
const result = await originalFetch(
`http://127.0.0.1:${proxy.address().port}/api/launches`,
);
assert.equal(result.status, 200);
assert.deepEqual(await result.json(), {
results: [{ id: 'owned-wire-fixture' }],
});
assert.equal(received.length, 1);
assert.match(
received[0].headers['user-agent'],
/^gods-eye-view-launch-library-proxy\/1\.0 /,
);
assert.equal(received[0].headers.authorization, undefined);
assert.match(received[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
} finally {
proxy.closeAllConnections();
upstream.closeAllConnections();
await Promise.all([
new Promise((resolve) => proxy.close(resolve)),
new Promise((resolve) => upstream.close(resolve)),
]);
}
});
}
+2 -2
View File
@@ -81,8 +81,8 @@ test('portable requests keep fixed origins, encode group data and preserve the 3
assert.equal(url.origin, 'https://ll.thespacedevs.com');
assert.equal(url.pathname, '/2.3.0/launches/');
assert.deepEqual(Object.fromEntries(url.searchParams), {
net__gte: '2026-01-30T12:34:56.000Z',
net__lte: '2026-03-01T12:34:56.000Z',
net__gte: '2026-01-30T12:34:00.000Z',
net__lte: '2026-03-01T12:34:00.000Z',
limit: '100',
mode: 'detailed',
});
+60 -1
View File
@@ -11,9 +11,12 @@ import {
} from '../area.js';
import {
LIMIT_SCHEMA,
breakdown,
breakdownPhrase,
capRows,
countNoun,
isoTime,
numericSummary,
thinEvenly,
} from '../results.js';
@@ -32,6 +35,31 @@ const MILITARY_SCHEMA = Object.freeze({
description: 'Use the military aircraft feed instead of the general feed.',
});
const MAX_TRACK_POINTS = 200;
const GROUP_LABELS = Object.freeze({
airline: 'airline',
operator: 'operator',
origin_country: 'country',
type_code: 'type',
});
/**
* The ICAO airline designator a callsign flies under: three letters, then a
* flight number that starts with a digit, seven characters at most (SWA1234,
* UAL12A, BAW1). Registrations used as callsigns (N123AB, GABCD) have no
* designator; Philippine RP-C1234 and Cuban CU-T1240, which read like one
* without their hyphen, are excluded too.
*/
export function airlineDesignator(callsign) {
const text = String(callsign ?? '')
.trim()
.toUpperCase();
if (/^(?:RPC|CUT)\d{4}$/.test(text)) return null;
const match = /^([A-Z]{3})\d[0-9A-Z]{0,3}$/.exec(text);
return match ? match[1] : null;
}
const groupValue = (field) =>
field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
@@ -82,7 +110,11 @@ export const aircraftInArea = defineTool({
title: 'Aircraft in an area',
description:
'Aircraft currently reported inside an area, nearest the center first. ' +
'Optionally military aircraft only, airborne only, or within an altitude band.',
'Optionally military aircraft only, airborne only, or within an altitude band. ' +
'group_by counts every matched aircraft by airline (the ICAO designator ' +
'in its callsign), operator, country or type, and ' +
'stats summarizes airborne altitude and speed; both cover all matches, not ' +
'just the rows returned.',
inputSchema: {
type: 'object',
properties: {
@@ -91,6 +123,18 @@ export const aircraftInArea = defineTool({
airborne_only: { type: 'boolean' },
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
group_by: {
type: 'string',
enum: Object.keys(GROUP_LABELS),
description:
'Count matched aircraft by this field, largest first. Feeds that do not ' +
'report the field count those aircraft as unknown.',
},
stats: {
type: 'boolean',
description:
'Summarize airborne altitude and speed over matched aircraft.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
@@ -128,9 +172,14 @@ export const aircraftInArea = defineTool({
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
];
const groups = args.group_by
? breakdown(rows, args.group_by, groupValue(args.group_by))
: null;
const airborne = rows.filter((row) => !row.on_ground);
return {
summary:
`${rows.length} ${kind} in ${area.label}` +
(groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, {
@@ -138,6 +187,16 @@ export const aircraftInArea = defineTool({
layers: [args.military ? 'military' : 'flights'],
}),
...capRows(rows, args.limit),
...(groups ? { breakdown: groups } : {}),
...(args.stats
? {
// Parked aircraft would pull both figures toward zero.
stats: {
altitude_m: numericSummary(airborne, 'altitude_m'),
speed_mps: numericSummary(airborne, 'speed_mps'),
},
}
: {}),
...snapshotInfo(snapshot),
},
};
+135
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { airlineDesignator } from './aviation.js';
import { LiveSourceError } from '../../sources/live/contract.js';
const record = (id, latitude, longitude, extra = {}) => ({
@@ -389,6 +390,96 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
assert.match(result.summary, /may be stale\.$/);
});
test('aircraft in an area can be counted by operator over every match', async () => {
const fleet = [
record('a1', 37.6, -122.3, { operator: 'United' }),
record('a2', 37.61, -122.3, { operator: 'Delta' }),
record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }),
record('a4', 37.63, -122.3, {
onGround: true,
baroAltitudeM: 0,
speedMps: 0,
}),
record('a5', 51.5, -0.1, { operator: 'British Airways' }),
];
const catalog = composeCatalog({
tools: coreTools,
services: { aircraft: feed(fleet) },
});
const result = await catalog.call('aircraft_in_area', {
area: bay,
group_by: 'operator',
stats: true,
limit: 1,
});
assert.equal(
result.summary,
'4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.',
);
assert.equal(result.data.returned, 1);
assert.deepEqual(result.data.breakdown, {
field: 'operator',
groups: [
{ value: 'United', count: 2 },
{ value: 'Delta', count: 1 },
],
other_count: 0,
unknown_count: 1,
});
// Both figures cover airborne aircraft only: the parked a4 stands at 0 m
// and 0 m/s and would pull each mean down.
assert.deepEqual(result.data.stats, {
altitude_m: {
count: 3,
min: 3000,
median: 10000,
mean: 7666.7,
max: 10000,
},
speed_mps: { count: 3, min: 230, median: 230, mean: 230, max: 230 },
});
});
test('aircraft stats are empty when every match is on the ground', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: feed([
record('g1', 37.6, -122.3, {
onGround: true,
baroAltitudeM: 0,
speedMps: 0,
}),
record('g2', 37.61, -122.3, {
onGround: true,
baroAltitudeM: 0,
speedMps: 4,
}),
]),
},
});
const result = await catalog.call('aircraft_in_area', {
area: bay,
stats: true,
});
assert.match(result.summary, /^2 aircraft in /);
assert.deepEqual(result.data.stats, { altitude_m: null, speed_mps: null });
});
test('aircraft answers carry no breakdown or stats unless asked', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { aircraft: feed(civil) },
});
const result = await catalog.call('aircraft_in_area', { area: bay });
assert.equal('breakdown' in result.data, false);
assert.equal('stats' in result.data, false);
await assert.rejects(
catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }),
/must be one of/,
);
});
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
const { normalizeOpenSkyAircraft } =
await import('../../sources/live/aircraft.js');
@@ -447,3 +538,47 @@ test('altitude bands require a reported altitude from normalized OpenSky records
});
assert.equal(minimum.data.total, 3);
});
test('an airline designator is read from an ICAO flight callsign only', () => {
for (const [callsign, designator] of [
['SWA1234', 'SWA'],
['ual12a', 'UAL'],
[' BAW1 ', 'BAW'],
['RCH123', 'RCH'],
['N123AB', null],
['GABCD', null],
['SWA', null],
['SWAX123', null],
['SWA12345', null],
['RPC1234', null],
['CUT1240', null],
['CUB123', 'CUB'],
['', null],
[null, null],
])
assert.equal(airlineDesignator(callsign), designator, String(callsign));
});
test('aircraft in an area can be counted by airline from their callsigns', async () => {
const fleet = [
record('a1', 37.6, -122.3, { callsign: 'SWA100' }),
record('a2', 37.61, -122.3, { callsign: 'SWA2' }),
record('a3', 37.62, -122.3, { callsign: 'UAL9' }),
record('a4', 37.63, -122.3, { callsign: 'N123AB' }),
];
const catalog = composeCatalog({
tools: coreTools,
services: { aircraft: feed(fleet) },
});
const result = await catalog.call('aircraft_in_area', {
area: bay,
group_by: 'airline',
});
assert.equal(
result.summary,
'4 aircraft in 60 km around 37.600, -122.300; most by airline: SWA (2), UAL (1); 1 unknown.',
);
assert.equal(result.data.breakdown.field, 'airline');
// Rows are unchanged: the designator is derived, not added to each row.
assert.equal('airline' in result.data.rows[0], false);
});
+38 -4
View File
@@ -12,11 +12,15 @@ import {
} from '../area.js';
import {
LIMIT_SCHEMA,
breakdown,
breakdownPhrase,
capRows,
countNoun,
isoTime,
numericSummary,
thinEvenly,
} from '../results.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
type: 'string',
@@ -29,6 +33,18 @@ const ALL_VESSELS = 1_000_000;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
const text = (value) => (typeof value === 'string' && value ? value : null);
// AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so
// a code reads as its family ("CARGO"), which is what group_by counts and the
// type filter matches; text types pass through. Type 0 is "not available" and
// reads as unknown.
const vesselType = (value) => normalizeVesselType(text(value) ?? '') || null;
// Crews type the destination by hand; one with no letters ("0", "---") names
// no port and reads as unknown.
const vesselDestination = (value) => {
const destination = text(value);
return destination && /\p{L}/u.test(destination) ? destination : null;
};
function vesselRow(record, from) {
const point = { lat: record.latitude, lon: record.longitude };
@@ -36,8 +52,8 @@ function vesselRow(record, from) {
mmsi: record.id,
name: text(record.name),
imo: text(record.imo),
type: text(record.type),
destination: text(record.destination),
type: vesselType(record.type),
destination: vesselDestination(record.destination),
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
@@ -88,7 +104,9 @@ export const vesselsInArea = defineTool({
title: 'Vessels in an area',
description:
'Ships currently reported by AIS inside an area, nearest the center ' +
'first, optionally filtered by vessel type.',
'first, optionally filtered by vessel type. group_by counts every matched ' +
'ship by type or destination, and stats summarizes their speed; both ' +
'cover all matches, not just the rows returned.',
inputSchema: {
type: 'object',
properties: {
@@ -99,6 +117,15 @@ export const vesselsInArea = defineTool({
maxLength: 40,
description: 'Vessel type to match, such as "tanker" or "cargo".',
},
group_by: {
type: 'string',
enum: ['type', 'destination'],
description: 'Count matched vessels by this field, largest first.',
},
stats: {
type: 'boolean',
description: 'Summarize speed over matched vessels.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
@@ -114,18 +141,25 @@ export const vesselsInArea = defineTool({
.filter(
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
(!wanted || String(record.type).toLowerCase().includes(wanted)),
(!wanted ||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
)
.map((record) => vesselRow(record, center))
.sort((a, b) => a.distance_km - b.distance_km);
const kind = args.type ? `${args.type} vessel` : 'vessel';
const groups = args.group_by ? breakdown(rows, args.group_by) : null;
return {
summary:
`${countNoun(rows.length, kind)} in ${area.label}` +
(groups ? breakdownPhrase(groups, args.group_by) : '') +
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
data: {
view: suggestView(services, { area, layers: ['ais-live-vessels'] }),
...capRows(rows, args.limit),
...(groups ? { breakdown: groups } : {}),
...(args.stats
? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } }
: {}),
...snapshotInfo(snapshot),
},
};
+174
View File
@@ -224,3 +224,177 @@ test('an area search names its area to the server; a vessel lookup asks for ever
assert.equal(lookup.has('lat'), false);
assert.equal(lookup.has('radius_km'), false);
});
test('AIS type 0 (not available) is counted as unknown, not as a type', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { type: '0' }),
vessel('366000002', 37.79, -122.41, { type: '0' }),
vessel('366000003', 37.77, -122.39, { type: 'Cargo' }),
vessel('366000004', 37.76, -122.38, { type: 'Tanker' }),
],
}),
},
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.deepEqual(result.data.breakdown, {
field: 'type',
groups: [
{ value: 'Cargo', count: 1 },
{ value: 'Tanker', count: 1 },
],
other_count: 0,
unknown_count: 2,
});
assert.match(
result.summary,
/most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./,
);
assert.doesNotMatch(result.summary, /0 \(/);
assert.equal(
result.data.rows.find((row) => row.mmsi === '366000001').type,
null,
);
});
test('numeric AIS ship-type codes group and filter by their family', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { type: '70' }),
vessel('366000002', 37.79, -122.41, { type: '74' }),
vessel('366000003', 37.77, -122.39, { type: '79' }),
vessel('366000004', 37.76, -122.38, { type: '80' }),
vessel('366000005', 37.75, -122.37, { type: '52' }),
],
}),
},
});
const grouped = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.deepEqual(grouped.data.breakdown.groups, [
{ value: 'CARGO', count: 3 },
{ value: 'TANKER', count: 1 },
{ value: 'TUG', count: 1 },
]);
const tankers = await catalog.call('vessels_in_area', {
area: bay,
type: 'tanker',
});
assert.deepEqual(
tankers.data.rows.map((row) => [row.mmsi, row.type]),
[['366000004', 'TANKER']],
);
});
test('reserved AIS ship-type codes 100 to 255 count as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { type: '100' }),
vessel('366000002', 37.79, -122.41, { type: '255' }),
vessel('366000003', 37.77, -122.39, { type: '70' }),
],
}),
},
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.deepEqual(result.data.breakdown.groups, [
{ value: 'CARGO', count: 1 },
]);
assert.equal(result.data.breakdown.unknown_count, 2);
assert.doesNotMatch(result.summary, /\b(100|255) \(/);
});
test('find_vessel answers carry normalized types and destinations', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, {
type: '71',
destination: '---',
}),
vessel('366000002', 37.79, -122.41, {
type: '0',
destination: 'NLRTM',
}),
],
}),
},
});
const cargo = await catalog.call('find_vessel', { mmsi: '366000001' });
assert.deepEqual(
cargo.data.rows.map((row) => [row.type, row.destination]),
[['CARGO', null]],
);
const unknown = await catalog.call('find_vessel', { mmsi: '366000002' });
assert.deepEqual(
unknown.data.rows.map((row) => [row.type, row.destination]),
[[null, 'NLRTM']],
);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { destination: '0' }),
vessel('366000002', 37.79, -122.41, { destination: '---' }),
vessel('366000003', 37.77, -122.39, { destination: 'NLRTM' }),
],
}),
},
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'destination',
});
assert.deepEqual(result.data.breakdown.groups, [
{ value: 'NLRTM', count: 1 },
]);
assert.equal(result.data.breakdown.unknown_count, 2);
});
test('vessels in an area can be counted by destination with a speed summary', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'destination',
stats: true,
});
assert.equal(
result.summary,
'2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.',
);
assert.deepEqual(result.data.breakdown, {
field: 'destination',
groups: [{ value: 'OAKLAND', count: 1 }],
other_count: 0,
unknown_count: 1,
});
assert.deepEqual(result.data.stats, {
speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 },
});
});
+78
View File
@@ -22,6 +22,84 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
};
}
/** Most groups a breakdown lists before counting the rest as other. */
export const MAX_GROUPS = 10;
/**
* Count rows by one text field over every row, not just those returned.
* Values group case-insensitively and keep their first spelling; rows
* without a value are counted as unknown. `valueOf` derives the value when
* it is not a row field as it stands.
*/
export function breakdown(rows, field, valueOf = (row) => row[field]) {
const groups = new Map();
let unknown = 0;
for (const row of rows) {
const raw = valueOf(row);
const value = typeof raw === 'string' ? raw.trim() : '';
if (!value) {
unknown += 1;
continue;
}
const key = value.toLowerCase();
const group = groups.get(key);
if (group) group.count += 1;
else groups.set(key, { value, count: 1 });
}
const sorted = [...groups.values()].sort(
(a, b) => b.count - a.count || a.value.localeCompare(b.value),
);
const listed = sorted.slice(0, MAX_GROUPS);
return {
field,
groups: listed,
other_count: rows.length - unknown - sum(listed.map((g) => g.count)),
unknown_count: unknown,
};
}
/** Minimum, median, mean and maximum of one numeric field, or null. */
export function numericSummary(rows, field) {
const values = rows
.map((row) => row[field])
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (!values.length) return null;
const middle = values.length >> 1;
const median =
values.length % 2
? values[middle]
: (values[middle - 1] + values[middle]) / 2;
const round1 = (value) => Number(value.toFixed(1));
return {
count: values.length,
min: values[0],
median: round1(median),
mean: round1(sum(values) / values.length),
max: values.at(-1),
};
}
/**
* "; most by operator: Delta (12), United (9), American (7); 4 unknown", or
* "; no operator reported" when no row carries the field.
*/
export function breakdownPhrase(result, label) {
if (!result.groups.length)
return result.unknown_count ? `; no ${label} reported` : '';
const top = result.groups
.slice(0, 3)
.map((group) => `${group.value} (${group.count})`);
const unknown = result.unknown_count
? `; ${result.unknown_count} unknown`
: '';
return `; most by ${label}: ${top.join(', ')}${unknown}`;
}
function sum(values) {
return values.reduce((total, value) => total + value, 0);
}
/** Format an epoch millisecond time as ISO 8601, or null. */
export function isoTime(ms) {
if (!Number.isFinite(ms)) return null;
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MAX_GROUPS,
breakdown,
breakdownPhrase,
numericSummary,
} from './results.js';
test('a breakdown counts every row, case-insensitively, largest first', () => {
const rows = [
{ operator: 'Delta' },
{ operator: 'United' },
{ operator: ' delta ' },
{ operator: null },
{ operator: '' },
{ operator: 'American' },
{ operator: 'United' },
{ operator: 'DELTA' },
];
assert.deepEqual(breakdown(rows, 'operator'), {
field: 'operator',
groups: [
{ value: 'Delta', count: 3 },
{ value: 'United', count: 2 },
{ value: 'American', count: 1 },
],
other_count: 0,
unknown_count: 2,
});
});
test('groups past the limit are counted as other', () => {
const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({
type: `T${String(index).padStart(2, '0')}`,
}));
rows.push({ type: 'T00' });
const result = breakdown(rows, 'type');
assert.equal(result.groups.length, MAX_GROUPS);
assert.deepEqual(result.groups[0], { value: 'T00', count: 2 });
assert.equal(result.other_count, 3);
assert.equal(
result.groups.reduce((total, group) => total + group.count, 0) +
result.other_count +
result.unknown_count,
rows.length,
);
});
test('a numeric summary skips missing values', () => {
const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}];
assert.deepEqual(numericSummary(rows, 'v'), {
count: 4,
min: 1,
median: 3.5,
mean: 4.5,
max: 10,
});
assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5);
assert.equal(numericSummary([{ v: null }], 'v'), null);
assert.equal(numericSummary([], 'v'), null);
});
test('the breakdown phrase names the three largest groups', () => {
const result = {
unknown_count: 0,
groups: [
{ value: 'A', count: 4 },
{ value: 'B', count: 3 },
{ value: 'C', count: 2 },
{ value: 'D', count: 1 },
],
};
assert.equal(
breakdownPhrase(result, 'type'),
'; most by type: A (4), B (3), C (2)',
);
assert.equal(
breakdownPhrase({ ...result, unknown_count: 5 }, 'type'),
'; most by type: A (4), B (3), C (2); 5 unknown',
);
// A feed that never reports the field says so instead of staying silent.
assert.equal(
breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'),
'; no operator reported',
);
assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), '');
});
+24 -1
View File
@@ -69,7 +69,8 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
syncPanelCollapseButton: (panel) =>
this._syncRailPanelCollapseButton(panel),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -283,6 +284,19 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
_syncRailPanelCollapseButton(panelEl) {
// Adaptive rail layout may directly restore an auto-collapsed Context
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
if (
panelEl?.id === 'global-context-panel' &&
!panelEl.classList.contains('collapsed') &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
this._syncPanelCollapseButton(panelEl);
}
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
@@ -489,6 +503,15 @@ export class PanelChrome {
syncShare,
});
}
// A repeated Context-open request must clear a stale compact Radio mini
// even when no collapsed class changes.
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelChrome } from './panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
function classList(initial = []) {
const values = new Set(initial);
return {
contains: (value) => values.has(value),
add: (...items) => items.forEach((item) => values.add(item)),
remove: (...items) => items.forEach((item) => values.delete(item)),
toggle(value, force) {
if (force === true) values.add(value);
else if (force === false) values.delete(value);
else if (values.has(value)) values.delete(value);
else values.add(value);
return values.has(value);
},
};
}
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
const priorDocument = globalThis.document;
const contextPanel = {
id: 'global-context-panel',
classList: classList(),
};
globalThis.document = {
documentElement: { dataset: { uiTheme: 'tactical' } },
getElementById(id) {
return id === 'global-context-panel' ? contextPanel : null;
},
};
t.after(() => {
globalThis.document = priorDocument;
});
const radioDock = { classList: classList(['disclosure-open']) };
const calls = [];
const owner = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(open);
radioDock.classList.toggle('disclosure-open', open);
},
_leftPanelStack: null,
_rightPanelStack: null,
_panelLayout: {
_leftStackPreferredPanelId: null,
_rightStackPreferredPanelId: null,
},
_syncPanelCollapseButton() {},
_scheduleLeftPanelLayout() {},
_scheduleRightPanelLayout() {},
shareLinkManager: null,
cockpitView: null,
};
PanelChrome.prototype.setPanelCollapsed.call(
owner,
'global-context-panel',
false,
);
assert.deepEqual(calls, [false]);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
});
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.requestAnimationFrame = priorRaf;
});
// Valid starting state: the tactical rail automatically collapsed Context,
// and the user opened the compact Radio controls from its header.
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const radioDock = { classList: classList(['disclosure-open']) };
const radioMini = { hidden: false };
const calls = [];
const chrome = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
radioDock.classList.toggle('disclosure-open', open);
radioMini.hidden = !open;
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
};
const layout = {
destroyed: false,
_rightStackLayoutFrame: null,
_rightStackReconsiderAutoCollapse: false,
_rightPanelStack: {
querySelectorAll: (selector) =>
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
},
_syncPanelCollapseButton(panel) {
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
},
_syncRightPanelAdaptiveLayout() {
calls.push('layout');
},
};
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
reconsiderAutoCollapse: true,
});
assert.equal(contextPanel.classList.contains('collapsed'), true);
assert.equal(radioMini.hidden, false);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
assert.equal(radioMini.hidden, true);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
const priorDocument = globalThis.document;
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const rightRail = {
querySelectorAll(selector) {
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
},
};
globalThis.document = {
getElementById(id) {
return id === 'right-context-rail' ? rightRail : null;
},
};
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.document = priorDocument;
globalThis.requestAnimationFrame = priorRaf;
});
const dock = { classList: classList(['disclosure-open']) };
const calls = [];
const chrome = new PanelChrome({
elements: { _contextRadioDock: dock },
operations: {
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
dock.classList.toggle('disclosure-open', open);
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
},
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
readCockpit: () => null,
readShareLinks: () => null,
readInitialShare: () => null,
readScrollRestoreOwner: () => null,
readDisplayScrollTop: () => 0,
});
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
calls.push('layout');
};
chrome._panelLayout._scheduleRightPanelLayout({
reconsiderAutoCollapse: true,
});
assert.equal(typeof nextFrame, 'function');
assert.equal(dock.classList.contains('disclosure-open'), true);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(dock.classList.contains('disclosure-open'), false);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
+12 -159
View File
@@ -1,6 +1,5 @@
import {
layerSnapshot,
layerSnapshots,
feedProvenanceEnvelope,
} from '../data/layerSnapshot.js';
import { readLayerLifecycleSummary } from './layerSummary.js';
@@ -27,7 +26,6 @@ import {
isContextRecordActive,
} from '../data/contextStore.js';
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
import { contextModeWord } from '../contextModePolicy.js';
import {
createAnalystEngine,
normalizeAnalystSpec,
@@ -48,6 +46,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
import {
getWorldPacket,
withContextModeVocabulary,
activeContactsWindow,
TRACKABLE_FAMILIES,
} from '../agent/worldPacket.js';
const ALLOWED_STYLES = new Set([
'normal',
@@ -119,64 +123,6 @@ const CONTEXT_MODE_ALIASES = new Map([
['space-missions', 'space-missions'],
['missions', 'space-missions'],
]);
/**
* Every model-readable field that carries a context-mode id, and what an
* absent value means for each.
*
* `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode`
* are absent when there is no such mode at all — calling those 'off' would
* assert a state that does not exist.
*/
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
{ field: 'mode', emptyAs: 'off' },
{ field: 'entering', emptyAs: null },
{ field: 'priorMode', emptyAs: null },
]);
/** Nested results that are themselves context-mode payloads the model reads. */
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
'context',
'contextRollback',
]);
/**
* Report a context-mode payload in the tools' own vocabulary.
*
* `set_context_mode` accepts 'contacts' while the mode's internal id is
* 'flights'. Reporting the internal id back made the model read
* `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts
* window counts sitting in the very same payload (owner field session
* 2026-08-21). Secondary fields and nested transition/rollback results are
* translated too — one leaked internal id is enough to recreate the confusion,
* and a rollback result is exactly what the model reads when something went
* wrong. Each internal id is kept alongside as `<field>Internal` for anything
* reasoning about layers.
* @param {object|null|undefined} state Any payload carrying context-mode fields.
* @returns {object|null|undefined} The same payload, modes translated.
*/
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;
}
const COCKPIT_ACTION_ALIASES = new Map([
['next', 'next'],
['previous', 'previous'],
@@ -246,14 +192,6 @@ const STACK_ALIASES = new Map([
['road map', 'osm'],
]);
/** Search order for track_entity across entity layer families. */
const TRACKABLE_FAMILIES = [
{ layerId: 'flights', kind: 'aircraft' },
{ layerId: 'military', kind: 'aircraft' },
{ layerId: 'ais-live-vessels', kind: 'vessel' },
{ layerId: 'satellites', kind: 'satellite' },
];
const FRAME_TARGETS = new Map([
['flights', 'flights'],
['planes', 'flights'],
@@ -2553,26 +2491,6 @@ function runManagedVoiceNavigation(
};
}
/** Gathers tracked/selected entities across layer families for read-back. */
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 {
// layer not ready
}
}
return tracked;
}
export async function getBasemapLabelContext(
viewer,
service = defaultGeospatial,
@@ -3210,60 +3128,12 @@ function getCurrentViewState(
dataManager,
sceneDirector = null,
) {
const cartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
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:
typeof styleManager.getContextModeState === 'function'
? {
...withContextModeVocabulary(styleManager.getContextModeState()),
// The numbers on the operator's Contacts panel, so a window/count
// question can be answered from what they are looking at.
...(activeContactsWindow(dataManager)
? { contactsWindow: activeContactsWindow(dataManager) }
: {}),
}
: null,
cockpit:
typeof styleManager.getCockpitState === 'function'
? styleManager.getCockpitState()
: null,
controls:
typeof styleManager.getControlState === 'function'
? styleManager.getControlState()
: null,
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
tracked: collectTrackedEntities(dataManager),
// Enabled layers only: the full registry cost ~1k tokens per call and
// the model only reasons about what is on.
disabledLayerCount: dataManager.getAll().filter((layer) => !layer.enabled)
.length,
layers: dataManager
.getAll()
.filter((layer) => layer.enabled)
.map((layer) => ({
id: layer.id,
name: layer.name,
enabled: layer.enabled,
count: layer.stats?.count || 0,
error: layer.stats?.error || null,
feedState: layerSnapshot(layer).feedState,
source: layerSnapshot(layer).source,
lastUpdate: layerSnapshot(layer).lastUpdate,
})),
feedProvenance: feedProvenanceEnvelope(
layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled),
),
};
return getWorldPacket({
viewer,
styleManager,
dataManager,
sceneDirector,
});
}
async function getEntityContext(
@@ -4635,23 +4505,6 @@ function clampNumber(value, min, max, fallback) {
* ("which of those is closest?") scoped to the session.
*/
/**
* The Contacts panel's own counts, or null when Contacts has no subject.
* Read through the awareness snapshot the panel renders, so the two cannot
* diverge no matter which surface asks.
* @returns {object|null} Panel-equivalent window counts.
*/
function activeContactsWindow(dataManager) {
try {
const layer = dataManager?.layers?.get('military-awareness')?.module;
return (
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
);
} catch {
return null;
}
}
/**
* Most records one layer hands a single query. Above every loaded feed seen
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so