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

+111
View File
@@ -0,0 +1,111 @@
/** Copy plain product data so a subscriber cannot mutate its owner's state. */
function copyState(value, ancestors = new Set()) {
if (value === null) return value;
if (typeof value !== 'object') {
if (['string', 'number', 'boolean', 'undefined'].includes(typeof value))
return value;
throw new TypeError('Product state must contain plain data');
}
if (ancestors.has(value)) throw new TypeError('Circular product state');
if (
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null
) {
throw new TypeError('Product state must contain plain records');
}
const next = new Set(ancestors).add(value);
const copy = Array.isArray(value)
? value.map((item) => copyState(item, next))
: Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
copyState(item, next),
]),
);
return Object.freeze(copy);
}
/**
* Publish immutable snapshots and ordinary action outcomes synchronously.
* Reentrant publications retain order. New subscribers start at current state;
* removed subscribers and destroyed channels cannot receive queued work.
*/
export function createStateChannel(readSnapshot) {
const listeners = new Set();
const pending = [];
let revision = 0;
let delivering = false;
let destroyed = false;
let lastSnapshot = null;
const getSnapshot = () => {
if (!destroyed) lastSnapshot = copyState(readSnapshot());
return lastSnapshot;
};
const deliver = (entry, notification) => {
try {
entry.listener(notification);
} catch {
console.error('Product state listener failed');
}
};
return Object.freeze({
getSnapshot,
subscribe(listener, { emitCurrent = true } = {}) {
if (typeof listener !== 'function')
throw new TypeError('Expected a state listener');
if (destroyed) return () => {};
const state = emitCurrent ? getSnapshot() : null;
const entry = { listener, revision };
listeners.add(entry);
if (emitCurrent)
deliver(
entry,
Object.freeze({
state,
change: null,
revision,
initial: true,
}),
);
return () => listeners.delete(entry);
},
publish(change = null) {
if (destroyed) return false;
pending.push(
Object.freeze({
state: getSnapshot(),
change: copyState(change),
revision: ++revision,
initial: false,
}),
);
if (delivering) return true;
delivering = true;
try {
while (!destroyed && pending.length) {
const notification = pending.shift();
for (const entry of [...listeners]) {
if (
destroyed ||
!listeners.has(entry) ||
entry.revision >= notification.revision
)
continue;
entry.revision = notification.revision;
deliver(entry, notification);
}
}
} finally {
delivering = false;
}
return true;
},
destroy() {
if (destroyed) return;
destroyed = true;
listeners.clear();
pending.length = 0;
},
});
}
+99
View File
@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStateChannel } from './stateChannel.js';
test('snapshots and action data are immutable copies of live product state', () => {
const live = { selected: { id: 'a' }, items: ['a'] };
const channel = createStateChannel(() => live);
const received = [];
channel.subscribe((event) => received.push(event));
const change = { type: 'selected', item: { id: 'b' } };
live.selected.id = 'b';
channel.publish(change);
change.item.id = 'mutated';
assert.equal(received[0].initial, true);
assert.equal(received[0].state.selected.id, 'a');
assert.equal(received[1].state.selected.id, 'b');
assert.equal(received[1].change.item.id, 'b');
assert.throws(() => received[1].state.items.push('c'), TypeError);
assert.deepEqual(live.items, ['a']);
});
test('reentrant updates retain order and a new subscriber never regresses behind its initial snapshot', () => {
let count = 0;
const channel = createStateChannel(() => ({ count }));
const first = [],
second = [],
joined = [];
channel.subscribe(({ state, initial }) => {
if (initial) return;
first.push(state.count);
if (state.count === 1) {
count = 2;
channel.publish({ type: 'next' });
channel.subscribe(({ state }) => joined.push(state.count));
}
});
channel.subscribe(({ state, initial }) => {
if (!initial) second.push(state.count);
});
count = 1;
channel.publish();
assert.deepEqual(first, [1, 2]);
assert.deepEqual(second, [1, 2]);
assert.deepEqual(joined, [2]);
});
test('unsubscribe and destroy during delivery revoke pending callbacks and future reads', () => {
let reads = 0;
const channel = createStateChannel(() => ({ reads: ++reads }));
let late = 0;
let remove;
channel.subscribe(
() => {
remove();
channel.publish();
channel.destroy();
},
{ emitCurrent: false },
);
remove = channel.subscribe(() => late++, { emitCurrent: false });
channel.publish();
const stoppedReads = reads;
channel.publish();
channel.subscribe(() => late++);
channel.destroy();
remove();
assert.equal(late, 0);
assert.equal(reads, stoppedReads);
});
test('a failed subscriber cannot prevent other controls from receiving an update', () => {
const channel = createStateChannel(() => ({ selected: 'a' }));
const errors = [];
const original = console.error;
console.error = (message) => errors.push(message);
try {
channel.subscribe(() => {
throw new Error('consumer failure');
});
let received = 0;
channel.subscribe(() => received++);
channel.publish();
assert.equal(received, 2);
assert.equal(errors.length, 2);
} finally {
console.error = original;
channel.destroy();
}
});
test('an invalid initial snapshot cannot retain a subscriber without a cleanup handle', () => {
let live = { action() {} };
const channel = createStateChannel(() => live);
let calls = 0;
assert.throws(() => channel.subscribe(() => calls++), /plain data/);
live = { selected: 'a' };
channel.publish();
assert.equal(calls, 0);
});
+9 -2
View File
@@ -43,7 +43,11 @@ export function clampBloomIntensity(value) {
* @returns {number} equivalent v2 intensity
*/
export function legacyBloomToV2(value) {
const legacy = clamp(Math.round(Number(value) || 0), 0, LEGACY_BLOOM_INTENSITY_MAX);
const legacy = clamp(
Math.round(Number(value) || 0),
0,
LEGACY_BLOOM_INTENSITY_MAX,
);
return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2);
}
@@ -59,7 +63,10 @@ export function decodeBloomIntensity(value, version = 1) {
if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT;
const normalizedVersion = Number(version);
if (Number.isFinite(normalizedVersion) && normalizedVersion >= BLOOM_SCALE_VERSION) {
if (
Number.isFinite(normalizedVersion) &&
normalizedVersion >= BLOOM_SCALE_VERSION
) {
return clampBloomIntensity(num);
}
+59 -64
View File
@@ -1,3 +1,7 @@
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -5,14 +9,19 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8');
const vessels = fs.readFileSync(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), 'utf8');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
function body(source, pattern, label) {
const name = pattern.source.match(/^(\w+)/)?.[1];
if (source === ui && typeof StyleManager.prototype[name] === 'function') {
const method = StyleManager.prototype[name].toString();
return method.slice(method.indexOf(') {') + 3, -1);
}
const match = source.match(pattern);
assert.ok(match, `${label} is missing`);
return match[1];
@@ -21,7 +30,7 @@ function body(source, pattern, label) {
function ordered(source, needles, label) {
let previous = -1;
for (const needle of needles) {
const index = source.indexOf(needle);
const index = source.indexOf(needle, previous + 1);
assert.ok(index >= 0, `${label}: missing ${needle}`);
assert.ok(index > previous, `${label}: ${needle} is out of order`);
previous = index;
@@ -29,11 +38,7 @@ function ordered(source, needles, label) {
}
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = body(
ui,
/enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/,
'Cockpit enter',
);
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();',
@@ -42,7 +47,7 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
], 'Cockpit takeover');
assert.match(
ui,
/onCameraTakeover: \(\) => this\._stampNavigation\(\{ cancelPendingSelection: false \}\),/,
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
'Cockpit retires stale camera work without clearing the aircraft selection it adopts',
);
});
@@ -53,14 +58,14 @@ test('one explicit tracking selection clears sibling IDs before publishing its d
/_persistAwarenessSelection\(event, cleared = false\) \{([\s\S]*?)\n \}/,
'tracking persistence',
);
assert.match(persist, /adoptLayerParams\?\.\(layerId,/);
assert.match(persist, /adoptLayerParams\?\.\(\s*layerId,/);
assert.match(persist, /\['flights', 'selectedFlightsTrackingId'\]/);
assert.match(persist, /\['military', 'selectedMilitaryTrackingId'\]/);
assert.match(persist, /\['satellites', 'selectedSatTrackingId'\]/);
assert.match(persist, /if \(otherLayerId === layerId\) continue;/);
assert.match(
persist,
/for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(otherLayerId,[\s\S]*?adoptLayerParams\?\.\(layerId,/,
/for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(\s*otherLayerId,[\s\S]*?adoptLayerParams\?\.\(\s*layerId,/,
'the previous family clears before Flight/Military/Satellite publishes the new durable ID',
);
});
@@ -75,9 +80,9 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /if \(!passivelyClearedShareSelection && !flightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedFlightsTrackingId: null/);
assert.match(stamp, /if \(!passivelyClearedShareSelection && !militaryFlightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedMilitaryTrackingId: null/);
assert.match(stamp, /if \(!passivelyClearedShareSelection && !satellitesLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedSatTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
});
test('voice Cockpit entry reaches the camera only through stamping seams', () => {
@@ -110,7 +115,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
assert.match(
ui,
/viewer\.trackedEntityChanged\.addEventListener\(\(entity\) => \{\s*if \(entity && !this\._disposed\) this\._stampNavigation\(\{ cancelPendingSelection: false \}\);/,
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
'tracker handoff must stamp',
);
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
@@ -131,21 +136,18 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
// is what stamps. Divergence here is how a voice-only camera path escapes
// the arbiter.
assert.match(
ui,
/this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/,
CockpitViewController.toString(),
/this\._listen\(this\.contextPrevious, 'click', \(\) =>\s*this\.navigateContext\(-1, \{ origin: 'user' \}\),?\s*\);/,
);
assert.match(
ui,
/this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/,
);
const funnel = body(
ui,
/navigateContext\(direction, options = \{\}\) \{([\s\S]*?)\n \}\n\n \/\*\* Adopt/,
'Cockpit Context navigation funnel',
CockpitViewController.toString(),
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
);
const funnel = navigateContext.toString();
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
ordered(funnel, [
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;',
'const navigationOptions = wasActive',
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
'this._adoptTrackedEntity(performance.now());',
], 'Cockpit Context navigation funnel');
@@ -243,22 +245,22 @@ test('validated voice camera destinations share the UI navigation authority faca
});
test('deferred search releases only after its final authority check', () => {
const handler = body(
ui,
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
'search handler',
);
ordered(handler, [
"this._beginDeferredNavigation('location')",
'this._activeLocationSearchGeneration = generation;',
'searchAndFlyTo(this.viewer, query',
'beforeFly: () => this._reassertNavigationHandoff(generation)',
'generation !== this._navigationGeneration',
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'const authority = this.begin();',
'this.onStart?.(authority);',
'await this.search(query',
'beforeFly: () => current() && this.beforeFly(authority)',
'if (!current() || controller.signal.aborted) return;',
'destination?.cancelled',
'finally',
'this._settleLocationSearchUi(generation)',
], 'deferred search');
assert.doesNotMatch(handler.slice(0, handler.indexOf('searchAndFlyTo')), /_releaseFollowCamera/);
'this.onSettled?.(authority)',
], 'deferred search component');
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
});
test('a direct globe gesture retires delayed camera and selection restore only', () => {
@@ -268,15 +270,15 @@ test('a direct globe gesture retires delayed camera and selection restore only',
);
assert.match(
ui,
/viewer\?\.canvas\?\.addEventListener\('pointerdown', this\._initialShareGestureHandler/,
/viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/,
);
assert.match(
ui,
/viewer\?\.canvas\?\.addEventListener\('wheel', this\._initialShareGestureHandler/,
/viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/,
);
assert.match(
ui,
/removeEventListener\('pointerdown', this\._initialShareGestureHandler\)[\s\S]*?removeEventListener\('wheel', this\._initialShareGestureHandler\)/,
/removeEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler,?\s*\)[\s\S]*?removeEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler,?\s*\)/,
);
const stamp = body(
ui,
@@ -300,7 +302,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
'this._disposed = true;',
'this._stampNavigation();',
'this._removeWorldRequestFocusListener?.();',
'await this._restoreContextSession();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
});
@@ -311,7 +313,7 @@ test('teardown synchronously closes immediate camera entry points', () => {
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
'await this._restoreContextSession();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
const navigation = body(
@@ -337,25 +339,18 @@ test('teardown synchronously closes immediate camera entry points', () => {
});
test('teardown refuses deferred location work before geocoding begins', () => {
const deferred = body(
ui,
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
'deferred navigation',
);
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
assert.match(deferred, /disposed: this\._disposed/);
const handler = body(
ui,
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
'search handler',
);
ordered(handler, [
"const generation = this._beginDeferredNavigation('location');",
'if (generation === false)',
'this._locationSearch.blur();',
'searchAndFlyTo(this.viewer, query',
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input.blur();',
'this.onStart?.(authority);',
'await this.search(query',
], 'disposed search refusal');
assert.match(handler, /if \(generation === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\._activeLocationSearchGeneration/);
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
});
test('refused canned destinations commit no location or POI state', () => {
@@ -387,7 +382,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
}
const vesselClick = body(
vessels,
/handler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
'vessel click',
);
ordered(vesselClick, [
@@ -397,7 +392,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
], 'vessel sibling ownership');
const vesselFocus = body(
vessels,
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n\}/,
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
'vessel focus helper',
);
assert.match(vesselFocus, /requestWorldFocus\(\{/);
+23 -1
View File
@@ -73,7 +73,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur
assert.match(diagnostics[0][0], /before setEnabled await/);
assert.match(diagnostics[1][0], /after setEnabled await/);
const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
});
@@ -95,3 +95,25 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
assert.deepEqual(transitions, [false]);
assert.deepEqual(diagnostics, []);
});
test('a disposed UI cannot activate a camera when enabling finishes late', async () => {
let release;
let disposed = false;
let activations = 0;
const pending = runCctvLayerEnableTransition({
target: true,
setEnabled: () => new Promise((resolve) => { release = resolve; }),
readOwnership: () => ({}),
shouldFocus: () => !disposed,
activate: () => { activations++; return 'camera-a'; },
fly: () => assert.fail('disposed UI must not fly'),
debug: () => {},
});
disposed = true;
release();
await pending;
assert.equal(activations, 0);
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/);
});
+1 -1
View File
@@ -57,7 +57,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once
assert.strictEqual(added[0].callback, listener);
assert.strictEqual(removed[0].callback, added[0].callback);
const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(
uiSource,
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
+162 -147
View File
@@ -1,3 +1,23 @@
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { SceneControls } from './ui/sceneControls.js';
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js';
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js';
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _restoreContextSession } from './ui/contextSession.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -7,11 +27,11 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const css = readStylesheet(path.join(ROOT, 'style.css'));
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8');
const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
test('Cockpit has one reset action beside its bottom exit path', () => {
@@ -65,16 +85,16 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
});
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
assert.match(ui, /const modes = COCKPIT_VISION_MODES;/);
assert.match(ui, /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/);
assert.doesNotMatch(ui, /none: 'NONE'/);
assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/);
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(this\.stages, this\.transitions\);/);
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
assert.match(
ui,
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
);
assert.match(
@@ -92,20 +112,20 @@ test('Contacts uses the approved radar icon', () => {
});
test('Cockpit Escape handling precedes form-control shortcut suppression and focus is restored', () => {
const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/);
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
const escapeIndex = keydown[1].indexOf("event.key === 'Escape'");
const formGuardIndex = keydown[1].indexOf("closest?.('input, textarea, select, [contenteditable]')");
const escapeIndex = keydown.indexOf("event.key === 'Escape'");
const formGuardIndex = keydown.indexOf("closest?.('input, textarea, select, [contenteditable]')");
assert.ok(escapeIndex >= 0 && formGuardIndex > escapeIndex, 'Escape must work while focus is inside a form control');
const enter = ui.match(/\n enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/);
const exit = ui.match(/\n exit\(\{ restoreTracking = true \} = \{\}\) \{([\s\S]*?)\n \}\n\n update\(\)/);
const enter = cockpitEnter.toString();
const exit = cockpitExit.toString();
assert.ok(enter && exit, 'Cockpit entry/exit methods are missing');
assert.match(enter[1], /activeElement/);
assert.match(enter[1], /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit[1], /focus\(\{ preventScroll: true \}\)/);
assert.match(exit[1], /this\.restoreTrackingFrame\(entity\)/);
assert.doesNotMatch(exit[1], /applyTrackedCameraFrame/);
assert.match(enter, /activeElement/);
assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit, /focus\(\{ preventScroll: true \}\)/);
assert.match(exit, /this\.restoreTrackingFrame\(entity\)/);
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match(
ui,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
@@ -114,24 +134,22 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
});
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/);
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown[1], /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/);
assert.match(keydown[1], /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(keydown, /document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/);
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(
keydown[1],
/const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
);
});
test('the Contact panel never hides itself out from under its own NEXT button', () => {
const updateContext = ui.match(/\n updateContext\(info, heading\) \{([\s\S]*?)\n \}\n\n scheduleContextLayout\(\)/);
const updateContext = cockpitContext.toString();
assert.ok(updateContext, 'Cockpit updateContext is missing');
const body = updateContext[1];
// ui.js cannot be imported under node (Cesium's `mgrs` dependency), so the
// decision lives in cockpitMath.resolveCockpitContextReadout and this pins
// ui.js to it. The panel hosts PREVIOUS/NEXT: the only reason it may hide is
// that no snapshot exists at all.
const body = updateContext;
// Pin the real presentation owner to the readout policy: PREVIOUS/NEXT
// remains reachable unless no Context snapshot exists at all.
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/);
assert.equal(
@@ -164,25 +182,23 @@ test('the Contact panel never hides itself out from under its own NEXT button',
);
assert.match(
css,
/\.cockpit-context-window\[data-state="lost"\]/,
/\.cockpit-context-window\[data-state='lost'\]/,
'CONTACT LOST reuses the panel-level data-state cue that "uncertain" already uses',
);
});
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
const read = ui.match(/\n readAircraftInfo\(\) \{([\s\S]*?)\n \}/);
const read = readAircraftInfo.toString();
assert.ok(read, 'readAircraftInfo is missing');
assert.match(read[1], /resolveTrackedAircraftInfo\(\{/);
assert.match(read[1], /gevTrackedId/);
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
assert.match(read, /gevTrackedId/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
// so its own handle is the tracked identity there.
assert.match(read[1], /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
});
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/);
assert.ok(handler, 'Context layer state handler is missing');
assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/);
assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/);
});
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
@@ -227,8 +243,9 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css,
/body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
);
assert.match(sceneDirector, /this\._running = true;\s*document\.body\.classList\.add\('scene-playback-mode'\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*document\.body\.classList\.remove\('scene-playback-mode'\);/);
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
assert.equal((html.match(/id="detection-toggle"/g) || []).length, 1, 'Detection control must have one stateful DOM owner');
assert.equal((html.match(/id="models3d-toggle"/g) || []).length, 1, '3D control must have one stateful DOM owner');
@@ -239,17 +256,17 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
const cockpitRadio = html.match(/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<\/aside>/);
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch(cockpitRadio[0], /context-radio-details-btn|radio-filter|radio-tuner/);
assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(--cockpit-utility-top/);
assert.match(css, /\[data-cockpit-launcher="display"\]\s*\{\s*width:\s*var\(--left-collapsed-width, 176px\)/);
assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/);
assert.match(css, /\[data-cockpit-launcher='display'\]\s*\{\s*width:\s*var\(\s*--left-collapsed-width, 176px\)/);
assert.match(css, /--display-panel-expanded-width:\s*272px/);
assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(--display-panel-expanded-width\)/);
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)\s*\{[\s\S]*?width:\s*var\(--display-panel-expanded-width\)/);
assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(\s*--display-panel-expanded-width\)/);
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)\s*\{[\s\S]*?width:\s*var\(\s*--display-panel-expanded-width\)/);
assert.doesNotMatch(ui, /--cockpit-display-expanded-width|dataPanelWidth/);
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, 0\.42\)/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/);
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
// These two widths size Cockpit Radio and nothing else. They were named for
@@ -259,8 +276,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(css, /--cockpit-radio-expanded-width:\s*232px/);
assert.doesNotMatch(css, /--map-stack-(?:collapsed|expanded)-width/);
assert.doesNotMatch(html, /id="stack-panel"/, 'the retired Map Stack panel must not remain in Cockpit layout');
assert.match(css, /\[data-cockpit-launcher="radio"\]\s*\{\s*width:\s*var\(--cockpit-radio-collapsed-width, 148px\)/);
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="radio"\]\)[\s\S]*?--cockpit-radio-expanded-width/);
assert.match(css, /\[data-cockpit-launcher='radio'\]\s*\{\s*width:\s*var\(\s*--cockpit-radio-collapsed-width, 148px\)/);
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='radio'\]\)[\s\S]*?--cockpit-radio-expanded-width/);
assert.doesNotMatch(ui, /--cockpit-radio-tab-width/);
assert.doesNotMatch(css, /\.cockpit-utility-launcher:hover/);
const desktopUtilityCss = css.slice(
@@ -273,8 +290,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
);
assert.match(css, /\.cockpit-utility-controls\.layout-primary-only[\s\S]*?display:\s*none/);
assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*?\.cockpit-utility-controls:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?display:\s*none/);
assert.match(ui, /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/);
assert.match(ui, /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{\s*availableHeight,\s*expandedHeight,\s*collapsedHeight,?\s*\}\)/);
assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
});
test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => {
@@ -286,7 +303,7 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
);
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1, 'Parameters must have one DOM owner');
assert.match(
ui,
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
);
assert.match(
@@ -302,67 +319,63 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
manager,
/for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/,
);
assert.match(ui, /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
assert.match(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
assert.match(_selectContextMode.toString(), /async function _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
assert.match(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(_restoreContextSession.toString(), /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(clearSelectedLayers.toString(), /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
});
test('Cockpit Display portal retains both scroll owners across round trips', () => {
assert.match(ui, /this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop \|\| 0/);
assert.match(ui, /this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop \|\| 0/);
assert.match(ui, /if \(!this\._cockpitDisplayPortalActive\)[\s\S]*?this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop/);
assert.match(ui, /if \(this\._cockpitDisplayPortalActive\)[\s\S]*?this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop/);
assert.match(ui, /this\._cockpitDisplayPanel\.scrollTop = this\._cockpitDisplayScrollTop[\s\S]*?this\._ppToggles\.scrollTop = this\._standardDisplayScrollTop/);
const portal = CockpitDisplayPortal.toString();
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
assert.match(
ui,
/if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/,
assert.match(radioBindings,
/if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
);
assert.match(
ui,
/!nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel = !this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
);
assert.match(
ui,
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
assert.match(setContextCollapsed.toString(),
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
'Contact expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(
ui,
assert.match(setSignalCollapsed.toString(),
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(
ui,
setSignalCollapsed.toString(),
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
);
assert.match(
ui,
/!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/,
radioBindings,
/!displayOpen\s*&&\s*!radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/,
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
);
assert.match(
ui,
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
radioBindings,
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
'left-side interactions must not collapse the independent Cockpit utilities',
);
});
@@ -392,7 +405,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
);
assert.match(
callback[1],
/_cockpitPanelRestore\.set\(panelId, panel\.classList\.contains\('collapsed'\)\)/,
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
'entry must remember each panel\'s exact collapsed state before hiding it',
);
assert.match(
@@ -437,12 +450,12 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
});
test('real disclosure changes reconsider only their own temporary panel lane', () => {
const collapseStart = ui.indexOf('setPanelCollapsed(panelId, collapsed, {');
const collapseStart = ui.indexOf(' setPanelCollapsed(');
const collapse = ui.slice(
collapseStart,
ui.indexOf('toggleCleanView(forceEnabled)', collapseStart),
);
assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed && !wasAutoCollapsed[\s\S]*?return;/);
assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/);
assert.match(collapse, /_rightPanelStack\?\.contains\(panelEl\)[\s\S]*?_scheduleRightPanelLayout\(\{ reconsiderAutoCollapse: true \}\)/);
assert.match(collapse, /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/);
});
@@ -450,7 +463,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
test('Cockpit hides the complete top-center globe action group', () => {
assert.match(
css,
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\) \{ display: none !important; \}/,
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
);
assert.match(
css,
@@ -467,14 +480,14 @@ test('Reset releases Contact camera ownership through its selection-preserving r
assert.ok(resetStart >= 0);
assert.ok(contextRelease > resetStart);
assert.ok(satelliteRelease > contextRelease);
assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/);
assert.match(
ui,
/for \(const button of \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]\) \{[\s\S]*?addEventListener\('click', this\._globeResetHandler\)/,
'both reset controls must delegate to the one shared reset route',
);
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
assert.match(readShellElements.toString(), /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/);
const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8');
assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/);
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
'both reset controls delegate to the same supplied reset action');
assert.match(cockpitEnter.toString(), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
assert.match(readRadioSource(new URL('./ui/cockpitTrackingController.js', import.meta.url), 'utf8'), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
});
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
@@ -496,30 +509,34 @@ test('Location navigation releases immediate routes before flight and deferred r
const poiHandler = ui.slice(ui.indexOf('_onPoiClick(cityId, poiIndex) {'), ui.indexOf('_expandPOIRow(cityId) {'));
assert.match(cityHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/);
assert.match(poiHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/);
assert.match(ui.slice(search, search + 320), /beforeFly: \(\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui.slice(search, search + 180), /\.\.\.options/);
const lookup = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
assert.match(lookup, /beforeFly: \(\) => current\(\) && this\.beforeFly\(authority\)/);
assert.ok(voiceStart >= 0, 'voice Location must use the same handoff');
assert.match(voiceActions, /styleManager\.reassertDeferredLocationNavigation\(generation\)/);
});
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/);
assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/);
assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/);
assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/);
assert.match(
ui,
/_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
);
assert.match(
ui,
/_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
);
});
test('Cockpit panel corridors reserve the owned topline readouts', () => {
const leftObstacles = ui.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
const rightObstacles = ui.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const leftObstacles = layout.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
const rightObstacles = layout.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
assert.ok(leftObstacles && rightObstacles, 'responsive panel obstacle selectors are missing');
assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline/);
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/);
@@ -533,45 +550,45 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
'the right margin must not borrow the left accordion corridor: it is solved '
+ 'against left-lane obstacles and put the strip through the briefing card',
);
const signalLayout = ui.match(/syncSignalLayout\(\) \{([\s\S]*?)\n \}\n\n dispose\(\)/);
const signalLayout = syncSignalLayout.toString();
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
assert.match(
signalLayout[1],
/setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/,
signalLayout,
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
'Cockpit owns the utility strip anchor and republishes it every layout tick',
);
assert.match(
signalLayout[1],
signalLayout,
/resolveCockpitUtilityAnchor\(\{[\s\S]*?recBottom: recBounds \? recBounds\.bottom : 0,[\s\S]*?signalTop: signalBounds\.top,[\s\S]*?stripHeight: utilityBounds\.height,/,
'the strip hangs off the REC readout and is clamped by the briefing card it shares the margin with',
);
assert.match(signalLayout[1], /#intel-hud \.hud-top-right/);
assert.match(signalLayout, /#intel-hud \.hud-top-right/);
assert.match(
signalLayout[1],
/const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/,
signalLayout,
/const recBounds = isRenderedOnScreen\(recReadout\)\s*\? recReadout\.getBoundingClientRect\(\)\s*: null;/,
'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC '
+ 'readout a rect — a rect test alone would anchor the strip to an invisible readout',
);
assert.match(
ui,
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
isRenderedOnScreen.toString(),
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
);
assert.match(
ui,
/_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.cockpitView\?\.scheduleContextLayout\(\);/,
layout,
/_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.scheduleCockpitLayout\(\);/,
'the strip must remeasure on the same HUD fade the accordion does — the REC '
+ 'readout keeps its rect until the transition ends',
);
assert.doesNotMatch(
signalLayout[1],
signalLayout,
/Math\.max\(120,/,
'the 120px corridor floor can never be reached by a 107px strip and only hid the collision',
);
assert.match(signalLayout[1], /const availableHeight = utilityAnchor\.maxHeight;/);
assert.match(signalLayout[1], /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
assert.match(signalLayout, /const availableHeight = utilityAnchor\.maxHeight;/);
assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
assert.match(
css,
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/,
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(\s*--cockpit-utility-top,\s*var\(\s*--left-stack-safe-top,\s*var\(\s*--left-stack-top\)\s*\)\s*\)/,
'the first frame before Cockpit publishes an anchor still needs the fallback chain',
);
assert.match(css, /body\.cockpit-mode #left-panel-stack\s*\{[\s\S]*?transition:\s*none;/);
@@ -634,26 +651,26 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
);
assert.doesNotMatch(ui, /\['presets',/);
assert.match(
ui,
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)/,
CockpitDisplayPortal.toString(),
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
);
assert.match(
ui,
/_setCockpitDisplayPortalActive\(active\) \{[\s\S]*?record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
CockpitDisplayPortal.prototype.setActive.toString(),
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
);
assert.match(
ui,
/window\.removeEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)[\s\S]*?_setCockpitDisplayPortalActive\(false\)[\s\S]*?record\.anchor\.remove\(\)/,
CockpitDisplayPortal.prototype.destroy.toString(),
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
);
assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/);
assert.equal((html.match(/id="style-buttons"/g) || []).length, 1);
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/);
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
assert.match(cycleVisionMode.toString(), /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{[\s\S]*?min-width:\s*0;/);
@@ -691,14 +708,12 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
});
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
const match = ui.match(
/_adoptTrackedEntity\(nowMs, suppliedInfo = null\) \{([\s\S]*?)\n \}\n\n setVisionMode/,
);
const match = _adoptTrackedEntity.toString();
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
assert.match(match[1], /this\.viewer\.trackedEntity = undefined;/);
assert.match(match[1], /this\.cockpitAnchorValid = false;/);
assert.match(match[1], /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
assert.match(match[1], /this\.lastFrameMs = nowMs;/);
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
assert.match(match, /this\.cockpitAnchorValid = false;/);
assert.match(match, /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
assert.match(match, /this\.lastFrameMs = nowMs;/);
});
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
@@ -723,7 +738,7 @@ test('cockpit summary presents the focused item as Contact', () => {
assert.match(match[0], /aria-label="Next — nearest unvisited contact in the 250 km window"/);
assert.match(match[0], /aria-label="Collapse Contact panel"/);
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
assert.match(ui, /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/);
assert.match(setContextCollapsed.toString(), /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/);
});
test('Cockpit Contact navigation omits the redundant Focus camera action', () => {
@@ -746,7 +761,9 @@ test('Global Context names its mixed contact cycle without changing the stable m
test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => {
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
assert.match(manager, /if \(!layer\.showInTogglePanel\) continue;/);
const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/);
assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/);
assert.match(html, /id="global-context-panel"/);
assert.match(html, /id="global-context-flights-btn"/);
assert.match(html, /id="global-context-missions-btn"/);
@@ -771,14 +788,12 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
assert.match(match[0], />CYCLE OFF<\/button>/);
assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./);
const update = ui.match(
/setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/,
);
const update = setBriefAutoRotate.toString();
assert.ok(update, 'cockpit briefing cycle state updater is missing');
assert.match(update[1], /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
assert.match(update[1], /setAttribute\('aria-label', label\)/);
assert.match(update[1], /\.title = help;/);
assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/);
assert.match(update, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
assert.match(update, /setAttribute\('aria-label', label\)/);
assert.match(update, /\.title = help;/);
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
});
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
@@ -845,7 +860,7 @@ test('cockpit state cannot report entryAllowed while already active', () => {
);
assert.match(
state,
/const entryAllowed = !active && Boolean\(/,
/const entryAllowed =\s*!active &&\s*Boolean\(/,
'entry is impossible while already inside, unconditionally',
);
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
+76 -36
View File
@@ -25,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) {
* becoming a first-person surge or reversal while still removing drift.
*/
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
if (!Number.isFinite(distanceM) || distanceM <= 0
|| !Number.isFinite(dtSec) || dtSec <= 0) return 0;
if (
!Number.isFinite(distanceM) ||
distanceM <= 0 ||
!Number.isFinite(dtSec) ||
dtSec <= 0
)
return 0;
const dt = Math.min(0.1, dtSec);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
@@ -36,14 +41,21 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
/** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false;
if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
return false;
if (
!Number.isFinite(lastUpdateMs) ||
lastUpdateMs <= 0 ||
nowMs < lastUpdateMs
)
return true;
return nowMs - lastUpdateMs >= intervalMs;
}
/** Return whether the bounded rendered-surface acquisition window has elapsed. */
export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) return true;
if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0)
return true;
return nowMs - startedMs >= timeoutMs;
}
@@ -51,7 +63,11 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
* Keep the cockpit camera above the shared rendered-surface floor.
* Unknown floors preserve the proposed camera height.
*/
export function cockpitGroundSafeHeight(proposedHeightM, groundHeightM, clearanceM) {
export function cockpitGroundSafeHeight(
proposedHeightM,
groundHeightM,
clearanceM,
) {
if (!Number.isFinite(proposedHeightM)) return proposedHeightM;
if (!Number.isFinite(groundHeightM)) return proposedHeightM;
const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0;
@@ -68,36 +84,51 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
}
/** Format the cockpit Context scope without overstating installation coverage. */
export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) {
const normalizedLabel = typeof subjectLabel === 'string'
? subjectLabel.trim() || '—'
: Number.isFinite(subjectLabel) ? String(subjectLabel) : '—';
const radiusKm = Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
const coverage = typeof installationCoverage === 'string'
? installationCoverage.trim()
: '';
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
? subjectLabel.trim() || '—'
: Number.isFinite(subjectLabel)
? String(subjectLabel)
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage
? `${base} · INSTALLATIONS ${coverage}`
: base;
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
export function compassDivisions(heading) {
const center = Math.round(normalizeHeading(heading) / 30) * 30;
return [-90, -60, -30, 0, 30, 60, 90].map((offset) => normalizeHeading(center + offset));
return [-90, -60, -30, 0, 30, 60, 90].map((offset) =>
normalizeHeading(center + offset),
);
}
/** Format a compass division as a cardinal/intercardinal label or degrees. */
export function formatCompassDivision(heading) {
const normalized = normalizeHeading(heading);
const labels = new Map([
[0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'],
[180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'],
[0, 'N'],
[45, 'NE'],
[90, 'E'],
[135, 'SE'],
[180, 'S'],
[225, 'SW'],
[270, 'W'],
[315, 'NW'],
]);
return labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0');
return (
labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0')
);
}
/** Choose a readable altitude-tape interval for the current flight level. */
@@ -190,20 +221,22 @@ export function formatSpeedRulerTick(valueKt) {
/** Return the initial great-circle bearing between two latitude/longitude points. */
export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) {
if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null;
const φ1 = fromLat * Math.PI / 180;
const φ2 = toLat * Math.PI / 180;
const Δλ = (toLon - fromLon) * Math.PI / 180;
const φ1 = (fromLat * Math.PI) / 180;
const φ2 = (toLat * Math.PI) / 180;
const Δλ = ((toLon - fromLon) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2)
- Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const x =
Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) return null;
return normalizeHeading(Math.atan2(y, x) * 180 / Math.PI);
return normalizeHeading((Math.atan2(y, x) * 180) / Math.PI);
}
/** Return a target bearing relative to the current heading in the [-180, 180) range. */
export function relativeBearing(bearing, heading) {
if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null;
return ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180;
return (
((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180
);
}
/**
@@ -229,10 +262,13 @@ export function resolveTrackedAircraftInfo({
if (civilian) candidates.push({ ...civilian, layerId: 'flights' });
if (military) candidates.push({ ...military, layerId: 'military' });
if (!candidates.length) return null;
const trackedKey = String(trackedId || '').trim().toLowerCase();
const trackedKey = String(trackedId || '')
.trim()
.toLowerCase();
if (trackedKey) {
const owner = candidates.find(
(candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
(candidate) =>
`${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
);
if (owner) return owner;
}
@@ -253,7 +289,10 @@ export function resolveTrackedAircraftInfo({
* aircraftRelative: boolean, contactLost: boolean}}
* Panel visibility plus the per-field rendering rules for this frame.
*/
export function resolveCockpitContextReadout({ snapshot = null, info = null } = {}) {
export function resolveCockpitContextReadout({
snapshot = null,
info = null,
} = {}) {
if (!snapshot) {
return {
visible: false,
@@ -264,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
};
}
const trackedId = info?.icao24 ?? info?.id;
const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId
&& String(snapshot.subject?.id) === String(trackedId);
const subjectMatchesTracked =
snapshot.subject?.layerId === info?.layerId &&
String(snapshot.subject?.id) === String(trackedId);
// `subjectPresent` is absent on snapshots built before the presence signal
// existed; only an explicit `false` means the subject left its source.
const contactLost = snapshot.subjectPresent === false;
return {
visible: true,
mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'),
mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign',
subjectMatchesTracked,
// The nose-relative arrow and BRG readout are measured in the tracked
// aircraft's own frame; every other value in that row is measured from the
+9 -13
View File
@@ -1,14 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { afterEach } from 'node:test';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const renderStart = source.indexOf(' renderCockpitSignals() {');
const renderEnd = source.indexOf(' setContextCollapsed(', renderStart);
assert.ok(renderStart >= 0 && renderEnd > renderStart);
const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1];
assert.ok(clickSource, 'the installed signal click route exists');
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => { globalThis.document = previousDocument; });
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
@@ -84,8 +80,9 @@ function fixture() {
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
const controller = new (new Function('document', 'formatAwarenessLabel',
`return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))();
globalThis.document = document;
const controller = Object.create(CockpitViewController.prototype);
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
@@ -98,9 +95,8 @@ function fixture() {
const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
const selected = [];
const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({
focusTarget: (...args) => selected.push(args),
});
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
const click = event => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => {
const nodes = document.body.querySelectorAll('button');
+4 -2
View File
@@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({
gap = 7,
minimumExpandedHeight = 120,
}) {
const available = Math.max(minimumExpandedHeight, Number(availableHeight) || 0);
const available = Math.max(
minimumExpandedHeight,
Number(availableHeight) || 0,
);
const expanded = Math.max(0, Number(expandedHeight) || 0);
const collapsed = Math.max(0, Number(collapsedHeight) || 0);
const spacing = Math.max(0, Number(gap) || 0);
@@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({
),
};
}
+7 -1
View File
@@ -1,4 +1,10 @@
export const COCKPIT_VISION_MODES = Object.freeze(['optical', 'crt', 'nvg', 'thermal', 'noir']);
export const COCKPIT_VISION_MODES = Object.freeze([
'optical',
'crt',
'nvg',
'thermal',
'noir',
]);
const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro',
+8 -4
View File
@@ -1,3 +1,4 @@
import { _syncContextModeButtons } from './ui/contextPresentation.js';
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
// Contacts, detections should just turn on, and they should stay on in Cockpit
// or in third-person tracking inside Contacts").
@@ -26,7 +27,9 @@ import {
} from './data/detection.js';
import { canonicalizeDensity } from './data/detectionPolicy.js';
const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
// Follow the UI wiring and its extracted preset definitions.
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
/**
* The tactical preset ui.js hands Contacts. Read out of the source so this test
@@ -34,7 +37,7 @@ const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
*/
const MILITARY_PRESET = (() => {
const match = uiSource.match(
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: '(\w+)', densityPct: (\d+) \}\)/,
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: '(\w+)',\s*densityPct: (\d+),?\s*\}\)/,
);
assert.ok(match, 'ui.js must expose one shared military detection preset');
return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) };
@@ -290,10 +293,11 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
'ACTIVATION turns detection on REGARDLESS of the style-preset override flag',
);
assert.match(
uiSource,
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/,
_syncContextModeButtons.toString(),
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/,
'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through',
);
assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/);
// The cockpit vision hook — the old trigger — must be out of the detection
// business entirely, or leaving the cockpit turns detections off again.
const visionHook = uiSource.slice(
+60 -97
View File
@@ -1,7 +1,15 @@
// Source-contract pins for StyleManager's Context-session handler ordering.
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
// these read the shipped source — the idiom established by cockpitMarkup /
// cameraHandoff tests. Each pin guards a bug that shipped or nearly shipped:
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { connectContextManager } from './ui/contextSubscriptions.js';
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
import { _selectContextMode, _clearLayersOutsideContextMode } from './ui/contextTransactions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { setContextMode as contextModeAction } from './ui/contextActions.js';
import { ContextControls } from './ui/contextControls.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
// - session bookkeeping ran AFTER the exit early-return, so the compensating
// userAdded.delete never ran on the left-panel chip exit and restoration
// resurrected the mission layer the user just disabled;
@@ -15,12 +23,9 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8');
const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8');
const handlerStart = src.indexOf('_handleContextLayerChange(change) {');
assert.ok(handlerStart > 0, 'handler found');
const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart);
const handler = src.slice(handlerStart, handlerEnd);
const handler = _handleContextLayerChange.toString();
test('context handler: session bookkeeping runs before the exit early-return', () => {
const record = handler.indexOf('recordContextSessionUserChange(');
@@ -52,23 +57,19 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi
});
test('direct Context entry captures on the synchronous request boundary', () => {
const attachStart = src.indexOf('attachDataManager(dataManager) {');
const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart);
const attach = src.slice(attachStart, attachEnd);
const attach = connectContextManager.toString();
assert.match(
attach,
/subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/,
);
assert.match(
attach,
/_captureContextSessionSnapshot\(\{ excludeLayerIds: \[change\.layerId\] \}\)/,
/_captureContextSessionSnapshot\(\{\s*excludeLayerIds:\s*\[change\.layerId\],?\s*\}\)/,
);
});
test('Clear All defers a newer explicit mission guard until its batch settles', () => {
const attachStart = src.indexOf('attachDataManager(dataManager) {');
const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart);
const attach = src.slice(attachStart, attachEnd);
const attach = connectContextManager.toString();
assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/);
assert.match(attach, /_contextModeDeferredEntryIntent = \{/);
assert.match(attach, /await this\._clearSelectedLayersManagerPromise/);
@@ -76,10 +77,7 @@ test('Clear All defers a newer explicit mission guard until its batch settles',
});
test('context restore replays explicit companion intent after the stale restore queue drains', () => {
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restore = _restoreContextSession.toString();
const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(');
const replay = restore.indexOf('await settleContextIntentReplay(');
assert.ok(restoreAwait > 0, 'restore queue is awaited');
@@ -88,10 +86,7 @@ test('context restore replays explicit companion intent after the stale restore
});
test('context restore settles the Contacts coordinator before dependency fanout', () => {
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restore = _restoreContextSession.toString();
const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'");
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle);
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', settle);
@@ -102,10 +97,7 @@ test('context restore settles the Contacts coordinator before dependency fanout'
});
test('context restore preserves its exact pending target after a failed transition', () => {
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restore = _restoreContextSession.toString();
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/);
assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/);
@@ -120,25 +112,22 @@ test('context handler: either failed direct Context-shell start rolls the sessio
assert.match(failedBranch, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
assert.match(
failedBranch,
/_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{ notificationToken \},\s*\)/,
/_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{\s*notificationToken,?\s*\},?\s*\)/,
);
assert.match(failedBranch, /_runUserFacingContextAction/);
assert.match(failedBranch, /const failureMessage =/);
assert.match(
failedBranch,
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\._showToast\(failureMessage\);\s*return true;/,
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/,
);
assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/);
assert.match(failedBranch, /\},\s*failureMessage,?\s*\),?\s*\);/);
assert.match(failedBranch, /_trackContextLayerReaction\(/);
assert.match(
failedBranch,
/else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/,
/else if \(\s*!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\s*\)/,
);
const deferredRestore = src.slice(
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
src.indexOf('async _selectContextMode('),
);
const deferredRestore = _restoreContextSessionAfterLayerSettles.toString();
assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/);
assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/);
assert.doesNotMatch(failedBranch, /excludeLayerIds/);
@@ -153,50 +142,45 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token
blockedBranch,
/!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/,
);
assert.match(blockedBranch, /this\._showToast\(/);
assert.match(blockedBranch, /this\.showToast\(/);
});
test('every user-facing Context exit route settles through the failure surface', () => {
const initPanel = src.slice(
src.indexOf('_initGlobalContextPanel() {'),
src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')),
);
const initPanel = _initGlobalContextPanel.toString();
assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3);
assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/);
assert.doesNotMatch(initPanel, /void this\._selectContextMode/);
const deactivationCalls = [...handler.matchAll(
/void this\._trackContextLayerReaction\(this\._runUserFacingContextAction\(\(notificationToken\) => \(\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\)\s*\)\)\)/g,
/void this\._trackContextLayerReaction\(\s*this\._runUserFacingContextAction\(\(notificationToken\) =>\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\),?\s*\),?\s*\)/g,
)];
assert.equal(deactivationCalls.length, 4, 'dependency and primary layer exits share the caught restore path');
assert.doesNotMatch(handler, /void this\._deactivateContextForLayerChange\(\)/);
});
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /await this\._runUserFacingContextAction\(/);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
assert.match(radioControls, /if \(toggled === false\) return/);
assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/);
});
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2);
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
});
test('right-rail context entry is transactional: activation result gates the mode', () => {
const select = src.slice(
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
const select = _selectContextMode.toString();
const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent(');
assert.ok(activation > 0, 'activation result captured');
const failureBlock = select.slice(activation);
@@ -206,8 +190,8 @@ test('right-rail context entry is transactional: activation result gates the mod
assert.match(failureBlock, /await this\._dataManager\._waitForVisibilityIntent\?\.\(/);
assert.match(failureBlock, /outcome\?\.intentEpoch === replacementIntent\.intentEpoch[\s\S]*?outcome\.succeeded === true/);
assert.match(failureBlock, /outcome\?\.cancellationReason === 'superseded'[\s\S]*?outcome\.successorEnabled === true[\s\S]*?outcome\.successorIntentEpoch > replacementIntent\.intentEpoch/);
assert.match(failureBlock, /activationError \|\| activated === false \|\| !this\._dataManager\.isEnabled\(entryLayerId\)/);
assert.match(failureBlock, /cancelledAndSettled = terminalIntentOutcome\?\.succeeded === false/);
assert.match(failureBlock, /activationError\s*\|\|\s*activated === false\s*\|\|\s*!this\._dataManager\.isEnabled\(entryLayerId\)/);
assert.match(failureBlock, /cancelledAndSettled\s*=\s*terminalIntentOutcome\?\.succeeded === false/);
assert.match(failureBlock, /\['caller-abort', 'resource-abort', 'superseded'\]/);
assert.match(failureBlock, /this\._contextMode = null/);
assert.match(failureBlock, /await this\._restoreContextSession\(\{[\s\S]*?excludeLayerIds: \[entryLayerId\],[\s\S]*?notificationToken/);
@@ -215,11 +199,8 @@ test('right-rail context entry is transactional: activation result gates the mod
});
test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => {
const select = src.slice(
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/);
const select = _selectContextMode.toString();
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode,\s*\{\s*notificationToken,\s*signal,?\s*\}\)/);
assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/);
assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/);
@@ -250,21 +231,19 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi
// invalidates in-flight Context transactions so teardown cannot be raced into
// publishing a mode.
const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {')));
assert.match(dispose, /this\._contextModeGeneration \+= 1;/);
assert.match(dispose, /this\._contextControls\.stop\(\)/);
assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/);
const guard = src.slice(
src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'),
src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"),
);
const guard = connectContextManager.toString();
assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/);
assert.match(guard, /const notificationToken\s*=\s*change\.notificationToken\s*\|\|\s*Symbol\('direct-context-shell-entry'\)/);
assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode, \{ notificationToken \}\)/);
assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode,\s*\{\s*notificationToken,?\s*\}\)/);
assert.match(
guard,
/await this\._restoreContextSession\(\{\s*excludeLayerIds: \[change\.layerId\],\s*notificationToken,\s*\}\)/,
);
assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(notificationToken\)/);
assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(\s*notificationToken,?\s*\)/);
});
test('a lost cross-mode switch says Context is off, and the state agrees', () => {
@@ -272,14 +251,11 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
// a bare "did not complete" while the operator's Context was silently gone.
// Text and state are derived from the same verdict so they cannot disagree,
// and the failed layer ids survive (Manjunath's honesty requirement).
const setter = src.slice(
src.indexOf(' async setContextMode(mode, {'),
src.indexOf(' getCockpitState() {'),
);
const setter = contextModeAction.toString();
assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost');
assert.match(
setter,
/const crossModeSwitchLost = transitioned !== true\s*&& Boolean\(priorMode\) && priorMode !== canonical && !state\.mode;/,
/const crossModeSwitchLost\s*=\s*transitioned !== true\s*&&\s*Boolean\(priorMode\)\s*&&\s*priorMode !== canonical\s*&&\s*!state\.mode;/,
'the verdict requires a real cross-mode switch that ended with no mode',
);
assert.match(
@@ -296,14 +272,10 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
});
test('Context cancellation reaches isolation and restore lifecycle mutations', () => {
const clearStart = src.indexOf('async _clearLayersOutsideContextMode(');
const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart));
const clear = _clearLayersOutsideContextMode.toString();
assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/);
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
);
const restore = _restoreContextSession.toString();
assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/);
assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/);
assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/);
@@ -314,10 +286,7 @@ test('Context cancellation reaches isolation and restore lifecycle mutations', (
});
test('Context facade preserves success when cancellation arrives after commit', () => {
const setContextMode = src.slice(
src.indexOf('async setContextMode('),
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
);
const setContextMode = contextModeAction.toString();
assert.match(
setContextMode,
/transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/,
@@ -329,23 +298,17 @@ test('Context facade preserves success when cancellation arrives after commit',
});
test('Context production rollback paths merge primary and restore failed-layer identities', () => {
const select = src.slice(
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
const select = _selectContextMode.toString();
assert.equal(
(select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length,
(select.match(/mergeContextTransitionErrors\(\s*transitionError,\s*restoreError,?\s*\)/g) || []).length,
2,
);
assert.match(select, /transitionError\.failedLayerIds = \[\.\.\.new Set\(\[/);
assert.match(select, /this\._contextTransitionFailedLayerIds = \[\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\)\]/);
assert.match(select, /transitionError\.failedLayerIds = \[\s*\.\.\.new Set\(\[/);
assert.match(select, /this\._contextTransitionFailedLayerIds = \[\s*\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\),?\s*\]/);
});
test('stale Context cancellation preserves rollback failed-layer identities', () => {
const setContextMode = src.slice(
src.indexOf('async setContextMode('),
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
);
const setContextMode = contextModeAction.toString();
assert.match(
setContextMode,
/if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/,
+39 -49
View File
@@ -1,10 +1,19 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
@@ -14,16 +23,14 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/);
}
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
assert.match(sync[1], /button\.tabIndex = 0/);
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
const syncSource = _syncContextModeButtons.toString();
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
assert.match(syncSource, /button\.tabIndex = 0/);
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
const syncSource = _syncContextModeButtons.toString();
const attributes = () => new Map();
const makeButton = () => {
@@ -52,6 +59,8 @@ test('Context transition state preserves focus and Tab availability until settle
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
@@ -64,7 +73,7 @@ test('Context transition state preserves focus and Tab availability until settle
_scheduleRightPanelLayout() {},
};
try {
Function(sync[1]).call(owner);
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
@@ -75,7 +84,7 @@ test('Context transition state preserves focus and Tab availability until settle
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
Function(sync[1]).call(owner);
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
@@ -89,32 +98,22 @@ test('Context transition state preserves focus and Tab availability until settle
});
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const select = ui.slice(
ui.indexOf('async _selectContextMode('),
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
const init = _initGlobalContextPanel.toString();
const select = _selectContextMode.toString();
const clear = clearSelectedLayers.toString();
assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2);
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
});
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
assert.ok(init, 'Context panel initialization is missing');
assert.match(init[1], /event\.key === 'ArrowRight'/);
assert.match(init[1], /event\.key === 'ArrowLeft'/);
assert.match(init[1], /event\.key === 'Home'/);
assert.match(init[1], /event\.key === 'End'/);
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
const initSource = _initGlobalContextPanel.toString();
assert.match(initSource, /event\.key === 'ArrowRight'/);
assert.match(initSource, /event\.key === 'ArrowLeft'/);
assert.match(initSource, /event\.key === 'Home'/);
assert.match(initSource, /event\.key === 'End'/);
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
});
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
@@ -132,22 +131,10 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
});
test('Context async action buttons remain focused while busy', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = ui.slice(
ui.indexOf('const toggleRadio = async (trigger) => {'),
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
);
const radioSync = ui.slice(
ui.indexOf('_renderRadioState(state) {'),
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
@@ -157,5 +144,8 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
assert.match(clear, /this\.setClearBusy\(true\)/);
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
});
+14 -12
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -5,8 +6,8 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
const css = readStylesheet(path.join(ROOT, 'style.css'));
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
/*
* Required-attribution keep-out pin.
@@ -165,7 +166,7 @@ function flattenRules(source) {
rules.push({
order: rules.length,
media: [...mediaStack],
parts: splitTopLevel(head, ',').map((part) => part.trim()).filter(Boolean),
parts: splitTopLevel(head, ',').map((part) => part.trim().replace(/\(\s+/g, '(').replace(/\s+\)/g, ')')).filter(Boolean),
decls,
});
index = close + 1;
@@ -436,20 +437,21 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
const start = ui.indexOf('_updateCommandDockTrayStack() {');
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {');
assert.ok(start > 0, '_updateCommandDockTrayStack is missing');
const writer = ui.slice(start, start + 1800);
const writer = layout.slice(start, layout.indexOf(' _scheduleAdaptivePanelLayout(', start));
// Every value traces back to a rect height, floored at 0 and rounded up.
assert.match(writer, /const locationHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/);
assert.match(writer, /const presetsHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/);
assert.match(writer, /const lowerPinnedHeight = [\s\S]{0,160}?getBoundingClientRect\(\)\.height \|\| 0;/);
assert.match(writer, /const locationHeight =\s*[\s\S]{0,180}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
assert.match(writer, /const presetsHeight =\s*[\s\S]{0,180}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
assert.match(writer, /const lowerPinnedHeight =\s*[\s\S]{0,220}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
assert.match(writer, /const locationHeightPx = Math\.ceil\(locationHeight\);/);
assert.match(writer, /const presetsHeightPx = Math\.ceil\(presetsHeight\);/);
assert.match(writer, /'--dock-location-pinned-height', `\$\{locationHeightPx\}px`/);
assert.match(writer, /'--dock-presets-pinned-height', `\$\{presetsHeightPx\}px`/);
assert.match(writer, /'--dock-lower-pinned-height', `\$\{Math\.ceil\(lowerPinnedHeight\)\}px`/);
assert.match(writer, /'--dock-location-pinned-height',\s*`\$\{locationHeightPx\}px`/);
assert.match(writer, /'--dock-presets-pinned-height',\s*`\$\{presetsHeightPx\}px`/);
assert.match(writer, /'--dock-lower-pinned-height',\s*`\$\{Math\.ceil\(lowerPinnedHeight\)\}px`/);
assert.match(writer, /'--dock-pinned-stack-height', stackHeight/);
assert.match(writer, /const stackHeight = pinnedCount > 1[\s\S]{0,160}?`calc\(\$\{locationHeightPx\}px \+ \$\{presetsHeightPx\}px \+ 1\.2rem\)`/);
assert.match(writer, /const stackHeight =\s*pinnedCount > 1[\s\S]{0,160}?`calc\(\$\{locationHeightPx\}px \+ \$\{presetsHeightPx\}px \+ 1\.2rem\)`/);
});
test('the inputs behind the measured constants are unchanged', () => {
+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',
);
+8 -7
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => {
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.deepEqual(
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
@@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', (
test('an overlay with NO class to watch still disarms the launcher', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
// The repro, pinned as the stacking it actually is: the attribution lightbox
// is full-screen ABOVE the card and announces itself with nothing. The card
@@ -263,14 +264,14 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
// stopPropagation() does NOT stop later listeners on the SAME document, so the
// disclosure's earlier capture handler closed the disclosure and the launcher
// dismissed itself off the same key. The earlier listener is the only one that
// can stop the later one — and only the immediate form does it.
const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock"));
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
assert.match(claim, /event\.preventDefault\(\);/);
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
@@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
test('the scroll fade only appears when the list really overflows', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
// A fade on a card where all five tiles fit promises a sixth mission that does
// not exist, which is worse than no affordance at all.
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
@@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', ()
test('markup, startup ordering and accessibility remain pinned', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8');
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
@@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
});
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
// now, so it was opening to offer controls for things already happening —
// while competing with the mission card for the one first impression there is.
+2 -1
View File
@@ -1,8 +1,9 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
function ruleBody(selector) {
const start = css.indexOf(selector);
+167
View File
@@ -0,0 +1,167 @@
/** Instance-owned military identity and active-layer classification.
* Both aircraft layers share the same application-supplied registry. */
export function createMilitaryRegistry({ source, now = Date.now } = {}) {
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;
let currentSource = null;
let lifetime = null;
let releaseSource = null;
/** Replace the classification source and cancel its outstanding work. */
function configureSource(nextSource, { signal } = {}) {
if (typeof nextSource?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
releaseSource?.();
const controller = new AbortController();
lifetime = controller;
currentSource = nextSource;
_milIcaos.clear();
_lastRefreshMs = 0;
_polling = false;
const release = () => {
controller.abort();
signal?.removeEventListener('abort', release);
if (lifetime !== controller) return;
currentSource = null;
lifetime = null;
releaseSource = null;
_milIcaos.clear();
_lastRefreshMs = 0;
_polling = false;
};
releaseSource = release;
if (signal?.aborted) release();
else signal?.addEventListener('abort', release, { once: true });
return release;
}
/**
* True when the dedicated military layer currently renders these aircraft
* (the flights layer should suppress duplicates rather than restyle them).
* @returns {boolean}
*/
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}
*/
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.
*/
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}
*/
function registerMilitaryIcaos(icaos) {
for (const icao of icaos || []) {
const hex = String(icao || '')
.trim()
.toLowerCase();
if (hex) _milIcaos.add(hex);
}
_lastRefreshMs = now();
}
/**
* Whether an aircraft is known military.
* @param {string} icao24 - ICAO24 hex (any case).
* @returns {boolean}
*/
function isMilitaryIcao(icao24) {
return _milIcaos.has(String(icao24 || '').toLowerCase());
}
/** Refresh known identities while the dedicated layer is disabled.
* A failed or cancelled request retains the current set and never swaps sources. */
async function refreshMilitaryRegistryIfStale() {
if (
!currentSource ||
!lifetime ||
lifetime.signal.aborted ||
_militaryLayerActive
)
return;
if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return;
const owner = lifetime;
const requestSource = currentSource;
const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]);
_polling = true;
try {
const ids =
typeof requestSource.getIdentities === 'function'
? await requestSource.getIdentities({}, { signal })
: (await requestSource.getSnapshot({}, { signal })).records.map(
(record) => record.id,
);
signal.throwIfAborted();
if (lifetime !== owner) return;
if (!Array.isArray(ids))
throw new TypeError('Expected aircraft identities');
registerMilitaryIcaos(ids);
} catch {
// Keep the existing classification through source outages.
} finally {
if (lifetime === owner) _polling = false;
}
}
function dispose() {
releaseSource?.();
_milIcaos.clear();
_lastRefreshMs = 0;
_activeChangeListeners.clear();
_militaryLayerActive = false;
}
if (source) configureSource(source);
return {
configureSource,
dispose,
isMilitaryLayerActive,
setMilitaryLayerActive,
onMilitaryLayerActiveChange,
registerMilitaryIcaos,
isMilitaryIcao,
refreshMilitaryRegistryIfStale,
};
}
+141
View File
@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMilitaryRegistry } from './classification.js';
test('classification instances have independent identities, listeners and active states', () => {
let requests = 0;
const source = {
getSnapshot() {
requests++;
},
};
const first = createMilitaryRegistry({ source });
const second = createMilitaryRegistry({ source });
let firstEvents = 0;
let secondEvents = 0;
first.onMilitaryLayerActiveChange(() => firstEvents++);
second.onMilitaryLayerActiveChange(() => secondEvents++);
first.registerMilitaryIcaos([' ABC123 ']);
first.setMilitaryLayerActive(true);
assert.equal(first.isMilitaryIcao('abc123'), true);
assert.equal(second.isMilitaryIcao('abc123'), false);
assert.equal(firstEvents, 1);
assert.equal(secondEvents, 0);
assert.equal(second.isMilitaryLayerActive(), false);
assert.equal(requests, 0);
first.dispose();
second.dispose();
});
test('classification uses normalized identities and respects its polling interval and active layer', async () => {
let time = 120000;
let calls = 0;
const registry = createMilitaryRegistry({
now: () => time,
source: {
async getSnapshot() {
calls++;
return { records: [{ id: 'abc123' }] };
},
},
});
await registry.refreshMilitaryRegistryIfStale();
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 1);
assert.equal(registry.isMilitaryIcao('abc123'), true);
time += 60000;
registry.setMilitaryLayerActive(true);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 1);
registry.setMilitaryLayerActive(false);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 2);
registry.dispose();
});
test('source replacement rejects an old response and disposal cancels the current request', async () => {
let resolveOld;
let resolveNew;
let oldSignal;
let newSignal;
const registry = createMilitaryRegistry({
source: {
getSnapshot(query, { signal }) {
oldSignal = signal;
return new Promise((resolve) => {
resolveOld = resolve;
});
},
},
});
const old = registry.refreshMilitaryRegistryIfStale();
const application = new AbortController();
registry.configureSource(
{
getSnapshot(query, { signal }) {
newSignal = signal;
return new Promise((resolve) => {
resolveNew = resolve;
});
},
},
{ signal: application.signal },
);
const current = registry.refreshMilitaryRegistryIfStale();
resolveOld({ records: [{ id: 'abc123' }] });
await old;
application.abort();
resolveNew({ records: [{ id: 'def456' }] });
await current;
assert.equal(oldSignal.aborted, true);
assert.equal(newSignal.aborted, true);
assert.equal(registry.isMilitaryIcao('abc123'), false);
assert.equal(registry.isMilitaryIcao('def456'), false);
registry.dispose();
});
test('a failed classification source preserves known identities and makes no fallback request', async () => {
let time = 120000;
let calls = 0;
const registry = createMilitaryRegistry({
now: () => time,
source: {
async getSnapshot() {
calls++;
throw new Error('fixture source unavailable');
},
},
});
registry.registerMilitaryIcaos(['abc123']);
time += 60000;
await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale());
assert.equal(calls, 1);
assert.equal(registry.isMilitaryIcao('abc123'), true);
registry.dispose();
assert.equal(registry.isMilitaryIcao('abc123'), false);
});
test('disposing a registry fed directly by its layer also clears classification', () => {
const registry = createMilitaryRegistry();
registry.registerMilitaryIcaos(['abc123']);
registry.dispose();
assert.equal(registry.isMilitaryIcao('abc123'), false);
});
test('classification prefers an explicit identity capability over positioned observations', async () => {
const registry = createMilitaryRegistry({
source: {
getSnapshot() {
assert.fail(
'position snapshots must not replace an available identity capability',
);
},
async getIdentities() {
return ['abc123'];
},
},
});
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
registry.dispose();
});
+17
View File
@@ -0,0 +1,17 @@
/** Common aircraft calculations and explicitly constructed classification. */
export { createMilitaryRegistry } from './classification.js';
export * from '../../data/aircraftClass.js';
export * from '../../data/aircraftIcons.js';
export * from '../../data/aircraftMeta.js';
export * from '../../data/aircraftNearbyPolicy.js';
export * from '../../data/cockpitAirLod.js';
export * from '../../data/cockpitContactDot.js';
export * from '../../data/contactMatch.js';
export * from '../../data/iconOrientation.js';
export * from '../../data/layerState.js';
export * from '../../data/modelVisualAnchor.js';
export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
export * from '../../data/routePlausible.js';
export * from '../../data/trackedModelRegime.js';
export * from '../../data/trackingClickGesture.js';
+126
View File
@@ -0,0 +1,126 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
export function createControls({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
const aisLiveVesselsLayer = services.vessels;
const methods = {
id: 'military-awareness',
name: 'Global Context',
icon: '◎',
source: 'Open-source proximity context',
// Context is entered from its dedicated right rail, not as a raw layer.
showInTogglePanel: false,
updateInterval: 0,
statsRefreshInterval: 1000,
attachDataManager(dataManager) {
layerState.dataManager = dataManager;
},
setParams(params = {}) {
if (typeof params.passive !== 'boolean') return;
const wasPassive = layerState.passive;
layerState.passive = params.passive;
if (layerState.enabled && wasPassive && !layerState.passive)
parts.dependencies.activateOperationalContext();
},
/** @returns {{ passive: boolean }} Current runtime parameters. */
getParams() {
return { passive: layerState.passive };
},
getStats() {
return {
count: layerState.results ? 1 : 0,
lastUpdate: layerState.results?.evaluatedAt || null,
stale: false,
error: null,
status: layerState.enabled ? 'ready' : 'idle',
};
},
/** Return the latest read-only context result for compact HUD consumers. */
getContextSnapshot() {
if (!layerState.enabled || !layerState.subject) return null;
if (!layerState.results) {
return parts.model.buildAwarenessContextSnapshot(
{
subject: { ...layerState.subject },
evaluatedAt: null,
radiusM: AWARENESS_RADIUS_M,
cohorts: [],
},
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
}
return parts.model.buildAwarenessContextSnapshot(
layerState.results,
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
},
/**
* Release Contact-owned camera tracking without discarding the selected
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
* can explicitly return to the same contact, while delayed activation work
* cannot silently reclaim the camera after the reset.
* @returns {boolean} Whether a Contact subject remains selected.
*/
releaseCameraOwnership({
preserveVesselSelection = false,
origin = 'programmatic',
} = {}) {
++layerState.activationId;
layerState.autoFocusAttempted = true;
layerState.autoFocusRetryPending = false;
const preservedSelectionKey =
parts.subject.subjectKey(layerState.subject) || 'camera-release';
layerState.pendingSelectionKey = preservedSelectionKey;
try {
flightsLayer.stopTracking?.({ origin });
militaryFlightsLayer.stopTracking?.({ origin });
if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.();
} finally {
if (layerState.pendingSelectionKey === preservedSelectionKey) {
layerState.pendingSelectionKey = null;
}
}
return Boolean(layerState.subject);
},
navigatePrevious(options = {}) {
return parts.history.navigateHistory(-1, options);
},
focusCurrent(options = {}) {
return parts.focus.focusCurrentSubject(options);
},
navigateNext(options = {}) {
return parts.history.navigateHistory(1, options);
},
/** Select a context target through its owning layer's established tracker. */
focusTarget(layerId, id, options = {}) {
return parts.focus.requestFocus(layerId, id, false, options);
},
};
return { methods };
}
+118
View File
@@ -0,0 +1,118 @@
import { DEPENDENCIES, AIRCRAFT_DEPENDENCIES } from './policy.js';
export function createDependencies({
state: layerState,
services,
parts,
source,
}) {
function refreshAfterDeferredDependency(activationId) {
if (
!layerState.enabled ||
layerState.passive ||
activationId !== layerState.activationId
)
return;
if (layerState.subject) {
parts.subject.refreshSelectedSubject(true);
return;
}
if (layerState.autoFocusRetryPending) {
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
}
}
async function enableDependencies(activationId = layerState.activationId) {
const aircraftPending = [];
for (const layerId of DEPENDENCIES) {
if (!layerState.enabled) return;
if (!layerState.dataManager) continue;
const alreadyEnabled =
layerState.dataManager.isEffectivelyEnabled?.(layerId) ??
layerState.dataManager.isEnabled(layerId);
if (alreadyEnabled) continue;
layerState.ownedDependencies.add(layerId);
// Each layer serializes its own lifecycle internally. Start them together
// so a slow or unavailable live feed cannot prevent the remaining context
// sources from turning on.
let pending;
try {
// Register the ON intent synchronously. If Contacts is turned OFF in the
// same turn, DataLayerManager then sees OFF as the newest intent and a
// stale deferred ON cannot resurrect a released dependency.
pending = Promise.resolve(
layerState.dataManager.setEnabled(layerId, true),
);
} catch (error) {
pending = Promise.reject(error);
}
if (AIRCRAFT_DEPENDENCIES.includes(layerId)) {
aircraftPending.push(pending);
} else {
// Vessels and mapped installations enrich an already usable aircraft
// context. Their settlement must never hold the Contacts transaction or
// Cockpit gate open, and the activation token prevents a late result
// from repainting or refocusing a newer/closed session.
pending.then(
() => refreshAfterDeferredDependency(activationId),
() => refreshAfterDeferredDependency(activationId),
);
}
}
await Promise.allSettled(aircraftPending);
}
function releaseOwnedDependencies(releaseActivationId) {
const owned = [...layerState.ownedDependencies];
layerState.ownedDependencies.clear();
if (!layerState.dataManager || !owned.length) return Promise.resolve();
return Promise.allSettled(
owned.map((layerId) => layerState.dataManager.setEnabled(layerId, false)),
).then(() => {
if (
layerState.enabled &&
layerState.activationId !== releaseActivationId
) {
return enableDependencies(layerState.activationId);
}
return null;
});
}
function activateOperationalContext() {
const activationId = ++layerState.activationId;
layerState.autoFocusRetryPending = false;
const initialTrackedSubject = parts.subject.currentTrackedFlightSubject();
if (initialTrackedSubject)
parts.subject.selectSubject(initialTrackedSubject);
return enableDependencies(activationId)
.then(() => {
if (
!layerState.enabled ||
layerState.passive ||
activationId !== layerState.activationId
)
return;
const trackedSubject = parts.subject.currentTrackedFlightSubject();
if (trackedSubject) {
parts.subject.selectSubject(trackedSubject);
return;
}
if (layerState.subject) {
parts.subject.selectSubject(layerState.subject);
return;
}
if (layerState.autoFocusAttempted) return;
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
})
.catch((error) =>
console.warn('[Global Context] dependency enable failed', error),
);
}
return {
refreshAfterDeferredDependency,
enableDependencies,
releaseOwnedDependencies,
activateOperationalContext,
};
}
+198
View File
@@ -0,0 +1,198 @@
import * as Cesium from 'cesium';
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
export function createFocus({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
const { announceNavigationAuthority } = services.navigation;
const militaryInstallationsLayer = services.installations;
const aisLiveVesselsLayer = services.vessels;
/** Focus a nearby example through the source layer that owns its selection. */
function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) {
if (!layerId || !id) return false;
if (layerId === 'flights') {
return (
flightsLayer.refocusTrackedById?.(id, { origin }) ||
flightsLayer.trackById(id, { origin })
);
}
if (layerId === 'military') {
return (
militaryFlightsLayer.refocusTrackedById?.(id, { origin }) ||
militaryFlightsLayer.trackById(id, { origin })
);
}
if (layerId === 'military-installations') {
if (!parts.model.contextTargetFlyToAllowed(layerId))
return selectKnownContextTarget(layerId, id);
// focusById flies the camera; it never assigns a tracked entity, so the
// stamp has to be announced here.
announceNavigationAuthority('context-installation-focus');
return militaryInstallationsLayer.focusById(id);
}
if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id))
return false;
const vessel = aisLiveVesselsLayer
.getAllPositions(12000)
.find((item) => String(item.id) === String(id));
if (
!parts.model.contextTargetFlyToAllowed(layerId) ||
!vessel?.position ||
!layerState.viewer
)
return true;
announceNavigationAuthority('context-vessel-focus');
layerState.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
{ duration: 1.4 },
);
return true;
}
function selectKnownContextTarget(layerId, id) {
const key = `${layerId}:${id}`;
const known = [
layerState.subject,
...layerState.navigationHistory,
...(layerState.results?.cohorts || []).flatMap((cohort) =>
cohort.id === layerId
? cohort.summary.navigationNearest || cohort.summary.nearest || []
: [],
),
].find(
(item) =>
item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key,
);
if (!known?.position) return false;
if (parts.model.contextTargetFlyToAllowed(layerId))
parts.model.releaseAircraftTracking();
parts.subject.selectSubject({
...known,
layerId,
id: String(known.id || known.mmsi),
label: known.label || known.name || known.callsign || String(id),
position: Cesium.Cartesian3.clone(known.position),
});
return true;
}
function requestFocus(
layerId,
id,
preserveHistory = false,
{ origin = 'programmatic' } = {},
) {
const key = `${layerId}:${id}`;
layerState.pendingSelectionKey = key;
if (preserveHistory) layerState.suppressedHistoryKey = key;
const focused = focusNearbyTarget(layerId, id, { origin });
layerState.pendingSelectionKey = null;
layerState.suppressedHistoryKey = null;
return focused;
}
function focusSubject(subject, preserveHistory = false, options = {}) {
if (!subject) return false;
return requestFocus(subject.layerId, subject.id, preserveHistory, options);
}
function focusCurrentSubject(options = {}) {
return focusSubject(layerState.subject, true, options);
}
/**
* Pick the observed candidate closest to the current view. This is intentionally
* a navigation preference, not a risk, capability, or affiliation calculation.
* @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates.
* @returns {Object|null} The best currently observable candidate.
*/
function closestToCurrentView(candidates) {
if (!Array.isArray(candidates) || !candidates.length) return null;
const cameraPosition = layerState.viewer?.camera?.positionWC;
if (!cameraPosition)
return candidates.find((candidate) => candidate?.position) || null;
let closest = null;
let closestDistance = Infinity;
for (const candidate of candidates) {
if (!candidate?.position) continue;
const distance = Cesium.Cartesian3.distance(
cameraPosition,
candidate.position,
);
if (Number.isFinite(distance) && distance < closestDistance) {
closest = candidate;
closestDistance = distance;
}
}
return closest;
}
/**
* Focus a single observed, context-priority target after activation. Civilian
* and military aircraft compete in one nearest-to-view pool; military is
* concatenated first so exact distance ties resolve to military under the
* strict comparison. An AIS vessel is only a fallback and is never inferred
* to be military. There is deliberately no distance cap.
* @returns {boolean} Whether a target was selected and framed.
*/
function focusAttentionTarget() {
if (
!layerState.enabled ||
layerState.subject ||
!layerState.viewer ||
layerState.autoFocusAttempted
)
return false;
const nearestFlight = closestToCurrentView([
...militaryFlightsLayer
.getAllPositions(800)
.map((item) => ({ ...item, layerId: 'military' })),
...flightsLayer
.getAllPositions(1000)
.map((item) => ({ ...item, layerId: 'flights' })),
]);
if (nearestFlight) {
const layer =
nearestFlight.layerId === 'military'
? militaryFlightsLayer
: flightsLayer;
if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) {
layerState.autoFocusAttempted = true;
return true;
}
}
const vessel = closestToCurrentView(
aisLiveVesselsLayer.getAllPositions(12000),
);
if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false;
layerState.autoFocusAttempted = true;
if (!parts.model.contextTargetFlyToAllowed('ais-live-vessels')) return true;
announceNavigationAuthority('context-vessel-autofocus', {
cancelPendingSelection: false,
});
layerState.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
{ duration: 1.6 },
);
return true;
}
return {
focusNearbyTarget,
selectKnownContextTarget,
requestFocus,
focusSubject,
focusCurrentSubject,
closestToCurrentView,
focusAttentionTarget,
};
}
+239
View File
@@ -0,0 +1,239 @@
export function createHistory({ state: layerState, services, parts, source }) {
const militaryFlightsLayer = services.military;
const flightsLayer = services.flights;
/**
* Walk navigation history for the next entry that can still be focused.
*
* A history entry only holds a SNAPSHOT of a contact; the live contact behind
* it can be evicted from its layer at any time (the cull that drives
* CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the
* only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead
* entry strands the operator there for the rest of the session, because the
* index never advances past it.
*
* `state.navigationIndex` moves only on a successful focus: a caller that
* falls through to the live cohort must still splice forward history from the
* position the operator actually occupies.
* @param {number} direction Walk direction (negative = previous).
* @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters.
* @returns {boolean} Whether a history entry was focused.
*/
function focusCompatibleHistory(
direction,
{ targetLayer, aircraftClass, aircraftOnly, origin = 'programmatic' },
) {
let cursor = layerState.navigationIndex;
for (;;) {
const historyIndex = findCompatibleHistoryIndex(
layerState.navigationHistory,
cursor,
direction,
{
targetLayer,
aircraftClass,
aircraftOnly,
resolveItem: historySubjectItem,
},
);
if (historyIndex === -1) return false;
if (
parts.focus.focusSubject(
layerState.navigationHistory[historyIndex],
true,
{ origin },
)
) {
layerState.navigationIndex = historyIndex;
return true;
}
// Contact evicted since it entered history — step over it. The scan is
// strictly monotonic, so this terminates at the end of history.
cursor = historyIndex;
}
}
function navigateHistory(
direction,
{
targetLayer = null,
aircraftClass = null,
aircraftOnly = false,
origin = 'programmatic',
} = {},
) {
if (
focusCompatibleHistory(direction, {
targetLayer,
aircraftClass,
aircraftOnly,
origin,
})
)
return true;
if (direction < 0) return false;
let targets = parts.navigation.selectNavigationTargets(
layerState.results?.cohorts,
layerState.subject,
[...layerState.navigationVisited],
{ targetLayer, aircraftClass, aircraftOnly },
);
const targetHistoryFullyVisited =
targets.length > 0 && targets.every((target) => target.visited);
const subjectCohortUnknown = parts.subject.subjectCohortFeedUnknown();
if (
targetHistoryFullyVisited &&
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
!subjectCohortUnknown
) {
const expanded = parts.navigation.findExpandedFlightTarget({
targetLayer,
aircraftClass,
});
if (expanded) {
return parts.focus.requestFocus(
expanded.candidate.layerId,
expanded.candidate.id,
false,
{ origin },
);
}
}
if (targetHistoryFullyVisited) {
parts.navigation.resetNavigationVisitedCycle();
targets = parts.navigation.selectNavigationTargets(
layerState.results?.cohorts,
layerState.subject,
[...layerState.navigationVisited],
{ targetLayer, aircraftClass, aircraftOnly },
);
}
const hasNearbyFlight = targets.some((target) =>
parts.navigation.isFlightLayer(target.layerId),
);
if (
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
!hasNearbyFlight &&
!subjectCohortUnknown
) {
const expanded = parts.navigation.findExpandedFlightTarget({
targetLayer,
aircraftClass,
});
if (expanded) {
return parts.focus.requestFocus(
expanded.candidate.layerId,
expanded.candidate.id,
false,
{ origin },
);
}
}
if (parts.subject.subjectCohortFeedUnknown()) return false;
const candidate = targets[0];
if (!candidate) return false;
return parts.focus.requestFocus(candidate.layerId, candidate.id, false, {
origin,
});
}
function historySubjectItem(subject) {
if (!subject) return null;
const cohort = layerState.results?.cohorts?.find(
(item) => item.id === subject.layerId,
);
const items =
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
return (
items.find(
(item) =>
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
) || subject
);
}
/** Retain filter metadata when a production subject enters navigation history. */
function historySubjectSnapshot(subject, sourceItem = null) {
const aircraftClass =
sourceItem?.aircraftClass ||
sourceItem?.klass ||
sourceItem?.type ||
subject?.aircraftClass ||
null;
return aircraftClass ? { ...subject, aircraftClass } : { ...subject };
}
function historySourceItem(subject) {
if (!parts.navigation.isFlightLayer(subject?.layerId) || !subject?.position)
return null;
// The current sweep already holds this contact's record. Reusing it keeps
// selection O(1) against the cohort instead of paying a fresh full-layer
// proximity scan per selection — the burst cost when NEXT walks a cohort.
const cohort = layerState.results?.cohorts?.find(
(item) => item.id === subject.layerId,
);
const items =
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
const fromSweep = items.find(
(item) => String(item?.icao24 || item?.id) === String(subject.id),
);
if (fromSweep) return fromSweep;
const layer =
subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer;
return (
layer
.getNearby(subject.position, 1000, 25, { includeHidden: true })
.find(
(item) => String(item?.icao24 || item?.id) === String(subject.id),
) || null
);
}
/** Find the next history entry compatible with requested navigation filters. */
function findCompatibleHistoryIndex(
history,
startIndex,
direction,
{
targetLayer = null,
aircraftClass = null,
aircraftOnly = false,
resolveItem = (subject) => subject,
} = {},
) {
const step = direction < 0 ? -1 : 1;
for (
let index = startIndex + step;
index >= 0 && index < history.length;
index += step
) {
const subject = history[index];
if (aircraftOnly && !parts.navigation.isFlightLayer(subject?.layerId))
continue;
if (targetLayer && subject?.layerId !== targetLayer) continue;
if (
aircraftClass &&
!parts.navigation.aircraftClassMatchesFilter(
resolveItem(subject),
aircraftClass,
)
)
continue;
return index;
}
return -1;
}
return {
focusCompatibleHistory,
navigateHistory,
historySubjectItem,
historySubjectSnapshot,
historySourceItem,
findCompatibleHistoryIndex,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { createModel } from './model.js';
import { createSubject } from './subject.js';
import { createPanel } from './panel.js';
import { createQueries } from './queries.js';
import { createNavigation } from './navigation.js';
import { createFocus } from './focus.js';
import { createTesting } from './testing.js';
import { createHistory } from './history.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { createDependencies } from './dependencies.js';
import { createControls } from './controls.js';
import { createIngestion } from './ingestion.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createAwarenessLayer({ services, source }) {
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.model = createModel(context);
parts.subject = createSubject(context);
parts.panel = createPanel(context);
parts.queries = createQueries(context);
parts.navigation = createNavigation(context);
parts.focus = createFocus(context);
parts.testing = createTesting(context);
parts.history = createHistory(context);
parts.rendering = createRendering(context);
parts.lifecycle = createLifecycle(context);
parts.dependencies = createDependencies(context);
parts.controls = createControls(context);
parts.ingestion = createIngestion(context);
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion?.methods,
{
contextTargetFlyToAllowed: parts.model.contextTargetFlyToAllowed,
awarenessClearMatchesSubject: parts.subject.awarenessClearMatchesSubject,
awarenessRefreshIntervalMs: parts.model.awarenessRefreshIntervalMs,
awarenessRefreshDecision: parts.model.awarenessRefreshDecision,
awarenessClearIsEviction: parts.model.awarenessClearIsEviction,
awarenessRefreshRequired: parts.model.awarenessRefreshRequired,
summarizeInstallationViewport:
parts.queries.summarizeInstallationViewport,
contactsWindowFromSnapshot: parts.model.contactsWindowFromSnapshot,
buildAwarenessContextSnapshot: parts.model.buildAwarenessContextSnapshot,
collectAircraftProximityWindow:
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
},
);
}
export { AWARENESS_QUERY_LIMIT } from './policy.js';
+15
View File
@@ -0,0 +1,15 @@
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
const methods = {
update() {
parts.subject.refreshSelectedSubject();
return Promise.resolve();
},
};
return { methods };
}
+263
View File
@@ -0,0 +1,263 @@
import { cameraPoseSignature } from '../../data/iconOrientation.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
/**
* Quantized camera-pose signature, or '' when the camera cannot report a full
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
* up can never be mistaken for continuous movement.
* @param {Cesium.Camera|null|undefined} camera
* @returns {string}
*/
function cameraMotionSignature(camera) {
if (!camera?.positionWC || !Number.isFinite(camera.heading)) return '';
return cameraPoseSignature(camera);
}
/**
* Take or drop the continuous-render hold to match the current need. Idempotent
* (the governor is identity-keyed), and self-healing: the release decision is
* made on a frame the hold itself guaranteed, so a hold can never strand the
* scene in continuous mode.
* @returns {void}
*/
function syncAwarenessRenderHold() {
if (layerState.enabled && parts.model.awarenessNeedsContinuousRender())
holdContinuousRender('military-awareness');
else releaseContinuousRender('military-awareness');
}
function attachRuntimeListeners() {
if (layerState.runtimeListenersAttached || !layerState.viewer) return;
window.addEventListener(
'gev:awareness-subject-selected',
layerState.subjectListener,
);
window.addEventListener('gev:entity-selected', layerState.contextListener);
window.addEventListener(
'gev:entity-selection-cleared',
layerState.clearListener,
);
window.addEventListener(
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
layerState.preRenderRemover =
layerState.viewer.scene.preRender.addEventListener(() => {
if (!layerState.enabled) return;
const now = Date.now();
// "Did the camera move" uses the SAME quantized pose signature the fleet
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
// camera.changed, whose granularity is globally degraded by other layers
// mutating camera.percentageChanged.
const poseSig = cameraMotionSignature(layerState.viewer.camera);
if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now;
}
const decision = parts.model.awarenessRefreshDecision({
nowMs: now,
lastRefreshMs: layerState.lastSubjectRefreshMs,
lastPoseChangeMs: layerState.lastCameraPoseChangeMs,
wasMoving: layerState.cameraMoving,
});
layerState.cameraMoving = decision.moving;
syncAwarenessRenderHold();
if (!decision.refresh) return;
layerState.lastSubjectRefreshMs = now;
if (
layerState.subject &&
['flights', 'military', 'ais-live-vessels'].includes(
layerState.subject.layerId,
)
) {
parts.subject.refreshSelectedSubject();
} else if (!layerState.passive && layerState.autoFocusRetryPending) {
layerState.autoFocusRetryPending = false;
parts.focus.focusAttentionTarget();
} else {
parts.rendering.scheduleDirectionOverlayUpdate();
}
});
layerState.runtimeListenersAttached = true;
}
function detachRuntimeListeners() {
if (layerState.runtimeListenersAttached) {
window.removeEventListener(
'gev:awareness-subject-selected',
layerState.subjectListener,
);
window.removeEventListener(
'gev:entity-selected',
layerState.contextListener,
);
window.removeEventListener(
'gev:entity-selection-cleared',
layerState.clearListener,
);
window.removeEventListener(
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
}
layerState.preRenderRemover?.();
layerState.preRenderRemover = null;
layerState.lastCameraPoseSig = '';
layerState.lastCameraPoseChangeMs = 0;
layerState.cameraMoving = false;
// No frames will arrive to run the per-frame release once the listener is
// gone, so drop the hold here.
releaseContinuousRender('military-awareness');
parts.rendering.cancelDirectionOverlayUpdate();
layerState.runtimeListenersAttached = false;
}
const methods = {
init(viewer) {
detachRuntimeListeners();
layerState.viewer = viewer;
layerState.subjectListener = (event) => {
if (!layerState.enabled) return;
parts.subject.selectSubject(event.detail);
};
layerState.contextListener = (event) => {
if (!layerState.enabled) return;
const subject = parts.subject.subjectFromContext(event.detail);
if (!subject) return;
if (parts.model.contextTargetFlyToAllowed(subject.layerId))
parts.model.releaseAircraftTracking();
parts.subject.selectSubject(subject);
};
layerState.clearListener = (event) => {
if (!layerState.enabled || layerState.pendingSelectionKey) return;
if (
!parts.subject.awarenessClearMatchesSubject(
layerState.subject,
event.detail,
)
)
return;
// An eviction keeps the subject so the readout can hold last-known
// values; only a deliberate clear tears the selection down.
if (parts.model.awarenessClearIsEviction(event.detail)) {
parts.subject.markSubjectEvicted();
return;
}
parts.subject.clearAwarenessSubject();
};
layerState.subjectClearListener = (event) => {
if (!layerState.enabled) return;
const cleared = event.detail;
if (layerState.pendingSelectionKey) return;
if (
parts.subject.awarenessClearMatchesSubject(
layerState.subject,
cleared,
) &&
String(layerState.subject?.id) === String(cleared?.id)
) {
if (parts.model.awarenessClearIsEviction(cleared)) {
// Deliberately does NOT set autoFocusAttempted: the subject survives,
// so the entry fallback has nothing to replace and the settlement
// guard below stays scoped to real deselects.
parts.subject.markSubjectEvicted();
return;
}
// A deliberate clear during dependency settlement must win over the
// entry fallback; otherwise Contacts can silently select a replacement.
layerState.autoFocusAttempted = true;
parts.subject.clearAwarenessSubject();
}
};
},
enable() {
layerState.enabled = true;
// NO unconditional continuous-render hold here. Contacts animates per frame
// only while the VIEW is moving (the direction arrows are screen-projected);
// parked, it is a throttled readout with nothing to animate. The hold is
// taken and released per frame by syncAwarenessRenderHold().
attachRuntimeListeners();
parts.panel.startAwarenessPageRotation();
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
parts.panel.ensurePanel();
parts.panel.hidePanel();
if (!layerState.passive)
return parts.dependencies.activateOperationalContext();
return true;
},
disable() {
layerState.enabled = false;
releaseContinuousRender('military-awareness');
detachRuntimeListeners();
parts.panel.stopAwarenessPageRotation();
layerState.cohortPages.clear();
const releaseActivationId = ++layerState.activationId;
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.results = null;
layerState.lastSubjectRefreshMs = 0;
layerState.lastEvaluatedPosition = null;
layerState.sourceRevision = '';
layerState.navigationHistory = [];
layerState.navigationVisited.clear();
layerState.navigationIndex = -1;
layerState.suppressedHistoryKey = null;
layerState.pendingSelectionKey = null;
parts.rendering.clearVisual();
parts.panel.hidePanel();
// The manager must not publish this coordinator as settled OFF while its
// owned dependency releases are still issuing newer absolute intents.
// Context mode handoffs restore the pre-entry snapshot only after this
// promise resolves, preventing those releases from superseding restore.
layerState.passive = true;
return parts.dependencies.releaseOwnedDependencies(releaseActivationId);
},
destroy() {
this.disable();
detachRuntimeListeners();
layerState.panel?.removeEventListener(
'click',
layerState.panelClickListener,
);
layerState.panelClickListener = null;
if (layerState.panelOwned) layerState.panel?.remove();
else if (layerState.panel) layerState.panel.replaceChildren();
layerState.panel = null;
layerState.panelOwned = false;
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
layerState.subjectClearListener = null;
layerState.viewer = null;
layerState.dataManager = null;
},
};
return {
cameraMotionSignature,
syncAwarenessRenderHold,
attachRuntimeListeners,
detachRuntimeListeners,
methods,
};
}
+251
View File
@@ -0,0 +1,251 @@
import {
AWARENESS_MOTION_REFRESH_MS,
AWARENESS_REFRESH_MS,
AWARENESS_MOTION_SETTLE_MS,
AWARENESS_REEVALUATE_DISTANCE_M,
} from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
/**
* Whether focusing a Context target may take ownership of the camera.
* Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable
* there but must not start a competing flyTo animation.
*
* @param {string} layerId Context target layer id.
* @param {HTMLElement|null} [body=document.body] Document body to inspect.
* @returns {boolean} Whether the target may start a camera flight.
*/
function contextTargetFlyToAllowed(
layerId,
body = globalThis.document?.body,
) {
const nonAircraft =
layerId === 'ais-live-vessels' || layerId === 'military-installations';
return !nonAircraft || !body?.classList?.contains('cockpit-mode');
}
/**
* Refresh cadence for the Contacts direction arrows and card readouts.
*
* A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms
* one. This is safe for requestRenderMode because the cadence is applied inside
* `scene.preRender`: a faster interval can only ever consume frames the camera
* movement is ALREADY forcing. A parked scene renders no frames at all, so no
* extra refresh happens and no timer is introduced — the render governor and
* the idle-leak fix both depend on parked staying quiet.
* @param {boolean} cameraMoving Whether the camera pose changed since the last frame.
* @returns {number} Minimum ms between refreshes.
*/
function awarenessRefreshIntervalMs(cameraMoving) {
return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS;
}
/**
* Decide, for one rendered frame, whether the view counts as MOVING and whether
* the Contacts readout may refresh.
*
* Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a
* slow drag crosses a bin only every few frames: treating a single unchanged
* frame as "parked" made every crossing look like a motion-end and refreshed at
* nearly display rate. Motion therefore ends only after the pose has been
* unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD
* floor in every state — no bin-crossing pattern, and no motion-end settle, can
* refresh faster than AWARENESS_MOTION_REFRESH_MS.
*
* @param {object} input
* @param {number} input.nowMs This frame's clock.
* @param {number} input.lastRefreshMs When the readout last refreshed.
* @param {number} input.lastPoseChangeMs When the camera pose last changed bins.
* @param {boolean} input.wasMoving Whether the previous frame counted as moving.
* @returns {{moving: boolean, refresh: boolean}}
*/
function awarenessRefreshDecision({
nowMs,
lastRefreshMs,
lastPoseChangeMs,
wasMoving,
}) {
const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS;
// The single frame where hysteresis expires: settle the readout at rest
// instead of leaving it up to a parked interval stale.
const settling = Boolean(wasMoving) && !moving;
const sinceRefresh = nowMs - lastRefreshMs;
const refresh =
sinceRefresh >= awarenessRefreshIntervalMs(true) &&
(moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false));
return { moving, refresh };
}
/**
* Decide whether a source-scoped clear was an eviction rather than a deselect.
*
* Both arrive on the same events: the owning layer calls the same teardown for
* "the user clicked away" and "this contact aged out of the feed". Only the
* origin separates them, and it matters — a deliberate clear takes the Contact
* panel down, while an eviction must keep it up in its CONTACT LOST state, or
* the panel disappears out from under its own PREVIOUS/NEXT controls.
* @param {{reason?: string}|null} cleared Cleared source detail.
* @returns {boolean} Whether the subject left the feed on its own.
*/
function awarenessClearIsEviction(cleared) {
return cleared?.reason === 'evicted';
}
/**
* Decide whether proximity cohorts need an expensive rescan.
* @param {object} input Refresh evidence.
* @param {boolean} input.force Explicit invalidation.
* @param {boolean} input.hasResults Whether a prior evaluation exists.
* @param {number} input.movementM Subject displacement since evaluation.
* @param {boolean} input.sourceRevisionChanged Whether any source state changed.
* @returns {boolean} Whether cohorts should be evaluated again.
*/
function awarenessRefreshRequired({
force,
hasResults,
movementM,
sourceRevisionChanged,
}) {
return Boolean(
force ||
!hasResults ||
sourceRevisionChanged ||
!Number.isFinite(movementM) ||
movementM >= AWARENESS_REEVALUATE_DISTANCE_M,
);
}
/**
* The per-layer counts the Contacts panel is showing, as one flat block.
*
* Three honest numbers were reaching the operator at once: this cohort count
* (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the
* layer-wide loaded total in the coverage note. After the camera dives to a
* tracked contact the flights layer reloads by viewport, so the loaded set can
* hold a fraction of the cohort — 8 against the panel's 42 in the field. The
* numbers are all correct and the disagreement still reads as chaos.
*
* Derived from the same snapshot the panel renders (`cohort.summary.count` via
* `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort
* whose feed cannot answer reports 'unknown' rather than a misleading zero.
* @param {object|null} snapshot `getContextSnapshot()` result.
* @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string,
* flights: number|string, military: number|string, vessels: number|string}|null}
* Panel-equivalent counts.
*/
function contactsWindowFromSnapshot(snapshot) {
if (!snapshot?.subject) return null;
const countFor = (cohortId) => {
const cohort = Array.isArray(snapshot.cohorts)
? snapshot.cohorts.find((item) => item?.id === cohortId)
: null;
return Number.isFinite(cohort?.count) ? cohort.count : 'unknown';
};
const flights = countFor('flights');
const military = countFor('military');
return {
centeredOn: snapshot.subject.label || snapshot.subject.id || null,
radiusKm: Number.isFinite(snapshot.radiusM)
? Math.round(snapshot.radiusM / 1000)
: null,
aircraft:
Number.isFinite(flights) && Number.isFinite(military)
? flights + military
: 'unknown',
flights,
military,
vessels: countFor('ais-live-vessels'),
};
}
/** Build the read-only Awareness snapshot shared with compact HUD consumers. */
function buildAwarenessContextSnapshot(
results,
navigation = {},
{ subjectPresent = true } = {},
) {
if (!results) return null;
return {
subject: { ...results.subject },
// Whether the subject is still reported by its source. The cockpit Contact
// readout holds its last-known values behind a CONTACT LOST cue when this
// is false, rather than presenting frozen geometry as a live reading.
subjectPresent: subjectPresent !== false,
evaluatedAt: results.evaluatedAt,
radiusM: results.radiusM,
cohorts: results.cohorts.map((cohort) => ({
id: cohort.id,
label: cohort.label,
source: cohort.source,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
reason: cohort.summary.reason,
nearest: cohort.summary.nearest.slice(),
})),
navigation: { ...navigation },
};
}
function navigationState() {
return {
canPrevious: layerState.navigationIndex > 0,
canFocus: Boolean(layerState.subject),
canNext: parts.navigation.canNavigateNext(),
};
}
/**
* Whether Contacts genuinely has per-frame work right now.
*
* The direction arrows are screen-projected against the camera basis, so they
* must be redrawn every frame while the view MOVES. Parked, or with no live
* cohort to point at — nothing selected, every feed empty, every feed failed —
* there is nothing to animate and Contacts must not be the reason the whole
* scene keeps repainting.
* @param {object} [snapshot] Explicit state, for tests.
* @returns {boolean}
*/
function awarenessNeedsContinuousRender({
cameraMoving = layerState.cameraMoving,
hasSubject = Boolean(layerState.subject),
hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results),
} = {}) {
return Boolean(cameraMoving && hasSubject && hasLiveResults);
}
/**
* Release any aircraft-owned follow camera before a non-aircraft context
* selection takes ownership. Both layers are safe no-ops when idle and release
* Cesium tracking in place, so the subsequent vessel/site framing starts from
* the current view without the previous aircraft continuing to drag it.
*/
function releaseAircraftTracking() {
flightsLayer.stopTracking?.();
militaryFlightsLayer.stopTracking?.();
}
return {
contextTargetFlyToAllowed,
awarenessRefreshIntervalMs,
awarenessRefreshDecision,
awarenessClearIsEviction,
awarenessRefreshRequired,
contactsWindowFromSnapshot,
buildAwarenessContextSnapshot,
navigationState,
awarenessNeedsContinuousRender,
releaseAircraftTracking,
};
}
+274
View File
@@ -0,0 +1,274 @@
import {
summarizeAwarenessCohort,
getAwarenessNavigationTargets,
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
findByDoublingRadius,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import {
AWARENESS_MAX_EXAMPLES,
AWARENESS_MAX_NAVIGATION_EXAMPLES,
} from './policy.js';
export function createNavigation({
state: layerState,
services,
parts,
source,
}) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
function summarizeAwarenessCohortForNavigation(
items,
source,
{
displayLimit = AWARENESS_MAX_EXAMPLES,
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
} = {},
) {
const summary = summarizeAwarenessCohort(items, {
...source,
limit: navigationLimit,
});
if (summary.count === null) return summary;
return {
...summary,
nearest: summary.nearest.slice(0, displayLimit),
navigationNearest: summary.nearest,
};
}
function resetNavigationVisitedCycle() {
layerState.navigationVisited.clear();
const currentKey = parts.subject.subjectKey(layerState.subject);
if (currentKey) layerState.navigationVisited.add(currentKey);
}
function isFlightLayer(layerId) {
return layerId === 'flights' || layerId === 'military';
}
function normalizeAircraftClass(value) {
return String(value || '')
.trim()
.toLowerCase();
}
function aircraftClassMatchesFilter(item, aircraftClass) {
const requested = normalizeAircraftClass(aircraftClass);
if (!requested) return true;
const candidates = [
item?.aircraftClass,
item?.klass,
item?.type,
item?.typeCode,
item?.typeName,
]
.map((value) => normalizeAircraftClass(value))
.filter(Boolean);
if (!candidates.length) return false;
return candidates.some(
(candidate) => candidate === requested || candidate.includes(requested),
);
}
function selectNavigationTargets(
sourceCohorts,
subject,
visitedKeys,
{ targetLayer = null, aircraftClass = null, aircraftOnly = false } = {},
) {
let targets = getAwarenessNavigationTargets(
sourceCohorts,
subject,
visitedKeys,
);
if (aircraftOnly) {
targets = targets.filter((target) => isFlightLayer(target.layerId));
}
if (targetLayer) {
targets = targets.filter((target) => target.layerId === targetLayer);
}
if (aircraftClass) {
targets = targets.filter((target) =>
aircraftClassMatchesFilter(target.item, aircraftClass),
);
}
return targets;
}
function alternativeFlightAvailable({
targetLayer = null,
aircraftClass = null,
} = {}) {
if (
!isFlightLayer(layerState.subject?.layerId) ||
!layerState.subject?.position
)
return false;
if (targetLayer && !isFlightLayer(targetLayer)) return false;
const flights =
targetLayer === 'military'
? []
: flightsLayer
.getNearby(
layerState.subject.position,
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
2,
{ includeHidden: true },
)
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
const military =
targetLayer === 'flights'
? []
: militaryFlightsLayer
.getNearby(
layerState.subject.position,
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
2,
{ includeHidden: true },
)
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
return [
['flights', flights],
['military', military],
].some(([layerId, items]) =>
items.some(
(item) =>
`${layerId}:${item.icao24 || item.id}` !==
parts.subject.subjectKey(layerState.subject),
),
);
}
function closestFlightWithinRadius(
radiusM,
visitedKeys,
excludeVisited,
{ targetLayer = null, aircraftClass = null } = {},
) {
if (!layerState.subject?.position) return null;
const visited = new Set(visitedKeys);
if (targetLayer && !isFlightLayer(targetLayer)) return null;
const candidates = [
...(targetLayer === 'military'
? []
: flightsLayer
.getNearby(layerState.subject.position, radiusM, 25000, {
includeHidden: true,
})
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
.map((item) => ({
layerId: 'flights',
id: String(item.icao24),
item,
}))),
...(targetLayer === 'flights'
? []
: militaryFlightsLayer
.getNearby(layerState.subject.position, radiusM, 5000, {
includeHidden: true,
})
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
.map((item) => ({
layerId: 'military',
id: String(item.icao24),
item,
}))),
].filter((target) => {
const key = `${target.layerId}:${target.id}`;
if (key === parts.subject.subjectKey(layerState.subject)) return false;
return !excludeVisited || !visited.has(key);
});
candidates.sort(
(a, b) =>
(a.item.distanceM ?? a.item.distance ?? Infinity) -
(b.item.distanceM ?? b.item.distance ?? Infinity),
);
return candidates[0] || null;
}
function findExpandedFlightTarget(options = {}) {
if (!isFlightLayer(layerState.subject?.layerId)) return null;
let visitedKeys = [...layerState.navigationVisited];
const search = () =>
findByDoublingRadius(
(radiusM) =>
closestFlightWithinRadius(radiusM, visitedKeys, true, options),
{
initialRadiusM: AWARENESS_RADIUS_M,
maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
},
);
const target = search();
if (target) return target;
resetNavigationVisitedCycle();
visitedKeys = [...layerState.navigationVisited];
return search();
}
/**
* Resolves NEXT availability from the same three branches used by navigation.
* Forward history remains usable while a feed is unavailable; discovering a
* new nearby or expanded target requires a known subject cohort.
* @param {Object} availability Navigation branch availability.
* @returns {boolean} Whether NEXT has a path that can run.
*/
function canNavigateAwarenessNext({
hasForwardHistory = false,
hasExpandedFlightTarget = false,
hasNearbyTarget = false,
subjectCohortUnknown = false,
} = {}) {
return Boolean(
hasForwardHistory ||
(!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)),
);
}
function canNavigateNext({ targetLayer = null, aircraftClass = null } = {}) {
return canNavigateAwarenessNext({
hasForwardHistory:
parts.history.findCompatibleHistoryIndex(
layerState.navigationHistory,
layerState.navigationIndex,
1,
{
targetLayer,
aircraftClass,
resolveItem: parts.history.historySubjectItem,
},
) !== -1,
hasNearbyTarget:
selectNavigationTargets(
layerState.results?.cohorts,
layerState.subject,
[...layerState.navigationVisited],
{ targetLayer, aircraftClass },
).length > 0,
hasExpandedFlightTarget: alternativeFlightAvailable({
targetLayer,
aircraftClass,
}),
subjectCohortUnknown: parts.subject.subjectCohortFeedUnknown(),
});
}
return {
summarizeAwarenessCohortForNavigation,
resetNavigationVisitedCycle,
isFlightLayer,
normalizeAircraftClass,
aircraftClassMatchesFilter,
selectNavigationTargets,
alternativeFlightAvailable,
closestFlightWithinRadius,
findExpandedFlightTarget,
canNavigateAwarenessNext,
canNavigateNext,
};
}
+249
View File
@@ -0,0 +1,249 @@
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
const existing = document.getElementById('military-awareness-panel');
const panel = existing || document.createElement('aside');
layerState.panelOwned = !existing;
if (!existing) {
panel.id = 'military-awareness-panel';
}
layerState.panelClickListener = (event) => {
const action = event.target.closest('button[data-awareness-action]');
if (action) {
event.preventDefault();
if (action.dataset.awarenessAction === 'previous')
parts.history.navigateHistory(-1, { origin: 'user' });
if (action.dataset.awarenessAction === 'next')
parts.history.navigateHistory(1, { origin: 'user' });
if (action.dataset.awarenessAction === 'focus')
parts.focus.focusCurrentSubject({ origin: 'user' });
return;
}
const target = event.target.closest(
'button[data-awareness-layer][data-awareness-id]',
);
if (!target) return;
event.preventDefault();
parts.focus.requestFocus(
target.dataset.awarenessLayer,
target.dataset.awarenessId,
false,
{ origin: 'user' },
);
};
panel.addEventListener('click', layerState.panelClickListener);
if (!existing) document.body.appendChild(panel);
layerState.panel = panel;
return panel;
}
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
layerState.panel.innerHTML = markup;
layerState.panelMarkup = markup;
}
}
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
}
function rowHtml(cohort) {
const summary = cohort.summary;
const count = summary.count === null ? '?' : String(summary.count);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
.map((item) => {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
})
.join('');
const pageCount = Math.max(
1,
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
);
const pageLabel =
pageCount > 1
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
: '';
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
<button type="button" data-awareness-action="focus">FOCUS</button>
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
</div>`;
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction)
return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
function captureAwarenessPanelFocus(
panel,
activeElement = document.activeElement,
) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector(
'[data-awareness-focus-continuation]',
);
const controls = [
...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
),
].filter((control) => !control.disabled);
const retained =
snapshot.key === 'continuation'
? continuation
: controls.find(
(control) => awarenessPanelControlKey(control) === snapshot.key,
);
const target = retained || continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>'"]/g,
(char) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char],
);
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
layerState.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
}
function rotateAwarenessPages() {
if (!layerState.enabled || !layerState.results) return;
let changed = false;
for (const cohort of layerState.results.cohorts) {
const pageCount = Math.max(
1,
Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE),
);
if (pageCount <= 1) continue;
const current = layerState.cohortPages.get(cohort.id) || 0;
layerState.cohortPages.set(
cohort.id,
((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) *
AWARENESS_PAGE_SIZE,
);
changed = true;
}
if (changed) {
renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(true);
}
}
function startAwarenessPageRotation() {
if (!layerState.pageTimer)
layerState.pageTimer = window.setInterval(
rotateAwarenessPages,
AWARENESS_PAGE_ROTATE_MS,
);
}
function stopAwarenessPageRotation() {
if (layerState.pageTimer) window.clearInterval(layerState.pageTimer);
layerState.pageTimer = null;
}
/**
* Whether an evaluation actually has a contact to point an arrow at.
*
* A cohort reports `count: null` when its feed is unavailable or stale, and `0`
* when the feed is healthy but empty. Only a positive count puts a marker on
* the compass rim, so anything else means there is nothing to animate.
* @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results
* @returns {boolean}
*/
function awarenessResultsAreLive(results) {
const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : [];
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
}
return {
ensurePanel,
hidePanel,
rowHtml,
navigationControlsHtml,
awarenessPanelControlKey,
captureAwarenessPanelFocus,
restoreAwarenessPanelFocus,
escapeHtml,
renderResults,
rotateAwarenessPages,
startAwarenessPageRotation,
stopAwarenessPageRotation,
awarenessResultsAreLive,
};
}
+68
View File
@@ -0,0 +1,68 @@
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
export const DEFERRED_DEPENDENCIES = [
'ais-live-vessels',
'military-installations',
];
export const DEPENDENCIES = [
...AIRCRAFT_DEPENDENCIES,
...DEFERRED_DEPENDENCIES,
];
export const AWARENESS_REFRESH_MS = 750;
/** @constant {number} Refresh cadence while the camera pose is CHANGING.
* Owner playtest 2026-08-18: the Contacts direction arrows and card readouts
* "feel sluggish when you look around" — at the parked 750 ms cadence the
* arrows lag the view by up to three quarters of a second. */
export const AWARENESS_MOTION_REFRESH_MS = 175;
/** @constant {number} How long the pose signature must stay UNCHANGED before
* the view counts as parked again. The signature is quantized, so a slow drag
* crosses a bin only every few frames; without this hysteresis every crossing
* read as a motion-end and refreshed at nearly display rate. */
export const AWARENESS_MOTION_SETTLE_MS = 250;
export const AWARENESS_REEVALUATE_DISTANCE_M = 250;
export const AWARENESS_PAGE_ROTATE_MS = 10000;
export const AWARENESS_PAGE_SIZE = 3;
export const AWARENESS_MAX_EXAMPLES = 10;
// Navigation walks the whole 250 km cohort, not the ten rows the panel shows,
// so these caps sit far above any realistic in-range population rather than at
// the display limit. They are still FINITE: an unbounded materialization would
// let a pathological feed sort an eleven-thousand-contact array on every
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
// derives `count` from the full in-range set before any slice.
export const AWARENESS_QUERY_LIMIT = 20000;
export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
export const CONTEXT_RIM_HEIGHT_M = 2500;
export const VESSEL_FOCUS_RADIUS_M = 3000;
export const SOURCE_LABEL = {
flights: 'OpenSky',
military: 'adsb.lol',
'ais-live-vessels': 'AISStream',
'military-installations': 'OpenStreetMap',
};
/**
* Whether a refresh tick could confirm the subject in its source collection.
* UNCHECKED means the tick did not look — it must never change the verdict.
*/
export const SUBJECT_PRESENCE = Object.freeze({
LIVE: 'live',
MISSING: 'missing',
UNCHECKED: 'unchecked',
});
+164
View File
@@ -0,0 +1,164 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
export function createQueries({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
function sourceState(layerId) {
const lifecycle =
layerState.dataManager?.getLayerLifecycleState?.(layerId) || null;
const enabled =
lifecycle?.enabled === true ||
layerState.dataManager?.isEnabled(layerId) === true;
const enabling = lifecycle?.lifecycleState === 'enabling';
const moduleStats =
layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {};
const stats = enabling
? { ...moduleStats, loading: true, status: 'loading' }
: moduleStats;
// A deferred source that has started but not settled is not evidence of an
// empty cohort. Keep it explicitly unavailable so the panel cannot flash a
// false all-clear, even if a lifecycle adapter briefly reports the requested
// visibility intent as enabled.
//
// `enabling` and the never-answered predicate cover two DIFFERENT windows, and
// both are needed because these dependencies do not settle the same way.
//
// - military-installations is covered by `enabling` alone. Its enable() is
// SYNCHRONOUS and the manager awaits update(), which owns the first
// Overpass fetch, so the lifecycle stays `enabling` for that whole window
// however long the fetch takes. Confirmed live: a held 17 s first fetch
// read `enabling` across 34 samples with the panel non-numeric throughout,
// and a failing one settled to `enabled` with status 'unavailable'. Its
// getStats() also reports loading while a request is in progress —
// setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
// status. That zero is an absence of evidence, not evidence of absence,
// and neither the lifecycle nor the status list can tell.
//
// Deliberately `loading === true` and not truthiness: layers that never report
// a busy flag (flights, military) must keep answering for themselves, and a
// source that HAS answered once (lastUpdate set) keeps its last real count
// through every later poll rather than blanking to `?` on each refresh.
const neverAnswered = stats.loading === true && !stats.lastUpdate;
const unavailable =
enabling ||
!enabled ||
neverAnswered ||
['unavailable', 'zoom-in'].includes(stats.status) ||
Boolean(stats.error && stats.count === 0);
return { available: !unavailable, stale: Boolean(stats.stale), stats };
}
function collectSourceStates() {
return Object.fromEntries(
DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]),
);
}
function sourceRevision(sourceStates) {
return DEPENDENCIES.map((layerId) => {
const source = sourceStates[layerId];
const stats = source?.stats || {};
return [
layerId,
source?.available,
source?.stale,
stats.lastUpdate || null,
stats.count ?? null,
stats.status || null,
stats.error || null,
];
})
.map((parts) => parts.join(':'))
.join('|');
}
function isSame(subject, item, prefix, key) {
return (
subject?.layerId === prefix && String(subject.id) === String(item?.[key])
);
}
/**
* Summarize viewport-backed installations without implying radius-complete coverage.
* @param {Array<object>} items Loaded installation matches.
* @param {object} source Current source availability and staleness.
* @returns {object} Awareness summary with viewport-honest reasoning.
*/
function summarizeInstallationViewport(items, source) {
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
items,
source,
);
if (summary.count === null)
return source.stats?.statusMessage
? { ...summary, reason: source.stats.statusMessage }
: summary;
return {
...summary,
reason: summary.count
? 'mapped matches from the loaded viewport'
: 'viewport feed is not a complete 250 km survey',
};
}
/**
* THE aircraft-proximity engine. One computation, two consumers: the Contacts
* panel window and the voice analyst's entity-centred "how many nearby".
*
* They used to be separate. The panel read live billboard positions through
* `getNearby` with a 20 000 cap; the analyst re-derived its own answer from
* last-fix coordinates over a 2 000-record slice. Same question, same centre,
* two numbers — and in the owner's trial the spoken answer (15) and the panel
* (111) disagreed badly enough that the model narrated the difference away.
* Routing both through here makes them the same number BY CONSTRUCTION, so
* they cannot drift again.
*
* The subject is excluded from its own window, which is why the panel reads
* "contacts around X" rather than "including X".
* @param {Cesium.Cartesian3} position Window centre.
* @param {object} [options]
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
* plus the combined aircraft count, or null without a position.
*/
function collectAircraftProximityWindow(
position,
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
) {
if (!position) return null;
const flights = flightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
);
const military = militaryFlightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
);
return { flights, military, aircraft: flights.length + military.length };
}
return {
sourceState,
collectSourceStates,
sourceRevision,
isSame,
summarizeInstallationViewport,
collectAircraftProximityWindow,
};
}
+288
View File
@@ -0,0 +1,288 @@
import * as Cesium from 'cesium';
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
const { governorRequestRender } = services.render;
function ensureDirectionOverlay() {
if (layerState.directionRoot || !layerState.viewer)
return layerState.directionRoot;
const root = document.createElement('div');
root.id = 'military-awareness-direction-overlay';
root.hidden = true;
root.setAttribute('aria-hidden', 'true');
const compass = document.createElement('div');
compass.className = 'military-awareness-compass-ring';
const cardinals = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
layerState.compassLabels = cardinals.map(([label, bearing]) => {
const element = document.createElement('span');
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
element.textContent = label;
element.dataset.bearing = String(bearing);
root.appendChild(element);
return element;
});
const heading = document.createElement('span');
heading.className = 'military-awareness-compass-heading';
root.append(compass, heading);
layerState.directionMarkers = Array.from({ length: 3 }, () => {
const marker = document.createElement('div');
marker.className = 'military-awareness-direction-marker';
const arrow = document.createElement('span');
arrow.className = 'military-awareness-direction-arrow';
arrow.textContent = '➜';
const label = document.createElement('span');
label.className = 'military-awareness-direction-label';
marker.append(arrow, label);
marker._arrow = arrow;
marker._label = label;
marker._lastAngle = 0;
root.appendChild(marker);
return marker;
});
layerState.compassRing = compass;
layerState.compassHeading = heading;
layerState.viewer.container.appendChild(root);
layerState.directionRoot = root;
return root;
}
function scheduleDirectionOverlayUpdate(force = false) {
if (layerState.directionFrame !== null || !layerState.enabled) return;
const now = Date.now();
if (
!force &&
now - layerState.lastDirectionUpdateMs <
parts.model.awarenessRefreshIntervalMs(layerState.cameraMoving)
)
return;
layerState.directionFrame = window.requestAnimationFrame(() => {
layerState.directionFrame = null;
layerState.lastDirectionUpdateMs = Date.now();
updateDirectionOverlay();
});
}
function cancelDirectionOverlayUpdate() {
if (layerState.directionFrame !== null)
window.cancelAnimationFrame(layerState.directionFrame);
layerState.directionFrame = null;
layerState.lastDirectionUpdateMs = 0;
}
function updateDirectionOverlay() {
const root = ensureDirectionOverlay();
if (
!root ||
!layerState.enabled ||
!layerState.subject?.position ||
!layerState.results
) {
if (root) root.hidden = true;
return;
}
const canvas = layerState.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
return;
}
root.hidden = false;
// Keep the compass comfortably inside the shader keyhole so its cardinals
// remain visible around the subject instead of hiding under edge panels.
const compassRadius = Math.max(
120,
Math.min(geometry.radius - 48, geometry.radius * 0.66),
);
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
layerState.compassRing.style.width = `${compassRadius * 2}px`;
layerState.compassRing.style.height = `${compassRadius * 2}px`;
const cameraHeading = layerState.viewer.camera.heading || 0;
const headingDeg =
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
layerState.compassRing.style.setProperty(
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
const labelRadius = compassRadius - 17;
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
}
const directionalCohort =
layerState.results.cohorts.find(
(cohort) => cohort.id === layerState.subject.layerId,
) ||
layerState.results.cohorts.find((cohort) => cohort.id === 'military');
const page = layerState.cohortPages.get(directionalCohort?.id) || 0;
const military =
directionalCohort?.summary.nearest.slice(
page,
page + AWARENESS_PAGE_SIZE,
) || [];
for (let index = 0; index < layerState.directionMarkers.length; index++) {
const marker = layerState.directionMarkers[index];
const item = military[index];
if (!item?.position) {
marker.hidden = true;
continue;
}
const direction = Cesium.Cartesian3.subtract(
item.position,
layerState.subject.position,
layerState.DIRECTION_SCRATCH[index],
);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) {
marker.hidden = true;
continue;
}
Cesium.Cartesian3.normalize(direction, direction);
const projection = celestialScreenAngle(
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
marker._lastAngle,
);
if (projection.stable) marker._lastAngle = projection.angle;
// Every detected contact shares the compass rim. Keeping the bearing
// markers on one radius makes them read as compass contacts rather than
// unconstrained labels floating over the map.
const markerRadius = compassRadius;
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian(
layerState.subject.position,
undefined,
layerState.SUBJECT_CARTOGRAPHIC_SCRATCH,
);
const targetCartographic = Cesium.Cartographic.fromCartesian(
item.position,
undefined,
layerState.TARGET_CARTOGRAPHIC_SCRATCH[index],
);
const bearing =
subjectCartographic && targetCartographic
? bearingBetweenCoordinates(
Cesium.Math.toDegrees(subjectCartographic.latitude),
Cesium.Math.toDegrees(subjectCartographic.longitude),
Cesium.Math.toDegrees(targetCartographic.latitude),
Cesium.Math.toDegrees(targetCartographic.longitude),
)
: null;
const bearingText = Number.isFinite(bearing)
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
: 'BRG —';
const courseText = Number.isFinite(item.track)
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
}
}
function clearVisual() {
if (layerState.visual?.entities && layerState.viewer) {
for (const entity of layerState.visual.entities)
layerState.viewer.entities.remove(entity);
// Idle mode renders only on request; a removed ring must not linger.
governorRequestRender('awareness-visual');
}
layerState.visual = null;
}
function renderVisual(subject) {
if (!layerState.viewer || !subject?.position) return;
const cartographic = Cesium.Cartographic.fromCartesian(subject.position);
if (!cartographic) return;
const groundCenter = Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
0,
);
const key = `${subject.layerId}:${subject.id}`;
if (layerState.visual?.key === key) {
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
// Move it only after a meaningful displacement so live tracking does not
// churn ten large geometries for sub-pixel motion every 750 ms.
if (
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
250
) {
for (const entity of layerState.visual.entities)
entity.position.setValue(groundCenter);
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
// Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual');
}
return;
}
clearVisual();
const entities = [];
entities.push(
layerState.viewer.entities.add({
position: groundCenter,
ellipse: {
semiMajorAxis: AWARENESS_RADIUS_M,
semiMinorAxis: AWARENESS_RADIUS_M,
fill: false,
outline: true,
outlineColor:
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
height: CONTEXT_RIM_HEIGHT_M,
},
}),
);
layerState.visual = {
key,
entities,
center: Cesium.Cartesian3.clone(groundCenter),
};
governorRequestRender('awareness-visual');
}
return {
ensureDirectionOverlay,
scheduleDirectionOverlayUpdate,
cancelDirectionOverlayUpdate,
updateDirectionOverlay,
clearVisual,
renderVisual,
};
}
+70
View File
@@ -0,0 +1,70 @@
import * as Cesium from 'cesium';
export function createState({ services }) {
const state = {};
state.DIRECTION_SCRATCH = Array.from(
{ length: 3 },
() => new Cesium.Cartesian3(),
);
state.SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic();
state.TARGET_CARTOGRAPHIC_SCRATCH = Array.from(
{ length: 3 },
() => new Cesium.Cartographic(),
);
Object.assign(state, {
viewer: null,
dataManager: null,
enabled: false,
// Set once a refresh observes the subject gone from a still-reporting source.
// The Contact readout turns this into its CONTACT LOST hold state.
subjectMissing: false,
// The dedicated Context chooser exposes a passive shell. Operational source
// layers activate only after Contacts is explicitly selected.
passive: true,
ownedDependencies: new Set(),
subject: null,
results: null,
visual: null,
panel: null,
panelOwned: false,
subjectListener: null,
contextListener: null,
clearListener: null,
subjectClearListener: null,
runtimeListenersAttached: false,
preRenderRemover: null,
panelClickListener: null,
directionRoot: null,
compassRing: null,
compassLabels: [],
compassHeading: null,
directionMarkers: [],
navigationHistory: [],
navigationVisited: new Set(),
navigationIndex: -1,
suppressedHistoryKey: null,
pendingSelectionKey: null,
lastSubjectRefreshMs: 0,
/** Camera-pose signature observed on the previous rendered frame. */
lastCameraPoseSig: '',
/** When the pose signature last changed bins (hysteresis anchor). */
lastCameraPoseChangeMs: 0,
/** Whether the view currently counts as moving (hysteretic, not per-frame). */
cameraMoving: false,
lastEvaluatedPosition: null,
sourceRevision: '',
panelMarkup: '',
directionFrame: null,
lastDirectionUpdateMs: 0,
activationId: 0,
autoFocusAttempted: false,
autoFocusRetryPending: false,
pageTimer: null,
cohortPages: new Map(),
});
return state;
}
+447
View File
@@ -0,0 +1,447 @@
import {
AWARENESS_RADIUS_M,
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import {
AWARENESS_QUERY_LIMIT,
SOURCE_LABEL,
SUBJECT_PRESENCE,
AWARENESS_PAGE_SIZE,
} from './policy.js';
export function createSubject({ state: layerState, services, parts, source }) {
const aisLiveVesselsLayer = services.vessels;
const militaryInstallationsLayer = services.installations;
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
/**
* Decide whether a source-scoped context clear belongs to the current subject.
* @param {{layerId?: string}|null} subject Current awareness subject.
* @param {{layerId?: string}|null} cleared Cleared source detail.
* @returns {boolean} Whether the current subject should be cleared.
*/
function awarenessClearMatchesSubject(subject, cleared) {
return Boolean(subject?.layerId && cleared?.layerId === subject.layerId);
}
function evaluateSubject(
subject,
sourceStates = parts.queries.collectSourceStates(),
) {
const position = subject?.position;
if (!position) return null;
const flightsState = sourceStates.flights;
const militaryState = sourceStates.military;
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
const { flights, military } = parts.queries.collectAircraftProximityWindow(
position,
{ subject },
);
const vessels = aisLiveVesselsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
);
const installations = militaryInstallationsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
);
return {
subject,
evaluatedAt: Date.now(),
radiusM: AWARENESS_RADIUS_M,
cohorts: [
{
id: 'flights',
label: 'Flights',
source: flightsState.stats.source || SOURCE_LABEL.flights,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
flightsState,
),
},
{
id: 'military',
label: 'Military flights',
source: militaryState.stats.source || SOURCE_LABEL.military,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
militaryState,
),
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
vesselsState,
),
},
{
id: 'military-installations',
label: 'Mapped installations',
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
coverage: 'CURRENT VIEWPORT ONLY',
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
),
},
],
};
}
function subjectKey(subject) {
return subject ? `${subject.layerId}:${subject.id}` : '';
}
function normalizeContextId(value) {
return String(value || '')
.trim()
.toLowerCase();
}
function normalizedSubjectKey(subject) {
if (!subject) return '';
return `${normalizeContextId(subject.layerId)}:${normalizeContextId(subject.id)}`;
}
/**
* Resolve the source-owned aircraft that currently owns Cesium tracking.
* Both flight layers may briefly retain local state during a cross-layer
* handoff, so the viewer's normalized tracked identity is the tie-breaker.
* @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null}
* Detached awareness subject, or null when no flight owns the follow camera.
*/
function currentTrackedFlightSubject() {
const trackedKey = normalizeContextId(
layerState.viewer?.trackedEntity?.gevTrackedId,
);
if (!trackedKey) return null;
const subjects = [
flightsLayer.getTrackedSubject?.(),
militaryFlightsLayer.getTrackedSubject?.(),
];
return (
subjects.find(
(subject) => normalizedSubjectKey(subject) === trackedKey,
) || null
);
}
function subjectCohortFeedUnknown() {
const cohort = layerState.results?.cohorts?.find(
(item) => item.id === layerState.subject?.layerId,
);
return (
cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN &&
cohort.summary.count === null
);
}
function selectSubject(subject) {
if (!layerState.enabled || !subject?.position) return;
parts.panel.startAwarenessPageRotation();
const key = subjectKey(subject);
layerState.navigationVisited.add(key);
const suppressHistory = layerState.suppressedHistoryKey === key;
layerState.pendingSelectionKey = null;
layerState.suppressedHistoryKey = null;
if (!suppressHistory) {
const current = layerState.navigationHistory[layerState.navigationIndex];
if (subjectKey(current) !== key) {
layerState.navigationHistory.splice(layerState.navigationIndex + 1);
layerState.navigationHistory.push({
...parts.history.historySubjectSnapshot(
subject,
parts.history.historySourceItem(subject),
),
position: Cesium.Cartesian3.clone(subject.position),
});
layerState.navigationIndex = layerState.navigationHistory.length - 1;
}
}
layerState.subject = subject;
// A newly chosen contact starts present; the next refresh re-decides.
layerState.subjectMissing = false;
refreshSelectedSubject(true);
}
/**
* Resolve the currently displayed position for a selected live subject. Flight
* tracking owns the motion callback, so awareness consumes its cached display
* position instead of creating a second tracker or extrapolation path.
* @param {{layerId: string, id: string, position: Cesium.Cartesian3}} subject Current subject.
* @param {object} [options] Position materialization controls.
* @param {boolean} [options.allowCollectionMaterialization=true] Whether a layer-wide fallback may run.
* @returns {Cesium.Cartesian3|null} A cloned live position when one is available.
*/
function resolveSubjectPosition(
subject,
{ allowCollectionMaterialization = true } = {},
) {
if (!subject?.position) return null;
if (subject.layerId === 'flights' || subject.layerId === 'military') {
const trackedPosition =
layerState.viewer?.trackedEntity?.gevDisplayPosition?.();
if (trackedPosition) {
return {
position: Cesium.Cartesian3.clone(trackedPosition),
// Only the follow camera's own contact proves presence this way; a
// different tracked entity says nothing about this subject.
presence:
String(layerState.viewer?.trackedEntity?.gevTrackedId || '') ===
subjectKey(subject)
? SUBJECT_PRESENCE.LIVE
: SUBJECT_PRESENCE.UNCHECKED,
};
}
// Cockpit already materializes its tracked position on a fixed 20 Hz loop.
// If that frame-owned cache is temporarily unavailable, keep the last
// awareness position instead of allocating/scanning up to 1,000 contacts.
if (!allowCollectionMaterialization) {
return {
position: Cesium.Cartesian3.clone(subject.position),
presence: SUBJECT_PRESENCE.UNCHECKED,
};
}
const layer =
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
return collectionSubjectPosition(
subject,
layer.getAllPositions(1000),
layer,
);
}
if (subject.layerId === 'ais-live-vessels') {
if (!allowCollectionMaterialization) {
return {
position: Cesium.Cartesian3.clone(subject.position),
presence: SUBJECT_PRESENCE.UNCHECKED,
};
}
return collectionSubjectPosition(
subject,
aisLiveVesselsLayer.getAllPositions(12000),
aisLiveVesselsLayer,
);
}
// Mapped installations come from static geometry, not a live feed: there is
// nothing to be culled from.
return {
position: Cesium.Cartesian3.clone(subject.position),
presence: SUBJECT_PRESENCE.LIVE,
};
}
/**
* Locate a subject inside its layer's live collection, keeping the last known
* position when it is gone.
*
* Presence comes from the layer's O(1) `hasContact`, never from the rows:
* `getAllPositions` stops at its cap and the flights layer routinely carries
* ~11k contacts against a 1,000-row cap, so "not in the rows" is not "gone".
* The rows are only consulted for a fresher position — a contact past the cap
* keeps its last known position while still reading as present.
* @param {{id: string, position: Cesium.Cartesian3}} subject Current subject.
* @param {Array<{id: string, position: Cesium.Cartesian3}>} rows Live collection rows.
* @param {{hasContact?: function}} layer The owning source layer.
* @returns {{position: Cesium.Cartesian3, presence: string}} Position plus presence verdict.
*/
function collectionSubjectPosition(subject, rows, layer) {
const collection = Array.isArray(rows) ? rows : [];
const current = collection.find(
(item) => String(item.id) === String(subject.id),
);
// null/undefined means the layer cannot answer (disabled or not yet loaded).
const known = layer?.hasContact?.(subject.id);
const presence =
known === true
? SUBJECT_PRESENCE.LIVE
: known === false
? SUBJECT_PRESENCE.MISSING
: SUBJECT_PRESENCE.UNCHECKED;
return {
position: current?.position
? Cesium.Cartesian3.clone(current.position)
: Cesium.Cartesian3.clone(subject.position),
presence,
};
}
/**
* Re-resolve a live subject's DISPLAY label from the layer that owns it.
*
* The subject snapshot is captured once at selection time, but a contact's
* label INPUTS can arrive later: adsbdb enrichment supplies a registration
* seconds after a callsign-less aircraft is selected, so every other surface
* swapped `ae1fa4` → `N123AB` while the cached Context subject kept the hex.
* The owning layer's tracked-subject accessor already applies the layer's
* label convention (callsign → registration → icao24), so ask it rather than
* re-deriving the chain here.
*
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
* @param {{layerId: string, id: string, label: string}} subject Current subject.
* @returns {string} The current label, or the cached one when the owning layer
* has no tracked subject (e.g. a cross-layer handoff is in flight).
*/
function resolveSubjectLabel(subject) {
if (!parts.navigation.isFlightLayer(subject?.layerId))
return subject?.label;
const layer =
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
const tracked = layer.getTrackedSubject?.();
if (tracked?.label && String(tracked.id) === String(subject.id))
return tracked.label;
return subject.label;
}
/** Refresh proximity counts/distances against the subject's current live position. */
function refreshSelectedSubject(force = false) {
if (!layerState.enabled || !layerState.subject) return;
const sources = parts.queries.collectSourceStates();
const nextSourceRevision = parts.queries.sourceRevision(sources);
const sourceRevisionChanged =
nextSourceRevision !== layerState.sourceRevision;
const resolved = resolveSubjectPosition(layerState.subject, {
allowCollectionMaterialization:
!document.body?.classList?.contains('cockpit-mode') ||
sourceRevisionChanged,
});
if (!resolved) return;
const { position, presence } = resolved;
// UNCHECKED leaves the verdict alone: this tick simply did not look.
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
else if (presence === SUBJECT_PRESENCE.MISSING)
layerState.subjectMissing = true;
const nextLabel = resolveSubjectLabel(layerState.subject);
const labelChanged = nextLabel !== layerState.subject.label;
layerState.subject = { ...layerState.subject, position, label: nextLabel };
const movementM = layerState.lastEvaluatedPosition
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
: Infinity;
if (
!parts.model.awarenessRefreshRequired({
force,
hasResults: Boolean(layerState.results),
movementM,
sourceRevisionChanged,
})
) {
layerState.results.subject = layerState.subject;
parts.rendering.renderVisual(layerState.subject);
// The panel markup embeds `subject.label`, so a label that changed while
// the contact was stationary (async enrichment answering after selection)
// is render-worthy ON ITS OWN. Without this the standalone Context panel
// kept the ICAO hex until unrelated movement, a source-revision bump, or
// page rotation happened to repaint it. Gated on an ACTUAL change so the
// common no-op refresh (every 750 ms) still costs no repaint.
if (labelChanged) parts.panel.renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(force);
return;
}
layerState.results = evaluateSubject(layerState.subject, sources);
layerState.sourceRevision = nextSourceRevision;
layerState.lastEvaluatedPosition = Cesium.Cartesian3.clone(
position,
layerState.lastEvaluatedPosition,
);
for (const cohort of layerState.results.cohorts) {
const maxPage = Math.max(
0,
Math.floor(
Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE,
) * AWARENESS_PAGE_SIZE,
);
layerState.cohortPages.set(
cohort.id,
Math.min(layerState.cohortPages.get(cohort.id) || 0, maxPage),
);
}
parts.rendering.renderVisual(layerState.subject);
parts.panel.renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(force);
}
function subjectFromContext(record) {
if (
!record ||
!['ais-live-vessels', 'military-installations'].includes(record.layerId)
)
return null;
const latitude = Number(record.latitude);
const longitude = Number(record.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
label: record.label || record.id,
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
};
}
/**
* Record that the current subject left its feed on its own.
*
* The subject, its cohort results and its navigation history all survive: the
* Contact panel keeps rendering them behind a CONTACT LOST cue, and PREVIOUS /
* NEXT stay operable so the operator can step off. Nulling the subject here
* instead would take the panel — and those controls — off screen.
*/
function markSubjectEvicted() {
if (!layerState.subject) return;
layerState.subjectMissing = true;
}
function clearAwarenessSubject() {
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.subjectMissing = false;
layerState.results = null;
layerState.navigationHistory = [];
layerState.navigationVisited.clear();
layerState.navigationIndex = -1;
layerState.suppressedHistoryKey = null;
layerState.pendingSelectionKey = null;
layerState.lastEvaluatedPosition = null;
layerState.sourceRevision = '';
layerState.cohortPages.clear();
parts.panel.stopAwarenessPageRotation();
parts.rendering.clearVisual();
parts.panel.hidePanel();
}
return {
awarenessClearMatchesSubject,
evaluateSubject,
subjectKey,
normalizeContextId,
normalizedSubjectKey,
currentTrackedFlightSubject,
subjectCohortFeedUnknown,
selectSubject,
resolveSubjectPosition,
collectionSubjectPosition,
resolveSubjectLabel,
refreshSelectedSubject,
subjectFromContext,
markSubjectEvicted,
clearAwarenessSubject,
};
}
+15
View File
@@ -0,0 +1,15 @@
export function createTesting({ state: layerState, services, parts, source }) {
/** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */
function _getAwarenessNavigationStateForTest() {
return {
historyKeys: layerState.navigationHistory.map(parts.subject.subjectKey),
navigationVisitedKeys: [...layerState.navigationVisited],
historyLength: layerState.navigationHistory.length,
navigationIndex: layerState.navigationIndex,
suppressedHistoryKey: layerState.suppressedHistoryKey,
pendingSelectionKey: layerState.pendingSelectionKey,
};
}
return { _getAwarenessNavigationStateForTest };
}
+46
View File
@@ -0,0 +1,46 @@
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
id: 'bikeshare',
name: 'Bikeshare',
icon: '🚲',
source: 'GBFS',
updateInterval: STATUS_POLL_MS,
/**
* Return a sampled array of detectable station objects for HUD overlay rendering.
* @param {Object} [options] - Sampling options (maxCount, seed).
* @returns {Array<{ position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean }>}
*/
getDetectableObjects(options = {}) {
return parts.queries.collectDetectableStations(options);
},
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
*/
getStats() {
const stats = {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
};
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
}
if (layerState._error) stats.error = layerState._error;
return stats;
},
};
return { methods };
}
+50
View File
@@ -0,0 +1,50 @@
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
import { createQueries } from './queries.js';
import { createTesting } from './testing.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createBikeshareLayer({ services, source }) {
if (typeof source?.getStations !== 'function')
throw new TypeError('A bikeshare source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
parts.selection = createSelection(context);
parts.queries = createQueries(context);
parts.testing = createTesting(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion?.methods,
{
createBikeshareSelectedOverlayEntry:
parts.selection.createBikeshareSelectedOverlayEntry,
_setBikeshareSelectionStateForTest:
parts.testing._setBikeshareSelectionStateForTest,
_selectBikeshareStationForTest:
parts.testing._selectBikeshareStationForTest,
_clearBikeshareSelectionForTest:
parts.testing._clearBikeshareSelectionForTest,
},
);
}
export {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from './policy.js';
export { createBikeshareSource } from './source.js';
+212
View File
@@ -0,0 +1,212 @@
import { CITY_BY_ID } from './registry.js';
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
/**
* Convert an upstream GBFS URL into a local proxy URL.
* The dev server proxies /api/gbfs/* to avoid CORS issues with third-party feeds.
* @param {string} upstreamUrl - Full HTTPS GBFS endpoint URL.
* @returns {string} Relative proxy URL.
*/
function toProxyUrl(upstreamUrl) {
return `/api/gbfs/${encodeURIComponent(upstreamUrl)}`;
}
/** Increment the loading reference count and mark loading state active. */
function beginLoading() {
layerState._loadingOps++;
layerState._loading = true;
}
/** Decrement the loading reference count; clear loading flag when zero. */
function endLoading() {
layerState._loadingOps = Math.max(0, layerState._loadingOps - 1);
layerState._loading = layerState._loadingOps > 0;
}
/**
* Fetch and parse JSON from a GBFS endpoint via the local proxy.
* @param {string} upstreamUrl - Full HTTPS GBFS endpoint URL.
* @param {Object} [options]
* @param {AbortSignal} [options.signal] - Optional abort signal for cancellation.
* @returns {Promise<Object>} Parsed JSON payload.
* @throws {Error} On non-OK HTTP status or malformed JSON.
*/
async function fetchGbfsJson(upstreamUrl, options) {
return source.getStations(upstreamUrl, options);
}
/**
* Abort an in-flight GBFS request for a specific city and remove it from the map.
* @param {Map<string, { controller: AbortController }>} map - In-flight request map.
* @param {string} cityId - City whose request should be cancelled.
*/
function abortInFlight(map, cityId) {
const entry = map.get(cityId);
if (!entry) return;
try {
entry.controller?.abort();
} catch {
// no-op
}
map.delete(cityId);
}
/** Abort all in-flight station info and status requests across all cities. */
function abortAllInFlight() {
for (const cityId of layerState._inFlightInfo.keys())
abortInFlight(layerState._inFlightInfo, cityId);
for (const cityId of layerState._inFlightStatus.keys())
abortInFlight(layerState._inFlightStatus, cityId);
}
/**
* Load station information for a city. Returns cached data if available,
* deduplicates concurrent requests, and caches the result on success.
* Station info is static metadata (location, name, capacity) and is
* fetched once per city per session.
* @param {string} cityId - City identifier.
* @param {number} generation - Proximity generation to detect stale requests.
* @returns {Promise<Map<string, Object>>} Map of stationId to station info.
* @throws {Error} On unknown city, fetch failure, or empty station list.
*/
async function loadCityStationInfo(cityId, generation) {
if (layerState._stationInfoCache.has(cityId))
return layerState._stationInfoCache.get(cityId);
const inFlight = layerState._inFlightInfo.get(cityId);
if (inFlight) return inFlight.promise;
const city = CITY_BY_ID.get(cityId);
if (!city) throw new Error(`Unknown GBFS city "${cityId}"`);
const controller = new AbortController();
const promise = (async () => {
beginLoading();
try {
const payload = await fetchGbfsJson(city.stationInformationUrl, {
signal: controller.signal,
});
const stationMap = parts.model.parseStationInformation(payload);
if (stationMap.size === 0) {
throw new Error(`No station information for ${city.city}`);
}
layerState._stationInfoCache.set(cityId, stationMap);
return stationMap;
} finally {
endLoading();
}
})().finally(() => {
// Clean up in-flight entry only if it is still ours (not replaced by a newer request)
const current = layerState._inFlightInfo.get(cityId);
if (current && current.controller === controller)
layerState._inFlightInfo.delete(cityId);
});
layerState._inFlightInfo.set(cityId, { promise, controller, generation });
return promise;
}
/**
* Load real-time station status for a city (bike/dock counts, operational flags).
* Unlike station info, status is NOT served from cache — it is always re-fetched
* to keep availability data current. Concurrent requests are deduplicated.
* @param {string} cityId - City identifier.
* @param {number} generation - Proximity generation to detect stale requests.
* @returns {Promise<Map<string, Object>>} Map of stationId to status objects.
* @throws {Error} On unknown city or fetch failure.
*/
async function loadCityStationStatus(cityId, generation) {
const inFlight = layerState._inFlightStatus.get(cityId);
if (inFlight) return inFlight.promise;
const city = CITY_BY_ID.get(cityId);
if (!city) throw new Error(`Unknown GBFS city "${cityId}"`);
const controller = new AbortController();
const promise = (async () => {
beginLoading();
try {
const payload = await fetchGbfsJson(city.stationStatusUrl, {
signal: controller.signal,
});
const statusMap = parts.model.parseStationStatus(payload);
layerState._statusCache.set(cityId, {
statusMap,
timestamp: Date.now(),
});
return statusMap;
} finally {
endLoading();
}
})().finally(() => {
const current = layerState._inFlightStatus.get(cityId);
if (current && current.controller === controller)
layerState._inFlightStatus.delete(cityId);
});
layerState._inFlightStatus.set(cityId, { promise, controller, generation });
return promise;
}
const methods = {
/**
* Periodic update tick — re-fetches station status for all active cities
* and refreshes point colors/sizes. Called by the layer manager at
* STATUS_POLL_MS intervals.
* @returns {Promise<void>}
*/
async update() {
if (!layerState._enabled || layerState._activeCityIds.size === 0) return;
const generation = layerState._proximityGeneration;
const cityIds = Array.from(layerState._activeCityIds);
await Promise.all(
cityIds.map(async (cityId) => {
try {
const statusMap = await loadCityStationStatus(cityId, generation);
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(
`[Data:Bikeshare] ${cityId} status update error:`,
error,
);
layerState._error = 'GBFS status update failed';
}
}),
);
layerState._count = layerState._stationRenderMap.size;
if (layerState._count > 0) layerState._lastUpdate = Date.now();
},
};
return {
toProxyUrl,
beginLoading,
endLoading,
fetchGbfsJson,
abortInFlight,
abortAllInFlight,
loadCityStationInfo,
loadCityStationStatus,
methods,
};
}
+171
View File
@@ -0,0 +1,171 @@
import * as Cesium from 'cesium';
import { GBFS_CITY_REGISTRY } from './registry.js';
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const methods = {
/**
* Initialize the bikeshare layer. Creates the point primitive collection,
* resets all internal state, and installs the click handler.
* Called once during app bootstrap.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
init(viewer) {
layerState._viewer = viewer;
layerState._pointCollection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(layerState._pointCollection);
registerSpriteCollection('bikeshare', layerState._pointCollection);
layerState._pointCollection.show = false;
layerState._enabled = false;
layerState._cameraDebounceTimer = null;
layerState._cameraChangedAttached = false;
layerState._altitudeGateEnabled = false;
layerState._proximityGeneration = 0;
layerState._activeCityIds = new Set();
layerState._cityRuntime = new Map();
layerState._stationInfoCache = new Map();
layerState._statusCache = new Map();
layerState._inFlightInfo = new Map();
layerState._inFlightStatus = new Map();
layerState._stationRenderMap = new Map();
layerState._clickHandler = null;
layerState._selectedKey = null;
layerState._selectedEntity = null;
layerState._count = 0;
layerState._lastUpdate = null;
layerState._loading = false;
layerState._loadingOps = 0;
layerState._error = null;
layerState._limitWarned = false;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
false,
);
parts.selection._installClickHandler(viewer);
restoreSpriteOrder(viewer);
console.log(
`[Data:Bikeshare] Initialized with ${GBFS_CITY_REGISTRY.length} cities`,
);
},
/**
* Enable the bikeshare layer. Shows points, attaches the camera listener,
* and triggers an initial proximity check.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
enable(viewer) {
layerState._enabled = true;
layerState._error = null;
layerState._pointCollection.show = true;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
true,
);
parts.selection._installClickHandler(viewer);
// Pick-ownership (H2): station point ids are string render-map keys.
registerPickOwner('bikeshare', (pickedId) =>
layerState._stationRenderMap.has(pickedId),
);
if (!layerState._cameraChangedAttached) {
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
viewer.camera.percentageChanged = Math.min(
viewer.camera.percentageChanged || 1,
0.05,
);
layerState._cameraChangedAttached = true;
}
void parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
},
/**
* Disable the bikeshare layer. Hides points, removes event listeners,
* aborts all pending fetches, and tears down all city data.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
disable(viewer) {
layerState._enabled = false;
layerState._proximityGeneration++;
layerState._altitudeGateEnabled = false;
clearTimeout(layerState._cameraDebounceTimer);
layerState._cameraDebounceTimer = null;
parts.selection._clearSelection();
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
false,
);
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
document.removeEventListener('keydown', parts.selection._onKeyDown);
unregisterPickOwner('bikeshare');
if (layerState._cameraChangedAttached) {
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities();
layerState._cityRuntime.clear();
layerState._pointCollection.show = false;
layerState._count = 0;
layerState._loading = false;
layerState._loadingOps = 0;
},
/** Permanently release primitives, handlers, and the selected host source. */
destroy(viewer) {
if (layerState._enabled) this.disable(viewer);
else {
parts.selection._clearSelection();
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
false,
);
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
document.removeEventListener('keydown', parts.selection._onKeyDown);
unregisterPickOwner('bikeshare');
}
if (layerState._cameraChangedAttached) {
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
layerState._viewer = null;
},
};
return { methods };
}
+162
View File
@@ -0,0 +1,162 @@
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
* @param {string} stationId - Station identifier within the city.
* @returns {string} Composite key in "cityId:stationId" format.
*/
function stationKey(cityId, stationId) {
return `${cityId}:${stationId}`;
}
/**
* Compute the great-circle distance between two points using the Haversine formula.
* @param {number} aLat - Latitude of point A in degrees.
* @param {number} aLon - Longitude of point A in degrees.
* @param {number} bLat - Latitude of point B in degrees.
* @param {number} bLon - Longitude of point B in degrees.
* @returns {number} Distance in kilometers.
*/
function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (value) => (value * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const p1 = toRad(aLat);
const p2 = toRad(bLat);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
stationKey,
haversineKm,
extractStationsArray,
parseStationInformation,
parseStationStatus,
};
}
+85
View File
@@ -0,0 +1,85 @@
import * as Cesium from 'cesium';
export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected';
export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 0,
moving: false,
});
// --- Activation / display thresholds ---
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
export const ACTIVATION_ALTITUDE_M = 50000;
/** Hysteresis enter threshold — layer activates when camera drops below this. */
export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 2000;
/** Hysteresis exit threshold — layer deactivates when camera rises above this. */
export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 2000;
/** Debounce interval (ms) for camera-change proximity checks. */
export const CAMERA_DEBOUNCE_MS = 340;
/** Default radius (km) for determining whether a city is in camera range. */
export const CITY_RANGE_BASE_KM = 100;
/** Polling interval (ms) for refreshing station status data. */
export const STATUS_POLL_MS = 60000;
// --- Point rendering constants ---
/** Minimum rendered point size in pixels. */
export const POINT_SIZE_MIN = 4;
/** Maximum rendered point size in pixels. */
export const POINT_SIZE_MAX = 12;
/** Fallback station capacity when real data is unavailable. */
export const DEFAULT_CAPACITY = 15;
/** Vertical offset (m) above terrain for station points. */
export const POINT_HEIGHT_OFFSET_M = 2.0;
/** Hard cap on total rendered station points across all cities. */
export const MAX_TOTAL_POINTS = 8000;
// --- Availability color palette ---
/** Station has >60% bikes available. */
export const COLOR_GREEN =
Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95);
/** Station has 30-60% bikes available. */
export const COLOR_YELLOW =
Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94);
/** Station has <30% bikes available. */
export const COLOR_RED =
Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94);
/** No status data available for station. */
export const COLOR_NEUTRAL =
Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62);
/** Station is offline (not installed, not renting, or not returning). */
export const COLOR_MUTED =
Cesium.Color.fromCssColorString('#687581').withAlpha(0.48);
/** Outline color for all station points. */
export const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25);
+75
View File
@@ -0,0 +1,75 @@
export function createQueries({ state: layerState, services, parts, source }) {
/**
* Build a compact HUD detection ID string for a station.
* Truncates long names to 24 chars for readability.
* @param {Object} record - Render record from _stationRenderMap.
* @returns {string} Formatted label like "Station Name [5/20]".
*/
function buildDetectionId(record) {
const bikes = Number.isFinite(record.bikesAvailable)
? record.bikesAvailable
: '?';
const capacity = Number.isFinite(record.capacity) ? record.capacity : '?';
// Render record stores the station name under `stationName` (see the render-map
// shape), so `record.name` was always undefined → every label read "Dock N".
const label = record.stationName || `Dock ${record.stationId}`;
// Truncate long station names to keep HUD readable
const short = label.length > 24 ? label.slice(0, 22) + '…' : label;
return `🚲 ${short} [${bikes}/${capacity}]`;
}
/**
* Collect a sampled subset of visible stations for HUD detection overlay rendering.
* Uses a deterministic stride pattern controlled by options.seed and options.maxCount
* to avoid overcrowding the HUD while still providing broad coverage.
* @param {Object} [options]
* @param {number} [options.maxCount] - Maximum number of detectable objects to return.
* @param {number} [options.seed] - Seed for deterministic stride offset selection.
* @returns {Array<{ position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean }>}
*/
function collectDetectableStations(options = {}) {
if (
!layerState._enabled ||
!layerState._pointCollection ||
!layerState._pointCollection.show ||
layerState._stationRenderMap.size === 0
)
return [];
// Gather all visible station records (include selected even though its point is hidden)
const records = [];
for (const record of layerState._stationRenderMap.values()) {
const isSelected = record.key === layerState._selectedKey;
if ((!record.point?.show && !isSelected) || !record.point?.position)
continue;
records.push(record);
}
if (records.length === 0) return [];
// Deterministic subsampling: pick every Nth station, offset by seed
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(1, Math.ceil(records.length / maxCount));
const start = ((seed % stride) + stride) % stride;
const result = [];
for (let i = start; i < records.length; i += stride) {
const record = records[i];
result.push({
position: record.point.position,
sourceId: record.key,
id: buildDetectionId(record),
type: 'VEH',
skipLabel: record.key === layerState._selectedKey,
});
if (result.length >= maxCount) break;
}
return result;
}
return { buildDetectionId, collectDetectableStations };
}
+394
View File
@@ -0,0 +1,394 @@
function buildBcycleUrls(systemId) {
return {
stationInformationUrl: `https://gbfs.bcycle.com/${systemId}/station_information.json`,
stationStatusUrl: `https://gbfs.bcycle.com/${systemId}/station_status.json`,
};
}
function bcycleEntry({
id,
city,
centerLat,
centerLon,
systemId,
loadRadiusKm = 100,
provider = 'BCycle',
}) {
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
...buildBcycleUrls(systemId),
};
}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
city: 'New York, NY',
centerLat: 40.7484,
centerLon: -73.9967,
loadRadiusKm: 140,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_status.json',
provider: 'Citi Bike',
},
{
id: 'chicago-divvy',
city: 'Chicago, IL',
centerLat: 41.8781,
centerLon: -87.6298,
loadRadiusKm: 120,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json',
provider: 'Divvy',
},
{
id: 'dc-capital-bikeshare',
city: 'Washington, DC',
centerLat: 38.9072,
centerLon: -77.0369,
loadRadiusKm: 120,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_status.json',
provider: 'Capital Bikeshare',
},
{
id: 'sf-bay-wheels',
city: 'San Francisco, CA',
centerLat: 37.7749,
centerLon: -122.4194,
loadRadiusKm: 110,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json',
provider: 'Bay Wheels',
},
{
id: 'boston-bluebikes',
city: 'Boston, MA',
centerLat: 42.3601,
centerLon: -71.0589,
loadRadiusKm: 100,
stationInformationUrl:
'https://gbfs.bluebikes.com/gbfs/en/station_information.json',
stationStatusUrl: 'https://gbfs.bluebikes.com/gbfs/en/station_status.json',
provider: 'Blue Bikes',
},
{
id: 'philadelphia-indego',
city: 'Philadelphia, PA',
centerLat: 39.9526,
centerLon: -75.1652,
loadRadiusKm: 100,
stationInformationUrl:
'https://gbfs.bcycle.com/bcycle_indego/station_information.json',
stationStatusUrl:
'https://gbfs.bcycle.com/bcycle_indego/station_status.json',
provider: 'Indego',
},
{
id: 'portland-biketown',
city: 'Portland, OR',
centerLat: 45.5152,
centerLon: -122.6784,
loadRadiusKm: 95,
stationInformationUrl:
'https://gbfs.biketownpdx.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.biketownpdx.com/gbfs/2.3/en/station_status.json',
provider: 'BIKETOWN',
},
{
id: 'la-metro-bike',
city: 'Los Angeles, CA',
centerLat: 34.0522,
centerLon: -118.2437,
loadRadiusKm: 120,
stationInformationUrl:
'https://gbfs.bcycle.com/bcycle_lametro/station_information.json',
stationStatusUrl:
'https://gbfs.bcycle.com/bcycle_lametro/station_status.json',
provider: 'Metro Bike',
},
{
id: 'austin-capmetro',
city: 'Austin, TX',
centerLat: 30.2672,
centerLon: -97.7431,
loadRadiusKm: 90,
stationInformationUrl:
'https://austin.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://austin.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'CapMetro',
},
{
id: 'honolulu-biki',
city: 'Honolulu, HI',
centerLat: 21.3069,
centerLon: -157.8583,
loadRadiusKm: 90,
stationInformationUrl:
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Biki',
},
{
id: 'columbus-cogo',
city: 'Columbus, OH',
centerLat: 39.9612,
centerLon: -82.9988,
loadRadiusKm: 90,
stationInformationUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json',
provider: 'CoGo',
},
{
id: 'chattanooga-bikechatt',
city: 'Chattanooga, TN',
centerLat: 35.0456,
centerLon: -85.3097,
loadRadiusKm: 90,
stationInformationUrl:
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Chattanooga',
},
bcycleEntry({
id: 'boulder-bcycle',
city: 'Boulder, CO',
centerLat: 40.015,
centerLon: -105.2705,
systemId: 'bcycle_boulder',
}),
bcycleEntry({
id: 'milwaukee-bublr',
city: 'Milwaukee, WI',
centerLat: 43.0389,
centerLon: -87.9065,
systemId: 'bcycle_bublr',
provider: 'Bublr',
}),
bcycleEntry({
id: 'madison-bcycle',
city: 'Madison, WI',
centerLat: 43.0731,
centerLon: -89.4012,
systemId: 'bcycle_madison',
}),
bcycleEntry({
id: 'nashville-bcycle',
city: 'Nashville, TN',
centerLat: 36.1627,
centerLon: -86.7816,
systemId: 'bcycle_nashville',
}),
bcycleEntry({
id: 'salt-lake-greenbike',
city: 'Salt Lake City, UT',
centerLat: 40.7608,
centerLon: -111.891,
systemId: 'bcycle_greenbikeslc',
provider: 'GREENbike',
}),
bcycleEntry({
id: 'san-antonio-bcycle',
city: 'San Antonio, TX',
centerLat: 29.4241,
centerLon: -98.4936,
systemId: 'bcycle_sanantonio',
}),
bcycleEntry({
id: 'cincinnati-red-bike',
city: 'Cincinnati, OH',
centerLat: 39.1031,
centerLon: -84.512,
systemId: 'bcycle_cincyredbike',
provider: 'Red Bike',
}),
bcycleEntry({
id: 'el-paso-bcycle',
city: 'El Paso, TX',
centerLat: 31.7619,
centerLon: -106.485,
systemId: 'bcycle_elpaso',
}),
bcycleEntry({
id: 'indianapolis-pacers',
city: 'Indianapolis, IN',
centerLat: 39.7684,
centerLon: -86.1581,
systemId: 'bcycle_pacersbikeshare',
provider: 'Pacers Bikeshare',
}),
bcycleEntry({
id: 'fort-lauderdale-broward',
city: 'Fort Lauderdale, FL',
centerLat: 26.1224,
centerLon: -80.1373,
systemId: 'bcycle_broward',
provider: 'Broward B-cycle',
}),
bcycleEntry({
id: 'memphis-bcycle',
city: 'Memphis, TN',
centerLat: 35.1495,
centerLon: -90.049,
systemId: 'bcycle_memphis',
}),
bcycleEntry({
id: 'des-moines-bcycle',
city: 'Des Moines, IA',
centerLat: 41.5868,
centerLon: -93.625,
systemId: 'bcycle_desmoines',
}),
bcycleEntry({
id: 'tucson-tugo',
city: 'Tucson, AZ',
centerLat: 32.2226,
centerLon: -110.9747,
systemId: 'bcycle_tugo',
provider: 'Tugo',
}),
bcycleEntry({
id: 'fort-worth-trinity',
city: 'Fort Worth, TX',
centerLat: 32.7555,
centerLon: -97.3308,
systemId: 'bcycle_fortworth',
provider: 'Trinity Metro',
}),
bcycleEntry({
id: 'omaha-heartland',
city: 'Omaha, NE',
centerLat: 41.2565,
centerLon: -95.9345,
systemId: 'bcycle_heartland',
provider: 'Heartland B-cycle',
}),
bcycleEntry({
id: 'lincoln-bikelnk',
city: 'Lincoln, NE',
centerLat: 40.8136,
centerLon: -96.7026,
systemId: 'bcycle_bikelnk',
provider: 'BikeLNK',
}),
bcycleEntry({
id: 'greenville-sc-bcycle',
city: 'Greenville, SC',
centerLat: 34.8526,
centerLon: -82.394,
systemId: 'bcycle_greenville',
}),
bcycleEntry({
id: 'buffalo-reddy',
city: 'Buffalo, NY',
centerLat: 42.8864,
centerLon: -78.8784,
systemId: 'bcycle_reddy',
provider: 'Reddy Bikeshare',
}),
bcycleEntry({
id: 'las-vegas-rtc-bike-share',
city: 'Las Vegas, NV',
centerLat: 36.1699,
centerLon: -115.1398,
systemId: 'bcycle_rtcbikeshare',
provider: 'RTC Bike Share',
}),
bcycleEntry({
id: 'santa-barbara-bcycle',
city: 'Santa Barbara, CA',
centerLat: 34.4208,
centerLon: -119.6982,
systemId: 'bcycle_santabarbara',
}),
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
.trim()
.toLowerCase();
const city = String(entry?.city || '').trim();
const centerLat = Number(entry?.centerLat);
const centerLon = Number(entry?.centerLon);
const loadRadiusKm = Number(entry?.loadRadiusKm);
const stationInformationUrl = new URL(
String(entry?.stationInformationUrl || '').trim(),
);
const stationStatusUrl = new URL(
String(entry?.stationStatusUrl || '').trim(),
);
if (!id) throw new Error('GBFS entry id is required');
if (!city) throw new Error(`GBFS entry "${id}" city is required`);
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLon)) {
throw new Error(`GBFS entry "${id}" has invalid center coordinates`);
}
// Enforce HTTPS-only for GBFS feeds
if (
stationInformationUrl.protocol !== 'https:' ||
stationStatusUrl.protocol !== 'https:'
) {
throw new Error(`GBFS entry "${id}" must use https URLs`);
}
// Validate that URLs end with expected GBFS endpoint filenames
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
}
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
}
// Deduplicate hostnames across both feed URLs for proxy allowlisting
const hosts = Array.from(
new Set([
stationInformationUrl.hostname.toLowerCase(),
stationStatusUrl.hostname.toLowerCase(),
]),
);
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm:
Number.isFinite(loadRadiusKm) && loadRadiusKm > 0
? loadRadiusKm
: CITY_RANGE_BASE_KM,
stationInformationUrl: stationInformationUrl.toString(),
stationStatusUrl: stationStatusUrl.toString(),
provider: String(entry?.provider || 'GBFS').trim() || 'GBFS',
hosts,
};
}
const GBFS_CITY_REGISTRY = (() => {
const seen = new Set();
return RAW_GBFS_CITY_REGISTRY.map((entry) => {
const normalized = normalizeRegistryEntry(entry);
if (seen.has(normalized.id)) {
throw new Error(`Duplicate GBFS city id: ${normalized.id}`);
}
seen.add(normalized.id);
return normalized;
});
})();
const CITY_BY_ID = new Map(
GBFS_CITY_REGISTRY.map((entry) => [entry.id, entry]),
);
export { GBFS_CITY_REGISTRY, CITY_BY_ID };
+282
View File
@@ -0,0 +1,282 @@
import * as Cesium from 'cesium';
import {
DEFAULT_CAPACITY,
POINT_SIZE_MIN,
POINT_SIZE_MAX,
COLOR_NEUTRAL,
COLOR_MUTED,
COLOR_GREEN,
COLOR_YELLOW,
COLOR_RED,
POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS,
COLOR_OUTLINE,
} from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const { governorRequestRender } = services.render;
/**
* Resolve the effective dock capacity for a station.
* Prefers the explicit capacity from station_information; falls back to
* bikes+docks from status data; uses DEFAULT_CAPACITY as last resort.
* @param {number|null} capacityFromInfo - Capacity from station_information, or null.
* @param {Object|null} status - Station status object with bikesAvailable/docksAvailable.
* @returns {number} Resolved capacity (always > 0).
*/
function resolveCapacity(capacityFromInfo, status) {
const fromInfo = parts.model.toNonNegativeInteger(capacityFromInfo);
if (fromInfo && fromInfo > 0) return fromInfo;
// Derive capacity from current bikes + available docks
const bikes = parts.model.toNonNegativeInteger(status?.bikesAvailable, 0);
const docks = parts.model.toNonNegativeInteger(status?.docksAvailable, 0);
const derived = bikes + docks;
if (derived > 0) return derived;
return DEFAULT_CAPACITY;
}
/**
* Map station capacity to a point pixel size using a sqrt scale.
* Larger-capacity stations render as bigger dots; clamped to [POINT_SIZE_MIN, POINT_SIZE_MAX].
* @param {number} capacity - Station dock capacity.
* @returns {number} Point size in pixels.
*/
function capacityToPixelSize(capacity) {
const c = Math.max(1, Math.min(80, Number(capacity) || DEFAULT_CAPACITY));
// sqrt scale so large stations don't dominate visually
const normalized = Math.sqrt(c) / Math.sqrt(80);
const size =
POINT_SIZE_MIN + normalized * (POINT_SIZE_MAX - POINT_SIZE_MIN);
return Math.max(POINT_SIZE_MIN, Math.min(POINT_SIZE_MAX, size));
}
/**
* Determine the display color for a station based on its availability ratio.
* - No status data: neutral gray.
* - Offline (not installed/renting/returning): muted gray.
* - >60% bikes available: green.
* - 30-60% bikes available: yellow.
* - <30% bikes available: red.
* @param {Object|null} status - Station status object.
* @param {number} capacity - Resolved station capacity.
* @returns {Cesium.Color} Color to apply to the station point.
*/
function statusToColor(status, capacity) {
if (!status) return COLOR_NEUTRAL;
if (!status.isInstalled || !status.isRenting || !status.isReturning)
return COLOR_MUTED;
const bikes = parts.model.toNonNegativeInteger(status.bikesAvailable);
if (!Number.isFinite(bikes)) return COLOR_NEUTRAL;
const cap = Math.max(1, Number(capacity) || DEFAULT_CAPACITY);
const ratio = bikes / cap;
if (ratio > 0.6) return COLOR_GREEN;
if (ratio >= 0.3) return COLOR_YELLOW;
return COLOR_RED;
}
/**
* Create a Cartesian3 position for a station, terrain-clamped when possible.
* Falls back to a fixed small height offset if terrain sampling is unsupported.
* @param {Object} station - Station info object with lat/lon.
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
*/
function createStationPosition(station) {
const lon = Number(station?.lon);
const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
let height = POINT_HEIGHT_OFFSET_M;
// Sample terrain height so points sit on ground rather than at ellipsoid level
if (layerState._viewer?.scene?.sampleHeightSupported) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const sampled = layerState._viewer.scene.sampleHeight(carto);
if (Number.isFinite(sampled)) {
height = sampled + POINT_HEIGHT_OFFSET_M;
}
}
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
}
/**
* Get or create the runtime tracking object for a city.
* @param {string} cityId - City identifier.
* @returns {{ stationKeys: Set<string> }} City runtime object.
*/
function ensureCityRuntime(cityId) {
if (layerState._cityRuntime.has(cityId))
return layerState._cityRuntime.get(cityId);
const runtime = { stationKeys: new Set() };
layerState._cityRuntime.set(cityId, runtime);
return runtime;
}
/**
* Ensure point primitives exist for all stations in a city.
* Creates new points for stations not yet rendered; skips existing ones.
* Respects the MAX_TOTAL_POINTS global cap to prevent GPU overload.
* @param {string} cityId - City identifier.
* @param {Map<string, Object>} stationMap - Parsed station info map for the city.
*/
function ensureCityPoints(cityId, stationMap) {
// Deferred debounce/fetch completions mutate point primitives after the
// camera settled — each commit needs one frame in idle mode. (perf wave 2 fix)
governorRequestRender('bikeshare-points');
const runtime = ensureCityRuntime(cityId);
for (const station of stationMap.values()) {
const key = parts.model.stationKey(cityId, station.stationId);
if (layerState._stationRenderMap.has(key)) {
runtime.stationKeys.add(key);
continue;
}
// Enforce global point cap
if (layerState._stationRenderMap.size >= MAX_TOTAL_POINTS) {
if (!layerState._limitWarned) {
layerState._limitWarned = true;
console.warn(
`[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`,
);
}
break;
}
const position = createStationPosition(station);
if (!position) continue;
// Add a point primitive with distance-based scale and translucency falloff
const point = layerState._pointCollection.add({
position,
pixelSize: capacityToPixelSize(station.capacity),
color: COLOR_NEUTRAL,
outlineColor: COLOR_OUTLINE,
outlineWidth: 1,
scaleByDistance: new Cesium.NearFarScalar(200, 1.35, 130000, 0.4),
translucencyByDistance: new Cesium.NearFarScalar(
200,
1.0,
180000,
0.15,
),
disableDepthTestDistance: 2500,
id: key,
});
layerState._stationRenderMap.set(key, {
key,
cityId,
stationId: station.stationId,
stationName: station.name,
point,
capacity: parts.model.toNonNegativeInteger(station.capacity),
bikesAvailable: null,
docksAvailable: null,
isInstalled: station.isInstalled,
isRenting: station.isRenting,
isReturning: station.isReturning,
});
runtime.stationKeys.add(key);
}
layerState._count = layerState._stationRenderMap.size;
}
/**
* Remove all rendered point primitives for a city and clean up runtime state.
* Also clears any active selection if it belongs to this city.
* @param {string} cityId - City identifier to remove.
*/
function removeCityPoints(cityId) {
const runtime = layerState._cityRuntime.get(cityId);
if (!runtime) return;
// Clear selection if it belongs to the city being removed
if (
layerState._selectedKey &&
runtime.stationKeys.has(layerState._selectedKey)
) {
parts.selection._clearSelection();
}
for (const key of runtime.stationKeys) {
const record = layerState._stationRenderMap.get(key);
if (!record) continue;
layerState._pointCollection.remove(record.point);
layerState._stationRenderMap.delete(key);
}
layerState._cityRuntime.delete(cityId);
layerState._count = layerState._stationRenderMap.size;
}
/**
* Apply real-time status data to rendered station points for a city.
* Updates each point's color (availability ratio) and pixel size (capacity),
* and refreshes the render record's cached availability fields.
* @param {string} cityId - City identifier.
* @param {Map<string, Object>} statusMap - Parsed station status map.
*/
function applyStatusToPoints(cityId, statusMap) {
governorRequestRender('bikeshare-status');
const runtime = layerState._cityRuntime.get(cityId);
if (!runtime) return;
for (const key of runtime.stationKeys) {
const record = layerState._stationRenderMap.get(key);
if (!record) continue;
const status = statusMap.get(record.stationId) || null;
const capacity = resolveCapacity(record.capacity, status);
record.capacity = capacity;
record.bikesAvailable = parts.model.toNonNegativeInteger(
status?.bikesAvailable,
);
record.docksAvailable = parts.model.toNonNegativeInteger(
status?.docksAvailable,
);
record.isInstalled = parts.model.normalizeGbfsBool(
status?.isInstalled,
true,
);
record.isRenting = parts.model.normalizeGbfsBool(status?.isRenting, true);
record.isReturning = parts.model.normalizeGbfsBool(
status?.isReturning,
true,
);
// Update visual properties based on current status
record.point.pixelSize = capacityToPixelSize(capacity);
record.point.color = statusToColor(status, capacity);
}
}
return {
resolveCapacity,
capacityToPixelSize,
statusToColor,
createStationPosition,
ensureCityRuntime,
ensureCityPoints,
removeCityPoints,
applyStatusToPoints,
};
}
+206
View File
@@ -0,0 +1,206 @@
import * as Cesium from 'cesium';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from './policy.js';
export function createSelection({
state: layerState,
services,
parts,
source,
}) {
/**
* Build a multi-line text label for the selected station popup.
* Shows station name, availability counts, and any abnormal operational flags.
* @param {Object} record - Render record from _stationRenderMap.
* @returns {string} Newline-delimited source text for the selected host card.
*/
function buildSelectionLabel(record) {
const stationName = String(record?.stationName || '').trim();
const stationLabel =
stationName ||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
const docks = Number.isFinite(record?.docksAvailable)
? record.docksAvailable
: '?';
const capacity = Number.isFinite(record?.capacity) ? record.capacity : '?';
const lines = [
stationLabel,
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
return lines.join('\n');
}
/**
* Build the protected selected-station entry from source-owned copy.
* @param {string} key Stable city/station composite key.
* @param {Object} record Bikeshare render record.
* @returns {Object|null}
*/
function createBikeshareSelectedOverlayEntry(key, record) {
const position = record?.point?.position;
if (!key || !position) return null;
const [title, ...details] = buildSelectionLabel(record).split('\n');
return {
id: String(key),
position,
variant: 'selected',
selected: true,
protected: true,
paintLane: 'selected',
collisionGroup: 'ambient-card',
priority: Number.MAX_SAFE_INTEGER,
title,
details,
accent: '#00ffff',
interactive: false,
anchorRadiusPx: 9,
minAnchorGapPx: 11,
verticalOnly: true,
placement: 'above',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
};
}
/**
* Clear the current station selection.
* Re-shows the hidden point primitive and removes the highlight entity.
*/
function _clearSelection() {
if (layerState._selectedKey) {
const record = layerState._stationRenderMap.get(layerState._selectedKey);
if (record?.point) {
record.point.show = true;
}
}
if (layerState._selectedEntity && layerState._viewer) {
layerState._viewer.entities.remove(layerState._selectedEntity);
}
layerState._selectedKey = null;
layerState._selectedEntity = null;
layerState._overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID);
}
/**
* Select a station by key: hides the original point primitive and adds a
* highlighted cyan entity plus a protected shared-host availability card.
* @param {string} key - Composite "cityId:stationId" key.
*/
function _selectStation(key) {
_clearSelection();
const record = layerState._stationRenderMap.get(key);
if (!record || !record.point?.position || !layerState._viewer) return;
layerState._selectedKey = key;
// Hide the base point so the highlight entity replaces it visually
record.point.show = false;
layerState._selectedEntity = layerState._viewer.entities.add({
position: record.point.position,
point: {
pixelSize: 14,
color: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
const entry = createBikeshareSelectedOverlayEntry(key, record);
if (entry) {
layerState._overlayHost.setEntries(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
[entry],
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
);
}
}
/**
* Install a screen-space click handler for station selection/deselection.
* Also registers a global keydown listener for Escape-to-deselect.
* Idempotent — does nothing if a handler is already installed.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
function _installClickHandler(viewer) {
if (layerState._clickHandler) return;
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
const picked = viewer.scene.pick(click.position);
if (picked) {
// Clicking selected entity itself — ignore (don't deselect).
if (picked.id === layerState._selectedEntity) return;
// Check if the picked primitive or entity id matches a station key
const primitive = picked.primitive;
if (
primitive &&
typeof primitive.id === 'string' &&
layerState._stationRenderMap.has(primitive.id)
) {
_selectStation(primitive.id);
return;
}
if (
typeof picked.id === 'string' &&
layerState._stationRenderMap.has(picked.id)
) {
_selectStation(picked.id);
return;
}
}
// Clicked empty space — deselect.
if (layerState._selectedKey) _clearSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
document.addEventListener('keydown', _onKeyDown);
}
/**
* Global keydown handler — deselects the current station on Escape.
* @param {KeyboardEvent} e - Keyboard event.
*/
function _onKeyDown(e) {
if (e.key === 'Escape' && layerState._selectedKey) {
_clearSelection();
}
}
return {
buildSelectionLabel,
createBikeshareSelectedOverlayEntry,
_clearSelection,
_selectStation,
_installClickHandler,
_onKeyDown,
};
}
+23
View File
@@ -0,0 +1,23 @@
/** Read a GBFS station document through the existing bounded server proxy. */
export function createBikeshareSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getStations(upstreamUrl, { signal } = {}) {
const url = new URL(upstreamUrl);
if (url.protocol !== 'https:' || url.username || url.password || url.hash)
throw new TypeError('A public HTTPS GBFS URL is required');
signal?.throwIfAborted();
const response = await fetchImpl(
'/api/gbfs?url=' + encodeURIComponent(url.href),
{ method: 'GET', headers: { Accept: 'application/json' }, signal },
);
if (!response.ok) throw new Error('GBFS HTTP ' + response.status);
const payload = await response.json();
signal?.throwIfAborted();
if (!payload || typeof payload !== 'object')
throw new Error('Malformed GBFS payload');
return payload;
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBikeshareSource } from './source.js';
test('station source keeps upstream URLs behind the fixed GBFS endpoint', async () => {
const calls = [];
const source = createBikeshareSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"data":{"stations":[]}}');
},
});
for (const url of [
'file:///etc/passwd',
'http://example.test/stations',
'https://user:[email protected]/stations',
])
await assert.rejects(source.getStations(url), /HTTPS GBFS/);
assert.equal(calls.length, 0);
await source.getStations('https://example.test/stations.json');
const url = new URL(calls[0][0], 'https://app.example');
assert.equal(url.pathname, '/api/gbfs');
assert.equal(
url.searchParams.get('url'),
'https://example.test/stations.json',
);
});
test('cancelled station parsing never publishes the response', async () => {
const controller = new AbortController();
const source = createBikeshareSource({
fetchImpl: async () => ({
ok: true,
json: async () => {
controller.abort();
return { data: { stations: [] } };
},
}),
});
await assert.rejects(
source.getStations('https://example.test/stations', {
signal: controller.signal,
}),
{ name: 'AbortError' },
);
});
+110
View File
@@ -0,0 +1,110 @@
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
services.overlays;
const layerState = {};
layerState.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
// ---------------------------------------------------------------------------
// Module-level mutable state
// ---------------------------------------------------------------------------
/** @type {Cesium.Viewer|null} Active Cesium viewer instance. */
layerState._viewer = null;
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null;
/** Whether the bikeshare layer is currently enabled. */
layerState._enabled = false;
/** Timer handle for camera-move debounce. */
layerState._cameraDebounceTimer = null;
/** Whether the camera.changed listener is currently attached. */
layerState._cameraChangedAttached = false;
/** Hysteresis flag for altitude-based activation. */
layerState._altitudeGateEnabled = false;
/** Monotonic generation counter; incremented on each proximity check to cancel stale work. */
layerState._proximityGeneration = 0;
/** @type {Set<string>} City ids currently considered in-range. */
layerState._activeCityIds = new Set();
/** @type {Map<string, { stationKeys: Set<string> }>} Per-city runtime tracking of rendered station keys. */
layerState._cityRuntime = new Map();
/** @type {Map<string, Map<string, Object>>} Cached station information per city (cityId -> stationId -> StationInfo). */
layerState._stationInfoCache = new Map();
/** @type {Map<string, { timestamp: number, statusMap: Map<string, Object> }>} Cached station status per city. */
layerState._statusCache = new Map();
/** @type {Map<string, { promise: Promise, controller: AbortController, generation: number }>} In-flight station info requests. */
layerState._inFlightInfo = new Map();
/** @type {Map<string, { promise: Promise, controller: AbortController, generation: number }>} In-flight station status requests. */
layerState._inFlightStatus = new Map();
/** @type {Map<string, Object>} Render records keyed by "cityId:stationId". */
layerState._stationRenderMap = new Map();
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */
layerState._clickHandler = null;
/** @type {string|null} Key of the currently selected station, or null. */
layerState._selectedKey = null;
/** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */
layerState._selectedEntity = null;
/** Total number of currently rendered station points. */
layerState._count = 0;
/** Timestamp (ms) of the last successful status update. */
layerState._lastUpdate = null;
/** Whether any GBFS fetch is currently in progress. */
layerState._loading = false;
/** Reference count of concurrent loading operations. */
layerState._loadingOps = 0;
/** Most recent error message string, or null. */
layerState._error = null;
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
layerState._limitWarned = false;
return layerState;
}
+34
View File
@@ -0,0 +1,34 @@
export function createTesting({ state: layerState, services, parts, source }) {
/** Seed a selected-station runtime record while still exercising real select/clear paths. */
function _setBikeshareSelectionStateForTest({
viewer,
key,
record,
overlayHost,
}) {
layerState._viewer = viewer;
layerState._stationRenderMap = new Map([[key, record]]);
layerState._selectedKey = null;
layerState._selectedEntity = null;
layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST;
}
/** Exercise the production selection path in focused runtime tests. */
function _selectBikeshareStationForTest(key) {
parts.selection._selectStation(key);
}
/** Exercise the production clear path and restore the production host seam. */
function _clearBikeshareSelectionForTest() {
parts.selection._clearSelection();
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
}
return {
_setBikeshareSelectionStateForTest,
_selectBikeshareStationForTest,
_clearBikeshareSelectionForTest,
};
}
+257
View File
@@ -0,0 +1,257 @@
import * as Cesium from 'cesium';
import { GBFS_CITY_REGISTRY } from './registry.js';
import {
ACTIVATION_EXIT_ALTITUDE_M,
ACTIVATION_ENTER_ALTITUDE_M,
CITY_RANGE_BASE_KM,
CAMERA_DEBOUNCE_MS,
} from './policy.js';
export function createViewport({ state: layerState, services, parts, source }) {
const { governorRequestRender } = services.render;
/**
* Get the camera's current altitude in meters above the ellipsoid.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
* @returns {number} Altitude in meters, or Infinity if unavailable.
*/
function getCameraAltitude(viewer) {
const carto = viewer?.camera?.positionCartographic;
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
}
/**
* Determine the lat/lon the camera is currently looking at.
* Prefers the center of the computed view rectangle; falls back to the
* camera's own cartographic position when the rectangle is unavailable.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
* @returns {{ lat: number, lon: number }|null} Center coordinates in degrees, or null.
*/
function getCameraCenterLatLon(viewer) {
// Try view rectangle center first (more accurate for tilted views)
const rect = viewer?.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
if (rect) {
const center = Cesium.Rectangle.center(rect);
return {
lat: Cesium.Math.toDegrees(center.latitude),
lon: Cesium.Math.toDegrees(center.longitude),
};
}
// Fallback: use the camera's own position
const carto = viewer?.camera?.positionCartographic;
if (carto) {
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
}
return null;
}
/**
* Determine whether the layer should be active at the given camera altitude.
* Uses hysteresis (separate enter/exit thresholds) to avoid rapid toggling
* when the camera hovers near the activation boundary.
* @param {number} altitude - Camera altitude in meters.
* @returns {boolean} True if the layer should remain/become active.
*/
function shouldActivateAtAltitude(altitude) {
if (!Number.isFinite(altitude)) {
layerState._altitudeGateEnabled = false;
return false;
}
if (layerState._altitudeGateEnabled) {
// Deactivate only after crossing the higher exit threshold
if (altitude >= ACTIVATION_EXIT_ALTITUDE_M)
layerState._altitudeGateEnabled = false;
} else if (altitude <= ACTIVATION_ENTER_ALTITUDE_M) {
// Activate when dropping below the lower enter threshold
layerState._altitudeGateEnabled = true;
}
return layerState._altitudeGateEnabled;
}
/**
* Determine which cities are close enough to the camera to warrant loading.
* Compares haversine distance from the camera center to each city's center
* against the city's configured load radius.
* @param {{ lat: number, lon: number }} center - Camera center in degrees.
* @returns {Set<string>} Set of city ids currently within load range.
*/
function computeInRangeCities(center) {
const active = new Set();
if (!center) return active;
for (const city of GBFS_CITY_REGISTRY) {
const distance = parts.model.haversineKm(
center.lat,
center.lon,
city.centerLat,
city.centerLon,
);
const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm);
if (distance <= radius) active.add(city.id);
}
return active;
}
/**
* Fully deactivate a single city: abort pending fetches and remove rendered points.
* @param {string} cityId - City identifier to deactivate.
*/
function deactivateCity(cityId) {
governorRequestRender('bikeshare-deactivate');
parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId);
parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId);
parts.rendering.removeCityPoints(cityId);
}
/** Deactivate all currently active cities and clear the active set. */
function deactivateAllCities() {
const cityIds = Array.from(layerState._activeCityIds);
for (const cityId of cityIds) deactivateCity(cityId);
layerState._activeCityIds.clear();
}
/**
* Activate a city: fetch station info, create point primitives, fetch status,
* and apply availability colors. Checks generation at each async boundary
* to bail out if the proximity context has changed.
* @param {string} cityId - City identifier to activate.
* @param {number} generation - Proximity generation at time of invocation.
*/
async function activateCity(cityId, generation) {
if (!layerState._enabled || !layerState._activeCityIds.has(cityId)) return;
try {
const stationMap = await parts.ingestion.loadCityStationInfo(
cityId,
generation,
);
// Bail if context changed during fetch
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
parts.rendering.ensureCityPoints(cityId, stationMap);
// Apply any cached status immediately for snappier initial rendering
const cachedStatus = layerState._statusCache.get(cityId)?.statusMap;
if (cachedStatus) {
parts.rendering.applyStatusToPoints(cityId, cachedStatus);
}
const statusMap = await parts.ingestion.loadCityStationStatus(
cityId,
generation,
);
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._lastUpdate = Date.now();
layerState._error = null;
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
layerState._error = 'GBFS fetch error';
deactivateCity(cityId);
layerState._activeCityIds.delete(cityId);
} finally {
layerState._count = layerState._stationRenderMap.size;
}
}
/**
* Core proximity check: determines which cities are in camera range at the
* current altitude, deactivates out-of-range cities, and activates newly
* in-range ones. Increments the generation counter to invalidate stale work.
* @returns {Promise<void>}
*/
async function runProximityCheck() {
if (!layerState._enabled || !layerState._viewer) return;
const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(layerState._viewer);
// Altitude gate: disable all cities when camera is too high
if (!shouldActivateAtAltitude(altitude)) {
deactivateAllCities();
return;
}
const center = getCameraCenterLatLon(layerState._viewer);
if (!center) return;
// Diff active set against newly computed in-range set
const nextActive = computeInRangeCities(center);
for (const cityId of layerState._activeCityIds) {
if (!nextActive.has(cityId)) {
deactivateCity(cityId);
}
}
layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) {
layerState._count = 0;
return;
}
// Only activate cities that don't already have rendered points
const toActivate = [];
for (const cityId of layerState._activeCityIds) {
if (!layerState._cityRuntime.has(cityId)) toActivate.push(cityId);
}
if (toActivate.length === 0) return;
await Promise.all(
toActivate.map((cityId) => activateCity(cityId, generation)),
);
}
/** Schedule a debounced proximity check after camera movement. */
function scheduleProximityCheck() {
clearTimeout(layerState._cameraDebounceTimer);
layerState._cameraDebounceTimer = setTimeout(() => {
void runProximityCheck();
}, CAMERA_DEBOUNCE_MS);
}
/** Camera change event handler — triggers a debounced proximity check. */
function onCameraChanged() {
if (!layerState._enabled) return;
scheduleProximityCheck();
}
return {
getCameraAltitude,
getCameraCenterLatLon,
shouldActivateAtAltitude,
computeInRangeCities,
deactivateCity,
deactivateAllCities,
activateCity,
runProximityCheck,
scheduleProximityCheck,
onCameraChanged,
};
}
+309
View File
@@ -0,0 +1,309 @@
import { createCalibrationGizmo } from '../../data/cctvGizmo.js';
import {
DEFAULT_CAMERA_CALIBRATION,
CCTV_CALIBRATION_STORAGE_KEY_V2,
} from './policy.js';
export function createCalibration({
state: layerState,
services,
parts,
source,
}) {
/**
* Returns whether a calibration patch moves the camera's ground anchor.
* Rotational, optical, range, and manual-height edits preserve the existing
* ground reference; only north/east translation needs a new floor.
* @param {Object|null|undefined} patch
* @returns {boolean}
*/
function calibrationPatchMovesAnchor(patch) {
if (!patch || typeof patch !== 'object') return false;
return (
Object.prototype.hasOwnProperty.call(patch, 'offsetNorthM') ||
Object.prototype.hasOwnProperty.call(patch, 'offsetEastM')
);
}
/**
* Sanitizes and clamps a calibration object to valid ranges.
* Missing or non-finite fields fall back to defaults.
* @param {Object} [value={}] - Raw calibration values.
* @returns {{ offsetNorthM: number, offsetEastM: number, headingDeg: number, pitchDeg: number, fovDeg: number, rangeScale: number, heightM: number }}
*/
function normalizeCalibration(value = {}) {
const raw = value && typeof value === 'object' ? value : {};
return {
offsetNorthM: parts.model.quantize(
parts.model.clamp(
parts.model.safeNumber(raw.offsetNorthM, 0),
-900,
900,
),
0.1,
),
offsetEastM: parts.model.quantize(
parts.model.clamp(
parts.model.safeNumber(raw.offsetEastM, 0),
-900,
900,
),
0.1,
),
headingDeg: parts.model.quantize(
parts.model.clamp(parts.model.safeNumber(raw.headingDeg, 0), -180, 180),
0.1,
),
pitchDeg: parts.model.quantize(
parts.model.clamp(parts.model.safeNumber(raw.pitchDeg, 0), -45, 45),
0.1,
),
fovDeg: parts.model.quantize(
parts.model.clamp(parts.model.safeNumber(raw.fovDeg, 0), -50, 50),
0.1,
),
rangeScale: parts.model.quantize(
parts.model.clamp(parts.model.safeNumber(raw.rangeScale, 1), 0.35, 3.0),
0.01,
),
heightM: parts.model.quantize(
parts.model.clamp(parts.model.safeNumber(raw.heightM, 0), -120, 240),
0.1,
),
};
}
/**
* Returns true if the given calibration is effectively the default (all offsets near zero).
* @param {Object} calibration
* @returns {boolean}
*/
function isDefaultCalibration(calibration) {
const probe = normalizeCalibration(calibration);
return Object.keys(DEFAULT_CAMERA_CALIBRATION).every(
(key) => Math.abs(probe[key] - DEFAULT_CAMERA_CALIBRATION[key]) < 0.0001,
);
}
/**
* Loads all persisted per-camera calibration overrides from the v2 store.
*
* v2 entries carry provenance: `{ values: <7-field offsets>, source: 'manual',
* savedAt: <epoch ms> }`. The v1 key (`CCTV_CALIBRATION_STORAGE_KEY_V1`) is
* NEVER read here — owner decision #3 (§9.3): wipe clean, no legacy import.
*
* @param {{getItem:function}|null} [storage] - Injectable storage (defaults
* to `window.localStorage`); lets the unit suite test this pure of a DOM.
* @returns {Map<string, {values:Object, source:string, savedAt:number}>}
*/
function readCalibrationStoreV2(
storage = parts.model.safeWindowLocalStorage(),
) {
const map = new Map();
if (!storage) return map;
try {
const raw = storage.getItem(CCTV_CALIBRATION_STORAGE_KEY_V2);
if (!raw) return map;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return map;
for (const [cameraId, entry] of Object.entries(parsed)) {
if (!cameraId || !entry || typeof entry !== 'object') continue;
if (!entry.values || typeof entry.values !== 'object') continue;
// 'manual' is the only provenance v2 knows (§9.3 killed 'legacy'); a
// malformed/foreign source string still normalizes to 'manual' rather
// than surfacing an unrecognized value into the badge logic.
map.set(cameraId, {
values: normalizeCalibration(entry.values),
source: 'manual',
savedAt: parts.model.safeNumber(entry.savedAt, 0),
});
}
return map;
} catch {
return map;
}
}
/**
* Persists a calibration map to the v2 store.
* @param {Map<string, {values:Object, source:string, savedAt:number}>} map
* @param {{setItem:function}|null} [storage] - Injectable storage (defaults
* to `window.localStorage`).
*/
function writeCalibrationStoreV2(
map,
storage = parts.model.safeWindowLocalStorage(),
) {
if (!storage) return;
try {
const payload = {};
for (const [cameraId, entry] of map.entries()) {
payload[cameraId] = {
values: normalizeCalibration(entry.values),
source: 'manual',
savedAt: parts.model.safeNumber(entry.savedAt, Date.now()),
};
}
storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload));
} catch {
// storage unavailable
}
}
/**
* Loads the v2 calibration store. `_calibrationById` holds these entries
* directly (`{values, source:'manual', savedAt}`) — never bare offset values
* — so it round-trips straight back through `writeCalibrationStoreV2`.
* @returns {Map<string, {values:Object, source:string, savedAt:number}>}
*/
function loadCalibrationStore() {
return readCalibrationStoreV2();
}
/** Persists the in-memory calibration entries (values + provenance) to the v2 store. */
function saveCalibrationStore() {
writeCalibrationStoreV2(layerState._calibrationById);
}
/**
* Derives the panel CAL badge state for a camera (design §3b, as amended by
* the LOCKED §9.2 — panel-only, no in-world tint).
*
* Three states:
* - 'calibrated' — a human explicitly saved a v2 calibration (`source:'manual'`).
* - 'curated' — no manual save, but the catalog entry was hand-authored
* (`poseSource:'curated'`, file/env sources only).
* - 'raw-prior' — everything else (all Austin Open Data today).
*
* Pure — no scoring math, no raycasts. `confidenceFromScore` and score-based
* quality seeding are retired; this replaces them.
* @param {{calSource?: string|null, poseSource?: string|null}} camera
* @returns {'calibrated'|'curated'|'raw-prior'}
*/
function deriveCalBadge(camera) {
if (camera?.calSource === 'manual') return 'calibrated';
if (camera?.poseSource === 'curated') return 'curated';
return 'raw-prior';
}
/**
* Applies a calibration patch to a record's IN-MEMORY pose (save-gated
* persistence, design §3e: no localStorage write here — only the explicit
* `calibration.save` action persists).
*
* Transient grade (gizmo mid-drag): recompute pose + frustum geometry only
* (the cheap v2 path) with throttled notify — no ground re-arm, no frame
* re-fetch, no store touch.
* Commit grade (drag end, numeric entry, voice): only an E/N anchor move
* resolves a new shared floor; all other edits keep the frozen reference.
*
* @param {Object} record - Camera record.
* @param {Object} patch - Partial 7-field calibration (absolute offset values).
* @param {{transient?: boolean}} [options]
* @returns {boolean} True when the patch applied.
*/
function applyCalibrationPatch(record, patch, options = {}) {
if (!record || !patch || typeof patch !== 'object') return false;
record.camera.calibration = normalizeCalibration({
...record.camera.calibration,
...patch,
});
parts.model.ensureCameraPose(record.camera);
// §9.1: touching range takes manual control — clear the activation clamp.
if ('rangeScale' in patch) {
record.probeClampRangeM = null;
}
const anchorMoved = calibrationPatchMovesAnchor(patch);
if (options.transient === true && anchorMoved) {
record.calibrationAnchorDirty = true;
}
record.calDirty = true;
if (options.transient === true) {
parts.geometry.applyFrustumGeometry(
record,
parts.ground.groundAltFor(record),
);
parts.presentation.notifyListenersThrottled();
return true;
}
if (anchorMoved) {
parts.ground.resolveCommittedGroundAnchor(record);
} else {
parts.geometry.applyFrustumGeometry(
record,
parts.ground.groundAltFor(record),
);
}
parts.frames.refreshProjectionImage(record, true);
return true;
}
/**
* Lazily creates the calibration gizmo controller. The gizmo sees the layer
* only through these callbacks: it attaches to the active record while the
* layer is enabled AND ADJUST mode is on, funnels drags through
* applyCalibrationPatch (transient), and runs the commit tail on release.
*/
function ensureGizmo() {
if (layerState._gizmo || !layerState._viewer) return;
// Both patch callbacks receive the gizmo's PINNED drag record — never
// re-resolve the active camera here: a mid-drag voice select or auto-hop
// would route the captured offsets onto a camera with a different basePose.
const liveRecord = (record) =>
record && layerState._recordById.get(record.camera?.id) === record
? record
: null;
layerState._gizmo = createCalibrationGizmo({
viewer: layerState._viewer,
getActiveRecord: () =>
layerState._enabled && layerState._calibrationMode
? parts.selection.getActiveRecord()
: null,
applyPatch: (patch, draggedRecord) => {
const record =
layerState._enabled && layerState._calibrationMode
? liveRecord(draggedRecord)
: null;
if (record) applyCalibrationPatch(record, patch, { transient: true });
},
endPatch: (draggedRecord) => {
const record = liveRecord(draggedRecord);
if (!record) return;
if (record.calibrationAnchorDirty) {
record.calibrationAnchorDirty = false;
parts.ground.resolveCommittedGroundAnchor(record);
} else {
parts.geometry.applyFrustumGeometry(
record,
parts.ground.groundAltFor(record),
);
}
parts.frames.refreshProjectionImage(record, true);
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
},
});
}
return {
calibrationPatchMovesAnchor,
normalizeCalibration,
isDefaultCalibration,
readCalibrationStoreV2,
writeCalibrationStoreV2,
loadCalibrationStore,
saveCalibrationStore,
deriveCalBadge,
applyCalibrationPatch,
ensureGizmo,
};
}
+404
View File
@@ -0,0 +1,404 @@
import {
createFrameSlot,
CCTV_OVERLAY_SOURCE_ID,
declutterCctvCards,
planFrameCachePrune,
createCctvThumbnailOverlayEntry,
CCTV_FRAME_CANVAS_W,
CCTV_FRAME_CANVAS_H,
applyFrameResult,
} from '../../data/cctvCards.js';
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
import {
CCTV_OVERLAY_SOURCE_OPTIONS,
CARD_VIEW_MARGIN,
CCTV_AMBIENT_CARD_DRAIN_CAP,
CARD_GAP_PX,
CARD_FETCH_TICK_MS,
} from './policy.js';
export function createCards({ state: layerState, services, parts, source }) {
/**
* Configures optional CCTV card presentation without changing card density.
* The active-camera thumbnail defaults OFF, preserving the shipped behavior
* where the monitor plane is the camera's sole active representation.
*
* @param {Object} [options]
* @param {boolean} [options.activeCameraCardEnabled=false]
* @returns {{activeCameraCardEnabled:boolean}}
*/
function setCctvCardPresentationOptions({
activeCameraCardEnabled = false,
} = {}) {
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
if (layerState._enabled) pushAmbientCardEntries();
layerState._viewer?.scene?.requestRender?.();
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
}
// ---------------------------------------------------------------------------
// Ambient card tier (2026-07-29 design — spec:
// docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md)
// ---------------------------------------------------------------------------
/** Returns (creating on demand) the stable frame slot for a camera id. */
function ensureCardFrameSlot(cameraId) {
let slot = layerState._cardFrameSlots.get(cameraId);
if (!slot) {
slot = createFrameSlot();
layerState._cardFrameSlots.set(cameraId, slot);
}
return slot;
}
/**
* Rebuilds the ambient card selection: horizon + in-view projection of the
* catalog (pure math — no scene queries), the zoom-budgeted nearest-first
* LOD pick, greedy screen-space declutter, and the eviction-grace pass that
* keeps budget-edge cards alive across small camera moves (zero-flicker).
* Runs on camera.moveEnd, enable, activation change, and the one-shot
* geometry-drain completion — NEVER per frame. The active camera is excluded
* from the ambient selection/quota. By default its monitor plane is the sole
* active representation; the optional protected-card path is applied only by
* `pushAmbientCardEntries`. Camera icons are never touched here (cards
* annotate markers, they don't replace them).
*/
function refreshAmbientCards() {
if (
!layerState._enabled ||
!layerState._viewer ||
layerState._viewer.isDestroyed() ||
!layerState._records.length
) {
layerState._cctvOverlayHost.setEntries(
CCTV_OVERLAY_SOURCE_ID,
[],
CCTV_OVERLAY_SOURCE_OPTIONS,
);
return;
}
const scene = layerState._viewer.scene;
const carto = layerState._viewer.camera.positionCartographic;
const viewerLat = carto ? Cesium.Math.toDegrees(carto.latitude) : 0;
const viewerLon = carto ? Cesium.Math.toDegrees(carto.longitude) : 0;
// The active camera is excluded from ambient selection ALWAYS (not just
// after its async activation settles). It is published separately in the
// protected lane below, and grace never applies to it.
const activeId = layerState._activeCameraId;
const occluder = horizonOccluder(layerState._viewer.camera);
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
const marginX = width * CARD_VIEW_MARGIN;
const marginY = height * CARD_VIEW_MARGIN;
const candidates = [];
const screenById = new Map();
for (const record of layerState._records) {
const id = record.camera.id;
if (id === activeId || !record.position) continue;
let inView = false;
let sx = NaN;
let sy = NaN;
if (occluder.isPointVisible(record.position)) {
const screen = scene.cartesianToCanvasCoordinates(record.position);
if (
screen &&
Number.isFinite(screen.x) &&
Number.isFinite(screen.y) &&
screen.x >= -marginX &&
screen.x <= width + marginX &&
screen.y >= -marginY &&
screen.y <= height + marginY
) {
inView = true;
sx = screen.x;
sy = screen.y;
screenById.set(id, { sx, sy });
}
}
candidates.push({
id,
distanceKm: parts.model.haversineKm(
viewerLat,
viewerLon,
record.camera.lat,
record.camera.lon,
),
inView,
isVideo: parts.model.isVideoFeedType(
parts.model.normalizeFeedType(record.camera.feedType),
),
sx,
sy,
});
}
// Owner finding 4: current card holders rank with the 20% incumbency
// distance discount, so a small camera move never batch-swaps the ring.
// Item C: passing the viewport dims + per-candidate screen anchors routes
// the budget fill through the screen-distribution grid, so periphery
// cells hold cards instead of everything clustering at screen center.
const { cardIds, budgets } = selectCctvLod(candidates, {
cameraHeightM: carto?.height,
incumbentIds: layerState._cardIds,
viewW: width,
viewH: height,
});
// Like the cold-fill burst, card density yields to the staggered geometry
// drain: painting the raised 20/28/40 budget per frame starves the
// frame-paced mesh-floor queue on weak GPUs (qa-cctv-v2 N=800 drain-budget
// regression). During the initial load the budget holds at the low tier;
// full density arrives the moment the drain completes (which triggers its
// own refreshAmbientCards pass).
const cardLimit = layerState._geoLoading
? Math.min(budgets.cardLimit, CCTV_AMBIENT_CARD_DRAIN_CAP)
: budgets.cardLimit;
const decluttered = declutterCctvCards(
cardIds
.filter((id) => screenById.has(id))
.slice(0, cardLimit)
.map((id, index) => ({
id,
...screenById.get(id),
// Priority carrier, not kilometers: declutter sorts ascending on
// this field, and the selection's order (distribution + incumbency)
// must survive — a periphery cell-winner must not be re-outranked
// by central proximity when two anchors contest the min separation.
distanceKm: index,
})),
{ limit: cardLimit },
);
// Owner finding 2: grace must never apply to the active camera — drop any
// lingering grace entry and keep it out of the retained-card baseline.
if (activeId) {
layerState._cardIds.delete(activeId);
layerState._cardGraceState.delete(activeId);
}
const retention = applyEvictionGrace({
selectedIds: decluttered,
builtIds: [...layerState._cardIds],
graceState: layerState._cardGraceState,
nowMs: Date.now(),
cardLimit: budgets.cardLimit,
});
layerState._cardIds = new Set(retention.keepIds);
layerState._cardGraceState = retention.graceState;
// Bounded thumbnail LRU: live cards (grace included) never lose their
// persisted frame — that persistence IS the no-flicker guarantee. The
// hover card's slot is protected too while the gesture lasts.
const keepFrames = new Set(layerState._cardIds);
if (layerState._hoverCardId) keepFrames.add(layerState._hoverCardId);
if (layerState._activeCameraCardEnabled && layerState._activeCameraId)
keepFrames.add(layerState._activeCameraId);
const drops = planFrameCachePrune(
[...layerState._cardFrameSlots].map(([id, slot]) => ({
id,
stamp: slot.stamp,
})),
keepFrames,
);
for (const id of drops) layerState._cardFrameSlots.delete(id);
pushAmbientCardEntries();
}
/**
* Builds and publishes the card entry list from the current kept set and the
* hover-summoned pin. The hover entry is budget-exempt and high-priority. The
* active camera is absent by default; `activeCameraCardEnabled` retains the
* migrated protected-publication path as an explicit product option. If the
* LOD selection adopts the hovered camera it remains a normal budgeted entry
* that keeps the pin while the gesture lasts.
*/
function pushAmbientCardEntries() {
const entries = [];
let rank = 0;
const push = (id, { pinned = false, active = false } = {}) => {
const record = layerState._recordById.get(id);
if (!record?.position) return;
entries.push(
createCctvThumbnailOverlayEntry({
id,
position: record.position,
gapPx: CARD_GAP_PX,
title: record.camera.name,
frameSlot: ensureCardFrameSlot(id),
rank: rank++,
pinned,
active,
}),
);
};
for (const id of layerState._cardIds)
push(id, { pinned: id === layerState._hoverCardId });
if (
layerState._hoverCardId &&
!layerState._cardIds.has(layerState._hoverCardId) &&
layerState._hoverCardId !== layerState._activeCameraId
) {
push(layerState._hoverCardId, { pinned: true });
}
if (layerState._activeCameraCardEnabled && layerState._activeCameraId) {
push(layerState._activeCameraId, { active: true });
}
layerState._cctvOverlayHost.setEntries(
CCTV_OVERLAY_SOURCE_ID,
entries,
CCTV_OVERLAY_SOURCE_OPTIONS,
);
}
/**
* Fetches one paced static frame and settles it into the stable slot via the
* pure persistence rule (applyFrameResult): success replaces the thumbnail,
* failure leaves the drawn frame untouched. The frame is downscaled once
* into a 2x-thumb offscreen canvas; the renderer reads the slot live.
* @param {Object} record - Camera record.
* @param {Object} slot - The camera's stable frame slot.
* @param {number} refreshMs - Source cadence (also keys the frame-URL tick).
* @param {Object} [options]
* @param {boolean} [options.userGesture] - Hover fast-track (item B): the
* launch bypasses the pacer gate, so its spacing sample would pollute the
* pacing telemetry — skip the min-spacing sample only. The launch still
* stamps `_cardLastFetchAt`, so the pacer waits a full interval after it.
*/
function fetchCardFrame(
record,
slot,
refreshMs,
{ userGesture = false } = {},
) {
if (typeof document !== 'undefined' && document.hidden && !userGesture)
return;
const now = Date.now();
const cameraId = record.camera.id;
layerState._cardFetchInFlightCount += 1;
layerState._cardFetchPendingIds.add(cameraId);
if (layerState._cardLastFetchAt > 0 && !userGesture) {
const spacing = now - layerState._cardLastFetchAt;
// NOTE: cold-fill bursts legitimately push this to ~250 ms — read it
// together with the ambientCards.fetchMode telemetry.
layerState._cardMinFetchSpacingMs =
layerState._cardMinFetchSpacingMs == null
? spacing
: Math.min(layerState._cardMinFetchSpacingMs, spacing);
}
layerState._cardLastFetchAt = now;
layerState._cardFetchCount += 1;
const image = new Image();
layerState._cardFetchImages.add(image);
const settle = (ok) => {
image.onload = null;
image.onerror = null;
if (layerState._cardFetchImages.delete(image)) {
layerState._cardFetchInFlightCount = Math.max(
0,
layerState._cardFetchInFlightCount - 1,
);
layerState._cardFetchPendingIds.delete(cameraId);
}
let frame = null;
if (ok) {
try {
const canvas = document.createElement('canvas');
canvas.width = CCTV_FRAME_CANVAS_W;
canvas.height = CCTV_FRAME_CANVAS_H;
canvas
.getContext('2d')
.drawImage(image, 0, 0, canvas.width, canvas.height);
frame = canvas;
} catch {
frame = null;
}
}
Object.assign(
slot,
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
);
layerState._viewer?.scene?.requestRender?.();
};
image.onload = () => settle(true);
image.onerror = () => settle(false);
image.src = parts.frames.frameUrlFor(record.camera, refreshMs);
}
/** Starts the card-frame pacer (idempotent; policy-gated per tick). */
function startCardFrameLoop() {
if (layerState._cardFetchTimer) return;
layerState._cardFetchTimer = setInterval(
parts.model.cardFrameTick,
CARD_FETCH_TICK_MS,
);
}
/** Stops the pacer and detaches all in-flight fetch handlers. */
function stopCardFrameLoop() {
if (layerState._cardFetchTimer) {
clearInterval(layerState._cardFetchTimer);
layerState._cardFetchTimer = 0;
}
for (const image of layerState._cardFetchImages) {
image.onload = null;
image.onerror = null;
image.removeAttribute('src');
}
layerState._cardFetchImages.clear();
layerState._cardFetchPendingIds.clear();
layerState._cardFetchInFlightCount = 0;
layerState._cardFetchMode = 'steady';
}
/**
* Complete ambient-tier teardown (layer disable/destroy): pacer + in-flight
* handlers, shared-host source entries, card set, grace state, thumbnail
* cache, and fetch telemetry. Nothing leaks across a toggle.
*/
function teardownAmbientCards() {
stopCardFrameLoop();
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
parts.hover.clearHoverCard();
layerState._cameraMoving = false;
layerState._cardIds = new Set();
layerState._cardGraceState = new Map();
layerState._cardFrameSlots = new Map();
layerState._cardFetchCount = 0;
layerState._cardLastFetchAt = 0;
layerState._cardMinFetchSpacingMs = null;
}
function handleVisibilityChange() {
if (!document.hidden) return;
for (const image of layerState._cardFetchImages) {
image.onload = null;
image.onerror = null;
image.removeAttribute('src');
}
layerState._cardFetchImages.clear();
layerState._cardFetchPendingIds.clear();
layerState._cardFetchInFlightCount = 0;
}
return {
handleVisibilityChange,
setCctvCardPresentationOptions,
ensureCardFrameSlot,
refreshAmbientCards,
pushAmbientCardEntries,
fetchCardFrame,
startCardFrameLoop,
stopCardFrameLoop,
teardownAmbientCards,
};
}
+210
View File
@@ -0,0 +1,210 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
export function createCatalog({ state: layerState, services, parts, source }) {
const { CITY_POIS } = services.locations;
/**
* Builds the initial camera catalog from CAMERA_SEEDS definitions.
* Each seed is resolved against its city's POI coordinates, offset, and
* passed through ensureCameraPose to populate derived fields.
* @returns {Object[]} Array of fully-initialized camera objects.
*/
function seedCatalog() {
const catalog = [];
for (const seed of CAMERA_SEEDS) {
const city = CITY_POIS[seed.cityId];
const poi = city?.pois?.[seed.poiIndex];
if (!city || !poi) continue;
const { latOffset, lonOffset } = parts.model.offsetDegrees(
poi.lat,
seed.offsetNorthM || 0,
seed.offsetEastM || 0,
);
const camera = {
id: seed.id,
name: seed.label,
cityId: seed.cityId,
city: city.name,
provider: 'OSM Camera Grid',
sourceKind: 'seed',
feedType: 'image',
feedConfigured: false,
headingConfidence: 'medium',
lat: poi.lat + latOffset,
lon: poi.lon + lonOffset,
headingDeg: parts.model.normalizeHeading(
seed.headingDeg ?? poi.heading ?? 0,
),
fovDeg: parts.model.clamp(seed.fovDeg ?? 70, 20, 120),
rangeM: parts.model.clamp(seed.rangeM ?? 700, 260, 1800),
mountHeightM: parts.model.clamp(seed.elevationM ?? 22, 8, 80),
groundElevationM: Number(city.groundElevation) || 0,
absoluteHeightM:
(Number(city.groundElevation) || 0) +
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
};
parts.model.ensureCameraPose(camera);
catalog.push(camera);
}
return catalog;
}
/**
* Looks up a city ID from CITY_POIS by exact or partial name match.
* @param {string} cityName
* @returns {string|null} Matching city ID or null.
*/
function cityIdByName(cityName) {
const probe = String(cityName || '')
.trim()
.toLowerCase();
if (!probe) return null;
for (const [cityId, city] of Object.entries(CITY_POIS)) {
if (city.name.toLowerCase() === probe) return cityId;
}
for (const [cityId, city] of Object.entries(CITY_POIS)) {
if (
city.name.toLowerCase().includes(probe) ||
probe.includes(city.name.toLowerCase())
)
return cityId;
}
return null;
}
/**
* Fetches configured camera sources from the backend.
* @returns {Promise<Object[]>} Array of raw source objects, or empty on failure.
*/
async function loadCameraSources() {
try {
const signal = layerState._sourceAbort?.signal;
const data = await source.getCatalog({ signal });
signal?.throwIfAborted();
if (!Array.isArray(data?.sources)) return [];
return data.sources;
} catch (error) {
if (error?.name === 'AbortError') throw error;
return [];
}
}
/**
* Merges raw backend sources with seed data to produce the final camera catalog.
* Seeds provide fallback values for heading, FOV, range, etc. when not specified
* by the source. Each camera is passed through ensureCameraPose.
* @param {Object[]} rawSources - Raw source objects from the backend.
* @returns {Object[]} Array of fully-initialized camera objects.
*/
function buildCatalogFromSources(rawSources) {
const sources = Array.isArray(rawSources) ? rawSources : [];
if (!sources.length) return [];
const seeded = seedCatalog();
const seedById = new Map(seeded.map((camera) => [camera.id, camera]));
const catalog = [];
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
const id = String(source.id || '').trim();
if (!id) continue;
const seed = seedById.get(id);
const cityId =
String(source.cityId || '').trim() ||
cityIdByName(source.city) ||
seed?.cityId ||
'';
const city = cityId && CITY_POIS[cityId] ? CITY_POIS[cityId] : null;
const lat = parts.model.safeNumber(source.lat, seed?.lat ?? NaN);
const lon = parts.model.safeNumber(source.lon, seed?.lon ?? NaN);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const sourceHeading = parts.model.safeNumber(source.headingDeg, NaN);
const headingDeg = parts.model.normalizeHeading(
Number.isFinite(sourceHeading)
? sourceHeading
: (seed?.headingDeg ?? parts.model.headingFromId(id)),
);
const fovDeg = parts.model.clamp(
parts.model.safeNumber(source.fovDeg, seed?.fovDeg ?? 74),
20,
125,
);
const rangeM = parts.model.clamp(
parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700),
220,
2200,
);
const mountHeightM = parts.model.clamp(
parts.model.safeNumber(source.mountHeightM, seed?.mountHeightM ?? 24),
6,
120,
);
const pitchDeg = parts.model.clamp(
parts.model.safeNumber(source.pitchDeg, seed?.pitchDeg ?? -17),
-55,
-2,
);
const groundElevationM = parts.model.safeNumber(
source.groundElevationM,
city?.groundElevation ?? seed?.groundElevationM ?? 0,
);
const feedType = parts.model.normalizeFeedType(
source.feedType || source.type || 'image',
);
const headingConfidence = String(
source.headingConfidence || (seed ? 'high' : 'low'),
).toLowerCase();
// CAL badge input (design §3b passthrough): hand-authored file/env source
// entries may carry poseSource:'curated'. Austin Open Data rows never set
// this — they stay RAW PRIOR until a human manually calibrates them.
const poseSource =
source.poseSource === 'curated' ? 'curated' : seed?.poseSource || null;
const camera = {
id,
name: String(source.name || seed?.name || id),
cityId,
city: String(source.city || city?.name || seed?.city || 'Global'),
provider: String(
source.provider || seed?.provider || 'Configured CCTV Source',
),
sourceKind: String(
source.sourceKind ||
source.kind ||
(source.url ? 'configured' : 'seed'),
).toLowerCase(),
feedType,
feedConfigured: typeof source.url === 'string' && !!source.url.trim(),
lat,
lon,
headingDeg,
headingConfidence,
fovDeg,
rangeM,
mountHeightM,
groundElevationM,
absoluteHeightM: groundElevationM + mountHeightM,
pitchDeg,
license: String(source.license || source.licenseNote || ''),
poseSource,
};
parts.model.ensureCameraPose(camera);
catalog.push(camera);
}
return catalog;
}
return {
seedCatalog,
cityIdByName,
loadCameraSources,
buildCatalogFromSources,
};
}
+352
View File
@@ -0,0 +1,352 @@
import {
DEFAULT_UPDATE_INTERVAL_MS,
MIN_AUTO_HOP_SEC,
MAX_AUTO_HOP_SEC,
DEFAULT_CAMERA_CALIBRATION,
} from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { CCTV_ACTIVATION_RESULT } = services.activation;
const methods = {
id: 'cctv',
name: 'CCTV',
icon: '📹',
source: 'CCTV + Street View fallback',
updateInterval: DEFAULT_UPDATE_INTERVAL_MS,
/**
* Applies runtime parameter changes: coverage/projection toggles, auto-hop
* settings, camera selection, and calibration patches/resets.
* @param {Object} [params={}] - Parameter object.
* @param {boolean} [params.showCoverage] - Back-compat coverage toggle (true→'on', false→'off').
* @param {'off'|'on'|'viewshed'} [params.coverageMode] - Full coverage-mode API.
* @param {boolean} [params.showProjection] - Toggle projection overlay visibility.
* @param {boolean} [params.autoHop] - Enable/disable auto-hop.
* @param {number} [params.autoHopSec] - Auto-hop interval in seconds.
* @param {string} [params.selectedCameraId] - Camera ID to activate.
* @param {Object} [params.calibration] - Calibration config: `patch` edits
* the live pose (save-gated — no persistence), `save` persists the current
* calibration as manual, `reset` restores the base prior.
* @param {boolean} [params.calibrationMode] - Toggle the ADJUST gizmo.
* @param {boolean} [params.focusSelected] - Fly to the active camera.
* @param {number} [params.focusDurationSec] - Fly-to duration.
*/
setParams(params = {}) {
if (typeof params.showCoverage === 'boolean') {
layerState._coverageMode = parts.model.normalizeCoverageMode(
params.showCoverage,
layerState._coverageMode,
);
}
if (typeof params.coverageMode === 'string') {
layerState._coverageMode = parts.model.normalizeCoverageMode(
params.coverageMode,
layerState._coverageMode,
);
}
if (typeof params.showProjection === 'boolean') {
layerState._showProjection = params.showProjection;
if (layerState._showProjection) {
const active = parts.selection.getActiveRecord();
if (active) parts.projection.ensureProjectionRuntime(active);
parts.projection.startProjectionLoop();
}
}
if (typeof params.autoHop === 'boolean') {
layerState._autoHop = params.autoHop;
if (params.autoHop) layerState._autoHopSuspended = false;
}
if (
typeof params.autoHopSec === 'number' &&
Number.isFinite(params.autoHopSec)
) {
layerState._autoHopSec = parts.model.clamp(
Math.round(params.autoHopSec),
MIN_AUTO_HOP_SEC,
MAX_AUTO_HOP_SEC,
);
}
if (
typeof params.selectedCameraId === 'string' &&
layerState._recordById.has(params.selectedCameraId)
) {
parts.selection.setActiveCamera(params.selectedCameraId);
}
if (params.calibration && typeof params.calibration === 'object') {
const calibrationCfg = params.calibration;
const targetCameraId =
typeof calibrationCfg.cameraId === 'string' && calibrationCfg.cameraId
? calibrationCfg.cameraId
: layerState._activeCameraId;
const targetRecord = targetCameraId
? layerState._recordById.get(targetCameraId)
: null;
if (targetRecord) {
if (calibrationCfg.reset) {
// RESET: back to the base prior, delete the persisted entry, clear
// the dirty flag (semantics unchanged from v2).
targetRecord.camera.calibration =
parts.calibration.normalizeCalibration(
DEFAULT_CAMERA_CALIBRATION,
);
targetRecord.camera.calSource = null;
targetRecord.calDirty = false;
parts.model.ensureCameraPose(targetRecord.camera);
layerState._calibrationById.delete(targetCameraId);
parts.calibration.saveCalibrationStore();
// Reset returns to the base lat/lon, so resolve that anchor once.
parts.ground.resolveCommittedGroundAnchor(targetRecord);
parts.frames.refreshProjectionImage(targetRecord, true);
}
if (
calibrationCfg.patch &&
typeof calibrationCfg.patch === 'object'
) {
// Save-gated persistence (design §3e): a patch edits the LIVE pose
// only. The store — and the CALIBRATED badge's `calSource` — move
// exclusively on the explicit `save` action below. (§9.1 range-slider
// clamp override + B9b re-ground live inside applyCalibrationPatch.)
parts.calibration.applyCalibrationPatch(
targetRecord,
calibrationCfg.patch,
);
}
if (calibrationCfg.save) {
// SAVE CAL: persist the current in-memory calibration with manual
// provenance. Saving an all-default calibration clears the entry
// (a no-op calibration is not a calibration).
if (
parts.calibration.isDefaultCalibration(
targetRecord.camera.calibration,
)
) {
targetRecord.camera.calSource = null;
layerState._calibrationById.delete(targetCameraId);
} else {
targetRecord.camera.calSource = 'manual';
layerState._calibrationById.set(targetCameraId, {
values: { ...targetRecord.camera.calibration },
source: 'manual',
savedAt: Date.now(),
});
}
targetRecord.calDirty = false;
parts.calibration.saveCalibrationStore();
}
}
}
if (typeof params.calibrationMode === 'boolean') {
layerState._calibrationMode = params.calibrationMode;
if (layerState._calibrationMode) {
parts.calibration.ensureGizmo();
layerState._gizmo?.setEnabled(true);
// ADJUST mode: gizmo drags mutate entity geometry from pointer events,
// which don't trigger renders in requestRenderMode. (perf wave 2)
holdContinuousRender('cctv-adjust');
} else {
releaseContinuousRender('cctv-adjust');
layerState._gizmo?.setEnabled(false);
}
}
if (params.focusSelected && layerState._activeCameraId) {
parts.navigation.focusCamera(
layerState._activeCameraId,
Number(params.focusDurationSec) || 1.8,
);
}
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
},
/**
* Returns the current runtime parameters including toggle states,
* active camera, and calibration values.
* @returns {Object}
*/
getParams() {
const active = parts.selection.getActiveRecord();
return {
showCoverage: layerState._coverageMode !== 'off',
coverageMode: layerState._coverageMode,
showProjection: layerState._showProjection,
calibrationMode: layerState._calibrationMode,
autoHop: layerState._autoHop,
autoHopSec: layerState._autoHopSec,
selectedCameraId: active?.camera.id || null,
calibration: active?.camera
? {
cameraId: active.camera.id,
values: {
...parts.calibration.normalizeCalibration(
active.camera.calibration,
),
},
}
: null,
};
},
/**
* Returns a sampled list of camera positions for the detection overlay system.
* @param {Object} [options={}]
* @param {number} [options.maxCount] - Maximum number of objects to return.
* @param {number} [options.seed] - Offset seed for deterministic stride sampling.
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._records.length === 0) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(
1,
Math.ceil(layerState._records.length / maxCount),
);
const start = seed % stride;
const objects = [];
for (let i = start; i < layerState._records.length; i += stride) {
const camera = layerState._records[i].camera;
objects.push({
position: layerState._records[i].position,
sourceId: camera.id,
id: `CAM-${camera.id}`,
type: 'CAM',
});
if (objects.length >= maxCount) break;
}
return objects;
},
/**
* Returns basic layer statistics, including initial-load progress while
* the staggered geometry queue is draining.
* @returns {{ count: number, lastUpdate: number|null, error: string|null, loading: boolean, loadingLoaded: number, loadingTotal: number }}
*/
getStats() {
return {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
loading: layerState._geoLoading,
loadingLoaded: Math.min(
layerState._geoLoadDone,
layerState._geoLoadTotal,
),
loadingTotal: layerState._geoLoadTotal,
};
},
/**
* Registers a callback that receives the full UI state on every change.
* The callback is invoked immediately with the current state.
* @param {Function} callback - Listener function receiving the UI state object.
* @returns {Function} Unsubscribe function.
*/
subscribe(callback) {
if (typeof callback !== 'function') return () => {};
layerState._listeners.add(callback);
callback(parts.presentation.uiState());
return () => {
layerState._listeners.delete(callback);
};
},
/**
* Returns the current UI state snapshot without subscribing.
* @returns {Object}
*/
getUIState() {
return parts.presentation.uiState();
},
/**
* Opts the active camera into or out of protected thumbnail publication.
* The default is false: the monitor plane remains the sole active-camera
* representation while ambient and hover-pinned cards continue unchanged.
* @param {Object} [options]
* @param {boolean} [options.activeCameraCardEnabled=false]
* @returns {{activeCameraCardEnabled:boolean}}
*/
setCardPresentationOptions(options = {}) {
return parts.cards.setCctvCardPresentationOptions(options);
},
/**
* Selects a camera by ID and optionally flies to it.
* @param {string} cameraId - Camera ID to select.
* @param {Object} [options={}]
* @param {boolean} [options.focus] - If true, fly the viewer to the camera.
* @param {number} [options.durationSec] - Fly-to duration in seconds.
* @returns {boolean} True if the camera was found and selected.
*/
selectCamera(cameraId, options = {}) {
const result = parts.selection.setActiveCamera(cameraId);
if (result === CCTV_ACTIVATION_RESULT.NOT_FOUND) return false;
if (options.focus) {
parts.navigation.focusCamera(cameraId, options.durationSec || 1.8);
}
return true;
},
/**
* Flies the viewer to a specific camera.
* @param {string} cameraId - Camera ID to focus on.
* @param {number} [durationSec=2.2] - Flight duration in seconds.
* @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result.
*/
focusCamera(cameraId, durationSec = 2.2) {
return parts.navigation.focusCamera(cameraId, durationSec);
},
/**
* Cycles the active camera forward or backward by `step` positions in the catalog.
* @param {number} [step=1] - Number of positions to advance (negative to go back).
* @param {Object} [options={}]
* @param {boolean} [options.focus] - If true, fly to the new camera.
* @param {number} [options.durationSec] - Fly-to duration in seconds.
* @returns {string|null} The newly active camera ID, or null if catalog is empty.
*/
cycleCamera(step = 1, options = {}) {
if (!layerState._records.length) return null;
const current = parts.selection.getActiveRecord();
const nextIdx = parts.navigation.cctvCycleIndex(
layerState._records.findIndex((record) => record === current),
step,
layerState._records.length,
);
const nextId = layerState._records[nextIdx].camera.id;
parts.selection.setActiveCamera(nextId);
if (options.focus) {
parts.navigation.focusCamera(nextId, options.durationSec || 1.8);
}
return nextId;
},
/**
* Selects and flies to the camera nearest the current viewer position.
* @param {Object} [options={}]
* @param {boolean} [options.focus=true] Whether to fly after selection.
* @param {number} [options.durationSec] - Fly-to duration in seconds.
* @returns {string|null} The nearest camera ID, or null if none found.
*/
focusNearest(options = {}) {
const nearest = parts.navigation.nearestCameraIdToViewer();
if (!nearest) return null;
parts.selection.setActiveCamera(nearest);
if (options.focus !== false) {
parts.navigation.focusCamera(nearest, options.durationSec || 1.8);
}
return nearest;
},
};
return { methods };
}
+368
View File
@@ -0,0 +1,368 @@
import {
FRAME_SIGNATURE_W,
FRAME_SIGNATURE_H,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
PROJECTION_TEXTURE_SWAP_MS,
ACTIVE_FRAME_REFRESH_MS,
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
PROJECTION_ACTIVE_REFRESH_MS,
PROJECTION_IDLE_REFRESH_MS,
PLACEHOLDER_REPAINT_MS,
} from './policy.js';
export function createFrames({ state: layerState, services, parts, source }) {
/**
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
* pixel buffer, no DOM) so it is unit-testable.
*
* Alpha is skipped deliberately — CCTV stills are opaque, so hashing it would
* cost a third more work to mix in a constant.
*
* @param {Uint8ClampedArray|number[]} data - RGBA pixels, 4 bytes per pixel.
* @returns {number|null} Unsigned 32-bit signature, or null for empty input.
*/
function frameSignatureFromPixels(data) {
if (!data || typeof data.length !== 'number' || data.length < 4)
return null;
let hash = 0x811c9dc5;
for (let i = 0; i < data.length; i += 4) {
hash = Math.imul(hash ^ data[i], 0x01000193);
hash = Math.imul(hash ^ data[i + 1], 0x01000193);
hash = Math.imul(hash ^ data[i + 2], 0x01000193);
}
return hash >>> 0;
}
/**
* Signature of the runtime's freshly decoded frame, via a reused 64x36
* scratch canvas.
*
* @param {Object} runtime - Projection runtime holding the decoded `.image`.
* @returns {number|null} Signature, or null when it cannot be computed (the
* caller then treats the frame as changed — the pre-2026-07-30 behavior).
*/
function projectionFrameSignature(runtime) {
const image = runtime?.image;
if (!image) return null;
if (!runtime.signatureCtx) {
const canvas = document.createElement('canvas');
canvas.width = FRAME_SIGNATURE_W;
canvas.height = FRAME_SIGNATURE_H;
runtime.signatureCanvas = canvas;
runtime.signatureCtx = canvas.getContext('2d', {
willReadFrequently: true,
});
}
const ctx = runtime.signatureCtx;
if (!ctx) return null;
try {
ctx.clearRect(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H);
ctx.drawImage(image, 0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H);
return frameSignatureFromPixels(
ctx.getImageData(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H).data,
);
} catch {
// Tainted canvas (a cross-origin source served without CORS) or a decode
// race. Returning null means "assume changed", so behavior degrades to
// the unconditional redraw this optimization replaced.
return null;
}
}
/**
* Blits the latest projection canvas into the next of two alternating
* offscreen buffer canvases and returns it (H5).
*
* Two buffers are required because Cesium's Material image path re-uploads a
* canvas texture only when the uniform receives a NEW object reference —
* redrawing the same canvas in place is invisible to the GPU. Alternating
* references forces a texture recreate, which at <=1Hz and 1080p is cheap.
*
* @param {Object} runtime - Projection runtime with `.canvas`.
* @returns {HTMLCanvasElement|null} The freshly painted buffer, or null.
*/
function paintNextProjectionBuffer(runtime) {
if (!runtime?.canvas) return null;
if (!runtime.buffers) {
runtime.buffers = [0, 1].map(() => {
const buffer = document.createElement('canvas');
buffer.width = PROJECTION_CANVAS_WIDTH;
buffer.height = PROJECTION_CANVAS_HEIGHT;
return buffer;
});
runtime.bufferIndex = 0;
}
runtime.bufferIndex = (runtime.bufferIndex + 1) % 2;
const buffer = runtime.buffers[runtime.bufferIndex];
const ctx = buffer.getContext('2d');
if (!ctx) return null;
ctx.clearRect(0, 0, buffer.width, buffer.height);
ctx.drawImage(runtime.canvas, 0, 0);
return buffer;
}
/**
* Pushes fresh pixels into the monitor plane material. Called every
* projection tick.
*
* Video feeds are skipped entirely — their HTMLVideoElement uniform is
* updated per-frame by Cesium natively (H5). Image/webcam-frame feeds swap
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
*
* @param {Object} record - Camera record with an initialized projection runtime.
*/
function refreshProjectionTextures(record) {
const runtime = record?.projection;
if (!runtime || runtime.mode === 'video') return;
const now = Date.now();
if (
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
PROJECTION_TEXTURE_SWAP_MS
)
return;
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
if (!planeShowing) return;
// Only swap when the canvas content actually changed since the last swap.
// Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED
// canvas re-uploads the texture for nothing, and each material image
// reassignment is a flash opportunity on the live plane (owner field test
// 2026-07-04: intermittent white flashes on the monitor plane).
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return;
const buffer = paintNextProjectionBuffer(runtime);
if (!buffer) return;
runtime.lastTextureSwapAt = now;
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
runtime.planeMaterial.image = buffer;
}
/**
* Builds the URL for fetching a camera frame image from the backend.
* Includes a tick parameter to control cache invalidation cadence.
* @param {Object} camera - Camera object.
* @param {number} [refreshMs=ACTIVE_FRAME_REFRESH_MS] - Refresh interval used for tick bucketing.
* @returns {string} Frame URL.
*/
function frameUrlFor(...args) {
return source.getFrameUrl(...args);
}
/**
* Builds the URL for fetching a camera's video/media stream.
* @param {Object} camera - Camera object.
* @returns {string} Media URL.
*/
function mediaUrlFor(...args) {
return source.getMediaUrl(...args);
}
/**
* Paints a placeholder frame onto the projection canvas when no live feed
* image or video is available. Shows camera name, city, and status text
* over a dark gradient with tactical border lines.
* @param {CanvasRenderingContext2D} ctx - 2D context for the projection canvas.
* @param {Object} camera - Camera object for label info.
* @param {Object|null} [health=null] - Health state for status message.
*/
function paintProjectionPlaceholder(ctx, camera, health = null) {
if (!ctx) return;
const w = PROJECTION_CANVAS_WIDTH;
const h = PROJECTION_CANVAS_HEIGHT;
ctx.clearRect(0, 0, w, h);
const g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, '#05111a');
g.addColorStop(1, '#01070c');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
const label = String(camera?.name || 'CCTV');
const city = String(camera?.city || 'GLOBAL');
const status = String(
health?.message || health?.status || camera?.feedType || 'NO FEED',
).toUpperCase();
ctx.strokeStyle = 'rgba(0, 220, 255, 0.24)';
ctx.lineWidth = 2;
ctx.strokeRect(18, 18, w - 36, h - 36);
ctx.strokeRect(36, 36, w - 72, h - 72);
ctx.fillStyle = 'rgba(170, 242, 255, 0.95)';
ctx.font = '600 32px "JetBrains Mono", monospace';
ctx.fillText(label.slice(0, 42), 46, 74);
ctx.fillStyle = 'rgba(127, 216, 231, 0.8)';
ctx.font = '500 24px "JetBrains Mono", monospace';
ctx.fillText(city.toUpperCase(), 46, 112);
ctx.font = '500 21px "JetBrains Mono", monospace';
ctx.fillText(status.slice(0, 58), 46, h - 42);
}
/**
* Triggers a new frame fetch for an image-mode projection if the refresh
* interval has elapsed. Active cameras refresh more frequently than idle ones.
* @param {Object} record - Camera record.
* @param {boolean} [force=false] - Bypass the interval check.
*/
function refreshProjectionImage(record, force = false) {
const runtime = record?.projection;
if (!runtime || runtime.mode !== 'image' || !runtime.image) return;
// Hidden-state gate (perf wave 2): no new frame fetch/decode for a canvas
// nobody can see. The refresh interval re-fills naturally on return.
if (typeof document !== 'undefined' && document.hidden && !force) return;
// Do not replace an in-flight URL on the 10-second refresh boundary. Slow
// providers otherwise leave cancelled server requests behind and the plane
// can remain permanently pending. The proxy bounds each attempt; load/error
// clears this latch so the next normal tick can refresh.
if (runtime.imageLoading) return;
const now = Date.now();
const refreshMs =
record.camera.id === layerState._activeCameraId
? PROJECTION_ACTIVE_REFRESH_MS
: PROJECTION_IDLE_REFRESH_MS;
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
runtime.lastImageRefreshAt = now;
const frameUrl = frameUrlFor(record.camera, refreshMs);
const sep = frameUrl.includes('?') ? '&' : '?';
runtime.imageLoading = true;
runtime.imageReady = false;
runtime.image.src = `${frameUrl}${sep}projTs=${Math.floor(now / refreshMs)}`;
}
/**
* Repaints the projection placeholder at most once per PLACEHOLDER_REPAINT_MS.
* The projection loop runs at RAF cadence — unthrottled, a pending feed would
* re-fill the 1080p canvas (gradient + text) on every single frame.
* @param {Object} record - Camera record.
* @param {Object} runtime - Projection runtime.
* @param {Object|null} health - Health state for status text.
*/
function paintPlaceholderThrottled(record, runtime, health) {
const now = Date.now();
if (
now - parts.model.safeNumber(runtime.lastPlaceholderPaintAt, 0) <
PLACEHOLDER_REPAINT_MS
)
return;
runtime.lastPlaceholderPaintAt = now;
runtime.drawnImageStamp = -1;
// The placeholder overwrites the canvas, so the last real frame is no longer
// on it. Drop the signature or an identical frame returning after an outage
// would be skipped as "unchanged" and leave the placeholder on the plane.
runtime.lastFrameSignature = null;
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
paintProjectionPlaceholder(runtime.ctx, record.camera, health);
}
/**
* Draws the current frame (video or image) onto the projection canvas.
* Falls back to the placeholder if the media source is not yet ready.
* Image feeds only repaint when a NEW image finished loading (stamp check):
* re-blitting an unchanged 1080p image every RAF tick is pure waste since
* texture uploads are already throttled to 1Hz buffer swaps.
* @param {Object} record - Camera record with an initialized projection runtime.
*/
function drawProjectionFrame(record) {
const runtime = record?.projection;
if (!runtime || !runtime.ctx) return;
const health = layerState._healthById.get(record.camera.id) || null;
if (runtime.mode === 'video' && runtime.video) {
const video = runtime.video;
if (
video.readyState >= 2 &&
video.videoWidth > 0 &&
video.videoHeight > 0
) {
runtime.ctx.clearRect(
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
);
runtime.ctx.drawImage(
video,
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
);
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
return;
}
paintPlaceholderThrottled(record, runtime, health);
return;
}
refreshProjectionImage(record);
if (runtime.image && runtime.imageReady) {
if (runtime.drawnImageStamp !== runtime.imageStamp) {
// The frame URL carries a 10s cache-buster tick, so a fresh Image
// DECODES every PROJECTION_ACTIVE_REFRESH_MS whether or not the provider
// actually published a new picture — measured 2026-07-30: a London
// camera republished once in 5 minutes, an Austin one not at all.
// Redrawing regardless bumped canvasStamp, which forced a buffer swap
// and a fresh 1920x1080 texture upload; the plane renders its white
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
// two Cesium needs to rebind, which IS the periodic white flash from the
// owner field tests (2026-07-04 and 2026-07-30).
const signature = projectionFrameSignature(runtime);
runtime.drawnImageStamp = runtime.imageStamp;
if (signature !== null && signature === runtime.lastFrameSignature) {
// Identical pixels — leave the canvas, and therefore the bound
// texture, completely alone. No canvasStamp bump, no swap, no flash.
return;
}
runtime.lastFrameSignature = signature;
runtime.ctx.clearRect(
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
);
runtime.ctx.drawImage(
runtime.image,
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
);
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
runtime.lastPlaceholderPaintAt = 0;
}
return;
}
// A refresh is in flight (imageReady=false): keep the last good frame on
// the canvas instead of flashing the placeholder. Placeholder only paints
// when nothing has ever been drawn for this camera.
if (runtime.drawnImageStamp === -1) {
paintPlaceholderThrottled(record, runtime, health);
}
}
return {
frameSignatureFromPixels,
projectionFrameSignature,
paintNextProjectionBuffer,
refreshProjectionTextures,
frameUrlFor,
mediaUrlFor,
paintProjectionPlaceholder,
refreshProjectionImage,
paintPlaceholderThrottled,
drawProjectionFrame,
};
}
+719
View File
@@ -0,0 +1,719 @@
import * as Cesium from 'cesium';
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
import {
PROJECTION_VERT_ASPECT,
FRUSTUM_GROUND_CLEARANCE_M,
PROBE_MIN_RANGE_M,
PROBE_CLEARANCE_M,
COVERAGE_NEIGHBOR_RADIUS_KM,
COVERAGE_NEIGHBOR_LIMIT,
IDLE_COVERAGE_COLOR,
} from './policy.js';
export function createGeometry({ state: layerState, services, parts, source }) {
const { warmGroundFloor, cachedGroundFloor } = services.ground;
const { sampleMeshFloorCells } = services.mesh;
/**
* V2 core geometry (design §2a): computes the pitched frustum pyramid — mount
* point, far-cap (monitor plane) center, and the 4 far-plane corners — purely
* from the calibrated pose + a caller-supplied ground altitude. ZERO scene
* queries: ground sampling happens in the caller (one-shot snap), and the
* obstruction probe passes its clamp in as `rangeOverrideM`.
*
* Math (spherical small-angle offsets; sub-centimetre at ≤2.2 km ranges):
* mountAlt = groundAltM + mountHeightM
* capCenter = projectPoint(heading, R·cos(pitch)) @ alt mountAlt + R·sin(pitch)
* halfW = R·tan(hFov/2)
* vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2)
* upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading
* corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset
* The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated
* pitch must never bury the plane's anchor); corners derive rigidly from the
* clamped center so the wireframe rays always terminate on the plane's
* corners — the bottom pair may dip below ground (tiles occlude it).
*
* @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg,
* rangeM, mountHeightM.
* @param {number} groundAltM - Ground altitude at the mount (metres).
* @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the
* effective range (never lengthens it).
* @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number,
* mount: {lat:number,lon:number,alt:number},
* capCenter: {lat:number,lon:number,alt:number},
* corners: { tl: Object, tr: Object, br: Object, bl: Object },
* topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }}
*/
function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) {
const ground = parts.model.safeNumber(groundAltM, 0);
const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700));
const override = parts.model.safeNumber(rangeOverrideM, NaN);
const R =
Number.isFinite(override) && override > 0
? Math.min(poseRange, override)
: poseRange;
const pitch = parts.model.toRad(
parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89),
);
const hFov = parts.model.toRad(
parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160),
);
const heading = parts.model.safeNumber(camera.headingDeg, 0);
const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24);
const horiz = R * Math.cos(pitch);
const vert = R * Math.sin(pitch);
const capLL = parts.model.projectPoint(
camera.lat,
camera.lon,
heading,
horiz,
);
const capAlt = mountAlt + vert;
const halfW = R * Math.tan(hFov / 2);
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT);
const halfH = R * Math.tan(vFovRad / 2);
// In-plane "up" of the pitched cap, decomposed into a vertical part and a
// horizontal part along the heading (pitch < 0 tilts the cap's top forward).
const upVert = Math.cos(pitch) * halfH;
const upHoriz = -Math.sin(pitch) * halfH;
const capL = parts.model.projectPoint(
capLL.lat,
capLL.lon,
heading - 90,
halfW,
);
const capR = parts.model.projectPoint(
capLL.lat,
capLL.lon,
heading + 90,
halfW,
);
// Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch
// never buries the plane's anchor — then derive the corners RIGIDLY from the
// lifted center. Clamping each corner independently flattened the wireframe
// into a ground-hugging fan while the rigid plane kept its height (owner
// field test 2026-07-04): the corner rays must always terminate exactly on
// the monitor plane's corners. The bottom pair may dip below ground; the 3D
// tiles occlude that portion, exactly as they do for the plane itself.
const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M;
const capAltClamped = Math.max(minAlt, capAlt);
const corner = (base, sign) => {
const ll = parts.model.projectPoint(
base.lat,
base.lon,
heading,
sign * upHoriz,
);
return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert };
};
const topCenter = corner(capLL, 1);
return {
rangeM: R,
vFovDeg: Cesium.Math.toDegrees(vFovRad),
halfW,
halfH,
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped },
corners: {
tl: corner(capL, 1),
tr: corner(capR, 1),
br: corner(capR, -1),
bl: corner(capL, -1),
},
topCenter,
groundAltM: ground,
};
}
/**
* Resolves the obstruction probe's effective-range clamp just short of a hit,
* with the field-derived 12 m floor used by the original H6 monitor.
* @param {number} rangeM Nominal camera range.
* @param {number} hitDistanceM Distance to the first obstruction.
* @returns {number|null} Clamp range, or null when the hit does not shorten it.
*/
function activationProbeClampRange(rangeM, hitDistanceM) {
const nominalRange = Number(rangeM);
const hitDistance = Number(hitDistanceM);
if (!Number.isFinite(nominalRange) || nominalRange <= 0) return null;
if (
!Number.isFinite(hitDistance) ||
hitDistance <= 0 ||
hitDistance >= nominalRange
)
return null;
return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M);
}
/**
* Converts a computeFrustumGeometry result into the Cartesian3 positions the
* entities consume. Fresh objects per call (geometry updates are rare —
* slider/save/activation — and entities must never share scratch objects).
* @param {Object} geometry - Result of computeFrustumGeometry.
* @returns {{ mount: Cesium.Cartesian3, capCenter: Cesium.Cartesian3,
* tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, br: Cesium.Cartesian3,
* bl: Cesium.Cartesian3, label: Cesium.Cartesian3 }}
*/
function frustumCartesians(geometry) {
const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt);
return {
mount: at(geometry.mount),
capCenter: at(geometry.capCenter),
tl: at(geometry.corners.tl),
tr: at(geometry.corners.tr),
br: at(geometry.corners.br),
bl: at(geometry.corners.bl),
label: Cesium.Cartesian3.fromDegrees(
geometry.topCenter.lon,
geometry.topCenter.lat,
geometry.topCenter.alt + 1.2,
),
};
}
/**
* Unit ECEF direction of the frustum view axis (heading/pitch) at a position.
* Used by the plane orientation and the activation obstruction probe — both
* need the UNCLAMPED axis, so it comes from the pose, not from clamped points.
* @param {Object} camera - Camera pose (headingDeg, pitchDeg).
* @param {Cesium.Cartesian3} atPos - ECEF position defining the local ENU frame.
* @returns {{ dir: Cesium.Cartesian3, up: Cesium.Cartesian3 }} View axis + the
* frame's in-plane "up" (both unit, mutually perpendicular).
*/
function frustumFrameEcef(camera, atPos) {
const h = parts.model.toRad(camera.headingDeg);
const p = parts.model.toRad(camera.pitchDeg);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(atPos);
const rot = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3());
// ENU components: view axis + the perpendicular "up" of the pitched cap.
const dirEnu = new Cesium.Cartesian3(
Math.sin(h) * Math.cos(p),
Math.cos(h) * Math.cos(p),
Math.sin(p),
);
const upEnu = new Cesium.Cartesian3(
-Math.sin(p) * Math.sin(h),
-Math.sin(p) * Math.cos(h),
Math.cos(p),
);
return {
dir: Cesium.Matrix3.multiplyByVector(
rot,
dirEnu,
new Cesium.Cartesian3(),
),
up: Cesium.Matrix3.multiplyByVector(rot, upEnu, new Cesium.Cartesian3()),
};
}
/**
* Recomputes the pure frustum geometry from the camera pose + the given ground
* altitude and writes it into the scene: billboard position, the 5 wireframe
* polylines (4 corner rays + closed far-plane rectangle), and the monitor
* plane placement. This is the ONLY place v2 geometry is written — called on
* slider input / save / activation / the one-shot ground snap, never per frame.
* @param {Object} record - Camera record.
* @param {number} groundAltM - Ground altitude at the mount (metres).
*/
function applyFrustumGeometry(record, groundAltM) {
const geometry = computeFrustumGeometry(
record.camera,
groundAltM,
record.probeClampRangeM,
);
const positions = frustumCartesians(geometry);
record.frustumGeometry = geometry;
record.frustumPositions = positions;
record.position = positions.mount;
record.camera.absoluteHeightM = geometry.mount.alt;
if (record.billboard) {
record.billboard.position = positions.mount;
}
if (record.coverageEntities?.length >= 5) {
record.coverageEntities[0].polyline.positions = [
positions.mount,
positions.tl,
];
record.coverageEntities[1].polyline.positions = [
positions.mount,
positions.tr,
];
record.coverageEntities[2].polyline.positions = [
positions.mount,
positions.br,
];
record.coverageEntities[3].polyline.positions = [
positions.mount,
positions.bl,
];
record.coverageEntities[4].polyline.positions = [
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
];
}
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
// positions (weld invariant). Only records currently showing a volume pay
// this (6 triangles, synchronous — trivial even during slider/gizmo drags).
// Tint derives from the live active id, not the cached viewshedActiveTint —
// during an activation switch this runs BEFORE refreshCoverageStyles, and
// the cache is stale for exactly that window.
if (record.viewshedPrimitive) {
rebuildViewshedVolume(
record,
record.camera.id === layerState._activeCameraId,
);
}
parts.projection.updatePlanePlacement(record);
// Gizmo handles track the pose they manipulate: refresh when the ACTIVE
// camera's geometry rewrites (incl. during its own drag).
if (
layerState._gizmo?.isEnabled() &&
record.camera.id === layerState._activeCameraId
) {
layerState._gizmo.refresh();
}
}
/**
* Refreshes a record's frustum geometry with a regime-aware, ONE-SHOT ground
* resolution (Task 5, spec §2). Per regime:
*
* - `terrain-globe` (any globe stack): `cachedGroundFloor` returns its DEM
* floor because mesh floors are regime-disabled. The exact Re:Earth prior
* remains the immediate fallback while that coarse cell warms.
* - `google-3d` (photoreal): the shared mesh-floor sampler may refine the
* DEM cell once, subject to its existing tiles-ready, distance,
* camera-height, and acceptance gates. Geometry reads only
* `cachedGroundFloor`, never a CCTV-owned point sample.
*
* v2 samples ONLY the mount — the far cap hangs in the air off mountAlt. No
* timer, no deadband: this function is called only from the staggered
* geometry queue (the enable-time drain + update()'s one-shot tiles-ready
* completion re-enqueue), from explicit pose-edit call sites, and from the
* map-stack regime-change handler.
* @param {Object} record - Camera record.
* @param {Object} [options={}]
* @param {boolean} [options.sampleGround=true] - When false, skip shared
* mesh-floor refinement and use the cached/prior ground instead.
*/
function updateRecordGeometry(record, options = {}) {
const sampleGround = options.sampleGround !== false;
const regime = parts.ground.currentSurfaceRegime();
const point = { lat: record.camera.lat, lon: record.camera.lon };
warmGroundFloor([point]);
if (regime === 'terrain-globe') {
const cachedFloor = cachedGroundFloor(point.lat, point.lon);
const ground = Number.isFinite(cachedFloor)
? cachedFloor
: parts.ground.groundPriorAltFor(record);
record.groundSamples['terrain-globe'] = ground;
record.groundResolved['terrain-globe'] = true;
applyFrustumGeometry(record, ground);
return;
}
// Photoreal regime. Sampling is delegated to the shared coarse-cell
// sampler. It remains event-driven, one-shot per cell, and keeps its
// existing acceptance window; CCTV adds no rooftop rejection policy.
if (sampleGround && parts.model.projectionTilesReady()) {
record.groundMeshSampleRequestCount =
(record.groundMeshSampleRequestCount || 0) + 1;
const viewerCarto = layerState._viewer?.camera?.positionCartographic;
const excludeObjects = [...(record.coverageEntities || [])];
if (record.billboard) excludeObjects.push(record.billboard);
if (record.projection?.planeEntity)
excludeObjects.push(record.projection.planeEntity);
sampleMeshFloorCells(layerState._viewer?.scene, [point], {
excludeObjects: excludeObjects.filter(Boolean),
viewerLat: viewerCarto
? Cesium.Math.toDegrees(viewerCarto.latitude)
: undefined,
viewerLon: viewerCarto
? Cesium.Math.toDegrees(viewerCarto.longitude)
: undefined,
});
}
const cachedFloor = cachedGroundFloor(point.lat, point.lon);
const ground = Number.isFinite(cachedFloor)
? cachedFloor
: parts.ground.groundAltFor(record, 'google-3d');
applyFrustumGeometry(record, ground);
record.groundResolved['google-3d'] = Number.isFinite(cachedFloor);
if (Number.isFinite(cachedFloor)) {
record.groundSamples['google-3d'] = ground;
}
}
/**
* Determines which camera coverage overlays should be visible based on
* proximity to the active camera. Limits visibility to
* COVERAGE_NEIGHBOR_LIMIT cameras within COVERAGE_NEIGHBOR_RADIUS_KM.
* @param {Object|null} activeRecord - The active camera record.
* @returns {Set<string>} Set of visible camera IDs.
*/
function buildCoverageVisibleSet(activeRecord) {
if (!layerState._records.length) return new Set();
// Coverage emphasis is relative to a selected camera. Without one, do not
// invent an arbitrary catalog-order cohort.
if (!activeRecord) return new Set();
const ranked = layerState._records.map((record) => {
if (record === activeRecord) {
return { record, distKm: -1 };
}
return {
record,
distKm: parts.model.haversineKm(
activeRecord.camera.lat,
activeRecord.camera.lon,
record.camera.lat,
record.camera.lon,
),
};
});
ranked.sort((a, b) => a.distKm - b.distKm);
const primary = ranked.filter(
(entry) =>
entry.distKm <= COVERAGE_NEIGHBOR_RADIUS_KM || entry.distKm === -1,
);
const fallback = ranked;
const chosen = (
primary.length >= COVERAGE_NEIGHBOR_LIMIT ? primary : fallback
)
.slice(0, COVERAGE_NEIGHBOR_LIMIT)
.map((entry) => entry.record.camera.id);
return new Set(chosen);
}
/**
* Removes (and destroys) a record's viewshed volume primitive, if any.
* @param {Object} record - Camera record.
*/
function destroyViewshedVolume(record) {
if (!record) return;
if (record.viewshedPrimitive && layerState._viewer) {
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive);
}
record.viewshedPrimitive = null;
}
/**
* (Re)builds a record's translucent viewshed volume from its CURRENT
* frustumPositions — the same 5 Cartesians the wireframe draws, so the volume
* is welded to the cone by construction. Called only where the wireframe
* already rewrites (style refresh on mode/visible-set/active changes,
* applyFrustumGeometry on pose edits) — no new update cadence, zero scene
* queries (viewshed design §3b).
* @param {Object} record - Camera record.
* @param {boolean} isActive - Active camera gets the brighter fill.
*/
function rebuildViewshedVolume(record, isActive) {
destroyViewshedVolume(record);
if (
!layerState._viewer ||
!record?.frustumPositions ||
!record.viewshedColors
)
return;
const color = isActive
? record.viewshedColors.fillActive
: record.viewshedColors.fill;
const primitive = createFrustumVolumePrimitive(
record.frustumPositions,
color,
);
// QA tag: the harness counts viewshed volumes by this marker.
primitive._gevViewshed = record.camera.id;
record.viewshedPrimitive =
layerState._viewer.scene.primitives.add(primitive);
record.viewshedActiveTint = !!isActive;
}
/**
* Counts how many other cameras have overlapping coverage with the target.
* Overlap is approximated by comparing inter-camera distance against the
* combined range of both cameras (scaled by 0.92).
* @param {Object} targetRecord - Camera record to check.
* @returns {number} Number of overlapping neighbors.
*/
function coverageNeighborCount(targetRecord) {
if (!targetRecord) return 0;
let count = 0;
for (const record of layerState._records) {
if (record === targetRecord) continue;
const dKm = parts.model.haversineKm(
targetRecord.camera.lat,
targetRecord.camera.lon,
record.camera.lat,
record.camera.lon,
);
const overlapKm =
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
if (dKm <= overlapKm) count++;
}
return count;
}
/**
* §9.1 activation obstruction probe (LOCKED owner decision): on camera
* ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis
* (mount → cap-center direction). If it hits the tiles closer than the pose
* range, clamp the plane's effective range just short of the first hit so the
* "big and dramatic" true end cap never clips into downtown buildings.
*
* This is the ONLY raycast in the whole CCTV subsystem — once per activation,
* never per-frame (the zero-raycast invariant applies to steady state). The
* per-camera range slider overrides the clamp: a user-set rangeScale skips the
* probe entirely. Probe failure/miss keeps the unclamped range.
* @param {Object} record - Camera record being activated.
*/
function runActivationObstructionProbe(record) {
record.probeClampRangeM = null;
const scene = layerState._viewer?.scene;
if (!scene || typeof scene.pickFromRay !== 'function') return;
const camera = record.camera;
const rangeScale = parts.calibration.normalizeCalibration(
camera.calibration,
).rangeScale;
if (Math.abs(rangeScale - 1) > 0.0001) return; // slider overrides the clamp
try {
const mountAlt = parts.ground.groundAltFor(record) + camera.mountHeightM;
const mountPos = Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
mountAlt,
);
const { dir } = frustumFrameEcef(camera, mountPos);
// Exclude everything the layer itself draws so the probe can only hit the
// world (3D tiles), not our own billboards/polylines/planes.
const exclude = [layerState._billboards, ...layerState._coverageEntities];
for (const runtime of layerState._projectionEntities) {
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
}
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
if (!hit?.position) return;
const dist = Cesium.Cartesian3.distance(mountPos, hit.position);
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
} catch {
// probe failure → keep the unclamped range
}
}
/**
* Clears a deactivated camera's temporary obstruction clamp and rewrites its
* geometry through the normal single-range path.
* @param {Object|null} record Camera runtime record being deactivated.
* @param {(record: Object) => void} rewriteGeometry Nominal geometry rewrite.
* @returns {boolean} Whether a clamp was cleared.
*/
function clearProbeClampOnDeactivation(record, rewriteGeometry) {
if (!record || !Number.isFinite(record.probeClampRangeM)) return false;
record.probeClampRangeM = null;
rewriteGeometry?.(record);
return true;
}
/**
* Creates the five Cesium polyline entities that visualize a camera's pitched
* frustum: 4 corner rays (mount → far-plane corner) + the closed far-plane
* rectangle. Entity ids stay in the `cctv-<id>-<role>` scheme (pick-owner
* regex depends on it): roles ray-tl / ray-tr / ray-br / ray-bl / cap.
* @param {Object} record - Camera record.
* @returns {Cesium.Entity[]} Array of five coverage entities.
*/
function buildCoverageEntities(record) {
const { camera } = record;
// Prefer the record's already-refined geometry. Lazy creation commonly
// happens after the staggered ground pass; recomputing from the catalog
// prior here would regress the camera to its pre-sampled datum.
let geometry = record.frustumGeometry;
let positions = record.frustumPositions;
if (!geometry || !positions) {
geometry = computeFrustumGeometry(
camera,
parts.ground.groundPriorAltFor(record),
record.probeClampRangeM,
);
positions = frustumCartesians(geometry);
record.frustumGeometry = geometry;
record.frustumPositions = positions;
record.position = positions.mount;
}
const addPolyline = (role, linePositions) =>
layerState._viewer.entities.add({
id: `cctv-${camera.id}-${role}`,
properties: { cctvCameraId: camera.id },
polyline: {
positions: linePositions,
width: 1.2,
material: IDLE_COVERAGE_COLOR,
},
});
const entities = [
addPolyline('ray-tl', [positions.mount, positions.tl]),
addPolyline('ray-tr', [positions.mount, positions.tr]),
addPolyline('ray-br', [positions.mount, positions.br]),
addPolyline('ray-bl', [positions.mount, positions.bl]),
addPolyline('cap', [
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
]),
];
entities[0]._coverageRole = 'edge';
entities[1]._coverageRole = 'edge';
entities[2]._coverageRole = 'edge';
entities[3]._coverageRole = 'edge';
entities[4]._coverageRole = 'cap';
return entities;
}
/**
* Materializes coverage entities for eligible records exactly once.
* The helper is dependency-injected so unit tests can prove the enable policy
* without constructing Cesium entities.
*
* @param {Object[]} records CCTV runtime records.
* @param {(record: Object) => boolean} [isEligible] Eligibility predicate.
* @param {(record: Object) => Object[]} buildEntities Coverage builder.
* @returns {Object[]} Newly created entities across all eligible records.
*/
function materializeCctvCoverageEntities(
records,
isEligible = () => true,
buildEntities,
) {
const created = [];
if (typeof buildEntities !== 'function') return created;
for (const record of Array.isArray(records) ? records : []) {
if (!record || record.coverageEntities?.length || !isEligible(record))
continue;
const entities = buildEntities(record);
record.coverageEntities = Array.isArray(entities)
? entities.filter(Boolean)
: [];
created.push(...record.coverageEntities);
}
return created;
}
/** Materializes one active camera's coverage set. */
function materializeCctvActiveCoverageEntities(record, buildEntities) {
return materializeCctvCoverageEntities([record], () => true, buildEntities);
}
/** Materializes only records in the current coverage-visible ID set. */
function materializeCctvVisibleCoverageEntities(
records,
visibleIds,
buildEntities,
) {
const eligibleIds =
visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []);
return materializeCctvCoverageEntities(
records,
(record) => eligibleIds.has(record.camera?.id),
buildEntities,
);
}
/** Registers newly built coverage entities with the layer-global collection. */
function registerCoverageEntities(created) {
layerState._coverageEntities.push(...created);
return created;
}
function ensureActiveCoverageEntities(record) {
return registerCoverageEntities(
materializeCctvActiveCoverageEntities(record, buildCoverageEntities),
);
}
function ensureVisibleCoverageEntities(records, visibleIds) {
return registerCoverageEntities(
materializeCctvVisibleCoverageEntities(
records,
visibleIds,
buildCoverageEntities,
),
);
}
/**
* Removes all coverage entities and projection runtimes from the scene.
*/
function destroyCoverageEntities() {
if (!layerState._viewer) return;
for (const entity of layerState._coverageEntities) {
layerState._viewer.entities.remove(entity);
}
layerState._coverageEntities = [];
for (const record of layerState._records) {
destroyViewshedVolume(record);
}
for (const runtime of layerState._projectionEntities) {
parts.projection.destroyProjectionRuntime(runtime);
}
layerState._projectionEntities = [];
}
return {
computeFrustumGeometry,
activationProbeClampRange,
frustumCartesians,
frustumFrameEcef,
applyFrustumGeometry,
updateRecordGeometry,
buildCoverageVisibleSet,
destroyViewshedVolume,
rebuildViewshedVolume,
coverageNeighborCount,
runActivationObstructionProbe,
clearProbeClampOnDeactivation,
buildCoverageEntities,
materializeCctvCoverageEntities,
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
registerCoverageEntities,
ensureActiveCoverageEntities,
ensureVisibleCoverageEntities,
destroyCoverageEntities,
};
}
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff. Show more