refactor: separate installation and proximity context components

This commit is contained in:
Sameh Khamis committed 2026-09-13 13:36:32 -07:00
1 parent 287493dfb7
commit 3b77277883
39 files changed
+4151 -2632

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
export function createControls({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
const aisLiveVesselsLayer = services.vessels;
const methods = {
id: 'military-awareness',
name: 'Global Context',
icon: '◎',
source: 'Open-source proximity context',
// Context is entered from its dedicated right rail, not as a raw layer.
showInTogglePanel: false,
updateInterval: 0,
statsRefreshInterval: 1000,
attachDataManager(dataManager) {
layerState.dataManager = dataManager;
},
setParams(params = {}) {
if (typeof params.passive !== 'boolean') return;
const wasPassive = layerState.passive;
layerState.passive = params.passive;
if (layerState.enabled && wasPassive && !layerState.passive)
parts.dependencies.activateOperationalContext();
},
/** @returns {{ passive: boolean }} Current runtime parameters. */
getParams() {
return { passive: layerState.passive };
},
getStats() {
return {
count: layerState.results ? 1 : 0,
lastUpdate: layerState.results?.evaluatedAt || null,
stale: false,
error: null,
status: layerState.enabled ? 'ready' : 'idle',
};
},
/** Return the latest read-only context result for compact HUD consumers. */
getContextSnapshot() {
if (!layerState.enabled || !layerState.subject) return null;
if (!layerState.results) {
return parts.model.buildAwarenessContextSnapshot(
{
subject: { ...layerState.subject },
evaluatedAt: null,
radiusM: AWARENESS_RADIUS_M,
cohorts: [],
},
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
}
return parts.model.buildAwarenessContextSnapshot(
layerState.results,
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
},
/**
* Release Contact-owned camera tracking without discarding the selected
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
* can explicitly return to the same contact, while delayed activation work
* cannot silently reclaim the camera after the reset.
* @returns {boolean} Whether a Contact subject remains selected.
*/
releaseCameraOwnership({
preserveVesselSelection = false,
origin = 'programmatic',
} = {}) {
++layerState.activationId;
layerState.autoFocusAttempted = true;
layerState.autoFocusRetryPending = false;
const preservedSelectionKey =
parts.subject.subjectKey(layerState.subject) || 'camera-release';
layerState.pendingSelectionKey = preservedSelectionKey;
try {
flightsLayer.stopTracking?.({ origin });
militaryFlightsLayer.stopTracking?.({ origin });
if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.();
} finally {
if (layerState.pendingSelectionKey === preservedSelectionKey) {
layerState.pendingSelectionKey = null;
}
}
return Boolean(layerState.subject);
},
navigatePrevious(options = {}) {
return parts.history.navigateHistory(-1, options);
},
focusCurrent(options = {}) {
return parts.focus.focusCurrentSubject(options);
},
navigateNext(options = {}) {
return parts.history.navigateHistory(1, options);
},
/** Select a context target through its owning layer's established tracker. */
focusTarget(layerId, id, options = {}) {
return parts.focus.requestFocus(layerId, id, false, options);
},
};
return { methods };
}
+118
View File
@@ -0,0 +1,118 @@
import { DEPENDENCIES, AIRCRAFT_DEPENDENCIES } from './policy.js';
export function createDependencies({
state: layerState,
services,
parts,
source,
}) {
function refreshAfterDeferredDependency(activationId) {
if (
!layerState.enabled ||
layerState.passive ||
activationId !== layerState.activationId
)
return;
if (layerState.subject) {
parts.subject.refreshSelectedSubject(true);
return;
}
if (layerState.autoFocusRetryPending) {
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
}
}
async function enableDependencies(activationId = layerState.activationId) {
const aircraftPending = [];
for (const layerId of DEPENDENCIES) {
if (!layerState.enabled) return;
if (!layerState.dataManager) continue;
const alreadyEnabled =
layerState.dataManager.isEffectivelyEnabled?.(layerId) ??
layerState.dataManager.isEnabled(layerId);
if (alreadyEnabled) continue;
layerState.ownedDependencies.add(layerId);
// Each layer serializes its own lifecycle internally. Start them together
// so a slow or unavailable live feed cannot prevent the remaining context
// sources from turning on.
let pending;
try {
// Register the ON intent synchronously. If Contacts is turned OFF in the
// same turn, DataLayerManager then sees OFF as the newest intent and a
// stale deferred ON cannot resurrect a released dependency.
pending = Promise.resolve(
layerState.dataManager.setEnabled(layerId, true),
);
} catch (error) {
pending = Promise.reject(error);
}
if (AIRCRAFT_DEPENDENCIES.includes(layerId)) {
aircraftPending.push(pending);
} else {
// Vessels and mapped installations enrich an already usable aircraft
// context. Their settlement must never hold the Contacts transaction or
// Cockpit gate open, and the activation token prevents a late result
// from repainting or refocusing a newer/closed session.
pending.then(
() => refreshAfterDeferredDependency(activationId),
() => refreshAfterDeferredDependency(activationId),
);
}
}
await Promise.allSettled(aircraftPending);
}
function releaseOwnedDependencies(releaseActivationId) {
const owned = [...layerState.ownedDependencies];
layerState.ownedDependencies.clear();
if (!layerState.dataManager || !owned.length) return Promise.resolve();
return Promise.allSettled(
owned.map((layerId) => layerState.dataManager.setEnabled(layerId, false)),
).then(() => {
if (
layerState.enabled &&
layerState.activationId !== releaseActivationId
) {
return enableDependencies(layerState.activationId);
}
return null;
});
}
function activateOperationalContext() {
const activationId = ++layerState.activationId;
layerState.autoFocusRetryPending = false;
const initialTrackedSubject = parts.subject.currentTrackedFlightSubject();
if (initialTrackedSubject)
parts.subject.selectSubject(initialTrackedSubject);
return enableDependencies(activationId)
.then(() => {
if (
!layerState.enabled ||
layerState.passive ||
activationId !== layerState.activationId
)
return;
const trackedSubject = parts.subject.currentTrackedFlightSubject();
if (trackedSubject) {
parts.subject.selectSubject(trackedSubject);
return;
}
if (layerState.subject) {
parts.subject.selectSubject(layerState.subject);
return;
}
if (layerState.autoFocusAttempted) return;
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
})
.catch((error) =>
console.warn('[Global Context] dependency enable failed', error),
);
}
return {
refreshAfterDeferredDependency,
enableDependencies,
releaseOwnedDependencies,
activateOperationalContext,
};
}
+198
View File
@@ -0,0 +1,198 @@
import * as Cesium from 'cesium';
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
export function createFocus({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
const { announceNavigationAuthority } = services.navigation;
const militaryInstallationsLayer = services.installations;
const aisLiveVesselsLayer = services.vessels;
/** Focus a nearby example through the source layer that owns its selection. */
function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) {
if (!layerId || !id) return false;
if (layerId === 'flights') {
return (
flightsLayer.refocusTrackedById?.(id, { origin }) ||
flightsLayer.trackById(id, { origin })
);
}
if (layerId === 'military') {
return (
militaryFlightsLayer.refocusTrackedById?.(id, { origin }) ||
militaryFlightsLayer.trackById(id, { origin })
);
}
if (layerId === 'military-installations') {
if (!parts.model.contextTargetFlyToAllowed(layerId))
return selectKnownContextTarget(layerId, id);
// focusById flies the camera; it never assigns a tracked entity, so the
// stamp has to be announced here.
announceNavigationAuthority('context-installation-focus');
return militaryInstallationsLayer.focusById(id);
}
if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id))
return false;
const vessel = aisLiveVesselsLayer
.getAllPositions(12000)
.find((item) => String(item.id) === String(id));
if (
!parts.model.contextTargetFlyToAllowed(layerId) ||
!vessel?.position ||
!layerState.viewer
)
return true;
announceNavigationAuthority('context-vessel-focus');
layerState.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
{ duration: 1.4 },
);
return true;
}
function selectKnownContextTarget(layerId, id) {
const key = `${layerId}:${id}`;
const known = [
layerState.subject,
...layerState.navigationHistory,
...(layerState.results?.cohorts || []).flatMap((cohort) =>
cohort.id === layerId
? cohort.summary.navigationNearest || cohort.summary.nearest || []
: [],
),
].find(
(item) =>
item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key,
);
if (!known?.position) return false;
if (parts.model.contextTargetFlyToAllowed(layerId))
parts.model.releaseAircraftTracking();
parts.subject.selectSubject({
...known,
layerId,
id: String(known.id || known.mmsi),
label: known.label || known.name || known.callsign || String(id),
position: Cesium.Cartesian3.clone(known.position),
});
return true;
}
function requestFocus(
layerId,
id,
preserveHistory = false,
{ origin = 'programmatic' } = {},
) {
const key = `${layerId}:${id}`;
layerState.pendingSelectionKey = key;
if (preserveHistory) layerState.suppressedHistoryKey = key;
const focused = focusNearbyTarget(layerId, id, { origin });
layerState.pendingSelectionKey = null;
layerState.suppressedHistoryKey = null;
return focused;
}
function focusSubject(subject, preserveHistory = false, options = {}) {
if (!subject) return false;
return requestFocus(subject.layerId, subject.id, preserveHistory, options);
}
function focusCurrentSubject(options = {}) {
return focusSubject(layerState.subject, true, options);
}
/**
* Pick the observed candidate closest to the current view. This is intentionally
* a navigation preference, not a risk, capability, or affiliation calculation.
* @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates.
* @returns {Object|null} The best currently observable candidate.
*/
function closestToCurrentView(candidates) {
if (!Array.isArray(candidates) || !candidates.length) return null;
const cameraPosition = layerState.viewer?.camera?.positionWC;
if (!cameraPosition)
return candidates.find((candidate) => candidate?.position) || null;
let closest = null;
let closestDistance = Infinity;
for (const candidate of candidates) {
if (!candidate?.position) continue;
const distance = Cesium.Cartesian3.distance(
cameraPosition,
candidate.position,
);
if (Number.isFinite(distance) && distance < closestDistance) {
closest = candidate;
closestDistance = distance;
}
}
return closest;
}
/**
* Focus a single observed, context-priority target after activation. Civilian
* and military aircraft compete in one nearest-to-view pool; military is
* concatenated first so exact distance ties resolve to military under the
* strict comparison. An AIS vessel is only a fallback and is never inferred
* to be military. There is deliberately no distance cap.
* @returns {boolean} Whether a target was selected and framed.
*/
function focusAttentionTarget() {
if (
!layerState.enabled ||
layerState.subject ||
!layerState.viewer ||
layerState.autoFocusAttempted
)
return false;
const nearestFlight = closestToCurrentView([
...militaryFlightsLayer
.getAllPositions(800)
.map((item) => ({ ...item, layerId: 'military' })),
...flightsLayer
.getAllPositions(1000)
.map((item) => ({ ...item, layerId: 'flights' })),
]);
if (nearestFlight) {
const layer =
nearestFlight.layerId === 'military'
? militaryFlightsLayer
: flightsLayer;
if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) {
layerState.autoFocusAttempted = true;
return true;
}
}
const vessel = closestToCurrentView(
aisLiveVesselsLayer.getAllPositions(12000),
);
if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false;
layerState.autoFocusAttempted = true;
if (!parts.model.contextTargetFlyToAllowed('ais-live-vessels')) return true;
announceNavigationAuthority('context-vessel-autofocus', {
cancelPendingSelection: false,
});
layerState.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
{ duration: 1.6 },
);
return true;
}
return {
focusNearbyTarget,
selectKnownContextTarget,
requestFocus,
focusSubject,
focusCurrentSubject,
closestToCurrentView,
focusAttentionTarget,
};
}
+239
View File
@@ -0,0 +1,239 @@
export function createHistory({ state: layerState, services, parts, source }) {
const militaryFlightsLayer = services.military;
const flightsLayer = services.flights;
/**
* Walk navigation history for the next entry that can still be focused.
*
* A history entry only holds a SNAPSHOT of a contact; the live contact behind
* it can be evicted from its layer at any time (the cull that drives
* CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the
* only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead
* entry strands the operator there for the rest of the session, because the
* index never advances past it.
*
* `state.navigationIndex` moves only on a successful focus: a caller that
* falls through to the live cohort must still splice forward history from the
* position the operator actually occupies.
* @param {number} direction Walk direction (negative = previous).
* @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters.
* @returns {boolean} Whether a history entry was focused.
*/
function focusCompatibleHistory(
direction,
{ targetLayer, aircraftClass, aircraftOnly, origin = 'programmatic' },
) {
let cursor = layerState.navigationIndex;
for (;;) {
const historyIndex = findCompatibleHistoryIndex(
layerState.navigationHistory,
cursor,
direction,
{
targetLayer,
aircraftClass,
aircraftOnly,
resolveItem: historySubjectItem,
},
);
if (historyIndex === -1) return false;
if (
parts.focus.focusSubject(
layerState.navigationHistory[historyIndex],
true,
{ origin },
)
) {
layerState.navigationIndex = historyIndex;
return true;
}
// Contact evicted since it entered history — step over it. The scan is
// strictly monotonic, so this terminates at the end of history.
cursor = historyIndex;
}
}
function navigateHistory(
direction,
{
targetLayer = null,
aircraftClass = null,
aircraftOnly = false,
origin = 'programmatic',
} = {},
) {
if (
focusCompatibleHistory(direction, {
targetLayer,
aircraftClass,
aircraftOnly,
origin,
})
)
return true;
if (direction < 0) return false;
let targets = parts.navigation.selectNavigationTargets(
layerState.results?.cohorts,
layerState.subject,
[...layerState.navigationVisited],
{ targetLayer, aircraftClass, aircraftOnly },
);
const targetHistoryFullyVisited =
targets.length > 0 && targets.every((target) => target.visited);
const subjectCohortUnknown = parts.subject.subjectCohortFeedUnknown();
if (
targetHistoryFullyVisited &&
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
!subjectCohortUnknown
) {
const expanded = parts.navigation.findExpandedFlightTarget({
targetLayer,
aircraftClass,
});
if (expanded) {
return parts.focus.requestFocus(
expanded.candidate.layerId,
expanded.candidate.id,
false,
{ origin },
);
}
}
if (targetHistoryFullyVisited) {
parts.navigation.resetNavigationVisitedCycle();
targets = parts.navigation.selectNavigationTargets(
layerState.results?.cohorts,
layerState.subject,
[...layerState.navigationVisited],
{ targetLayer, aircraftClass, aircraftOnly },
);
}
const hasNearbyFlight = targets.some((target) =>
parts.navigation.isFlightLayer(target.layerId),
);
if (
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
!hasNearbyFlight &&
!subjectCohortUnknown
) {
const expanded = parts.navigation.findExpandedFlightTarget({
targetLayer,
aircraftClass,
});
if (expanded) {
return parts.focus.requestFocus(
expanded.candidate.layerId,
expanded.candidate.id,
false,
{ origin },
);
}
}
if (parts.subject.subjectCohortFeedUnknown()) return false;
const candidate = targets[0];
if (!candidate) return false;
return parts.focus.requestFocus(candidate.layerId, candidate.id, false, {
origin,
});
}
function historySubjectItem(subject) {
if (!subject) return null;
const cohort = layerState.results?.cohorts?.find(
(item) => item.id === subject.layerId,
);
const items =
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
return (
items.find(
(item) =>
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
) || subject
);
}
/** Retain filter metadata when a production subject enters navigation history. */
function historySubjectSnapshot(subject, sourceItem = null) {
const aircraftClass =
sourceItem?.aircraftClass ||
sourceItem?.klass ||
sourceItem?.type ||
subject?.aircraftClass ||
null;
return aircraftClass ? { ...subject, aircraftClass } : { ...subject };
}
function historySourceItem(subject) {
if (!parts.navigation.isFlightLayer(subject?.layerId) || !subject?.position)
return null;
// The current sweep already holds this contact's record. Reusing it keeps
// selection O(1) against the cohort instead of paying a fresh full-layer
// proximity scan per selection — the burst cost when NEXT walks a cohort.
const cohort = layerState.results?.cohorts?.find(
(item) => item.id === subject.layerId,
);
const items =
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
const fromSweep = items.find(
(item) => String(item?.icao24 || item?.id) === String(subject.id),
);
if (fromSweep) return fromSweep;
const layer =
subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer;
return (
layer
.getNearby(subject.position, 1000, 25, { includeHidden: true })
.find(
(item) => String(item?.icao24 || item?.id) === String(subject.id),
) || null
);
}
/** Find the next history entry compatible with requested navigation filters. */
function findCompatibleHistoryIndex(
history,
startIndex,
direction,
{
targetLayer = null,
aircraftClass = null,
aircraftOnly = false,
resolveItem = (subject) => subject,
} = {},
) {
const step = direction < 0 ? -1 : 1;
for (
let index = startIndex + step;
index >= 0 && index < history.length;
index += step
) {
const subject = history[index];
if (aircraftOnly && !parts.navigation.isFlightLayer(subject?.layerId))
continue;
if (targetLayer && subject?.layerId !== targetLayer) continue;
if (
aircraftClass &&
!parts.navigation.aircraftClassMatchesFilter(
resolveItem(subject),
aircraftClass,
)
)
continue;
return index;
}
return -1;
}
return {
focusCompatibleHistory,
navigateHistory,
historySubjectItem,
historySubjectSnapshot,
historySourceItem,
findCompatibleHistoryIndex,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { createModel } from './model.js';
import { createSubject } from './subject.js';
import { createPanel } from './panel.js';
import { createQueries } from './queries.js';
import { createNavigation } from './navigation.js';
import { createFocus } from './focus.js';
import { createTesting } from './testing.js';
import { createHistory } from './history.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { createDependencies } from './dependencies.js';
import { createControls } from './controls.js';
import { createIngestion } from './ingestion.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createAwarenessLayer({ services, source }) {
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.model = createModel(context);
parts.subject = createSubject(context);
parts.panel = createPanel(context);
parts.queries = createQueries(context);
parts.navigation = createNavigation(context);
parts.focus = createFocus(context);
parts.testing = createTesting(context);
parts.history = createHistory(context);
parts.rendering = createRendering(context);
parts.lifecycle = createLifecycle(context);
parts.dependencies = createDependencies(context);
parts.controls = createControls(context);
parts.ingestion = createIngestion(context);
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion?.methods,
{
contextTargetFlyToAllowed: parts.model.contextTargetFlyToAllowed,
awarenessClearMatchesSubject: parts.subject.awarenessClearMatchesSubject,
awarenessRefreshIntervalMs: parts.model.awarenessRefreshIntervalMs,
awarenessRefreshDecision: parts.model.awarenessRefreshDecision,
awarenessClearIsEviction: parts.model.awarenessClearIsEviction,
awarenessRefreshRequired: parts.model.awarenessRefreshRequired,
summarizeInstallationViewport:
parts.queries.summarizeInstallationViewport,
contactsWindowFromSnapshot: parts.model.contactsWindowFromSnapshot,
buildAwarenessContextSnapshot: parts.model.buildAwarenessContextSnapshot,
collectAircraftProximityWindow:
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
},
);
}
export { AWARENESS_QUERY_LIMIT } from './policy.js';
+15
View File
@@ -0,0 +1,15 @@
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
const methods = {
update() {
parts.subject.refreshSelectedSubject();
return Promise.resolve();
},
};
return { methods };
}
+263
View File
@@ -0,0 +1,263 @@
import { cameraPoseSignature } from '../../data/iconOrientation.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
/**
* Quantized camera-pose signature, or '' when the camera cannot report a full
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
* up can never be mistaken for continuous movement.
* @param {Cesium.Camera|null|undefined} camera
* @returns {string}
*/
function cameraMotionSignature(camera) {
if (!camera?.positionWC || !Number.isFinite(camera.heading)) return '';
return cameraPoseSignature(camera);
}
/**
* Take or drop the continuous-render hold to match the current need. Idempotent
* (the governor is identity-keyed), and self-healing: the release decision is
* made on a frame the hold itself guaranteed, so a hold can never strand the
* scene in continuous mode.
* @returns {void}
*/
function syncAwarenessRenderHold() {
if (layerState.enabled && parts.model.awarenessNeedsContinuousRender())
holdContinuousRender('military-awareness');
else releaseContinuousRender('military-awareness');
}
function attachRuntimeListeners() {
if (layerState.runtimeListenersAttached || !layerState.viewer) return;
window.addEventListener(
'gev:awareness-subject-selected',
layerState.subjectListener,
);
window.addEventListener('gev:entity-selected', layerState.contextListener);
window.addEventListener(
'gev:entity-selection-cleared',
layerState.clearListener,
);
window.addEventListener(
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
layerState.preRenderRemover =
layerState.viewer.scene.preRender.addEventListener(() => {
if (!layerState.enabled) return;
const now = Date.now();
// "Did the camera move" uses the SAME quantized pose signature the fleet
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
// camera.changed, whose granularity is globally degraded by other layers
// mutating camera.percentageChanged.
const poseSig = cameraMotionSignature(layerState.viewer.camera);
if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now;
}
const decision = parts.model.awarenessRefreshDecision({
nowMs: now,
lastRefreshMs: layerState.lastSubjectRefreshMs,
lastPoseChangeMs: layerState.lastCameraPoseChangeMs,
wasMoving: layerState.cameraMoving,
});
layerState.cameraMoving = decision.moving;
syncAwarenessRenderHold();
if (!decision.refresh) return;
layerState.lastSubjectRefreshMs = now;
if (
layerState.subject &&
['flights', 'military', 'ais-live-vessels'].includes(
layerState.subject.layerId,
)
) {
parts.subject.refreshSelectedSubject();
} else if (!layerState.passive && layerState.autoFocusRetryPending) {
layerState.autoFocusRetryPending = false;
parts.focus.focusAttentionTarget();
} else {
parts.rendering.scheduleDirectionOverlayUpdate();
}
});
layerState.runtimeListenersAttached = true;
}
function detachRuntimeListeners() {
if (layerState.runtimeListenersAttached) {
window.removeEventListener(
'gev:awareness-subject-selected',
layerState.subjectListener,
);
window.removeEventListener(
'gev:entity-selected',
layerState.contextListener,
);
window.removeEventListener(
'gev:entity-selection-cleared',
layerState.clearListener,
);
window.removeEventListener(
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
}
layerState.preRenderRemover?.();
layerState.preRenderRemover = null;
layerState.lastCameraPoseSig = '';
layerState.lastCameraPoseChangeMs = 0;
layerState.cameraMoving = false;
// No frames will arrive to run the per-frame release once the listener is
// gone, so drop the hold here.
releaseContinuousRender('military-awareness');
parts.rendering.cancelDirectionOverlayUpdate();
layerState.runtimeListenersAttached = false;
}
const methods = {
init(viewer) {
detachRuntimeListeners();
layerState.viewer = viewer;
layerState.subjectListener = (event) => {
if (!layerState.enabled) return;
parts.subject.selectSubject(event.detail);
};
layerState.contextListener = (event) => {
if (!layerState.enabled) return;
const subject = parts.subject.subjectFromContext(event.detail);
if (!subject) return;
if (parts.model.contextTargetFlyToAllowed(subject.layerId))
parts.model.releaseAircraftTracking();
parts.subject.selectSubject(subject);
};
layerState.clearListener = (event) => {
if (!layerState.enabled || layerState.pendingSelectionKey) return;
if (
!parts.subject.awarenessClearMatchesSubject(
layerState.subject,
event.detail,
)
)
return;
// An eviction keeps the subject so the readout can hold last-known
// values; only a deliberate clear tears the selection down.
if (parts.model.awarenessClearIsEviction(event.detail)) {
parts.subject.markSubjectEvicted();
return;
}
parts.subject.clearAwarenessSubject();
};
layerState.subjectClearListener = (event) => {
if (!layerState.enabled) return;
const cleared = event.detail;
if (layerState.pendingSelectionKey) return;
if (
parts.subject.awarenessClearMatchesSubject(
layerState.subject,
cleared,
) &&
String(layerState.subject?.id) === String(cleared?.id)
) {
if (parts.model.awarenessClearIsEviction(cleared)) {
// Deliberately does NOT set autoFocusAttempted: the subject survives,
// so the entry fallback has nothing to replace and the settlement
// guard below stays scoped to real deselects.
parts.subject.markSubjectEvicted();
return;
}
// A deliberate clear during dependency settlement must win over the
// entry fallback; otherwise Contacts can silently select a replacement.
layerState.autoFocusAttempted = true;
parts.subject.clearAwarenessSubject();
}
};
},
enable() {
layerState.enabled = true;
// NO unconditional continuous-render hold here. Contacts animates per frame
// only while the VIEW is moving (the direction arrows are screen-projected);
// parked, it is a throttled readout with nothing to animate. The hold is
// taken and released per frame by syncAwarenessRenderHold().
attachRuntimeListeners();
parts.panel.startAwarenessPageRotation();
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
parts.panel.ensurePanel();
parts.panel.hidePanel();
if (!layerState.passive)
return parts.dependencies.activateOperationalContext();
return true;
},
disable() {
layerState.enabled = false;
releaseContinuousRender('military-awareness');
detachRuntimeListeners();
parts.panel.stopAwarenessPageRotation();
layerState.cohortPages.clear();
const releaseActivationId = ++layerState.activationId;
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.results = null;
layerState.lastSubjectRefreshMs = 0;
layerState.lastEvaluatedPosition = null;
layerState.sourceRevision = '';
layerState.navigationHistory = [];
layerState.navigationVisited.clear();
layerState.navigationIndex = -1;
layerState.suppressedHistoryKey = null;
layerState.pendingSelectionKey = null;
parts.rendering.clearVisual();
parts.panel.hidePanel();
// The manager must not publish this coordinator as settled OFF while its
// owned dependency releases are still issuing newer absolute intents.
// Context mode handoffs restore the pre-entry snapshot only after this
// promise resolves, preventing those releases from superseding restore.
layerState.passive = true;
return parts.dependencies.releaseOwnedDependencies(releaseActivationId);
},
destroy() {
this.disable();
detachRuntimeListeners();
layerState.panel?.removeEventListener(
'click',
layerState.panelClickListener,
);
layerState.panelClickListener = null;
if (layerState.panelOwned) layerState.panel?.remove();
else if (layerState.panel) layerState.panel.replaceChildren();
layerState.panel = null;
layerState.panelOwned = false;
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
layerState.subjectClearListener = null;
layerState.viewer = null;
layerState.dataManager = null;
},
};
return {
cameraMotionSignature,
syncAwarenessRenderHold,
attachRuntimeListeners,
detachRuntimeListeners,
methods,
};
}
+251
View File
@@ -0,0 +1,251 @@
import {
AWARENESS_MOTION_REFRESH_MS,
AWARENESS_REFRESH_MS,
AWARENESS_MOTION_SETTLE_MS,
AWARENESS_REEVALUATE_DISTANCE_M,
} from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
/**
* Whether focusing a Context target may take ownership of the camera.
* Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable
* there but must not start a competing flyTo animation.
*
* @param {string} layerId Context target layer id.
* @param {HTMLElement|null} [body=document.body] Document body to inspect.
* @returns {boolean} Whether the target may start a camera flight.
*/
function contextTargetFlyToAllowed(
layerId,
body = globalThis.document?.body,
) {
const nonAircraft =
layerId === 'ais-live-vessels' || layerId === 'military-installations';
return !nonAircraft || !body?.classList?.contains('cockpit-mode');
}
/**
* Refresh cadence for the Contacts direction arrows and card readouts.
*
* A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms
* one. This is safe for requestRenderMode because the cadence is applied inside
* `scene.preRender`: a faster interval can only ever consume frames the camera
* movement is ALREADY forcing. A parked scene renders no frames at all, so no
* extra refresh happens and no timer is introduced — the render governor and
* the idle-leak fix both depend on parked staying quiet.
* @param {boolean} cameraMoving Whether the camera pose changed since the last frame.
* @returns {number} Minimum ms between refreshes.
*/
function awarenessRefreshIntervalMs(cameraMoving) {
return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS;
}
/**
* Decide, for one rendered frame, whether the view counts as MOVING and whether
* the Contacts readout may refresh.
*
* Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a
* slow drag crosses a bin only every few frames: treating a single unchanged
* frame as "parked" made every crossing look like a motion-end and refreshed at
* nearly display rate. Motion therefore ends only after the pose has been
* unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD
* floor in every state — no bin-crossing pattern, and no motion-end settle, can
* refresh faster than AWARENESS_MOTION_REFRESH_MS.
*
* @param {object} input
* @param {number} input.nowMs This frame's clock.
* @param {number} input.lastRefreshMs When the readout last refreshed.
* @param {number} input.lastPoseChangeMs When the camera pose last changed bins.
* @param {boolean} input.wasMoving Whether the previous frame counted as moving.
* @returns {{moving: boolean, refresh: boolean}}
*/
function awarenessRefreshDecision({
nowMs,
lastRefreshMs,
lastPoseChangeMs,
wasMoving,
}) {
const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS;
// The single frame where hysteresis expires: settle the readout at rest
// instead of leaving it up to a parked interval stale.
const settling = Boolean(wasMoving) && !moving;
const sinceRefresh = nowMs - lastRefreshMs;
const refresh =
sinceRefresh >= awarenessRefreshIntervalMs(true) &&
(moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false));
return { moving, refresh };
}
/**
* Decide whether a source-scoped clear was an eviction rather than a deselect.
*
* Both arrive on the same events: the owning layer calls the same teardown for
* "the user clicked away" and "this contact aged out of the feed". Only the
* origin separates them, and it matters — a deliberate clear takes the Contact
* panel down, while an eviction must keep it up in its CONTACT LOST state, or
* the panel disappears out from under its own PREVIOUS/NEXT controls.
* @param {{reason?: string}|null} cleared Cleared source detail.
* @returns {boolean} Whether the subject left the feed on its own.
*/
function awarenessClearIsEviction(cleared) {
return cleared?.reason === 'evicted';
}
/**
* Decide whether proximity cohorts need an expensive rescan.
* @param {object} input Refresh evidence.
* @param {boolean} input.force Explicit invalidation.
* @param {boolean} input.hasResults Whether a prior evaluation exists.
* @param {number} input.movementM Subject displacement since evaluation.
* @param {boolean} input.sourceRevisionChanged Whether any source state changed.
* @returns {boolean} Whether cohorts should be evaluated again.
*/
function awarenessRefreshRequired({
force,
hasResults,
movementM,
sourceRevisionChanged,
}) {
return Boolean(
force ||
!hasResults ||
sourceRevisionChanged ||
!Number.isFinite(movementM) ||
movementM >= AWARENESS_REEVALUATE_DISTANCE_M,
);
}
/**
* The per-layer counts the Contacts panel is showing, as one flat block.
*
* Three honest numbers were reaching the operator at once: this cohort count
* (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the
* layer-wide loaded total in the coverage note. After the camera dives to a
* tracked contact the flights layer reloads by viewport, so the loaded set can
* hold a fraction of the cohort — 8 against the panel's 42 in the field. The
* numbers are all correct and the disagreement still reads as chaos.
*
* Derived from the same snapshot the panel renders (`cohort.summary.count` via
* `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort
* whose feed cannot answer reports 'unknown' rather than a misleading zero.
* @param {object|null} snapshot `getContextSnapshot()` result.
* @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string,
* flights: number|string, military: number|string, vessels: number|string}|null}
* Panel-equivalent counts.
*/
function contactsWindowFromSnapshot(snapshot) {
if (!snapshot?.subject) return null;
const countFor = (cohortId) => {
const cohort = Array.isArray(snapshot.cohorts)
? snapshot.cohorts.find((item) => item?.id === cohortId)
: null;
return Number.isFinite(cohort?.count) ? cohort.count : 'unknown';
};
const flights = countFor('flights');
const military = countFor('military');
return {
centeredOn: snapshot.subject.label || snapshot.subject.id || null,
radiusKm: Number.isFinite(snapshot.radiusM)
? Math.round(snapshot.radiusM / 1000)
: null,
aircraft:
Number.isFinite(flights) && Number.isFinite(military)
? flights + military
: 'unknown',
flights,
military,
vessels: countFor('ais-live-vessels'),
};
}
/** Build the read-only Awareness snapshot shared with compact HUD consumers. */
function buildAwarenessContextSnapshot(
results,
navigation = {},
{ subjectPresent = true } = {},
) {
if (!results) return null;
return {
subject: { ...results.subject },
// Whether the subject is still reported by its source. The cockpit Contact
// readout holds its last-known values behind a CONTACT LOST cue when this
// is false, rather than presenting frozen geometry as a live reading.
subjectPresent: subjectPresent !== false,
evaluatedAt: results.evaluatedAt,
radiusM: results.radiusM,
cohorts: results.cohorts.map((cohort) => ({
id: cohort.id,
label: cohort.label,
source: cohort.source,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
reason: cohort.summary.reason,
nearest: cohort.summary.nearest.slice(),
})),
navigation: { ...navigation },
};
}
function navigationState() {
return {
canPrevious: layerState.navigationIndex > 0,
canFocus: Boolean(layerState.subject),
canNext: parts.navigation.canNavigateNext(),
};
}
/**
* Whether Contacts genuinely has per-frame work right now.
*
* The direction arrows are screen-projected against the camera basis, so they
* must be redrawn every frame while the view MOVES. Parked, or with no live
* cohort to point at — nothing selected, every feed empty, every feed failed —
* there is nothing to animate and Contacts must not be the reason the whole
* scene keeps repainting.
* @param {object} [snapshot] Explicit state, for tests.
* @returns {boolean}
*/
function awarenessNeedsContinuousRender({
cameraMoving = layerState.cameraMoving,
hasSubject = Boolean(layerState.subject),
hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results),
} = {}) {
return Boolean(cameraMoving && hasSubject && hasLiveResults);
}
/**
* Release any aircraft-owned follow camera before a non-aircraft context
* selection takes ownership. Both layers are safe no-ops when idle and release
* Cesium tracking in place, so the subsequent vessel/site framing starts from
* the current view without the previous aircraft continuing to drag it.
*/
function releaseAircraftTracking() {
flightsLayer.stopTracking?.();
militaryFlightsLayer.stopTracking?.();
}
return {
contextTargetFlyToAllowed,
awarenessRefreshIntervalMs,
awarenessRefreshDecision,
awarenessClearIsEviction,
awarenessRefreshRequired,
contactsWindowFromSnapshot,
buildAwarenessContextSnapshot,
navigationState,
awarenessNeedsContinuousRender,
releaseAircraftTracking,
};
}
+274
View File
@@ -0,0 +1,274 @@
import {
summarizeAwarenessCohort,
getAwarenessNavigationTargets,
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
findByDoublingRadius,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import {
AWARENESS_MAX_EXAMPLES,
AWARENESS_MAX_NAVIGATION_EXAMPLES,
} from './policy.js';
export function createNavigation({
state: layerState,
services,
parts,
source,
}) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
function summarizeAwarenessCohortForNavigation(
items,
source,
{
displayLimit = AWARENESS_MAX_EXAMPLES,
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
} = {},
) {
const summary = summarizeAwarenessCohort(items, {
...source,
limit: navigationLimit,
});
if (summary.count === null) return summary;
return {
...summary,
nearest: summary.nearest.slice(0, displayLimit),
navigationNearest: summary.nearest,
};
}
function resetNavigationVisitedCycle() {
layerState.navigationVisited.clear();
const currentKey = parts.subject.subjectKey(layerState.subject);
if (currentKey) layerState.navigationVisited.add(currentKey);
}
function isFlightLayer(layerId) {
return layerId === 'flights' || layerId === 'military';
}
function normalizeAircraftClass(value) {
return String(value || '')
.trim()
.toLowerCase();
}
function aircraftClassMatchesFilter(item, aircraftClass) {
const requested = normalizeAircraftClass(aircraftClass);
if (!requested) return true;
const candidates = [
item?.aircraftClass,
item?.klass,
item?.type,
item?.typeCode,
item?.typeName,
]
.map((value) => normalizeAircraftClass(value))
.filter(Boolean);
if (!candidates.length) return false;
return candidates.some(
(candidate) => candidate === requested || candidate.includes(requested),
);
}
function selectNavigationTargets(
sourceCohorts,
subject,
visitedKeys,
{ targetLayer = null, aircraftClass = null, aircraftOnly = false } = {},
) {
let targets = getAwarenessNavigationTargets(
sourceCohorts,
subject,
visitedKeys,
);
if (aircraftOnly) {
targets = targets.filter((target) => isFlightLayer(target.layerId));
}
if (targetLayer) {
targets = targets.filter((target) => target.layerId === targetLayer);
}
if (aircraftClass) {
targets = targets.filter((target) =>
aircraftClassMatchesFilter(target.item, aircraftClass),
);
}
return targets;
}
function alternativeFlightAvailable({
targetLayer = null,
aircraftClass = null,
} = {}) {
if (
!isFlightLayer(layerState.subject?.layerId) ||
!layerState.subject?.position
)
return false;
if (targetLayer && !isFlightLayer(targetLayer)) return false;
const flights =
targetLayer === 'military'
? []
: flightsLayer
.getNearby(
layerState.subject.position,
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
2,
{ includeHidden: true },
)
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
const military =
targetLayer === 'flights'
? []
: militaryFlightsLayer
.getNearby(
layerState.subject.position,
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
2,
{ includeHidden: true },
)
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
return [
['flights', flights],
['military', military],
].some(([layerId, items]) =>
items.some(
(item) =>
`${layerId}:${item.icao24 || item.id}` !==
parts.subject.subjectKey(layerState.subject),
),
);
}
function closestFlightWithinRadius(
radiusM,
visitedKeys,
excludeVisited,
{ targetLayer = null, aircraftClass = null } = {},
) {
if (!layerState.subject?.position) return null;
const visited = new Set(visitedKeys);
if (targetLayer && !isFlightLayer(targetLayer)) return null;
const candidates = [
...(targetLayer === 'military'
? []
: flightsLayer
.getNearby(layerState.subject.position, radiusM, 25000, {
includeHidden: true,
})
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
.map((item) => ({
layerId: 'flights',
id: String(item.icao24),
item,
}))),
...(targetLayer === 'flights'
? []
: militaryFlightsLayer
.getNearby(layerState.subject.position, radiusM, 5000, {
includeHidden: true,
})
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
.map((item) => ({
layerId: 'military',
id: String(item.icao24),
item,
}))),
].filter((target) => {
const key = `${target.layerId}:${target.id}`;
if (key === parts.subject.subjectKey(layerState.subject)) return false;
return !excludeVisited || !visited.has(key);
});
candidates.sort(
(a, b) =>
(a.item.distanceM ?? a.item.distance ?? Infinity) -
(b.item.distanceM ?? b.item.distance ?? Infinity),
);
return candidates[0] || null;
}
function findExpandedFlightTarget(options = {}) {
if (!isFlightLayer(layerState.subject?.layerId)) return null;
let visitedKeys = [...layerState.navigationVisited];
const search = () =>
findByDoublingRadius(
(radiusM) =>
closestFlightWithinRadius(radiusM, visitedKeys, true, options),
{
initialRadiusM: AWARENESS_RADIUS_M,
maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
},
);
const target = search();
if (target) return target;
resetNavigationVisitedCycle();
visitedKeys = [...layerState.navigationVisited];
return search();
}
/**
* Resolves NEXT availability from the same three branches used by navigation.
* Forward history remains usable while a feed is unavailable; discovering a
* new nearby or expanded target requires a known subject cohort.
* @param {Object} availability Navigation branch availability.
* @returns {boolean} Whether NEXT has a path that can run.
*/
function canNavigateAwarenessNext({
hasForwardHistory = false,
hasExpandedFlightTarget = false,
hasNearbyTarget = false,
subjectCohortUnknown = false,
} = {}) {
return Boolean(
hasForwardHistory ||
(!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)),
);
}
function canNavigateNext({ targetLayer = null, aircraftClass = null } = {}) {
return canNavigateAwarenessNext({
hasForwardHistory:
parts.history.findCompatibleHistoryIndex(
layerState.navigationHistory,
layerState.navigationIndex,
1,
{
targetLayer,
aircraftClass,
resolveItem: parts.history.historySubjectItem,
},
) !== -1,
hasNearbyTarget:
selectNavigationTargets(
layerState.results?.cohorts,
layerState.subject,
[...layerState.navigationVisited],
{ targetLayer, aircraftClass },
).length > 0,
hasExpandedFlightTarget: alternativeFlightAvailable({
targetLayer,
aircraftClass,
}),
subjectCohortUnknown: parts.subject.subjectCohortFeedUnknown(),
});
}
return {
summarizeAwarenessCohortForNavigation,
resetNavigationVisitedCycle,
isFlightLayer,
normalizeAircraftClass,
aircraftClassMatchesFilter,
selectNavigationTargets,
alternativeFlightAvailable,
closestFlightWithinRadius,
findExpandedFlightTarget,
canNavigateAwarenessNext,
canNavigateNext,
};
}
+249
View File
@@ -0,0 +1,249 @@
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
const existing = document.getElementById('military-awareness-panel');
const panel = existing || document.createElement('aside');
layerState.panelOwned = !existing;
if (!existing) {
panel.id = 'military-awareness-panel';
}
layerState.panelClickListener = (event) => {
const action = event.target.closest('button[data-awareness-action]');
if (action) {
event.preventDefault();
if (action.dataset.awarenessAction === 'previous')
parts.history.navigateHistory(-1, { origin: 'user' });
if (action.dataset.awarenessAction === 'next')
parts.history.navigateHistory(1, { origin: 'user' });
if (action.dataset.awarenessAction === 'focus')
parts.focus.focusCurrentSubject({ origin: 'user' });
return;
}
const target = event.target.closest(
'button[data-awareness-layer][data-awareness-id]',
);
if (!target) return;
event.preventDefault();
parts.focus.requestFocus(
target.dataset.awarenessLayer,
target.dataset.awarenessId,
false,
{ origin: 'user' },
);
};
panel.addEventListener('click', layerState.panelClickListener);
if (!existing) document.body.appendChild(panel);
layerState.panel = panel;
return panel;
}
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
layerState.panel.innerHTML = markup;
layerState.panelMarkup = markup;
}
}
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
}
function rowHtml(cohort) {
const summary = cohort.summary;
const count = summary.count === null ? '?' : String(summary.count);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
.map((item) => {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
})
.join('');
const pageCount = Math.max(
1,
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
);
const pageLabel =
pageCount > 1
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
: '';
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
<button type="button" data-awareness-action="focus">FOCUS</button>
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
</div>`;
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction)
return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
function captureAwarenessPanelFocus(
panel,
activeElement = document.activeElement,
) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector(
'[data-awareness-focus-continuation]',
);
const controls = [
...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
),
].filter((control) => !control.disabled);
const retained =
snapshot.key === 'continuation'
? continuation
: controls.find(
(control) => awarenessPanelControlKey(control) === snapshot.key,
);
const target = retained || continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>'"]/g,
(char) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char],
);
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
layerState.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
}
function rotateAwarenessPages() {
if (!layerState.enabled || !layerState.results) return;
let changed = false;
for (const cohort of layerState.results.cohorts) {
const pageCount = Math.max(
1,
Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE),
);
if (pageCount <= 1) continue;
const current = layerState.cohortPages.get(cohort.id) || 0;
layerState.cohortPages.set(
cohort.id,
((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) *
AWARENESS_PAGE_SIZE,
);
changed = true;
}
if (changed) {
renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(true);
}
}
function startAwarenessPageRotation() {
if (!layerState.pageTimer)
layerState.pageTimer = window.setInterval(
rotateAwarenessPages,
AWARENESS_PAGE_ROTATE_MS,
);
}
function stopAwarenessPageRotation() {
if (layerState.pageTimer) window.clearInterval(layerState.pageTimer);
layerState.pageTimer = null;
}
/**
* Whether an evaluation actually has a contact to point an arrow at.
*
* A cohort reports `count: null` when its feed is unavailable or stale, and `0`
* when the feed is healthy but empty. Only a positive count puts a marker on
* the compass rim, so anything else means there is nothing to animate.
* @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results
* @returns {boolean}
*/
function awarenessResultsAreLive(results) {
const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : [];
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
}
return {
ensurePanel,
hidePanel,
rowHtml,
navigationControlsHtml,
awarenessPanelControlKey,
captureAwarenessPanelFocus,
restoreAwarenessPanelFocus,
escapeHtml,
renderResults,
rotateAwarenessPages,
startAwarenessPageRotation,
stopAwarenessPageRotation,
awarenessResultsAreLive,
};
}
+68
View File
@@ -0,0 +1,68 @@
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
export const DEFERRED_DEPENDENCIES = [
'ais-live-vessels',
'military-installations',
];
export const DEPENDENCIES = [
...AIRCRAFT_DEPENDENCIES,
...DEFERRED_DEPENDENCIES,
];
export const AWARENESS_REFRESH_MS = 750;
/** @constant {number} Refresh cadence while the camera pose is CHANGING.
* Owner playtest 2026-08-18: the Contacts direction arrows and card readouts
* "feel sluggish when you look around" — at the parked 750 ms cadence the
* arrows lag the view by up to three quarters of a second. */
export const AWARENESS_MOTION_REFRESH_MS = 175;
/** @constant {number} How long the pose signature must stay UNCHANGED before
* the view counts as parked again. The signature is quantized, so a slow drag
* crosses a bin only every few frames; without this hysteresis every crossing
* read as a motion-end and refreshed at nearly display rate. */
export const AWARENESS_MOTION_SETTLE_MS = 250;
export const AWARENESS_REEVALUATE_DISTANCE_M = 250;
export const AWARENESS_PAGE_ROTATE_MS = 10000;
export const AWARENESS_PAGE_SIZE = 3;
export const AWARENESS_MAX_EXAMPLES = 10;
// Navigation walks the whole 250 km cohort, not the ten rows the panel shows,
// so these caps sit far above any realistic in-range population rather than at
// the display limit. They are still FINITE: an unbounded materialization would
// let a pathological feed sort an eleven-thousand-contact array on every
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
// derives `count` from the full in-range set before any slice.
export const AWARENESS_QUERY_LIMIT = 20000;
export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
export const CONTEXT_RIM_HEIGHT_M = 2500;
export const VESSEL_FOCUS_RADIUS_M = 3000;
export const SOURCE_LABEL = {
flights: 'OpenSky',
military: 'adsb.lol',
'ais-live-vessels': 'AISStream',
'military-installations': 'OpenStreetMap',
};
/**
* Whether a refresh tick could confirm the subject in its source collection.
* UNCHECKED means the tick did not look — it must never change the verdict.
*/
export const SUBJECT_PRESENCE = Object.freeze({
LIVE: 'live',
MISSING: 'missing',
UNCHECKED: 'unchecked',
});
+164
View File
@@ -0,0 +1,164 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
export function createQueries({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
function sourceState(layerId) {
const lifecycle =
layerState.dataManager?.getLayerLifecycleState?.(layerId) || null;
const enabled =
lifecycle?.enabled === true ||
layerState.dataManager?.isEnabled(layerId) === true;
const enabling = lifecycle?.lifecycleState === 'enabling';
const moduleStats =
layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {};
const stats = enabling
? { ...moduleStats, loading: true, status: 'loading' }
: moduleStats;
// A deferred source that has started but not settled is not evidence of an
// empty cohort. Keep it explicitly unavailable so the panel cannot flash a
// false all-clear, even if a lifecycle adapter briefly reports the requested
// visibility intent as enabled.
//
// `enabling` and the never-answered predicate cover two DIFFERENT windows, and
// both are needed because these dependencies do not settle the same way.
//
// - military-installations is covered by `enabling` alone. Its enable() is
// SYNCHRONOUS and the manager awaits update(), which owns the first
// Overpass fetch, so the lifecycle stays `enabling` for that whole window
// however long the fetch takes. Confirmed live: a held 17 s first fetch
// read `enabling` across 34 samples with the panel non-numeric throughout,
// and a failing one settled to `enabled` with status 'unavailable'. Its
// getStats() also reports loading while a request is in progress —
// setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
// status. That zero is an absence of evidence, not evidence of absence,
// and neither the lifecycle nor the status list can tell.
//
// Deliberately `loading === true` and not truthiness: layers that never report
// a busy flag (flights, military) must keep answering for themselves, and a
// source that HAS answered once (lastUpdate set) keeps its last real count
// through every later poll rather than blanking to `?` on each refresh.
const neverAnswered = stats.loading === true && !stats.lastUpdate;
const unavailable =
enabling ||
!enabled ||
neverAnswered ||
['unavailable', 'zoom-in'].includes(stats.status) ||
Boolean(stats.error && stats.count === 0);
return { available: !unavailable, stale: Boolean(stats.stale), stats };
}
function collectSourceStates() {
return Object.fromEntries(
DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]),
);
}
function sourceRevision(sourceStates) {
return DEPENDENCIES.map((layerId) => {
const source = sourceStates[layerId];
const stats = source?.stats || {};
return [
layerId,
source?.available,
source?.stale,
stats.lastUpdate || null,
stats.count ?? null,
stats.status || null,
stats.error || null,
];
})
.map((parts) => parts.join(':'))
.join('|');
}
function isSame(subject, item, prefix, key) {
return (
subject?.layerId === prefix && String(subject.id) === String(item?.[key])
);
}
/**
* Summarize viewport-backed installations without implying radius-complete coverage.
* @param {Array<object>} items Loaded installation matches.
* @param {object} source Current source availability and staleness.
* @returns {object} Awareness summary with viewport-honest reasoning.
*/
function summarizeInstallationViewport(items, source) {
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
items,
source,
);
if (summary.count === null)
return source.stats?.statusMessage
? { ...summary, reason: source.stats.statusMessage }
: summary;
return {
...summary,
reason: summary.count
? 'mapped matches from the loaded viewport'
: 'viewport feed is not a complete 250 km survey',
};
}
/**
* THE aircraft-proximity engine. One computation, two consumers: the Contacts
* panel window and the voice analyst's entity-centred "how many nearby".
*
* They used to be separate. The panel read live billboard positions through
* `getNearby` with a 20 000 cap; the analyst re-derived its own answer from
* last-fix coordinates over a 2 000-record slice. Same question, same centre,
* two numbers — and in the owner's trial the spoken answer (15) and the panel
* (111) disagreed badly enough that the model narrated the difference away.
* Routing both through here makes them the same number BY CONSTRUCTION, so
* they cannot drift again.
*
* The subject is excluded from its own window, which is why the panel reads
* "contacts around X" rather than "including X".
* @param {Cesium.Cartesian3} position Window centre.
* @param {object} [options]
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
* plus the combined aircraft count, or null without a position.
*/
function collectAircraftProximityWindow(
position,
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
) {
if (!position) return null;
const flights = flightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
);
const military = militaryFlightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
);
return { flights, military, aircraft: flights.length + military.length };
}
return {
sourceState,
collectSourceStates,
sourceRevision,
isSame,
summarizeInstallationViewport,
collectAircraftProximityWindow,
};
}
+288
View File
@@ -0,0 +1,288 @@
import * as Cesium from 'cesium';
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
const { governorRequestRender } = services.render;
function ensureDirectionOverlay() {
if (layerState.directionRoot || !layerState.viewer)
return layerState.directionRoot;
const root = document.createElement('div');
root.id = 'military-awareness-direction-overlay';
root.hidden = true;
root.setAttribute('aria-hidden', 'true');
const compass = document.createElement('div');
compass.className = 'military-awareness-compass-ring';
const cardinals = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
layerState.compassLabels = cardinals.map(([label, bearing]) => {
const element = document.createElement('span');
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
element.textContent = label;
element.dataset.bearing = String(bearing);
root.appendChild(element);
return element;
});
const heading = document.createElement('span');
heading.className = 'military-awareness-compass-heading';
root.append(compass, heading);
layerState.directionMarkers = Array.from({ length: 3 }, () => {
const marker = document.createElement('div');
marker.className = 'military-awareness-direction-marker';
const arrow = document.createElement('span');
arrow.className = 'military-awareness-direction-arrow';
arrow.textContent = '➜';
const label = document.createElement('span');
label.className = 'military-awareness-direction-label';
marker.append(arrow, label);
marker._arrow = arrow;
marker._label = label;
marker._lastAngle = 0;
root.appendChild(marker);
return marker;
});
layerState.compassRing = compass;
layerState.compassHeading = heading;
layerState.viewer.container.appendChild(root);
layerState.directionRoot = root;
return root;
}
function scheduleDirectionOverlayUpdate(force = false) {
if (layerState.directionFrame !== null || !layerState.enabled) return;
const now = Date.now();
if (
!force &&
now - layerState.lastDirectionUpdateMs <
parts.model.awarenessRefreshIntervalMs(layerState.cameraMoving)
)
return;
layerState.directionFrame = window.requestAnimationFrame(() => {
layerState.directionFrame = null;
layerState.lastDirectionUpdateMs = Date.now();
updateDirectionOverlay();
});
}
function cancelDirectionOverlayUpdate() {
if (layerState.directionFrame !== null)
window.cancelAnimationFrame(layerState.directionFrame);
layerState.directionFrame = null;
layerState.lastDirectionUpdateMs = 0;
}
function updateDirectionOverlay() {
const root = ensureDirectionOverlay();
if (
!root ||
!layerState.enabled ||
!layerState.subject?.position ||
!layerState.results
) {
if (root) root.hidden = true;
return;
}
const canvas = layerState.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
return;
}
root.hidden = false;
// Keep the compass comfortably inside the shader keyhole so its cardinals
// remain visible around the subject instead of hiding under edge panels.
const compassRadius = Math.max(
120,
Math.min(geometry.radius - 48, geometry.radius * 0.66),
);
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
layerState.compassRing.style.width = `${compassRadius * 2}px`;
layerState.compassRing.style.height = `${compassRadius * 2}px`;
const cameraHeading = layerState.viewer.camera.heading || 0;
const headingDeg =
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
layerState.compassRing.style.setProperty(
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
const labelRadius = compassRadius - 17;
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
}
const directionalCohort =
layerState.results.cohorts.find(
(cohort) => cohort.id === layerState.subject.layerId,
) ||
layerState.results.cohorts.find((cohort) => cohort.id === 'military');
const page = layerState.cohortPages.get(directionalCohort?.id) || 0;
const military =
directionalCohort?.summary.nearest.slice(
page,
page + AWARENESS_PAGE_SIZE,
) || [];
for (let index = 0; index < layerState.directionMarkers.length; index++) {
const marker = layerState.directionMarkers[index];
const item = military[index];
if (!item?.position) {
marker.hidden = true;
continue;
}
const direction = Cesium.Cartesian3.subtract(
item.position,
layerState.subject.position,
layerState.DIRECTION_SCRATCH[index],
);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) {
marker.hidden = true;
continue;
}
Cesium.Cartesian3.normalize(direction, direction);
const projection = celestialScreenAngle(
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
marker._lastAngle,
);
if (projection.stable) marker._lastAngle = projection.angle;
// Every detected contact shares the compass rim. Keeping the bearing
// markers on one radius makes them read as compass contacts rather than
// unconstrained labels floating over the map.
const markerRadius = compassRadius;
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian(
layerState.subject.position,
undefined,
layerState.SUBJECT_CARTOGRAPHIC_SCRATCH,
);
const targetCartographic = Cesium.Cartographic.fromCartesian(
item.position,
undefined,
layerState.TARGET_CARTOGRAPHIC_SCRATCH[index],
);
const bearing =
subjectCartographic && targetCartographic
? bearingBetweenCoordinates(
Cesium.Math.toDegrees(subjectCartographic.latitude),
Cesium.Math.toDegrees(subjectCartographic.longitude),
Cesium.Math.toDegrees(targetCartographic.latitude),
Cesium.Math.toDegrees(targetCartographic.longitude),
)
: null;
const bearingText = Number.isFinite(bearing)
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
: 'BRG —';
const courseText = Number.isFinite(item.track)
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
}
}
function clearVisual() {
if (layerState.visual?.entities && layerState.viewer) {
for (const entity of layerState.visual.entities)
layerState.viewer.entities.remove(entity);
// Idle mode renders only on request; a removed ring must not linger.
governorRequestRender('awareness-visual');
}
layerState.visual = null;
}
function renderVisual(subject) {
if (!layerState.viewer || !subject?.position) return;
const cartographic = Cesium.Cartographic.fromCartesian(subject.position);
if (!cartographic) return;
const groundCenter = Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
0,
);
const key = `${subject.layerId}:${subject.id}`;
if (layerState.visual?.key === key) {
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
// Move it only after a meaningful displacement so live tracking does not
// churn ten large geometries for sub-pixel motion every 750 ms.
if (
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
250
) {
for (const entity of layerState.visual.entities)
entity.position.setValue(groundCenter);
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
// Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual');
}
return;
}
clearVisual();
const entities = [];
entities.push(
layerState.viewer.entities.add({
position: groundCenter,
ellipse: {
semiMajorAxis: AWARENESS_RADIUS_M,
semiMinorAxis: AWARENESS_RADIUS_M,
fill: false,
outline: true,
outlineColor:
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
height: CONTEXT_RIM_HEIGHT_M,
},
}),
);
layerState.visual = {
key,
entities,
center: Cesium.Cartesian3.clone(groundCenter),
};
governorRequestRender('awareness-visual');
}
return {
ensureDirectionOverlay,
scheduleDirectionOverlayUpdate,
cancelDirectionOverlayUpdate,
updateDirectionOverlay,
clearVisual,
renderVisual,
};
}
+70
View File
@@ -0,0 +1,70 @@
import * as Cesium from 'cesium';
export function createState({ services }) {
const state = {};
state.DIRECTION_SCRATCH = Array.from(
{ length: 3 },
() => new Cesium.Cartesian3(),
);
state.SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic();
state.TARGET_CARTOGRAPHIC_SCRATCH = Array.from(
{ length: 3 },
() => new Cesium.Cartographic(),
);
Object.assign(state, {
viewer: null,
dataManager: null,
enabled: false,
// Set once a refresh observes the subject gone from a still-reporting source.
// The Contact readout turns this into its CONTACT LOST hold state.
subjectMissing: false,
// The dedicated Context chooser exposes a passive shell. Operational source
// layers activate only after Contacts is explicitly selected.
passive: true,
ownedDependencies: new Set(),
subject: null,
results: null,
visual: null,
panel: null,
panelOwned: false,
subjectListener: null,
contextListener: null,
clearListener: null,
subjectClearListener: null,
runtimeListenersAttached: false,
preRenderRemover: null,
panelClickListener: null,
directionRoot: null,
compassRing: null,
compassLabels: [],
compassHeading: null,
directionMarkers: [],
navigationHistory: [],
navigationVisited: new Set(),
navigationIndex: -1,
suppressedHistoryKey: null,
pendingSelectionKey: null,
lastSubjectRefreshMs: 0,
/** Camera-pose signature observed on the previous rendered frame. */
lastCameraPoseSig: '',
/** When the pose signature last changed bins (hysteresis anchor). */
lastCameraPoseChangeMs: 0,
/** Whether the view currently counts as moving (hysteretic, not per-frame). */
cameraMoving: false,
lastEvaluatedPosition: null,
sourceRevision: '',
panelMarkup: '',
directionFrame: null,
lastDirectionUpdateMs: 0,
activationId: 0,
autoFocusAttempted: false,
autoFocusRetryPending: false,
pageTimer: null,
cohortPages: new Map(),
});
return state;
}
+447
View File
@@ -0,0 +1,447 @@
import {
AWARENESS_RADIUS_M,
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import {
AWARENESS_QUERY_LIMIT,
SOURCE_LABEL,
SUBJECT_PRESENCE,
AWARENESS_PAGE_SIZE,
} from './policy.js';
export function createSubject({ state: layerState, services, parts, source }) {
const aisLiveVesselsLayer = services.vessels;
const militaryInstallationsLayer = services.installations;
const flightsLayer = services.flights;
const militaryFlightsLayer = services.military;
/**
* Decide whether a source-scoped context clear belongs to the current subject.
* @param {{layerId?: string}|null} subject Current awareness subject.
* @param {{layerId?: string}|null} cleared Cleared source detail.
* @returns {boolean} Whether the current subject should be cleared.
*/
function awarenessClearMatchesSubject(subject, cleared) {
return Boolean(subject?.layerId && cleared?.layerId === subject.layerId);
}
function evaluateSubject(
subject,
sourceStates = parts.queries.collectSourceStates(),
) {
const position = subject?.position;
if (!position) return null;
const flightsState = sourceStates.flights;
const militaryState = sourceStates.military;
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
const { flights, military } = parts.queries.collectAircraftProximityWindow(
position,
{ subject },
);
const vessels = aisLiveVesselsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
);
const installations = militaryInstallationsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
);
return {
subject,
evaluatedAt: Date.now(),
radiusM: AWARENESS_RADIUS_M,
cohorts: [
{
id: 'flights',
label: 'Flights',
source: flightsState.stats.source || SOURCE_LABEL.flights,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
flightsState,
),
},
{
id: 'military',
label: 'Military flights',
source: militaryState.stats.source || SOURCE_LABEL.military,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
militaryState,
),
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
vesselsState,
),
},
{
id: 'military-installations',
label: 'Mapped installations',
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
coverage: 'CURRENT VIEWPORT ONLY',
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
),
},
],
};
}
function subjectKey(subject) {
return subject ? `${subject.layerId}:${subject.id}` : '';
}
function normalizeContextId(value) {
return String(value || '')
.trim()
.toLowerCase();
}
function normalizedSubjectKey(subject) {
if (!subject) return '';
return `${normalizeContextId(subject.layerId)}:${normalizeContextId(subject.id)}`;
}
/**
* Resolve the source-owned aircraft that currently owns Cesium tracking.
* Both flight layers may briefly retain local state during a cross-layer
* handoff, so the viewer's normalized tracked identity is the tie-breaker.
* @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null}
* Detached awareness subject, or null when no flight owns the follow camera.
*/
function currentTrackedFlightSubject() {
const trackedKey = normalizeContextId(
layerState.viewer?.trackedEntity?.gevTrackedId,
);
if (!trackedKey) return null;
const subjects = [
flightsLayer.getTrackedSubject?.(),
militaryFlightsLayer.getTrackedSubject?.(),
];
return (
subjects.find(
(subject) => normalizedSubjectKey(subject) === trackedKey,
) || null
);
}
function subjectCohortFeedUnknown() {
const cohort = layerState.results?.cohorts?.find(
(item) => item.id === layerState.subject?.layerId,
);
return (
cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN &&
cohort.summary.count === null
);
}
function selectSubject(subject) {
if (!layerState.enabled || !subject?.position) return;
parts.panel.startAwarenessPageRotation();
const key = subjectKey(subject);
layerState.navigationVisited.add(key);
const suppressHistory = layerState.suppressedHistoryKey === key;
layerState.pendingSelectionKey = null;
layerState.suppressedHistoryKey = null;
if (!suppressHistory) {
const current = layerState.navigationHistory[layerState.navigationIndex];
if (subjectKey(current) !== key) {
layerState.navigationHistory.splice(layerState.navigationIndex + 1);
layerState.navigationHistory.push({
...parts.history.historySubjectSnapshot(
subject,
parts.history.historySourceItem(subject),
),
position: Cesium.Cartesian3.clone(subject.position),
});
layerState.navigationIndex = layerState.navigationHistory.length - 1;
}
}
layerState.subject = subject;
// A newly chosen contact starts present; the next refresh re-decides.
layerState.subjectMissing = false;
refreshSelectedSubject(true);
}
/**
* Resolve the currently displayed position for a selected live subject. Flight
* tracking owns the motion callback, so awareness consumes its cached display
* position instead of creating a second tracker or extrapolation path.
* @param {{layerId: string, id: string, position: Cesium.Cartesian3}} subject Current subject.
* @param {object} [options] Position materialization controls.
* @param {boolean} [options.allowCollectionMaterialization=true] Whether a layer-wide fallback may run.
* @returns {Cesium.Cartesian3|null} A cloned live position when one is available.
*/
function resolveSubjectPosition(
subject,
{ allowCollectionMaterialization = true } = {},
) {
if (!subject?.position) return null;
if (subject.layerId === 'flights' || subject.layerId === 'military') {
const trackedPosition =
layerState.viewer?.trackedEntity?.gevDisplayPosition?.();
if (trackedPosition) {
return {
position: Cesium.Cartesian3.clone(trackedPosition),
// Only the follow camera's own contact proves presence this way; a
// different tracked entity says nothing about this subject.
presence:
String(layerState.viewer?.trackedEntity?.gevTrackedId || '') ===
subjectKey(subject)
? SUBJECT_PRESENCE.LIVE
: SUBJECT_PRESENCE.UNCHECKED,
};
}
// Cockpit already materializes its tracked position on a fixed 20 Hz loop.
// If that frame-owned cache is temporarily unavailable, keep the last
// awareness position instead of allocating/scanning up to 1,000 contacts.
if (!allowCollectionMaterialization) {
return {
position: Cesium.Cartesian3.clone(subject.position),
presence: SUBJECT_PRESENCE.UNCHECKED,
};
}
const layer =
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
return collectionSubjectPosition(
subject,
layer.getAllPositions(1000),
layer,
);
}
if (subject.layerId === 'ais-live-vessels') {
if (!allowCollectionMaterialization) {
return {
position: Cesium.Cartesian3.clone(subject.position),
presence: SUBJECT_PRESENCE.UNCHECKED,
};
}
return collectionSubjectPosition(
subject,
aisLiveVesselsLayer.getAllPositions(12000),
aisLiveVesselsLayer,
);
}
// Mapped installations come from static geometry, not a live feed: there is
// nothing to be culled from.
return {
position: Cesium.Cartesian3.clone(subject.position),
presence: SUBJECT_PRESENCE.LIVE,
};
}
/**
* Locate a subject inside its layer's live collection, keeping the last known
* position when it is gone.
*
* Presence comes from the layer's O(1) `hasContact`, never from the rows:
* `getAllPositions` stops at its cap and the flights layer routinely carries
* ~11k contacts against a 1,000-row cap, so "not in the rows" is not "gone".
* The rows are only consulted for a fresher position — a contact past the cap
* keeps its last known position while still reading as present.
* @param {{id: string, position: Cesium.Cartesian3}} subject Current subject.
* @param {Array<{id: string, position: Cesium.Cartesian3}>} rows Live collection rows.
* @param {{hasContact?: function}} layer The owning source layer.
* @returns {{position: Cesium.Cartesian3, presence: string}} Position plus presence verdict.
*/
function collectionSubjectPosition(subject, rows, layer) {
const collection = Array.isArray(rows) ? rows : [];
const current = collection.find(
(item) => String(item.id) === String(subject.id),
);
// null/undefined means the layer cannot answer (disabled or not yet loaded).
const known = layer?.hasContact?.(subject.id);
const presence =
known === true
? SUBJECT_PRESENCE.LIVE
: known === false
? SUBJECT_PRESENCE.MISSING
: SUBJECT_PRESENCE.UNCHECKED;
return {
position: current?.position
? Cesium.Cartesian3.clone(current.position)
: Cesium.Cartesian3.clone(subject.position),
presence,
};
}
/**
* Re-resolve a live subject's DISPLAY label from the layer that owns it.
*
* The subject snapshot is captured once at selection time, but a contact's
* label INPUTS can arrive later: adsbdb enrichment supplies a registration
* seconds after a callsign-less aircraft is selected, so every other surface
* swapped `ae1fa4` → `N123AB` while the cached Context subject kept the hex.
* The owning layer's tracked-subject accessor already applies the layer's
* label convention (callsign → registration → icao24), so ask it rather than
* re-deriving the chain here.
*
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
* @param {{layerId: string, id: string, label: string}} subject Current subject.
* @returns {string} The current label, or the cached one when the owning layer
* has no tracked subject (e.g. a cross-layer handoff is in flight).
*/
function resolveSubjectLabel(subject) {
if (!parts.navigation.isFlightLayer(subject?.layerId))
return subject?.label;
const layer =
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
const tracked = layer.getTrackedSubject?.();
if (tracked?.label && String(tracked.id) === String(subject.id))
return tracked.label;
return subject.label;
}
/** Refresh proximity counts/distances against the subject's current live position. */
function refreshSelectedSubject(force = false) {
if (!layerState.enabled || !layerState.subject) return;
const sources = parts.queries.collectSourceStates();
const nextSourceRevision = parts.queries.sourceRevision(sources);
const sourceRevisionChanged =
nextSourceRevision !== layerState.sourceRevision;
const resolved = resolveSubjectPosition(layerState.subject, {
allowCollectionMaterialization:
!document.body?.classList?.contains('cockpit-mode') ||
sourceRevisionChanged,
});
if (!resolved) return;
const { position, presence } = resolved;
// UNCHECKED leaves the verdict alone: this tick simply did not look.
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
else if (presence === SUBJECT_PRESENCE.MISSING)
layerState.subjectMissing = true;
const nextLabel = resolveSubjectLabel(layerState.subject);
const labelChanged = nextLabel !== layerState.subject.label;
layerState.subject = { ...layerState.subject, position, label: nextLabel };
const movementM = layerState.lastEvaluatedPosition
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
: Infinity;
if (
!parts.model.awarenessRefreshRequired({
force,
hasResults: Boolean(layerState.results),
movementM,
sourceRevisionChanged,
})
) {
layerState.results.subject = layerState.subject;
parts.rendering.renderVisual(layerState.subject);
// The panel markup embeds `subject.label`, so a label that changed while
// the contact was stationary (async enrichment answering after selection)
// is render-worthy ON ITS OWN. Without this the standalone Context panel
// kept the ICAO hex until unrelated movement, a source-revision bump, or
// page rotation happened to repaint it. Gated on an ACTUAL change so the
// common no-op refresh (every 750 ms) still costs no repaint.
if (labelChanged) parts.panel.renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(force);
return;
}
layerState.results = evaluateSubject(layerState.subject, sources);
layerState.sourceRevision = nextSourceRevision;
layerState.lastEvaluatedPosition = Cesium.Cartesian3.clone(
position,
layerState.lastEvaluatedPosition,
);
for (const cohort of layerState.results.cohorts) {
const maxPage = Math.max(
0,
Math.floor(
Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE,
) * AWARENESS_PAGE_SIZE,
);
layerState.cohortPages.set(
cohort.id,
Math.min(layerState.cohortPages.get(cohort.id) || 0, maxPage),
);
}
parts.rendering.renderVisual(layerState.subject);
parts.panel.renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(force);
}
function subjectFromContext(record) {
if (
!record ||
!['ais-live-vessels', 'military-installations'].includes(record.layerId)
)
return null;
const latitude = Number(record.latitude);
const longitude = Number(record.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
label: record.label || record.id,
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
};
}
/**
* Record that the current subject left its feed on its own.
*
* The subject, its cohort results and its navigation history all survive: the
* Contact panel keeps rendering them behind a CONTACT LOST cue, and PREVIOUS /
* NEXT stay operable so the operator can step off. Nulling the subject here
* instead would take the panel — and those controls — off screen.
*/
function markSubjectEvicted() {
if (!layerState.subject) return;
layerState.subjectMissing = true;
}
function clearAwarenessSubject() {
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.subjectMissing = false;
layerState.results = null;
layerState.navigationHistory = [];
layerState.navigationVisited.clear();
layerState.navigationIndex = -1;
layerState.suppressedHistoryKey = null;
layerState.pendingSelectionKey = null;
layerState.lastEvaluatedPosition = null;
layerState.sourceRevision = '';
layerState.cohortPages.clear();
parts.panel.stopAwarenessPageRotation();
parts.rendering.clearVisual();
parts.panel.hidePanel();
}
return {
awarenessClearMatchesSubject,
evaluateSubject,
subjectKey,
normalizeContextId,
normalizedSubjectKey,
currentTrackedFlightSubject,
subjectCohortFeedUnknown,
selectSubject,
resolveSubjectPosition,
collectionSubjectPosition,
resolveSubjectLabel,
refreshSelectedSubject,
subjectFromContext,
markSubjectEvicted,
clearAwarenessSubject,
};
}
+15
View File
@@ -0,0 +1,15 @@
export function createTesting({ state: layerState, services, parts, source }) {
/** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */
function _getAwarenessNavigationStateForTest() {
return {
historyKeys: layerState.navigationHistory.map(parts.subject.subjectKey),
navigationVisitedKeys: [...layerState.navigationVisited],
historyLength: layerState.navigationHistory.length,
navigationIndex: layerState.navigationIndex,
suppressedHistoryKey: layerState.suppressedHistoryKey,
pendingSelectionKey: layerState.pendingSelectionKey,
};
}
return { _getAwarenessNavigationStateForTest };
}