diff --git a/CHANGELOG.md b/CHANGELOG.md
index c42fe59f5..ee3c03787 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## Installation and context components
+
+- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
+- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
+- Retain source and ground-floor ownership in standalone composition; reject malformed
+ installation snapshots and ignore failures from cancelled requests.
+
## Satellite and mission layer components
- Separate catalog loading, orbit calculations, display, tracking and interaction
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 34cb5b8e3..8d3efa6e3 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,14 @@
# God's Eye View Current State
+Installations and proximity context now expose `./layers/installations` and
+`./layers/awareness` factories. Each owns its records, selection, presentation,
+navigation and lifecycle. The standalone entries supply existing scene operations
+and the aircraft/vessel/installation instances used for proximity queries.
+Installation requests use a bounded source adapter; explicit nearby-place search
+remains separate from ordinary mapped-site loading. Invalid snapshots retain the
+previous display, and cancelled requests cannot publish a later failure state.
+Viewport limits, saturation retry, placement, query caps and controls are retained.
+
Satellite and mission layers expose separate factories through `./layers/satellites`
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
and teardown. Source adapters retain the existing CelesTrak and Launch Library
diff --git a/package.json b/package.json
index fc5b7bdfd..8d9acb9a6 100644
--- a/package.json
+++ b/package.json
@@ -150,6 +150,8 @@
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
- "./layers/launches": "./src/layers/launches/index.js"
+ "./layers/launches": "./src/layers/launches/index.js",
+ "./layers/installations": "./src/layers/installations/index.js",
+ "./layers/awareness": "./src/layers/awareness/index.js"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 89f291857..3b5209646 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -360,5 +360,35 @@
"src/data/satellites.js",
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
- "src/layers/launches/source.test.mjs"
+ "src/layers/launches/source.test.mjs",
+ "src/layers/installations/lifecycle.js",
+ "src/layers/installations/viewport.js",
+ "src/layers/installations/controls.js",
+ "src/layers/installations/source.js",
+ "src/layers/installations/state.js",
+ "src/layers/installations/policy.js",
+ "src/layers/installations/rendering.js",
+ "src/layers/installations/ingestion.js",
+ "src/layers/installations/index.js",
+ "src/layers/installations/model.js",
+ "src/layers/installations/selection.js",
+ "src/layers/awareness/focus.js",
+ "src/layers/awareness/lifecycle.js",
+ "src/layers/awareness/dependencies.js",
+ "src/layers/awareness/controls.js",
+ "src/layers/awareness/panel.js",
+ "src/layers/awareness/state.js",
+ "src/layers/awareness/policy.js",
+ "src/layers/awareness/rendering.js",
+ "src/layers/awareness/testing.js",
+ "src/layers/awareness/ingestion.js",
+ "src/layers/awareness/queries.js",
+ "src/layers/awareness/navigation.js",
+ "src/layers/awareness/index.js",
+ "src/layers/awareness/model.js",
+ "src/layers/awareness/history.js",
+ "src/layers/awareness/subject.js",
+ "src/data/militaryInstallations.js",
+ "src/data/militaryAwareness.js",
+ "src/layers/installations/source.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 74cdfb0d0..84b16ad2e 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -763,5 +763,50 @@
"src/layers/launches/testing.js"
],
"external": ["cesium"]
+ },
+ "installations-layer": {
+ "exports": ["./layers/installations"],
+ "modules": [
+ "src/data/installationFeedback.js",
+ "src/data/militaryInstallationData.js",
+ "src/layers/installations/controls.js",
+ "src/layers/installations/index.js",
+ "src/layers/installations/ingestion.js",
+ "src/layers/installations/lifecycle.js",
+ "src/layers/installations/model.js",
+ "src/layers/installations/policy.js",
+ "src/layers/installations/rendering.js",
+ "src/layers/installations/selection.js",
+ "src/layers/installations/source.js",
+ "src/layers/installations/state.js",
+ "src/layers/installations/viewport.js"
+ ],
+ "external": ["cesium"]
+ },
+ "awareness-layer": {
+ "exports": ["./layers/awareness"],
+ "modules": [
+ "src/cockpitMath.js",
+ "src/data/iconOrientation.js",
+ "src/data/militaryAwarenessEngine.js",
+ "src/layers/awareness/controls.js",
+ "src/layers/awareness/dependencies.js",
+ "src/layers/awareness/focus.js",
+ "src/layers/awareness/history.js",
+ "src/layers/awareness/index.js",
+ "src/layers/awareness/ingestion.js",
+ "src/layers/awareness/lifecycle.js",
+ "src/layers/awareness/model.js",
+ "src/layers/awareness/navigation.js",
+ "src/layers/awareness/panel.js",
+ "src/layers/awareness/policy.js",
+ "src/layers/awareness/queries.js",
+ "src/layers/awareness/rendering.js",
+ "src/layers/awareness/state.js",
+ "src/layers/awareness/subject.js",
+ "src/layers/awareness/testing.js",
+ "src/ui/panelRailGeometry.js"
+ ],
+ "external": ["cesium"]
}
}
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index 32c184152..24884b5ad 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -1,3 +1,4 @@
+import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
@@ -30,7 +31,7 @@ const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'),
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', () => {
diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js
index ca100eeb5..5692f8027 100644
--- a/src/data/militaryAwareness.js
+++ b/src/data/militaryAwareness.js
@@ -1,1948 +1,46 @@
-import * as Cesium from 'cesium';
-import flightsLayer from './flights.js';
-import militaryFlightsLayer from './militaryFlights.js';
-import aisLiveVesselsLayer from './aisLiveVessels.js';
-import militaryInstallationsLayer from './militaryInstallations.js';
-import {
- AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
- AWARENESS_RADIUS_M,
- AWARENESS_RELATIONSHIP,
- findByDoublingRadius,
- formatAwarenessDistance,
- formatAwarenessLabel,
- getAwarenessNavigationTargets,
- summarizeAwarenessCohort,
-} from './militaryAwarenessEngine.js';
-import { announceNavigationAuthority } from '../navigationPolicy.js';
-import { celestialScreenAngle, getKeyholeGeometry } from '../celestialRing.js';
-import { bearingBetweenCoordinates } from '../cockpitMath.js';
-import { cameraPoseSignature } from './iconOrientation.js';
-import {
- governorRequestRender,
- holdContinuousRender,
- releaseContinuousRender,
-} from '../renderGovernor.js';
+import { createAwarenessLayer } from '../layers/awareness/index.js';
+import flights from './flights.js';
+import military from './militaryFlights.js';
+import vessels from './aisLiveVessels.js';
+import installations from './militaryInstallations.js';
+import * as navigation from '../navigationPolicy.js';
+import * as geometry from '../celestialRing.js';
+import * as render from '../renderGovernor.js';
-const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
-const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
-const DEPENDENCIES = [...AIRCRAFT_DEPENDENCIES, ...DEFERRED_DEPENDENCIES];
-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. */
-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. */
-const AWARENESS_MOTION_SETTLE_MS = 250;
-const AWARENESS_REEVALUATE_DISTANCE_M = 250;
-const AWARENESS_PAGE_ROTATE_MS = 10000;
-const AWARENESS_PAGE_SIZE = 3;
-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;
-const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
-const CONTEXT_RIM_HEIGHT_M = 2500;
-const VESSEL_FOCUS_RADIUS_M = 3000;
-const DIRECTION_SCRATCH = Array.from({ length: 3 }, () => new Cesium.Cartesian3());
-const SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic();
-const TARGET_CARTOGRAPHIC_SCRATCH = Array.from({ length: 3 }, () => new Cesium.Cartographic());
-const SOURCE_LABEL = {
- flights: 'OpenSky',
- military: 'adsb.lol',
- 'ais-live-vessels': 'AISStream',
- 'military-installations': 'OpenStreetMap',
-};
-
-/**
- * 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.
- */
-export function contextTargetFlyToAllowed(layerId, body = globalThis.document?.body) {
- const nonAircraft = layerId === 'ais-live-vessels' || layerId === 'military-installations';
- return !nonAircraft || !body?.classList?.contains('cockpit-mode');
-}
-
-/**
- * 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.
- */
-export function awarenessClearMatchesSubject(subject, cleared) {
- return Boolean(subject?.layerId && cleared?.layerId === subject.layerId);
-}
-
-/**
- * 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.
- */
-export 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}}
- */
-export 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.
- */
-export 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.
- */
-export function awarenessRefreshRequired({ force, hasResults, movementM, sourceRevisionChanged }) {
- return Boolean(
- force
- || !hasResults
- || sourceRevisionChanged
- || !Number.isFinite(movementM)
- || movementM >= AWARENESS_REEVALUATE_DISTANCE_M,
- );
-}
-
-/**
- * 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.
- */
-const SUBJECT_PRESENCE = Object.freeze({
- LIVE: 'live',
- MISSING: 'missing',
- UNCHECKED: 'unchecked',
-});
-
-const 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(),
-};
-
-function ensurePanel() {
- if (state.panel) return state.panel;
- const existing = document.getElementById('military-awareness-panel');
- const panel = existing || document.createElement('aside');
- state.panelOwned = !existing;
- if (!existing) {
- panel.id = 'military-awareness-panel';
- }
- state.panelClickListener = (event) => {
- const action = event.target.closest('button[data-awareness-action]');
- if (action) {
- event.preventDefault();
- if (action.dataset.awarenessAction === 'previous') navigateHistory(-1, { origin: 'user' });
- if (action.dataset.awarenessAction === 'next') navigateHistory(1, { origin: 'user' });
- if (action.dataset.awarenessAction === 'focus') focusCurrentSubject({ origin: 'user' });
- return;
- }
- const target = event.target.closest('button[data-awareness-layer][data-awareness-id]');
- if (!target) return;
- event.preventDefault();
- requestFocus(target.dataset.awarenessLayer, target.dataset.awarenessId, false, { origin: 'user' });
- };
- panel.addEventListener('click', state.panelClickListener);
- if (!existing) document.body.appendChild(panel);
- state.panel = panel;
- return panel;
-}
-
-function hidePanel() {
- if (state.panel) {
- const markup = `
- ${state.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}
- ${state.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}
-
`;
- state.panel.hidden = false;
- if (state.panelMarkup !== markup) {
- state.panel.innerHTML = markup;
- state.panelMarkup = markup;
- }
- }
- if (state.directionRoot) state.directionRoot.hidden = true;
-}
-
-function sourceState(layerId) {
- const lifecycle = state.dataManager?.getLayerLifecycleState?.(layerId) || null;
- const enabled = lifecycle?.enabled === true || state.dataManager?.isEnabled(layerId) === true;
- const enabling = lifecycle?.lifecycleState === 'enabling';
- const moduleStats = state.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} items Loaded installation matches.
- * @param {object} source Current source availability and staleness.
- * @returns {object} Awareness summary with viewport-honest reasoning.
- */
-export function summarizeInstallationViewport(items, source) {
- const summary = 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',
- };
-}
-
-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,
- };
-}
-
-/**
- * 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.
- */
-export 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',
+const layer = createAwarenessLayer({
+ services: {
flights,
military,
- vessels: countFor('ais-live-vessels'),
- };
-}
-
-/** Build the read-only Awareness snapshot shared with compact HUD consumers. */
-export 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 },
- };
-}
-
-/**
- * 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.
- */
-export 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 };
-}
-
-function evaluateSubject(subject, sourceStates = 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 } = collectAircraftProximityWindow(position, { subject });
- const vessels = aisLiveVesselsLayer.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
- .filter((item) => !isSame(subject, item, 'ais-live-vessels', 'mmsi'));
- const installations = militaryInstallationsLayer.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
- .filter((item) => !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: summarizeAwarenessCohortForNavigation(flights, flightsState) },
- { id: 'military', label: 'Military flights', source: militaryState.stats.source || SOURCE_LABEL.military, summary: summarizeAwarenessCohortForNavigation(military, militaryState) },
- { id: 'ais-live-vessels', label: 'AIS vessels', source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], summary: summarizeAwarenessCohortForNavigation(vessels, vesselsState) },
- {
- id: 'military-installations',
- label: 'Mapped installations',
- source: installationsState.stats.source || SOURCE_LABEL['military-installations'],
- coverage: 'CURRENT VIEWPORT ONLY',
- summary: summarizeInstallationViewport(installations, installationsState),
- },
- ],
- };
-}
-
-function rowHtml(cohort) {
- const summary = cohort.summary;
- const count = summary.count === null ? '?' : String(summary.count);
- const page = state.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 `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)} `;
- }
- const accessibleLabel = label === '—' ? 'Unavailable' : label;
- return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)} `;
- }).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 `
- ${escapeHtml(cohort.label)} ${count}${pageLabel}
- ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}
- ${nearest ? `` : ''}
- `;
-}
-
-/** 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 (!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 (!contextTargetFlyToAllowed(layerId) || !vessel?.position || !state.viewer) return true;
- announceNavigationAuthority('context-vessel-focus');
- state.viewer.camera.flyToBoundingSphere(
- new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
- { duration: 1.4 },
- );
- return true;
-}
-
-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)}`;
-}
-
-function resetNavigationVisitedCycle() {
- state.navigationVisited.clear();
- const currentKey = subjectKey(state.subject);
- if (currentKey) state.navigationVisited.add(currentKey);
-}
-
-/** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */
-export function _getAwarenessNavigationStateForTest() {
- return {
- historyKeys: state.navigationHistory.map(subjectKey),
- navigationVisitedKeys: [...state.navigationVisited],
- historyLength: state.navigationHistory.length,
- navigationIndex: state.navigationIndex,
- suppressedHistoryKey: state.suppressedHistoryKey,
- pendingSelectionKey: state.pendingSelectionKey,
- };
-}
-
-function selectKnownContextTarget(layerId, id) {
- const key = `${layerId}:${id}`;
- const known = [
- state.subject,
- ...state.navigationHistory,
- ...(state.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 (contextTargetFlyToAllowed(layerId)) releaseAircraftTracking();
- 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}`;
- state.pendingSelectionKey = key;
- if (preserveHistory) state.suppressedHistoryKey = key;
- const focused = focusNearbyTarget(layerId, id, { origin });
- state.pendingSelectionKey = null;
- state.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(state.subject, true, options);
-}
-
-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;
-}
-
-/**
- * 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(state.viewer?.trackedEntity?.gevTrackedId);
- if (!trackedKey) return null;
- const subjects = [
- flightsLayer.getTrackedSubject?.(),
- militaryFlightsLayer.getTrackedSubject?.(),
- ];
- return subjects.find((subject) => normalizedSubjectKey(subject) === trackedKey) || null;
-}
-
-function alternativeFlightAvailable({
- targetLayer = null,
- aircraftClass = null,
-} = {}) {
- if (!isFlightLayer(state.subject?.layerId) || !state.subject?.position) return false;
- if (targetLayer && !isFlightLayer(targetLayer)) return false;
-
- const flights = targetLayer === 'military'
- ? []
- : flightsLayer.getNearby(
- state.subject.position,
- AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
- 2,
- { includeHidden: true },
- ).filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
-
- const military = targetLayer === 'flights'
- ? []
- : militaryFlightsLayer.getNearby(
- state.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}` !== subjectKey(state.subject)
- )));
-}
-
-function closestFlightWithinRadius(radiusM, visitedKeys, excludeVisited, {
- targetLayer = null,
- aircraftClass = null,
-} = {}) {
- if (!state.subject?.position) return null;
- const visited = new Set(visitedKeys);
- if (targetLayer && !isFlightLayer(targetLayer)) return null;
- const candidates = [
- ...(targetLayer === 'military' ? [] : flightsLayer.getNearby(
- state.subject.position,
- radiusM,
- 25000,
- { includeHidden: true },
- )
- .filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
- .map((item) => ({ layerId: 'flights', id: String(item.icao24), item }))),
- ...(targetLayer === 'flights' ? [] : militaryFlightsLayer.getNearby(
- state.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 === subjectKey(state.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(state.subject?.layerId)) return null;
- let visitedKeys = [...state.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 = [...state.navigationVisited];
- return search();
-}
-
-function subjectCohortFeedUnknown() {
- const cohort = state.results?.cohorts?.find((item) => item.id === state.subject?.layerId);
- return cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN
- && cohort.summary.count === null;
-}
-
-/**
- * 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.
- */
-export 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: findCompatibleHistoryIndex(
- state.navigationHistory,
- state.navigationIndex,
- 1,
- { targetLayer, aircraftClass, resolveItem: historySubjectItem },
- ) !== -1,
- hasNearbyTarget: selectNavigationTargets(
- state.results?.cohorts,
- state.subject,
- [...state.navigationVisited],
- { targetLayer, aircraftClass },
- ).length > 0,
- hasExpandedFlightTarget: alternativeFlightAvailable({
- targetLayer,
- aircraftClass,
- }),
- subjectCohortUnknown: subjectCohortFeedUnknown(),
- });
-}
-
-/**
- * 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 = state.navigationIndex;
- for (;;) {
- const historyIndex = findCompatibleHistoryIndex(
- state.navigationHistory,
- cursor,
- direction,
- { targetLayer, aircraftClass, aircraftOnly, resolveItem: historySubjectItem },
- );
- if (historyIndex === -1) return false;
- if (focusSubject(state.navigationHistory[historyIndex], true, { origin })) {
- state.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 = selectNavigationTargets(
- state.results?.cohorts,
- state.subject,
- [...state.navigationVisited],
- { targetLayer, aircraftClass, aircraftOnly },
- );
- const targetHistoryFullyVisited = targets.length > 0 && targets.every((target) => target.visited);
- const subjectCohortUnknown = subjectCohortFeedUnknown();
- if (targetHistoryFullyVisited && isFlightLayer(state.subject?.layerId) && !subjectCohortUnknown) {
- const expanded = findExpandedFlightTarget({ targetLayer, aircraftClass });
- if (expanded) {
- return requestFocus(expanded.candidate.layerId, expanded.candidate.id, false, { origin });
- }
- }
- if (targetHistoryFullyVisited) {
- resetNavigationVisitedCycle();
- targets = selectNavigationTargets(
- state.results?.cohorts,
- state.subject,
- [...state.navigationVisited],
- { targetLayer, aircraftClass, aircraftOnly },
- );
- }
- const hasNearbyFlight = targets.some((target) => isFlightLayer(target.layerId));
- if (isFlightLayer(state.subject?.layerId) && !hasNearbyFlight && !subjectCohortUnknown) {
- const expanded = findExpandedFlightTarget({ targetLayer, aircraftClass });
- if (expanded) {
- return requestFocus(expanded.candidate.layerId, expanded.candidate.id, false, { origin });
- }
- }
- if (subjectCohortFeedUnknown()) return false;
- const candidate = targets[0];
- if (!candidate) return false;
- return requestFocus(candidate.layerId, candidate.id, false, { origin });
-}
-
-function historySubjectItem(subject) {
- if (!subject) return null;
- const cohort = state.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. */
-export 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 (!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 = state.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. */
-export 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 && !isFlightLayer(subject?.layerId)) continue;
- if (targetLayer && subject?.layerId !== targetLayer) continue;
- if (aircraftClass && !aircraftClassMatchesFilter(resolveItem(subject), aircraftClass)) continue;
- return index;
- }
- return -1;
-}
-
-function navigationControlsHtml() {
- const canPrevious = state.navigationIndex > 0;
- return `
- PREVIOUS
- FOCUS
- NEXT
-
`;
-}
-
-function navigationState() {
- return {
- canPrevious: state.navigationIndex > 0,
- canFocus: Boolean(state.subject),
- canNext: canNavigateNext(),
- };
-}
-
-/** Stable identity for a delegated Contacts-panel button across live repaints. */
-export 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. */
-export 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. */
-export 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 (!state.enabled || !state.results) return hidePanel();
- const panel = ensurePanel();
- const { subject, cohorts } = state.results;
- const markup = `${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW
- ${navigationControlsHtml()}
- ${cohorts.map(rowHtml).join('')}
- Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.
`;
- panel.hidden = false;
- if (state.panelMarkup !== markup) {
- const focusSnapshot = captureAwarenessPanelFocus(panel);
- panel.innerHTML = markup;
- state.panelMarkup = markup;
- restoreAwarenessPanelFocus(panel, focusSnapshot);
- }
-}
-
-function rotateAwarenessPages() {
- if (!state.enabled || !state.results) return;
- let changed = false;
- for (const cohort of state.results.cohorts) {
- const pageCount = Math.max(1, Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE));
- if (pageCount <= 1) continue;
- const current = state.cohortPages.get(cohort.id) || 0;
- state.cohortPages.set(cohort.id, ((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) * AWARENESS_PAGE_SIZE);
- changed = true;
- }
- if (changed) { renderResults(); scheduleDirectionOverlayUpdate(true); }
-}
-
-function startAwarenessPageRotation() {
- if (!state.pageTimer) state.pageTimer = window.setInterval(rotateAwarenessPages, AWARENESS_PAGE_ROTATE_MS);
-}
-
-function stopAwarenessPageRotation() {
- if (state.pageTimer) window.clearInterval(state.pageTimer);
- state.pageTimer = null;
-}
-
-function ensureDirectionOverlay() {
- if (state.directionRoot || !state.viewer) return state.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],
- ];
- state.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);
-
- state.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;
- });
-
- state.compassRing = compass;
- state.compassHeading = heading;
- state.viewer.container.appendChild(root);
- state.directionRoot = root;
- return root;
-}
-
-function scheduleDirectionOverlayUpdate(force = false) {
- if (state.directionFrame !== null || !state.enabled) return;
- const now = Date.now();
- if (!force && now - state.lastDirectionUpdateMs < awarenessRefreshIntervalMs(state.cameraMoving)) return;
- state.directionFrame = window.requestAnimationFrame(() => {
- state.directionFrame = null;
- state.lastDirectionUpdateMs = Date.now();
- updateDirectionOverlay();
- });
-}
-
-function cancelDirectionOverlayUpdate() {
- if (state.directionFrame !== null) window.cancelAnimationFrame(state.directionFrame);
- state.directionFrame = null;
- state.lastDirectionUpdateMs = 0;
-}
-
-function updateDirectionOverlay() {
- const root = ensureDirectionOverlay();
- if (!root || !state.enabled || !state.subject?.position || !state.results) {
- if (root) root.hidden = true;
- return;
- }
-
- const canvas = state.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));
- state.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
- state.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
- state.compassRing.style.width = `${compassRadius * 2}px`;
- state.compassRing.style.height = `${compassRadius * 2}px`;
-
- const cameraHeading = state.viewer.camera.heading || 0;
- const headingDeg = (Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
- state.compassRing.style.setProperty('--compass-rotation', `${-headingDeg}deg`);
- state.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
- state.compassHeading.style.left = `${geometry.centerX}px`;
- state.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
- for (const label of state.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 = state.results.cohorts.find((cohort) => cohort.id === state.subject.layerId)
- || state.results.cohorts.find((cohort) => cohort.id === 'military');
- const page = state.cohortPages.get(directionalCohort?.id) || 0;
- const military = directionalCohort?.summary.nearest.slice(page, page + AWARENESS_PAGE_SIZE) || [];
- for (let index = 0; index < state.directionMarkers.length; index++) {
- const marker = state.directionMarkers[index];
- const item = military[index];
- if (!item?.position) {
- marker.hidden = true;
- continue;
- }
- const direction = Cesium.Cartesian3.subtract(item.position, state.subject.position, 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, state.viewer.camera.rightWC),
- Cesium.Cartesian3.dot(direction, state.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(state.subject.position, undefined, SUBJECT_CARTOGRAPHIC_SCRATCH);
- const targetCartographic = Cesium.Cartographic.fromCartesian(item.position, undefined, 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 (state.visual?.entities && state.viewer) {
- for (const entity of state.visual.entities) state.viewer.entities.remove(entity);
- // Idle mode renders only on request; a removed ring must not linger.
- governorRequestRender('awareness-visual');
- }
- state.visual = null;
-}
-
-function renderVisual(subject) {
- if (!state.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 (state.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(state.visual.center, groundCenter) >= 250) {
- for (const entity of state.visual.entities) entity.position.setValue(groundCenter);
- Cesium.Cartesian3.clone(groundCenter, state.visual.center);
- // Content actually changed — buy exactly one frame instead of holding.
- governorRequestRender('awareness-visual');
- }
- return;
- }
- clearVisual();
-
- const entities = [];
-
- entities.push(state.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,
- },
- }));
-
- state.visual = { key, entities, center: Cesium.Cartesian3.clone(groundCenter) };
- governorRequestRender('awareness-visual');
-}
-
-function selectSubject(subject) {
- if (!state.enabled || !subject?.position) return;
- startAwarenessPageRotation();
- const key = subjectKey(subject);
- state.navigationVisited.add(key);
- const suppressHistory = state.suppressedHistoryKey === key;
- state.pendingSelectionKey = null;
- state.suppressedHistoryKey = null;
- if (!suppressHistory) {
- const current = state.navigationHistory[state.navigationIndex];
- if (subjectKey(current) !== key) {
- state.navigationHistory.splice(state.navigationIndex + 1);
- state.navigationHistory.push({
- ...historySubjectSnapshot(subject, historySourceItem(subject)),
- position: Cesium.Cartesian3.clone(subject.position),
- });
- state.navigationIndex = state.navigationHistory.length - 1;
- }
- }
- state.subject = subject;
- // A newly chosen contact starts present; the next refresh re-decides.
- state.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 = state.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(state.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 (!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 (!state.enabled || !state.subject) return;
- const sources = collectSourceStates();
- const nextSourceRevision = sourceRevision(sources);
- const sourceRevisionChanged = nextSourceRevision !== state.sourceRevision;
- const resolved = resolveSubjectPosition(state.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) state.subjectMissing = false;
- else if (presence === SUBJECT_PRESENCE.MISSING) state.subjectMissing = true;
- const nextLabel = resolveSubjectLabel(state.subject);
- const labelChanged = nextLabel !== state.subject.label;
- state.subject = { ...state.subject, position, label: nextLabel };
- const movementM = state.lastEvaluatedPosition
- ? Cesium.Cartesian3.distance(state.lastEvaluatedPosition, position)
- : Infinity;
- if (!awarenessRefreshRequired({
- force,
- hasResults: Boolean(state.results),
- movementM,
- sourceRevisionChanged,
- })) {
- state.results.subject = state.subject;
- renderVisual(state.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) renderResults();
- scheduleDirectionOverlayUpdate(force);
- return;
- }
- state.results = evaluateSubject(state.subject, sources);
- state.sourceRevision = nextSourceRevision;
- state.lastEvaluatedPosition = Cesium.Cartesian3.clone(position, state.lastEvaluatedPosition);
- for (const cohort of state.results.cohorts) {
- const maxPage = Math.max(0, Math.floor(Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE) * AWARENESS_PAGE_SIZE);
- state.cohortPages.set(cohort.id, Math.min(state.cohortPages.get(cohort.id) || 0, maxPage));
- }
- renderVisual(state.subject);
- renderResults();
- 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 (!state.subject) return;
- state.subjectMissing = true;
-}
-
-function clearAwarenessSubject() {
- state.autoFocusRetryPending = false;
- state.subject = null;
- state.subjectMissing = false;
- state.results = null;
- state.navigationHistory = [];
- state.navigationVisited.clear();
- state.navigationIndex = -1;
- state.suppressedHistoryKey = null;
- state.pendingSelectionKey = null;
- state.lastEvaluatedPosition = null;
- state.sourceRevision = '';
- state.cohortPages.clear();
- stopAwarenessPageRotation();
- clearVisual();
- hidePanel();
-}
-
-/**
- * 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);
-}
-
-/**
- * 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}
- */
-export function awarenessResultsAreLive(results) {
- const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : [];
- return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
-}
-
-/**
- * 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}
- */
-export function awarenessNeedsContinuousRender({
- cameraMoving = state.cameraMoving,
- hasSubject = Boolean(state.subject),
- hasLiveResults = awarenessResultsAreLive(state.results),
-} = {}) {
- return Boolean(cameraMoving && hasSubject && hasLiveResults);
-}
-
-/**
- * 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 (state.enabled && awarenessNeedsContinuousRender()) holdContinuousRender('military-awareness');
- else releaseContinuousRender('military-awareness');
-}
-
-function attachRuntimeListeners() {
- if (state.runtimeListenersAttached || !state.viewer) return;
- window.addEventListener('gev:awareness-subject-selected', state.subjectListener);
- window.addEventListener('gev:entity-selected', state.contextListener);
- window.addEventListener('gev:entity-selection-cleared', state.clearListener);
- window.addEventListener('gev:awareness-subject-cleared', state.subjectClearListener);
- state.preRenderRemover = state.viewer.scene.preRender.addEventListener(() => {
- if (!state.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(state.viewer.camera);
- if (poseSig !== state.lastCameraPoseSig) {
- state.lastCameraPoseSig = poseSig;
- state.lastCameraPoseChangeMs = now;
- }
- const decision = awarenessRefreshDecision({
- nowMs: now,
- lastRefreshMs: state.lastSubjectRefreshMs,
- lastPoseChangeMs: state.lastCameraPoseChangeMs,
- wasMoving: state.cameraMoving,
- });
- state.cameraMoving = decision.moving;
- syncAwarenessRenderHold();
- if (!decision.refresh) return;
- state.lastSubjectRefreshMs = now;
- if (state.subject && ['flights', 'military', 'ais-live-vessels'].includes(state.subject.layerId)) {
- refreshSelectedSubject();
- } else if (!state.passive && state.autoFocusRetryPending) {
- state.autoFocusRetryPending = false;
- focusAttentionTarget();
- } else {
- scheduleDirectionOverlayUpdate();
- }
- });
- state.runtimeListenersAttached = true;
-}
-
-function detachRuntimeListeners() {
- if (state.runtimeListenersAttached) {
- window.removeEventListener('gev:awareness-subject-selected', state.subjectListener);
- window.removeEventListener('gev:entity-selected', state.contextListener);
- window.removeEventListener('gev:entity-selection-cleared', state.clearListener);
- window.removeEventListener('gev:awareness-subject-cleared', state.subjectClearListener);
- }
- state.preRenderRemover?.();
- state.preRenderRemover = null;
- state.lastCameraPoseSig = '';
- state.lastCameraPoseChangeMs = 0;
- state.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');
- cancelDirectionOverlayUpdate();
- state.runtimeListenersAttached = false;
-}
-
-/**
- * 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?.();
-}
-
-function refreshAfterDeferredDependency(activationId) {
- if (!state.enabled || state.passive || activationId !== state.activationId) return;
- if (state.subject) {
- refreshSelectedSubject(true);
- return;
- }
- if (state.autoFocusRetryPending) {
- state.autoFocusRetryPending = !focusAttentionTarget();
- }
-}
-
-async function enableDependencies(activationId = state.activationId) {
- const aircraftPending = [];
- for (const layerId of DEPENDENCIES) {
- if (!state.enabled) return;
- if (!state.dataManager) continue;
- const alreadyEnabled = state.dataManager.isEffectivelyEnabled?.(layerId)
- ?? state.dataManager.isEnabled(layerId);
- if (alreadyEnabled) continue;
- state.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(state.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 = [...state.ownedDependencies];
- state.ownedDependencies.clear();
- if (!state.dataManager || !owned.length) return Promise.resolve();
- return Promise.allSettled(owned.map((layerId) => state.dataManager.setEnabled(layerId, false)))
- .then(() => {
- if (state.enabled && state.activationId !== releaseActivationId) {
- return enableDependencies(state.activationId);
- }
- return null;
- });
-}
-
-function activateOperationalContext() {
- const activationId = ++state.activationId;
- state.autoFocusRetryPending = false;
- const initialTrackedSubject = currentTrackedFlightSubject();
- if (initialTrackedSubject) selectSubject(initialTrackedSubject);
- return enableDependencies(activationId).then(() => {
- if (!state.enabled || state.passive || activationId !== state.activationId) return;
- const trackedSubject = currentTrackedFlightSubject();
- if (trackedSubject) {
- selectSubject(trackedSubject);
- return;
- }
- if (state.subject) {
- selectSubject(state.subject);
- return;
- }
- if (state.autoFocusAttempted) return;
- state.autoFocusRetryPending = !focusAttentionTarget();
- }).catch((error) => console.warn('[Global Context] dependency enable failed', error));
-}
-
-/**
- * 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 = state.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 (!state.enabled || state.subject || !state.viewer || state.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' })) {
- state.autoFocusAttempted = true;
- return true;
- }
- }
-
- const vessel = closestToCurrentView(aisLiveVesselsLayer.getAllPositions(12000));
- if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false;
-
- state.autoFocusAttempted = true;
- if (!contextTargetFlyToAllowed('ais-live-vessels')) return true;
-
- announceNavigationAuthority('context-vessel-autofocus', {
- cancelPendingSelection: false,
- });
- state.viewer.camera.flyToBoundingSphere(
- new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
- { duration: 1.6 },
- );
- return true;
-}
-
-const militaryAwarenessLayer = {
- 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) { state.dataManager = dataManager; },
- setParams(params = {}) {
- if (typeof params.passive !== 'boolean') return;
- const wasPassive = state.passive;
- state.passive = params.passive;
- if (state.enabled && wasPassive && !state.passive) activateOperationalContext();
+ vessels,
+ installations,
+ navigation,
+ geometry,
+ render,
},
- /** @returns {{ passive: boolean }} Current runtime parameters. */
- getParams() {
- return { passive: state.passive };
- },
- init(viewer) {
- detachRuntimeListeners();
- state.viewer = viewer;
- state.subjectListener = (event) => {
- if (!state.enabled) return;
- selectSubject(event.detail);
- };
- state.contextListener = (event) => {
- if (!state.enabled) return;
- const subject = subjectFromContext(event.detail);
- if (!subject) return;
- if (contextTargetFlyToAllowed(subject.layerId)) releaseAircraftTracking();
- selectSubject(subject);
- };
- state.clearListener = (event) => {
- if (!state.enabled || state.pendingSelectionKey) return;
- if (!awarenessClearMatchesSubject(state.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 (awarenessClearIsEviction(event.detail)) {
- markSubjectEvicted();
- return;
- }
- clearAwarenessSubject();
- };
- state.subjectClearListener = (event) => {
- if (!state.enabled) return;
- const cleared = event.detail;
- if (state.pendingSelectionKey) return;
- if (
- awarenessClearMatchesSubject(state.subject, cleared)
- && String(state.subject?.id) === String(cleared?.id)
- ) {
- if (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.
- markSubjectEvicted();
- return;
- }
- // A deliberate clear during dependency settlement must win over the
- // entry fallback; otherwise Contacts can silently select a replacement.
- state.autoFocusAttempted = true;
- clearAwarenessSubject();
- }
- };
- },
- enable() {
- state.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();
- startAwarenessPageRotation();
- state.autoFocusAttempted = false;
- state.autoFocusRetryPending = false;
- ensurePanel();
- hidePanel();
- if (!state.passive) return activateOperationalContext();
- return true;
- },
- disable() {
- state.enabled = false;
- releaseContinuousRender('military-awareness');
- detachRuntimeListeners();
- stopAwarenessPageRotation();
- state.cohortPages.clear();
- const releaseActivationId = ++state.activationId;
- state.autoFocusAttempted = false;
- state.autoFocusRetryPending = false;
- state.subject = null;
- state.results = null;
- state.lastSubjectRefreshMs = 0;
- state.lastEvaluatedPosition = null;
- state.sourceRevision = '';
- state.navigationHistory = [];
- state.navigationVisited.clear();
- state.navigationIndex = -1;
- state.suppressedHistoryKey = null;
- state.pendingSelectionKey = null;
- clearVisual();
- 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.
- state.passive = true;
- return releaseOwnedDependencies(releaseActivationId);
- },
- update() {
- refreshSelectedSubject();
- return Promise.resolve();
- },
- destroy() {
- this.disable();
- detachRuntimeListeners();
- state.panel?.removeEventListener('click', state.panelClickListener);
- state.panelClickListener = null;
- if (state.panelOwned) state.panel?.remove();
- else if (state.panel) state.panel.replaceChildren();
- state.panel = null;
- state.panelOwned = false;
- state.directionRoot?.remove();
- state.directionRoot = null;
- state.compassRing = null;
- state.compassLabels = [];
- state.compassHeading = null;
- state.directionMarkers = [];
- state.panelMarkup = '';
- state.subjectListener = null;
- state.contextListener = null;
- state.clearListener = null;
- state.subjectClearListener = null;
- state.viewer = null;
- state.dataManager = null;
- },
- getStats() {
- return { count: state.results ? 1 : 0, lastUpdate: state.results?.evaluatedAt || null, stale: false, error: null, status: state.enabled ? 'ready' : 'idle' };
- },
- /** Return the latest read-only context result for compact HUD consumers. */
- getContextSnapshot() {
- if (!state.enabled || !state.subject) return null;
- if (!state.results) {
- return buildAwarenessContextSnapshot({
- subject: { ...state.subject },
- evaluatedAt: null,
- radiusM: AWARENESS_RADIUS_M,
- cohorts: [],
- }, navigationState(), {
- subjectPresent: !state.subjectMissing,
- });
- }
- return buildAwarenessContextSnapshot(state.results, navigationState(), {
- subjectPresent: !state.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' } = {}) {
- ++state.activationId;
- state.autoFocusAttempted = true;
- state.autoFocusRetryPending = false;
-
- const preservedSelectionKey = subjectKey(state.subject) || 'camera-release';
- state.pendingSelectionKey = preservedSelectionKey;
- try {
- flightsLayer.stopTracking?.({ origin });
- militaryFlightsLayer.stopTracking?.({ origin });
- if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.();
- } finally {
- if (state.pendingSelectionKey === preservedSelectionKey) {
- state.pendingSelectionKey = null;
- }
- }
- return Boolean(state.subject);
- },
- navigatePrevious(options = {}) { return navigateHistory(-1, options); },
- focusCurrent(options = {}) { return focusCurrentSubject(options); },
- navigateNext(options = {}) { return navigateHistory(1, options); },
- /** Select a context target through its owning layer's established tracker. */
- focusTarget(layerId, id, options = {}) { return requestFocus(layerId, id, false, options); },
-};
-
-export default militaryAwarenessLayer;
+});
+export const contextTargetFlyToAllowed = layer.contextTargetFlyToAllowed;
+export const awarenessClearMatchesSubject = layer.awarenessClearMatchesSubject;
+export const awarenessRefreshIntervalMs = layer.awarenessRefreshIntervalMs;
+export const awarenessRefreshDecision = layer.awarenessRefreshDecision;
+export const awarenessClearIsEviction = layer.awarenessClearIsEviction;
+export const awarenessRefreshRequired = layer.awarenessRefreshRequired;
+export const summarizeInstallationViewport =
+ layer.summarizeInstallationViewport;
+export const contactsWindowFromSnapshot = layer.contactsWindowFromSnapshot;
+export const buildAwarenessContextSnapshot =
+ layer.buildAwarenessContextSnapshot;
+export const collectAircraftProximityWindow =
+ layer.collectAircraftProximityWindow;
+export const _getAwarenessNavigationStateForTest =
+ layer._getAwarenessNavigationStateForTest;
+export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
+export const historySubjectSnapshot = layer.historySubjectSnapshot;
+export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex;
+export const awarenessPanelControlKey = layer.awarenessPanelControlKey;
+export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus;
+export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus;
+export const awarenessResultsAreLive = layer.awarenessResultsAreLive;
+export const awarenessNeedsContinuousRender =
+ layer.awarenessNeedsContinuousRender;
+export { AWARENESS_QUERY_LIMIT } from '../layers/awareness/index.js';
+export default layer;
diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs
index 8378cf5b0..4fd72612c 100644
--- a/src/data/militaryAwareness.test.mjs
+++ b/src/data/militaryAwareness.test.mjs
@@ -43,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',
);
diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js
index 0013146d1..af61d8c91 100644
--- a/src/data/militaryInstallations.js
+++ b/src/data/militaryInstallations.js
@@ -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} 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} 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;
diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs
index fff2c6eef..5825ed221 100644
--- a/src/data/militaryInstallations.test.mjs
+++ b/src/data/militaryInstallations.test.mjs
@@ -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');
});
diff --git a/src/layers/awareness/controls.js b/src/layers/awareness/controls.js
new file mode 100644
index 000000000..ca8fd7781
--- /dev/null
+++ b/src/layers/awareness/controls.js
@@ -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 };
+}
diff --git a/src/layers/awareness/dependencies.js b/src/layers/awareness/dependencies.js
new file mode 100644
index 000000000..15a23e455
--- /dev/null
+++ b/src/layers/awareness/dependencies.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/focus.js b/src/layers/awareness/focus.js
new file mode 100644
index 000000000..e7681b14b
--- /dev/null
+++ b/src/layers/awareness/focus.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/history.js b/src/layers/awareness/history.js
new file mode 100644
index 000000000..e8e05505a
--- /dev/null
+++ b/src/layers/awareness/history.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/index.js b/src/layers/awareness/index.js
new file mode 100644
index 000000000..cd01e0cfc
--- /dev/null
+++ b/src/layers/awareness/index.js
@@ -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';
diff --git a/src/layers/awareness/ingestion.js b/src/layers/awareness/ingestion.js
new file mode 100644
index 000000000..68d0db43f
--- /dev/null
+++ b/src/layers/awareness/ingestion.js
@@ -0,0 +1,15 @@
+export function createIngestion({
+ state: layerState,
+ services,
+ parts,
+ source,
+}) {
+ const methods = {
+ update() {
+ parts.subject.refreshSelectedSubject();
+ return Promise.resolve();
+ },
+ };
+
+ return { methods };
+}
diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js
new file mode 100644
index 000000000..ff8e30ec0
--- /dev/null
+++ b/src/layers/awareness/lifecycle.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js
new file mode 100644
index 000000000..c99f35fee
--- /dev/null
+++ b/src/layers/awareness/model.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/navigation.js b/src/layers/awareness/navigation.js
new file mode 100644
index 000000000..23505e944
--- /dev/null
+++ b/src/layers/awareness/navigation.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js
new file mode 100644
index 000000000..8a831907a
--- /dev/null
+++ b/src/layers/awareness/panel.js
@@ -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 = `
+ ${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}
+ ${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}
+
`;
+ 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 `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)} `;
+ }
+ const accessibleLabel = label === '—' ? 'Unavailable' : label;
+ return `${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)} `;
+ })
+ .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 `
+ ${escapeHtml(cohort.label)} ${count}${pageLabel}
+ ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}
+ ${nearest ? `` : ''}
+ `;
+ }
+
+ function navigationControlsHtml() {
+ const canPrevious = layerState.navigationIndex > 0;
+ return `
+ PREVIOUS
+ FOCUS
+ NEXT
+
`;
+ }
+
+ /** 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 = `${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW
+ ${navigationControlsHtml()}
+ ${cohorts.map(rowHtml).join('')}
+ Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.
`;
+ 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,
+ };
+}
diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js
new file mode 100644
index 000000000..621c3983a
--- /dev/null
+++ b/src/layers/awareness/policy.js
@@ -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',
+});
diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js
new file mode 100644
index 000000000..d5a2c9e5c
--- /dev/null
+++ b/src/layers/awareness/queries.js
@@ -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} 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,
+ };
+}
diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js
new file mode 100644
index 000000000..8ef9b2afe
--- /dev/null
+++ b/src/layers/awareness/rendering.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js
new file mode 100644
index 000000000..17ffd0e28
--- /dev/null
+++ b/src/layers/awareness/state.js
@@ -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;
+}
diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js
new file mode 100644
index 000000000..742d96f20
--- /dev/null
+++ b/src/layers/awareness/subject.js
@@ -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,
+ };
+}
diff --git a/src/layers/awareness/testing.js b/src/layers/awareness/testing.js
new file mode 100644
index 000000000..d7bee51cd
--- /dev/null
+++ b/src/layers/awareness/testing.js
@@ -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 };
+}
diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js
new file mode 100644
index 000000000..1c894f5f7
--- /dev/null
+++ b/src/layers/installations/controls.js
@@ -0,0 +1,126 @@
+import * as Cesium from 'cesium';
+import { installationFeedback } from '../../data/installationFeedback.js';
+import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
+
+export function createControls({ state: layerState, services, parts, source }) {
+ const methods = {
+ id: LAYER_ID,
+
+ name: 'Mapped Installations',
+
+ icon: '⌖',
+
+ source: 'OpenStreetMap + optional Google Maps Places',
+
+ updateInterval: 0,
+
+ statsRefreshInterval: 1000,
+
+ /** Request a one-shot Google Maps Places search around the current map view. */
+ searchNearby() {
+ layerState.googleSearchRequested = true;
+ return parts.ingestion.loadInstallations();
+ },
+
+ 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 layerState.records) {
+ if (record.kind !== 'installation') continue;
+ if (
+ parts.model.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.
+ layerState.distanceEndpointScratch.longitude = Cesium.Math.toRadians(
+ record.longitude,
+ );
+ layerState.distanceEndpointScratch.latitude = Cesium.Math.toRadians(
+ record.latitude,
+ );
+ layerState.distanceEndpointScratch.height = 0;
+ layerState.distanceGeodesicScratch.setEndPoints(
+ centerCartographic,
+ layerState.distanceEndpointScratch,
+ );
+ const distanceM = layerState.distanceGeodesicScratch.surfaceDistance;
+ if (!Number.isFinite(distanceM) || distanceM > range) continue;
+ nearby.push({
+ ...record,
+ position: Cesium.Cartesian3.fromDegrees(
+ record.longitude,
+ record.latitude,
+ parts.rendering.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 = layerState.recordById.get(String(id));
+ if (!record || !layerState.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 (!parts.selection.selectRecord(record.id)) return false;
+ layerState.viewer.camera.flyToBoundingSphere(
+ new Cesium.BoundingSphere(
+ Cesium.Cartesian3.fromDegrees(
+ record.longitude,
+ record.latitude,
+ parts.rendering.installationSurfaceHeightM(record),
+ ),
+ 18000,
+ ),
+ { duration: 1.4 },
+ );
+ return true;
+ },
+
+ getStats() {
+ return {
+ count: layerState.records.length,
+ lastUpdate: layerState.lastUpdate,
+ stale: layerState.stale,
+ saturated: layerState.saturated,
+ error: layerState.error,
+ status: layerState.status,
+ loading: layerState.loading,
+ retryAt: layerState.retryAt,
+ retrying: layerState.loading && Boolean(layerState.failureReason),
+ failureReason: layerState.failureReason,
+ statusMessage: installationFeedback({
+ ...layerState,
+ retrying: layerState.loading && Boolean(layerState.failureReason),
+ }),
+ loadingLabel: layerState.loading
+ ? 'loading mapped installation context'
+ : '',
+ };
+ },
+ };
+
+ return { methods };
+}
diff --git a/src/layers/installations/index.js b/src/layers/installations/index.js
new file mode 100644
index 000000000..d8433fe12
--- /dev/null
+++ b/src/layers/installations/index.js
@@ -0,0 +1,43 @@
+import { createModel } from './model.js';
+import { createRendering } from './rendering.js';
+import { createIngestion } from './ingestion.js';
+import { createViewport } from './viewport.js';
+import { createSelection } from './selection.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 createInstallationsLayer({ services, source }) {
+ if (
+ typeof source?.getMappedSites !== 'function' ||
+ typeof source?.searchNearby !== 'function'
+ )
+ throw new TypeError('An installation source is required');
+ const state = createState({ services });
+ const parts = {};
+ const context = { state, services, parts, source };
+ parts.model = createModel(context);
+ parts.rendering = createRendering(context);
+ parts.ingestion = createIngestion(context);
+ parts.viewport = createViewport(context);
+ parts.selection = createSelection(context);
+ parts.controls = createControls(context);
+ parts.lifecycle = createLifecycle(context);
+ return Object.assign(
+ {},
+ parts.controls.methods,
+ parts.lifecycle.methods,
+ parts.ingestion?.methods,
+ {
+ approximateSurfaceDistanceM: parts.model.approximateSurfaceDistanceM,
+ classifyGoogleMilitaryPlace: parts.model.classifyGoogleMilitaryPlace,
+ installationSourceLabel: parts.model.installationSourceLabel,
+ installationSurfaceHeightM: parts.rendering.installationSurfaceHeightM,
+ installationWithinViewport: parts.model.installationWithinViewport,
+ installationResponseSaturated: parts.model.installationResponseSaturated,
+ installationRetryDelayMs: parts.viewport.installationRetryDelayMs,
+ },
+ );
+}
+export { createInstallationSource } from './source.js';
diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js
new file mode 100644
index 000000000..7564b6025
--- /dev/null
+++ b/src/layers/installations/ingestion.js
@@ -0,0 +1,214 @@
+import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
+
+export function createIngestion({
+ state: layerState,
+ services,
+ parts,
+ source,
+}) {
+ const { governorRequestRender } = services.render;
+ const { resolveGroundFloorCellsBounded } = services.ground;
+
+ /**
+ * 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 (layerState.status === status && layerState.error === error) return;
+ layerState.status = status;
+ layerState.error = error;
+ governorRequestRender('installations-status');
+ }
+
+ async function loadInstallations() {
+ if (!layerState.enabled || !layerState.viewer) return;
+ const box = parts.viewport.viewportBox(layerState.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) {
+ layerState.abort?.abort();
+ layerState.abort = null;
+ layerState.loading = false;
+ parts.viewport.clearUnavailableRetry();
+ setInstallationStatus('zoom-in');
+ return;
+ }
+ layerState.abort?.abort();
+ const requestAbort = new AbortController();
+ layerState.abort = requestAbort;
+ layerState.loading = true;
+ parts.viewport.clearUnavailableRetry({ resetBackoff: false });
+ // The previous attempt's failure is not the outcome of this new attempt.
+ setInstallationStatus('loading');
+ try {
+ const fetchInstallations = (exact) =>
+ source.getMappedSites(box, { exact, signal: requestAbort.signal });
+
+ 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 = parts.model.installationResponseSaturated(payload);
+ if (saturated) {
+ payload = await fetchInstallations(true);
+ saturated = parts.model.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) =>
+ parts.model.installationWithinViewport(record, box),
+ );
+ let placesError = null;
+ if (layerState.googleSearchRequested) {
+ layerState.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 placesPayload = await source.searchNearby(
+ { latitude, longitude, radiusM },
+ { signal: requestAbort.signal },
+ );
+ 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 = parts.model.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 (
+ requestAbort.signal.aborted ||
+ layerState.abort !== requestAbort ||
+ !layerState.enabled ||
+ 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 ||
+ layerState.abort !== requestAbort ||
+ !layerState.enabled
+ )
+ return;
+ layerState.records = records;
+ layerState.recordById = new Map(
+ layerState.records.map((record) => [record.id, record]),
+ );
+ layerState.lastUpdate = Date.now();
+ layerState.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.
+ layerState.saturated = saturated;
+ layerState.failureReason = null;
+ parts.viewport.clearUnavailableRetry();
+ setInstallationStatus(
+ layerState.records.length
+ ? layerState.stale
+ ? 'stale'
+ : 'ready'
+ : 'empty',
+ payload.status === 'stale'
+ ? 'Serving cached mapped context'
+ : saturated
+ ? 'Too many mapped sites in view to list them all'
+ : placesError,
+ );
+ parts.rendering.renderRecords();
+ parts.rendering.warmInstallationFloors(layerState.records);
+ } catch (error) {
+ if (
+ requestAbort.signal.aborted ||
+ layerState.abort !== requestAbort ||
+ !layerState.enabled ||
+ error?.name === 'AbortError'
+ )
+ return;
+ layerState.failureReason = error?.failureReason || 'unavailable';
+ setInstallationStatus(
+ 'unavailable',
+ error?.message || 'Installation context unavailable',
+ );
+ parts.viewport.scheduleUnavailableRetry();
+ } finally {
+ // An older aborted request must not clear a newer request's busy state.
+ if (layerState.abort === requestAbort) {
+ layerState.abort = null;
+ layerState.loading = false;
+ }
+ }
+ }
+ const methods = {
+ update() {
+ return loadInstallations();
+ },
+ };
+
+ return { setInstallationStatus, loadInstallations, methods };
+}
diff --git a/src/layers/installations/lifecycle.js b/src/layers/installations/lifecycle.js
new file mode 100644
index 000000000..8df912244
--- /dev/null
+++ b/src/layers/installations/lifecycle.js
@@ -0,0 +1,62 @@
+import * as Cesium from 'cesium';
+import { LAYER_ID } from './policy.js';
+
+export function createLifecycle({
+ state: layerState,
+ services,
+ parts,
+ source,
+}) {
+ const { registerPickOwner, unregisterPickOwner } = services.picking;
+ const { clearSelectedEntityContextForLayer } = services.context;
+
+ const methods = {
+ init(viewer) {
+ layerState.viewer = viewer;
+ layerState.dataSource = new Cesium.CustomDataSource(
+ 'military-installations',
+ );
+ viewer.dataSources.add(layerState.dataSource);
+ layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
+ parts.viewport.scheduleLoad,
+ );
+ parts.selection.installInteraction(viewer);
+ },
+
+ enable() {
+ layerState.enabled = true;
+ registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
+ layerState.dataSource.show = true;
+ // DataLayerManager invokes update() immediately after enable(), which owns
+ // the first fetch. Avoid racing it with a second aborting request here.
+ },
+
+ disable() {
+ layerState.enabled = false;
+ unregisterPickOwner(LAYER_ID);
+ parts.viewport.clearUnavailableRetry();
+ clearTimeout(layerState.timer);
+ layerState.abort?.abort();
+ layerState.abort = null;
+ layerState.loading = false;
+ if (layerState.dataSource) layerState.dataSource.show = false;
+ clearSelectedEntityContextForLayer(LAYER_ID);
+ layerState.selectedId = null;
+ layerState.failureReason = null;
+ parts.ingestion.setInstallationStatus('idle');
+ },
+
+ destroy(viewer) {
+ this.disable();
+ layerState.moveEndRemove?.();
+ layerState.clickHandler?.destroy();
+ layerState.clickHandler = null;
+ parts.rendering.clearRendered();
+ if (layerState.dataSource && viewer)
+ viewer.dataSources.remove(layerState.dataSource, true);
+ layerState.dataSource = null;
+ },
+ };
+
+ return { methods };
+}
diff --git a/src/layers/installations/model.js b/src/layers/installations/model.js
new file mode 100644
index 000000000..dc72898ed
--- /dev/null
+++ b/src/layers/installations/model.js
@@ -0,0 +1,165 @@
+import * as Cesium from 'cesium';
+import {
+ EARTH_MEAN_RADIUS_M,
+ COLOR_BY_CLASS,
+ GOOGLE_MILITARY_PLACE_TYPES,
+} from './policy.js';
+
+export function createModel({ state: layerState, services, parts, source }) {
+ /**
+ * Allocation-free spherical distance used only as a conservative rejection
+ * pass before the exact ellipsoidal geodesic calculation.
+ */
+
+ 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)))
+ );
+ }
+
+ 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.
+ */
+
+ 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. */
+
+ 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';
+ }
+
+ /**
+ * 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}
+ */
+
+ 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}
+ */
+
+ 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;
+ }
+ return {
+ approximateSurfaceDistanceM,
+ colorFor,
+ classifyGoogleMilitaryPlace,
+ installationSourceLabel,
+ installationWithinViewport,
+ installationResponseSaturated,
+ };
+}
diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js
new file mode 100644
index 000000000..b64a3ed16
--- /dev/null
+++ b/src/layers/installations/policy.js
@@ -0,0 +1,21 @@
+export const LAYER_ID = 'military-installations';
+
+export const REQUEST_DEBOUNCE_MS = 500;
+
+export const MAX_VIEWPORT_DEGREES = 10;
+
+export const MAX_RENDERED = 700;
+
+export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
+
+export const COLOR_BY_CLASS = {
+ airfield: '#5aa9ff',
+ naval_base: '#48c7d5',
+ range: '#d9a85d',
+ military_land: '#9ca6b0',
+ places_candidate: '#c58cff',
+};
+
+export const EARTH_MEAN_RADIUS_M = 6371008.8;
+
+export const DISTANCE_PREFILTER_MARGIN_M = 5000;
diff --git a/src/layers/installations/rendering.js b/src/layers/installations/rendering.js
new file mode 100644
index 000000000..3ff922f49
--- /dev/null
+++ b/src/layers/installations/rendering.js
@@ -0,0 +1,195 @@
+import * as Cesium from 'cesium';
+import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
+
+export function createRendering({
+ state: layerState,
+ services,
+ parts,
+ source,
+}) {
+ const { floorAltitudeM, cachedGroundFloor } = services.ground;
+ const {
+ removeEntityContextsForLayer,
+ getSelectedEntityContext,
+ registerEntityContext,
+ selectEntityContext,
+ } = services.context;
+ const { governorRequestRender } = services.render;
+ const { warmFireAnchorFloors } = services.anchors;
+
+ /**
+ * 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.
+ */
+
+ function installationSurfaceHeightM(record) {
+ return (
+ floorAltitudeM(
+ null,
+ cachedGroundFloor(record?.latitude, record?.longitude),
+ ) ?? 0
+ );
+ }
+
+ function clearRendered() {
+ if (layerState.dataSource?.entities)
+ layerState.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} Records to render this paint.
+ */
+
+ function renderableRecords() {
+ const rendered = layerState.records.slice(0, MAX_RENDERED);
+ if (!layerState.selectedId) return rendered;
+ if (rendered.some((record) => record.id === layerState.selectedId))
+ return rendered;
+ const selected = layerState.recordById.get(layerState.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 &&
+ layerState.selectedId &&
+ selectedContext &&
+ selectedContext.id !== layerState.selectedId
+ ) {
+ layerState.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 = parts.model.colorFor(record);
+ const surfaceHeightM = installationSurfaceHeightM(record);
+ const displayPosition = Cesium.Cartesian3.fromDegrees(
+ record.longitude,
+ record.latitude,
+ surfaceHeightM,
+ );
+ const entity = layerState.dataSource.entities.add({
+ id: record.id,
+ position: displayPosition,
+ point: {
+ pixelSize: record.id === layerState.selectedId ? 13 : 9,
+ color:
+ record.id === layerState.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: parts.model.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 = layerState.selectedId
+ ? layerState.dataSource.entities.getById(layerState.selectedId)
+ : null;
+ if (selectedEntity) selectEntityContext(selectedEntity);
+ else layerState.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} 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 (!layerState.enabled || !layerState.dataSource) return;
+ if (
+ !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
+ )
+ return;
+ renderRecords();
+ });
+ }
+ return {
+ installationSurfaceHeightM,
+ clearRendered,
+ renderableRecords,
+ renderRecords,
+ warmInstallationFloors,
+ };
+}
diff --git a/src/layers/installations/selection.js b/src/layers/installations/selection.js
new file mode 100644
index 000000000..ace9124cb
--- /dev/null
+++ b/src/layers/installations/selection.js
@@ -0,0 +1,43 @@
+import * as Cesium from 'cesium';
+import { LAYER_ID } from './policy.js';
+
+export function createSelection({
+ state: layerState,
+ services,
+ parts,
+ source,
+}) {
+ const { clearSelectedEntityContextForLayer } = services.context;
+
+ function selectRecord(id) {
+ const record = layerState.recordById.get(id);
+ if (!record || !layerState.dataSource) return false;
+ layerState.selectedId = id;
+ parts.rendering.renderRecords({ claimSelection: true });
+ // renderRecords drops selectedId when the record produced no entity.
+ return layerState.selectedId === id;
+ }
+
+ function installInteraction(viewer) {
+ if (layerState.clickHandler) return;
+ layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
+ viewer.scene.canvas,
+ );
+ layerState.clickHandler.setInputAction((click) => {
+ if (!layerState.enabled) return;
+ const picked = viewer.scene.pick(click.position);
+ const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
+ if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
+ selectRecord(id);
+ } else if (layerState.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.
+ layerState.selectedId = null;
+ clearSelectedEntityContextForLayer(LAYER_ID);
+ parts.rendering.renderRecords();
+ }
+ }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
+ }
+ return { selectRecord, installInteraction };
+}
diff --git a/src/layers/installations/source.js b/src/layers/installations/source.js
new file mode 100644
index 000000000..ccbb799d4
--- /dev/null
+++ b/src/layers/installations/source.js
@@ -0,0 +1,78 @@
+/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
+export function createInstallationSource({
+ fetchImpl = (...args) => globalThis.fetch(...args),
+} = {}) {
+ return {
+ async getMappedSites(box, { exact = false, signal } = {}) {
+ const { south, west, north, east } = box || {};
+ if (
+ ![south, west, north, east].every(Number.isFinite) ||
+ south < -90 ||
+ north > 90 ||
+ west < -180 ||
+ east > 180 ||
+ north <= south ||
+ east <= west ||
+ north - south > 10 ||
+ east - west > 10
+ )
+ throw new TypeError('A bounded installation viewport is required');
+ signal?.throwIfAborted();
+ const query = new URLSearchParams(
+ Object.entries({ south, west, north, east }).map(([key, value]) => [
+ key,
+ value.toFixed(5),
+ ]),
+ );
+ if (exact) query.set('exact', '1');
+ const response = await fetchImpl(`/api/military-installations?${query}`, {
+ signal,
+ });
+ const body = await response.json();
+ signal?.throwIfAborted();
+ 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',
+ },
+ );
+ if (!Array.isArray(body?.elements))
+ throw new Error('Malformed installation snapshot');
+ return body;
+ },
+ async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) {
+ if (
+ ![latitude, longitude, radiusM].every(Number.isFinite) ||
+ Math.abs(latitude) > 90 ||
+ Math.abs(longitude) > 180 ||
+ radiusM < 1000 ||
+ radiusM > 50000
+ )
+ throw new TypeError('Invalid nearby installation search');
+ signal?.throwIfAborted();
+ const response = await fetchImpl(
+ `/api/google/text-search?${new URLSearchParams({
+ q: 'military installation',
+ lat: latitude.toFixed(5),
+ lon: longitude.toFixed(5),
+ radiusM: String(radiusM),
+ })}`,
+ { signal },
+ );
+ const payload = await response.json();
+ signal?.throwIfAborted();
+ if (!response.ok)
+ throw new Error(
+ payload?.error || `Google Places HTTP ${response.status}`,
+ );
+ if (!Array.isArray(payload?.places))
+ throw new Error('Malformed nearby-place snapshot');
+ return payload;
+ },
+ };
+}
diff --git a/src/layers/installations/source.test.mjs b/src/layers/installations/source.test.mjs
new file mode 100644
index 000000000..3aa54bbef
--- /dev/null
+++ b/src/layers/installations/source.test.mjs
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createInstallationSource } from './source.js';
+
+const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 };
+test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => {
+ const calls = [];
+ const source = createInstallationSource({
+ fetchImpl: async (url) => {
+ calls.push(new URL(url, 'https://example.test'));
+ return new Response(JSON.stringify({ elements: [], status: 'stale' }));
+ },
+ });
+ for (const invalid of [
+ null,
+ { ...box, east: Infinity },
+ { ...box, north: 90 },
+ { ...box, south: 31 },
+ ])
+ await assert.rejects(
+ source.getMappedSites(invalid),
+ /bounded installation viewport/,
+ );
+ assert.equal(calls.length, 0);
+ const payload = await source.getMappedSites(box, { exact: true });
+ assert.equal(payload.status, 'stale');
+ assert.equal(calls[0].pathname, '/api/military-installations');
+ assert.equal(calls[0].searchParams.get('exact'), '1');
+ assert.equal(calls[0].searchParams.get('south'), '30.10000');
+});
+test('malformed installation and place snapshots are never accepted as empty success', async () => {
+ const source = createInstallationSource({
+ fetchImpl: async () => new Response('{}'),
+ });
+ await assert.rejects(
+ source.getMappedSites(box),
+ /Malformed installation snapshot/,
+ );
+ await assert.rejects(
+ source.searchNearby({ latitude: 30.2, longitude: -97.7, radiusM: 1000 }),
+ /Malformed nearby-place snapshot/,
+ );
+});
+test('installation response parsing respects cancellation before any follow-on search', async () => {
+ const controller = new AbortController();
+ const source = createInstallationSource({
+ fetchImpl: async () => ({
+ ok: true,
+ json: async () => {
+ controller.abort();
+ return { elements: [] };
+ },
+ }),
+ });
+ await assert.rejects(
+ source.getMappedSites(box, { signal: controller.signal }),
+ { name: 'AbortError' },
+ );
+});
diff --git a/src/layers/installations/state.js b/src/layers/installations/state.js
new file mode 100644
index 000000000..217d54c14
--- /dev/null
+++ b/src/layers/installations/state.js
@@ -0,0 +1,37 @@
+import * as Cesium from 'cesium';
+
+export function createState({ services }) {
+ const state = {};
+
+ state.distanceEndpointScratch = new Cesium.Cartographic();
+
+ state.distanceGeodesicScratch = new Cesium.EllipsoidGeodesic();
+
+ Object.assign(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,
+ });
+ return state;
+}
diff --git a/src/layers/installations/viewport.js b/src/layers/installations/viewport.js
new file mode 100644
index 000000000..70269257e
--- /dev/null
+++ b/src/layers/installations/viewport.js
@@ -0,0 +1,84 @@
+import * as Cesium from 'cesium';
+import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js';
+
+export function createViewport({ state: layerState, services, parts, source }) {
+ 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 };
+ }
+
+ /**
+ * 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.
+ */
+
+ 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 (!layerState.enabled) return;
+ clearTimeout(layerState.retryTimer);
+ layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs);
+ layerState.retryAt = Date.now() + layerState.retryDelayMs;
+ layerState.retryTimer = setTimeout(() => {
+ layerState.retryTimer = null;
+ layerState.retryAt = 0;
+ if (layerState.enabled && !layerState.loading)
+ parts.ingestion.loadInstallations();
+ }, layerState.retryDelayMs);
+ }
+
+ function clearUnavailableRetry({ resetBackoff = true } = {}) {
+ clearTimeout(layerState.retryTimer);
+ layerState.retryTimer = null;
+ layerState.retryAt = 0;
+ if (resetBackoff) layerState.retryDelayMs = 0;
+ }
+
+ function scheduleLoad() {
+ if (!layerState.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(layerState.timer);
+ layerState.timer = setTimeout(() => {
+ parts.ingestion.loadInstallations();
+ }, REQUEST_DEBOUNCE_MS);
+ }
+ return {
+ viewportBox,
+ installationRetryDelayMs,
+ scheduleUnavailableRetry,
+ clearUnavailableRetry,
+ scheduleLoad,
+ };
+}
diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs
index b31b9f332..d2e7047aa 100644
--- a/src/testSupport/readLayerSource.mjs
+++ b/src/testSupport/readLayerSource.mjs
@@ -13,6 +13,8 @@ export function readLayerSource(file) {
'firmsHeatmap.js',
'satellites.js',
'rocketLaunches.js',
+ 'militaryInstallations.js',
+ 'militaryAwareness.js',
].includes(basename(path))
)
return readFileSync(path, 'utf8');
@@ -28,7 +30,11 @@ export function readLayerSource(file) {
? '../layers/satellites'
: basename(path) === 'rocketLaunches.js'
? '../layers/launches'
- : '../layers/vessels',
+ : basename(path) === 'militaryInstallations.js'
+ ? '../layers/installations'
+ : basename(path) === 'militaryAwareness.js'
+ ? '../layers/awareness'
+ : '../layers/vessels',
);
return readdirSync(directory)
.filter((name) => name.endsWith('.js'))