From 3b77277883396be01555225ce4337c5a06175c07 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 13:36:32 -0700 Subject: [PATCH] refactor: separate installation and proximity context components --- CHANGELOG.md | 7 + docs/CURRENT-STATE.md | 9 + package.json | 4 +- scripts/format-scope.json | 32 +- scripts/package-boundaries.json | 45 + src/cockpitMarkup.test.mjs | 3 +- src/data/militaryAwareness.js | 1986 +--------------------- src/data/militaryAwareness.test.mjs | 2 +- src/data/militaryInstallations.js | 700 +------- src/data/militaryInstallations.test.mjs | 9 +- src/layers/awareness/controls.js | 126 ++ src/layers/awareness/dependencies.js | 118 ++ src/layers/awareness/focus.js | 198 +++ src/layers/awareness/history.js | 239 +++ src/layers/awareness/index.js | 66 + src/layers/awareness/ingestion.js | 15 + src/layers/awareness/lifecycle.js | 263 +++ src/layers/awareness/model.js | 251 +++ src/layers/awareness/navigation.js | 274 +++ src/layers/awareness/panel.js | 249 +++ src/layers/awareness/policy.js | 68 + src/layers/awareness/queries.js | 164 ++ src/layers/awareness/rendering.js | 288 ++++ src/layers/awareness/state.js | 70 + src/layers/awareness/subject.js | 447 +++++ src/layers/awareness/testing.js | 15 + src/layers/installations/controls.js | 126 ++ src/layers/installations/index.js | 43 + src/layers/installations/ingestion.js | 214 +++ src/layers/installations/lifecycle.js | 62 + src/layers/installations/model.js | 165 ++ src/layers/installations/policy.js | 21 + src/layers/installations/rendering.js | 195 +++ src/layers/installations/selection.js | 43 + src/layers/installations/source.js | 78 + src/layers/installations/source.test.mjs | 59 + src/layers/installations/state.js | 37 + src/layers/installations/viewport.js | 84 + src/testSupport/readLayerSource.mjs | 8 +- 39 files changed, 4151 insertions(+), 2632 deletions(-) create mode 100644 src/layers/awareness/controls.js create mode 100644 src/layers/awareness/dependencies.js create mode 100644 src/layers/awareness/focus.js create mode 100644 src/layers/awareness/history.js create mode 100644 src/layers/awareness/index.js create mode 100644 src/layers/awareness/ingestion.js create mode 100644 src/layers/awareness/lifecycle.js create mode 100644 src/layers/awareness/model.js create mode 100644 src/layers/awareness/navigation.js create mode 100644 src/layers/awareness/panel.js create mode 100644 src/layers/awareness/policy.js create mode 100644 src/layers/awareness/queries.js create mode 100644 src/layers/awareness/rendering.js create mode 100644 src/layers/awareness/state.js create mode 100644 src/layers/awareness/subject.js create mode 100644 src/layers/awareness/testing.js create mode 100644 src/layers/installations/controls.js create mode 100644 src/layers/installations/index.js create mode 100644 src/layers/installations/ingestion.js create mode 100644 src/layers/installations/lifecycle.js create mode 100644 src/layers/installations/model.js create mode 100644 src/layers/installations/policy.js create mode 100644 src/layers/installations/rendering.js create mode 100644 src/layers/installations/selection.js create mode 100644 src/layers/installations/source.js create mode 100644 src/layers/installations/source.test.mjs create mode 100644 src/layers/installations/state.js create mode 100644 src/layers/installations/viewport.js 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 `
  • `; - }).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 ? `
      ${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 `
    - - - -
    `; -} - -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 `
  • `; + }) + .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 ? `
      ${nearest}
    ` : ''} +
    `; + } + + function navigationControlsHtml() { + const canPrevious = layerState.navigationIndex > 0; + return `
    + + + +
    `; + } + + /** 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'))