Merge branch 'main' into fix/antimeridian-traffic-bounds

This commit is contained in:
Ashfaq authored and GitHub committed 2026-09-14 07:07:43 +05:30
commit 5c2382dd37
405 files changed
+84572 -55443

No files matched your search

+67 -2106
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -1641,3 +1641,28 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
assert.equal(nameless.id, '366999124');
assert.equal(nameless.mmsi, '366999124');
});
test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false });
const requests = [];
aisLiveVesselsLayer.setSource({
label: 'Test vessel source',
getSnapshot: async () => ({ records: [] }),
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
});
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
try {
harness.record.reference = 'opaque:test-reference';
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 1);
assert.equal(requests[0].reference, 'opaque:test-reference');
assert.ok(requests[0].options.signal instanceof AbortSignal);
assert.equal(aisLiveVesselsLayer.source, 'Test vessel source');
} finally {
harness.cleanup();
_setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource());
}
});
+22 -1674
View File
File diff suppressed because it is too large. Load diff
+87 -4823
View File
File diff suppressed because it is too large. Load diff
+18 -13
View File
@@ -14,6 +14,11 @@
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
// plain node:test.
import { test } from 'node:test';
// Preserve structural pins across component qualification and formatter wrapping.
function componentFunctionSource(fn) {
return fn.toString().replace(/\blayerState\./g, '').replace(/\bparts\.\w+\./g, '')
.replace(/,\s*(?=\))/g, '').replace(/\s+/g, ' ').replace(/\(\s+/g, '(');
}
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -67,8 +72,8 @@ import {
activateCctvCameraFromWorldClick,
} from '../cctvFocusRequest.js';
const UI_SOURCE = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui.js'),
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui', 'cctvPresentation.js'),
'utf8',
);
@@ -208,7 +213,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
});
assert.equal(insertCount, 0, 'building a 100-camera catalog must insert zero coverage entities');
assert.doesNotMatch(cctvLayer.init.toString(), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/);
assert.doesNotMatch(componentFunctionSource(cctvLayer.init), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/);
const activated = materializeCctvActiveCoverageEntities(records[42], build);
assert.equal(activated.length, 5);
@@ -224,7 +229,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
assert.equal(coverageOn.length, 14 * 5);
assert.equal(insertCount, 15 * 5, 'COVERAGE ON builds only remaining visible/eligible sets');
assert.equal(records.filter((record) => record.coverageEntities.length > 0).length, 15);
assert.match(refreshCoverageStyles.toString(), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/);
assert.match(componentFunctionSource(refreshCoverageStyles), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/);
assert.equal(
materializeCctvVisibleCoverageEntities(records, visibleIds, build).length,
0,
@@ -614,7 +619,7 @@ test('CCTV disable→enable defers the active-camera re-probe until its next act
assert.equal(record.activationDone, false);
assert.equal(cctvRecordNeedsActivation('active', 'active', record), true);
assert.doesNotMatch(
cctvLayer.enable.toString(),
componentFunctionSource(cctvLayer.enable),
/setActiveCamera|runActivationObstructionProbe|pickFromRay/,
'enable must restore nominal visuals without entering the activation probe path',
);
@@ -724,11 +729,11 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']);
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
assert.match(cctvLayer.init.toString(), /bindCctvWorldClickGesture\(_clickHandler/);
assert.match(cctvLayer.init.toString(), /_cctvOverlayHost\.hitTest/);
assert.match(cctvLayer.init.toString(), /sourceId: CCTV_OVERLAY_SOURCE_ID/);
assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/);
assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/);
assert.match(componentFunctionSource(cctvLayer.init), /bindCctvWorldClickGesture\(_clickHandler/);
assert.match(componentFunctionSource(cctvLayer.init), /_cctvOverlayHost\.hitTest/);
assert.match(componentFunctionSource(cctvLayer.init), /sourceId: CCTV_OVERLAY_SOURCE_ID/);
assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/);
assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/);
});
// ─── Empty-space deselection and stable null-active state ──────────────────
@@ -795,7 +800,7 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
activeCameraId: 'cam-1',
}), true, 'an ID-less surface pick remains true empty space');
const initSource = cctvLayer.init.toString();
const initSource = componentFunctionSource(cctvLayer.init);
assert.ok(
initSource.indexOf('if (pickedId !== null) return')
< initSource.indexOf('_cctvOverlayHost.hitTest'),
@@ -931,10 +936,10 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
const renderer = UI_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n \}\n/);
const renderer = CCTV_PRESENTATION_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/);
assert.ok(renderer, '_renderCctvState is missing');
assert.match(renderer[0], /else if \(!activeId\)[\s\S]*?selectedIndex = -1/);
assert.match(renderer[0], /_cctvFocusBtn\.disabled = !enabled \|\| cameras\.length === 0 \|\| !activeId/);
assert.match(renderer[0], /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/);
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
+24 -15
View File
@@ -1,3 +1,5 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -14,9 +16,8 @@ import militaryFlightsLayer, {
const SUBJECT = 'abc123';
const NEXT_SUBJECT = 'def456';
const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
const FLIGHTS_SOURCE = readLayerSource(new URL('./flights.js', import.meta.url));
const MILITARY_SOURCE = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
const LAYERS = [
{
@@ -84,27 +85,35 @@ function candidateIds(layer) {
}
test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => {
const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/
.exec(UI_SOURCE)?.[0];
assert.ok(dispatcher, 'Cockpit event dispatcher is defined');
assert.match(dispatcher, /info\?\.icao24/);
assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/);
assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/);
assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/);
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/,
'entry and in-Cockpit handoff publish the active subject');
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/,
const previousWindow = globalThis.window;
const details = [];
globalThis.window = { dispatchEvent: event => details.push(event.detail) };
try {
dispatchCockpitModeChanged(true, { icao24: ' ABC123 ', layerId: 'military' });
dispatchCockpitModeChanged(true, { icao24: ' DEF456 ', layerId: 'other' });
dispatchCockpitModeChanged(false);
assert.deepEqual(details, [
{ active: true, subjectId: 'abc123', layerId: 'military' },
{ active: true, subjectId: 'def456', layerId: null },
{ active: false, subjectId: null, layerId: null },
]);
} finally { globalThis.window = previousWindow; }
for (const action of [enter, _adoptTrackedEntity]) {
assert.match(action.toString(), /this\.dispatchCockpitModeChanged\(true, info\);/,
'entry and in-Cockpit handoff each publish the active subject');
}
assert.match(exit.toString(), /this\.dispatchCockpitModeChanged\(false\);/,
'exit clears the active subject');
for (const [name, source] of [
['commercial', FLIGHTS_SOURCE],
['military', MILITARY_SOURCE],
]) {
const consumer = /function _applyCockpitState\(detail = \{\}\) \{[\s\S]*?\n\}/
const consumer = /function\s*(?:parts\.\w+\.)?_applyCockpitState\(\s*detail\s*=\s*\{\},?\s*\)\s*\{[\s\S]*?\n {0,2}\}/
.exec(source)?.[0];
assert.ok(consumer, `${name} Cockpit consumer is defined`);
assert.match(consumer, /detail\?\.subjectId/);
assert.match(consumer, /\.trim\(\)\.toLowerCase\(\)/);
assert.match(consumer, /\.trim\(\s*,?\s*\)\s*\.toLowerCase\(\s*,?\s*\)/);
assert.doesNotMatch(consumer, /layerId/,
`${name} must also suppress a duplicate subject originating in the sibling AIR feed`);
}
+22 -21
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -25,7 +26,7 @@ function numericConstant(source, name) {
}
for (const layer of LAYERS) {
const source = readFileSync(layer.path, 'utf8');
const source = readLayerSource(layer.path);
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)];
@@ -37,11 +38,11 @@ for (const layer of LAYERS) {
});
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
const regime = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(regime, '_modelRegimeActive is defined');
assert.match(regime, /if \(!_models3dEnabled\) return false;/,
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
'OFF must keep Cockpit AIR contacts in 2D');
assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/,
assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/,
'Cockpit must not bypass the owner-visible Display toggle');
});
@@ -51,13 +52,13 @@ for (const layer of LAYERS) {
// owns. The tracked regime is DEFAULT-ON by camera distance (2026-08-19), so
// it no longer routes through the toggle-gated `_modelRegimeActive` — the
// suppression is now an explicit early return.
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(regime, '_trackedModelRegimeActive is defined');
assert.match(regime, /if \(!_trackedIcao \|\| _cockpitContactMode \|\|[\s\S]*?return false;/,
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
'_trackedModelRegimeActive excludes cockpit');
const tracked = /function _updateTrackedModel\(\)[\s\S]*?\n if \(!active\)/.exec(source)?.[0];
const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(tracked, '_updateTrackedModel is defined');
assert.match(tracked, /_trackedModelRegimeActive\(\)/,
assert.match(tracked, /(?:parts\.\w+\.)?_trackedModelRegimeActive\(\s*,?\s*\)/,
'the tracked-model driver uses the cockpit-aware predicate');
});
@@ -68,15 +69,15 @@ for (const layer of LAYERS) {
assert.equal(numericConstant(source, 'MODEL_ALL_KEEP_M'), 450_000);
assert.equal(numericConstant(source, 'COCKPIT_MODEL_MAX'), 60);
const add = /function _modelAddDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
const keep = /function _modelKeepDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
assert.match(add, /_models3dMode === 'all' \? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M/);
assert.match(keep, /_models3dMode === 'all' \? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M/);
const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/);
assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/);
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
const cap = /function _modelCap\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
assert.match(cap, /Math\.min\(COCKPIT_MODEL_MAX/,
const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/,
'Cockpit keeps its 60-model performance ceiling');
});
@@ -88,19 +89,19 @@ for (const layer of LAYERS) {
// `_iconKind` is identity for every unconverted contact (see
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
// operator explicitly converted into a TR-3B.
assert.match(source, /bb\.image = aircraftIcon\(_iconKind\(icao24, meta\?\.klass\)(, bb\._gevIconLarge \? TRACKED_ICON_PX : undefined)?\)/,
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/,
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
assert.match(source, /bb\.rotation = 0;/,
assert.match(source, /bb\.rotation\s*=\s*0;/,
'far dots are reset to a rotation-free presentation');
assert.match(source, /\(!_cockpitContactMode \|\| isCockpitNear\) && \(doRotations \|\| revealed\)/,
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/,
'near 2D silhouettes continue to receive projected course');
assert.match(source, /if \(bb\.show\) bb\.show = false; \/\/ hand off ONLY once the model renders/,
assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
'the gap-proof billboard-to-model handoff remains intact');
});
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
const setMode = /function _setCockpitContactMode\([\s\S]*?\n\}/.exec(source)?.[0];
assert.match(setMode, /else _cockpitNearContacts = new Set\(\);/);
assert.match(setMode, /for \(const \[icao24, bb\] of _billboards\) _applyFleetBillboardPresentation\(icao24, bb\);/);
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/);
});
}
+3 -2
View File
@@ -671,7 +671,8 @@ test('pathological detection paint holds alternate frames without freezing share
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8')
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
assert.doesNotMatch(source, /postRender\.addEventListener/);
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
@@ -694,7 +695,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
// three times" — same guarantee, and the copies can no longer drift.
assert.match(
uiSource,
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/,
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/,
'the tactical detection default is still Dense @ 75%',
);
assert.equal(
+3 -2
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
// src/data/detectionRenderDemand.test.mjs
//
// Detection must not hold the render loop open — and must not drop work on the
@@ -427,8 +428,8 @@ test('the render-governor gate covers the parked case, with teeth on the painter
// promptness goes with them, silently. This test is where that shows up.
test('aircraft brackets stay prompt because the aircraft layers hold the render loop', async () => {
for (const file of ['./flights.js', './militaryFlights.js']) {
const source = await readFile(new URL(file, import.meta.url), 'utf8');
const enable = /\n enable\([\s\S]*?\n \},/.exec(source)?.[0];
const source = readLayerSource(new URL(file, import.meta.url));
const enable = /\n([ \t]*)enable\([\s\S]*?\n\1\},/.exec(source)?.[0];
assert.ok(enable, `${file}: enable() is still identifiable`);
assert.match(
enable,
+16 -336
View File
@@ -1,342 +1,22 @@
import * as Cesium from 'cesium';
import {
createEarthquakesLayer as createLayer,
createUsgsEarthquakeSource,
} from '../layers/earthquakes/index.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
/**
* USGS earthquake discs — last 24 hours, M2.5+.
*
* Ellipse axes are STATIC (plain numbers), redefined only when a poll brings
* new data. They must never become a `CallbackProperty` again: every entity
* here is a `CLAMP_TO_GROUND` ellipse, and a per-frame axis re-tessellates its
* ground primitive on EVERY frame. Measured on the shipped 58-event feed
* (2026-08-20 QA hunt, parked camera over SF at 40 km):
*
* 58 discs, callback axes → 32.4 ms/frame, 30 fps
* 58 discs, static axes → 1.4 ms/frame, 60 fps
*
* The former ±15 % radius "pulse" was DROPPED to buy that back: an
* imperceptible breathing wobble on a translucent ground disc is not worth
* rebuilding 58 ground primitives 60 times a second. With no per-frame
* animator left, the layer also no longer holds the render governor
* continuous — the manager's `layer-tick` / `layer-visibility` requests
* already cover every discrete mutation this layer makes.
*/
const API_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
const DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
/**
* Color by depth:
* - Shallow (<70km): Red
* - Intermediate (70-300km): Orange
* - Deep (>300km): Yellow
*/
function depthColor(depthKm) {
if (depthKm < 70) return Cesium.Color.RED;
if (depthKm < 300) return Cesium.Color.ORANGE;
return Cesium.Color.YELLOW;
export * from '../layers/earthquakes/index.js';
/** Wire the standalone source and application overlay owner. */
export function createEarthquakesLayer({
source = createUsgsEarthquakeSource(),
overlayHost = {
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
},
} = {}) {
return createLayer({ source, overlayHost });
}
/**
* Build the source-owned presentation for one ambient magnitude label.
* Magnitude formatting deliberately remains here instead of moving into the
* shared renderer.
* @param {object} input
* @param {string} input.id Stable USGS or deterministic fallback id.
* @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse.
* @param {number} input.magnitude USGS magnitude.
* @param {string} input.accent Source-owned depth-band color.
* @returns {object}
*/
export function createEarthquakeOverlayEntry({ id, position, magnitude, accent }) {
const mag = Number(magnitude);
return {
id: String(id),
position,
variant: 'label',
title: `M${mag.toFixed(1)}`,
accent,
priority: Math.round(mag * 1000),
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 15,
verticalOnly: true,
placement: 'above',
};
}
/** Keep the largest events, with stable identity as the tie-break. */
export function selectEarthquakeOverlayCohort(
entries,
limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT,
) {
const cap = Math.max(0, Math.min(
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
Math.floor(Number(limit) || 0),
));
if (!Array.isArray(entries) || cap === 0) return [];
return entries.slice().sort((a, b) => (
b.priority - a.priority || String(a.id).localeCompare(String(b.id))
)).slice(0, cap);
}
/**
* Map one earthquake's raw plain values to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Falls back to an index-based id
* when the USGS event id is absent.
* @param {Object|null|undefined} raw - Plain values pulled off the entity:
* {id, mag, place, time, depth, lat, lon}.
* @param {number} [index=0] - Position in the snapshot (fallback id only).
* @returns {{id: string, magnitude: number|null, depthKm: number|null,
* lat: number|null, lon: number|null, timeMs: number|null, place: string|null}}
*/
export function mapAnalystRecord(raw, index = 0) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => { const t = String(v ?? '').trim(); return t || null; };
return {
id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`,
magnitude: num(raw?.mag),
depthKm: num(raw?.depth),
lat: num(raw?.lat),
lon: num(raw?.lon),
timeMs: num(raw?.time), // USGS epoch ms
place: text(raw?.place),
};
}
/** Validate a complete feed before replacing the last good earthquake snapshot. */
export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const [index, feature] of geojson.features.entries()) {
const coordinates = feature?.geometry?.coordinates;
const properties = feature?.properties;
if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties
|| typeof properties !== 'object' || Array.isArray(properties)
|| (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null;
const [lon, lat, depthKm] = coordinates;
const mag = properties.mag;
if (!Number.isFinite(lon) || Math.abs(lon) > 180
|| !Number.isFinite(lat) || Math.abs(lat) > 90
|| (depthKm != null && !Number.isFinite(depthKm))
|| (mag != null && (!Number.isFinite(mag) || mag > 10))) return null;
// A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue;
const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id);
if (ids.has(stableId)) return null;
ids.add(stableId);
rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null,
mag, place: typeof properties.place === 'string' ? properties.place : null,
time: Number.isFinite(properties.time) ? properties.time : null });
}
return rows;
}
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
const layer = {
id: 'earthquakes',
name: 'Earthquakes (24h)',
icon: '🌋',
source: 'USGS',
updateInterval: 60000,
init(viewer) {
_dataSource = new Cesium.CustomDataSource('earthquakes');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
console.log('[Data:Earthquakes] Initialized');
},
enable(viewer) {
_enabled = true;
// No continuous-render hold: the discs are static geometry now, so the
// layer has no per-frame animator to keep the render loop alive for.
if (_dataSource) _dataSource.show = true;
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
},
disable(viewer) {
_enabled = false;
if (_dataSource) _dataSource.show = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
},
async update(viewer) {
try {
const response = await fetch(API_URL);
if (!response.ok) {
_lastError = `USGS HTTP ${response.status}`;
console.warn(`[Data:Earthquakes] API returned ${response.status}`);
return false;
}
const geojson = await response.json();
const rows = normalizeEarthquakeSnapshot(geojson);
if (!rows) {
_lastError = 'Malformed USGS response';
return false;
}
const nextEntities = [];
let count = 0;
const overlayEntries = [];
for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) {
count++;
const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 0);
const isSignificant = mag >= 5.0;
const fillAlpha = isSignificant ? 0.4 : 0.3;
const outlineAlpha = isSignificant ? 1.0 : 0.8;
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
nextEntities.push(new Cesium.Entity({
id: `earthquake:${stableId}`,
position,
ellipse: {
// Static axes — see the module header. A CallbackProperty here
// re-tessellates the clamped ground geometry every frame.
semiMajorAxis: baseRadius,
semiMinorAxis: baseRadius,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(fillAlpha)
),
outline: true,
outlineColor: color.withAlpha(outlineAlpha),
outlineWidth: isSignificant ? 3 : 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
properties: {
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
usgsId,
mag,
place,
time,
depth: depthKm,
},
}));
overlayEntries.push(createEarthquakeOverlayEntry({
id: String(stableId),
position,
magnitude: mag,
accent: color.toCssColorString(),
}));
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
if (_enabled) {
overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID,
selectEarthquakeOverlayCohort(overlayEntries),
{
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
}
_count = count;
_lastUpdate = Date.now();
_lastError = null;
console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`);
return true;
} catch (e) {
console.warn('[Data:Earthquakes] Fetch error:', e);
_lastError = 'USGS network error';
return false;
}
},
destroy(viewer) {
_enabled = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_count = 0;
_lastUpdate = null;
_lastError = null;
},
/**
* Snapshot the layer's in-memory earthquake records as plain JSON-safe
* objects for the analyst query engine. On-demand only (called at most
* once per spoken query) — zero per-frame cost, no listeners, no caching.
* Returns [] while the layer is disabled or empty.
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const entities = _dataSource.entities.values;
if (!entities.length) return [];
const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000;
const now = Cesium.JulianDate.now();
const result = [];
for (const entity of entities) {
if (result.length >= limit) break;
const cartesian = entity.position ? entity.position.getValue(now) : null;
const carto = cartesian ? Cesium.Cartographic.fromCartesian(cartesian) : null;
const p = entity.properties;
result.push(mapAnalystRecord({
id: p?.usgsId?.getValue(now) ?? null,
mag: p?.mag?.getValue(now),
place: p?.place?.getValue(now),
time: p?.time?.getValue(now),
depth: p?.depth?.getValue(now),
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
}, result.length));
}
return result;
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
};
},
};
return layer;
}
const earthquakesLayer = createEarthquakesLayer();
export default earthquakesLayer;
export default createEarthquakesLayer();
+2 -1
View File
@@ -294,7 +294,8 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
});
test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => {
const source = readFileSync(new URL('./earthquakes.js', import.meta.url), 'utf8');
const source = ['index', 'model', 'source'].map(name =>
readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n');
assert.doesNotMatch(
source,
/new Cesium\.CallbackProperty/,
+9 -87
View File
@@ -1,87 +1,9 @@
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
// ~1-2 km below the visible surface).
//
// The fix rides the height-datum ship's machinery end to end: anchors read
// the SAME shared ground floor every other ground-adjacent consumer uses
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
// cell), and cold cells warm through the same batched, chunked, cached
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
// fires are static, and floor cells latch warm for the session, so each
// rendered detection costs at most one DEM lookup EVER — and detections
// sharing a ~111 m coarse cell share that lookup.
import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js';
/** @constant {number} Metres above the resolved floor for a fire anchor.
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
* a few metres of lift biases "slightly above the visible surface" (owner
* principle) without a visible hover on the screen-sized sprite. */
export const FIRE_ANCHOR_LIFT_M = 5;
/**
* Synchronous anchor height for one detection: shared ground floor + lift
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
* exactly as before until their floor lands). Warm-cache read only; never
* triggers network or sampling.
* @param {number} lat
* @param {number} lon
* @returns {number} Ellipsoidal anchor height in metres.
*/
export function fireAnchorHeight(lat, lon) {
const floor = cachedGroundFloor(lat, lon);
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
}
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
* sequentially so overlapping renders can't stack concurrent requests on
* the single dev-server proxy (same courtesy as terrainHeights' sequential
* chunking). Each queued batch re-filters against the warm cache when it
* actually runs, so cells resolved by an earlier batch are never refetched. */
let _chain = null;
/**
* Batched warm of the ground-floor cells behind a rendered detection set.
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
* one requested point actually gained a warm floor — a failed resolve (proxy
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
* `false`, so a render → warm → re-render chain terminates instead of
* looping; the next natural rebuild retries and the cache self-heals.
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
* @returns {Promise<boolean>} Whether any requested point warmed.
*/
export function warmFireAnchorFloors(points) {
const cold = collectCold(points);
if (!cold.length) return Promise.resolve(false);
const prev = _chain;
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
_chain = run.then(() => true, () => false);
return run;
}
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
function collectCold(points) {
if (!Array.isArray(points)) return [];
const cold = [];
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
cold.push({ lat: p.lat, lon: p.lon });
}
return cold;
}
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
async function resolveBatch(points) {
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
if (!cold.length) return false;
try {
await resolveGroundFloorCells(cold);
} catch { /* resolver is best-effort and never throws; belt and braces */ }
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
}
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
export function _resetFireAnchorsForTest() {
_chain = null;
}
import { createFireAnchors } from '../layers/firms/anchors.js';
import * as groundFloor from './groundFloor.js';
export { FIRE_ANCHOR_LIFT_M } from '../layers/firms/anchors.js';
const anchors = createFireAnchors(groundFloor);
export const {
fireAnchorHeight,
warmFireAnchorFloors,
_resetFireAnchorsForTest,
} = anchors;
+45 -1602
View File
File diff suppressed because it is too large. Load diff
+22 -3
View File
@@ -9,11 +9,11 @@ import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import fs from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LAYER_SOURCE = fs.readFileSync(
const LAYER_SOURCE = readLayerSource(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
'utf8',
);
@@ -40,7 +40,7 @@ function makeFire(overrides = {}) {
*/
function harness({
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
withDataSource = false,
withDataSource = false, feed = null,
} = {}) {
const hadWindow = Object.hasOwn(globalThis, 'window');
const priorWindow = globalThis.window;
@@ -58,6 +58,7 @@ function harness({
const published = [];
let handler = null;
const layer = createFirmsHeatmapLayer({
...(feed ? { feed } : {}),
id: 'firms-test',
name: 'Fires',
overlayHost: {
@@ -349,3 +350,21 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
h.cleanup();
}
});
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
let resolve, signal;
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
signal = options.signal; return new Promise(done => { resolve = done; });
} } });
try {
const before = h.layer.getStats();
const pending = h.layer.update();
h.layer.disable();
assert.equal(signal.aborted, true);
resolve({ fires: [], fetchedAt: Date.now() });
await pending;
assert.equal(h.layer.getStats().count, before.count);
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
+3 -3
View File
@@ -4,6 +4,6 @@
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
z12 x935 y1686, captured 2026-07-16 from
`api.tomtom.com/traffic/map/4/tile/flow/relative/12/935/1686.pbf`
(22,980 bytes). Used ONLY by `src/data/flowTiles.test.mjs` to pin MVT
decoding offline — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never served to the app. © TomTom.
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
browser mode — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never loaded by ordinary application startup. © TomTom.
+73 -5336
View File
File diff suppressed because it is too large. Load diff
+6 -161
View File
@@ -1,161 +1,6 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tilesForBounds } from './tomtomTiles.js';
/**
* @file TomTom traffic-flow vector-tile client: fetch + MVT decode.
*
* Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy
* (the TomTom key never reaches the browser) and decodes the Mapbox Vector
* Tile layer "Traffic flow" into plain lon/lat polylines with congestion
* attributes. Consumed by the traffic layer's live mode
* (`src/data/traffic.js` → `src/data/flowMatch.js`).
*
* Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string,
* closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed
* ratio (1 = free flow). Features with a missing/non-finite `traffic_level`
* are skipped unless `road_closure` is true (closures decode with level 0).
*
* Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and
* tree-shakeable; decoding happens client-side so the proxy stays a dumb
* binary cache.
*
* @module data/flowTiles
*/
export { tilesForBounds };
/** @const {string} MVT layer name in TomTom flow tiles (verified live 2026-07-16). */
const FLOW_LAYER_NAME = 'Traffic flow';
/** @const {number} Ms — per-tile decode cache TTL (matches the proxy's 120 s tile TTL). */
const DECODE_CACHE_TTL_MS = 120_000;
/** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */
const DECODE_CACHE_MAX_ENTRIES = 64;
/**
* Decoded-tile cache keyed by "z/x/y".
* @type {Map<string, {at:number, segments:Array}>}
*/
const _decodeCache = new Map();
/** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */
let _tilesFetched = 0;
/**
* Decode one TomTom flow tile (MVT protobuf) into flow segments.
*
* @param {Uint8Array|Buffer|ArrayBuffer} data - Raw .pbf tile bytes.
* @param {number} z - Tile zoom (for tile-local → lon/lat projection).
* @param {number} x - Tile column.
* @param {number} y - Tile row.
* @returns {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>}
* Flow polylines in [lon, lat] degrees. Returns [] for undecodable buffers
* or tiles without a "Traffic flow" layer (defensive — a corrupt tile must
* not kill the traffic layer).
*/
export function decodeFlowTile(data, z, x, y) {
let layer;
try {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const tile = new VectorTile(new PbfReader(bytes));
layer = tile.layers[FLOW_LAYER_NAME];
} catch {
return [];
}
if (!layer) return [];
const segments = [];
for (let i = 0; i < layer.length; i++) {
let feature;
let geometry;
try {
feature = layer.feature(i);
geometry = feature.toGeoJSON(x, y, z).geometry;
} catch {
continue; // one malformed feature must not drop the tile
}
const props = feature.properties || {};
const closure = props.road_closure === true || props.road_closure === 'true';
const rawLevel = props.traffic_level;
const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel);
// Skip features we can't color — unless closed (closures render dot-free
// regardless of level, so they stay useful without one).
if (!hasLevel && !closure) continue;
const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0;
const roadType = typeof props.road_type === 'string' ? props.road_type : '';
const lines = geometry.type === 'LineString'
? [geometry.coordinates]
: geometry.type === 'MultiLineString'
? geometry.coordinates
: [];
for (const coords of lines) {
if (!Array.isArray(coords) || coords.length < 2) continue;
segments.push({ coords, trafficLevel, roadType, closure });
}
}
return segments;
}
/** Insert into the decode cache with oldest-entry eviction. */
function cacheSet(key, entry) {
if (!_decodeCache.has(key) && _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES) {
const oldest = _decodeCache.keys().next().value;
_decodeCache.delete(oldest);
}
_decodeCache.set(key, entry);
}
/**
* Fetch + decode all flow tiles covering the given bounds.
*
* Tiles are fetched from the local proxy in parallel; each decoded tile is
* cached in memory for 120 s (keyed z/x/y), so repeat calls for the same
* viewport are free. Partial tile failures return the segments that DID
* decode (last-good philosophy); the promise rejects only when every tile
* failed (e.g. keyless 503, aborted signal, proxy down).
*
* @param {{south:number, west:number, north:number, east:number}} bounds - Degrees.
* @param {Object} [opts]
* @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled).
* @param {number} [opts.zoom=12] - Flow tile zoom level.
* @returns {Promise<Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>>}
* Flat array of flow segments across all covering tiles.
*/
export async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
const tiles = tilesForBounds(bounds, zoom);
if (tiles.length === 0) return [];
const now = Date.now();
const results = await Promise.allSettled(tiles.map(async ({ z, x, y }) => {
const key = `${z}/${x}/${y}`;
const cached = _decodeCache.get(key);
if (cached && now - cached.at < DECODE_CACHE_TTL_MS) return cached.segments;
_tilesFetched += 1;
const res = await fetch(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { signal });
if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`);
const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y);
cacheSet(key, { at: Date.now(), segments });
return segments;
}));
const fulfilled = results.filter((r) => r.status === 'fulfilled');
if (fulfilled.length === 0) {
throw results[0].reason instanceof Error ? results[0].reason : new Error('flow fetch failed');
}
return fulfilled.flatMap((r) => r.value);
}
/**
* Session diagnostics for `getStats()` surfaces.
* @returns {{tilesFetched:number}} Count of tile requests issued to the proxy
* this session (decode-cache hits excluded).
*/
export function getFlowSessionStats() {
return { tilesFetched: _tilesFetched };
}
/** Clear the decode cache (tests + layer teardown). Session stats persist. */
export function resetFlowTileCache() {
_decodeCache.clear();
}
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
export { tilesForBounds } from './tomtomTiles.js';
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
const source = createFlowTileSource();
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
source;
+6 -3
View File
@@ -5,14 +5,17 @@ export function installationFeedback(stats = {}, now = Date.now()) {
timeout: 'Overpass timed out',
query_failed: 'Overpass could not complete the query',
};
const reason = reasons[stats.failureReason] || 'Overpass temporarily unavailable';
if (stats.loading) return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
const reason =
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
if (stats.loading)
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
if (stats.retryAt > 0) {
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
}
if (stats.status === 'unavailable') return reason;
if (stats.status === 'zoom-in') return 'Zoom in to search mapped installations';
if (stats.status === 'zoom-in')
return 'Zoom in to search mapped installations';
if (stats.stale) return 'Showing cached mapped sites';
if (stats.status === 'idle') return 'Mapped sites not loaded';
return 'Mapped sites loaded';
+8 -7
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -365,21 +366,21 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
// first-run session actually boots from.
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
assert.match(source, /^let _models3dEnabled = true;$/m,
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
assert.match(source, /^\s*(?:let |flightState\.)_models3dEnabled = true;$/m,
`${name}: the fleet starts armed, matching the codec default`);
assert.match(source, /^let _models3dMode = 'proximity';/m,
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
`${name}: and starts in proximity, matching the codec default`);
}
const ui = await readFile(new URL('../ui.js', import.meta.url), 'utf8');
assert.match(ui, /^\s*this\._models3dEnabled = true;$/m,
const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
assert.match(ui, /this\._models3dMode = 'proximity';/,
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity');
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8');
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
assert.match(ui, /this\._models3dBtn\?\.setAttribute\('aria-pressed', String\(this\._models3dEnabled\)\)/,
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only');
assert.match(html, /class="pp-slider-row visible" id="models3d-mode-row"/,
'index.html: and the Proximity/All row paints open with it');
+61 -17
View File
@@ -354,6 +354,17 @@ export function createLocalGeoJsonLayer(
let _destroyed = false;
let _loadPromise = null;
let _loadController = null;
/**
* The parsed bundled dataset, kept across disable/enable so a re-enable
* rebuilds entities without refetching. The Cesium entities themselves are
* NOT kept: a hidden data source still costs every frame (DataSourceDisplay
* walks every visualizer over every entity regardless of `show`), and the
* ~11k entities of the three bundled layers hold hundreds of MB, so leaving
* them parked after a toggle-off made the whole scene render ~2× slower for
* the rest of the session (owner field test 2026-09-13).
* @type {Array<object>|null}
*/
let _cachedFeatures = null;
/**
* Globe-LOD active set: the record ids allowed to carry a live stem right
* now. This bounds geometry refreshes and ground-sample work to the
@@ -432,6 +443,27 @@ export function createLocalGeoJsonLayer(
host: overlayHost,
});
/**
* Take the built entities out of the scene entirely. The parsed features
* stay cached, so the next enable() rebuilds without a fetch; what must not
* survive a disable is the per-frame visualizer walk and the entity memory.
* @param {object} viewer
*/
const releaseDataSource = (viewer) => {
if (!_dataSource) return;
const source = _dataSource;
_dataSource = null;
_stemRecords = [];
_stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
removeEntityContextsForLayer(id);
try {
viewer?.dataSources?.remove(source, true);
} catch {
/* already gone */
}
};
const disableLayer = (viewer) => {
_enabled = false;
clearGroundRetryRender();
@@ -440,7 +472,7 @@ export function createLocalGeoJsonLayer(
_lastLodBudgetLimit = 0;
_lodComputed = false;
_lastLodProbeMs = Number.NEGATIVE_INFINITY;
if (_dataSource) _dataSource.show = false;
releaseDataSource(viewer);
_overlayPublisher.hide();
clearSelectedEntityContextForLayer(id);
if (viewer?.selectedEntity?.__localLayerId === id) {
@@ -529,20 +561,26 @@ export function createLocalGeoJsonLayer(
// windows (before vs after the add settles) need different cleanup.
let addedToScene = false;
try {
const response = await fetch(url, {
signal: _loadController.signal,
});
if (_destroyed) return;
// A 404 returns an HTML body that would otherwise die in JSON.parse
// one line later, reported as a parse error for a missing file.
if (!response.ok) {
throw new Error(`HTTP ${response.status ?? '?'}`);
}
const text = await response.text();
if (_destroyed) return;
const lines = text.split('\n').filter((l) => l.trim().length > 0);
let features = _cachedFeatures;
if (!features) {
const response = await fetch(url, {
signal: _loadController.signal,
});
if (_destroyed) return;
// A 404 returns an HTML body that would otherwise die in JSON.parse
// one line later, reported as a parse error for a missing file.
if (!response.ok) {
throw new Error(`HTTP ${response.status ?? '?'}`);
}
const text = await response.text();
if (_destroyed) return;
const lines = text
.split('\n')
.filter((l) => l.trim().length > 0);
const features = lines.map((line) => JSON.parse(line));
features = lines.map((line) => JSON.parse(line));
_cachedFeatures = features;
}
const geojson = {
type: 'FeatureCollection',
@@ -991,9 +1029,14 @@ export function createLocalGeoJsonLayer(
}
// Honor a disable() that landed while we were awaiting the fetch/parse:
// disable() runs before _dataSource exists, so its show=false is a no-op —
// reading _enabled here (rather than forcing true) respects the toggle-off.
if (_dataSource) _dataSource.show = _enabled;
// disable() runs before _dataSource exists, so its release is a no-op —
// release the finished build here instead of parking it hidden in the
// scene (the parsed features stay cached for the next enable).
if (!_enabled) {
releaseDataSource(viewer);
return;
}
if (_dataSource) _dataSource.show = true;
viewer.scene.requestRender?.();
},
@@ -1016,6 +1059,7 @@ export function createLocalGeoJsonLayer(
}
_overlayPublisher.destroy();
_dataSource = null;
_cachedFeatures = null;
_stemRecords = [];
_count = 0;
_lastUpdate = null;
+30 -5
View File
@@ -54,7 +54,8 @@ test('destroy removes context records and permits a fresh replacement', async t
const oldEntity = [...getContextStore().entities.values()][0].entity;
assert.equal(getContextStore().entities.size, 1);
env.layer.disable(env.viewer);
assert.equal(getContextStore().entities.size, 1, 'disabled cached source retains its records');
assert.equal(getContextStore().entities.size, 0, 'disable releases its records with the source');
assert.equal(env.sources.size, 0, 'disable removes the built source from the scene');
env.layer.destroy(env.viewer);
assert.equal(getContextStore().entities.size, 0);
assert.equal(env.sources.size, 0);
@@ -126,17 +127,41 @@ test('concurrent enable and disable/re-enable share one pending dataset load', a
assert.equal([...env.sources][0].show, true);
});
test('disable during loading keeps the completed source hidden until re-enabled', async t => {
test('disable during loading releases the completed source; re-enable rebuilds without refetching', async t => {
const env = harness(t);
const release = deferred();
t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
const fetchMock = t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
const loading = env.layer.enable(env.viewer);
env.layer.disable(env.viewer);
release.resolve();
await loading;
assert.equal([...env.sources][0].show, false);
assert.equal(env.sources.size, 0, 'a build that finishes after disable() is released, not parked hidden');
assert.equal(env.listeners.size, 0);
assert.equal(getContextStore().entities.size, 0);
await env.layer.enable(env.viewer);
assert.equal([...env.sources][0].show, true);
assert.equal(env.sources.size, 1);
assert.equal([...env.sources][0].show, true);
assert.equal(fetchMock.mock.callCount(), 1, 'the parsed dataset is cached across the toggle');
});
test('a toggle-off frees the entities and a toggle-on rebuilds them from the cached dataset', async t => {
const env = harness(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async () => response());
await env.layer.enable(env.viewer);
const first = [...env.sources][0];
assert.equal(first.entities.values.length, 1);
assert.equal(getContextStore().entities.size, 1);
env.layer.disable(env.viewer);
assert.equal(env.sources.size, 0, 'no data source stays registered while the layer is off');
assert.equal(getContextStore().entities.size, 0);
assert.equal(env.layer.getLodDiagnostics().total, 0);
assert.equal(env.layer.getStats().count, 1, 'the row keeps reporting the dataset size');
await env.layer.enable(env.viewer);
const second = [...env.sources][0];
assert.notEqual(second, first, 're-enable builds a fresh source');
assert.equal(second.show, true);
assert.equal(second.entities.values.length, 1);
assert.equal(getContextStore().entities.size, 1);
assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch for a bundled dataset');
assert.equal(env.layer.getLodDiagnostics().total, 1);
});
+25 -330
View File
@@ -1,5 +1,6 @@
import { governorRequestRender } from '../renderGovernor.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { LayerPanel } from '../ui/layers.js';
export { layerFeedState } from '../ui/layers.js';
import { markDetectionSourcesChanged } from './detection.js';
function cloneLayerParams(value) {
if (Array.isArray(value)) return value.map(cloneLayerParams);
@@ -11,15 +12,6 @@ function cloneLayerParams(value) {
return value;
}
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
'enabled-only',
@@ -65,53 +57,6 @@ function refreshFailureFromStats(stats, label) {
return null;
}
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError = state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
if (
(presentedError || state.unavailable === true || state.available === false)
&& !hasPriorData
&& !GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true
|| status === 'fallback'
|| state.mode === 'sim'
|| /\bfallback\b/i.test(source)
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded
|| presentedError
|| state.unavailable === true
|| state.available === false
) return 'degraded';
return 'nominal';
}
/**
* DataLayerManager — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe.
@@ -1894,6 +1839,9 @@ export class DataLayerManager {
* Should be called when the viewer is being torn down.
*/
async destroyAll() {
this._layerPanel?.destroy();
this._layerPanel = null;
this._toggleContainer = null;
for (const layerId of [...this.layers.keys()]) {
await this.destroyLayer(layerId);
}
@@ -2019,98 +1967,6 @@ export class DataLayerManager {
this._renderToggles();
}
_renderToggles() {
if (!this._toggleContainer) return;
this._toggleContainer.innerHTML = '';
for (const layer of this.getAll()) {
if (!layer.showInTogglePanel) continue;
const row = document.createElement('div');
row.className = 'data-toggle-row';
row.dataset.layerId = layer.id;
const topRow = document.createElement('div');
topRow.className = 'data-toggle-top';
const left = document.createElement('div');
left.className = 'data-toggle-left';
left.innerHTML = `<span class="data-icon">${layer.icon}</span><span class="data-name">${layer.name}</span>`;
const right = document.createElement('div');
right.className = 'data-toggle-right';
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
toggle.addEventListener('click', async () => {
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
const current = this.getAll().find(({ id }) => id === layer.id);
if (current) this._syncToggleButton(toggle, current);
}
});
right.appendChild(count);
right.appendChild(toggle);
topRow.appendChild(left);
topRow.appendChild(right);
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
bottomRow.textContent = this._buildMetaText(layer);
row.appendChild(topRow);
row.appendChild(bottomRow);
// Optional per-layer sub-controls (chips + color legend). The click
// listener is delegated and attached once here, so it survives
// _refreshTogglePanel — which only rewrites the container's contents.
const rowModule = this.layers.get(layer.id)?.module;
if (typeof rowModule?.getRowControls === 'function') {
// A layer whose controls settle asynchronously (a chunked catalog load
// that can also fail) pushes a re-render through this; nothing else
// would repaint the row before its next scheduled refresh.
rowModule.setRowControlsListener?.(() => this._refreshTogglePanel());
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
controls.addEventListener('click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips
?.find((entry) => entry.id === button.dataset.chipId);
if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
}
this._toggleContainer.appendChild(row);
}
}
/**
* Read a layer's optional row-control descriptor, tolerating a throw so one
* misbehaving layer cannot blank the whole panel. Resolved from the registry
* rather than the `getAll()` projection, which deliberately omits `module`.
* @param {string} layerId Registered layer id.
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
*/
_rowControlsFor(layerId) {
const module = this.layers.get(layerId)?.module;
if (typeof module?.getRowControls !== 'function') return null;
@@ -2122,186 +1978,25 @@ export class DataLayerManager {
}
}
/**
* Render a layer's row chips and color legend, and keep the whole block
* hidden while the layer is off (or while a dependency owner has surrendered
* it) so a quiet row stays quiet.
*
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
* rebuilt: this runs on every panel refresh — including the one the chip's
* own click triggers — and replacing the node would drop keyboard focus
* mid-interaction. Legend entries hold no focus and no listeners, so they
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
*/
_syncRowControls(container, layer) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
}
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
}
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
container.appendChild(button);
}
const state = chip.state || (chip.active ? 'active' : 'idle');
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
if (button.textContent !== chip.label) button.textContent = chip.label;
button.title = chip.title || '';
button.disabled = Boolean(chip.disabled);
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
}
for (const node of stale.values()) node.remove();
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
}
}
_refreshTogglePanel() {
if (!this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
// refresh on return. (perf wave 2)
if (typeof document !== 'undefined' && document.hidden) {
this._panelRefreshPendingOnVisible = true;
return;
}
for (const layer of this.getAll()) {
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
if (!row) continue;
const btn = row.querySelector('.data-toggle-btn');
if (btn) {
this._syncToggleButton(btn, layer);
}
const count = row.querySelector('.data-count');
if (count) {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
}
}
_buildMetaText(layer) {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = FEED_STATE_LABELS[feedState];
const source = stats.source || layer.source;
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return `${lifecycleState.toUpperCase()} · ${source}`;
}
if (layer.lifecycleUncertain) {
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
}
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
}
return `${stateLabel} · ${source} · ${presentedError}`;
}
// A guidance status carries its prompt in `statusMessage`, not `error`, so
// the row still tells the operator what to do without reporting a fault.
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
return `${source} · ${stats.statusMessage.trim()}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
if (stats.loading) {
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: 'loading...';
return `${source} · ${loadingLabel}`;
}
if (feedState === 'fallback') {
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: (stats.coverage || ago);
return `${stateLabel} · ${source} · ${detail}`;
}
if (feedState === 'stale') {
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
? ` · retrying in ${stats.retryInSec}s`
: '';
return `${stateLabel} · ${source} · ${ago}${retry}`;
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return `${source} · ${stats.loadingLabel.trim()}`;
}
return `${source} · ${ago}`;
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
button.classList.toggle('active', layer.enabled);
button.classList.toggle('transitioning', transitioning);
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
}
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
}
_formatCount(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
_getLayerPanel() {
if (!this._layerPanel) this._layerPanel = new LayerPanel({
getLayers: () => this.getAll(),
isEnabled: (id) => this.isEnabled(id),
setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options),
setLayerParams: (id, params, options) => this.setLayerParams(id, params, options),
getRowControls: (id) => this._rowControlsFor(id),
hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function',
subscribeRowControls: (id, listener) => {
const module = this.layers.get(id)?.module;
module?.setRowControlsListener?.(listener);
return () => module?.setRowControlsListener?.(null);
},
onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; },
});
return this._layerPanel;
}
_renderToggles() { this._getLayerPanel().mount(this._toggleContainer); }
_refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); }
_buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); }
_syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); }
}
+35
View File
@@ -105,6 +105,7 @@ test('renders ordinary layer rows without recreating a panel-hidden coordinator'
},
appendChild(child) { this.children.push(child); return child; },
addEventListener() {},
removeEventListener() {},
setAttribute(name, value) { this.attributes[name] = String(value); },
querySelector(selector) {
if (selector.startsWith('[data-layer-id="')) {
@@ -2756,6 +2757,7 @@ function makeControlElement() {
},
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; },
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) {
@@ -3267,3 +3269,36 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
assert.match(text, /Zoom in to search mapped installations$/);
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
});
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
let rowListener;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
manager.register(layer.module);
try {
const first = makeControlElement();
manager.buildTogglePanel(first);
const oldButton = first.querySelector('.data-toggle-btn');
const retainedClick = oldButton.listeners.click;
const second = makeControlElement();
manager.buildTogglePanel(second);
assert.equal(oldButton.listeners.click, undefined);
await retainedClick();
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
const currentButton = second.querySelector('.data-toggle-btn');
assert.equal(typeof currentButton.listeners.click, 'function');
assert.equal(typeof rowListener, 'function');
await manager.destroyAll();
assert.equal(currentButton.listeners.click, undefined);
assert.equal(rowListener, null);
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
File diff suppressed because it is too large. Load diff
+17 -16
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -42,7 +43,7 @@ import {
} from './militaryAwarenessEngine.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = fs.readFileSync(
const militaryAwarenessSource = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
'utf8',
);
@@ -297,7 +298,7 @@ test('Contacts live repaint restores only stable identity and never retargets an
});
test('Contacts continuation target is the visible explanatory note', () => {
const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url));
assert.match(
source,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
@@ -1352,12 +1353,12 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
// disable() hides the collection but keeps the records, so a map lookup
// alone would report a preserved subject as FRESH from hidden stale data.
for (const [name, source, guard] of [
['flights', fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'),
/hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/],
['militaryFlights', fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'),
/hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/],
['aisLiveVessels', fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'),
/hasContact\(mmsi\) \{\s*\n\s*if \(!state\.enabled \|\| !state\.vesselMap \|\| state\.vesselMap\.size === 0\) return null;/],
['flights', readLayerSource(new URL('./flights.js', import.meta.url)),
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
]) {
assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`);
}
@@ -1527,14 +1528,14 @@ test('a deliberate source clear still fully clears the subject', () => {
test('production eviction sites actually tag their clears', () => {
// The event contract above is worthless if the real cull paths never set the
// origin, so pin the three production call sites.
const flightsSource = fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
const militarySource = fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
const vesselsSource = fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8');
const flightsSource = readLayerSource(new URL('./flights.js', import.meta.url));
const militarySource = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
const vesselsSource = readLayerSource(new URL('./aisLiveVessels.js', import.meta.url));
for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) {
assert.match(
source,
/if \(icao24 === _trackedIcao\) \{\s*\n\s*_clearTracking\(false, \{ evicted: true \}\);/,
/if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/,
`${name} must mark its aged-out cull as an eviction`,
);
assert.match(
@@ -1553,9 +1554,9 @@ test('production eviction sites actually tag their clears', () => {
// The tag alone is not enough — see the behavioral test in
// firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod,
// whose registration sweep deletes the record the clear needs to see.
const firmsSource = fs.readFileSync(new URL('./firmsHeatmap.js', import.meta.url), 'utf8');
const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url));
const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });');
const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);');
const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);');
assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction');
assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding');
assert.ok(
@@ -1588,9 +1589,9 @@ test('cockpit blocks only non-aircraft Context camera flights', () => {
});
test('vessel entry and selection framing both use the 3 km focus radius', () => {
assert.match(militaryAwarenessSource, /const VESSEL_FOCUS_RADIUS_M = 3000;/);
assert.match(militaryAwarenessSource, /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/);
const focusRadiusUses = militaryAwarenessSource.match(
/new Cesium\.BoundingSphere\(vessel\.position, VESSEL_FOCUS_RADIUS_M\)/g,
/new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g,
) || [];
assert.equal(focusRadiusUses.length, 2);
});
File diff suppressed because it is too large. Load diff
+21 -679
View File
@@ -1,681 +1,23 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
getSelectedEntityContext,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from './contextStore.js';
import {
cachedGroundFloor,
floorAltitudeM,
resolveGroundFloorCellsBounded,
} from './groundFloor.js';
// The shared batched/chunked/session-cached DEM warm chain. The module name is
// historical (it shipped with the FIRMS fire anchors); the mechanism itself is
// generic — cold coarse floor cells for a rendered point set, resolved strictly
// sequentially so overlapping renders cannot stack requests on the proxy.
import { warmFireAnchorFloors } from './fireAnchors.js';
import { normalizeMilitaryInstallations } from './militaryInstallationData.js';
import { installationFeedback } from './installationFeedback.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
createInstallationsLayer,
createInstallationSource,
} from '../layers/installations/index.js';
import * as render from '../renderGovernor.js';
import * as context from './contextStore.js';
import * as ground from './groundFloor.js';
import * as anchors from './fireAnchors.js';
import * as picking from './pickRegistry.js';
const LAYER_ID = 'military-installations';
const REQUEST_DEBOUNCE_MS = 500;
const MAX_VIEWPORT_DEGREES = 10;
const MAX_RENDERED = 700;
const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
const COLOR_BY_CLASS = {
airfield: '#5aa9ff',
naval_base: '#48c7d5',
range: '#d9a85d',
military_land: '#9ca6b0',
places_candidate: '#c58cff',
};
const EARTH_MEAN_RADIUS_M = 6371008.8;
const DISTANCE_PREFILTER_MARGIN_M = 5000;
const distanceEndpointScratch = new Cesium.Cartographic();
const distanceGeodesicScratch = new Cesium.EllipsoidGeodesic();
/**
* Allocation-free spherical distance used only as a conservative rejection
* pass before the exact ellipsoidal geodesic calculation.
*/
export function approximateSurfaceDistanceM(latitudeARad, longitudeARad, latitudeBDeg, longitudeBDeg) {
const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg);
const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg);
const latitudeDelta = latitudeBRad - latitudeARad;
const longitudeDelta = Math.atan2(
Math.sin(longitudeBRad - longitudeARad),
Math.cos(longitudeBRad - longitudeARad),
);
const sinLatitude = Math.sin(latitudeDelta / 2);
const sinLongitude = Math.sin(longitudeDelta / 2);
const haversine = sinLatitude * sinLatitude
+ Math.cos(latitudeARad) * Math.cos(latitudeBRad) * sinLongitude * sinLongitude;
return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine)));
}
const state = {
viewer: null,
dataSource: null,
enabled: false,
records: [],
recordById: new Map(),
selectedId: null,
lastUpdate: null,
error: null,
status: 'idle',
stale: false,
/** Whether the upstream truncated at its element cap for the current view. */
saturated: false,
loading: false,
abort: null,
/** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */
retryTimer: null,
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */
retryDelayMs: 0,
retryAt: 0,
failureReason: null,
moveEndRemove: null,
clickHandler: null,
timer: null,
googleSearchRequested: false,
};
function colorFor(record) {
return Cesium.Color.fromCssColorString(COLOR_BY_CLASS[record.class] || '#9ca6b0');
}
/**
* Classify a Places text-search result without turning a name match into a
* mapped military-land claim. Google currently has no documented military
* Places type, so ordinary results remain visually distinct candidates; the
* explicit branch is retained for any source response that does carry one.
* @param {object} place Google Places result.
* @returns {string|null} Installation class, or null when not authoritative.
*/
export function classifyGoogleMilitaryPlace(place) {
const types = new Set([
place?.primaryType,
...(Array.isArray(place?.types) ? place.types : []),
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean));
return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type))
? 'military_land'
: 'places_candidate';
}
/** @param {object} record @returns {string} Human-readable source attribution. */
export function installationSourceLabel(record) {
const names = [...new Set((Array.isArray(record?.sources) ? record.sources : [])
.map((source) => String(source?.name || '').trim())
.filter(Boolean))];
return names.join(' + ') || 'Unknown mapped source';
}
/**
* Shared rendered-surface height for an installation anchor or footprint.
* @param {{latitude:number, longitude:number}} record Installation record.
* @returns {number} Ellipsoidal render height in metres.
*/
export function installationSurfaceHeightM(record) {
return floorAltitudeM(
null,
cachedGroundFloor(record?.latitude, record?.longitude),
) ?? 0;
}
/**
* Whether a mapped record belongs to the REQUESTED viewport.
*
* The proxy snaps the request bbox outward onto a shared cache grid, so a
* response is a SUPERSET of what was asked for, and rendering that superset
* would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY"
* context claim. What may be tested depends on how much of a feature's geometry
* we actually hold:
*
* - A NODE is a point: its centre IS its whole geometry, so an exact
* containment test is correct and loses nothing.
* - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox
* queries return features that merely INTERSECT the box, so centre-testing
* these would drop large bases whose centre sits just outside.
* - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on
* their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without
* one, so their true extent is unknown here — and Overpass already proved
* they intersect the queried bbox. Centre-testing them would erase exactly
* the biggest installations. The honest cost is slight over-inclusion,
* bounded by one snap cell (~5.5 km) around the viewport.
*
* @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record
* @param {{south:number, west:number, north:number, east:number}} box Requested viewport.
* @returns {boolean}
*/
export function installationWithinViewport(record, box) {
if (!record || !box) return false;
const { latitude, longitude, footprint } = record;
const centreInside = latitude >= box.south && latitude <= box.north
&& longitude >= box.west && longitude <= box.east;
if (centreInside) return true;
if (Array.isArray(footprint) && footprint.length) {
let minLat = Infinity; let maxLat = -Infinity;
let minLon = Infinity; let maxLon = -Infinity;
for (const [lon, lat] of footprint) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
return maxLat >= box.south && minLat <= box.north
&& maxLon >= box.west && minLon <= box.east;
}
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
return record.osmType !== 'node';
}
/**
* Whether a response was truncated at the upstream element cap.
*
* The proxy states this outright, but a `saturated`-less payload is NOT
* evidence of a complete answer: entries cached before the saturation guard
* shipped predate the field and live for 30 days. Fall back to deriving it from
* the element count against the cap the payload itself reports.
* @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload
* @returns {boolean}
*/
export function installationResponseSaturated(payload) {
if (typeof payload?.saturated === 'boolean') return payload.saturated;
const cap = Number(payload?.elementCap);
if (!Number.isFinite(cap) || cap <= 0) return false;
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
}
/**
* Commit a status/error transition and buy the one frame it needs.
*
* With the render governor idle — Contacts has released its hold and nothing
* else animates — no frame would otherwise arrive to re-read this, so a load
* that fails after the scene went quiet would leave the last healthy readout on
* screen indefinitely.
* @param {string} status @param {?string} error
*/
function setInstallationStatus(status, error = null) {
if (state.status === status && state.error === error) return;
state.status = status;
state.error = error;
governorRequestRender('installations-status');
}
function viewportBox(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid);
if (!rectangle) return null;
const south = Cesium.Math.toDegrees(rectangle.south);
const north = Cesium.Math.toDegrees(rectangle.north);
const west = Cesium.Math.toDegrees(rectangle.west);
const east = Cesium.Math.toDegrees(rectangle.east);
// Cross-dateline/global views require a zoom before a bounded request.
if (!Number.isFinite(south + north + west + east) || east <= west || north - south > MAX_VIEWPORT_DEGREES || east - west > MAX_VIEWPORT_DEGREES) return null;
return { south, west, north, east };
}
function clearRendered() {
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID);
}
/**
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
* the selected one when it falls outside that window.
*
* Context navigation walks the FULL nearby cohort, which is not bounded by the
* render cap, so selecting item 701+ used to produce no entity at all — the
* camera flew, `getById` returned null, and the selection was silently dropped
* on the floor, leaving the Context subject stale so NEXT offered the same
* installation forever. One extra entity keeps every cohort item selectable
* and the cohort count honest.
* @returns {Array<object>} Records to render this paint.
*/
function renderableRecords() {
const rendered = state.records.slice(0, MAX_RENDERED);
if (!state.selectedId) return rendered;
if (rendered.some((record) => record.id === state.selectedId)) return rendered;
const selected = state.recordById.get(state.selectedId);
return selected ? [...rendered, selected] : rendered;
}
function renderRecords({ claimSelection = false } = {}) {
// Context navigation can select another layer without a canvas click.
// A delayed floor/data repaint must not steal that newer selection back.
const selectedContext = getSelectedEntityContext();
if (!claimSelection && state.selectedId && selectedContext && selectedContext.id !== state.selectedId) {
state.selectedId = null;
}
// Post-moveEnd debounced fetches commit after the camera settles; the
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
governorRequestRender('installations-render');
clearRendered();
for (const record of renderableRecords()) {
const color = colorFor(record);
const surfaceHeightM = installationSurfaceHeightM(record);
const displayPosition = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
surfaceHeightM,
);
const entity = state.dataSource.entities.add({
id: record.id,
position: displayPosition,
point: {
pixelSize: record.id === state.selectedId ? 13 : 9,
color: record.id === state.selectedId ? Cesium.Color.WHITE : color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
polygon: record.footprint ? {
hierarchy: new Cesium.PolygonHierarchy(record.footprint.map(([longitude, latitude]) => Cesium.Cartesian3.fromDegrees(longitude, latitude))),
material: color.withAlpha(0.12),
outline: true,
outlineColor: color.withAlpha(0.65),
height: surfaceHeightM,
} : undefined,
});
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || 'MAPPED INSTALLATION',
details: [String(record.class || 'installation').replaceAll('_', ' ').toUpperCase()],
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
};
registerEntityContext(entity, {
id: record.id,
layerId: LAYER_ID,
layerName: record.kind === 'place_candidate'
? 'Military Site Search Candidates'
: 'Mapped Military Installations',
source: installationSourceLabel(record),
label: record.name,
latitude: record.latitude,
longitude: record.longitude,
properties: {
class: record.class,
primaryType: record.primaryType || null,
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
validation: record.validation,
retrievedAt: record.retrievedAt,
},
});
}
const selectedEntity = state.selectedId
? state.dataSource.entities.getById(state.selectedId)
: null;
if (selectedEntity) selectEntityContext(selectedEntity);
else state.selectedId = null;
}
/**
* Second paint for floors that missed the bounded pre-render deadline.
*
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
* a cold DEM can never hold the dots hostage — but the resolve keeps running
* and lands seconds later, and without this the records it covers stay pinned
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
* 2026-08-18: "orange dots at the bottom").
*
* This is the render -> warm -> re-render chain FIRMS already uses, with one
* difference the installations path forces: the trigger is whether a cell that
* was COLD AT PAINT TIME is warm now, not whether this particular batch warmed
* it. The bounded resolve above is still running against the same cells, so
* asking "did MY batch warm anything" would answer false exactly when the other
* resolve won the race — the common case. Still terminating: a set that is
* wholly cold afterwards re-renders zero times and the next camera-driven load
* retries.
* @param {Array<object>} records Records just rendered.
* @returns {void}
*/
function warmInstallationFloors(records) {
const cold = records
.filter((record) => cachedGroundFloor(record.latitude, record.longitude) == null)
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
if (!cold.length) return;
warmFireAnchorFloors(cold).then(() => {
if (!state.enabled || !state.dataSource) return;
if (!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)) return;
renderRecords();
});
}
function selectRecord(id) {
const record = state.recordById.get(id);
if (!record || !state.dataSource) return false;
state.selectedId = id;
renderRecords({ claimSelection: true });
// renderRecords drops selectedId when the record produced no entity.
return state.selectedId === id;
}
function installInteraction(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
state.clickHandler.setInputAction((click) => {
if (!state.enabled) return;
const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
if (id && state.recordById.has(id) && id !== state.selectedId) {
selectRecord(id);
} else if (state.selectedId) {
// Clicking the selected site again, empty map, or another contact
// releases this layer's selection. Clear only our shared context so a
// sibling click handler's newly selected aircraft/site stays intact.
state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID);
renderRecords();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
/**
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
*/
export function installationRetryDelayMs(prevDelayMs) {
const RETRY_MIN_MS = 30000;
const RETRY_CEIL_MS = 240000;
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
}
/**
* 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only
* on enable and on camera moveEnd, so a parked camera whose first request died
* (one flaky Overpass mirror is enough) stayed unavailable forever while the
* proxy sat healthy while the layer refused to show its features. While the
* layer is enabled and
* unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load,
* zoom-out, or disable cancels it.
*/
function scheduleUnavailableRetry() {
if (!state.enabled) return;
clearTimeout(state.retryTimer);
state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs);
state.retryAt = Date.now() + state.retryDelayMs;
state.retryTimer = setTimeout(() => {
state.retryTimer = null;
state.retryAt = 0;
if (state.enabled && !state.loading) loadInstallations();
}, state.retryDelayMs);
}
function clearUnavailableRetry({ resetBackoff = true } = {}) {
clearTimeout(state.retryTimer);
state.retryTimer = null;
state.retryAt = 0;
if (resetBackoff) state.retryDelayMs = 0;
}
function scheduleLoad() {
if (!state.enabled) return;
// A user-driven load supersedes any pending retry; the load reschedules on
// failure, so the backoff step is kept rather than reset.
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.timer);
state.timer = setTimeout(() => { loadInstallations(); }, REQUEST_DEBOUNCE_MS);
}
async function loadInstallations() {
if (!state.enabled || !state.viewer) return;
const box = viewportBox(state.viewer);
// Guidance, not a fault: the layer chose not to query because the view is
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
// of `error` — the manager derives refresh failures and the global status
// chip derives LOAD FAILED from that field, and a "zoom in" prompt turned
// every Cockpit refresh into a reported failure. The row and toast read the
// prompt from `statusMessage` (installationFeedback) instead.
if (!box) {
state.abort?.abort();
state.abort = null;
state.loading = false;
clearUnavailableRetry();
setInstallationStatus('zoom-in');
return;
}
state.abort?.abort();
const requestAbort = new AbortController();
state.abort = requestAbort;
state.loading = true;
clearUnavailableRetry({ resetBackoff: false });
// The previous attempt's failure is not the outcome of this new attempt.
setInstallationStatus('loading');
try {
const fetchInstallations = async (exact) => {
const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)]));
if (exact) query.set('exact', '1');
const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal });
const body = await response.json();
if (!response.ok) throw Object.assign(new Error(body?.error || `Installation feed HTTP ${response.status}`), {
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(body?.reason) ? body.reason : 'unavailable',
});
return body;
};
let payload = await fetchInstallations(false);
// A SATURATED snapped tile was truncated upstream, so features from the
// snap's extra ring may have crowded out sites actually on screen. Re-ask
// for the exact viewport (separately keyed and cached) before rendering.
let saturated = installationResponseSaturated(payload);
if (saturated) {
payload = await fetchInstallations(true);
saturated = installationResponseSaturated(payload);
}
const normalized = normalizeMilitaryInstallations(payload, payload.retrievedAt || new Date().toISOString());
// The proxy answers a bbox at least as large as the viewport; keep only what
// was actually asked for so nothing off-screen reaches the map or the
// "current viewport only" context claim.
const records = normalized.records.filter((record) => installationWithinViewport(record, box));
let placesError = null;
if (state.googleSearchRequested) {
state.googleSearchRequested = false;
const latitude = (box.south + box.north) / 2;
const longitude = (box.west + box.east) / 2;
const radiusM = Math.min(50000, Math.max(1000, Math.round(Math.max(box.north - box.south, box.east - box.west) * 55_000)));
try {
const placesResponse = await fetch(`/api/google/text-search?${new URLSearchParams({
q: 'military installation', lat: latitude.toFixed(5), lon: longitude.toFixed(5), radiusM: String(radiusM),
})}`, { signal: requestAbort.signal });
const placesPayload = await placesResponse.json();
if (!placesResponse.ok) throw new Error(placesPayload?.error || `Google Places HTTP ${placesResponse.status}`);
const seen = new Set(records.map((record) => `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`));
for (const place of Array.isArray(placesPayload?.places) ? placesPayload.places : []) {
if (!place?.id || !place?.name || !Number.isFinite(place.latitude) || !Number.isFinite(place.longitude)) continue;
const placeClass = classifyGoogleMilitaryPlace(place);
const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`;
if (seen.has(signature)) continue;
seen.add(signature);
const retrievedAt = new Date().toISOString();
records.push({
id: `google:${place.id}`,
kind: placeClass === 'military_land' ? 'installation' : 'place_candidate',
class: placeClass,
name: String(place.name).trim(),
latitude: place.latitude,
longitude: place.longitude,
footprint: null,
primaryType: place.primaryType || null,
placeTypes: Array.isArray(place.types) ? place.types : [],
sources: [{ name: 'Google Maps Places', id: place.id, retrievedAt }],
validation: 'unreviewed',
retrievedAt,
});
}
} catch (error) {
if (error?.name === 'AbortError') return;
placesError = 'Google Places search unavailable; showing mapped sites';
}
}
await resolveGroundFloorCellsBounded(records.map((record) => ({
lat: record.latitude,
lon: record.longitude,
})));
if (requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled) return;
state.records = records;
state.recordById = new Map(state.records.map((record) => [record.id, record]));
state.lastUpdate = Date.now();
state.stale = payload.status === 'stale';
// Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed.
state.saturated = saturated;
state.failureReason = null;
clearUnavailableRetry();
setInstallationStatus(
state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty',
payload.status === 'stale'
? 'Serving cached mapped context'
: (saturated ? 'Too many mapped sites in view to list them all' : placesError),
);
renderRecords();
warmInstallationFloors(state.records);
} catch (error) {
if (error?.name === 'AbortError') return;
state.failureReason = error?.failureReason || 'unavailable';
setInstallationStatus('unavailable', error?.message || 'Installation context unavailable');
scheduleUnavailableRetry();
} finally {
// An older aborted request must not clear a newer request's busy state.
if (state.abort === requestAbort) {
state.abort = null;
state.loading = false;
}
}
}
const militaryInstallationsLayer = {
id: LAYER_ID,
name: 'Mapped Installations',
icon: '⌖',
source: 'OpenStreetMap + optional Google Maps Places',
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
state.viewer = viewer;
state.dataSource = new Cesium.CustomDataSource('military-installations');
viewer.dataSources.add(state.dataSource);
state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad);
installInteraction(viewer);
},
enable() {
state.enabled = true;
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
state.dataSource.show = true;
// DataLayerManager invokes update() immediately after enable(), which owns
// the first fetch. Avoid racing it with a second aborting request here.
},
disable() {
state.enabled = false;
unregisterPickOwner(LAYER_ID);
clearUnavailableRetry();
clearTimeout(state.timer);
state.abort?.abort();
state.abort = null;
state.loading = false;
if (state.dataSource) state.dataSource.show = false;
clearSelectedEntityContextForLayer(LAYER_ID);
state.selectedId = null;
state.failureReason = null;
setInstallationStatus('idle');
},
update() { return loadInstallations(); },
/** Request a one-shot Google Maps Places search around the current map view. */
searchNearby() {
state.googleSearchRequested = true;
return loadInstallations();
},
destroy(viewer) {
this.disable();
state.moveEndRemove?.();
state.clickHandler?.destroy();
state.clickHandler = null;
clearRendered();
if (state.dataSource && viewer) viewer.dataSources.remove(state.dataSource, true);
state.dataSource = null;
},
getNearby(center, rangeM, maxCount = 50) {
if (!center) return [];
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
const centerCartographic = Cesium.Cartographic.fromCartesian(center);
if (!centerCartographic) return [];
const nearby = [];
const approximateLimit = Number.isFinite(range)
? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M
: Infinity;
for (const record of state.records) {
if (record.kind !== 'installation') continue;
if (approximateSurfaceDistanceM(
centerCartographic.latitude,
centerCartographic.longitude,
record.latitude,
record.longitude,
) > approximateLimit) continue;
// The awareness disk is projected onto the ground. Confirm candidates
// with an exact ellipsoidal surface distance and reusable scratch state.
distanceEndpointScratch.longitude = Cesium.Math.toRadians(record.longitude);
distanceEndpointScratch.latitude = Cesium.Math.toRadians(record.latitude);
distanceEndpointScratch.height = 0;
distanceGeodesicScratch.setEndPoints(centerCartographic, distanceEndpointScratch);
const distanceM = distanceGeodesicScratch.surfaceDistance;
if (!Number.isFinite(distanceM) || distanceM > range) continue;
nearby.push({
...record,
position: Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
installationSurfaceHeightM(record),
),
distanceM,
});
}
nearby.sort((a, b) => a.distanceM - b.distanceM);
return nearby.slice(0, Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50);
},
/**
* Select and frame a mapped installation from another contextual UI.
* @param {string} id Source-backed installation id.
* @returns {boolean} True when an available installation was focused.
*/
focusById(id) {
const record = state.recordById.get(String(id));
if (!record || !state.viewer) return false;
// No camera flight without a real selection: a flight plus a stale subject
// reads as success to Context navigation and strands NEXT on this item.
if (!selectRecord(record.id)) return false;
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
installationSurfaceHeightM(record),
),
18000,
),
{ duration: 1.4 },
);
return true;
},
getStats() {
return {
count: state.records.length,
lastUpdate: state.lastUpdate,
stale: state.stale,
saturated: state.saturated,
error: state.error,
status: state.status,
loading: state.loading,
retryAt: state.retryAt,
retrying: state.loading && Boolean(state.failureReason),
failureReason: state.failureReason,
statusMessage: installationFeedback({ ...state, retrying: state.loading && Boolean(state.failureReason) }),
loadingLabel: state.loading ? 'loading mapped installation context' : '',
};
},
};
export default militaryInstallationsLayer;
const layer = createInstallationsLayer({
source: createInstallationSource(),
services: { render, context, ground, anchors, picking },
});
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace;
export const installationSourceLabel = layer.installationSourceLabel;
export const installationSurfaceHeightM = layer.installationSurfaceHeightM;
export const installationWithinViewport = layer.installationWithinViewport;
export const installationResponseSaturated =
layer.installationResponseSaturated;
export const installationRetryDelayMs = layer.installationRetryDelayMs;
export default layer;
+5 -4
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -782,7 +783,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui
import { installationRetryDelayMs } from './militaryInstallations.js';
import fs from 'node:fs';
const installationsSource = fs.readFileSync(
const installationsSource = readLayerSource(
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => {
@@ -797,10 +798,10 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
test('the retry is wired to every lifecycle edge, not just declared', () => {
assert.match(installationsSource,
/setInstallationStatus\('unavailable',[^]*?\);\n\s*scheduleUnavailableRetry\(\);/,
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
'a failed load schedules the retry immediately after reporting unavailable');
assert.match(installationsSource,
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*state\.records\.length/,
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
'a successful load clears the pending retry and resets the backoff');
assert.match(installationsSource,
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/,
@@ -811,6 +812,6 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
/function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/,
'a user-driven load supersedes the retry without resetting the backoff step');
assert.match(installationsSource,
/state\.enabled && !state\.loading\) loadInstallations\(\)/,
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
'the fired retry re-checks enablement and never races an in-flight load');
});
+10 -118
View File
@@ -1,119 +1,11 @@
/**
* Shared military-aircraft ICAO24 registry (2026-06-10 playtest fix).
*
* adsb.lol /v2/mil tags aircraft via its database's military flag; OpenSky
* carries no such tag, so military aircraft (e.g. ADAPT91/92) appeared in
* BOTH layers as duplicate icons and tracks. This registry reconciles them:
*
* - The military layer, while enabled, feeds every poll's ICAO set here and
* marks itself active — the commercial flights layer then SUPPRESSES its
* duplicates (military layer wins icon, track, and click).
* - While the military layer is OFF, the flights layer keeps a low-rate
* poll (60s against the dev proxy's cached /api/adsblol/mil) so known
* military aircraft are still classified and styled amber.
*/
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
const MIL_POLL_INTERVAL_MS = 60000;
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
const _milIcaos = new Set();
/** @type {boolean} True while the dedicated military layer is enabled. */
let _militaryLayerActive = false;
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
const _activeChangeListeners = new Set();
/** @type {number} Epoch ms of the last registry refresh (any source). */
let _lastRefreshMs = 0;
/** @type {boolean} A self-poll fetch is in flight. */
let _polling = false;
/**
* True when the dedicated military layer currently renders these aircraft
* (the flights layer should suppress duplicates rather than restyle them).
* @returns {boolean}
*/
export function isMilitaryLayerActive() {
return _militaryLayerActive;
}
/**
* Marks the dedicated military layer enabled/disabled. On a TRANSITION
* (value actually changed) the registered change listeners fire so the
* flights layer can reconcile its duplicates immediately instead of waiting
* out its 30 s poll (pre-ship audit M2).
* @param {boolean} active - Whether the military layer renders.
* @returns {void}
*/
export function setMilitaryLayerActive(active) {
const next = !!active;
if (next === _militaryLayerActive) return;
_militaryLayerActive = next;
for (const listener of _activeChangeListeners) {
try {
listener(next);
} catch {
// a broken listener must never break the layer toggle
}
}
}
/**
* Subscribes to military-layer active-state transitions (fired only when the
* value changes, AFTER the new state is committed).
* @param {(active: boolean) => void} listener - Change callback.
* @returns {() => void} Unsubscribe function.
*/
export function onMilitaryLayerActiveChange(listener) {
if (typeof listener !== 'function') return () => {};
_activeChangeListeners.add(listener);
return () => _activeChangeListeners.delete(listener);
}
/**
* Replaces/extends the known-military set from a fresh poll.
* Adds only — transient dropouts from one poll must not declassify an
* aircraft mid-session (the set stays small: a few hundred hexes).
* @param {Iterable<string>} icaos - ICAO24 hexes from a /v2/mil response.
* @returns {void}
*/
export function registerMilitaryIcaos(icaos) {
for (const icao of icaos || []) {
const hex = String(icao || '').trim().toLowerCase();
if (hex) _milIcaos.add(hex);
}
_lastRefreshMs = Date.now();
}
/**
* Whether an aircraft is known military.
* @param {string} icao24 - ICAO24 hex (any case).
* @returns {boolean}
*/
export function isMilitaryIcao(icao24) {
return _milIcaos.has(String(icao24 || '').toLowerCase());
}
/**
* Refreshes the registry from /api/adsblol/mil when stale — used by the
* flights layer so classification works while the military layer is off
* (the military layer's own polls keep it fresh otherwise). The dev proxy
* caches upstream responses, so this is nearly free.
* @returns {void} Fire-and-forget; failures leave the current set intact.
*/
export function refreshMilitaryRegistryIfStale() {
if (_militaryLayerActive) return; // military layer's polls keep us fresh
if (_polling || (Date.now() - _lastRefreshMs) < MIL_POLL_INTERVAL_MS) return;
_polling = true;
(async () => {
try {
const response = await fetch('/api/adsblol/mil', { signal: AbortSignal.timeout(10000) });
if (!response.ok) return;
const data = await response.json();
const aircraft = Array.isArray(data?.ac) ? data.ac : [];
registerMilitaryIcaos(aircraft.map((entry) => entry?.hex));
} catch {
// keep the existing set on any failure
} finally {
_polling = false;
}
})();
}
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
export const registerMilitaryIcaos = registry.registerMilitaryIcaos;
export const isMilitaryIcao = registry.isMilitaryIcao;
export const refreshMilitaryRegistryIfStale =
registry.refreshMilitaryRegistryIfStale;
+6 -5
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
// Regression test for the giant-military-jet bug: MODEL_SCALE must put every
// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is.
//
@@ -358,7 +359,7 @@ function nativeVisualCenter(file) {
// --- per-layer constants, read from source (see header) --------------------
function layerConstants(sourceFile) {
const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8');
const src = readLayerSource(path.join(ROOT, sourceFile));
const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/);
assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`);
const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/);
@@ -367,8 +368,8 @@ function layerConstants(sourceFile) {
}
function normalBillboardScaleByDistance(sourceFile) {
const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8');
const fn = src.match(/function _normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
const src = readLayerSource(path.join(ROOT, sourceFile));
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`);
const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/);
assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`);
@@ -391,7 +392,7 @@ const LAYERS = [
name: 'military',
source: 'src/data/militaryFlights.js',
asset: (() => {
const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8');
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/);
assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found');
return m[1];
@@ -491,7 +492,7 @@ for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) {
// PLANE_* constants — pin them to the measured meter-scale GLB + flights'
// calibration so the copies cannot drift.
test('military layer airplane.glb constants match the measured GLB and flights calibration', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8');
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
const grab = (name) => {
const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`));
assert.ok(m, `militaryFlights.js: ${name} not found`);
+97 -2874
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeRadioCountryInput } from './radioCountry.js';
import { normalizeRadioCountryInput } from '../layers/radio/index.js';
test('Radio country normalization maps ISO codes and bounded common names', () => {
for (const [input, code] of [
+79 -3697
View File
File diff suppressed because it is too large. Load diff
+51 -2394
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -1,3 +1,4 @@
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -113,8 +114,8 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
assert.match(
createFirmsHeatmapLayer.toString(),
/registerSpriteCollection\('firms', _billboards\);\s*restoreSpriteOrder\(_viewer\);/,
createFirmsRendering.toString(),
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
'lazy FIRMS registration must restore order immediately',
);
});
+57 -9
View File
@@ -496,6 +496,14 @@ export function createTeleGeographySubmarineCableLayer({
let _cableDataSource = null;
let _landingDataSource = null;
let _referenceDataSource = null;
/**
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
* the three data sources (see releaseDataSources) instead of hiding them,
* so a re-enable has to rebuild the entities; the network/parse half of
* that rebuild is the expensive part users would notice, so it is cached.
*/
let _cachedCableJson = null;
let _cachedLandingJson = null;
let _referenceRecords = [];
let _surfaceRecords = [];
let _clickHandler = null;
@@ -553,11 +561,17 @@ export function createTeleGeographySubmarineCableLayer({
const owns = () => generation === _loadGeneration && !abort.signal.aborted;
try {
const [cableJson, landingJson] = await Promise.all([
fetchJson(cableUrl, abort.signal),
fetchJson(landingPointUrl, abort.signal),
]);
if (!owns()) return;
let cableJson = _cachedCableJson;
let landingJson = _cachedLandingJson;
if (!cableJson || !landingJson) {
[cableJson, landingJson] = await Promise.all([
fetchJson(cableUrl, abort.signal),
fetchJson(landingPointUrl, abort.signal),
]);
if (!owns()) return;
_cachedCableJson = cableJson;
_cachedLandingJson = landingJson;
}
const cableFeatures = normalizeFeatures(cableJson, 'cable');
const landingFeatures = normalizeFeatures(landingJson, 'landing');
@@ -706,6 +720,37 @@ export function createTeleGeographySubmarineCableLayer({
}
}
/**
* Remove the three data sources from the viewer and forget the built
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
* DataSourceDisplay still walks every visualizer of a hidden source on
* every frame, and the thousands of hidden polylines/billboards keep
* costing CPU and GC pressure long after the user toggled the layer off —
* that is the "everything gets sluggish after I turn layers off" report.
* The fetched JSON stays cached, so the next enable rebuilds without a
* network round trip or a re-parse.
*/
function releaseDataSources(viewer) {
const sources = [_cableDataSource, _landingDataSource, _referenceDataSource];
_cableDataSource = null;
_landingDataSource = null;
_referenceDataSource = null;
_referenceRecords = [];
_surfaceRecords = [];
_pickByEntity = new WeakMap();
_referenceLabelCount = 0;
_publishScratch.length = 0;
// The blend pass targets the collections being dropped; the rebuilt
// sources need their own pass.
_markerBlendDone = false;
_loaded = false;
_referenceSweepGate.reset();
for (const source of sources) {
if (!source) continue;
try { viewer?.dataSources?.remove(source, true); } catch { /* collection gone */ }
}
}
function updateVisibility() {
if (_cableDataSource) _cableDataSource.show = _enabled;
if (_landingDataSource) _landingDataSource.show = _enabled;
@@ -1093,7 +1138,6 @@ export function createTeleGeographySubmarineCableLayer({
disable() {
_enabled = false;
updateVisibility();
_overlayPublisher.hide();
resetPublishSignature();
if (_loading && _abort) {
@@ -1101,6 +1145,10 @@ export function createTeleGeographySubmarineCableLayer({
_loading = false;
_loadingLabel = '';
}
// Free the entities rather than hide them; enable() rebuilds from the
// cached JSON. An in-flight load's own post-await ownership check
// removes whatever that stale generation adds later.
releaseDataSources(_viewer);
},
update(viewer) {
@@ -1109,9 +1157,9 @@ export function createTeleGeographySubmarineCableLayer({
destroy(viewer) {
if (_abort) _abort.abort();
if (_cableDataSource && viewer) viewer.dataSources.remove(_cableDataSource, true);
if (_landingDataSource && viewer) viewer.dataSources.remove(_landingDataSource, true);
if (_referenceDataSource && viewer) viewer.dataSources.remove(_referenceDataSource, true);
releaseDataSources(viewer || _viewer);
_cachedCableJson = null;
_cachedLandingJson = null;
// hide() clears every published host entry and goes invisible while
// keeping the publisher reusable — the legacy layer supported re-init
// after destroy, and hidden publishers already drop late publishes.
+42 -1
View File
@@ -743,7 +743,15 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b
env.layer.disable();
env.layer.enable(env.viewer);
await env.settle();
assert.equal(geojson.calls.length, 1, 'stale load A must not start the landing GeoJson build');
// A already cached the fetched JSON, so load B skips the network and goes
// straight to its own CABLE build; the only new GeoJson call must be that
// one — a landing build here would mean stale A ran on past its check.
assert.equal(env.pendingFetches.length, 0, 'load B rebuilds from the cached JSON without refetching');
assert.equal(geojson.calls.length, 2, 'stale load A must not start the landing GeoJson build');
assert.ok(
Cesium.Color.equals(geojson.calls[1][1].stroke, geojson.calls[0][1].stroke),
'the second GeoJson call is load B\'s cable build, not A\'s landing build',
);
env.releaseAll();
await env.settle();
@@ -1254,3 +1262,36 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i
env.layer.destroy(env.viewer);
});
test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => {
const env = await createRealCableLayerHarness();
assert.equal(env.dataSources.length, 3, 'cable + landing + reference sources while on');
const before = env.layer.getStats().count;
assert.equal(before, 2);
// Any fetch after this point is a bug: the rebuild must come from the cache.
const originalFetch = globalThis.fetch;
let fetches = 0;
globalThis.fetch = async () => { fetches += 1; throw new Error('unexpected refetch'); };
try {
env.layer.disable();
assert.equal(env.dataSources.length, 0, 'hidden entities are not free; disable must remove the sources');
env.layer.enable(env.viewer);
const deadline = Date.now() + 2_000;
while (Date.now() < deadline && env.dataSources.length < 3) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(fetches, 0, 'the rebuild must not touch the network');
assert.equal(env.layer.getStats().error, null);
assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio');
assert.equal(env.layer.getStats().count, before);
const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || ''));
assert.ok(referenceSource?.entities.values.length > 0, 'reference stems are rebuilt');
env.layer.destroy(env.viewer);
assert.equal(env.dataSources.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
+9 -8
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
// src/data/tr3bRegistry.test.mjs
// TR-3B conversion Easter egg: registry state, sprite-variant selection,
// class-label override, and the render-path invariants that keep a converted
@@ -227,7 +228,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
test('both flight layers keep a converted contact 2D and visible (render invariants)', async () => {
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
// no TR-3B GLB, so the triangle billboard stays the visual.
@@ -237,13 +238,13 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
// (2026-08-19), so the suppression moved from a conjunct on
// `_modelRegimeActive()` to an explicit early return. The invariant is
// unchanged: a converted contact never reaches the model handoff.
assert.match(source, /if \(!_trackedIcao \|\| _cockpitContactMode \|\| isTr3b\(_trackedIcao\)\) \{/,
assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/,
`${name}: the standalone tracked model is suppressed for a converted contact`);
// 2. The billboard is never hidden by that suppression — it must keep
// satisfying the getNearby/getDetectableObjects visibility guards, so a
// converted contact still works in Contacts and Cockpit.
assert.match(source, /if \(bb && id !== _trackedIcao\) bb\.show = true;|if \(modelled && id !== _trackedIcao\) modelled\.show = true;/,
assert.match(source, /if\s*\(\s*bb\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*bb\.show\s*=\s*true;|if\s*\(\s*modelled\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*modelled\.show\s*=\s*true;/,
`${name}: converting restores the billboard the model handoff had hidden`);
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
@@ -253,7 +254,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || [];
assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`);
for (const call of callSites) {
assert.match(call, /aircraftIcon\(\s*_iconKind\(/,
assert.match(call, /aircraftIcon\(\s*\s*(?:parts\.rendering\.)?_iconKind\(\s*/,
`${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`);
}
@@ -270,8 +271,8 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
// personally clicked, and re-tracking the same aircraft after a layer restart
// should still show the triangle. Only a page reload resets it — so no
// production code may clear the registry.
for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) {
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
assert.doesNotMatch(source, /clearTr3bRegistry/,
`${name}: teardown must not clear session conversions`);
}
@@ -406,10 +407,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
// in the fleet tick (no seam to drive headlessly), so this asserts the guard's
// POSITION rather than replaying the four-pass selection.
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
// ambient-enrichment candidate loop that also builds a `cand`.
const loop = /const cand = \[\];\s*\n\s*for \(const \[icao, bb\] of _billboards\)[\s\S]*?cand\.push\(/.exec(source)?.[0];
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0];
assert.ok(loop, `${name}: the model-eligibility candidate loop is present`);
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
+11 -10
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
// src/data/trackedModelRegime.test.mjs
//
// Zoom-driven 2D↔3D for the TRACKED contact (owner directive 2026-08-19).
@@ -931,15 +932,15 @@ for (const fixture of LAYERS) {
test('both layers gate the tracked-model load and record its failures', async () => {
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
assert.match(source, /!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed\(\)/,
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/,
`${name}: the driver asks permission before starting another tracked-model load`);
assert.match(source, /_noteTrackedModelLoadFailure\(trackedSpec\.url, err\)/,
assert.match(source, /(?:parts\.\w+\.)?_noteTrackedModelLoadFailure\(\s*trackedSpec\.url,\s*err,?\s*\)/,
`${name}: a rejected load is recorded against this selection, not silently retried`);
// The reset must be reachable from the real teardown, not only a test seam.
assert.match(source, /_releaseTrackedModel\(\);\n _resetTrackedSelectionState\(\);/,
assert.match(source, /(?:parts\.\w+\.)?_releaseTrackedModel\(\s*,?\s*\);\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/,
`${name}: deselect clears the per-selection latches in the production path`);
assert.match(source, /_trackedIcao = icao24;\n _resetTrackedSelectionState\(\);/,
assert.match(source, /(?:flightState\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/,
`${name}: selecting a contact starts from a clean per-selection state`);
}
});
@@ -947,16 +948,16 @@ test('both layers gate the tracked-model load and record its failures', async ()
test('the tracked regime never consults the fleet models3d toggle', async () => {
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(regime, `${name}: _trackedModelRegimeActive is defined`);
assert.doesNotMatch(regime, /_models3dEnabled|_modelRegimeActive\(\)/,
assert.doesNotMatch(regime, /(?:flightState\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/,
`${name}: the tracked contact's handoff is default-on, not gated on the fleet toggle`);
assert.match(regime, /trackedModelZoomActive\(/,
`${name}: the tracked contact uses the shared hysteretic zoom policy`);
// The FLEET must still obey the toggle — the budget decision stays the operator's.
const fleet = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
assert.match(fleet, /if \(!_models3dEnabled\) return false;/,
const fleet = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(fleet, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
`${name}: the fleet regime still obeys the DISPLAY-rail 3D toggle`);
}
});
+6 -5
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
@@ -244,14 +245,14 @@ test('tracking layers write gevLabelModel and expose only their cached display p
'militaryFlights.js',
'satellites.js',
'militaryInstallations.js',
].map(async (name) => [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]));
].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))]));
const sources = Object.fromEntries(files);
for (const [name, source] of files) {
assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`);
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
}
assert.ok(sources['flights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false);
@@ -260,11 +261,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
[name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
)));
for (const [name, source] of files) {
assert.ok(
source.includes('const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao);'),
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
`${name} trail head uses the dedicated lower-centre model anchor`,
);
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
+5 -4
View File
@@ -1,3 +1,4 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -110,16 +111,16 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno
test('civilian and military click handlers apply duration only at the deselect branch', () => {
const sources = [
readFileSync(new URL('./flights.js', import.meta.url), 'utf8'),
readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'),
readLayerSource(new URL('./flights.js', import.meta.url)),
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
];
for (const source of sources) {
assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/);
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+_clearTracking\([^)]*\{ origin: 'user' \}\)/);
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/);
}
assert.doesNotMatch(
sources[0],
/_trackedEntity = _viewer\.entities\.add\(\{\s*id:/,
/(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/,
'civilian tracked entities must retain Cesium-generated GUIDs',
);
});
+13 -2506
View File
File diff suppressed because it is too large. Load diff
+10 -11
View File
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { getTrafficTimingDiagnostics } from './traffic.js';
const SOURCE = readFileSync(new URL('./traffic.js', import.meta.url), 'utf8');
const SOURCE = readLayerSource(new URL('./traffic.js', import.meta.url), 'utf8').replace(/parts\.(?:model|timing)\./g, '');
function functionBody(name) {
const declaration = `function ${name}(`;
@@ -166,9 +166,8 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
plugins: [{
name: 'traffic-timing-test-hooks',
transform(code, id) {
if (!id.endsWith('/src/data/traffic.js')) return null;
return `${code}\nexport const __trafficTimingTestHooks = {\n` +
` currentAnchor: () => _trafficTimingCurrentAnchor,\n};\n`;
if (!id.endsWith('/src/layers/traffic/index.js')) return null;
return code.replace('return Object.assign(', 'return Object.assign({ __trafficTimingTestHooks: { currentAnchor: () => state._trafficTimingCurrentAnchor } },');
},
}],
});
@@ -240,7 +239,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
});
trafficLayer.enable(viewer);
assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1);
assert.equal(moveEnd.listenerCount(), 1, 'marksInstalled must represent a live listener');
assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check');
assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0);
assert.equal(performance.getEntriesByName('traffic:stale:measure').length, 0);
@@ -248,7 +247,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
// and moveEnd arrives only after cameraEventWaitTime (~500 ms).
setLongitude(-97.72);
changed.raise();
const anchorA = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorA = traffic.default.__trafficTimingTestHooks.currentAnchor();
const anchorMarkA = performance.getEntriesByType('mark').find((entry) => (
entry.detail?.segment === 'last-camera-change'
&& entry.detail?.interactionId === anchorA.interactionId
@@ -262,7 +261,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
traceObjectsCreated: 1,
uncorrelatedTracesDropped: 0,
});
assert.equal(traffic.__trafficTimingTestHooks.currentAnchor(), null);
assert.equal(traffic.default.__trafficTimingTestHooks.currentAnchor(), null);
const fetchFromA = performance.getEntriesByType('measure').find((entry) => (
entry.detail?.segment === 'last-camera-change-to-fetch-start'
&& entry.detail?.interactionId === anchorA.interactionId
@@ -288,16 +287,16 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
// and must count one drop without consuming C's current anchor.
setLongitude(-97.70);
changed.raise();
const anchorB = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorB = traffic.default.__trafficTimingTestHooks.currentAnchor();
const [timerBId, timerB] = pendingDebounce();
setLongitude(-97.68);
changed.raise();
const anchorC = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorC = traffic.default.__trafficTimingTestHooks.currentAnchor();
assert.notEqual(anchorC.interactionId, anchorB.interactionId);
assert.equal(timeouts.has(timerBId), false, 'the ordinary debounce path must cancel B');
await timerB.callback();
assert.equal(
traffic.__trafficTimingTestHooks.currentAnchor().interactionId,
traffic.default.__trafficTimingTestHooks.currentAnchor().interactionId,
anchorC.interactionId,
'a stale callback must not consume the newer C anchor',
);