mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge branch 'main' into fix/antimeridian-traffic-bounds
This commit is contained in:
405 files changed
+84572
-55443
No files matched your search
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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\(\{/);
|
||||
|
||||
@@ -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'\)/);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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\]/,
|
||||
|
||||
@@ -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*=/);
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+87
-4823
File diff suppressed because it is too large.
Load diff
+18
-13
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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*\);/);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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(); }
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+6
-161
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
+42
-1944
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
|
||||
+65
-3876
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+51
-2394
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;/,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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;'),
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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';
|
||||
Vendored
+126
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,15 @@
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const methods = {
|
||||
update() {
|
||||
parts.subject.refreshSelectedSubject();
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+46
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+352
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user