From 1edebcb6f8c56d286525d985b3c3c67e5fa8ba2e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 10:23:17 -0700 Subject: [PATCH] Format military-flight and classification components --- src/data/militaryFlights.js | 57 +- src/data/militaryRegistry.js | 3 +- src/layers/aircraft/classification.js | 269 +-- src/layers/aircraft/classification.test.mjs | 58 +- src/layers/military/controller.js | 23 +- src/layers/military/index.js | 41 +- src/layers/military/ingestion.js | 958 +++++---- src/layers/military/lifecycle.js | 522 ++--- src/layers/military/motion.js | 882 ++++---- src/layers/military/ownership.test.mjs | 78 +- src/layers/military/policy.js | 52 +- src/layers/military/queries.js | 1431 +++++++------ src/layers/military/rendering.js | 1812 ++++++++++------- src/layers/military/state.js | 503 +++-- src/layers/military/testing.js | 301 +-- src/layers/military/tracking.js | 2037 ++++++++++--------- src/testSupport/readLayerSource.mjs | 10 +- 17 files changed, 5078 insertions(+), 3959 deletions(-) diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index a0270a689..a37fc0933 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -17,18 +17,51 @@ import * as render from '../renderGovernor.js'; import * as groundSnap from './groundSnap.js'; import * as recession from './aircraftRecession.js'; -const militaryFlightsLayer = createMilitaryFlightLayer({ source: createAdsbLolSource(), resolveAsset: (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, services: { picking, sprites, trails, aircraftPresentation, camera, militaryRegistry, labels, groundFloor, meshFloor, geoid, focus, readout, context, render, groundSnap, recession } }); +const militaryFlightsLayer = createMilitaryFlightLayer({ + source: createAdsbLolSource(), + resolveAsset: (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, + services: { + picking, + sprites, + trails, + aircraftPresentation, + camera, + militaryRegistry, + labels, + groundFloor, + meshFloor, + geoid, + focus, + readout, + context, + render, + groundSnap, + recession, + }, +}); export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js'; -export const _setTrackedMilitaryRefreshStateForTest = militaryFlightsLayer.testing._setTrackedMilitaryRefreshStateForTest; -export const _setMilitaryTrackingRefreshOutcomeForTest = militaryFlightsLayer.testing._setMilitaryTrackingRefreshOutcomeForTest; -export const _addMilitaryTrackingCandidateForTest = militaryFlightsLayer.testing._addMilitaryTrackingCandidateForTest; -export const _pendingMilitaryTrackingRestoreForTest = militaryFlightsLayer.testing._pendingMilitaryTrackingRestoreForTest; -export const _applyPendingMilitaryTrackingRestoreForTest = militaryFlightsLayer.testing._applyPendingMilitaryTrackingRestoreForTest; -export const _setCockpitDetectionSubjectForTest = militaryFlightsLayer.testing._setCockpitDetectionSubjectForTest; -export const _trackedModelRegimeActiveForTest = militaryFlightsLayer.testing._trackedModelRegimeActiveForTest; -export const _updateTrackedModelForTest = militaryFlightsLayer.testing._updateTrackedModelForTest; -export const _trackedBillboardColorForTest = militaryFlightsLayer.testing._trackedBillboardColorForTest; -export const _driveFleetModelHandoffForTest = militaryFlightsLayer.testing._driveFleetModelHandoffForTest; -export const _ensureFleetModelForTest = militaryFlightsLayer.testing._ensureFleetModelForTest; +export const _setTrackedMilitaryRefreshStateForTest = + militaryFlightsLayer.testing._setTrackedMilitaryRefreshStateForTest; +export const _setMilitaryTrackingRefreshOutcomeForTest = + militaryFlightsLayer.testing._setMilitaryTrackingRefreshOutcomeForTest; +export const _addMilitaryTrackingCandidateForTest = + militaryFlightsLayer.testing._addMilitaryTrackingCandidateForTest; +export const _pendingMilitaryTrackingRestoreForTest = + militaryFlightsLayer.testing._pendingMilitaryTrackingRestoreForTest; +export const _applyPendingMilitaryTrackingRestoreForTest = + militaryFlightsLayer.testing._applyPendingMilitaryTrackingRestoreForTest; +export const _setCockpitDetectionSubjectForTest = + militaryFlightsLayer.testing._setCockpitDetectionSubjectForTest; +export const _trackedModelRegimeActiveForTest = + militaryFlightsLayer.testing._trackedModelRegimeActiveForTest; +export const _updateTrackedModelForTest = + militaryFlightsLayer.testing._updateTrackedModelForTest; +export const _trackedBillboardColorForTest = + militaryFlightsLayer.testing._trackedBillboardColorForTest; +export const _driveFleetModelHandoffForTest = + militaryFlightsLayer.testing._driveFleetModelHandoffForTest; +export const _ensureFleetModelForTest = + militaryFlightsLayer.testing._ensureFleetModelForTest; export const mapAnalystRecord = militaryFlightsLayer.mapAnalystRecord; export default militaryFlightsLayer; diff --git a/src/data/militaryRegistry.js b/src/data/militaryRegistry.js index b277b1018..8cd3e1ed9 100644 --- a/src/data/militaryRegistry.js +++ b/src/data/militaryRegistry.js @@ -7,4 +7,5 @@ export const setMilitaryLayerActive = registry.setMilitaryLayerActive; export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange; export const registerMilitaryIcaos = registry.registerMilitaryIcaos; export const isMilitaryIcao = registry.isMilitaryIcao; -export const refreshMilitaryRegistryIfStale = registry.refreshMilitaryRegistryIfStale; +export const refreshMilitaryRegistryIfStale = + registry.refreshMilitaryRegistryIfStale; diff --git a/src/layers/aircraft/classification.js b/src/layers/aircraft/classification.js index f927325c0..2db7ee9aa 100644 --- a/src/layers/aircraft/classification.js +++ b/src/layers/aircraft/classification.js @@ -1,143 +1,160 @@ /** Instance-owned military identity and active-layer classification. * Both aircraft layers share the same application-supplied registry. */ export function createMilitaryRegistry({ source, now = Date.now } = {}) { -const MIL_POLL_INTERVAL_MS = 60000; + const MIL_POLL_INTERVAL_MS = 60000; -/** @type {Set} Lowercase ICAO24 hexes known to be military. */ -const _milIcaos = new Set(); -/** @type {boolean} True while the dedicated military layer is enabled. */ -let _militaryLayerActive = false; -/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */ -const _activeChangeListeners = new Set(); -/** @type {number} Epoch ms of the last registry refresh (any source). */ -let _lastRefreshMs = 0; -/** @type {boolean} A self-poll fetch is in flight. */ -let _polling = false; -let currentSource = null; -let lifetime = null; -let releaseSource = null; + /** @type {Set} Lowercase ICAO24 hexes known to be military. */ + const _milIcaos = new Set(); + /** @type {boolean} True while the dedicated military layer is enabled. */ + let _militaryLayerActive = false; + /** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */ + const _activeChangeListeners = new Set(); + /** @type {number} Epoch ms of the last registry refresh (any source). */ + let _lastRefreshMs = 0; + /** @type {boolean} A self-poll fetch is in flight. */ + let _polling = false; + let currentSource = null; + let lifetime = null; + let releaseSource = null; -/** Replace the classification source and cancel its outstanding work. */ -function configureSource(nextSource, { signal } = {}) { - if (typeof nextSource?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); - releaseSource?.(); - const controller = new AbortController(); - lifetime = controller; - currentSource = nextSource; - _milIcaos.clear(); - _lastRefreshMs = 0; - _polling = false; - const release = () => { - controller.abort(); - signal?.removeEventListener('abort', release); - if (lifetime !== controller) return; - currentSource = null; - lifetime = null; - releaseSource = null; + /** Replace the classification source and cancel its outstanding work. */ + function configureSource(nextSource, { signal } = {}) { + if (typeof nextSource?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + releaseSource?.(); + const controller = new AbortController(); + lifetime = controller; + currentSource = nextSource; _milIcaos.clear(); _lastRefreshMs = 0; _polling = false; - }; - releaseSource = release; - if (signal?.aborted) release(); - else signal?.addEventListener('abort', release, { once: true }); - return release; -} + const release = () => { + controller.abort(); + signal?.removeEventListener('abort', release); + if (lifetime !== controller) return; + currentSource = null; + lifetime = null; + releaseSource = null; + _milIcaos.clear(); + _lastRefreshMs = 0; + _polling = false; + }; + releaseSource = release; + if (signal?.aborted) release(); + else signal?.addEventListener('abort', release, { once: true }); + return release; + } -/** - * True when the dedicated military layer currently renders these aircraft - * (the flights layer should suppress duplicates rather than restyle them). - * @returns {boolean} - */ -function isMilitaryLayerActive() { - return _militaryLayerActive; -} + /** + * True when the dedicated military layer currently renders these aircraft + * (the flights layer should suppress duplicates rather than restyle them). + * @returns {boolean} + */ + function isMilitaryLayerActive() { + return _militaryLayerActive; + } -/** - * Marks the dedicated military layer enabled/disabled. On a TRANSITION - * (value actually changed) the registered change listeners fire so the - * flights layer can reconcile its duplicates immediately instead of waiting - * out its 30 s poll (pre-ship audit M2). - * @param {boolean} active - Whether the military layer renders. - * @returns {void} - */ -function setMilitaryLayerActive(active) { - const next = !!active; - if (next === _militaryLayerActive) return; - _militaryLayerActive = next; - for (const listener of _activeChangeListeners) { - try { - listener(next); - } catch { - // a broken listener must never break the layer toggle + /** + * Marks the dedicated military layer enabled/disabled. On a TRANSITION + * (value actually changed) the registered change listeners fire so the + * flights layer can reconcile its duplicates immediately instead of waiting + * out its 30 s poll (pre-ship audit M2). + * @param {boolean} active - Whether the military layer renders. + * @returns {void} + */ + function setMilitaryLayerActive(active) { + const next = !!active; + if (next === _militaryLayerActive) return; + _militaryLayerActive = next; + for (const listener of _activeChangeListeners) { + try { + listener(next); + } catch { + // a broken listener must never break the layer toggle + } } } -} -/** - * Subscribes to military-layer active-state transitions (fired only when the - * value changes, AFTER the new state is committed). - * @param {(active: boolean) => void} listener - Change callback. - * @returns {() => void} Unsubscribe function. - */ -function onMilitaryLayerActiveChange(listener) { - if (typeof listener !== 'function') return () => {}; - _activeChangeListeners.add(listener); - return () => _activeChangeListeners.delete(listener); -} - -/** - * Replaces/extends the known-military set from a fresh poll. - * Adds only — transient dropouts from one poll must not declassify an - * aircraft mid-session (the set stays small: a few hundred hexes). - * @param {Iterable} icaos - ICAO24 hexes from a /v2/mil response. - * @returns {void} - */ -function registerMilitaryIcaos(icaos) { - for (const icao of icaos || []) { - const hex = String(icao || '').trim().toLowerCase(); - if (hex) _milIcaos.add(hex); + /** + * Subscribes to military-layer active-state transitions (fired only when the + * value changes, AFTER the new state is committed). + * @param {(active: boolean) => void} listener - Change callback. + * @returns {() => void} Unsubscribe function. + */ + function onMilitaryLayerActiveChange(listener) { + if (typeof listener !== 'function') return () => {}; + _activeChangeListeners.add(listener); + return () => _activeChangeListeners.delete(listener); } - _lastRefreshMs = now(); -} -/** - * Whether an aircraft is known military. - * @param {string} icao24 - ICAO24 hex (any case). - * @returns {boolean} - */ -function isMilitaryIcao(icao24) { - return _milIcaos.has(String(icao24 || '').toLowerCase()); -} - -/** Refresh known identities while the dedicated layer is disabled. - * A failed or cancelled request retains the current set and never swaps sources. */ -async function refreshMilitaryRegistryIfStale() { - if (!currentSource || !lifetime || lifetime.signal.aborted || _militaryLayerActive) return; - if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return; - const owner = lifetime; - const requestSource = currentSource; - const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]); - _polling = true; - try { - const snapshot = await requestSource.getSnapshot({}, { signal }); - signal.throwIfAborted(); - if (lifetime !== owner) return; - registerMilitaryIcaos(snapshot.records.map((record) => record.id)); - } catch { - // Keep the existing classification through source outages. - } finally { - if (lifetime === owner) _polling = false; + /** + * Replaces/extends the known-military set from a fresh poll. + * Adds only — transient dropouts from one poll must not declassify an + * aircraft mid-session (the set stays small: a few hundred hexes). + * @param {Iterable} icaos - ICAO24 hexes from a /v2/mil response. + * @returns {void} + */ + function registerMilitaryIcaos(icaos) { + for (const icao of icaos || []) { + const hex = String(icao || '') + .trim() + .toLowerCase(); + if (hex) _milIcaos.add(hex); + } + _lastRefreshMs = now(); } -} -function dispose() { - releaseSource?.(); - _milIcaos.clear(); - _lastRefreshMs = 0; - _activeChangeListeners.clear(); - _militaryLayerActive = false; -} -if (source) configureSource(source); -return { configureSource, dispose, isMilitaryLayerActive, setMilitaryLayerActive, - onMilitaryLayerActiveChange, registerMilitaryIcaos, isMilitaryIcao, refreshMilitaryRegistryIfStale }; + + /** + * Whether an aircraft is known military. + * @param {string} icao24 - ICAO24 hex (any case). + * @returns {boolean} + */ + function isMilitaryIcao(icao24) { + return _milIcaos.has(String(icao24 || '').toLowerCase()); + } + + /** Refresh known identities while the dedicated layer is disabled. + * A failed or cancelled request retains the current set and never swaps sources. */ + async function refreshMilitaryRegistryIfStale() { + if ( + !currentSource || + !lifetime || + lifetime.signal.aborted || + _militaryLayerActive + ) + return; + if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return; + const owner = lifetime; + const requestSource = currentSource; + const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]); + _polling = true; + try { + const snapshot = await requestSource.getSnapshot({}, { signal }); + signal.throwIfAborted(); + if (lifetime !== owner) return; + registerMilitaryIcaos(snapshot.records.map((record) => record.id)); + } catch { + // Keep the existing classification through source outages. + } finally { + if (lifetime === owner) _polling = false; + } + } + function dispose() { + releaseSource?.(); + _milIcaos.clear(); + _lastRefreshMs = 0; + _activeChangeListeners.clear(); + _militaryLayerActive = false; + } + if (source) configureSource(source); + return { + configureSource, + dispose, + isMilitaryLayerActive, + setMilitaryLayerActive, + onMilitaryLayerActiveChange, + registerMilitaryIcaos, + isMilitaryIcao, + refreshMilitaryRegistryIfStale, + }; } diff --git a/src/layers/aircraft/classification.test.mjs b/src/layers/aircraft/classification.test.mjs index c2fa9ca00..0958fb10e 100644 --- a/src/layers/aircraft/classification.test.mjs +++ b/src/layers/aircraft/classification.test.mjs @@ -4,7 +4,11 @@ import { createMilitaryRegistry } from './classification.js'; test('classification instances have independent identities, listeners and active states', () => { let requests = 0; - const source = { getSnapshot() { requests++; } }; + const source = { + getSnapshot() { + requests++; + }, + }; const first = createMilitaryRegistry({ source }); const second = createMilitaryRegistry({ source }); let firstEvents = 0; @@ -26,9 +30,15 @@ test('classification instances have independent identities, listeners and active test('classification uses normalized identities and respects its polling interval and active layer', async () => { let time = 120000; let calls = 0; - const registry = createMilitaryRegistry({ now: () => time, source: { - async getSnapshot() { calls++; return { records: [{ id: 'abc123' }] }; }, - } }); + const registry = createMilitaryRegistry({ + now: () => time, + source: { + async getSnapshot() { + calls++; + return { records: [{ id: 'abc123' }] }; + }, + }, + }); await registry.refreshMilitaryRegistryIfStale(); await registry.refreshMilitaryRegistryIfStale(); assert.equal(calls, 1); @@ -48,15 +58,29 @@ test('source replacement rejects an old response and disposal cancels the curren let resolveNew; let oldSignal; let newSignal; - const registry = createMilitaryRegistry({ source: { - getSnapshot(query, { signal }) { oldSignal = signal; return new Promise((resolve) => { resolveOld = resolve; }); }, - } }); + const registry = createMilitaryRegistry({ + source: { + getSnapshot(query, { signal }) { + oldSignal = signal; + return new Promise((resolve) => { + resolveOld = resolve; + }); + }, + }, + }); const old = registry.refreshMilitaryRegistryIfStale(); const application = new AbortController(); - registry.configureSource({ getSnapshot(query, { signal }) { - newSignal = signal; - return new Promise((resolve) => { resolveNew = resolve; }); - } }, { signal: application.signal }); + registry.configureSource( + { + getSnapshot(query, { signal }) { + newSignal = signal; + return new Promise((resolve) => { + resolveNew = resolve; + }); + }, + }, + { signal: application.signal }, + ); const current = registry.refreshMilitaryRegistryIfStale(); resolveOld({ records: [{ id: 'abc123' }] }); await old; @@ -73,9 +97,15 @@ test('source replacement rejects an old response and disposal cancels the curren test('a failed classification source preserves known identities and makes no fallback request', async () => { let time = 120000; let calls = 0; - const registry = createMilitaryRegistry({ now: () => time, source: { - async getSnapshot() { calls++; throw new Error('fixture source unavailable'); }, - } }); + const registry = createMilitaryRegistry({ + now: () => time, + source: { + async getSnapshot() { + calls++; + throw new Error('fixture source unavailable'); + }, + }, + }); registry.registerMilitaryIcaos(['abc123']); time += 60000; await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale()); diff --git a/src/layers/military/controller.js b/src/layers/military/controller.js index afc09eca3..d1f172819 100644 --- a/src/layers/military/controller.js +++ b/src/layers/military/controller.js @@ -1,11 +1,14 @@ - -export function createController({ flightState, services, parts, layer, resolveAsset }) { - - - -function _abortActiveUpdates() { - for (const controller of flightState._activeUpdateControllers) controller.abort(); - flightState._activeUpdateControllers.clear(); -} -return { _abortActiveUpdates }; +export function createController({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + function _abortActiveUpdates() { + for (const controller of flightState._activeUpdateControllers) + controller.abort(); + flightState._activeUpdateControllers.clear(); + } + return { _abortActiveUpdates }; } diff --git a/src/layers/military/index.js b/src/layers/military/index.js index 2c59ee47a..c972bebb1 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -8,21 +8,30 @@ import { createLifecycle } from './lifecycle.js'; import { createTesting } from './testing.js'; import { createQueries } from './queries.js'; /** Compose one military-flight layer with application-owned scene services. */ -export function createMilitaryFlightLayer({ source, services, resolveAsset = (url) => url } = {}) { -const flightState = createFlightState({ source, services }); -const parts = {}; -const layer = {}; -const context = { flightState, services, parts, layer, resolveAsset }; -parts.rendering = createRendering(context); -parts.motion = createMotion(context); -parts.tracking = createTracking(context); -parts.controller = createController(context); -parts.ingestion = createIngestion(context); -parts.lifecycle = createLifecycle(context); -parts.testing = createTesting(context); -parts.queries = createQueries(context); -Object.assign(layer, parts.queries.methods, parts.lifecycle.methods, parts.ingestion.methods); -Object.defineProperty(layer, 'testing', { value: parts.testing }); -return layer; +export function createMilitaryFlightLayer({ + source, + services, + resolveAsset = (url) => url, +} = {}) { + const flightState = createFlightState({ source, services }); + const parts = {}; + const layer = {}; + const context = { flightState, services, parts, layer, resolveAsset }; + parts.rendering = createRendering(context); + parts.motion = createMotion(context); + parts.tracking = createTracking(context); + parts.controller = createController(context); + parts.ingestion = createIngestion(context); + parts.lifecycle = createLifecycle(context); + parts.testing = createTesting(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + Object.defineProperty(layer, 'testing', { value: parts.testing }); + return layer; } export { TRACKED_MODEL_MAX_PX } from './policy.js'; diff --git a/src/layers/military/ingestion.js b/src/layers/military/ingestion.js index bfe8a0034..1dbc8e0ff 100644 --- a/src/layers/military/ingestion.js +++ b/src/layers/military/ingestion.js @@ -2,438 +2,546 @@ import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import * as Cesium from 'cesium'; import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; import { classifyAircraft, CLASS_SCALE_2D } from '../../data/aircraftClass.js'; -import { turnRateFromFixHistory, liftRepeatedGroundFix, synthesizeForwardKinematicsFix } from '../../data/motionModel.js'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; -import { GROUND_FLOOR_WARM_MAX_ALT_M, POSITION_HISTORY_LIMIT, BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR, MIL_ICON_COLOR, LANDED_MISSING_POLL_LIMIT, MISSING_POLL_LIMIT, ERROR_BACKOFF_INTERVAL } from './policy.js'; +import { + GROUND_FLOOR_WARM_MAX_ALT_M, + POSITION_HISTORY_LIMIT, + BILLBOARD_SCALE, + GROUND_SCALE, + TRACKED_ICON_COLOR, + MIL_ICON_COLOR, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, + ERROR_BACKOFF_INTERVAL, +} from './policy.js'; -export function createIngestion({ flightState, services, parts, layer, resolveAsset }) { -const { geoidHeight } = services.geoid; -const { floorAltitudeM, cachedGroundFloor, warmGroundFloor, GROUND_FLOOR_LIFT_M } = services.groundFloor; -const { sampleMeshFloorCells } = services.meshFloor; -const { registerMilitaryIcaos } = services.militaryRegistry; -const militaryFlightsLayer = layer; +export function createIngestion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { geoidHeight } = services.geoid; + const { + floorAltitudeM, + cachedGroundFloor, + warmGroundFloor, + GROUND_FLOOR_LIFT_M, + } = services.groundFloor; + const { sampleMeshFloorCells } = services.meshFloor; + const { registerMilitaryIcaos } = services.militaryRegistry; + const militaryFlightsLayer = layer; -const methods = { - - - /** - * Fetch the latest military aircraft positions from adsb.lol and reconcile - * with the billboard collection. Handles error backoff, feed-time-stamped - * position history updates, and grace-period removal of absent aircraft. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - * @returns {Promise} - */ - async update(viewer, { signal = null } = {}) { - const nowMs = Date.now(); - const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; - flightState._lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - ids: new Set(), - source: flightState._lastSource, - }; - if (flightState._retryAt && nowMs < flightState._retryAt) { - flightState._backoff = true; - return; - } - - const resourceController = new AbortController(); - flightState._activeUpdateControllers.add(resourceController); - const updateSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - try { - updateSignal.throwIfAborted(); - const snapshot = await flightState._source.getSnapshot({}, { signal: updateSignal }); - updateSignal.throwIfAborted(); - flightState._lastStatus = snapshot.status ?? 200; - flightState._lastSource = snapshot.source; - militaryFlightsLayer.source = flightState._lastSource; - const usableAircraft = snapshot.records; - flightState._backoff = snapshot.stale || snapshot.freshness === 'unknown'; - flightState._retryAt = 0; - flightState._lastError = snapshot.reason || (snapshot.freshness === 'unknown' ? 'Source snapshot time unavailable' : null); - const currentIcaos = new Set(); - const receiptNowMs = snapshot.observedAtMs; - // Field-test fix (RS46): coarse floor cells to warm for the below-ground - // clamp — collected during the loop (low airborne contacts only), - // batch-resolved once after it. Never a fetch inside the loop. - const _floorWarmPoints = []; - - for (const aircraft of usableAircraft) { - const { id: icao24, longitude: lon, latitude: lat } = aircraft; - - currentIcaos.add(icao24); - flightState._missingPolls.delete(icao24); - - const prevMeta = flightState._flightData.get(icao24); - // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no - // separate boolean). Grounded planes fall back to their last known - // altitude (field elevation is unknowable here), else 0 m — never the - // 3 km airborne default (a parked plane must not float). - const onGround = aircraft.onGround; - const altitudeFt = aircraft.baroAltitudeM == null ? null : aircraft.baroAltitudeM / 0.3048; - const altitudeM = aircraft.baroAltitudeM - ?? (onGround ? (Number.isFinite(prevMeta?.altitudeFt) ? prevMeta.altitudeFt * 0.3048 : 0) : 3048); - const track = aircraft.courseDeg || 0; - const speedMps = aircraft.speedMps ?? 0; - const verticalRateMps = aircraft.verticalRateMps; - const callsign = aircraft.callsign; - const type = aircraft.typeCode; - const registration = aircraft.registration; - const operator = aircraft.operator; - const geoAltitudeM = aircraft.ellipsoidAltitudeM; - const baroAltitudeM = aircraft.baroAltitudeM; - - // geoid undulation N: cached per-aircraft (negligible drift — see - // task brief) once the geoid grid has loaded; unavailable pre-load - // just means the baro fallback branch below adds N=0 for a beat. - let geoidN = flightState._geoidNCache.get(icao24); - if (geoidN === undefined && flightState._geoidReady) { - geoidN = geoidHeight(lat, lon); - flightState._geoidNCache.set(icao24, geoidN); - } - - // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a - // grounded plane's MODEL already rides groundSnap.js's one-shot tileset - // sample (_modelDisplayPosition), which is the visual on the ground, and - // its billboard is depth-test-free (_groundDepthDistance) so its exact - // height is cosmetic. Deliberately pass surfaceM=null so - // pickRenderAltitudeM's on-ground surface branch never fires here: - // 1. It would be the SECOND correction of the same grounded plane - // (model tileset-snap is the first) — the exact double-correct the - // brief forbids. - // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the - // grounded billboard sits at 0 m until a surface value warms; letting - // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls - // drags the model's ground-snap input past groundSnap's 50 m - // move-invalidation threshold and forces a re-sample every time the - // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant - // the track regression locks (qa: sampleHeight count must stay flat). - // Grounded planes therefore keep the pre-existing `altitudeM` default - // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what - // matters for AIRBORNE military planes — the actual "renders at MSL" bug. - const pickedAltM = pickRenderAltitudeM({ - geoAltM: geoAltitudeM, - baroAltM: baroAltitudeM, - onGround, - surfaceM: null, - geoidN, - }); - // pickRenderAltitudeM returns the sentinel `null` only when NEITHER - // alt_geom nor alt_baro was ever reported for this aircraft (not even - // stickily) — fall back to the SAME existing default policy `altitudeM` - // already uses (which also carries the on-ground 0 m / last-known-baro - // case), so the two never disagree on the "no data yet" case. - let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; - // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only - // AIRBORNE contact near steep terrain can compute a render height - // BELOW the local surface (no alt_geom; baro+N carries QNH error - // larger than the height above ground). Floor it at the coarse-grid - // ellipsoidal ground (warm-cache read only — the batch warm below - // fills cells for later polls). Grounded contacts are deliberately - // NOT touched: their model rides groundSnap's tileset sample and - // their billboard is depth-test-free (see the surfaceM:null block - // above — same one-shot-invariant reasoning). - if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, cachedGroundFloor(lat, lon)); - _floorWarmPoints.push({ lat, lon }); - } else if (onGround) { - // Grounded contacts: warm the floor cell, and — round 4 — when NO - // 3D model owns this contact's visual, lift the billboard itself - // onto the floor (mesh-first): the R20053 heli sat "straight up in - // the ground" because grounded rows render at the legacy ~0 m. - // With a model present the billboard stays put (it hides behind - // the tileset-snapped model, and moving it would drag groundSnap's - // input past its move-invalidation threshold — the T7 one-shot - // invariant the track regression locks). - _floorWarmPoints.push({ lat, lon }); - const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); - if (!modelOwnsVisual) { - const floor = cachedGroundFloor(lat, lon); - if (Number.isFinite(floor)) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); - } - } - } - - const position = Cesium.Cartesian3.fromDegrees(lon, lat, renderAltitudeM); - // Landing/takeoff transition: the ground flip restyles IN PLACE. - const groundFlipped = !!prevMeta && (prevMeta.onGround === true) !== onGround; - // Either flip direction retires the model's ground snap: a departing plane - // flies free of it, a landing plane earns a fresh sample where it rolls out. - if (groundFlipped) flightState._groundSnap.forget(icao24); - - // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold - // last-known-good (bounded by the layer's eviction, which deletes the entry). - const stickyType = stickyText(type, prevMeta?.type); - const meta = { - sourceReference: aircraft.reference, - observedReceiptMs: Date.now(), - callsign: stickyText(callsign, prevMeta?.callsign), - type: stickyType, - // Type outranks category automatically inside classifyAircraft. - klass: classifyAircraft({ typeCode: stickyType, category: aircraft?.category }), - registration: stickyText(registration, prevMeta?.registration), - operator: stickyText(operator, prevMeta?.operator), - altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), - // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the - // untouched aviation `altitudeFt` — never rename/replace it (labels, - // the FL readout, and the landed-fast-cull heuristic all still read - // altitudeFt/baro). - geoAltitudeM, - renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), - // Analyst seam (additive): sticky like the other kinematics. - verticalRateMps: stickyNumber(verticalRateMps, prevMeta?.verticalRateMps, null), - lastContactEpochMs: stickyNumber( - aircraft.contactTimeMs, - prevMeta?.lastContactEpochMs, - null, - ), - turnRateDps: prevMeta?.turnRateDps || 0, - onGround, - // Round 7: sticky airborne history (see _likelyLanded). - wasAirborne: prevMeta?.wasAirborne === true || !onGround, - // Round 6: lifted occlusion-test point for at/below-ellipsoid - // renders (mirror of flights.js). - cullPosition: renderAltitudeM < 10 ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) : null, - // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded - // re-floor sweep keys floors off these (mirror of flights.js). - rawLat: lat, - rawLon: lon, - }; - flightState._flightData.set(icao24, meta); - - const isTracked = icao24 === flightState._trackedIcao; - - // Append to position history stamped with the FEED's fix epoch. - // adsb.lol's seen_pos is the AGE in seconds of the last position - // report, so the fix epoch is receipt time minus that age. Only - // append when the fix actually advances, so stale repeats don't - // create zero-dt segments. - const fixEpochMs = aircraft.positionTimeMs ?? receiptNowMs; - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); - if (!flightState._positionHistory.has(icao24)) { - flightState._positionHistory.set(icao24, []); - } - const history = flightState._positionHistory.get(icao24); - const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { - // Per-fix kinematics: the fix's own velocity/track ride along so the - // extrapolation paths use the values that BELONG to the fix they - // extend, not whatever the latest poll reported. - history.push({ - time: fixTime, - epochMs: fixEpochMs, - position: position.clone(), - velocity: meta.speedMps, - track: meta.track, - }); - if (history.length > POSITION_HISTORY_LIMIT) { - history.shift(); - } - // Turn rate from the fix-track history — computed once per new fix - // (≤5 samples), consumed by the extrapolation paths at tick rate. - meta.turnRateDps = turnRateFromFixHistory(history); - // Trail accumulation is separate from the 5-fix DR history (PRD F2) - // so the visible trail keeps growing while tracked. Round 2 - // (owner): ground traffic appends too — taxi history stays live - // after touchdown; _appendTrailFix floors grounded positions at - // the surface so the ground leg drapes instead of diving. - if (isTracked) parts.tracking._appendTrailFix(position.clone()); - } else { - const modelOwnsGroundVisual = parts.rendering._modelOwnsVisual(icao24); - if (!modelOwnsGroundVisual) { - liftRepeatedGroundFix(newest, position, meta.onGround); - } - const kinematicsChanged = newest.velocity !== meta.speedMps - || newest.track !== meta.track; - if (kinematicsChanged) { - const synthetic = synthesizeForwardKinematicsFix(newest, { - epochMs: Date.now(), - velocity: meta.speedMps, - track: meta.track, - turnRateDps: meta.turnRateDps, - }); - if (synthetic) { - history.push(synthetic); - if (history.length > POSITION_HISTORY_LIMIT) history.shift(); - meta.turnRateDps = turnRateFromFixHistory(history); - if (isTracked) parts.tracking._appendTrailFix(synthetic.position.clone()); - } - } - } - - if (flightState._billboards.has(icao24)) { - const bb = flightState._billboards.get(icao24); - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - // Ground flips (landing/takeoff) restyle this SAME billboard in - // place — the transition is never a removal. - if (prevMeta?.klass !== meta.klass || groundFlipped || flightState._cockpitContactMode) { - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) parts.rendering._syncModelToClass(icao24); - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - } else { - const bb = flightState._billboardCollection.add({ - position, - image: aircraftIcon(parts.rendering._iconKind(icao24, meta.klass)), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[meta.klass] || 1) * (meta.onGround ? GROUND_SCALE : 1), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, - sizeInMeters: false, - scaleByDistance: parts.rendering._normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: parts.rendering._groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - flightState._billboards.set(icao24, bb); - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - } - - // Takeoff while TRACKED: ground traffic drew no trail, so start one - // from the fresh airborne history (touchdown needs no action — the - // append gate above simply freezes the existing trail). - if (isTracked && groundFlipped && !meta.onGround) parts.tracking._startTrail(icao24); - - // If this is the tracked aircraft, update label text - // (position updates automatically via dead-reckoning CallbackProperty) - if (isTracked && flightState._trackedEntity) { - const info = flightState._flightData.get(icao24); - parts.tracking._updateTrackedLabelModel(icao24); - } - } - - // Field-test fix (RS46): one batch warm of the low-airborne floor cells - // collected above — fire-and-forget, single-flight, results read - // synchronously by NEXT poll's clamp (same contract as flights.js's - // grounded-surface warm). - warmGroundFloor(_floorWarmPoints); - // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — - // a parked contact whose feed went quiet froze at its pre-warm height. - // The model-ownership gate matches the live grounded-clamp path (a - // tileset-snapped model owns its visual; moving its billboard would - // disturb groundSnap's input — the T7 one-shot invariant). - for (const [icao24, info] of flightState._flightData) { - if (!info?.onGround || currentIcaos.has(icao24)) continue; - if (parts.rendering._modelOwnsVisual(icao24)) continue; - if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) continue; - const floor = cachedGroundFloor(info.rawLat, info.rawLon); - if (!Number.isFinite(floor)) continue; - const lifted = floor + GROUND_FLOOR_LIFT_M; - if (Number.isFinite(info.renderAltitudeM) && info.renderAltitudeM >= floor - 1) continue; - info.renderAltitudeM = lifted; - info.cullPosition = null; - const liftedPos = Cesium.Cartesian3.fromDegrees(info.rawLon, info.rawLat, lifted); - const hist = flightState._positionHistory.get(icao24); - const newest = hist?.[hist.length - 1]; - if (newest) newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position); - const bbStale = flightState._billboards.get(icao24); - if (bbStale) bbStale.position = liftedPos; - } - // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, - // budget-capped, viewer-proximate, google-3d regime only), excluding - // this layer's own billboards/models from the probe. - { - const viewerCarto = flightState._viewer?.camera?.positionCartographic || null; - sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { - excludeObjects: [...flightState._billboards.values(), ...flightState._models.values(), flightState._trackedModel].filter(Boolean), - viewerLat: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.latitude) : undefined, - viewerLon: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.longitude) : undefined, - }); - } - - // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. - // adsb.lol routinely drops aircraft for a single poll; immediate removal - // made icons blink and yanked the camera off actively tracked flights. - // EXCEPTION: likely-landed planes (last fix low + slow) get only - // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a - // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of flightState._billboards) { - if (currentIcaos.has(icao24)) continue; - // Partial admissions do not prove absence, but stale retention is bounded. - if (!snapshot.complete && Date.now() - (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < 300000) continue; - const misses = (flightState._missingPolls.get(icao24) || 0) + 1; - const limit = parts.queries._likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; - if (misses < limit) { - flightState._missingPolls.set(icao24, misses); - if (icao24 === flightState._trackedIcao && flightState._trackedEntity) { - // Honest readout: the tracked plane has no faded billboard (its - // entity owns the visual), so refresh the label — with icao24 now - // in _missingPolls, _buildTrackedLabel appends the STALE cue so - // last-known altitude/speed aren't presented as live. - parts.tracking._updateTrackedLabelModel(icao24); - } - continue; - } - flightState._missingPolls.delete(icao24); - - // If the tracked flight is truly gone, clear tracking BEFORE deleting - // its state (M3 ordering, keep it): teardown reads the maps this loop - // is about to delete (billboard restore, DR cache reset), and we must - // never leave the camera mid-follow with stale tracking state. The - // camera is then RELEASED IN PLACE — it stays where the follow left - // it, fully free (owner decision 2026-07-02: no overview flyTo). - if (icao24 === flightState._trackedIcao) { - parts.tracking._clearTracking(false, { evicted: true }); - } - - flightState._billboardCollection.remove(bb); - flightState._billboards.delete(icao24); - parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) - flightState._flightData.delete(icao24); - flightState._positionHistory.delete(icao24); - flightState._displayCourse.delete(icao24); - flightState._groundSnap.forget(icao24); - flightState._geoidNCache.delete(icao24); - } - - // Fresh courses arrived — force a rotation pass on the next fleet tick - flightState._lastCamPoseSig = ''; - - // Feed the shared registry so the flights layer can classify/suppress - registerMilitaryIcaos(currentIcaos); - - flightState._count = flightState._billboards.size; - flightState._lastUpdate = receiptNowMs; + const methods = { + /** + * Fetch the latest military aircraft positions from adsb.lol and reconcile + * with the billboard collection. Handles error backoff, feed-time-stamped + * position history updates, and grace-period removal of absent aircraft. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * @returns {Promise} + */ + async update(viewer, { signal = null } = {}) { + const nowMs = Date.now(); + const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; flightState._lastTrackingRefreshOutcome = { epoch: trackingRefreshEpoch, - status: 'accepted', - ids: currentIcaos, + status: 'source-unavailable', + ids: new Set(), source: flightState._lastSource, }; - console.log(`[Data:Military] Updated: ${flightState._count} aircraft`); - parts.tracking._applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Military update aborted', 'AbortError'); + if (flightState._retryAt && nowMs < flightState._retryAt) { + flightState._backoff = true; + return; } - console.warn('[Data:Military] Fetch error:', e); - flightState._backoff = true; - flightState._retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); - flightState._lastStatus = e?.status ?? null; - if (e?.source) { flightState._lastSource = e.source; this.source = flightState._lastSource; } - flightState._lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; - } finally { - flightState._activeUpdateControllers.delete(resourceController); - } - } -}; -return { methods }; + const resourceController = new AbortController(); + flightState._activeUpdateControllers.add(resourceController); + const updateSignal = signal + ? AbortSignal.any([signal, resourceController.signal]) + : resourceController.signal; + try { + updateSignal.throwIfAborted(); + const snapshot = await flightState._source.getSnapshot( + {}, + { signal: updateSignal }, + ); + updateSignal.throwIfAborted(); + flightState._lastStatus = snapshot.status ?? 200; + flightState._lastSource = snapshot.source; + militaryFlightsLayer.source = flightState._lastSource; + const usableAircraft = snapshot.records; + flightState._backoff = + snapshot.stale || snapshot.freshness === 'unknown'; + flightState._retryAt = 0; + flightState._lastError = + snapshot.reason || + (snapshot.freshness === 'unknown' + ? 'Source snapshot time unavailable' + : null); + const currentIcaos = new Set(); + const receiptNowMs = snapshot.observedAtMs; + // Field-test fix (RS46): coarse floor cells to warm for the below-ground + // clamp — collected during the loop (low airborne contacts only), + // batch-resolved once after it. Never a fetch inside the loop. + const _floorWarmPoints = []; + + for (const aircraft of usableAircraft) { + const { id: icao24, longitude: lon, latitude: lat } = aircraft; + + currentIcaos.add(icao24); + flightState._missingPolls.delete(icao24); + + const prevMeta = flightState._flightData.get(icao24); + // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no + // separate boolean). Grounded planes fall back to their last known + // altitude (field elevation is unknowable here), else 0 m — never the + // 3 km airborne default (a parked plane must not float). + const onGround = aircraft.onGround; + const altitudeFt = + aircraft.baroAltitudeM == null + ? null + : aircraft.baroAltitudeM / 0.3048; + const altitudeM = + aircraft.baroAltitudeM ?? + (onGround + ? Number.isFinite(prevMeta?.altitudeFt) + ? prevMeta.altitudeFt * 0.3048 + : 0 + : 3048); + const track = aircraft.courseDeg || 0; + const speedMps = aircraft.speedMps ?? 0; + const verticalRateMps = aircraft.verticalRateMps; + const callsign = aircraft.callsign; + const type = aircraft.typeCode; + const registration = aircraft.registration; + const operator = aircraft.operator; + const geoAltitudeM = aircraft.ellipsoidAltitudeM; + const baroAltitudeM = aircraft.baroAltitudeM; + + // geoid undulation N: cached per-aircraft (negligible drift — see + // task brief) once the geoid grid has loaded; unavailable pre-load + // just means the baro fallback branch below adds N=0 for a beat. + let geoidN = flightState._geoidNCache.get(icao24); + if (geoidN === undefined && flightState._geoidReady) { + geoidN = geoidHeight(lat, lon); + flightState._geoidNCache.set(icao24, geoidN); + } + + // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a + // grounded plane's MODEL already rides groundSnap.js's one-shot tileset + // sample (_modelDisplayPosition), which is the visual on the ground, and + // its billboard is depth-test-free (_groundDepthDistance) so its exact + // height is cosmetic. Deliberately pass surfaceM=null so + // pickRenderAltitudeM's on-ground surface branch never fires here: + // 1. It would be the SECOND correction of the same grounded plane + // (model tileset-snap is the first) — the exact double-correct the + // brief forbids. + // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the + // grounded billboard sits at 0 m until a surface value warms; letting + // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls + // drags the model's ground-snap input past groundSnap's 50 m + // move-invalidation threshold and forces a re-sample every time the + // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant + // the track regression locks (qa: sampleHeight count must stay flat). + // Grounded planes therefore keep the pre-existing `altitudeM` default + // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what + // matters for AIRBORNE military planes — the actual "renders at MSL" bug. + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: baroAltitudeM, + onGround, + surfaceM: null, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // alt_geom nor alt_baro was ever reported for this aircraft (not even + // stickily) — fall back to the SAME existing default policy `altitudeM` + // already uses (which also carries the on-ground 0 m / last-known-baro + // case), so the two never disagree on the "no data yet" case. + let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; + // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only + // AIRBORNE contact near steep terrain can compute a render height + // BELOW the local surface (no alt_geom; baro+N carries QNH error + // larger than the height above ground). Floor it at the coarse-grid + // ellipsoidal ground (warm-cache read only — the batch warm below + // fills cells for later polls). Grounded contacts are deliberately + // NOT touched: their model rides groundSnap's tileset sample and + // their billboard is depth-test-free (see the surfaceM:null block + // above — same one-shot-invariant reasoning). + if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + _floorWarmPoints.push({ lat, lon }); + } else if (onGround) { + // Grounded contacts: warm the floor cell, and — round 4 — when NO + // 3D model owns this contact's visual, lift the billboard itself + // onto the floor (mesh-first): the R20053 heli sat "straight up in + // the ground" because grounded rows render at the legacy ~0 m. + // With a model present the billboard stays put (it hides behind + // the tileset-snapped model, and moving it would drag groundSnap's + // input past its move-invalidation threshold — the T7 one-shot + // invariant the track regression locks). + _floorWarmPoints.push({ lat, lon }); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsVisual) { + const floor = cachedGroundFloor(lat, lon); + if (Number.isFinite(floor)) { + renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); + } + } + } + + const position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + renderAltitudeM, + ); + // Landing/takeoff transition: the ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Either flip direction retires the model's ground snap: a departing plane + // flies free of it, a landing plane earns a fresh sample where it rolls out. + if (groundFlipped) flightState._groundSnap.forget(icao24); + + // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold + // last-known-good (bounded by the layer's eviction, which deletes the entry). + const stickyType = stickyText(type, prevMeta?.type); + const meta = { + sourceReference: aircraft.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + type: stickyType, + // Type outranks category automatically inside classifyAircraft. + klass: classifyAircraft({ + typeCode: stickyType, + category: aircraft?.category, + }), + registration: stickyText(registration, prevMeta?.registration), + operator: stickyText(operator, prevMeta?.operator), + altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitudeFt` — never rename/replace it (labels, + // the FL readout, and the landed-fast-cull heuristic all still read + // altitudeFt/baro). + geoAltitudeM, + renderAltitudeM, + speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), + track: stickyNumber(track, prevMeta?.track, null), + // Analyst seam (additive): sticky like the other kinematics. + verticalRateMps: stickyNumber( + verticalRateMps, + prevMeta?.verticalRateMps, + null, + ), + lastContactEpochMs: stickyNumber( + aircraft.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + turnRateDps: prevMeta?.turnRateDps || 0, + onGround, + // Round 7: sticky airborne history (see _likelyLanded). + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + // Round 6: lifted occlusion-test point for at/below-ellipsoid + // renders (mirror of flights.js). + cullPosition: + renderAltitudeM < 10 + ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) + : null, + // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded + // re-floor sweep keys floors off these (mirror of flights.js). + rawLat: lat, + rawLon: lon, + }; + flightState._flightData.set(icao24, meta); + + const isTracked = icao24 === flightState._trackedIcao; + + // Append to position history stamped with the FEED's fix epoch. + // adsb.lol's seen_pos is the AGE in seconds of the last position + // report, so the fix epoch is receipt time minus that age. Only + // append when the fix actually advances, so stale repeats don't + // create zero-dt segments. + const fixEpochMs = aircraft.positionTimeMs ?? receiptNowMs; + const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + if (!flightState._positionHistory.has(icao24)) { + flightState._positionHistory.set(icao24, []); + } + const history = flightState._positionHistory.get(icao24); + const newest = history[history.length - 1]; + if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + // Per-fix kinematics: the fix's own velocity/track ride along so the + // extrapolation paths use the values that BELONG to the fix they + // extend, not whatever the latest poll reported. + history.push({ + time: fixTime, + epochMs: fixEpochMs, + position: position.clone(), + velocity: meta.speedMps, + track: meta.track, + }); + if (history.length > POSITION_HISTORY_LIMIT) { + history.shift(); + } + // Turn rate from the fix-track history — computed once per new fix + // (≤5 samples), consumed by the extrapolation paths at tick rate. + meta.turnRateDps = turnRateFromFixHistory(history); + // Trail accumulation is separate from the 5-fix DR history (PRD F2) + // so the visible trail keeps growing while tracked. Round 2 + // (owner): ground traffic appends too — taxi history stays live + // after touchdown; _appendTrailFix floors grounded positions at + // the surface so the ground leg drapes instead of diving. + if (isTracked) parts.tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = + parts.rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + const kinematicsChanged = + newest.velocity !== meta.speedMps || newest.track !== meta.track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.speedMps, + track: meta.track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) + parts.tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + // Ground flips (landing/takeoff) restyle this SAME billboard in + // place — the transition is never a removal. + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Per-class GLBs: a class change can mean a different asset OR scale — + // resync the live model, any in-flight load, and the tracked + // standalone (mirror of flights.js). + if (prevMeta?.klass !== meta.klass) + parts.rendering._syncModelToClass(icao24); + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + } else { + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon( + parts.rendering._iconKind(icao24, meta.klass), + ), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta.klass] || 1) * + (meta.onGround ? GROUND_SCALE : 1), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + sizeInMeters: false, + scaleByDistance: + parts.rendering._normalBillboardScaleByDistance(), + // Grounded/near-surface planes sit at/below the photoreal tile + // surface — render them depth-test-free so they never vanish up + // close (_groundDepthDistance). + disableDepthTestDistance: parts.rendering._groundDepthDistance(), + id: icao24, + show: !isTracked, // hidden if currently tracked (entity replaces it) + }); + flightState._billboards.set(icao24, bb); + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + } + + // Takeoff while TRACKED: ground traffic drew no trail, so start one + // from the fresh airborne history (touchdown needs no action — the + // append gate above simply freezes the existing trail). + if (isTracked && groundFlipped && !meta.onGround) + parts.tracking._startTrail(icao24); + + // If this is the tracked aircraft, update label text + // (position updates automatically via dead-reckoning CallbackProperty) + if (isTracked && flightState._trackedEntity) { + const info = flightState._flightData.get(icao24); + parts.tracking._updateTrackedLabelModel(icao24); + } + } + + // Field-test fix (RS46): one batch warm of the low-airborne floor cells + // collected above — fire-and-forget, single-flight, results read + // synchronously by NEXT poll's clamp (same contract as flights.js's + // grounded-surface warm). + warmGroundFloor(_floorWarmPoints); + // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — + // a parked contact whose feed went quiet froze at its pre-warm height. + // The model-ownership gate matches the live grounded-clamp path (a + // tileset-snapped model owns its visual; moving its billboard would + // disturb groundSnap's input — the T7 one-shot invariant). + for (const [icao24, info] of flightState._flightData) { + if (!info?.onGround || currentIcaos.has(icao24)) continue; + if (parts.rendering._modelOwnsVisual(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + const floor = cachedGroundFloor(info.rawLat, info.rawLon); + if (!Number.isFinite(floor)) continue; + const lifted = floor + GROUND_FLOOR_LIFT_M; + if ( + Number.isFinite(info.renderAltitudeM) && + info.renderAltitudeM >= floor - 1 + ) + continue; + info.renderAltitudeM = lifted; + info.cullPosition = null; + const liftedPos = Cesium.Cartesian3.fromDegrees( + info.rawLon, + info.rawLat, + lifted, + ); + const hist = flightState._positionHistory.get(icao24); + const newest = hist?.[hist.length - 1]; + if (newest) + newest.position = Cesium.Cartesian3.clone( + liftedPos, + newest.position, + ); + const bbStale = flightState._billboards.get(icao24); + if (bbStale) bbStale.position = liftedPos; + } + // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, + // budget-capped, viewer-proximate, google-3d regime only), excluding + // this layer's own billboards/models from the probe. + { + const viewerCarto = + flightState._viewer?.camera?.positionCartographic || null; + sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : undefined, + }); + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // adsb.lol routinely drops aircraft for a single poll; immediate removal + // made icons blink and yanked the camera off actively tracked flights. + // EXCEPTION: likely-landed planes (last fix low + slow) get only + // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a + // feed gap, and the full grace left phantom planes parked at airports. + for (const [icao24, bb] of flightState._billboards) { + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if ( + !snapshot.complete && + Date.now() - + (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < + 300000 + ) + continue; + const misses = (flightState._missingPolls.get(icao24) || 0) + 1; + const limit = parts.queries._likelyLanded(icao24) + ? LANDED_MISSING_POLL_LIMIT + : MISSING_POLL_LIMIT; + if (misses < limit) { + flightState._missingPolls.set(icao24, misses); + if ( + icao24 === flightState._trackedIcao && + flightState._trackedEntity + ) { + // Honest readout: the tracked plane has no faded billboard (its + // entity owns the visual), so refresh the label — with icao24 now + // in _missingPolls, _buildTrackedLabel appends the STALE cue so + // last-known altitude/speed aren't presented as live. + parts.tracking._updateTrackedLabelModel(icao24); + } + continue; + } + flightState._missingPolls.delete(icao24); + + // If the tracked flight is truly gone, clear tracking BEFORE deleting + // its state (M3 ordering, keep it): teardown reads the maps this loop + // is about to delete (billboard restore, DR cache reset), and we must + // never leave the camera mid-follow with stale tracking state. The + // camera is then RELEASED IN PLACE — it stays where the follow left + // it, fully free (owner decision 2026-07-02: no overview flyTo). + if (icao24 === flightState._trackedIcao) { + parts.tracking._clearTracking(false, { evicted: true }); + } + + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) + flightState._flightData.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._geoidNCache.delete(icao24); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Feed the shared registry so the flights layer can classify/suppress + registerMilitaryIcaos(currentIcaos); + + flightState._count = flightState._billboards.size; + flightState._lastUpdate = receiptNowMs; + flightState._lastTrackingRefreshOutcome = { + epoch: trackingRefreshEpoch, + status: 'accepted', + ids: currentIcaos, + source: flightState._lastSource, + }; + console.log(`[Data:Military] Updated: ${flightState._count} aircraft`); + parts.tracking._applyPendingTrackingRestore(); + } catch (e) { + if (updateSignal.aborted || e?.name === 'AbortError') { + throw new DOMException('Military update aborted', 'AbortError'); + } + console.warn('[Data:Military] Fetch error:', e); + flightState._backoff = true; + flightState._retryAt = + Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); + flightState._lastStatus = e?.status ?? null; + if (e?.source) { + flightState._lastSource = e.source; + this.source = flightState._lastSource; + } + flightState._lastError = + e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; + } finally { + flightState._activeUpdateControllers.delete(resourceController); + } + }, + }; + + return { methods }; } diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 3d0fe9d23..31d8b558e 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,249 +1,301 @@ import * as Cesium from 'cesium'; import { JET_MODEL_URL } from './policy.js'; -export function createLifecycle({ flightState, services, parts, layer, resolveAsset }) { -const { clearFocusTarget } = services.focus; -const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; -const { holdContinuousRender, releaseContinuousRender } = services.render; -const { ensureGeoidReady } = services.geoid; -const { registerPickOwner, unregisterPickOwner } = services.picking; -const { setMilitaryLayerActive } = services.militaryRegistry; +export function createLifecycle({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { clearFocusTarget } = services.focus; + const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; + const { holdContinuousRender, releaseContinuousRender } = services.render; + const { ensureGeoidReady } = services.geoid; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { setMilitaryLayerActive } = services.militaryRegistry; -const methods = { + const methods = { + /** Configure the source before initialization; an active layer keeps its owner. */ + setSource(source) { + if (flightState._viewer) + throw new Error('Configure the source before layer initialization'); + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + flightState._source = source; + flightState._lastSource = source.label || flightState._lastSource; + this.source = flightState._lastSource; + }, - - /** Configure the source before initialization; an active layer keeps its owner. */ - setSource(source) { - if (flightState._viewer) throw new Error('Configure the source before layer initialization'); - if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); - flightState._source = source; - flightState._lastSource = source.label || flightState._lastSource; - this.source = flightState._lastSource; - }, - - - /** - * Initialize the layer: create the billboard collection, reset all state, - * and install the click-to-track handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - init(viewer) { - if (flightState._viewer) throw new Error('Flight layer is already initialized'); - if (typeof flightState._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); - if (flightState.lifetime.signal.aborted) flightState.lifetime = new AbortController(); - clearFocusTarget('militaryFlights'); - flightState._viewer = viewer; - flightState._billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(flightState._billboardCollection); - registerSpriteCollection('military', flightState._billboardCollection); - flightState._modelCollection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { - const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ url: resolveAsset(JET_MODEL_URL), asynchronous: false }) - .then((m) => { - if (epoch === flightState._modelEpoch) { flightState._preloadModel = m; flightState._planeModelLoaded = true; } - else { try { m.destroy(); } catch { /* gone */ } } + /** + * Initialize the layer: create the billboard collection, reset all state, + * and install the click-to-track handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + init(viewer) { + if (flightState._viewer) + throw new Error('Flight layer is already initialized'); + if (typeof flightState._source?.getSnapshot !== 'function') + throw new TypeError('A snapshot source is required'); + if (flightState.lifetime.signal.aborted) + flightState.lifetime = new AbortController(); + clearFocusTarget('militaryFlights'); + flightState._viewer = viewer; + flightState._billboardCollection = new Cesium.BillboardCollection(); + viewer.scene.primitives.add(flightState._billboardCollection); + registerSpriteCollection('military', flightState._billboardCollection); + flightState._modelCollection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(flightState._modelCollection); + // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed + // (keeps the retained instance referenced; never rendered). Captured against this epoch so a + // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. + if (!flightState._preloadModel) { + const epoch = flightState._modelEpoch; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(JET_MODEL_URL), + asynchronous: false, }) - .catch(() => { /* tracked plane just stays a billboard a beat longer */ }); - } - flightState._billboards = new Map(); - flightState._detectionObjects = new Map(); - flightState._flightData = new Map(); - flightState._positionHistory = new Map(); - flightState._displayCourse.clear(); - flightState._groundSnap.clear(); - flightState._count = 0; - flightState._lastUpdate = null; - flightState._backoff = false; - flightState._retryAt = 0; - flightState._lastError = null; - flightState._lastStatus = null; - flightState._trackedIcao = null; - parts.tracking._resetTrackedSelectionState(); - flightState._trackedEntity = null; - flightState._cockpitSubjectId = null; - flightState._cockpitContactMode = document.body.classList.contains('cockpit-mode'); - flightState._cockpitNearContacts = new Set(); - if (!flightState._cockpitModeListener) { - flightState._cockpitModeListener = (event) => parts.tracking._applyCockpitState(event?.detail); - window.addEventListener('gev:cockpit-mode-changed', flightState._cockpitModeListener); - } + .then((m) => { + if (epoch === flightState._modelEpoch) { + flightState._preloadModel = m; + flightState._planeModelLoaded = true; + } else { + try { + m.destroy(); + } catch { + /* gone */ + } + } + }) + .catch(() => { + /* tracked plane just stays a billboard a beat longer */ + }); + } + flightState._billboards = new Map(); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map(); + flightState._positionHistory = new Map(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._backoff = false; + flightState._retryAt = 0; + flightState._lastError = null; + flightState._lastStatus = null; + flightState._trackedIcao = null; + parts.tracking._resetTrackedSelectionState(); + flightState._trackedEntity = null; + flightState._cockpitSubjectId = null; + flightState._cockpitContactMode = + document.body.classList.contains('cockpit-mode'); + flightState._cockpitNearContacts = new Set(); + if (!flightState._cockpitModeListener) { + flightState._cockpitModeListener = (event) => + parts.tracking._applyCockpitState(event?.detail); + window.addEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + } - parts.tracking._installClickHandler(viewer); + parts.tracking._installClickHandler(viewer); - restoreSpriteOrder(viewer); + restoreSpriteOrder(viewer); - console.log('[Data:Military] Initialized with billboard icons'); - }, + console.log('[Data:Military] Initialized with billboard icons'); + }, + /** + * Show the layer and re-install the click handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + enable(viewer) { + if (flightState._billboardCollection) + flightState._billboardCollection.show = true; + holdContinuousRender('military'); // per-frame animator (perf wave 2) + if (flightState._modelCollection) + flightState._modelCollection.show = true; + parts.tracking._setCockpitContactMode( + document.body.classList.contains('cockpit-mode'), + ); + // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop + // only reads geoidHeight() synchronously after this resolves (guarded by + // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. + if (!flightState._geoidReady) { + const lifetime = flightState.lifetime; + ensureGeoidReady() + .then(() => { + if (!lifetime.signal.aborted) flightState._geoidReady = true; + }) + .catch(() => { + /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ + }); + } + parts.tracking._installClickHandler(viewer); + registerPickOwner('military', (pickedId) => + flightState._billboards.has(pickedId), + ); + // The flights layer suppresses its military duplicates while we render them + setMilitaryLayerActive(true); + // Force a fresh rotation pass on the first tick after re-enable + flightState._lastCamPoseSig = ''; + if (!flightState._preRenderRemove && viewer?.scene) { + flightState._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering._fleetTick, + ); + } + if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { + flightState._trackedModelPreUpdateRemove = + viewer.scene.preUpdate.addEventListener( + parts.rendering._updateTrackedModel, + ); + } + if (!flightState._moveEndRemove && viewer?.camera) { + // Arrival polish (mirror of flights.js — see the comment there): a settled + // camera move forces a full rotation pass on the very next frame, because + // the pose-signature gate can eat the settle (final easing frames land + // inside one quantization bucket) and leave stale noses for up to + // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. + flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( + () => { + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + }, + ); + } + restoreSpriteOrder(viewer); + }, - /** - * Show the layer and re-install the click handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - enable(viewer) { - if (flightState._billboardCollection) flightState._billboardCollection.show = true; - holdContinuousRender('military'); // per-frame animator (perf wave 2) - if (flightState._modelCollection) flightState._modelCollection.show = true; - parts.tracking._setCockpitContactMode(document.body.classList.contains('cockpit-mode')); - // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop - // only reads geoidHeight() synchronously after this resolves (guarded by - // _geoidReady) — never awaited per-aircraft, never blocking a poll tick. - if (!flightState._geoidReady) { - const lifetime = flightState.lifetime; - ensureGeoidReady() - .then(() => { if (!lifetime.signal.aborted) flightState._geoidReady = true; }) - .catch(() => { /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); - } - parts.tracking._installClickHandler(viewer); - registerPickOwner('military', (pickedId) => flightState._billboards.has(pickedId)); - // The flights layer suppresses its military duplicates while we render them - setMilitaryLayerActive(true); - // Force a fresh rotation pass on the first tick after re-enable - flightState._lastCamPoseSig = ''; - if (!flightState._preRenderRemove && viewer?.scene) { - flightState._preRenderRemove = viewer.scene.preRender.addEventListener(parts.rendering._fleetTick); - } - if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { - flightState._trackedModelPreUpdateRemove = viewer.scene.preUpdate.addEventListener(parts.rendering._updateTrackedModel); - } - if (!flightState._moveEndRemove && viewer?.camera) { - // Arrival polish (mirror of flights.js — see the comment there): a settled - // camera move forces a full rotation pass on the very next frame, because - // the pose-signature gate can eat the settle (final easing frames land - // inside one quantization bucket) and leave stale noses for up to - // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. - flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(() => { - flightState._lastCamPoseSig = ''; - flightState._lastFleetTickMs = 0; - }); - } - restoreSpriteOrder(viewer); - }, + /** + * Hide the layer, clear any active tracking, and remove input handlers + * so clicks do not get intercepted while the layer is off. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + disable(viewer) { + parts.controller._abortActiveUpdates(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._billboardCollection) + flightState._billboardCollection.show = false; + releaseContinuousRender('military'); + parts.rendering._releaseModels(); + if (flightState._modelCollection) + flightState._modelCollection.show = false; + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + // Remove click handler + keydown listener while disabled to avoid + // intercepting input when the layer is off + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + unregisterPickOwner('military'); + // Flights layer takes over rendering known-military aircraft (amber) + setMilitaryLayerActive(false); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + }, + /** + * Tear down the layer: clear tracking, remove handlers, remove the billboard + * collection from the scene, and release all state. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + destroy(viewer) { + flightState.lifetime.abort(); + parts.controller._abortActiveUpdates(); + releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) + parts.tracking._clearTracking(); + parts.tracking._destroyTrail(); + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._clickHandler) { + flightState._clickHandler.destroy(); + flightState._clickHandler = null; + } + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + document.removeEventListener('keydown', parts.tracking._onKeyDown); + if (flightState._cockpitModeListener) { + window.removeEventListener( + 'gev:cockpit-mode-changed', + flightState._cockpitModeListener, + ); + flightState._cockpitModeListener = null; + } + unregisterPickOwner('military'); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + parts.rendering._releaseModels(); + if (flightState._billboardCollection) { + viewer.scene.primitives.remove(flightState._billboardCollection); + flightState._billboardCollection = null; + } + if (flightState._modelCollection) { + viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models + flightState._modelCollection = null; + } + flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelPending.clear(); + flightState._modelGen.clear(); + if (flightState._preloadModel) { + try { + flightState._preloadModel.destroy(); + } catch { + /* gone */ + } + flightState._preloadModel = null; + } + flightState._planeModelLoaded = false; + flightState._billboards.clear(); + flightState._detectionObjects.clear(); + flightState._flightData.clear(); + flightState._positionHistory.clear(); + flightState._displayCourse.clear(); + flightState._groundSnap.clear(); + flightState._missingPolls.clear(); + flightState._count = 0; + flightState._lastUpdate = null; + flightState._cockpitContactMode = false; + flightState._cockpitNearContacts = new Set(); + flightState._cockpitSubjectId = null; + flightState._trackingRefreshEpoch += 1; + flightState._lastTrackingRefreshOutcome = { + epoch: flightState._trackingRefreshEpoch, + status: 'destroyed', + ids: new Set(), + source: flightState._lastSource, + }; + parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling + flightState._viewer = null; + }, + }; - /** - * Hide the layer, clear any active tracking, and remove input handlers - * so clicks do not get intercepted while the layer is off. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - disable(viewer) { - parts.controller._abortActiveUpdates(); - parts.tracking._cancelPendingTrackingRestore(); - if (flightState._billboardCollection) flightState._billboardCollection.show = false; - releaseContinuousRender('military'); - parts.rendering._releaseModels(); - if (flightState._modelCollection) flightState._modelCollection.show = false; - parts.tracking._clearTracking(); - parts.tracking._destroyTrail(); - // Remove click handler + keydown listener while disabled to avoid - // intercepting input when the layer is off - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } - if (flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove(); - flightState._trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', parts.tracking._onKeyDown); - unregisterPickOwner('military'); - // Flights layer takes over rendering known-military aircraft (amber) - setMilitaryLayerActive(false); - if (flightState._preRenderRemove) { - flightState._preRenderRemove(); - flightState._preRenderRemove = null; - } - if (flightState._trackedModelPreUpdateRemove) { - flightState._trackedModelPreUpdateRemove(); - flightState._trackedModelPreUpdateRemove = null; - } - if (flightState._moveEndRemove) { - flightState._moveEndRemove(); - flightState._moveEndRemove = null; - } - }, - - - /** - * Tear down the layer: clear tracking, remove handlers, remove the billboard - * collection from the scene, and release all state. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - destroy(viewer) { - flightState.lifetime.abort(); - parts.controller._abortActiveUpdates(); - releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) - parts.tracking._clearTracking(); - parts.tracking._destroyTrail(); - parts.tracking._cancelPendingTrackingRestore(); - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } - if (flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove(); - flightState._trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', parts.tracking._onKeyDown); - if (flightState._cockpitModeListener) { - window.removeEventListener('gev:cockpit-mode-changed', flightState._cockpitModeListener); - flightState._cockpitModeListener = null; - } - unregisterPickOwner('military'); - if (flightState._preRenderRemove) { - flightState._preRenderRemove(); - flightState._preRenderRemove = null; - } - if (flightState._trackedModelPreUpdateRemove) { - flightState._trackedModelPreUpdateRemove(); - flightState._trackedModelPreUpdateRemove = null; - } - if (flightState._moveEndRemove) { - flightState._moveEndRemove(); - flightState._moveEndRemove = null; - } - parts.rendering._releaseModels(); - if (flightState._billboardCollection) { - viewer.scene.primitives.remove(flightState._billboardCollection); - flightState._billboardCollection = null; - } - if (flightState._modelCollection) { - viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models - flightState._modelCollection = null; - } - flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) - flightState._modelPending.clear(); - flightState._modelGen.clear(); - if (flightState._preloadModel) { try { flightState._preloadModel.destroy(); } catch { /* gone */ } flightState._preloadModel = null; } - flightState._planeModelLoaded = false; - flightState._billboards.clear(); - flightState._detectionObjects.clear(); - flightState._flightData.clear(); - flightState._positionHistory.clear(); - flightState._displayCourse.clear(); - flightState._groundSnap.clear(); - flightState._missingPolls.clear(); - flightState._count = 0; - flightState._lastUpdate = null; - flightState._cockpitContactMode = false; - flightState._cockpitNearContacts = new Set(); - flightState._cockpitSubjectId = null; - flightState._trackingRefreshEpoch += 1; - flightState._lastTrackingRefreshOutcome = { - epoch: flightState._trackingRefreshEpoch, - status: 'destroyed', - ids: new Set(), - source: flightState._lastSource, - }; - parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling - flightState._viewer = null; - } -}; - -return { methods }; + return { methods }; } diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 98768414e..004558e2e 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -1,388 +1,528 @@ import * as Cesium from 'cesium'; -import { lerpAngleDeg, speedRamp, courseBetweenCartesians, COURSE_HOLD_SPEED_MPS, staleCoastLimitSeconds, arcOffsetEnu, courseSlewCapDps, limitCourseStep } from '../../data/motionModel.js'; +import { + lerpAngleDeg, + speedRamp, + courseBetweenCartesians, + COURSE_HOLD_SPEED_MPS, + staleCoastLimitSeconds, + arcOffsetEnu, + courseSlewCapDps, + limitCourseStep, +} from '../../data/motionModel.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; -import { modelVisualAnchor, modelAnchorWorld } from '../../data/modelVisualAnchor.js'; -import { RENDER_DELAY_SEC, TRACKED_MODEL_MAX_PX, TRACKED_MODEL_MIN_PX, BILLBOARD_SCALE, TRACKED_BILLBOARD_SCALE_BY_DISTANCE, DR_CORRECTION_MS, COURSE_SLEW_DT_MAX_SEC, COURSE_MAX_DPS } from './policy.js'; +import { + modelVisualAnchor, + modelAnchorWorld, +} from '../../data/modelVisualAnchor.js'; +import { + RENDER_DELAY_SEC, + TRACKED_MODEL_MAX_PX, + TRACKED_MODEL_MIN_PX, + BILLBOARD_SCALE, + TRACKED_BILLBOARD_SCALE_BY_DISTANCE, + DR_CORRECTION_MS, + COURSE_SLEW_DT_MAX_SEC, + COURSE_MAX_DPS, +} from './policy.js'; -export function createMotion({ flightState, services, parts, layer, resolveAsset }) { -const { trackedModelScaleForPixelCap } = services.camera; -const { nearFarScalarValueAtDistance, clearFocusTarget, publishFocusTargetFromCachedPosition } = services.focus; +export function createMotion({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { trackedModelScaleForPixelCap } = services.camera; + const { + nearFarScalarValueAtDistance, + clearFocusTarget, + publishFocusTargetFromCachedPosition, + } = services.focus; + /** + * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. + * + * Renders one poll interval behind real time so positions interpolate between + * two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). When the + * newest fix is older than the delayed render time (stale position), projects + * forward using the aircraft's ground speed and track through the freshest + * source contact plus a bounded grace window. + * + * @param {string} icao24 - ICAO hex identifier of the aircraft + * @param {Cesium.Cartesian3} [result] - Optional out-parameter to write into. + * @returns {Cesium.Cartesian3|null} Estimated current ECEF position, or null if no history + */ -/** - * Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math. - * - * Renders one poll interval behind real time so positions interpolate between - * two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). When the - * newest fix is older than the delayed render time (stale position), projects - * forward using the aircraft's ground speed and track through the freshest - * source contact plus a bounded grace window. - * - * @param {string} icao24 - ICAO hex identifier of the aircraft - * @param {Cesium.Cartesian3} [result] - Optional out-parameter to write into. - * @returns {Cesium.Cartesian3|null} Estimated current ECEF position, or null if no history - */ - -function _deadReckon(icao24, result) { - const history = flightState._positionHistory.get(icao24); - const info = flightState._flightData.get(icao24); - if (!history || history.length === 0) { - flightState._drCourseDeg = null; flightState._drSpeedMps = null; flightState._drCourseHold = false; - return null; - } - - const out = result || new Cesium.Cartesian3(); - // Render one poll interval behind real time so we interpolate between - // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, flightState._scratchRenderTime - ); - - // Bracketing pair: interpolate — no extrapolation error, no snap-back. - for (let i = history.length - 1; i >= 1; i--) { - const a = history[i - 1]; - const b = history[i]; - if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; - // Course of the DISPLAYED motion. The chord is only trustworthy when the - // segment covers real ground (at hover its direction is GPS jitter; on a - // slow tight turn it STEPS the whole per-segment turn at each boundary), - // so it is blended against the reported per-fix track by displayed - // ground speed — and the track is TIME-INTERPOLATED between the fixes so - // a slow turner's nose advances continuously through the segment instead - // of snapping once per poll. Helicopters always use the reported track - // (rotorcraft chords are noise-dominated at their typical speeds). - const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); - const segSpeed = span > 0 ? chordLenM / span : ((info && info.speedMps) || 0); - const fallbackTrack = (info && info.track) || 0; - const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; - const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; - const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); - const w = (info && info.klass === 'helicopter') ? 0 : speedRamp(segSpeed); - const chordCourse = w > 0 ? courseBetweenCartesians(a.position, b.position) : null; - flightState._drCourseDeg = chordCourse != null ? lerpAngleDeg(trackCourse, chordCourse, w) : trackCourse; - flightState._drSpeedMps = segSpeed; - flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; - return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + function _deadReckon(icao24, result) { + const history = flightState._positionHistory.get(icao24); + const info = flightState._flightData.get(icao24); + if (!history || history.length === 0) { + flightState._drCourseDeg = null; + flightState._drSpeedMps = null; + flightState._drCourseHold = false; + return null; } - } - const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference(renderTime, newest.time); - if (elapsedSec <= 0) { - // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking - // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing - // pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time - // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history - // fills, renderTime advances toward the first fix and the icon glides FORWARD into - // the bracketing interpolation above with NO freeze and NO backward snap. (Holding - // the oldest fix froze the icon until enough history accrued, then jumped.) - const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference(oldest.time, renderTime); // ≥ 0 - return _extrapolateFix(oldest, info, -Math.min(lookbackSec, 60), out, (info && info.turnRateDps) || 0); - } + const out = result || new Cesium.Cartesian3(); + // Render one poll interval behind real time so we interpolate between + // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchRenderTime, + ); - // Continue through fresh adsb.lol contacts even when the position epoch has - // not advanced, but keep an absolute stale-feed drift ceiling. - const coastLimitSec = staleCoastLimitSeconds({ - // Captured at poll normalization so the fleet tick does not allocate a - // Date for every aircraft twelve times per second. - fixEpochMs: Number.isFinite(newest.epochMs) - ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), - lastContactEpochMs: info?.lastContactEpochMs, - }); - return _extrapolateFix( - newest, - info, - Math.min(elapsedSec, coastLimitSec), - out, - (info && info.turnRateDps) || 0, - ); -} + // Bracketing pair: interpolate — no extrapolation error, no snap-back. + for (let i = history.length - 1; i >= 1; i--) { + const a = history[i - 1]; + const b = history[i]; + if ( + Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && + Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) + ) { + const span = Cesium.JulianDate.secondsDifference(b.time, a.time); + const t = + span > 0 + ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span + : 1.0; + // Course of the DISPLAYED motion. The chord is only trustworthy when the + // segment covers real ground (at hover its direction is GPS jitter; on a + // slow tight turn it STEPS the whole per-segment turn at each boundary), + // so it is blended against the reported per-fix track by displayed + // ground speed — and the track is TIME-INTERPOLATED between the fixes so + // a slow turner's nose advances continuously through the segment instead + // of snapping once per poll. Helicopters always use the reported track + // (rotorcraft chords are noise-dominated at their typical speeds). + const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); + const segSpeed = + span > 0 ? chordLenM / span : (info && info.speedMps) || 0; + const fallbackTrack = (info && info.track) || 0; + const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; + const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; + const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); + const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed); + const chordCourse = + w > 0 ? courseBetweenCartesians(a.position, b.position) : null; + flightState._drCourseDeg = + chordCourse != null + ? lerpAngleDeg(trackCourse, chordCourse, w) + : trackCourse; + flightState._drSpeedMps = segSpeed; + flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; + return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + } + } + const newest = history[history.length - 1]; + const elapsedSec = Cesium.JulianDate.secondsDifference( + renderTime, + newest.time, + ); + if (elapsedSec <= 0) { + // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking + // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing + // pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time + // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history + // fills, renderTime advances toward the first fix and the icon glides FORWARD into + // the bracketing interpolation above with NO freeze and NO backward snap. (Holding + // the oldest fix froze the icon until enough history accrued, then jumped.) + const oldest = history[0]; + const lookbackSec = Cesium.JulianDate.secondsDifference( + oldest.time, + renderTime, + ); // ≥ 0 + return _extrapolateFix( + oldest, + info, + -Math.min(lookbackSec, 60), + out, + (info && info.turnRateDps) || 0, + ); + } -/** - * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a - * constant-rate-turn arc when `turnRateDps` is significant (straight line - * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` - * projects BACKWARD (before the fix) — used for warm-up, estimating where the - * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, - * up = +Z; heading 0 deg = north, 90 deg = east (clockwise from north). Sets - * `_drCourseDeg` to the arc's instantaneous end course on every path. - * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). - */ - -function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { - const speed = Number.isFinite(fix.velocity) ? fix.velocity : ((info && info.speedMps) || 0); - const heading = Number.isFinite(fix.track) ? fix.track : ((info && info.track) || 0); - flightState._drSpeedMps = speed; - flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; - if (speed === 0 || dt === 0) { - flightState._drCourseDeg = heading; - return Cesium.Cartesian3.clone(fix.position, out); - } - // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a - // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. - arcOffsetEnu(speed, heading, turnRateDps, dt, flightState._scratchArc); - flightState._drCourseDeg = flightState._scratchArc.endCourseDeg; - Cesium.Cartesian3.fromElements(flightState._scratchArc.east, flightState._scratchArc.north, 0, flightState._scratchOffset); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(fix.position, Cesium.Ellipsoid.WGS84, flightState._scratchEnu); - return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); -} - - -/** - * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) - * predates its oldest real fix — _deadReckon is extrapolating backward with no real - * history yet behind the displayed icon, so the trail must draw nothing. - * @returns {boolean} - */ - -function _isTrackWarmingUp() { - if (!flightState._trackedIcao) return false; - const history = flightState._positionHistory.get(flightState._trackedIcao); - if (!history || history.length === 0) return true; - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, flightState._scratchWarmupTime - ); - return Cesium.JulianDate.lessThan(renderTime, history[0].time); -} - - -/** Resolve the selected aircraft's actual rendered square extent this frame. */ - -function _trackedFocusSizePx(icao24, position) { - const camera = flightState._viewer?.camera; - const scene = flightState._viewer?.scene; - if (!camera?.positionWC || !position || !scene) return 28; - const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); - if (parts.rendering._modelOwnsVisual(icao24)) { - const spec = parts.rendering._modelSpec(flightState._flightData.get(icao24)?.klass); - const scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM, - viewportHeightPx: scene.canvas.clientHeight, - fovyRad: camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, + // Continue through fresh adsb.lol contacts even when the position epoch has + // not advanced, but keep an absolute stale-feed drift ceiling. + const coastLimitSec = staleCoastLimitSeconds({ + // Captured at poll normalization so the fleet tick does not allocate a + // Date for every aircraft twelve times per second. + fixEpochMs: Number.isFinite(newest.epochMs) + ? newest.epochMs + : Cesium.JulianDate.toDate(newest.time).getTime(), + lastContactEpochMs: info?.lastContactEpochMs, }); - const focalLengthPx = scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); - const projectedDiameterPx = (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; - return Math.max(TRACKED_MODEL_MIN_PX, Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx)); - } - - const billboard = flightState._trackedEntity?.billboard; - const time = flightState._viewer.clock.currentTime; - const width = billboard?.width?.getValue(time) ?? 28; - const height = billboard?.height?.getValue(time) ?? 28; - const scale = billboard?.scale?.getValue(time) - ?? (BILLBOARD_SCALE * (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1)); - const scaleByDistance = billboard?.scaleByDistance?.getValue(time) - ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE; - const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); - return Math.max(width, height) * scale * distanceScale; -} - - -/** - * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium - * frame number so the position / rotation / trail-head callbacks share ONE computation - * (and one reconciliation-state update) per frame. Returns a stable module holder, or - * null when the aircraft has no fix. - * @param {string} icao24 - * @returns {Cesium.Cartesian3|null} - */ - -function _trackedDisplayPosition(icao24) { - const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; - if (frame === flightState._cachedDRFrame && icao24 === flightState._drReconcileIcao) return flightState._cachedDRPosition; - - // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting - // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) - const sameTrack = flightState._drReconcileValid && flightState._drReconcileIcao === icao24; - const raw = _deadReckon(icao24, flightState._scratchDrRaw); - flightState._cachedDRCourse = flightState._drCourseDeg; - flightState._cachedDRSpeedMps = flightState._drSpeedMps; - flightState._cachedDRHold = flightState._drCourseHold; - flightState._cachedDRFrame = frame; - flightState._drReconcileIcao = icao24; - if (!raw) { - flightState._cachedDRPosition = null; - flightState._drReconcileValid = false; - clearFocusTarget('militaryFlights', icao24); - return null; - } - - const nowMs = Date.now(); - const info = flightState._flightData.get(icao24); - if (sameTrack) { - const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); - const speed = (info && info.speedMps) || 0; - // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. - const plausible = speed * dtSec * 4 + 25; - if (Cesium.Cartesian3.distance(raw, flightState._drPrevRaw) > plausible) { - // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. - Cesium.Cartesian3.subtract(flightState._drPrevDisplay, raw, flightState._drCorrection); - flightState._drCorrectionStartMs = nowMs; - } - } else { - Cesium.Cartesian3.fromElements(0, 0, 0, flightState._drCorrection); - flightState._drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed - } - - const elapsed = nowMs - flightState._drCorrectionStartMs; - const factor = elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; - const display = Cesium.Cartesian3.multiplyByScalar(flightState._drCorrection, factor, flightState._trackedPosHolder); - Cesium.Cartesian3.add(raw, display, display); - - Cesium.Cartesian3.clone(raw, flightState._drPrevRaw); - Cesium.Cartesian3.clone(display, flightState._drPrevDisplay); - flightState._drPrevMs = nowMs; - flightState._drReconcileValid = true; - flightState._cachedDRPosition = display; - const focusSizePx = _trackedFocusSizePx(icao24, flightState._cachedDRPosition); - // Publish the exact frame cache shared by entity + follow camera. A second - // DR sample here would advance reconciliation in a different frame phase - // and visibly jitter the focus rectangle against the tracked aircraft. - publishFocusTargetFromCachedPosition({ - ownerLayer: 'militaryFlights', - id: icao24, - scene: flightState._viewer?.scene, - camera: flightState._viewer?.camera, - displayPosition: flightState._cachedDRPosition, - widthPx: focusSizePx, - heightPx: focusSizePx, - }); - return display; -} - - -/** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already - * settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at - * a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the - * now-stable plane. Null when no valid fix (callers fall back to the billboard position). */ - -function _trackedDisplayCached() { - return (flightState._drReconcileValid && flightState._drReconcileIcao === flightState._trackedIcao) ? flightState._cachedDRPosition : null; -} - - -/** - * The position the tracked aircraft is VISUALLY at this frame — the translation its 3D - * model is actually rendering with when the model owns the visual, otherwise the cached - * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. - * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, - * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its - * follow-camera anti-jitter contract untouched. - */ - -function _trackedVisualCached() { - if (flightState._trackedIcao && parts.rendering._modelOwnsVisual(flightState._trackedIcao)) { - const spec = parts.rendering._modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass); - return modelVisualAnchor( - flightState._trackedModel.modelMatrix, - spec.visualCenterNative, - Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale : spec.scale, - flightState._trackedVisualPos, + return _extrapolateFix( + newest, + info, + Math.min(elapsedSec, coastLimitSec), + out, + (info && info.turnRateDps) || 0, ); } - return _trackedDisplayCached(); -} + /** + * Dead-reckon a fix along its own velocity/track by `dt` seconds, integrating a + * constant-rate-turn arc when `turnRateDps` is significant (straight line + * otherwise). Positive `dt` projects FORWARD (after the fix); negative `dt` + * projects BACKWARD (before the fix) — used for warm-up, estimating where the + * aircraft was before its first observed fix. ENU frame: east = +X, north = +Y, + * up = +Z; heading 0 deg = north, 90 deg = east (clockwise from north). Sets + * `_drCourseDeg` to the arc's instantaneous end course on every path. + * Arc math adapted from skylight (https://github.com/cpaczek/skylight, MIT). + */ -/** The tracked model's rendered bounding radius (m), or 0 when no model of this - * contact is drawing. Carries Cesium's effective `computedScale`, which may be - * above `scale` to satisfy minimumPixelSize — the trail head has to be judged - * against the size the operator SEES, not the nominal one. */ -/** World-space origin of the tracked model, or null when no model of this - * contact is drawing. This is the centre the rendered bounding sphere is - * measured from, so the trail clip and the envelope agree on one frame. */ - -function _trackedModelCenterWorld() { - if (!flightState._trackedIcao || !flightState._trackedModel || !parts.rendering._modelOwnsVisual(flightState._trackedIcao)) return null; - return Cesium.Matrix4.getTranslation(flightState._trackedModel.modelMatrix, flightState._scratchTrailClip); -} - - - -function _trackedModelEnvelopeM() { - if (!flightState._trackedIcao || !flightState._trackedModel || !parts.rendering._modelOwnsVisual(flightState._trackedIcao)) return 0; - const spec = parts.rendering._modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass); - const scale = Number.isFinite(flightState._trackedModel.computedScale) - ? flightState._trackedModel.computedScale - : spec.scale; - return spec.nativeRadiusM * scale; -} - - -/** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on - * the visual centre; only the trail moves to the model's aft-belly hardpoint. */ - -function _trackedTrailCached() { - if (flightState._trackedIcao && parts.rendering._modelOwnsVisual(flightState._trackedIcao)) { - const spec = parts.rendering._modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass); - // Through the model's OWN render chain (modelVisualAnchor's hand-rolled - // half-correction put this offset on the lateral axis — see - // modelAnchorWorld). - return modelAnchorWorld(flightState._trackedModel, spec.trailAnchorNative, flightState._trackedTrailPos); + function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { + const speed = Number.isFinite(fix.velocity) + ? fix.velocity + : (info && info.speedMps) || 0; + const heading = Number.isFinite(fix.track) + ? fix.track + : (info && info.track) || 0; + flightState._drSpeedMps = speed; + flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; + if (speed === 0 || dt === 0) { + flightState._drCourseDeg = heading; + return Cesium.Cartesian3.clone(fix.position, out); + } + // Constant-rate-turn arc (straight line when turnRateDps ≈ 0) — a plane in a + // standard-rate turn is ~90° of arc wrong per 30 s if extrapolated straight. + arcOffsetEnu(speed, heading, turnRateDps, dt, flightState._scratchArc); + flightState._drCourseDeg = flightState._scratchArc.endCourseDeg; + Cesium.Cartesian3.fromElements( + flightState._scratchArc.east, + flightState._scratchArc.north, + 0, + flightState._scratchOffset, + ); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame( + fix.position, + Cesium.Ellipsoid.WGS84, + flightState._scratchEnu, + ); + return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); } - return _trackedDisplayCached(); -} - - -/** Smoothed world course for the tracked aircraft this frame. Reads the - * frame-cached course (set by _trackedDisplayPosition — the follow-camera's - * own computation), NEVER re-runs _deadReckon. Safe to call more than once - * per frame: the second call sees dt≈0 and the limiter is a no-op. - * - * The smoothed value lives in the SHARED per-icao _displayCourse entry (see - * its declaration): on click the limiter continues from whatever nose the - * fleet pass was displaying, and on untrack the fleet pass continues from - * whatever nose this path last wrote — tracked and fleet consumers of the - * same aircraft can never disagree across the handoff (mirror of flights.js). */ - -function _trackedDisplayCourse() { - const info = flightState._flightData.get(flightState._trackedIcao); - const fallback = (info && info.track) || 0; - const cacheValid = flightState._drReconcileValid && flightState._drReconcileIcao === flightState._trackedIcao && flightState._cachedDRCourse != null; - const raw = cacheValid ? flightState._cachedDRCourse : fallback; - const nowMs = Date.now(); - const dt = flightState._trackedCourseMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - flightState._trackedCourseMs) / 1000) - : 0; - flightState._trackedCourseMs = nowMs; - const prev = flightState._displayCourse.get(flightState._trackedIcao); - // Hover hold: at near-zero displayed speed both the chord and the reported - // track are noise — keep the last stable nose direction instead of chasing. - if (cacheValid && flightState._cachedDRHold && prev != null) return prev; - const cap = courseSlewCapDps(cacheValid ? flightState._cachedDRSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS); - const course = limitCourseStep(prev, raw, cap, dt); - flightState._displayCourse.set(flightState._trackedIcao, course); - return course; -} - - -/** Reset reconciliation + per-frame cache when tracking stops or switches - * target. Deliberately does NOT touch _displayCourse: the smoothed course - * entry belongs to the AIRCRAFT (not to the tracked session) and must - * survive the handoff back to the fleet pass. */ - -function _resetTrackedDisplay() { - flightState._drReconcileValid = false; - flightState._drReconcileIcao = null; - flightState._cachedDRFrame = -1; - flightState._cachedDRPosition = null; - flightState._cachedDRCourse = null; - flightState._cachedDRSpeedMps = null; - flightState._cachedDRHold = false; - flightState._trackedCourseMs = 0; -} -return { _deadReckon, _extrapolateFix, _isTrackWarmingUp, _trackedFocusSizePx, _trackedDisplayPosition, _trackedDisplayCached, _trackedVisualCached, _trackedModelCenterWorld, _trackedModelEnvelopeM, _trackedTrailCached, _trackedDisplayCourse, _resetTrackedDisplay }; + + /** + * True while the tracked aircraft's delayed display time (now − RENDER_DELAY_SEC) + * predates its oldest real fix — _deadReckon is extrapolating backward with no real + * history yet behind the displayed icon, so the trail must draw nothing. + * @returns {boolean} + */ + + function _isTrackWarmingUp() { + if (!flightState._trackedIcao) return false; + const history = flightState._positionHistory.get(flightState._trackedIcao); + if (!history || history.length === 0) return true; + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + return Cesium.JulianDate.lessThan(renderTime, history[0].time); + } + + /** Resolve the selected aircraft's actual rendered square extent this frame. */ + + function _trackedFocusSizePx(icao24, position) { + const camera = flightState._viewer?.camera; + const scene = flightState._viewer?.scene; + if (!camera?.positionWC || !position || !scene) return 28; + const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); + if (parts.rendering._modelOwnsVisual(icao24)) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(icao24)?.klass, + ); + const scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM, + viewportHeightPx: scene.canvas.clientHeight, + fovyRad: camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + const focalLengthPx = + scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); + const projectedDiameterPx = + (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; + return Math.max( + TRACKED_MODEL_MIN_PX, + Math.min(TRACKED_MODEL_MAX_PX, projectedDiameterPx), + ); + } + + const billboard = flightState._trackedEntity?.billboard; + const time = flightState._viewer.clock.currentTime; + const width = billboard?.width?.getValue(time) ?? 28; + const height = billboard?.height?.getValue(time) ?? 28; + const scale = + billboard?.scale?.getValue(time) ?? + BILLBOARD_SCALE * + (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1); + const scaleByDistance = + billboard?.scaleByDistance?.getValue(time) ?? + TRACKED_BILLBOARD_SCALE_BY_DISTANCE; + const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); + return Math.max(width, height) * scale * distanceScale; + } + + /** + * Per-frame-cached, discontinuity-smoothed tracked DISPLAY position. Cached by Cesium + * frame number so the position / rotation / trail-head callbacks share ONE computation + * (and one reconciliation-state update) per frame. Returns a stable module holder, or + * null when the aircraft has no fix. + * @param {string} icao24 + * @returns {Cesium.Cartesian3|null} + */ + + function _trackedDisplayPosition(icao24) { + const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; + if ( + frame === flightState._cachedDRFrame && + icao24 === flightState._drReconcileIcao + ) + return flightState._cachedDRPosition; + + // Does the reconciliation state belong to THIS aircraft? (Capture before overwriting + // _drReconcileIcao, so a track switch doesn't inherit the old plane's _drPrevRaw.) + const sameTrack = + flightState._drReconcileValid && flightState._drReconcileIcao === icao24; + const raw = _deadReckon(icao24, flightState._scratchDrRaw); + flightState._cachedDRCourse = flightState._drCourseDeg; + flightState._cachedDRSpeedMps = flightState._drSpeedMps; + flightState._cachedDRHold = flightState._drCourseHold; + flightState._cachedDRFrame = frame; + flightState._drReconcileIcao = icao24; + if (!raw) { + flightState._cachedDRPosition = null; + flightState._drReconcileValid = false; + clearFocusTarget('militaryFlights', icao24); + return null; + } + + const nowMs = Date.now(); + const info = flightState._flightData.get(icao24); + if (sameTrack) { + const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); + const speed = (info && info.speedMps) || 0; + // Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base. + const plausible = speed * dtSec * 4 + 25; + if (Cesium.Cartesian3.distance(raw, flightState._drPrevRaw) > plausible) { + // Discontinuity — re-anchor so the DISPLAYED position stays continuous, then decay. + Cesium.Cartesian3.subtract( + flightState._drPrevDisplay, + raw, + flightState._drCorrection, + ); + flightState._drCorrectionStartMs = nowMs; + } + } else { + Cesium.Cartesian3.fromElements(0, 0, 0, flightState._drCorrection); + flightState._drCorrectionStartMs = nowMs - DR_CORRECTION_MS; // fully decayed + } + + const elapsed = nowMs - flightState._drCorrectionStartMs; + const factor = + elapsed >= DR_CORRECTION_MS ? 0 : 1 - elapsed / DR_CORRECTION_MS; + const display = Cesium.Cartesian3.multiplyByScalar( + flightState._drCorrection, + factor, + flightState._trackedPosHolder, + ); + Cesium.Cartesian3.add(raw, display, display); + + Cesium.Cartesian3.clone(raw, flightState._drPrevRaw); + Cesium.Cartesian3.clone(display, flightState._drPrevDisplay); + flightState._drPrevMs = nowMs; + flightState._drReconcileValid = true; + flightState._cachedDRPosition = display; + const focusSizePx = _trackedFocusSizePx( + icao24, + flightState._cachedDRPosition, + ); + // Publish the exact frame cache shared by entity + follow camera. A second + // DR sample here would advance reconciliation in a different frame phase + // and visibly jitter the focus rectangle against the tracked aircraft. + publishFocusTargetFromCachedPosition({ + ownerLayer: 'militaryFlights', + id: icao24, + scene: flightState._viewer?.scene, + camera: flightState._viewer?.camera, + displayPosition: flightState._cachedDRPosition, + widthPx: focusSizePx, + heightPx: focusSizePx, + }); + return display; + } + + /** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already + * settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at + * a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the + * now-stable plane. Null when no valid fix (callers fall back to the billboard position). */ + + function _trackedDisplayCached() { + return flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao + ? flightState._cachedDRPosition + : null; + } + + /** + * The position the tracked aircraft is VISUALLY at this frame — the translation its 3D + * model is actually rendering with when the model owns the visual, otherwise the cached + * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. + * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, + * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its + * follow-camera anti-jitter contract untouched. + */ + + function _trackedVisualCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + return modelVisualAnchor( + flightState._trackedModel.modelMatrix, + spec.visualCenterNative, + Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale, + flightState._trackedVisualPos, + ); + } + return _trackedDisplayCached(); + } + + /** The tracked model's rendered bounding radius (m), or 0 when no model of this + * contact is drawing. Carries Cesium's effective `computedScale`, which may be + * above `scale` to satisfy minimumPixelSize — the trail head has to be judged + * against the size the operator SEES, not the nominal one. */ + /** World-space origin of the tracked model, or null when no model of this + * contact is drawing. This is the centre the rendered bounding sphere is + * measured from, so the trail clip and the envelope agree on one frame. */ + + function _trackedModelCenterWorld() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return null; + return Cesium.Matrix4.getTranslation( + flightState._trackedModel.modelMatrix, + flightState._scratchTrailClip, + ); + } + + function _trackedModelEnvelopeM() { + if ( + !flightState._trackedIcao || + !flightState._trackedModel || + !parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) + return 0; + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const scale = Number.isFinite(flightState._trackedModel.computedScale) + ? flightState._trackedModel.computedScale + : spec.scale; + return spec.nativeRadiusM * scale; + } + + /** Trail endpoint for the rendered tracked owner. Brackets/readouts stay on + * the visual centre; only the trail moves to the model's aft-belly hardpoint. */ + + function _trackedTrailCached() { + if ( + flightState._trackedIcao && + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ) { + const spec = parts.rendering._modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + // Through the model's OWN render chain (modelVisualAnchor's hand-rolled + // half-correction put this offset on the lateral axis — see + // modelAnchorWorld). + return modelAnchorWorld( + flightState._trackedModel, + spec.trailAnchorNative, + flightState._trackedTrailPos, + ); + } + return _trackedDisplayCached(); + } + + /** Smoothed world course for the tracked aircraft this frame. Reads the + * frame-cached course (set by _trackedDisplayPosition — the follow-camera's + * own computation), NEVER re-runs _deadReckon. Safe to call more than once + * per frame: the second call sees dt≈0 and the limiter is a no-op. + * + * The smoothed value lives in the SHARED per-icao _displayCourse entry (see + * its declaration): on click the limiter continues from whatever nose the + * fleet pass was displaying, and on untrack the fleet pass continues from + * whatever nose this path last wrote — tracked and fleet consumers of the + * same aircraft can never disagree across the handoff (mirror of flights.js). */ + + function _trackedDisplayCourse() { + const info = flightState._flightData.get(flightState._trackedIcao); + const fallback = (info && info.track) || 0; + const cacheValid = + flightState._drReconcileValid && + flightState._drReconcileIcao === flightState._trackedIcao && + flightState._cachedDRCourse != null; + const raw = cacheValid ? flightState._cachedDRCourse : fallback; + const nowMs = Date.now(); + const dt = flightState._trackedCourseMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._trackedCourseMs) / 1000, + ) + : 0; + flightState._trackedCourseMs = nowMs; + const prev = flightState._displayCourse.get(flightState._trackedIcao); + // Hover hold: at near-zero displayed speed both the chord and the reported + // track are noise — keep the last stable nose direction instead of chasing. + if (cacheValid && flightState._cachedDRHold && prev != null) return prev; + const cap = courseSlewCapDps( + cacheValid + ? flightState._cachedDRSpeedMps + : ((info && info.speedMps) ?? NaN), + COURSE_MAX_DPS, + ); + const course = limitCourseStep(prev, raw, cap, dt); + flightState._displayCourse.set(flightState._trackedIcao, course); + return course; + } + + /** Reset reconciliation + per-frame cache when tracking stops or switches + * target. Deliberately does NOT touch _displayCourse: the smoothed course + * entry belongs to the AIRCRAFT (not to the tracked session) and must + * survive the handoff back to the fleet pass. */ + + function _resetTrackedDisplay() { + flightState._drReconcileValid = false; + flightState._drReconcileIcao = null; + flightState._cachedDRFrame = -1; + flightState._cachedDRPosition = null; + flightState._cachedDRCourse = null; + flightState._cachedDRSpeedMps = null; + flightState._cachedDRHold = false; + flightState._trackedCourseMs = 0; + } + return { + _deadReckon, + _extrapolateFix, + _isTrackWarmingUp, + _trackedFocusSizePx, + _trackedDisplayPosition, + _trackedDisplayCached, + _trackedVisualCached, + _trackedModelCenterWorld, + _trackedModelEnvelopeM, + _trackedTrailCached, + _trackedDisplayCourse, + _resetTrackedDisplay, + }; } diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index a8eb46d2a..75a559f3f 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -4,24 +4,51 @@ import { createMilitaryFlightLayer } from './index.js'; import { createFlightState } from './state.js'; function services() { - const names = ['picking', 'sprites', 'trails', 'aircraftPresentation', 'camera', - 'militaryRegistry', 'labels', 'groundFloor', 'meshFloor', 'geoid', 'focus', - 'readout', 'context', 'render', 'recession']; - return { ...Object.fromEntries(names.map((name) => [name, {}])), - groundSnap: { createGroundSnap: () => ({ clear() {} }) } }; + const names = [ + 'picking', + 'sprites', + 'trails', + 'aircraftPresentation', + 'camera', + 'militaryRegistry', + 'labels', + 'groundFloor', + 'meshFloor', + 'geoid', + 'focus', + 'readout', + 'context', + 'render', + 'recession', + ]; + return { + ...Object.fromEntries(names.map((name) => [name, {}])), + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + }; } test('military layer instances isolate policy and restoration state without requesting a source', async () => { let requests = 0; - const source = { label: 'Fixture aircraft', getSnapshot() { requests++; } }; + const source = { + label: 'Fixture aircraft', + getSnapshot() { + requests++; + }, + }; const first = createMilitaryFlightLayer({ source, services: services() }); const second = createMilitaryFlightLayer({ source, services: services() }); first.setParams({ models3dMode: 'all' }); assert.equal(first.getParams().models3dMode, 'all'); assert.equal(second.getParams().models3dMode, 'proximity'); first.testing._setMilitaryTrackingRefreshOutcomeForTest({ ids: [] }); - assert.equal((await first.resolveTrackingRestoreTarget('abc123')).status, 'missing'); - assert.equal((await second.resolveTrackingRestoreTarget('abc123')).status, 'source-unavailable'); + assert.equal( + (await first.resolveTrackingRestoreTarget('abc123')).status, + 'missing', + ); + assert.equal( + (await second.resolveTrackingRestoreTarget('abc123')).status, + 'source-unavailable', + ); assert.equal(requests, 0); }); @@ -33,8 +60,16 @@ test('military source omission fails before viewer initialization', () => { test('military state owns separate contact maps, motion scratch and ground sampling', () => { const first = createFlightState({ services: services() }); const second = createFlightState({ services: services() }); - for (const key of ['_flightData', '_billboards', '_positionHistory', '_groundSnap', - '_scratchCarto', '_models', '_activeUpdateControllers', 'lifetime']) { + for (const key of [ + '_flightData', + '_billboards', + '_positionHistory', + '_groundSnap', + '_scratchCarto', + '_models', + '_activeUpdateControllers', + 'lifetime', + ]) { assert.notEqual(first[key], second[key], key); } }); @@ -45,12 +80,23 @@ test('a normalized source can retain its stale reason without changing standalon supplied.groundFloor.warmGroundFloor = async () => {}; supplied.meshFloor.sampleMeshFloorCells = () => {}; supplied.militaryRegistry.registerMilitaryIcaos = () => {}; - const layer = createMilitaryFlightLayer({ services: supplied, source: { - label: 'Fixture aircraft', async getSnapshot() { return { - source: 'Fixture aircraft', records: [], complete: true, - observedAtMs: 123000, stale: true, freshness: 'stale', reason, - }; }, - } }); + const layer = createMilitaryFlightLayer({ + services: supplied, + source: { + label: 'Fixture aircraft', + async getSnapshot() { + return { + source: 'Fixture aircraft', + records: [], + complete: true, + observedAtMs: 123000, + stale: true, + freshness: 'stale', + reason, + }; + }, + }, + }); await layer.update({}); assert.equal(layer.getStats().error, reason); assert.equal(layer.getStats().lastUpdate, 123000); diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index 16961748f..c72c06088 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; - /** * @module militaryFlights * @description Real-time military flight tracking layer powered by the adsb.lol API. @@ -30,7 +29,6 @@ export const POSITION_HISTORY_LIMIT = 5; export const BILLBOARD_SCALE = 0.7; - /** @constant {Cesium.Color} Default amber tint for untracked military billboards */ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); @@ -39,7 +37,6 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); - // --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- // adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate // boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude @@ -58,7 +55,6 @@ export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); export const GROUND_SCALE = 0.8; - // --- 3D model rendering (mirrors flights.js) ----------------------------------------- // When enabled, military aircraft render as 3D glTF jet models once the camera is below // MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is @@ -69,18 +65,17 @@ export const GROUND_SCALE = 0.8; export const JET_MODEL_URL = '/models/jet.glb'; - export const MODEL_ALT_CEIL_M = 800000; - // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) +// m: below this camera altitude, draw 3D models (raised so it's easy to trigger) export const MODEL_MIN_PX = 24; - // floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) +// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) export const TRACKED_MODEL_MIN_PX = 40; - // keep the glTF silhouette comparable to the selected 2D glyph at handoff +// keep the glTF silhouette comparable to the selected 2D glyph at handoff export const TRACKED_MODEL_MAX_PX = 200; - // owner-selected close-range tracked-target feel +// owner-selected close-range tracked-target feel export const MODEL_NATIVE_RADIUS_M = 29.83; @@ -97,32 +92,32 @@ export const MODEL_SCALE = 1; // and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). export const MODEL_MAX = 150; - // 'proximity' cap +// 'proximity' cap export const MODEL_MAX_ALL = 350; - // 'all' cap +// 'all' cap -export const MODEL_PROX_ADD_M = 150000; - // proximity: model NEW planes within 150 km +export const MODEL_PROX_ADD_M = 150000; +// proximity: model NEW planes within 150 km export const MODEL_PROX_KEEP_M = 185000; - // proximity: KEEP modeled planes out to 185 km +// proximity: KEEP modeled planes out to 185 km // Cockpit keeps the standard Display radii but lowers the GLB budget. Contacts // inside the selected band remain AIR silhouettes when they cannot own a model; // contacts outside the band use compact dots. export const COCKPIT_MODEL_MAX = 60; - // max concurrent GLBs in cockpit (never raises the map cap) +// max concurrent GLBs in cockpit (never raises the map cap) -export const MODEL_ALL_ADD_M = 400000; - // all: model NEW planes within 400 km (~to the horizon) +export const MODEL_ALL_ADD_M = 400000; +// all: model NEW planes within 400 km (~to the horizon) -export const MODEL_ALL_KEEP_M = 450000; - // all: KEEP modeled planes out to 450 km +export const MODEL_ALL_KEEP_M = 450000; +// all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; - // every aircraft GLB is exported nose -X in the shared transform-applied convention +// every aircraft GLB is exported nose -X in the shared transform-applied convention // Preserve the layer's amber identity while reducing approved texture/livery // contribution to a weak diffuse hint, matching civilian launch presentation. @@ -134,13 +129,10 @@ export const MODEL_COLOR_BLEND_AMOUNT = 0.94; export const PLANE_MODEL_URL = '/models/airplane.glb'; - export const PLANE_MODEL_SCALE = 1; - export const PLANE_NATIVE_RADIUS_M = 34.41; - export const PLANE_BELLY_OFFSET_NATIVE = 6.719; // Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this @@ -156,16 +148,15 @@ export const MODEL_BELLY_OFFSET_NATIVE = 5.631; export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); - - export const COCKPIT_CONTACT_SIZE_PX = 6; - export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( - 1000, 3.0, 8000000, 0.5, + 1000, + 3.0, + 8000000, + 0.5, ); - // --------------------------------------------------------------------------- // Track-history trail state (PRD WS-F F2/F4): a fading polyline behind the // tracked aircraft. The accumulation array is intentionally SEPARATE from @@ -181,7 +172,6 @@ export const TRAIL_COLOR = '#FFB800'; export const TRAIL_MAX_POINTS = 400; - // --------------------------------------------------------------------------- // Render-behind smoothing (mirrors flights.js): the fleet renders at // now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes @@ -229,7 +219,6 @@ export const LANDED_MISSING_POLL_LIMIT = 1; export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; - // --------------------------------------------------------------------------- // Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment // degenerates when the camera looks straight down (the normal is parallel to @@ -258,7 +247,6 @@ export const COURSE_MAX_DPS = 60; export const COURSE_SLEW_DT_MAX_SEC = 0.25; - // -- Tracked-display reconciliation (see flights.js for the rationale): absorb a raw // position step (warm-up→interpolation handoff, feed glitch, backfill splice) into a // correction offset that decays to zero over DR_CORRECTION_MS, so the tracked icon, @@ -266,7 +254,6 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25; export const DR_CORRECTION_MS = 900; - /** Bounded on-demand loading for the tracked model (mirror of flights.js). The * tracked regime is DEFAULT-ON and its driver runs every `scene.preUpdate`, so * a missing or corrupt GLB — or a dead network — would otherwise spin @@ -279,7 +266,6 @@ export const DR_CORRECTION_MS = 900; export const TRACKED_MODEL_MAX_LOAD_FAILS = 3; - export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500; /** Boost flip = BATCHED release through the fleet tick (mirror of flights.js — diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 373cd8eb8..a1e545d6d 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -3,674 +3,817 @@ import * as Cesium from 'cesium'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; -import { rankContactMatch, contactMatchWins, CONTACT_MATCH_TIER } from '../../data/contactMatch.js'; +import { + rankContactMatch, + contactMatchWins, + CONTACT_MATCH_TIER, +} from '../../data/contactMatch.js'; import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; -export function createQueries({ flightState, services, parts, layer, resolveAsset }) { -const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation; -const { formatFlightLevel } = services.labels; -const { applyTrackedCameraFrame } = services.camera; +export function createQueries({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation; + const { formatFlightLevel } = services.labels; + const { applyTrackedCameraFrame } = services.camera; + /** + * Coerce a value to a finite number, returning null if not possible. + * Handles both numeric and string inputs (e.g. API fields that may arrive as strings). + * @param {*} value - Raw value from the API response + * @returns {number|null} Finite number or null + */ -/** - * Coerce a value to a finite number, returning null if not possible. - * Handles both numeric and string inputs (e.g. API fields that may arrive as strings). - * @param {*} value - Raw value from the API response - * @returns {number|null} Finite number or null - */ - -function _toFiniteNumber(value) { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const n = Number.parseFloat(value.trim()); - return Number.isFinite(n) ? n : null; + function _toFiniteNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const n = Number.parseFloat(value.trim()); + return Number.isFinite(n) ? n : null; + } + return null; } - return null; -} - - -/** - * Safely convert a value to a trimmed string, defaulting to '' for falsy inputs. - * @param {*} value - Raw value to stringify - * @returns {string} Trimmed string - */ - -function _toCleanText(value) { - return String(value || '').trim(); -} - - -/** - * Format an altitude value in feet for display, with fallback text. - * @param {number|null|undefined} altitudeFt - Barometric altitude in feet - * @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown") - */ - -function _formatAltitude(altitudeFt) { - if (!Number.isFinite(altitudeFt)) return 'Alt unknown'; - return `${Math.round(altitudeFt)} ft`; -} - - -/** - * True when the aircraft's latest metadata reads "on or about the runway" - * (low + slow) — see the landed fast-cull rationale above. Both gates must - * hold, so a plane missing either datum keeps the normal grace. - * @param {string} icao24 - ICAO hex identifier of the aircraft - * @returns {boolean} - */ - -function _likelyLanded(icao24) { - const info = flightState._flightData.get(icao24); - if (!info) return false; - // Round 7 (mirror of flights.js): fast cull only for contacts seen - // AIRBORNE this session — parked contacts ride the normal grace so feed - // flaps don't churn their identity/floor state. - if (info.wasAirborne !== true) return false; - if (info.onGround) return true; - return Number.isFinite(info.altitudeFt) && info.altitudeFt < LANDED_ALT_MAX_FT - && Number.isFinite(info.speedMps) && info.speedMps < LANDED_SPEED_MAX_MPS; -} - - -/** - * Build a public descriptor for one aircraft using its best current position - * (dead-reckoned when history exists, billboard position otherwise). - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Descriptor with a cloned position, or null if the aircraft is unknown. - */ - -function _describeFlight(icao24) { - const info = flightState._flightData.get(icao24); - const bb = flightState._billboards.get(icao24); - const basePos = parts.motion._deadReckon(icao24) || (bb ? bb.position : null); - if (!basePos) return null; - const displayed = displayedKinematics({ - derivedSpeedMps: flightState._drSpeedMps, - derivedTrackDeg: flightState._drCourseDeg, - reportedSpeedMps: info?.speedMps, - reportedTrackDeg: info?.track, - }); - const carto = Cesium.Cartographic.fromCartesian(basePos, Cesium.Ellipsoid.WGS84, flightState._scratchCarto); - if (!carto) return null; - return { - icao24, - callsign: _toCleanText(info?.callsign) || null, - // Additive: the label chain's middle link, trimmed like `callsign`, so - // getTrackedSubject and the voice narration can read it straight off the - // descriptor instead of reaching back into `_flightData`. - registration: _toCleanText(info?.registration) || null, - position: Cesium.Cartesian3.clone(basePos), - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - // Keep the cockpit readout on the reported aviation altitude. Render - // terrain height is a separate visual datum and may be below zero. - altitudeM: Number.isFinite(info?.altitudeM) ? info.altitudeM : carto.height, - renderAltitudeM: Number.isFinite(info?.renderAltitudeM) ? info.renderAltitudeM : carto.height, - onGround: info?.onGround === true, - velocityMps: displayed.speedMps, - track: displayed.trackDeg, - stale: Boolean(flightState._missingPolls.get(icao24) || flightState._backoff), - }; -} - - - -function _normalizeTrackedIcao(candidate) { - const normalized = String(candidate ?? '').trim().toLowerCase(); - return normalized || null; -} - - -/** - * Map one military aircraft's internal poll record to a plain JSON-safe - * analyst record (analyst query engine seam) — same shape as the flights - * layer's mapAnalystRecord, with `military` always true. Pure — no Cesium - * types, no fetches. Missing/unknown fields are null, never NaN/undefined. - * adsb.lol carries no origin-country field and this layer has no route - * enrichment, so originCountry/routeOrigin/routeDestination are always null. - * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @param {Object|null|undefined} info - `_flightData` record for this aircraft. - * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, - * lon: number|null, altitudeM: number|null, speedMps: number|null, - * heading: number|null, verticalRateMps: number|null, onGround: boolean, - * military: boolean, aircraftClass: string|null, originCountry: null, - * operator: string|null, routeOrigin: null, routeDestination: null}} - */ - -function mapAnalystRecord(icao24, info) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const callsign = text(info?.callsign); - return { - id: callsign || text(info?.registration) || icao24, - icao24, - callsign, - lat: num(info?.rawLat), - lon: num(info?.rawLon), - // altitudeFt is the sticky barometric/MSL aviation field — converted to - // meters here for shape parity with the flights layer. - altitudeM: Number.isFinite(info?.altitudeFt) ? info.altitudeFt * 0.3048 : null, - speedMps: num(info?.speedMps), - heading: num(info?.track), - verticalRateMps: num(info?.verticalRateMps), - onGround: info?.onGround === true, - military: true, - // A converted contact reports the class it RENDERS as (mirror of - // flights.js), so an analyst filter/superlative agrees with the triangle. - aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), - originCountry: null, - operator: text(info?.operator), - routeOrigin: null, - routeDestination: null, - }; -} -const methods = { mapAnalystRecord, - - id: 'military', - - name: 'Military Flights', - - icon: '🎖️', - - source: flightState._lastSource, - - /** @type {number} Polling interval in ms between API fetches */ - updateInterval: 15000, - - - /** @deprecated Compatibility alias for {@link enable}. */ - show(viewer) { - this.enable(viewer); - }, - - - /** @deprecated Compatibility alias for {@link disable}. */ - hide(viewer) { - this.disable(viewer); - }, - /** - * Live layer params. - * `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on, - * surrounding military aircraft become 3D models once the camera is zoomed in past - * MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by - * camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). - * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). - * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params + * Safely convert a value to a trimmed string, defaulting to '' for falsy inputs. + * @param {*} value - Raw value to stringify + * @returns {string} Trimmed string */ - setParams(params = {}, { origin = 'programmatic' } = {}) { - if (isExplicitLayerStateOrigin(origin) - && !Object.hasOwn(params, 'selectedMilitaryTrackingId')) { - parts.tracking._cancelPendingTrackingRestore(); - } - if (typeof params.models3d === 'boolean' && params.models3d !== flightState._models3dEnabled) { - flightState._models3dEnabled = params.models3d; - if (!flightState._models3dEnabled) { - parts.rendering._releaseModels(); - parts.tracking._syncTracked2dRotation(); - // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked - // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). - if (flightState._billboardCollection) for (const [icao, bb] of flightState._billboards) { if (icao !== flightState._trackedIcao) bb.show = true; } - } - } - if ((params.models3dMode === 'proximity' || params.models3dMode === 'all') && params.models3dMode !== flightState._models3dMode) { - // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. - flightState._models3dMode = params.models3dMode; - if (flightState._cockpitContactMode) { - parts.tracking._refreshCockpitNearContacts(); - flightState._lastFleetTickMs = 0; - } - } - if (typeof params.irBoost === 'boolean' && params.irBoost !== flightState._irBoost) { - flightState._irBoost = params.irBoost; - parts.rendering._reloadModelsForIrBoost(); - // Sprites don't reload with the models — swap the TR-3B glyph between its - // cold and thermal-reactive variants directly (mirror of flights.js). - parts.tracking._refreshTr3bForStyle(); - } - if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) { - const requested = _normalizeTrackedIcao(params.selectedMilitaryTrackingId); - if (requested === flightState._trackedIcao) { - flightState._pendingTrackingRestore = null; - } else if (requested === null) { - parts.tracking._cancelPendingTrackingRestore(); - if (flightState._trackedIcao) parts.tracking._clearTracking(false, { origin }); - } else { - const generation = ++flightState._trackingIntentGeneration; - flightState._pendingTrackingRestore = { id: requested, generation, origin }; - if (flightState._trackedIcao) parts.tracking._clearTracking(false, { origin }); - parts.tracking._applyPendingTrackingRestore(); - } - } - return true; - }, - - getParams() { - return { - models3d: flightState._models3dEnabled, - models3dMode: flightState._models3dMode, - irBoost: flightState._irBoost, - selectedMilitaryTrackingId: flightState._trackedIcao, - }; - }, + function _toCleanText(value) { + return String(value || '').trim(); + } /** - * Re-render a contact whose TR-3B conversion just flipped (Easter egg). - * Callers own the registry write; this only re-derives what renders. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when this layer owns the contact. + * Format an altitude value in feet for display, with fallback text. + * @param {number|null|undefined} altitudeFt - Barometric altitude in feet + * @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown") */ - refreshTr3b(icao24) { return parts.tracking._refreshTr3bContact(icao24); }, + function _formatAltitude(altitudeFt) { + if (!Number.isFinite(altitudeFt)) return 'Alt unknown'; + return `${Math.round(altitudeFt)} ft`; + } /** - * Find military aircraft near a given ECEF position, sorted by distance. - * Used by proximity-based features (e.g. detection overlay, spatial queries). - * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates - * @param {number} range - Maximum distance in meters (Infinity if not specified) - * @param {number} [maxCount=50] - Maximum number of results to return - * @param {object} [options] Query membership options. - * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. - * @returns {Array} Sorted array of nearby aircraft descriptors + * True when the aircraft's latest metadata reads "on or about the runway" + * (low + slow) — see the landed fast-cull rationale above. Both gates must + * hold, so a plane missing either datum keeps the normal grace. + * @param {string} icao24 - ICAO hex identifier of the aircraft + * @returns {boolean} */ - getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if (!center || !flightState._billboardCollection || !flightState._billboardCollection.show) return []; - - const limit = Number.isFinite(maxCount) - ? Math.max(1, Math.floor(maxCount)) - : 50; - const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - - const now = Cesium.JulianDate.now(); - const nearby = []; - - for (const [icao24, bb] of flightState._billboards) { - const isTracked = icao24 === flightState._trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so proximity counts - // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. - if (!aircraftIncludedInNearby({ - isTracked, - billboardShown: bb.show, - modelRendering: parts.rendering._modelOwnsVisual(icao24), - includeHidden, - })) continue; - - const trackedPos = isTracked ? parts.motion._trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; - if (!pos) continue; - - const distance = Cesium.Cartesian3.distance(center, pos); - if (distance > maxRange) continue; - - const info = flightState._flightData.get(icao24); - nearby.push({ - id: info?.callsign?.trim() || info?.registration?.trim() || icao24, - icao24, - position: pos, - distance, - // Filter surface (mirror of flights.js): the cockpit next/previous path - // matches on this field, so it follows the conversion. - aircraftClass: tr3bAircraftClass(icao24, String(info?.klass || info?.type || '').trim().toLowerCase() || null), - track: info?.track ?? null, - // Display type — converted too, so a Contacts row can't still name the - // airframe the triangle replaced (the filter matcher reads this as a - // fallback candidate as well). - type: tr3bTypeLabel(icao24, info?.type || null), - registration: info?.registration || null, - operator: info?.operator || null, - altitudeFt: info?.altitudeFt ?? null, - }); - } - - nearby.sort((a, b) => a.distance - b.distance); - return nearby.slice(0, limit); - }, + function _likelyLanded(icao24) { + const info = flightState._flightData.get(icao24); + if (!info) return false; + // Round 7 (mirror of flights.js): fast cull only for contacts seen + // AIRBORNE this session — parked contacts ride the normal grace so feed + // flaps don't churn their identity/floor state. + if (info.wasAirborne !== true) return false; + if (info.onGround) return true; + return ( + Number.isFinite(info.altitudeFt) && + info.altitudeFt < LANDED_ALT_MAX_FT && + Number.isFinite(info.speedMps) && + info.speedMps < LANDED_SPEED_MAX_MPS + ); + } /** - * Return a subset of aircraft suitable for the detection overlay system. - * Uses a deterministic stride-based sampling to keep the count manageable - * while distributing selections evenly across the collection. - * @param {Object} [options={}] - Options - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all) - * @param {number} [options.seed] - Seed offset for stride sampling (for frame variation) - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} - */ - getDetectableObjects(options = {}) { - if (!flightState._billboardCollection || !flightState._billboardCollection.show) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : flightState._billboards.size; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Deterministic stride: evenly space selections across the billboard map - const stride = Math.max(1, Math.ceil(flightState._billboards.size / maxCount)); - const start = seed % stride; - - const result = []; - let idx = 0; - for (const [icao24, bb] of flightState._billboards) { - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (flightState._cockpitContactMode && icao24.toLowerCase() === flightState._cockpitSubjectId) continue; - const isTracked = icao24 === flightState._trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so the detection box - // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. - const model = flightState._models.get(icao24); - const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); - if (!isTracked && !bb.show && !modelOwnsVisual) continue; - const info = flightState._flightData.get(icao24); - const callsign = info?.callsign?.trim(); - const registration = info?.registration?.trim(); - let object = flightState._detectionObjects.get(icao24); - if (!object) { - object = { - sourceId: icao24, type: 'AIR', tier: 'military', _weldPos: new Cesium.Cartesian3(), - }; - flightState._detectionObjects.set(icao24, object); - } - // WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned - // contacts read the translation the fleet tick already wrote into their - // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket - // are co-located anyway. - const spec = modelOwnsVisual ? parts.rendering._modelSpec(info?.klass) : null; - const pos = isTracked - ? (parts.motion._trackedVisualCached() || bb.position) - : (modelOwnsVisual - ? modelVisualAnchor( - model.modelMatrix, - spec.visualCenterNative, - Number.isFinite(model.computedScale) ? model.computedScale : spec.scale, - object._weldPos || (object._weldPos = new Cesium.Cartesian3()), - ) - : bb.position); - if (!pos) continue; - object.position = pos; - object.skipLabel = isTracked; - const id = callsign || registration || icao24; - if (object.id !== id) object.id = id; - // Card surface: detectionDraw composes its secondary line from - // `[src.klass, src.metric]`, so a converted contact's card must name the - // TR-3B, not the airframe underneath it. - const klass = tr3bTypeLabel(icao24, info?.type || 'MIL'); - if (object.klass !== klass) object.klass = klass; - const altitudeFt = info?.altitudeFt ?? 0; - if (object._altitudeFt !== altitudeFt) { - object._altitudeFt = altitudeFt; - // military metadata stores altitudeFt (feet); the helper takes metres - object.metric = formatFlightLevel(altitudeFt * 0.3048); - } - result.push(object); - if (result.length >= maxCount) break; - } - if (flightState._detectionObjects.size > flightState._billboards.size + 512) { - for (const icao24 of flightState._detectionObjects.keys()) { - if (!flightState._billboards.has(icao24)) flightState._detectionObjects.delete(icao24); - } - } - return result; - }, - - - /** - * Find a single aircraft by free-text query. - * Match priority: exact icao24 hex, exact callsign, callsign prefix, - * then callsign substring (all case-insensitive, trimmed). - * @param {string} query - ICAO hex or full/partial callsign. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Best match with a cloned, dead-reckoned position, or null if none. - */ - findByQuery(query) { - if (!flightState._flightData || flightState._flightData.size === 0) return null; - const q = String(query || '').trim().toLowerCase(); - if (!q) return null; - - // Registration is searched alongside callsign because it is what the - // operator SEES: a callsign-less contact reads as its tail number on the - // card, in the analyst's answer, and in the Contacts list. Matching only - // callsigns meant "follow 6606" — and the analyst → track_entity handoff - // the tool instructions prescribe — answered "nothing matched" for the - // very identity the app had just shown (owner field session 2026-08-21, - // 23:48: three failed track_entity retries before a fallback stuck). - // Ranking is shared with the flights layer (contactMatch.js) so the two - // cannot disagree, and it is strictly tiered so a registration can never - // out-rank a real callsign on feed order alone. - let best = null; - for (const [icao24, info] of flightState._flightData) { - const candidate = { - tier: rankContactMatch({ - query: q, - hex: icao24, - callsign: info?.callsign, - registration: info?.registration, - }), - id: icao24, - }; - if (!contactMatchWins(candidate, best)) continue; - best = candidate; - if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; - } - return best ? _describeFlight(best.id) : null; - }, - - - /** - * Return id/label/position for up to maxCount currently rendered aircraft. - * Cheap snapshot for voice-tool framing — billboard positions only, no - * dead reckoning and no cloning. - * @param {number} [maxCount=500] - Maximum number of entries to return. - * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} - */ - /** - * Whether this layer still carries a contact, in O(1). - * - * Mirror of `flights.hasContact`: presence consumers must not infer absence - * from the capped `getAllPositions` rows. Id matching follows trackById — - * exact key first, then lowercase. - * A disabled layer keeps its records but hides the collection, so it must - * decline rather than answer from data the user can no longer see. - * @param {string} icao24 Contact identifier. - * @returns {boolean|null} Presence, or null when the layer is disabled or - * holds no data and therefore cannot answer. - */ - hasContact(icao24) { - if (!flightState._billboardCollection || !flightState._billboardCollection.show || flightState._billboards.size === 0) return null; - if (!icao24) return false; - const id = String(icao24).trim(); - return flightState._billboards.has(id) || flightState._billboards.has(id.toLowerCase()); - }, - - - getAllPositions(maxCount = 500) { - if (!flightState._billboardCollection || flightState._billboards.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 500; - - const result = []; - for (const [icao24, bb] of flightState._billboards) { - const pos = bb.position; - if (!pos) continue; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, flightState._scratchCarto); - if (!carto) continue; - const info = flightState._flightData.get(icao24); - result.push({ - id: icao24, // identity (trackById/Context resolve this) - // Last surface still reading as the raw hex for a callsign-less - // contact — same chain as getNearby/getDetectableObjects/getTrackedSubject. - label: _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24, - position: pos, - latitude: Cesium.Math.toDegrees(carto.latitude), - longitude: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - }); - if (result.length >= limit) break; - } - return result; - }, - - - /** - * Snapshot the layer's in-memory records as plain JSON-safe objects for - * the analyst query engine. On-demand only (called at most once per - * spoken query) — zero per-frame cost, no listeners, no caching. Returns - * [] while the layer is disabled or empty. - * @param {number} [maxCount=2000] - Maximum records to return (truncation). - * @returns {Array} See mapAnalystRecord for the record shape. - */ - getAnalystRecords(maxCount = 2000) { - if (!flightState._billboardCollection || !flightState._billboardCollection.show || flightState._flightData.size === 0) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const [icao24, info] of flightState._flightData) { - result.push(mapAnalystRecord(icao24, info)); - if (result.length >= limit) break; - } - return result; - }, - - - /** - * Start camera-tracking an aircraft by ICAO hex identifier. + * Build a public descriptor for one aircraft using its best current position + * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {boolean} True if the aircraft exists and tracking started. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Descriptor with a cloned position, or null if the aircraft is unknown. */ - trackById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24) return false; - let id = String(icao24).trim(); - if (!flightState._billboards.has(id)) id = id.toLowerCase(); - if (!flightState._billboards.has(id)) return false; - if (parts.tracking._isExplicitTrackingOrigin(origin)) parts.tracking._cancelPendingTrackingRestore(); - if (flightState._trackedIcao === id) return parts.tracking._publishTrackedSelection(id, origin); - parts.tracking._trackFlight(id, { origin }); - return true; - }, - - /** Resolve a shared Follow target only against the latest accepted refresh. */ - async resolveTrackingRestoreTarget(icao24, { signal = null, origin = 'share-restore' } = {}) { - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const id = String(icao24 ?? '').trim().toLowerCase(); - if (!id) return { status: 'missing', reason: 'invalid-target' }; - const outcome = flightState._lastTrackingRefreshOutcome; - if (outcome.status !== 'accepted') { - return { - status: 'source-unavailable', - reason: `${flightState._lastSource} snapshot unavailable`, - refreshEpoch: outcome.epoch, - source: outcome.source, - }; - } - if (!outcome.ids.has(id)) { - return { - status: 'missing', - reason: 'target-absent-from-snapshot', - refreshEpoch: outcome.epoch, - source: outcome.source, - }; - } - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - const followed = this.trackById(id, { origin }); - return followed - ? { status: 'found', refreshEpoch: outcome.epoch, source: outcome.source } - : { status: 'source-unavailable', reason: 'target-not-renderable', refreshEpoch: outcome.epoch }; - }, - - - /** Reapply the canonical follow frame without recreating the selected flight. */ - refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { - if (!icao24 || flightState._cockpitContactMode || !flightState._viewer || !flightState._trackedEntity) return false; - let id = String(icao24).trim(); - if (!flightState._billboards.has(id)) id = id.toLowerCase(); - if ( - id !== flightState._trackedIcao - || !flightState._viewer.entities?.contains?.(flightState._trackedEntity) - || flightState._viewer.trackedEntity !== flightState._trackedEntity - ) return false; - flightState._trackedCameraFrameStop?.(); - flightState._viewer.camera.cancelFlight(); - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; - parts.tracking._publishTrackedSelection(id, origin); - return true; - }, - - - /** - * Stop tracking the currently followed aircraft (no-op if none). - * @returns {boolean} Always true. - */ - stopTracking({ origin = 'programmatic' } = {}) { - parts.tracking._cancelPendingTrackingRestore(); - parts.tracking._clearTracking(false, { origin }); - return true; - }, - - - cancelPendingTrackingRestore() { - parts.tracking._cancelPendingTrackingRestore(); - }, - - - /** - * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} - * Tracked aircraft info, or null when nothing is tracked. - */ - getTrackedInfo() { - if (!flightState._trackedIcao) return null; - const described = _describeFlight(flightState._trackedIcao); - if (!described) return null; - const { position, ...rest } = described; - return rest; - }, - - - /** - * Return the current aircraft as a Context subject without changing - * tracking or camera ownership. - * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} - * Detached subject descriptor, or null when no aircraft is tracked. - */ - getTrackedSubject() { - if (!flightState._trackedIcao) return null; - const described = _describeFlight(flightState._trackedIcao); - if (!described?.position) return null; + function _describeFlight(icao24) { + const info = flightState._flightData.get(icao24); + const bb = flightState._billboards.get(icao24); + const basePos = + parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + if (!basePos) return null; + const displayed = displayedKinematics({ + derivedSpeedMps: flightState._drSpeedMps, + derivedTrackDeg: flightState._drCourseDeg, + reportedSpeedMps: info?.speedMps, + reportedTrackDeg: info?.track, + }); + const carto = Cesium.Cartographic.fromCartesian( + basePos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) return null; return { - layerId: 'military', - id: described.icao24, - // Same label chain as getNearby/getDetectableObjects: a callsign-less - // contact reads as its registration, never as the raw ICAO hex. - label: described.callsign || described.registration || described.icao24, - position: Cesium.Cartesian3.clone(described.position), - }; - }, - - - /** - * Return current layer health/status for the HUD status chip. - * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} - */ - getStats() { - const retryInSec = flightState._retryAt ? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000)) : 0; - return { - count: flightState._count, - lastUpdate: flightState._lastUpdate, - stale: flightState._backoff, - error: flightState._lastError, - status: flightState._lastStatus, - retryInSec, - source: flightState._lastSource, - fallback: false, + icao24, + callsign: _toCleanText(info?.callsign) || null, + // Additive: the label chain's middle link, trimmed like `callsign`, so + // getTrackedSubject and the voice narration can read it straight off the + // descriptor instead of reaching back into `_flightData`. + registration: _toCleanText(info?.registration) || null, + position: Cesium.Cartesian3.clone(basePos), + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + // Keep the cockpit readout on the reported aviation altitude. Render + // terrain height is a separate visual datum and may be below zero. + altitudeM: Number.isFinite(info?.altitudeM) + ? info.altitudeM + : carto.height, + renderAltitudeM: Number.isFinite(info?.renderAltitudeM) + ? info.renderAltitudeM + : carto.height, + onGround: info?.onGround === true, + velocityMps: displayed.speedMps, + track: displayed.trackDeg, + stale: Boolean( + flightState._missingPolls.get(icao24) || flightState._backoff, + ), }; } -}; -return { _toFiniteNumber, _toCleanText, _formatAltitude, _likelyLanded, _describeFlight, _normalizeTrackedIcao, mapAnalystRecord, methods }; + function _normalizeTrackedIcao(candidate) { + const normalized = String(candidate ?? '') + .trim() + .toLowerCase(); + return normalized || null; + } + + /** + * Map one military aircraft's internal poll record to a plain JSON-safe + * analyst record (analyst query engine seam) — same shape as the flights + * layer's mapAnalystRecord, with `military` always true. Pure — no Cesium + * types, no fetches. Missing/unknown fields are null, never NaN/undefined. + * adsb.lol carries no origin-country field and this layer has no route + * enrichment, so originCountry/routeOrigin/routeDestination are always null. + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @param {Object|null|undefined} info - `_flightData` record for this aircraft. + * @returns {{id: string, icao24: string, callsign: string|null, lat: number|null, + * lon: number|null, altitudeM: number|null, speedMps: number|null, + * heading: number|null, verticalRateMps: number|null, onGround: boolean, + * military: boolean, aircraftClass: string|null, originCountry: null, + * operator: string|null, routeOrigin: null, routeDestination: null}} + */ + + function mapAnalystRecord(icao24, info) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const callsign = text(info?.callsign); + return { + id: callsign || text(info?.registration) || icao24, + icao24, + callsign, + lat: num(info?.rawLat), + lon: num(info?.rawLon), + // altitudeFt is the sticky barometric/MSL aviation field — converted to + // meters here for shape parity with the flights layer. + altitudeM: Number.isFinite(info?.altitudeFt) + ? info.altitudeFt * 0.3048 + : null, + speedMps: num(info?.speedMps), + heading: num(info?.track), + verticalRateMps: num(info?.verticalRateMps), + onGround: info?.onGround === true, + military: true, + // A converted contact reports the class it RENDERS as (mirror of + // flights.js), so an analyst filter/superlative agrees with the triangle. + aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), + originCountry: null, + operator: text(info?.operator), + routeOrigin: null, + routeDestination: null, + }; + } + const methods = { + mapAnalystRecord, + + id: 'military', + + name: 'Military Flights', + + icon: '🎖️', + + source: flightState._lastSource, + + /** @type {number} Polling interval in ms between API fetches */ + updateInterval: 15000, + + /** @deprecated Compatibility alias for {@link enable}. */ + show(viewer) { + this.enable(viewer); + }, + + /** @deprecated Compatibility alias for {@link disable}. */ + hide(viewer) { + this.disable(viewer); + }, + + /** + * Live layer params. + * `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on, + * surrounding military aircraft become 3D models once the camera is zoomed in past + * MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by + * camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js). + * `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane). + * @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params + */ + setParams(params = {}, { origin = 'programmatic' } = {}) { + if ( + isExplicitLayerStateOrigin(origin) && + !Object.hasOwn(params, 'selectedMilitaryTrackingId') + ) { + parts.tracking._cancelPendingTrackingRestore(); + } + if ( + typeof params.models3d === 'boolean' && + params.models3d !== flightState._models3dEnabled + ) { + flightState._models3dEnabled = params.models3d; + if (!flightState._models3dEnabled) { + parts.rendering._releaseModels(); + parts.tracking._syncTracked2dRotation(); + // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked + // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). + if (flightState._billboardCollection) + for (const [icao, bb] of flightState._billboards) { + if (icao !== flightState._trackedIcao) bb.show = true; + } + } + } + if ( + (params.models3dMode === 'proximity' || + params.models3dMode === 'all') && + params.models3dMode !== flightState._models3dMode + ) { + // The next fleet tick re-derives the eligible set under the new cap and releases the overflow. + flightState._models3dMode = params.models3dMode; + if (flightState._cockpitContactMode) { + parts.tracking._refreshCockpitNearContacts(); + flightState._lastFleetTickMs = 0; + } + } + if ( + typeof params.irBoost === 'boolean' && + params.irBoost !== flightState._irBoost + ) { + flightState._irBoost = params.irBoost; + parts.rendering._reloadModelsForIrBoost(); + // Sprites don't reload with the models — swap the TR-3B glyph between its + // cold and thermal-reactive variants directly (mirror of flights.js). + parts.tracking._refreshTr3bForStyle(); + } + if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) { + const requested = _normalizeTrackedIcao( + params.selectedMilitaryTrackingId, + ); + if (requested === flightState._trackedIcao) { + flightState._pendingTrackingRestore = null; + } else if (requested === null) { + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + } else { + const generation = ++flightState._trackingIntentGeneration; + flightState._pendingTrackingRestore = { + id: requested, + generation, + origin, + }; + if (flightState._trackedIcao) + parts.tracking._clearTracking(false, { origin }); + parts.tracking._applyPendingTrackingRestore(); + } + } + return true; + }, + + getParams() { + return { + models3d: flightState._models3dEnabled, + models3dMode: flightState._models3dMode, + irBoost: flightState._irBoost, + selectedMilitaryTrackingId: flightState._trackedIcao, + }; + }, + + /** + * Re-render a contact whose TR-3B conversion just flipped (Easter egg). + * Callers own the registry write; this only re-derives what renders. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when this layer owns the contact. + */ + refreshTr3b(icao24) { + return parts.tracking._refreshTr3bContact(icao24); + }, + + /** + * Find military aircraft near a given ECEF position, sorted by distance. + * Used by proximity-based features (e.g. detection overlay, spatial queries). + * @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates + * @param {number} range - Maximum distance in meters (Infinity if not specified) + * @param {number} [maxCount=50] - Maximum number of results to return + * @param {object} [options] Query membership options. + * @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft. + * @returns {Array} Sorted array of nearby aircraft descriptors + */ + getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { + if ( + !center || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 50; + const maxRange = + Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; + + const now = Cesium.JulianDate.now(); + const nearby = []; + + for (const [icao24, bb] of flightState._billboards) { + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so proximity counts + // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. + if ( + !aircraftIncludedInNearby({ + isTracked, + billboardShown: bb.show, + modelRendering: parts.rendering._modelOwnsVisual(icao24), + includeHidden, + }) + ) + continue; + + const trackedPos = isTracked + ? parts.motion._trackedDisplayCached() + : null; // cached, no recompute (anti-jitter) + const pos = trackedPos || bb.position; + if (!pos) continue; + + const distance = Cesium.Cartesian3.distance(center, pos); + if (distance > maxRange) continue; + + const info = flightState._flightData.get(icao24); + nearby.push({ + id: info?.callsign?.trim() || info?.registration?.trim() || icao24, + icao24, + position: pos, + distance, + // Filter surface (mirror of flights.js): the cockpit next/previous path + // matches on this field, so it follows the conversion. + aircraftClass: tr3bAircraftClass( + icao24, + String(info?.klass || info?.type || '') + .trim() + .toLowerCase() || null, + ), + track: info?.track ?? null, + // Display type — converted too, so a Contacts row can't still name the + // airframe the triangle replaced (the filter matcher reads this as a + // fallback candidate as well). + type: tr3bTypeLabel(icao24, info?.type || null), + registration: info?.registration || null, + operator: info?.operator || null, + altitudeFt: info?.altitudeFt ?? null, + }); + } + + nearby.sort((a, b) => a.distance - b.distance); + return nearby.slice(0, limit); + }, + + /** + * Return a subset of aircraft suitable for the detection overlay system. + * Uses a deterministic stride-based sampling to keep the count manageable + * while distributing selections evenly across the collection. + * @param {Object} [options={}] - Options + * @param {number} [options.maxCount] - Maximum objects to return (defaults to all) + * @param {number} [options.seed] - Seed offset for stride sampling (for frame variation) + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + */ + getDetectableObjects(options = {}) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return []; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : flightState._billboards.size; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + // Deterministic stride: evenly space selections across the billboard map + const stride = Math.max( + 1, + Math.ceil(flightState._billboards.size / maxCount), + ); + const start = seed % stride; + + const result = []; + let idx = 0; + for (const [icao24, bb] of flightState._billboards) { + const shouldTake = (idx - start) % stride === 0; + idx++; + if (!shouldTake) continue; + if ( + flightState._cockpitContactMode && + icao24.toLowerCase() === flightState._cockpitSubjectId + ) + continue; + const isTracked = icao24 === flightState._trackedIcao; + // Keep planes rendered as a 3D model (billboard hidden) so the detection box + // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. + const model = flightState._models.get(icao24); + const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); + if (!isTracked && !bb.show && !modelOwnsVisual) continue; + const info = flightState._flightData.get(icao24); + const callsign = info?.callsign?.trim(); + const registration = info?.registration?.trim(); + let object = flightState._detectionObjects.get(icao24); + if (!object) { + object = { + sourceId: icao24, + type: 'AIR', + tier: 'military', + _weldPos: new Cesium.Cartesian3(), + }; + flightState._detectionObjects.set(icao24, object); + } + // WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned + // contacts read the translation the fleet tick already wrote into their + // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket + // are co-located anyway. + const spec = modelOwnsVisual + ? parts.rendering._modelSpec(info?.klass) + : null; + const pos = isTracked + ? parts.motion._trackedVisualCached() || bb.position + : modelOwnsVisual + ? modelVisualAnchor( + model.modelMatrix, + spec.visualCenterNative, + Number.isFinite(model.computedScale) + ? model.computedScale + : spec.scale, + object._weldPos || (object._weldPos = new Cesium.Cartesian3()), + ) + : bb.position; + if (!pos) continue; + object.position = pos; + object.skipLabel = isTracked; + const id = callsign || registration || icao24; + if (object.id !== id) object.id = id; + // Card surface: detectionDraw composes its secondary line from + // `[src.klass, src.metric]`, so a converted contact's card must name the + // TR-3B, not the airframe underneath it. + const klass = tr3bTypeLabel(icao24, info?.type || 'MIL'); + if (object.klass !== klass) object.klass = klass; + const altitudeFt = info?.altitudeFt ?? 0; + if (object._altitudeFt !== altitudeFt) { + object._altitudeFt = altitudeFt; + // military metadata stores altitudeFt (feet); the helper takes metres + object.metric = formatFlightLevel(altitudeFt * 0.3048); + } + result.push(object); + if (result.length >= maxCount) break; + } + if ( + flightState._detectionObjects.size > + flightState._billboards.size + 512 + ) { + for (const icao24 of flightState._detectionObjects.keys()) { + if (!flightState._billboards.has(icao24)) + flightState._detectionObjects.delete(icao24); + } + } + return result; + }, + + /** + * Find a single aircraft by free-text query. + * Match priority: exact icao24 hex, exact callsign, callsign prefix, + * then callsign substring (all case-insensitive, trimmed). + * @param {string} query - ICAO hex or full/partial callsign. + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Best match with a cloned, dead-reckoned position, or null if none. + */ + findByQuery(query) { + if (!flightState._flightData || flightState._flightData.size === 0) + return null; + const q = String(query || '') + .trim() + .toLowerCase(); + if (!q) return null; + + // Registration is searched alongside callsign because it is what the + // operator SEES: a callsign-less contact reads as its tail number on the + // card, in the analyst's answer, and in the Contacts list. Matching only + // callsigns meant "follow 6606" — and the analyst → track_entity handoff + // the tool instructions prescribe — answered "nothing matched" for the + // very identity the app had just shown (owner field session 2026-08-21, + // 23:48: three failed track_entity retries before a fallback stuck). + // Ranking is shared with the flights layer (contactMatch.js) so the two + // cannot disagree, and it is strictly tiered so a registration can never + // out-rank a real callsign on feed order alone. + let best = null; + for (const [icao24, info] of flightState._flightData) { + const candidate = { + tier: rankContactMatch({ + query: q, + hex: icao24, + callsign: info?.callsign, + registration: info?.registration, + }), + id: icao24, + }; + if (!contactMatchWins(candidate, best)) continue; + best = candidate; + if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break; + } + return best ? _describeFlight(best.id) : null; + }, + + /** + * Return id/label/position for up to maxCount currently rendered aircraft. + * Cheap snapshot for voice-tool framing — billboard positions only, no + * dead reckoning and no cloning. + * @param {number} [maxCount=500] - Maximum number of entries to return. + * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} + */ + /** + * Whether this layer still carries a contact, in O(1). + * + * Mirror of `flights.hasContact`: presence consumers must not infer absence + * from the capped `getAllPositions` rows. Id matching follows trackById — + * exact key first, then lowercase. + * A disabled layer keeps its records but hides the collection, so it must + * decline rather than answer from data the user can no longer see. + * @param {string} icao24 Contact identifier. + * @returns {boolean|null} Presence, or null when the layer is disabled or + * holds no data and therefore cannot answer. + */ + hasContact(icao24) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._billboards.size === 0 + ) + return null; + if (!icao24) return false; + const id = String(icao24).trim(); + return ( + flightState._billboards.has(id) || + flightState._billboards.has(id.toLowerCase()) + ); + }, + + getAllPositions(maxCount = 500) { + if ( + !flightState._billboardCollection || + flightState._billboards.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 500; + + const result = []; + for (const [icao24, bb] of flightState._billboards) { + const pos = bb.position; + if (!pos) continue; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchCarto, + ); + if (!carto) continue; + const info = flightState._flightData.get(icao24); + result.push({ + id: icao24, // identity (trackById/Context resolve this) + // Last surface still reading as the raw hex for a callsign-less + // contact — same chain as getNearby/getDetectableObjects/getTrackedSubject. + label: + _toCleanText(info?.callsign) || + _toCleanText(info?.registration) || + icao24, + position: pos, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + }); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Snapshot the layer's in-memory records as plain JSON-safe objects for + * the analyst query engine. On-demand only (called at most once per + * spoken query) — zero per-frame cost, no listeners, no caching. Returns + * [] while the layer is disabled or empty. + * @param {number} [maxCount=2000] - Maximum records to return (truncation). + * @returns {Array} See mapAnalystRecord for the record shape. + */ + getAnalystRecords(maxCount = 2000) { + if ( + !flightState._billboardCollection || + !flightState._billboardCollection.show || + flightState._flightData.size === 0 + ) + return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const [icao24, info] of flightState._flightData) { + result.push(mapAnalystRecord(icao24, info)); + if (result.length >= limit) break; + } + return result; + }, + + /** + * Start camera-tracking an aircraft by ICAO hex identifier. + * @param {string} icao24 - ICAO hex identifier of the aircraft. + * @returns {boolean} True if the aircraft exists and tracking started. + */ + trackById(icao24, { origin = 'programmatic' } = {}) { + if (!icao24) return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if (!flightState._billboards.has(id)) return false; + if (parts.tracking._isExplicitTrackingOrigin(origin)) + parts.tracking._cancelPendingTrackingRestore(); + if (flightState._trackedIcao === id) + return parts.tracking._publishTrackedSelection(id, origin); + parts.tracking._trackFlight(id, { origin }); + return true; + }, + + /** Resolve a shared Follow target only against the latest accepted refresh. */ + async resolveTrackingRestoreTarget( + icao24, + { signal = null, origin = 'share-restore' } = {}, + ) { + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const id = String(icao24 ?? '') + .trim() + .toLowerCase(); + if (!id) return { status: 'missing', reason: 'invalid-target' }; + const outcome = flightState._lastTrackingRefreshOutcome; + if (outcome.status !== 'accepted') { + return { + status: 'source-unavailable', + reason: `${flightState._lastSource} snapshot unavailable`, + refreshEpoch: outcome.epoch, + source: outcome.source, + }; + } + if (!outcome.ids.has(id)) { + return { + status: 'missing', + reason: 'target-absent-from-snapshot', + refreshEpoch: outcome.epoch, + source: outcome.source, + }; + } + if (signal?.aborted) + return { + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + const followed = this.trackById(id, { origin }); + return followed + ? { + status: 'found', + refreshEpoch: outcome.epoch, + source: outcome.source, + } + : { + status: 'source-unavailable', + reason: 'target-not-renderable', + refreshEpoch: outcome.epoch, + }; + }, + + /** Reapply the canonical follow frame without recreating the selected flight. */ + refocusTrackedById(icao24, { origin = 'programmatic' } = {}) { + if ( + !icao24 || + flightState._cockpitContactMode || + !flightState._viewer || + !flightState._trackedEntity + ) + return false; + let id = String(icao24).trim(); + if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if ( + id !== flightState._trackedIcao || + !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) + return false; + flightState._trackedCameraFrameStop?.(); + flightState._viewer.camera.cancelFlight(); + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; + parts.tracking._publishTrackedSelection(id, origin); + return true; + }, + + /** + * Stop tracking the currently followed aircraft (no-op if none). + * @returns {boolean} Always true. + */ + stopTracking({ origin = 'programmatic' } = {}) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin }); + return true; + }, + + cancelPendingTrackingRestore() { + parts.tracking._cancelPendingTrackingRestore(); + }, + + /** + * Describe the currently tracked aircraft at its dead-reckoned position. + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * Tracked aircraft info, or null when nothing is tracked. + */ + getTrackedInfo() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described) return null; + const { position, ...rest } = described; + return rest; + }, + + /** + * Return the current aircraft as a Context subject without changing + * tracking or camera ownership. + * @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null} + * Detached subject descriptor, or null when no aircraft is tracked. + */ + getTrackedSubject() { + if (!flightState._trackedIcao) return null; + const described = _describeFlight(flightState._trackedIcao); + if (!described?.position) return null; + return { + layerId: 'military', + id: described.icao24, + // Same label chain as getNearby/getDetectableObjects: a callsign-less + // contact reads as its registration, never as the raw ICAO hex. + label: described.callsign || described.registration || described.icao24, + position: Cesium.Cartesian3.clone(described.position), + }; + }, + + /** + * Return current layer health/status for the HUD status chip. + * @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}} + */ + getStats() { + const retryInSec = flightState._retryAt + ? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000)) + : 0; + return { + count: flightState._count, + lastUpdate: flightState._lastUpdate, + stale: flightState._backoff, + error: flightState._lastError, + status: flightState._lastStatus, + retryInSec, + source: flightState._lastSource, + fallback: false, + }; + }, + }; + + return { + _toFiniteNumber, + _toCleanText, + _formatAltitude, + _likelyLanded, + _describeFlight, + _normalizeTrackedIcao, + mapAnalystRecord, + methods, + }; } diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 1f6d88198..1e5dbb573 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -1,756 +1,1118 @@ -import { CLASS_MODEL_REAL, CLASS_SCALE_3D, CLASS_SCALE_2D } from '../../data/aircraftClass.js'; -import { visualCenterForModel, trailAnchorForModel } from '../../data/modelVisualAnchor.js'; +import { + CLASS_MODEL_REAL, + CLASS_SCALE_3D, + CLASS_SCALE_2D, +} from '../../data/aircraftClass.js'; +import { + visualCenterForModel, + trailAnchorForModel, +} from '../../data/modelVisualAnchor.js'; import * as Cesium from 'cesium'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; -import { cameraPoseSignature, horizonOccluder, screenProjectedRotation } from '../../data/iconOrientation.js'; +import { + cameraPoseSignature, + horizonOccluder, + screenProjectedRotation, +} from '../../data/iconOrientation.js'; import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; -import { PLANE_MODEL_SCALE, PLANE_MODEL_URL, PLANE_NATIVE_RADIUS_M, PLANE_BELLY_OFFSET_NATIVE, MODEL_SCALE, JET_MODEL_URL, MODEL_NATIVE_RADIUS_M, MODEL_BELLY_OFFSET_NATIVE, MODEL_HEADING_OFFSET_DEG, BILLBOARD_SCALE, GROUND_SCALE, COCKPIT_CONTACT_SIZE_PX, MIL_ICON_COLOR, TRACKED_ICON_COLOR, MODEL_ALT_CEIL_M, MODEL_MAX_ALL, MODEL_MAX, COCKPIT_MODEL_MAX, MODEL_ALL_ADD_M, MODEL_PROX_ADD_M, MODEL_ALL_KEEP_M, MODEL_PROX_KEEP_M, IR_RELOAD_BATCH, MODEL_MIN_PX, MODEL_COLOR_BLEND_AMOUNT, TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MAX_PX, FLEET_DR_INTERVAL_MS, COURSE_SLEW_DT_MAX_SEC, ROTATION_REFRESH_MS, COURSE_MAX_DPS } from './policy.js'; +import { + PLANE_MODEL_SCALE, + PLANE_MODEL_URL, + PLANE_NATIVE_RADIUS_M, + PLANE_BELLY_OFFSET_NATIVE, + MODEL_SCALE, + JET_MODEL_URL, + MODEL_NATIVE_RADIUS_M, + MODEL_BELLY_OFFSET_NATIVE, + MODEL_HEADING_OFFSET_DEG, + BILLBOARD_SCALE, + GROUND_SCALE, + COCKPIT_CONTACT_SIZE_PX, + MIL_ICON_COLOR, + TRACKED_ICON_COLOR, + MODEL_ALT_CEIL_M, + MODEL_MAX_ALL, + MODEL_MAX, + COCKPIT_MODEL_MAX, + MODEL_ALL_ADD_M, + MODEL_PROX_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_PROX_KEEP_M, + IR_RELOAD_BATCH, + MODEL_MIN_PX, + MODEL_COLOR_BLEND_AMOUNT, + TRACKED_MODEL_MIN_PX, + TRACKED_MODEL_MAX_PX, + FLEET_DR_INTERVAL_MS, + COURSE_SLEW_DT_MAX_SEC, + ROTATION_REFRESH_MS, + COURSE_MAX_DPS, +} from './policy.js'; -export function createRendering({ flightState, services, parts, layer, resolveAsset }) { -const { tr3bIconKind, isTr3b } = services.aircraftPresentation; -const { trackedModelScaleForPixelCap } = services.camera; -const { focusNowMs, getFocusTarget, nearFarScalarValueAtDistance, advanceProjectedSpriteFocus } = services.focus; -const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = services.recession; +export function createRendering({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { tr3bIconKind, isTr3b } = services.aircraftPresentation; + const { trackedModelScaleForPixelCap } = services.camera; + const { + focusNowMs, + getFocusTarget, + nearFarScalarValueAtDistance, + advanceProjectedSpriteFocus, + } = services.focus; + const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = + services.recession; + /** Depth-test policy for aircraft billboards (mirror of flights.js — see the + * full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact + * renders depth-test-free at every distance — a uniform always-visible rule; + * the fleet tick's horizon occluder still removes far-side contacts. */ -/** Depth-test policy for aircraft billboards (mirror of flights.js — see the - * full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact - * renders depth-test-free at every distance — a uniform always-visible rule; - * the fleet tick's horizon occluder still removes far-side contacts. */ - -function _groundDepthDistance() { - return Number.POSITIVE_INFINITY; -} - - -function _modelSpec(klass) { - let spec = flightState._specCache.get(klass); - if (spec) return spec; - const real = CLASS_MODEL_REAL[klass]; - if (real) { - spec = { url: real.url, scale: 1, nativeRadiusM: real.radiusM, bellyM: real.bellyM, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(real.url), trailAnchorNative: trailAnchorForModel(real.url) }; - } else if (klass === 'airliner' || klass === 'quadjet' || klass === 'glider') { - const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { url: PLANE_MODEL_URL, scale, nativeRadiusM: PLANE_NATIVE_RADIUS_M, bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL) }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { url: JET_MODEL_URL, scale, nativeRadiusM: MODEL_NATIVE_RADIUS_M, bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, visualCenterNative: visualCenterForModel(JET_MODEL_URL), trailAnchorNative: trailAnchorForModel(JET_MODEL_URL) }; - } - flightState._specCache.set(klass, spec); - return spec; -} - - - -function _normalBillboardScaleByDistance() { - // Match commercial flights and preserve the established close-range 3× - // default. Smaller owner-visible scaling must be proposed separately. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); -} - - - -function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); -} - - - -function _militaryBillboardScale(icao24) { - const meta = flightState._flightData.get(icao24); - return BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); -} - - -/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ - -function _applyFleetBillboardPresentation(icao24, bb) { - if (!bb) return; - const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; - const isCockpitContact = flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; - const isCockpitNear = isCockpitContact && flightState._cockpitNearContacts.has(icao24); - if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = bb.color?.alpha ?? 1; - bb.image = cockpitContactDotImage(); - bb.width = COCKPIT_CONTACT_SIZE_PX; - bb.height = COCKPIT_CONTACT_SIZE_PX; - bb.scale = limbScale; - bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); - bb.rotation = 0; - return; + function _groundDepthDistance() { + return Number.POSITIVE_INFINITY; } - const meta = flightState._flightData.get(icao24); - bb.image = aircraftIcon(_iconKind(icao24, meta?.klass), bb._gevIconLarge ? TRACKED_ICON_PX : undefined); - bb.width = icao24 === flightState._trackedIcao ? 24 : 20; - bb.height = icao24 === flightState._trackedIcao ? 24 : 20; - bb.scale = _militaryBillboardScale(icao24) * limbScale; - bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); -} - - -/** Sprite kind for one contact's billboard. Identity for every aircraft except - * the ones the operator converted into a TR-3B (Easter egg), which draw the - * black-triangle glyph — its thermal-reactive variant while an IR style owns - * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a - * conversion survive the poll reconciler and the two-tier raster swap. */ - -const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); - - -/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ - -function _modelColor(icao24) { - if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; - return MIL_ICON_COLOR; -} - - -/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude - * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the - * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in - * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms - * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED - * contact does not route through here: it is one model, it is what the camera is aimed at, - * and it takes its own default-on, hysteretic zoom regime - * (`_trackedModelRegimeActive`). Mirror of flights.js. */ - -function _modelRegimeActive() { - if (!flightState._models3dEnabled) return false; - const h = flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; - return h < MODEL_ALT_CEIL_M; -} - - -/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the - * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the - * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ - -function _modelCap() { - const mapCap = flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; - // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. - return flightState._cockpitContactMode ? Math.min(COCKPIT_MODEL_MAX, mapCap) : mapCap; -} - - -/** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ - -function _modelAddDistM() { - return flightState._models3dMode === 'all' ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; -} - - -function _modelKeepDistM() { - return flightState._models3dMode === 'all' ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; -} - - -/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable - * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on - * the last-written transform, and the per-frame tracked write made it flicker). */ - -function _modelMatrix(pos, headingDeg, result = flightState._scratchModelMtx, offsetDeg = MODEL_HEADING_OFFSET_DEG) { - flightState._scratchModelHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg); - flightState._scratchModelHpr.pitch = 0; - flightState._scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, flightState._scratchModelHpr, Cesium.Ellipsoid.WGS84, undefined, result, - ); -} - - -/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of - * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its - * (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches - * picked-object IDs — every billboard AND model here carries its icao as `id` — plus - * the tracked entity object itself. Built lazily, only when a sample actually fires. */ - -function _groundSampleExclusions() { - const out = [...flightState._billboards.keys()]; - if (flightState._trackedEntity) out.push(flightState._trackedEntity); - return out; -} - - -/** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). - * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin - * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly - * offset so they sit on their - * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the - * depth-test-free billboard visible and the model hidden while this returns null; a - * contact that has already resolved once holds that measurement through a later - * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ - -function _modelDisplayPosition(icao24, pos, result) { - const meta = flightState._flightData.get(icao24); - if (!meta || !meta.onGround) return pos; - const h = flightState._groundSnap.heightFor(flightState._viewer, icao24, pos, _groundSampleExclusions); - if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto); - carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height, Cesium.Ellipsoid.WGS84, result); -} - - -/** Atomically hand one fleet contact from its billboard to a safely placed, - * ready 3D model. Missing/loading models and unresolved terrain always leave - * the billboard owning the visual, so no render frame can hide both. - * `beforeShow` runs only on the committing path — the per-tick model treatment - * belongs to a model that is about to draw, not to one still waiting. */ - -function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { - if (!model) { - bb.show = true; - return false; + function _modelSpec(klass) { + let spec = flightState._specCache.get(klass); + if (spec) return spec; + const real = CLASS_MODEL_REAL[klass]; + if (real) { + spec = { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + headingOffsetDeg: 180, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } else if ( + klass === 'airliner' || + klass === 'quadjet' || + klass === 'glider' + ) { + const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + spec = { + url: PLANE_MODEL_URL, + scale, + nativeRadiusM: PLANE_NATIVE_RADIUS_M, + bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, + headingOffsetDeg: 180, + visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), + trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), + }; + } else { + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + spec = { + url: JET_MODEL_URL, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + visualCenterNative: visualCenterForModel(JET_MODEL_URL), + trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), + }; + } + flightState._specCache.set(klass, spec); + return spec; } - const displayPos = _modelDisplayPosition(icao24, pos, flightState._scratchGroundPos); - if (!displayPos) { - model.show = false; // no ground evidence → nothing safe to place a depth-tested model at - bb.show = true; - return false; + + function _normalBillboardScaleByDistance() { + // Match commercial flights and preserve the established close-range 3× + // default. Smaller owner-visible scaling must be proposed separately. + return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); } - // The matrix is written BEFORE the readiness test on purpose: a model can flip - // `ready` during scene update after this tick, and a first rendered frame on a - // stale load-start matrix is the one-frame jump this ordering prevents. - _modelMatrix( - displayPos, - course, - model.modelMatrix, - _modelSpec(flightState._flightData.get(icao24)?.klass).headingOffsetDeg, - ); - if (!model.ready) { - model.show = false; // not loaded yet → keep the 2D icon, no half-model flash - bb.show = true; - return false; + + function _cockpitBillboardScaleByDistance() { + return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); } - beforeShow?.(); - if (!model.show) model.show = true; - if (bb.show) bb.show = false; // hand off ONLY once the model renders - return true; -} - -/** A model owns the visual only while it is ready, shown, and (for the tracked - * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ - -function _modelOwnsVisual(icao24) { - if (icao24 === flightState._trackedIcao) { - return parts.tracking._trackedModelRegimeActive() && _modelIsRendering(flightState._trackedModel); + function _militaryBillboardScale(icao24) { + const meta = flightState._flightData.get(icao24); + return ( + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1) + ); } - return _modelIsRendering(flightState._models.get(icao24)); -} + /** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ -/** `show` is sufficient evidence of a safe placement because only the handoff and - * the tracked driver ever set it true, and both do so after committing a matrix; - * everything else — admission, unresolved ground, an unready glTF, the limb cull, - * a regime exit — only ever clears it. Mirror of flights.js. */ - -function _modelIsRendering(model) { - return !!model && model.ready === true && model.show === true; -} - - -/** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ - -const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; -}; - -/** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the - * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ - -function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); - const current = flightState._models.get(icao24); - if ((current && current._gevSpecKey !== key) || (!current && flightState._modelPending.has(icao24))) { - const bb = flightState._billboards.get(icao24); - if (bb && icao24 !== flightState._trackedIcao) bb.show = true; - _releaseModel(icao24); - } - if (icao24 === flightState._trackedIcao && flightState._trackedModel && flightState._trackedModel._gevSpecKey !== key) { - _releaseTrackedModel(); - } -} - - -function _reloadModelsForIrBoost() { - flightState._irReloadQueue = [...flightState._models.keys()]; - for (const icao of flightState._modelPending) { - if (!flightState._models.has(icao)) flightState._modelGen.set(icao, (flightState._modelGen.get(icao) || 0) + 1); - } - _releaseTrackedModel(); -} - - -function _drainIrReloadQueue() { - if (!flightState._irReloadQueue) return; - const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); - for (const icao of batch) { - const model = flightState._models.get(icao); - if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state - const bb = flightState._billboards.get(icao); - if (bb && icao !== flightState._trackedIcao) bb.show = true; - _releaseModel(icao); - } - if (flightState._irReloadQueue.length === 0) flightState._irReloadQueue = null; -} - - -/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ - -async function _ensureModel(icao24) { - // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet - // tick skips it, so a model here would be orphaned + double-rendered. - if (icao24 === flightState._trackedIcao) return; - if (flightState._models.has(icao24) || flightState._modelPending.has(icao24)) return; - // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads - // (the cap is rechecked post-await too, before the add). - if ((flightState._models.size + flightState._modelPending.size) >= _modelCap()) return; - const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead - const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale - flightState._modelPending.add(icao24); - let model = null; - // Spec identity captured at load START (mirror of flights.js): a mid-load - // reclassification makes the post-await admission reject the stale asset. - // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); - const loadIrBoost = flightState._irBoost; - try { - const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), - asynchronous: false, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track - }); - } catch { - // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current - // (a destroy/re-init may have swapped the globals while this load was in flight). - if (epoch === flightState._modelEpoch) { flightState._modelPending.delete(icao24); _cleanupModelGen(icao24); } - return; - } - // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new - // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. - if (epoch !== flightState._modelEpoch) { try { model.destroy(); } catch { /* gone */ } return; } - flightState._modelPending.delete(icao24); - // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the - // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was - // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. - // Recheck the shared Display 3D toggle and altitude ceiling after the async - // load. Cockpit uses the same OFF / Proximity / All contract as map Display. - const stale = (flightState._modelGen.get(icao24) || 0) !== gen - || !_modelRegimeActive() || !flightState._modelCollection || flightState._modelCollection.isDestroyed() - || !flightState._flightData.has(icao24) || icao24 === flightState._trackedIcao - || flightState._models.has(icao24) || flightState._models.size >= _modelCap() - // Class reclassified mid-load → this GLB/scale is for the OLD class. - || _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey - // IR boost flipped mid-load → this model baked the wrong shader/tint. - || flightState._irBoost !== loadIrBoost; - if (stale) { - try { model.destroy(); } catch { /* already gone */ } - _cleanupModelGen(icao24); // bound the map - return; - } - // Keep the pick identity explicit on the resolved primitive. This also - // protects injected/custom loaders that do not copy the creation option. - model.id = icao24; - model._gevSpecKey = specKey; // class-change sync compares against this - model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this - // Admitted, not yet the visual. Cesium's default is show=true, which would let - // an unplaced primitive claim ownership from the billboard for the frames - // between admission and the next fleet tick (and draw at the identity matrix, - // i.e. the Earth's centre). The handoff turns it on once it has a matrix. - model.show = false; - flightState._modelCollection.add(model); - flightState._models.set(icao24, model); - flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard -} - - -/** Remove the 3D model for ONE aircraft (removal / track handoff). - * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ - -function _releaseModel(icao24) { - const m = flightState._models.get(icao24); - const pending = flightState._modelPending.has(icao24); - // Only bump the generation when there's something to invalidate (an in-flight load or a - // live model) — so removing never-modeled aircraft doesn't grow _modelGen. - if (m || pending) { - flightState._modelGen.set(icao24, (flightState._modelGen.get(icao24) || 0) + 1); - } - if (m) { - if (flightState._modelCollection && !flightState._modelCollection.isDestroyed()) { try { flightState._modelCollection.remove(m); } catch { /* gone */ } } - flightState._models.delete(icao24); - } - // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping - // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so - // the resolving load's gen-check still rejects it. - _cleanupModelGen(icao24); -} - - -/** Drop an icao's generation entry once nothing references it (no live model, no in-flight - * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ - -function _cleanupModelGen(icao24) { - if (!flightState._modelPending.has(icao24) && !flightState._models.has(icao24)) flightState._modelGen.delete(icao24); -} - - -/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ - -function _releaseModels() { - flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain - // Invalidate in-flight loads so a completion after this bulk release can't add a model. - for (const icao of flightState._modelPending) flightState._modelGen.set(icao, (flightState._modelGen.get(icao) || 0) + 1); - if (flightState._modelCollection && !flightState._modelCollection.isDestroyed()) { - for (const m of flightState._models.values()) { try { flightState._modelCollection.remove(m); } catch { /* gone */ } } - } - flightState._models.clear(); -} - - -/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ - -function _releaseTrackedModel() { - flightState._trackedModelGen++; - flightState._trackedModelLoading = false; - if (flightState._trackedModel) { - if (flightState._modelCollection && !flightState._modelCollection.isDestroyed()) { try { flightState._modelCollection.remove(flightState._trackedModel); } catch { /* gone */ } } - flightState._trackedModel = null; - } -} - - -/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The - * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ - -function _updateTrackedModel() { - const active = flightState._trackedIcao && parts.tracking._trackedModelRegimeActive() - && flightState._modelCollection && !flightState._modelCollection.isDestroyed(); - if (!active) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; } - // Ask the frame-cached source directly. If the entity callback already ran, - // this is a no-op; if model loading completed between phases, it establishes - // this frame's single sample before the model renders. Camera, detection, and - // readout consumers then reuse that exact cached position. - const pos = parts.motion._trackedDisplayPosition(flightState._trackedIcao) || flightState._billboards.get(flightState._trackedIcao)?.position; - if (!pos) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; } - if (!flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed()) { - flightState._trackedModelLoading = true; - const gen = flightState._trackedModelGen; - const trackedSpec = _modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass); - const trackedKey = _specKeyFor(flightState._flightData.get(flightState._trackedIcao)?.klass); - const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), - asynchronous: false, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: flightState._trackedIcao, - }).then((m) => { - if (gen !== flightState._trackedModelGen || !flightState._modelCollection || flightState._modelCollection.isDestroyed()) { try { m.destroy(); } catch { /* gone */ } return; } - // Class reclassified OR boost flipped mid-load (no release ran — - // _trackedModel was still null): drop the stale asset; driver reloads. - if (_specKeyFor(flightState._flightData.get(flightState._trackedIcao)?.klass) !== trackedKey || flightState._irBoost !== trackedIrBoost) { - try { m.destroy(); } catch { /* gone */ } - flightState._trackedModelLoading = false; - return; - } - // Assign after resolution as well as in the creation options so the - // standalone primitive always exposes the tracked aircraft pick id. - m.id = flightState._trackedIcao; - m._gevSpecKey = trackedKey; // class-change sync compares against this - m.show = false; // admitted, not yet the visual — the driver shows it once placed - // Seed the world transform before the primitive enters the scene. A model - // can become ready+shown between render phases; leaving Cesium's identity - // default here produces a one-frame jump to the Earth's center. - const currentPos = parts.motion._trackedDisplayCached() - || flightState._billboards.get(flightState._trackedIcao)?.position; - if (currentPos) { - const displayPos = _modelDisplayPosition(flightState._trackedIcao, currentPos, flightState._scratchGroundPos); - if (displayPos) { - _modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), m.modelMatrix, trackedSpec.headingOffsetDeg); - } - } - flightState._trackedModel = m; - flightState._trackedModelLoading = false; - // A good load retires this selection's failure budget — a contact that - // recovers after a transient blip is not one attempt from giving up. - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; - flightState._modelCollection.add(m); - flightState._planeModelLoaded = true; - }).catch((err) => { - if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure - flightState._trackedModelLoading = false; - parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); - }); - return; - } - if (flightState._trackedModel) { - // Keep the transform current while GPU resources are still loading. Cesium - // can flip ready during scene update after this callback; waiting for ready - // here would let that first rendered frame use a stale load-start matrix. - const displayPos = _modelDisplayPosition(flightState._trackedIcao, pos, flightState._scratchGroundPos); - if (!displayPos) { - flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + function _applyFleetBillboardPresentation(icao24, bb) { + if (!bb) return; + const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; + const isCockpitContact = + flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; + const isCockpitNear = + isCockpitContact && flightState._cockpitNearContacts.has(icao24); + if (isCockpitContact && !isCockpitNear) { + const freshnessAlpha = bb.color?.alpha ?? 1; + bb.image = cockpitContactDotImage(); + bb.width = COCKPIT_CONTACT_SIZE_PX; + bb.height = COCKPIT_CONTACT_SIZE_PX; + bb.scale = limbScale; + bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); + bb.rotation = 0; return; } - const spec = _modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass); - _modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), flightState._trackedModel.modelMatrix, spec.headingOffsetDeg); - if (!flightState._trackedModel.ready) return; - flightState._trackedModel.scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance(flightState._viewer.camera.positionWC, displayPos), - viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, - fovyRad: flightState._viewer.camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - flightState._trackedModel.show = true; - } -} - -/** - * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, - * horizon-culls billboards beyond the limb (no far-side depth with the globe - * hidden), and refreshes screen-projected icon rotations whenever the camera - * pose changed (plus a 1s drift catch-up while idle). Driven by - * scene.preRender — NOT camera.changed, whose granularity is globally - * degraded by other layers mutating camera.percentageChanged. - * @returns {void} - */ - -function _fleetTick() { - if (!flightState._viewer || !flightState._billboardCollection || !flightState._billboardCollection.show) return; - const scene = flightState._viewer.scene; - const camera = flightState._viewer.camera; - const nowMs = focusNowMs(Date.now()); - - // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz - // primitive rebuild here. The body rebuilds only when a real fix arrives.) - - if ((nowMs - flightState._lastFleetTickMs) < FLEET_DR_INTERVAL_MS) return; - const tickDtSec = flightState._lastFleetTickMs - ? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - flightState._lastFleetTickMs) / 1000) - : 0.08; - flightState._lastFleetTickMs = nowMs; - - _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload - if (flightState._cockpitContactMode) parts.tracking._refreshCockpitNearContacts(); - const poseSig = cameraPoseSignature(camera); - // Only nearby Cockpit silhouettes need projected course; far dots remain - // rotation-free through the per-contact gate below. - const doRotations = (poseSig !== flightState._lastCamPoseSig || (nowMs - flightState._lastRotPassMs) >= ROTATION_REFRESH_MS); - if (doRotations) { - flightState._lastCamPoseSig = poseSig; - flightState._lastRotPassMs = nowMs; - } - - const occluder = horizonOccluder(camera); - const focusTarget = getFocusTarget(); - - // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. - const useModels = _modelRegimeActive(); - // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that - // resolves after zoom-out could briefly add a model outside the 3D-model regime). - if (!useModels && (flightState._models.size || flightState._modelPending.size)) _releaseModels(); - - // 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap — - // mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen - // new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP - // is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding). - let modelEligible = null; - if (useModels) { - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume(camPos, camera.directionWC, camera.upWC); - const cand = []; - for (const [icao, bb] of flightState._billboards) { - if (icao === flightState._trackedIcao) continue; - // A converted TR-3B renders as a billboard and can never take a model, so - // it must not occupy a CAP SLOT either (mirror of flights.js) — excluded - // at selection time, not just at the handoff below. - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded - // placement is handled by the one-shot ground snap in _modelDisplayPosition). - const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); - if (d2 > keepDistSq) continue; // beyond keep radius → never eligible - Cesium.Cartesian3.clone(bb.position, flightState._scratchModelBS.center); - cand.push([icao, d2, cull.computeVisibility(flightState._scratchModelBS) !== Cesium.Intersect.OUTSIDE]); - } - cand.sort((a, b) => a[1] - b[1]); - if (cand.length > cap && (nowMs - flightState._lastModelCapWarnMs) > 5000) { - console.warn(`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen prioritized.`); - flightState._lastModelCapWarnMs = nowMs; - } - modelEligible = new Set(); - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (inF && flightState._models.has(icao)) modelEligible.add(icao); } // 1. KEEP on-screen - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 2. ADD on-screen - for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && flightState._models.has(icao)) modelEligible.add(icao); } // 3. KEEP off-screen (can't starve visible) - for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 4. ADD off-screen leftover - const toRelease = []; - for (const icao of flightState._models.keys()) { - if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); - } - - for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion - - const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); - // Gate the write — assigning Billboard.position dirties the whole - // collection's vertex buffer, so skip sub-meter moves. - if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { - bb.position = dr; - } - - // Round 6: occlusion-test a LIFTED point for contacts at/below the - // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb - // read "beyond the horizon" and would hide low contacts awaiting floors). - const beyondHorizon = !occluder.isPointVisible(flightState._flightData.get(icao24)?.cullPosition || bb.position); - // A billboard flipping INTO view (horizon reveal while the camera idles) - // gets its rotation refreshed THIS tick even without a pose change — - // otherwise it reappears wearing its stale (often creation-north) nose for - // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false - // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !bb.show; - if (bb.show === beyondHorizon) bb.show = !beyondHorizon; - if (beyondHorizon) { - // Also hide any 3D model — otherwise a model that crossed the limb would keep - // rendering through the hidden globe at its last matrix. - const m = flightState._models.get(icao24); - if (m && m.show) m.show = false; - continue; - } - - const info = flightState._flightData.get(icao24); - - // One sprite-owned write site composes freshness × focus × limb haze and - // base class/ground scale × limb taper. The locked NearFarScalar remains - // untouched and multiplicative; there is no cull or zero-alpha path. - const cameraDistanceM = Cesium.Cartesian3.distance(camera.positionWC, bb.position); - const distanceScale = nearFarScalarValueAtDistance(bb.scaleByDistance, cameraDistanceM); - const focus = advanceProjectedSpriteFocus( - bb, - bb.position, - scene, - camera, - nowMs, - focusTarget, - undefined, - (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, - (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + const meta = flightState._flightData.get(icao24); + bb.image = aircraftIcon( + _iconKind(icao24, meta?.klass), + bb._gevIconLarge ? TRACKED_ICON_PX : undefined, ); - const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); - const treatment = applyAircraftBillboardTreatment({ - billboard: bb, - baseScale: flightState._cockpitContactMode && !isCockpitNear ? 1 : _militaryBillboardScale(icao24), - baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, - baseColor: MIL_ICON_COLOR, - focusFactor: focus.factor, - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }); - flightState._billboardLimbScale.set(bb, treatment.factors.scale); - // Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on - // the billboard's ACTUAL on-screen size (post-treatment bb.scale, so - // focus/limb recession counts) with hysteresis (atlas has no mips). - if (!flightState._cockpitContactMode || isCockpitNear) { - const glyphDevPx = (bb.width || 20) * (bb.scale || 1) - * distanceScale * (globalThis.devicePixelRatio || 1); - const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; - if (wantLarge !== !!bb._gevIconLarge) { - bb._gevIconLarge = wantLarge; - bb.image = aircraftIcon(_iconKind(icao24, flightState._flightData.get(icao24)?.klass), wantLarge ? TRACKED_ICON_PX : undefined); + bb.width = icao24 === flightState._trackedIcao ? 24 : 20; + bb.height = icao24 === flightState._trackedIcao ? 24 : 20; + bb.scale = _militaryBillboardScale(icao24) * limbScale; + bb.scaleByDistance = _normalBillboardScaleByDistance(); + bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1); + } + + /** Sprite kind for one contact's billboard. Identity for every aircraft except + * the ones the operator converted into a TR-3B (Easter egg), which draw the + * black-triangle glyph — its thermal-reactive variant while an IR style owns + * the scene. Routing EVERY `aircraftIcon()` call through this is what makes a + * conversion survive the poll reconciler and the two-tier raster swap. */ + + const _iconKind = (icao24, klass) => + tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); + + /** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ + + function _modelColor(icao24) { + if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; + return MIL_ICON_COLOR; + } + + /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude + * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the + * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in + * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms + * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED + * contact does not route through here: it is one model, it is what the camera is aimed at, + * and it takes its own default-on, hysteretic zoom regime + * (`_trackedModelRegimeActive`). Mirror of flights.js. */ + + function _modelRegimeActive() { + if (!flightState._models3dEnabled) return false; + const h = + flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; + return h < MODEL_ALT_CEIL_M; + } + + /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the + * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the + * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ + + function _modelCap() { + const mapCap = + flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; + // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. + return flightState._cockpitContactMode + ? Math.min(COCKPIT_MODEL_MAX, mapCap) + : mapCap; + } + + /** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ + + function _modelAddDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_ADD_M + : MODEL_PROX_ADD_M; + } + + function _modelKeepDistM() { + return flightState._models3dMode === 'all' + ? MODEL_ALL_KEEP_M + : MODEL_PROX_KEEP_M; + } + + /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into + * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable + * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on + * the last-written transform, and the per-frame tracked write made it flicker). */ + + function _modelMatrix( + pos, + headingDeg, + result = flightState._scratchModelMtx, + offsetDeg = MODEL_HEADING_OFFSET_DEG, + ) { + flightState._scratchModelHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + offsetDeg, + ); + flightState._scratchModelHpr.pitch = 0; + flightState._scratchModelHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + pos, + flightState._scratchModelHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + } + + /** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of + * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its + * (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches + * picked-object IDs — every billboard AND model here carries its icao as `id` — plus + * the tracked entity object itself. Built lazily, only when a sample actually fires. */ + + function _groundSampleExclusions() { + const out = [...flightState._billboards.keys()]; + if (flightState._trackedEntity) out.push(flightState._trackedEntity); + return out; + } + + /** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). + * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin + * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly + * offset so they sit on their + * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the + * depth-test-free billboard visible and the model hidden while this returns null; a + * contact that has already resolved once holds that measurement through a later + * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ + + function _modelDisplayPosition(icao24, pos, result) { + const meta = flightState._flightData.get(icao24); + if (!meta || !meta.onGround) return pos; + const h = flightState._groundSnap.heightFor( + flightState._viewer, + icao24, + pos, + _groundSampleExclusions, + ); + if (h == null) return null; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + flightState._scratchGroundCarto, + ); + carto.height = h + _modelSpec(meta.klass).bellyM; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height, + Cesium.Ellipsoid.WGS84, + result, + ); + } + + /** Atomically hand one fleet contact from its billboard to a safely placed, + * ready 3D model. Missing/loading models and unresolved terrain always leave + * the billboard owning the visual, so no render frame can hide both. + * `beforeShow` runs only on the committing path — the per-tick model treatment + * belongs to a model that is about to draw, not to one still waiting. */ + + function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { + if (!model) { + bb.show = true; + return false; + } + const displayPos = _modelDisplayPosition( + icao24, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + bb.show = true; + return false; + } + // The matrix is written BEFORE the readiness test on purpose: a model can flip + // `ready` during scene update after this tick, and a first rendered frame on a + // stale load-start matrix is the one-frame jump this ordering prevents. + _modelMatrix( + displayPos, + course, + model.modelMatrix, + _modelSpec(flightState._flightData.get(icao24)?.klass).headingOffsetDeg, + ); + if (!model.ready) { + model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + bb.show = true; + return false; + } + beforeShow?.(); + if (!model.show) model.show = true; + if (bb.show) bb.show = false; // hand off ONLY once the model renders + return true; + } + + /** A model owns the visual only while it is ready, shown, and (for the tracked + * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ + + function _modelOwnsVisual(icao24) { + if (icao24 === flightState._trackedIcao) { + return ( + parts.tracking._trackedModelRegimeActive() && + _modelIsRendering(flightState._trackedModel) + ); + } + return _modelIsRendering(flightState._models.get(icao24)); + } + + /** `show` is sufficient evidence of a safe placement because only the handoff and + * the tracked driver ever set it true, and both do so after committing a matrix; + * everything else — admission, unresolved ground, an unready glTF, the limb cull, + * a regime exit — only ever clears it. Mirror of flights.js. */ + + function _modelIsRendering(model) { + return !!model && model.ready === true && model.show === true; + } + + /** Spec identity for a LOADED model — mirror of flights.js: URL and scale + * together (same-URL classes differ by scale). */ + + const _specKeyFor = (klass) => { + const spec = _modelSpec(klass); + return `${spec.url}@${spec.scale}`; + }; + + /** Class-change model sync — mirror of flights.js: drop a live model or + * in-flight load whose spec no longer matches the class, re-showing the + * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ + + function _syncModelToClass(icao24) { + const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const current = flightState._models.get(icao24); + if ( + (current && current._gevSpecKey !== key) || + (!current && flightState._modelPending.has(icao24)) + ) { + const bb = flightState._billboards.get(icao24); + if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao24); + } + if ( + icao24 === flightState._trackedIcao && + flightState._trackedModel && + flightState._trackedModel._gevSpecKey !== key + ) { + _releaseTrackedModel(); + } + } + + function _reloadModelsForIrBoost() { + flightState._irReloadQueue = [...flightState._models.keys()]; + for (const icao of flightState._modelPending) { + if (!flightState._models.has(icao)) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + } + _releaseTrackedModel(); + } + + function _drainIrReloadQueue() { + if (!flightState._irReloadQueue) return; + const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); + for (const icao of batch) { + const model = flightState._models.get(icao); + if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state + const bb = flightState._billboards.get(icao); + if (bb && icao !== flightState._trackedIcao) bb.show = true; + _releaseModel(icao); + } + if (flightState._irReloadQueue.length === 0) + flightState._irReloadQueue = null; + } + + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + + async function _ensureModel(icao24) { + // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet + // tick skips it, so a model here would be orphaned + double-rendered. + if (icao24 === flightState._trackedIcao) return; + if ( + flightState._models.has(icao24) || + flightState._modelPending.has(icao24) + ) + return; + // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads + // (the cap is rechecked post-await too, before the add). + if ( + flightState._models.size + flightState._modelPending.size >= + _modelCap() + ) + return; + const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead + const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale + flightState._modelPending.add(icao24); + let model = null; + // Spec identity captured at load START (mirror of flights.js): a mid-load + // reclassification makes the post-await admission reject the stale asset. + // Boost state likewise — creation options bake it in. + const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const loadIrBoost = flightState._irBoost; + try { + const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); + model = await Cesium.Model.fromGltfAsync({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + id: icao24, // so scene.pick returns the icao for click-to-track + }); + } catch { + // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current + // (a destroy/re-init may have swapped the globals while this load was in flight). + if (epoch === flightState._modelEpoch) { + flightState._modelPending.delete(icao24); + _cleanupModelGen(icao24); + } + return; + } + // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new + // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. + if (epoch !== flightState._modelEpoch) { + try { + model.destroy(); + } catch { + /* gone */ + } + return; + } + flightState._modelPending.delete(icao24); + // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the + // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was + // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. + // Recheck the shared Display 3D toggle and altitude ceiling after the async + // load. Cockpit uses the same OFF / Proximity / All contract as map Display. + const stale = + (flightState._modelGen.get(icao24) || 0) !== gen || + !_modelRegimeActive() || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() || + !flightState._flightData.has(icao24) || + icao24 === flightState._trackedIcao || + flightState._models.has(icao24) || + flightState._models.size >= _modelCap() || + // Class reclassified mid-load → this GLB/scale is for the OLD class. + _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey || + // IR boost flipped mid-load → this model baked the wrong shader/tint. + flightState._irBoost !== loadIrBoost; + if (stale) { + try { + model.destroy(); + } catch { + /* already gone */ + } + _cleanupModelGen(icao24); // bound the map + return; + } + // Keep the pick identity explicit on the resolved primitive. This also + // protects injected/custom loaders that do not copy the creation option. + model.id = icao24; + model._gevSpecKey = specKey; // class-change sync compares against this + model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this + // Admitted, not yet the visual. Cesium's default is show=true, which would let + // an unplaced primitive claim ownership from the billboard for the frames + // between admission and the next fleet tick (and draw at the identity matrix, + // i.e. the Earth's centre). The handoff turns it on once it has a matrix. + model.show = false; + flightState._modelCollection.add(model); + flightState._models.set(icao24, model); + flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard + } + + /** Remove the 3D model for ONE aircraft (removal / track handoff). + * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ + + function _releaseModel(icao24) { + const m = flightState._models.get(icao24); + const pending = flightState._modelPending.has(icao24); + // Only bump the generation when there's something to invalidate (an in-flight load or a + // live model) — so removing never-modeled aircraft doesn't grow _modelGen. + if (m || pending) { + flightState._modelGen.set( + icao24, + (flightState._modelGen.get(icao24) || 0) + 1, + ); + } + if (m) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } + } + flightState._models.delete(icao24); + } + // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping + // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so + // the resolving load's gen-check still rejects it. + _cleanupModelGen(icao24); + } + + /** Drop an icao's generation entry once nothing references it (no live model, no in-flight + * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ + + function _cleanupModelGen(icao24) { + if ( + !flightState._modelPending.has(icao24) && + !flightState._models.has(icao24) + ) + flightState._modelGen.delete(icao24); + } + + /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ + + function _releaseModels() { + flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain + // Invalidate in-flight loads so a completion after this bulk release can't add a model. + for (const icao of flightState._modelPending) + flightState._modelGen.set( + icao, + (flightState._modelGen.get(icao) || 0) + 1, + ); + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + for (const m of flightState._models.values()) { + try { + flightState._modelCollection.remove(m); + } catch { + /* gone */ + } } } + flightState._models.clear(); + } - // Smoothed display course: the path direction _deadReckon just reported - // for THIS aircraft (nothing else calls _deadReckon in between), rate- - // limited so segment-boundary course steps glide instead of snapping. - // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering - // aircraft (hold flag) keeps its previous nose direction outright. - const rawCourse = flightState._drCourseDeg != null ? flightState._drCourseDeg : ((info && info.track) || 0); - const prevCourse = flightState._displayCourse.get(icao24); - const course = (flightState._drCourseHold && prevCourse != null) - ? prevCourse - : limitCourseStep( - prevCourse, rawCourse, - courseSlewCapDps(flightState._drSpeedMps != null ? flightState._drSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS), - tickDtSec, - ); - flightState._displayCourse.set(icao24, course); + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ - // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the - // billboard stays shown until the model is actually READY to render, so a plane is never both - // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every - // tick regardless so it's framed the instant it becomes ready. - // Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter - // egg IS the triangle and there is no GLB for it, so the model handoff is - // suppressed. The billboard keeps rendering, so the contact still satisfies - // the getNearby/getDetectableObjects visibility guards. - if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { - _ensureModel(icao24); - const model = flightState._models.get(icao24); - const ownsVisual = _driveFleetModelHandoff( - icao24, - model, - bb, - dr, - course, - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write; boosted - // models also skip the recession fade (mirror of flights.js — - // billboards keep their normal fade, hot MODELS stay full-strength). - baseColor: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - alpha: flightState._irBoost ? 1 : treatment.alpha, - }); - }, + function _releaseTrackedModel() { + flightState._trackedModelGen++; + flightState._trackedModelLoading = false; + if (flightState._trackedModel) { + if ( + flightState._modelCollection && + !flightState._modelCollection.isDestroyed() + ) { + try { + flightState._modelCollection.remove(flightState._trackedModel); + } catch { + /* gone */ + } + } + flightState._trackedModel = null; + } + } + + /** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The + * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ + + function _updateTrackedModel() { + const active = + flightState._trackedIcao && + parts.tracking._trackedModelRegimeActive() && + flightState._modelCollection && + !flightState._modelCollection.isDestroyed(); + if (!active) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + // Ask the frame-cached source directly. If the entity callback already ran, + // this is a no-op; if model loading completed between phases, it establishes + // this frame's single sample before the model renders. Camera, detection, and + // readout consumers then reuse that exact cached position. + const pos = + parts.motion._trackedDisplayPosition(flightState._trackedIcao) || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) { + if (flightState._trackedModel) flightState._trackedModel.show = false; + return; + } + if ( + !flightState._trackedModel && + !flightState._trackedModelLoading && + parts.tracking._trackedModelLoadAllowed() + ) { + flightState._trackedModelLoading = true; + const gen = flightState._trackedModelGen; + const trackedSpec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, ); - if (ownsVisual) continue; // skip billboard rotation + const trackedKey = _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + const trackedIrBoost = flightState._irBoost; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(trackedSpec.url), + asynchronous: false, + minimumPixelSize: TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, + colorBlendMode: Cesium.ColorBlendMode.MIX, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + customShader: flightState._irBoost + ? flightState._IR_UNLIT_SHADER + : undefined, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (scene.pick → primitive with no id) → an unintended + // deselect. With the icao, the click handler recognizes it as ours. + id: flightState._trackedIcao, + }) + .then((m) => { + if ( + gen !== flightState._trackedModelGen || + !flightState._modelCollection || + flightState._modelCollection.isDestroyed() + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + return; + } + // Class reclassified OR boost flipped mid-load (no release ran — + // _trackedModel was still null): drop the stale asset; driver reloads. + if ( + _specKeyFor( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ) !== trackedKey || + flightState._irBoost !== trackedIrBoost + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + flightState._trackedModelLoading = false; + return; + } + // Assign after resolution as well as in the creation options so the + // standalone primitive always exposes the tracked aircraft pick id. + m.id = flightState._trackedIcao; + m._gevSpecKey = trackedKey; // class-change sync compares against this + m.show = false; // admitted, not yet the visual — the driver shows it once placed + // Seed the world transform before the primitive enters the scene. A model + // can become ready+shown between render phases; leaving Cesium's identity + // default here produces a one-frame jump to the Earth's center. + const currentPos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (currentPos) { + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + currentPos, + flightState._scratchGroundPos, + ); + if (displayPos) { + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + m.modelMatrix, + trackedSpec.headingOffsetDeg, + ); + } + } + flightState._trackedModel = m; + flightState._trackedModelLoading = false; + // A good load retires this selection's failure budget — a contact that + // recovers after a transient blip is not one attempt from giving up. + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + flightState._modelCollection.add(m); + flightState._planeModelLoaded = true; + }) + .catch((err) => { + if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure + flightState._trackedModelLoading = false; + parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); + }); + return; + } + if (flightState._trackedModel) { + // Keep the transform current while GPU resources are still loading. Cesium + // can flip ready during scene update after this callback; waiting for ready + // here would let that first rendered frame use a stale load-start matrix. + const displayPos = _modelDisplayPosition( + flightState._trackedIcao, + pos, + flightState._scratchGroundPos, + ); + if (!displayPos) { + flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + return; + } + const spec = _modelSpec( + flightState._flightData.get(flightState._trackedIcao)?.klass, + ); + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + flightState._trackedModel.modelMatrix, + spec.headingOffsetDeg, + ); + if (!flightState._trackedModel.ready) return; + flightState._trackedModel.scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM: Cesium.Cartesian3.distance( + flightState._viewer.camera.positionWC, + displayPos, + ), + viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, + fovyRad: flightState._viewer.camera.frustum.fovy, + maximumPixelSize: TRACKED_MODEL_MAX_PX, + }); + flightState._trackedModel.show = true; + } + } + + /** + * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, + * horizon-culls billboards beyond the limb (no far-side depth with the globe + * hidden), and refreshes screen-projected icon rotations whenever the camera + * pose changed (plus a 1s drift catch-up while idle). Driven by + * scene.preRender — NOT camera.changed, whose granularity is globally + * degraded by other layers mutating camera.percentageChanged. + * @returns {void} + */ + + function _fleetTick() { + if ( + !flightState._viewer || + !flightState._billboardCollection || + !flightState._billboardCollection.show + ) + return; + const scene = flightState._viewer.scene; + const camera = flightState._viewer.camera; + const nowMs = focusNowMs(Date.now()); + + // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz + // primitive rebuild here. The body rebuilds only when a real fix arrives.) + + if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; + const tickDtSec = flightState._lastFleetTickMs + ? Math.min( + COURSE_SLEW_DT_MAX_SEC, + (nowMs - flightState._lastFleetTickMs) / 1000, + ) + : 0.08; + flightState._lastFleetTickMs = nowMs; + + _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload + if (flightState._cockpitContactMode) + parts.tracking._refreshCockpitNearContacts(); + const poseSig = cameraPoseSignature(camera); + // Only nearby Cockpit silhouettes need projected course; far dots remain + // rotation-free through the per-contact gate below. + const doRotations = + poseSig !== flightState._lastCamPoseSig || + nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS; + if (doRotations) { + flightState._lastCamPoseSig = poseSig; + flightState._lastRotPassMs = nowMs; } - if ((!flightState._cockpitContactMode || isCockpitNear) && (doRotations || revealed)) { - const rot = screenProjectedRotation(scene, bb.position, course, bb.rotation); - if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { - bb.rotation = rot; + const occluder = horizonOccluder(camera); + const focusTarget = getFocusTarget(); + + // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. + // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. + const useModels = _modelRegimeActive(); + // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that + // resolves after zoom-out could briefly add a model outside the 3D-model regime). + if ( + !useModels && + (flightState._models.size || flightState._modelPending.size) + ) + _releaseModels(); + + // 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap — + // mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen + // new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP + // is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding). + let modelEligible = null; + if (useModels) { + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + const cand = []; + for (const [icao, bb] of flightState._billboards) { + if (icao === flightState._trackedIcao) continue; + // A converted TR-3B renders as a billboard and can never take a model, so + // it must not occupy a CAP SLOT either (mirror of flights.js) — excluded + // at selection time, not just at the handoff below. + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded + // placement is handled by the one-shot ground snap in _modelDisplayPosition). + const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + if (d2 > keepDistSq) continue; // beyond keep radius → never eligible + Cesium.Cartesian3.clone( + bb.position, + flightState._scratchModelBS.center, + ); + cand.push([ + icao, + d2, + cull.computeVisibility(flightState._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); + } + cand.sort((a, b) => a[1] - b[1]); + if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { + console.warn( + `[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen prioritized.`, + ); + flightState._lastModelCapWarnMs = nowMs; + } + modelEligible = new Set(); + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && flightState._models.has(icao)) modelEligible.add(icao); + } // 1. KEEP on-screen + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } // 2. ADD on-screen + for (const [icao, , inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && flightState._models.has(icao)) modelEligible.add(icao); + } // 3. KEEP off-screen (can't starve visible) + for (const [icao, d2, inF] of cand) { + if (modelEligible.size >= cap) break; + if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) + modelEligible.add(icao); + } // 4. ADD off-screen leftover + const toRelease = []; + for (const icao of flightState._models.keys()) { + if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); + } + + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + + const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); + // Gate the write — assigning Billboard.position dirties the whole + // collection's vertex buffer, so skip sub-meter moves. + if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { + bb.position = dr; + } + + // Round 6: occlusion-test a LIFTED point for contacts at/below the + // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb + // read "beyond the horizon" and would hide low contacts awaiting floors). + const beyondHorizon = !occluder.isPointVisible( + flightState._flightData.get(icao24)?.cullPosition || bb.position, + ); + // A billboard flipping INTO view (horizon reveal while the camera idles) + // gets its rotation refreshed THIS tick even without a pose change — + // otherwise it reappears wearing its stale (often creation-north) nose for + // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false + // here; harmless — the model branch below `continue`s past the rotation.) + const revealed = !beyondHorizon && !bb.show; + if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + if (beyondHorizon) { + // Also hide any 3D model — otherwise a model that crossed the limb would keep + // rendering through the hidden globe at its last matrix. + const m = flightState._models.get(icao24); + if (m && m.show) m.show = false; + continue; + } + + const info = flightState._flightData.get(icao24); + + // One sprite-owned write site composes freshness × focus × limb haze and + // base class/ground scale × limb taper. The locked NearFarScalar remains + // untouched and multiplicative; there is no cull or zero-alpha path. + const cameraDistanceM = Cesium.Cartesian3.distance( + camera.positionWC, + bb.position, + ); + const distanceScale = nearFarScalarValueAtDistance( + bb.scaleByDistance, + cameraDistanceM, + ); + const focus = advanceProjectedSpriteFocus( + bb, + bb.position, + scene, + camera, + nowMs, + focusTarget, + undefined, + (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, + (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + ); + const isCockpitNear = + flightState._cockpitContactMode && + flightState._cockpitNearContacts.has(icao24); + const treatment = applyAircraftBillboardTreatment({ + billboard: bb, + baseScale: + flightState._cockpitContactMode && !isCockpitNear + ? 1 + : _militaryBillboardScale(icao24), + baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseColor: MIL_ICON_COLOR, + focusFactor: focus.factor, + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }); + flightState._billboardLimbScale.set(bb, treatment.factors.scale); + // Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on + // the billboard's ACTUAL on-screen size (post-treatment bb.scale, so + // focus/limb recession counts) with hysteresis (atlas has no mips). + if (!flightState._cockpitContactMode || isCockpitNear) { + const glyphDevPx = + (bb.width || 20) * + (bb.scale || 1) * + distanceScale * + (globalThis.devicePixelRatio || 1); + const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76; + if (wantLarge !== !!bb._gevIconLarge) { + bb._gevIconLarge = wantLarge; + bb.image = aircraftIcon( + _iconKind(icao24, flightState._flightData.get(icao24)?.klass), + wantLarge ? TRACKED_ICON_PX : undefined, + ); + } + } + + // Smoothed display course: the path direction _deadReckon just reported + // for THIS aircraft (nothing else calls _deadReckon in between), rate- + // limited so segment-boundary course steps glide instead of snapping. + // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering + // aircraft (hold flag) keeps its previous nose direction outright. + const rawCourse = + flightState._drCourseDeg != null + ? flightState._drCourseDeg + : (info && info.track) || 0; + const prevCourse = flightState._displayCourse.get(icao24); + const course = + flightState._drCourseHold && prevCourse != null + ? prevCourse + : limitCourseStep( + prevCourse, + rawCourse, + courseSlewCapDps( + flightState._drSpeedMps != null + ? flightState._drSpeedMps + : ((info && info.speedMps) ?? NaN), + COURSE_MAX_DPS, + ), + tickDtSec, + ); + flightState._displayCourse.set(icao24, course); + + // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the + // billboard stays shown until the model is actually READY to render, so a plane is never both + // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every + // tick regardless so it's framed the instant it becomes ready. + // Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter + // egg IS the triangle and there is no GLB for it, so the model handoff is + // suppressed. The billboard keeps rendering, so the contact still satisfies + // the getNearby/getDetectableObjects visibility guards. + if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { + _ensureModel(icao24); + const model = flightState._models.get(icao24); + const ownsVisual = _driveFleetModelHandoff( + icao24, + model, + bb, + dr, + course, + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write; boosted + // models also skip the recession fade (mirror of flights.js — + // billboards keep their normal fade, hot MODELS stay full-strength). + baseColor: flightState._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: flightState._irBoost ? 1 : treatment.alpha, + }); + }, + ); + if (ownsVisual) continue; // skip billboard rotation + } + + if ( + (!flightState._cockpitContactMode || isCockpitNear) && + (doRotations || revealed) + ) { + const rot = screenProjectedRotation( + scene, + bb.position, + course, + bb.rotation, + ); + if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { + bb.rotation = rot; + } } } } -} -return { _groundDepthDistance, _modelSpec, _normalBillboardScaleByDistance, _cockpitBillboardScaleByDistance, _militaryBillboardScale, _applyFleetBillboardPresentation, _iconKind, _modelColor, _modelRegimeActive, _modelCap, _modelAddDistM, _modelKeepDistM, _modelMatrix, _groundSampleExclusions, _modelDisplayPosition, _driveFleetModelHandoff, _modelOwnsVisual, _modelIsRendering, _specKeyFor, _syncModelToClass, _reloadModelsForIrBoost, _drainIrReloadQueue, _ensureModel, _releaseModel, _cleanupModelGen, _releaseModels, _releaseTrackedModel, _updateTrackedModel, _fleetTick }; + return { + _groundDepthDistance, + _modelSpec, + _normalBillboardScaleByDistance, + _cockpitBillboardScaleByDistance, + _militaryBillboardScale, + _applyFleetBillboardPresentation, + _iconKind, + _modelColor, + _modelRegimeActive, + _modelCap, + _modelAddDistM, + _modelKeepDistM, + _modelMatrix, + _groundSampleExclusions, + _modelDisplayPosition, + _driveFleetModelHandoff, + _modelOwnsVisual, + _modelIsRendering, + _specKeyFor, + _syncModelToClass, + _reloadModelsForIrBoost, + _drainIrReloadQueue, + _ensureModel, + _releaseModel, + _cleanupModelGen, + _releaseModels, + _releaseTrackedModel, + _updateTrackedModel, + _fleetTick, + }; } diff --git a/src/layers/military/state.js b/src/layers/military/state.js index f24e18735..8d5cc27cd 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,443 +1,400 @@ import * as Cesium from 'cesium'; export function createFlightState({ source, services }) { -const { createGroundSnap } = services.groundSnap; -const flightState = { lifetime: new AbortController() }; + const { createGroundSnap } = services.groundSnap; + const flightState = { lifetime: new AbortController() }; + flightState._source = source; + /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: + * military contacts should read as their WEIGHT CLASS, always in this layer's + * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → + * 180° offset); airliner/quadjet/glider get the shared 747 silhouette + * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); + * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays + * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, + * and the tint must dominate any livery (owner: "stays yellow"). Specs are + * static per class — memoized (the fleet pass asks at 12 Hz per model). */ -flightState._source = source; + flightState._specCache = new Map(); -/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: - * military contacts should read as their WEIGHT CLASS, always in this layer's - * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → - * 180° offset); airliner/quadjet/glider get the shared 747 silhouette - * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); - * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays - * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, - * and the tint must dominate any livery (owner: "stays yellow"). Specs are - * static per class — memoized (the fleet pass asks at 12 Hz per model). */ + /** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ -flightState._specCache = new Map(); + flightState._groundSnap = createGroundSnap(); -/** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */ + flightState._scratchGroundCarto = new Cesium.Cartographic(); -flightState._groundSnap = createGroundSnap(); + flightState._scratchGroundPos = new Cesium.Cartesian3(); + /** @type {Cesium.PrimitiveCollection|null} */ -flightState._scratchGroundCarto = new Cesium.Cartographic(); + flightState._modelCollection = null; + /** @type {Map} icao24 → model */ -flightState._scratchGroundPos = new Cesium.Cartesian3(); + flightState._models = new Map(); -/** @type {Cesium.PrimitiveCollection|null} */ + /** @type {Set} icao24 currently loading (async) */ -flightState._modelCollection = null; + flightState._modelPending = new Set(); -/** @type {Map} icao24 → model */ + /** @type {Map} icao24 → load generation; bumped on release to invalidate + * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ -flightState._models = new Map(); + flightState._modelGen = new Map(); -/** @type {Set} icao24 currently loading (async) */ + /** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle + * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ -flightState._modelPending = new Set(); + flightState._modelEpoch = 0; -/** @type {Map} icao24 → load generation; bumped on release to invalidate - * an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */ + /** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs + * layer-state restoration, so this initializer — not the codec — is what the app + * actually starts with; it must stay in lockstep with the `models3d` default in + * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would + * light a button the layer has not armed. */ -flightState._modelGen = new Map(); + flightState._models3dEnabled = true; -/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle - * against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */ + flightState._models3dMode = 'proximity'; + // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) -flightState._modelEpoch = 0; + flightState._lastModelCapWarnMs = 0; + // throttle the "more planes in view than the cap" console notice + // The tracked entity's billboard goes transparent (rather than hidden) once the model takes + // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its + // alpha after the GLB is preloaded so the model is ready to render the instant the billboard + // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not + // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. -/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs - * layer-state restoration, so this initializer — not the codec — is what the app - * actually starts with; it must stay in lockstep with the `models3d` default in - * `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would - * light a button the layer has not armed. */ + flightState._planeModelLoaded = false; -flightState._models3dEnabled = true; + /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + flightState._preloadModel = null; -flightState._models3dMode = 'proximity'; - // 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL) + flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); -flightState._lastModelCapWarnMs = 0; - // throttle the "more planes in view than the cap" console notice -// The tracked entity's billboard goes transparent (rather than hidden) once the model takes -// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its -// alpha after the GLB is preloaded so the model is ready to render the instant the billboard -// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not -// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + flightState._scratchModelMtx = new Cesium.Matrix4(); -flightState._planeModelLoaded = false; + flightState._scratchModelBS = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1.0, + ); + // frustum-visibility test + /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ -/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + flightState._billboardLimbScale = new WeakMap(); -flightState._preloadModel = null; + // --------------------------------------------------------------------------- + // Module-level state + // --------------------------------------------------------------------------- + /** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ -flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + flightState._billboardCollection = null; + /** @type {Map} ICAO hex -> billboard primitive */ -flightState._scratchModelMtx = new Cesium.Matrix4(); + flightState._billboards = new Map(); + /** Stable lightweight records reused by the detection overlay between polls. */ -flightState._scratchModelBS = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); - // frustum-visibility test -/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ + flightState._detectionObjects = new Map(); -flightState._billboardLimbScale = new WeakMap(); + /** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ + flightState._flightData = new Map(); -// --------------------------------------------------------------------------- -// Module-level state -// --------------------------------------------------------------------------- + /** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ -/** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ + flightState._positionHistory = new Map(); -flightState._billboardCollection = null; + /** @type {number} Current number of visible aircraft */ -/** @type {Map} ICAO hex -> billboard primitive */ + flightState._count = 0; -flightState._billboards = new Map(); + /** @type {number|null} Epoch ms of last successful API update */ -/** Stable lightweight records reused by the detection overlay between polls. */ + flightState._lastUpdate = null; -flightState._detectionObjects = new Map(); + /** @type {boolean} True when in error-backoff mode */ -/** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ + flightState._backoff = false; -flightState._flightData = new Map(); + /** @type {number} Epoch ms after which the next retry is allowed */ -/** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ + flightState._retryAt = 0; -flightState._positionHistory = new Map(); + /** @type {string|null} Human-readable description of the last error */ -/** @type {number} Current number of visible aircraft */ + flightState._lastError = null; -flightState._count = 0; + flightState._activeUpdateControllers = new Set(); -/** @type {number|null} Epoch ms of last successful API update */ + /** @type {number|null} HTTP status code from the last API response */ -flightState._lastUpdate = null; + flightState._lastStatus = null; -/** @type {boolean} True when in error-backoff mode */ + flightState._lastSource = source?.label || 'Aircraft'; -flightState._backoff = false; + /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ -/** @type {number} Epoch ms after which the next retry is allowed */ + flightState._geoidReady = false; -flightState._retryAt = 0; + /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ -/** @type {string|null} Human-readable description of the last error */ + flightState._geoidNCache = new Map(); -flightState._lastError = null; + // -- Click-to-track state -- + /** @type {string|null} ICAO hex of the currently tracked aircraft */ + flightState._trackedIcao = null; -flightState._activeUpdateControllers = new Set(); + flightState._pendingTrackingRestore = null; -/** @type {number|null} HTTP status code from the last API response */ + flightState._trackingIntentGeneration = 0; -flightState._lastStatus = null; + flightState._trackingRefreshEpoch = 0; + flightState._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: flightState._lastSource, + }; -flightState._lastSource = source?.label || 'Aircraft'; + /** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ -/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ + flightState._trackedEntity = null; -flightState._geoidReady = false; + /** Disposes the single active tracked-camera framing owner. */ -/** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ + flightState._trackedCameraFrameStop = null; -flightState._geoidNCache = new Map(); + /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on + * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere + * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ + flightState._trackedModel = null; -// -- Click-to-track state -- -/** @type {string|null} ICAO hex of the currently tracked aircraft */ + flightState._trackedModelGen = 0; -flightState._trackedIcao = null; + flightState._trackedModelLoading = false; + /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ -flightState._pendingTrackingRestore = null; + flightState._clickHandler = null; + /** @type {Cesium.Viewer|null} Cached viewer reference */ -flightState._trackingIntentGeneration = 0; + flightState._viewer = null; + /** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ -flightState._trackingRefreshEpoch = 0; + flightState._cockpitContactMode = false; + /** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ -flightState._lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - ids: new Set(), - source: flightState._lastSource, -}; + flightState._cockpitNearContacts = new Set(); -/** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ + /** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ -flightState._trackedEntity = null; + flightState._cockpitSubjectId = null; -/** Disposes the single active tracked-camera framing owner. */ + /** @type {((event: CustomEvent) => void)|null} */ -flightState._trackedCameraFrameStop = null; + flightState._cockpitModeListener = null; -/** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on - * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere - * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ + /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ -flightState._trackedModel = null; + flightState._trail = null; + /** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ -flightState._trackedModelGen = 0; + flightState._trailHeadEntity = null; + /** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ -flightState._trackedModelLoading = false; + flightState._trailHeadSeq = 0; -/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ + /** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ -flightState._clickHandler = null; + flightState._trailPositions = []; -/** @type {Cesium.Viewer|null} Cached viewer reference */ + /** @type {number} Monotonic token — invalidates in-flight backfill responses */ -flightState._viewer = null; + flightState._trailBackfillToken = 0; -/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ + /** @type {Map} icao24 -> consecutive missed polls */ -flightState._cockpitContactMode = false; + flightState._missingPolls = new Map(); -/** AIR contacts inside the selected Display range; independent from model admission/load/cap. */ + /** @type {number} Epoch ms of the last fleet dead-reckoning pass */ -flightState._cockpitNearContacts = new Set(); + flightState._lastFleetTickMs = 0; -/** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */ + /** @type {string} Camera pose signature at the last rotation pass */ -flightState._cockpitSubjectId = null; + flightState._lastCamPoseSig = ''; -/** @type {((event: CustomEvent) => void)|null} */ + /** @type {number} Epoch ms of the last full rotation pass */ -flightState._cockpitModeListener = null; + flightState._lastRotPassMs = 0; -/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */ + /** @type {number} Last computed rotation for the tracked entity (radians) */ -flightState._trail = null; + flightState._lastTrackedRotation = 0; -/** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ + /** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ -flightState._trailHeadEntity = null; + flightState._preRenderRemove = null; -/** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */ + flightState._trackedModelPreUpdateRemove = null; -flightState._trailHeadSeq = 0; + /** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ -/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ + flightState._moveEndRemove = null; -flightState._trailPositions = []; + /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -/** @type {number} Monotonic token — invalidates in-flight backfill responses */ + flightState._trackedEntityChangedRemove = null; -flightState._trailBackfillToken = 0; + // -- Scratch variables (reused each frame to avoid GC pressure) -- -/** @type {Map} icao24 -> consecutive missed polls */ + flightState._scratchOffset = new Cesium.Cartesian3(); -flightState._missingPolls = new Map(); + flightState._scratchCarto = new Cesium.Cartographic(); -/** @type {number} Epoch ms of the last fleet dead-reckoning pass */ + flightState._scratchEnu = new Cesium.Matrix4(); -flightState._lastFleetTickMs = 0; + flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; -/** @type {string} Camera pose signature at the last rotation pass */ + flightState._scratchRenderTime = new Cesium.JulianDate(); -flightState._lastCamPoseSig = ''; + flightState._scratchFleetPos = new Cesium.Cartesian3(); -/** @type {number} Epoch ms of the last full rotation pass */ + flightState._scratchDrRaw = new Cesium.Cartesian3(); -flightState._lastRotPassMs = 0; + flightState._scratchWarmupTime = new Cesium.JulianDate(); -/** @type {number} Last computed rotation for the tracked entity (radians) */ + flightState._trackedPosHolder = new Cesium.Cartesian3(); -flightState._lastTrackedRotation = 0; + // -- Per-frame dead-reckoning cache for the tracked entity -- + /** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ -/** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ + flightState._cachedDRPosition = null; -flightState._preRenderRemove = null; + /** @type {number} Frame number the cache was last populated for */ + flightState._cachedDRFrame = -1; -flightState._trackedModelPreUpdateRemove = null; + /** Course (deg) of the position `_deadReckon` most recently returned — set on + * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same + * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ -/** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ + flightState._drCourseDeg = null; -flightState._moveEndRemove = null; + /** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion + * `_deadReckon` just returned, and whether that motion is too slow for ANY + * course source to be trusted (hover/GPS drift — consumers HOLD their + * previous display course instead of chasing noise). */ -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + flightState._drSpeedMps = null; -flightState._trackedEntityChangedRemove = null; + flightState._drCourseHold = false; + /** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ -// -- Scratch variables (reused each frame to avoid GC pressure) -- + flightState._cachedDRCourse = null; -flightState._scratchOffset = new Cesium.Cartesian3(); + /** Frame-cached siblings of _cachedDRCourse (same discipline). */ + flightState._cachedDRSpeedMps = null; -flightState._scratchCarto = new Cesium.Cartographic(); + flightState._cachedDRHold = false; + /** Wall-clock of the tracked course limiter's last advance (dt source only — + * the course VALUE lives in the shared per-icao _displayCourse map below). */ -flightState._scratchEnu = new Cesium.Matrix4(); + flightState._trackedCourseMs = 0; + /** Per-aircraft smoothed display course — the SINGLE source of truth for the + * nose direction an aircraft displays (mirror of flights.js, 2026-07-03 + * field fix). The fleet pass reads/writes it at tick cadence for untracked + * planes; _trackedDisplayCourse reads/writes the SAME entry per frame for + * the tracked plane (the fleet pass skips the tracked icao, so exactly one + * writer owns an entry at a time). Sharing the entry — including its slew + * state — keeps the tracked↔fleet handoff seamless (separate states froze + * the fleet entry while tracked → nose flip on click / click-away). */ -flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; + flightState._displayCourse = new Map(); + flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0); -flightState._scratchRenderTime = new Cesium.JulianDate(); + flightState._drCorrectionStartMs = 0; + flightState._drPrevRaw = new Cesium.Cartesian3(); -flightState._scratchFleetPos = new Cesium.Cartesian3(); + flightState._drPrevDisplay = new Cesium.Cartesian3(); + flightState._drPrevMs = 0; -flightState._scratchDrRaw = new Cesium.Cartesian3(); + flightState._drReconcileValid = false; + /** @type {string|null} icao the reconciliation state currently belongs to */ -flightState._scratchWarmupTime = new Cesium.JulianDate(); + flightState._drReconcileIcao = null; + /** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ -flightState._trackedPosHolder = new Cesium.Cartesian3(); + flightState._trackedVisualPos = new Cesium.Cartesian3(); + flightState._trackedTrailPos = new Cesium.Cartesian3(); -// -- Per-frame dead-reckoning cache for the tracked entity -- -/** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ + /** Scratch for the tracked model's world-space origin (the envelope centre). */ -flightState._cachedDRPosition = null; + flightState._scratchTrailClip = new Cesium.Cartesian3(); -/** @type {number} Frame number the cache was last populated for */ + /** Scratch for the shortened trail-head start. Owned by the head-segment + * callback alone, so nothing else can overwrite it mid-frame. */ -flightState._cachedDRFrame = -1; + flightState._scratchTrailHead = new Cesium.Cartesian3(); + /** Hysteresis latch for the tracked contact's zoom regime, plus the selection it + * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER + * ceiling instead of inheriting the previous target's looser EXIT band. */ -/** Course (deg) of the position `_deadReckon` most recently returned — set on - * every branch of `_deadReckon`, read IMMEDIATELY by the caller (same - * synchronous flow; module-scratch idiom, like the Cartesian scratches). */ + flightState._trackedZoomLatched = false; -flightState._drCourseDeg = null; + flightState._trackedZoomLatchIcao = null; -/** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion - * `_deadReckon` just returned, and whether that motion is too slow for ANY - * course source to be trusted (hover/GPS drift — consumers HOLD their - * previous display course instead of chasing noise). */ + flightState._trackedModelFailIcao = null; -flightState._drSpeedMps = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; -flightState._drCourseHold = false; + /** IR hot-target mode — mirror of flights.js: under the luminance-mapped + * NVG/FLIR post-styles every model renders flat white (hottest); the dominant + * amber/tracked tint restores on style exit. */ -/** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */ + flightState._irBoost = false; -flightState._cachedDRCourse = null; + /** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR + * field rounds): material-stage tint still sun-shades, so side-on planes + * read near-black under a high sun; UNLIT emits flat white regardless. + * customShader on a READY model is a silent no-op (field-verified), so the + * boost flips by release-and-reload — creation-time options carry it. */ -/** Frame-cached siblings of _cachedDRCourse (same discipline). */ + flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }); -flightState._cachedDRSpeedMps = null; + flightState._irReloadQueue = null; + /** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ -flightState._cachedDRHold = false; - -/** Wall-clock of the tracked course limiter's last advance (dt source only — - * the course VALUE lives in the shared per-icao _displayCourse map below). */ - -flightState._trackedCourseMs = 0; - -/** Per-aircraft smoothed display course — the SINGLE source of truth for the - * nose direction an aircraft displays (mirror of flights.js, 2026-07-03 - * field fix). The fleet pass reads/writes it at tick cadence for untracked - * planes; _trackedDisplayCourse reads/writes the SAME entry per frame for - * the tracked plane (the fleet pass skips the tracked icao, so exactly one - * writer owns an entry at a time). Sharing the entry — including its slew - * state — keeps the tracked↔fleet handoff seamless (separate states froze - * the fleet entry while tracked → nose flip on click / click-away). */ - -flightState._displayCourse = new Map(); - - -flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0); - - -flightState._drCorrectionStartMs = 0; - - -flightState._drPrevRaw = new Cesium.Cartesian3(); - - -flightState._drPrevDisplay = new Cesium.Cartesian3(); - - -flightState._drPrevMs = 0; - - -flightState._drReconcileValid = false; - -/** @type {string|null} icao the reconciliation state currently belongs to */ - -flightState._drReconcileIcao = null; - - -/** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ - -flightState._trackedVisualPos = new Cesium.Cartesian3(); - - -flightState._trackedTrailPos = new Cesium.Cartesian3(); - -/** Scratch for the tracked model's world-space origin (the envelope centre). */ - -flightState._scratchTrailClip = new Cesium.Cartesian3(); - -/** Scratch for the shortened trail-head start. Owned by the head-segment - * callback alone, so nothing else can overwrite it mid-frame. */ - -flightState._scratchTrailHead = new Cesium.Cartesian3(); - - -/** Hysteresis latch for the tracked contact's zoom regime, plus the selection it - * belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER - * ceiling instead of inheriting the previous target's looser EXIT band. */ - -flightState._trackedZoomLatched = false; - - -flightState._trackedZoomLatchIcao = null; - - -flightState._trackedModelFailIcao = null; - - -flightState._trackedModelFailCount = 0; - - -flightState._trackedModelRetryAtMs = 0; - - -/** IR hot-target mode — mirror of flights.js: under the luminance-mapped - * NVG/FLIR post-styles every model renders flat white (hottest); the dominant - * amber/tracked tint restores on style exit. */ - -flightState._irBoost = false; - -/** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR - * field rounds): material-stage tint still sun-shades, so side-on planes - * read near-black under a high sun; UNLIT emits flat white regardless. - * customShader on a READY model is a silent no-op (field-verified), so the - * boost flips by release-and-reload — creation-time options carry it. */ - -flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT }); - - -flightState._irReloadQueue = null; - - -/** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ - -flightState._scratchTrailCarto = new Cesium.Cartographic(); -return flightState; + flightState._scratchTrailCarto = new Cesium.Cartographic(); + return flightState; } diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index eb38d1b6f..670676f4f 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -1,152 +1,165 @@ import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; -export function createTesting({ flightState, services, parts, layer, resolveAsset }) { - - -/** - * Seed only the mutable state needed to exercise tracked-card refreshes through - * the production military poll reconciler. Tests still call - * `militaryFlightsLayer.update()` rather than invoking the writer directly. - * @param {object} state - * @param {string} state.icao24 - * @param {object} state.entity - * @param {object} state.meta - * @param {object} state.billboard - * @param {object} state.billboardCollection - * @param {object} state.viewer - * @param {Array} [state.history=[]] - * @param {boolean} [state.tracked=true] - * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed - * by icao24, for the billboard-hidden/model-shown handoff state. - */ - -function _setTrackedMilitaryRefreshStateForTest({ - icao24, - entity, - meta, - billboard, - billboardCollection, - viewer, - history = [], - tracked = true, - models = [], - modelCollection = null, +export function createTesting({ + flightState, + services, + parts, + layer, + resolveAsset, }) { - flightState._viewer = viewer; - flightState._modelCollection = modelCollection; - flightState._billboardCollection = billboardCollection; - flightState._billboards = new Map([[icao24, billboard]]); - flightState._models.clear(); - for (const [key, model] of models) flightState._models.set(key, model); - flightState._detectionObjects = new Map(); - flightState._flightData = new Map([[icao24, meta]]); - flightState._positionHistory = new Map([[icao24, history]]); - flightState._missingPolls = new Map(); - flightState._displayCourse.clear(); - flightState._geoidNCache.clear(); - flightState._trackedIcao = tracked ? icao24 : null; - flightState._trackedEntity = tracked ? entity : null; - flightState._trackedModel = null; - parts.tracking._cancelPendingTrackingRestore(); - flightState._trackedModelLoading = false; - // NOTE: deliberately does NOT reset the per-selection latches. They are - // production state owned by the tracking lifecycle (_resetTrackedSelectionState), - // and clearing them here would mask exactly the deselect→re-track hole this - // seam is used to test. - flightState._backoff = false; - flightState._retryAt = 0; -} + /** + * Seed only the mutable state needed to exercise tracked-card refreshes through + * the production military poll reconciler. Tests still call + * `militaryFlightsLayer.update()` rather than invoking the writer directly. + * @param {object} state + * @param {string} state.icao24 + * @param {object} state.entity + * @param {object} state.meta + * @param {object} state.billboard + * @param {object} state.billboardCollection + * @param {object} state.viewer + * @param {Array} [state.history=[]] + * @param {boolean} [state.tracked=true] + * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed + * by icao24, for the billboard-hidden/model-shown handoff state. + */ + function _setTrackedMilitaryRefreshStateForTest({ + icao24, + entity, + meta, + billboard, + billboardCollection, + viewer, + history = [], + tracked = true, + models = [], + modelCollection = null, + }) { + flightState._viewer = viewer; + flightState._modelCollection = modelCollection; + flightState._billboardCollection = billboardCollection; + flightState._billboards = new Map([[icao24, billboard]]); + flightState._models.clear(); + for (const [key, model] of models) flightState._models.set(key, model); + flightState._detectionObjects = new Map(); + flightState._flightData = new Map([[icao24, meta]]); + flightState._positionHistory = new Map([[icao24, history]]); + flightState._missingPolls = new Map(); + flightState._displayCourse.clear(); + flightState._geoidNCache.clear(); + flightState._trackedIcao = tracked ? icao24 : null; + flightState._trackedEntity = tracked ? entity : null; + flightState._trackedModel = null; + parts.tracking._cancelPendingTrackingRestore(); + flightState._trackedModelLoading = false; + // NOTE: deliberately does NOT reset the per-selection latches. They are + // production state owned by the tracking lifecycle (_resetTrackedSelectionState), + // and clearing them here would mask exactly the deselect→re-track hole this + // seam is used to test. + flightState._backoff = false; + flightState._retryAt = 0; + } -/** Seed the authoritative snapshot outcome used by share-Follow tests. */ + /** Seed the authoritative snapshot outcome used by share-Follow tests. */ -function _setMilitaryTrackingRefreshOutcomeForTest({ - status = 'accepted', - ids = [], - source = flightState._lastSource, -} = {}) { - const epoch = ++flightState._trackingRefreshEpoch; - flightState._lastTrackingRefreshOutcome = { - epoch, - status, - ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), - source, + function _setMilitaryTrackingRefreshOutcomeForTest({ + status = 'accepted', + ids = [], + source = flightState._lastSource, + } = {}) { + const epoch = ++flightState._trackingRefreshEpoch; + flightState._lastTrackingRefreshOutcome = { + epoch, + status, + ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), + source, + }; + } + + /** Add a cached contact so tests can model a target arriving on a later feed. */ + + function _addMilitaryTrackingCandidateForTest({ + icao24, + meta, + billboard, + history = [], + }) { + flightState._billboards.set(icao24, billboard); + flightState._flightData.set(icao24, meta); + flightState._positionHistory.set(icao24, history); + } + + /** Return the deferred restore target held by the production tracker. */ + + function _pendingMilitaryTrackingRestoreForTest() { + return flightState._pendingTrackingRestore?.id ?? null; + } + + /** Exercise the production deferred-restore retry after a simulated feed refresh. */ + + function _applyPendingMilitaryTrackingRestoreForTest() { + return parts.tracking._applyPendingTrackingRestore(); + } + + /** Set the exact Cockpit subject through the production state transition for focused tests. */ + + function _setCockpitDetectionSubjectForTest(active, subjectId = null) { + parts.tracking._applyCockpitState({ active, subjectId }); + } + + /** Evaluate the TRACKED contact's zoom regime through the production predicate. + * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) + * and otherwise only observable through a live scene, so tests drive it here. */ + + function _trackedModelRegimeActiveForTest() { + return parts.tracking._trackedModelRegimeActive(); + } + + /** Run one frame of the production tracked-model driver (normally a + * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ + + function _updateTrackedModelForTest() { + return parts.rendering._updateTrackedModel(); + } + + /** Evaluate the production tracked-billboard handoff colour for focused tests. */ + + function _trackedBillboardColorForTest() { + return parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR; + } + + /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ + + function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { + return parts.rendering._driveFleetModelHandoff( + icao24, + flightState._models.get(icao24), + flightState._billboards.get(icao24), + position, + course, + ); + } + + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ + + async function _ensureFleetModelForTest(icao24) { + await parts.rendering._ensureModel(icao24); + return flightState._models.get(icao24) || null; + } + return { + _setTrackedMilitaryRefreshStateForTest, + _setMilitaryTrackingRefreshOutcomeForTest, + _addMilitaryTrackingCandidateForTest, + _pendingMilitaryTrackingRestoreForTest, + _applyPendingMilitaryTrackingRestoreForTest, + _setCockpitDetectionSubjectForTest, + _trackedModelRegimeActiveForTest, + _updateTrackedModelForTest, + _trackedBillboardColorForTest, + _driveFleetModelHandoffForTest, + _ensureFleetModelForTest, }; } - - -/** Add a cached contact so tests can model a target arriving on a later feed. */ - -function _addMilitaryTrackingCandidateForTest({ icao24, meta, billboard, history = [] }) { - flightState._billboards.set(icao24, billboard); - flightState._flightData.set(icao24, meta); - flightState._positionHistory.set(icao24, history); -} - - -/** Return the deferred restore target held by the production tracker. */ - -function _pendingMilitaryTrackingRestoreForTest() { - return flightState._pendingTrackingRestore?.id ?? null; -} - - -/** Exercise the production deferred-restore retry after a simulated feed refresh. */ - -function _applyPendingMilitaryTrackingRestoreForTest() { - return parts.tracking._applyPendingTrackingRestore(); -} - - -/** Set the exact Cockpit subject through the production state transition for focused tests. */ - -function _setCockpitDetectionSubjectForTest(active, subjectId = null) { - parts.tracking._applyCockpitState({ active, subjectId }); -} - - -/** Evaluate the TRACKED contact's zoom regime through the production predicate. - * The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed) - * and otherwise only observable through a live scene, so tests drive it here. */ - -function _trackedModelRegimeActiveForTest() { - return parts.tracking._trackedModelRegimeActive(); -} - - -/** Run one frame of the production tracked-model driver (normally a - * `scene.preUpdate` listener) so tests can pin its bounded load retries. */ - -function _updateTrackedModelForTest() { - return parts.rendering._updateTrackedModel(); -} - - -/** Evaluate the production tracked-billboard handoff colour for focused tests. */ - -function _trackedBillboardColorForTest() { - return parts.rendering._modelOwnsVisual(flightState._trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR; -} - - -/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ - -function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { - return parts.rendering._driveFleetModelHandoff( - icao24, - flightState._models.get(icao24), - flightState._billboards.get(icao24), - position, - course, - ); -} - - -/** Exercise the exact asynchronous fleet loader and return its admitted model. */ - -async function _ensureFleetModelForTest(icao24) { - await parts.rendering._ensureModel(icao24); - return flightState._models.get(icao24) || null; -} -return { _setTrackedMilitaryRefreshStateForTest, _setMilitaryTrackingRefreshOutcomeForTest, _addMilitaryTrackingCandidateForTest, _pendingMilitaryTrackingRestoreForTest, _applyPendingMilitaryTrackingRestoreForTest, _setCockpitDetectionSubjectForTest, _trackedModelRegimeActiveForTest, _updateTrackedModelForTest, _trackedBillboardColorForTest, _driveFleetModelHandoffForTest, _ensureFleetModelForTest }; -} diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 3c6bd2239..f4c6e2fc6 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -2,958 +2,1171 @@ import * as Cesium from 'cesium'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; -import { screenProjectedRotation, stabilizeScreenRotation } from '../../data/iconOrientation.js'; +import { + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; import { trailHeadStart } from '../../data/modelVisualAnchor.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; -import { bindTrackingClickGesture, isTrackingSelectionGesture, isTrackingClickGesture } from '../../data/trackingClickGesture.js'; -import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, TRAIL_MAX_POINTS, RENDER_DELAY_SEC, TRAIL_COLOR, BILLBOARD_SCALE, GROUND_SCALE, MIL_ICON_COLOR, AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; +import { + bindTrackingClickGesture, + isTrackingSelectionGesture, + isTrackingClickGesture, +} from '../../data/trackingClickGesture.js'; +import { + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + TRAIL_MAX_POINTS, + RENDER_DELAY_SEC, + TRAIL_COLOR, + BILLBOARD_SCALE, + GROUND_SCALE, + MIL_ICON_COLOR, + AMBER_TRANSPARENT, + TRACKED_ICON_COLOR, +} from './policy.js'; -export function createTracking({ flightState, services, parts, layer, resolveAsset }) { -const { selectTrackedSubjectContext, refreshTrackedSubjectContext, clearTrackedSubjectContext } = services.context; -const { tr3bTypeLabel, isTr3b, tr3bConvertedIds } = services.aircraftPresentation; -const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; -const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = services.groundFloor; -const { createTrail } = services.trails; -const { ensureGeoidReady, geoidHeight } = services.geoid; -const { clearFocusTarget } = services.focus; -const { applyTrackedCameraFrame } = services.camera; -const { resolvePickId, isOwnedByOtherLayer } = services.picking; +export function createTracking({ + flightState, + services, + parts, + layer, + resolveAsset, +}) { + const { + selectTrackedSubjectContext, + refreshTrackedSubjectContext, + clearTrackedSubjectContext, + } = services.context; + const { tr3bTypeLabel, isTr3b, tr3bConvertedIds } = + services.aircraftPresentation; + const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; + const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = + services.groundFloor; + const { createTrail } = services.trails; + const { ensureGeoidReady, geoidHeight } = services.geoid; + const { clearFocusTarget } = services.focus; + const { applyTrackedCameraFrame } = services.camera; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; - - -function _emitAwarenessEvent(type, detail) { - if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return; - window.dispatchEvent(new CustomEvent(type, { detail })); -} - - - -function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = flightState._billboards.get(icao24); - const info = flightState._flightData.get(icao24); - if (!bb?.position || !info) return false; - if (flightState._trackedEntity) flightState._trackedEntity.gevSelectionOrigin = origin; - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'military', - id: icao24, - label: parts.queries._toCleanText(info.callsign) || parts.queries._toCleanText(info.registration) || icao24, - position: Cesium.Cartesian3.clone(bb.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); - return true; -} - - -/** - * Describe the selected contact for the shared context slot the voice tools - * and Cockpit read. Values are the LIVE descriptor, not a selection-time - * snapshot, so a long follow never narrates a position the plane has left. - * @param {string} icao24 Contact identity. - * @returns {object|null} Context metadata, or null when the contact is gone. - */ - -function _contextSubjectMetadata(icao24) { - const described = parts.queries._describeFlight(icao24); - if (!described) return null; - const info = flightState._flightData.get(icao24); - const label = described.callsign || described.registration || icao24; - return { - id: icao24, - layerId: 'military', - layerName: 'Military Flights', - source: flightState._lastSource, - label, - latitude: described.latitude, - longitude: described.longitude, - // Flat text only: the voice payload compacts properties through a - // string cleaner that drops nested objects. - properties: { - name: label, - operator: parts.queries._toCleanText(info?.operator) || '', - callsign: described.callsign || '', - registration: described.registration || '', - // Converted contacts report their class as TR-3B — the same override - // the readout and Contacts card show. - type: tr3bTypeLabel(icao24, parts.queries._toCleanText(info?.type) || ''), - altitude: described.onGround ? 'on ground' : parts.queries._formatAltitude(info?.altitudeFt), - speed: Number.isFinite(described.velocityMps) - ? `${Math.round(described.velocityMps * 1.944)} kt` - : '', - heading: Number.isFinite(described.track) ? `${Math.round(described.track)}°` : '', - icao24, - // Honesty cue: the contact is coasting on dead reckoning, so the - // narrated position/velocity are last-known rather than live. - status: described.stale ? 'stale (missed polls)' : 'live', - }, - }; -} - - - -function _isExplicitTrackingOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; -} - - -/** Refresh the AIR-only Cockpit near band independently from model admission. */ - -function _refreshCockpitNearContacts() { - if (!flightState._cockpitContactMode || !flightState._viewer?.camera?.positionWC) { - if (flightState._cockpitNearContacts.size) flightState._cockpitNearContacts = new Set(); - return; + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); } - const previous = flightState._cockpitNearContacts; - const distancesSquared = []; - for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao || !bb?.position) continue; - distancesSquared.push([ - icao24, - Cesium.Cartesian3.distanceSquared(flightState._viewer.camera.positionWC, bb.position), - ]); + + function _publishTrackedSelection(icao24, origin = 'programmatic') { + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb?.position || !info) return false; + if (flightState._trackedEntity) + flightState._trackedEntity.gevSelectionOrigin = origin; + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'military', + id: icao24, + label: + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24, + position: Cesium.Cartesian3.clone(bb.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); + return true; } - const next = nextCockpitNearContacts( - previous, - distancesSquared, - parts.rendering._modelAddDistM(), - parts.rendering._modelKeepDistM(), - ); - flightState._cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of flightState._billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; + + /** + * Describe the selected contact for the shared context slot the voice tools + * and Cockpit read. Values are the LIVE descriptor, not a selection-time + * snapshot, so a long follow never narrates a position the plane has left. + * @param {string} icao24 Contact identity. + * @returns {object|null} Context metadata, or null when the contact is gone. + */ + + function _contextSubjectMetadata(icao24) { + const described = parts.queries._describeFlight(icao24); + if (!described) return null; + const info = flightState._flightData.get(icao24); + const label = described.callsign || described.registration || icao24; + return { + id: icao24, + layerId: 'military', + layerName: 'Military Flights', + source: flightState._lastSource, + label, + latitude: described.latitude, + longitude: described.longitude, + // Flat text only: the voice payload compacts properties through a + // string cleaner that drops nested objects. + properties: { + name: label, + operator: parts.queries._toCleanText(info?.operator) || '', + callsign: described.callsign || '', + registration: described.registration || '', + // Converted contacts report their class as TR-3B — the same override + // the readout and Contacts card show. + type: tr3bTypeLabel( + icao24, + parts.queries._toCleanText(info?.type) || '', + ), + altitude: described.onGround + ? 'on ground' + : parts.queries._formatAltitude(info?.altitudeFt), + speed: Number.isFinite(described.velocityMps) + ? `${Math.round(described.velocityMps * 1.944)} kt` + : '', + heading: Number.isFinite(described.track) + ? `${Math.round(described.track)}°` + : '', + icao24, + // Honesty cue: the contact is coasting on dead reckoning, so the + // narrated position/velocity are last-known rather than live. + status: described.stale ? 'stale (missed polls)' : 'live', + }, + }; } - if (presentationChanged) flightState._lastCamPoseSig = ''; -} + function _isExplicitTrackingOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; + } -/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + /** Refresh the AIR-only Cockpit near band independently from model admission. */ -function _setCockpitContactMode(active) { - const next = active === true; - if (flightState._cockpitContactMode === next) return; - flightState._cockpitContactMode = next; - if (next) _refreshCockpitNearContacts(); - else flightState._cockpitNearContacts = new Set(); - // The collection stays visible in cockpit. Near AIR contacts retain their - // aircraft silhouette until a ready model takes over; far contacts are pips. - // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. - if (flightState._modelCollection) flightState._modelCollection.show = true; - flightState._trail?.setVisible(!next); - if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; - for (const [icao24, bb] of flightState._billboards) parts.rendering._applyFleetBillboardPresentation(icao24, bb); - flightState._lastCamPoseSig = ''; - flightState._lastFleetTickMs = 0; -} - - - -function _applyCockpitState(detail = {}) { - const active = detail?.active === true; - flightState._cockpitSubjectId = active - ? String(detail?.subjectId || '').trim().toLowerCase() || null - : null; - _setCockpitContactMode(active); -} - - -/** - * Build the multi-line presentation text for the protected tracked host card. - * Lines: callsign, type/registration, operator/altitude. - * While the plane is in its missed-poll grace (coasting on dead reckoning - * with sticky metadata) the first line carries a "· STALE" cue — the fleet's - * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity - * owns the visual), so without this the readout would present last-known - * altitude/speed as live. - * @param {Object|null} info - Flight metadata from _flightData - * @param {string} icao24 - ICAO hex identifier (fallback display name) - * @returns {string} Newline-separated label text - */ - -function _buildTrackedLabel(info, icao24) { - const stale = (flightState._missingPolls.get(icao24) || flightState._backoff) ? ' · STALE' : ''; - const callsign = (parts.queries._toCleanText(info?.callsign) || parts.queries._toCleanText(info?.registration) || icao24) + stale; - // Converted contacts report their class as TR-3B — that override is exactly - // what the Easter egg replaces the real type with. - const type = tr3bTypeLabel(icao24, parts.queries._toCleanText(info?.type) || 'Type unknown'); - const registration = parts.queries._toCleanText(info?.registration) || 'Reg unknown'; - const operator = parts.queries._toCleanText(info?.operator) || 'Operator unknown'; - const altitude = parts.queries._formatAltitude(info?.altitudeFt); - const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; - const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; - return [ - callsign, - `${type} · ${registration}`, - `${operator} · ${tail}`, - ].join('\n'); -} - - -/** Write the explicit tracked presentation model and refresh its host entry. */ - -function _updateTrackedLabelModel(icao24) { - if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) return; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(flightState._flightData.get(icao24), icao24), - '#ffd166', - ); - refreshTrackedReadout(flightState._trackedEntity); - // The readout and the context slot describe the same contact — refresh them - // together so voice never narrates a fix the card has already replaced. - refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); -} - - -/** Re-image the tracked entity's billboard from the current class/conversion. */ - -function _syncTrackedBillboardImage() { - if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) return; - flightState._trackedEntity.billboard.image = aircraftIcon( - parts.rendering._iconKind(flightState._trackedIcao, flightState._flightData.get(flightState._trackedIcao)?.klass), - TRACKED_ICON_PX, - ); -} - - -/** - * Re-render one contact after its TR-3B conversion (or the active IR style) - * changed. Mirror of flights.js: converting drops any 3D model so the triangle - * owns the visual, then the billboard, tracked entity, and card re-derive. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when the layer owns this contact. - */ - -function _refreshTr3bContact(icao24) { - const id = String(icao24 || '').trim().toLowerCase(); - if (!id) return false; - if (isTr3b(id)) { - if (flightState._models.has(id) || flightState._modelPending.has(id)) parts.rendering._releaseModel(id); - const modelled = flightState._billboards.get(id); - if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick - if (id === flightState._trackedIcao) { - parts.rendering._releaseTrackedModel(); - _syncTracked2dRotation(); + function _refreshCockpitNearContacts() { + if ( + !flightState._cockpitContactMode || + !flightState._viewer?.camera?.positionWC + ) { + if (flightState._cockpitNearContacts.size) + flightState._cockpitNearContacts = new Set(); + return; } + const previous = flightState._cockpitNearContacts; + const distancesSquared = []; + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao || !bb?.position) continue; + distancesSquared.push([ + icao24, + Cesium.Cartesian3.distanceSquared( + flightState._viewer.camera.positionWC, + bb.position, + ), + ]); + } + const next = nextCockpitNearContacts( + previous, + distancesSquared, + parts.rendering._modelAddDistM(), + parts.rendering._modelKeepDistM(), + ); + flightState._cockpitNearContacts = next; + let presentationChanged = false; + for (const [icao24, bb] of flightState._billboards) { + if (previous.has(icao24) === next.has(icao24)) continue; + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + presentationChanged = true; + } + if (presentationChanged) flightState._lastCamPoseSig = ''; } - const bb = flightState._billboards.get(id); - if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); - if (id === flightState._trackedIcao) { - _syncTrackedBillboardImage(); - _updateTrackedLabelModel(id); + + /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + + function _setCockpitContactMode(active) { + const next = active === true; + if (flightState._cockpitContactMode === next) return; + flightState._cockpitContactMode = next; + if (next) _refreshCockpitNearContacts(); + else flightState._cockpitNearContacts = new Set(); + // The collection stays visible in cockpit. Near AIR contacts retain their + // aircraft silhouette until a ready model takes over; far contacts are pips. + // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. + if (flightState._modelCollection) flightState._modelCollection.show = true; + flightState._trail?.setVisible(!next); + if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; + for (const [icao24, bb] of flightState._billboards) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; } - flightState._viewer?.scene?.requestRender?.(); - return flightState._billboards.has(id) || id === flightState._trackedIcao; -} - -/** Re-image every converted contact this layer owns (IR style flip). */ - -function _refreshTr3bForStyle() { - for (const id of tr3bConvertedIds()) { - if (flightState._billboards.has(id) || id === flightState._trackedIcao) _refreshTr3bContact(id); + function _applyCockpitState(detail = {}) { + const active = detail?.active === true; + flightState._cockpitSubjectId = active + ? String(detail?.subjectId || '') + .trim() + .toLowerCase() || null + : null; + _setCockpitContactMode(active); } -} + /** + * Build the multi-line presentation text for the protected tracked host card. + * Lines: callsign, type/registration, operator/altitude. + * While the plane is in its missed-poll grace (coasting on dead reckoning + * with sticky metadata) the first line carries a "· STALE" cue — the fleet's + * 45%-alpha billboard fade doesn't apply to the tracked plane (its entity + * owns the visual), so without this the readout would present last-known + * altitude/speed as live. + * @param {Object|null} info - Flight metadata from _flightData + * @param {string} icao24 - ICAO hex identifier (fallback display name) + * @returns {string} Newline-separated label text + */ -/** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and - * the load-failure bound. Called from each path that changes which contact is - * selected — deselect, re-track, cross-layer handoff, init, destroy. - * - * This must live in the production lifecycle, not only in the predicate's - * icao-change guard: a deselect followed by a same-turn re-track of the SAME - * icao (Contacts re-entry, a cross-layer round trip back to this layer) never - * makes `_trackedIcao` *observably* change, so the guard never fires. Without - * the reset, a contact dropped inside the hysteresis band comes back as a - * MODEL above the ENTER ceiling, and a contact whose GLB had already failed - * out would never get its retries back. Mirror of flights.js. */ - -function _resetTrackedSelectionState() { - flightState._trackedZoomLatched = false; - flightState._trackedZoomLatchIcao = null; - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; -} - - -/** Whether the driver may start another tracked-model load this frame. */ - -function _trackedModelLoadAllowed(nowMs = Date.now()) { - if (flightState._trackedModelFailIcao !== flightState._trackedIcao) return true; // untried selection - if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) return false; - return nowMs >= flightState._trackedModelRetryAtMs; -} - - -/** Record a rejected tracked-model load and arm the backoff / give-up latch. */ - -function _noteTrackedModelLoadFailure(url, err) { - if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { - flightState._trackedModelFailIcao = flightState._trackedIcao; - flightState._trackedModelFailCount = 0; + function _buildTrackedLabel(info, icao24) { + const stale = + flightState._missingPolls.get(icao24) || flightState._backoff + ? ' · STALE' + : ''; + const callsign = + (parts.queries._toCleanText(info?.callsign) || + parts.queries._toCleanText(info?.registration) || + icao24) + stale; + // Converted contacts report their class as TR-3B — that override is exactly + // what the Easter egg replaces the real type with. + const type = tr3bTypeLabel( + icao24, + parts.queries._toCleanText(info?.type) || 'Type unknown', + ); + const registration = + parts.queries._toCleanText(info?.registration) || 'Reg unknown'; + const operator = + parts.queries._toCleanText(info?.operator) || 'Operator unknown'; + const altitude = parts.queries._formatAltitude(info?.altitudeFt); + const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; + const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; + return [ + callsign, + `${type} · ${registration}`, + `${operator} · ${tail}`, + ].join('\n'); } - flightState._trackedModelFailCount += 1; - flightState._trackedModelRetryAtMs = Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; - if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { - console.warn( - `[Data:Military] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` - + 'this contact stays 2D until another is selected', - err, + + /** Write the explicit tracked presentation model and refresh its host entry. */ + + function _updateTrackedLabelModel(icao24) { + if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) + return; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _buildTrackedLabel(flightState._flightData.get(icao24), icao24), + '#ffd166', + ); + refreshTrackedReadout(flightState._trackedEntity); + // The readout and the context slot describe the same contact — refresh them + // together so voice never narrates a fix the card has already replaced. + refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); + } + + /** Re-image the tracked entity's billboard from the current class/conversion. */ + + function _syncTrackedBillboardImage() { + if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) + return; + flightState._trackedEntity.billboard.image = aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, ); } -} + /** + * Re-render one contact after its TR-3B conversion (or the active IR style) + * changed. Mirror of flights.js: converting drops any 3D model so the triangle + * owns the visual, then the billboard, tracked entity, and card re-derive. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when the layer owns this contact. + */ -/** - * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven - * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the - * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET - * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live - * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the - * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km - * ceiling this used to inherit), hand back only above - * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. - * First-person means your own airframe is not drawn in cockpit — the eye sits - * metres from its origin. - */ - -function _trackedModelRegimeActive() { - if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { - flightState._trackedZoomLatchIcao = flightState._trackedIcao; - flightState._trackedZoomLatched = false; + function _refreshTr3bContact(icao24) { + const id = String(icao24 || '') + .trim() + .toLowerCase(); + if (!id) return false; + if (isTr3b(id)) { + if (flightState._models.has(id) || flightState._modelPending.has(id)) + parts.rendering._releaseModel(id); + const modelled = flightState._billboards.get(id); + if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick + if (id === flightState._trackedIcao) { + parts.rendering._releaseTrackedModel(); + _syncTracked2dRotation(); + } + } + const bb = flightState._billboards.get(id); + if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + if (id === flightState._trackedIcao) { + _syncTrackedBillboardImage(); + _updateTrackedLabelModel(id); + } + flightState._viewer?.scene?.requestRender?.(); + return flightState._billboards.has(id) || id === flightState._trackedIcao; } - // A converted TR-3B has no 3D asset — suppressing the regime keeps its - // tracked billboard fully opaque (the colour callback reads this too), so - // the triangle stays the visual all the way in. Mirror of flights.js. - if (!flightState._trackedIcao || flightState._cockpitContactMode || isTr3b(flightState._trackedIcao)) { - flightState._trackedZoomLatched = false; - return false; - } - flightState._trackedZoomLatched = trackedModelZoomActive( - flightState._viewer?.camera?.positionCartographic?.height, - flightState._trackedZoomLatched, - ); - return flightState._trackedZoomLatched; -} + /** Re-image every converted contact this layer owns (IR style flip). */ -/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ - -function _syncTracked2dRotation() { - if (!flightState._trackedIcao || !flightState._viewer) return; - const pos = parts.motion._trackedDisplayCached() || flightState._billboards.get(flightState._trackedIcao)?.position; - if (!pos) return; - const projected = screenProjectedRotation( - flightState._viewer.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rotation = stabilizeScreenRotation(flightState._lastTrackedRotation, projected, 0); - if (rotation !== null) flightState._lastTrackedRotation = rotation; -} - - -/** - * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm - * coarse ground cell — a pure LIFT (above-floor positions pass through - * untouched, unknown floors change nothing). Grounded military billboards - * deliberately render at the pre-datum default height (T7 groundSnap - * invariant), so every position entering the TRAIL subsystem (seed, append, - * per-frame head) goes through this instead — the visible taxi history sits - * on the surface without touching the billboard/model machinery. - * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). - * @returns {Cesium.Cartesian3} The same or a lifted position. - */ - -function _trailFloorPosition(position) { - const carto = Cesium.Cartographic.fromCartesian(position, Cesium.Ellipsoid.WGS84, flightState._scratchTrailCarto); - if (!carto) return position; - const latDeg = Cesium.Math.toDegrees(carto.latitude); - const lonDeg = Cesium.Math.toDegrees(carto.longitude); - const floored = floorAltitudeM(carto.height, cachedGroundFloor(latDeg, lonDeg)); - if (floored == null || floored === carto.height) return position; - return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); -} - - -/** - * Append one fix to the tracked aircraft's trail accumulation and refresh - * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {Cesium.Cartesian3} position - New fix position, appended at the head. - */ - -function _appendTrailFix(position) { - flightState._trailPositions.push(_trailFloorPosition(position)); - if (flightState._trailPositions.length > TRAIL_MAX_POINTS) flightState._trailPositions.shift(); - _refreshTrailDisplay(); -} - - -/** - * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That - * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at - * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. - * The cheap per-frame _trailHeadEntity segment bridges the last body point to the - * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll - * cadence), never at motion cadence. - */ - -function _refreshTrailDisplay() { - if (!flightState._trail) return; - flightState._trail.setPositions(flightState._trailPositions.length > 1 ? flightState._trailPositions.slice(0, -1) : flightState._trailPositions); -} - - -/** - * Start the trail for a newly tracked aircraft: seed it with the short - * dead-reckoning history (chronological), render immediately, then - * fire-and-forget an adsb.lol trace backfill (~24 h of real history). - * @param {string} icao24 - ICAO hex identifier being tracked. - */ - -function _startTrail(icao24) { - flightState._trailBackfillToken += 1; - flightState._trailPositions = []; - const history = flightState._positionHistory.get(icao24) || []; - // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The - // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them - // would draw the trail in front of the plane. They join via _appendTrailFix as they age. - const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), -RENDER_DELAY_SEC, flightState._scratchWarmupTime - ); - for (const fix of history) { - if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { - // Floor grounded-history seeds (round 2): a grounded contact's stored - // fixes carry the pre-datum default height — the trail copy sits on - // the surface instead (pure lift; airborne fixes pass through). - flightState._trailPositions.push(_trailFloorPosition(Cesium.Cartesian3.clone(fix.position))); + function _refreshTr3bForStyle() { + for (const id of tr3bConvertedIds()) { + if (flightState._billboards.has(id) || id === flightState._trackedIcao) + _refreshTr3bContact(id); } } - if (!flightState._trail && flightState._viewer) { - flightState._trail = createTrail(flightState._viewer, { color: TRAIL_COLOR, width: 2.5 }); + + /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and + * the load-failure bound. Called from each path that changes which contact is + * selected — deselect, re-track, cross-layer handoff, init, destroy. + * + * This must live in the production lifecycle, not only in the predicate's + * icao-change guard: a deselect followed by a same-turn re-track of the SAME + * icao (Contacts re-entry, a cross-layer round trip back to this layer) never + * makes `_trackedIcao` *observably* change, so the guard never fires. Without + * the reset, a contact dropped inside the hysteresis band comes back as a + * MODEL above the ENTER ceiling, and a contact whose GLB had already failed + * out would never get its retries back. Mirror of flights.js. */ + + function _resetTrackedSelectionState() { + flightState._trackedZoomLatched = false; + flightState._trackedZoomLatchIcao = null; + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; } - flightState._trail?.setVisible(!flightState._cockpitContactMode); - // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated - // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, - // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued - // to the 12 Hz icon instead of lagging ~1 s behind it. - if (!flightState._trailHeadEntity && flightState._viewer) { - flightState._trailHeadEntity = flightState._viewer.entities.add({ - // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick - // owner so a click on the head segment never reads as empty space. - id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`, - show: !flightState._cockpitContactMode, - polyline: { - positions: new Cesium.CallbackProperty(() => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the - // segment would draw IN FRONT of the plane. Likewise during warm-up the icon - // predates all real history, so there is no valid body point behind it. - if (!flightState._trackedIcao || flightState._trailPositions.length < 2 || parts.motion._isTrackWarmingUp()) return []; - const head = parts.motion._trackedTrailCached() || parts.motion._trackedDisplayPosition(flightState._trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = flightState._trailPositions[flightState._trailPositions.length - 2]; - // Floor the head too (round 2): a grounded tracked contact's DR - // display height is the pre-datum default — without this the last - // segment dives underground while taxiing. - const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. Measured against - // the FLOORED endpoint, which is the one actually drawn. - // See trailHeadStart. - const from = trailHeadStart( - start, end, parts.motion._trackedModelCenterWorld(), parts.motion._trackedModelEnvelopeM(), flightState._scratchTrailHead, - ); - if (!from) return []; - return [from, end]; - }, false), + + /** Whether the driver may start another tracked-model load this frame. */ + + function _trackedModelLoadAllowed(nowMs = Date.now()) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) + return true; // untried selection + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) + return false; + return nowMs >= flightState._trackedModelRetryAtMs; + } + + /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ + + function _noteTrackedModelLoadFailure(url, err) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { + flightState._trackedModelFailIcao = flightState._trackedIcao; + flightState._trackedModelFailCount = 0; + } + flightState._trackedModelFailCount += 1; + flightState._trackedModelRetryAtMs = + Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { + console.warn( + `[Data:Military] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + + 'this contact stays 2D until another is selected', + err, + ); + } + } + + /** + * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven + * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the + * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET + * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live + * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the + * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km + * ceiling this used to inherit), hand back only above + * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. + * First-person means your own airframe is not drawn in cockpit — the eye sits + * metres from its origin. + */ + + function _trackedModelRegimeActive() { + if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { + flightState._trackedZoomLatchIcao = flightState._trackedIcao; + flightState._trackedZoomLatched = false; + } + // A converted TR-3B has no 3D asset — suppressing the regime keeps its + // tracked billboard fully opaque (the colour callback reads this too), so + // the triangle stays the visual all the way in. Mirror of flights.js. + if ( + !flightState._trackedIcao || + flightState._cockpitContactMode || + isTr3b(flightState._trackedIcao) + ) { + flightState._trackedZoomLatched = false; + return false; + } + flightState._trackedZoomLatched = trackedModelZoomActive( + flightState._viewer?.camera?.positionCartographic?.height, + flightState._trackedZoomLatched, + ); + return flightState._trackedZoomLatched; + } + + /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ + + function _syncTracked2dRotation() { + if (!flightState._trackedIcao || !flightState._viewer) return; + const pos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) return; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rotation = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + 0, + ); + if (rotation !== null) flightState._lastTrackedRotation = rotation; + } + + /** + * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm + * coarse ground cell — a pure LIFT (above-floor positions pass through + * untouched, unknown floors change nothing). Grounded military billboards + * deliberately render at the pre-datum default height (T7 groundSnap + * invariant), so every position entering the TRAIL subsystem (seed, append, + * per-frame head) goes through this instead — the visible taxi history sits + * on the surface without touching the billboard/model machinery. + * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). + * @returns {Cesium.Cartesian3} The same or a lifted position. + */ + + function _trailFloorPosition(position) { + const carto = Cesium.Cartographic.fromCartesian( + position, + Cesium.Ellipsoid.WGS84, + flightState._scratchTrailCarto, + ); + if (!carto) return position; + const latDeg = Cesium.Math.toDegrees(carto.latitude); + const lonDeg = Cesium.Math.toDegrees(carto.longitude); + const floored = floorAltitudeM( + carto.height, + cachedGroundFloor(latDeg, lonDeg), + ); + if (floored == null || floored === carto.height) return position; + return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); + } + + /** + * Append one fix to the tracked aircraft's trail accumulation and refresh + * the rendered trail. Caller passes an owned (cloned) Cartesian3. + * @param {Cesium.Cartesian3} position - New fix position, appended at the head. + */ + + function _appendTrailFix(position) { + flightState._trailPositions.push(_trailFloorPosition(position)); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) + flightState._trailPositions.shift(); + _refreshTrailDisplay(); + } + + /** + * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That + * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at + * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. + * The cheap per-frame _trailHeadEntity segment bridges the last body point to the + * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll + * cadence), never at motion cadence. + */ + + function _refreshTrailDisplay() { + if (!flightState._trail) return; + flightState._trail.setPositions( + flightState._trailPositions.length > 1 + ? flightState._trailPositions.slice(0, -1) + : flightState._trailPositions, + ); + } + + /** + * Start the trail for a newly tracked aircraft: seed it with the short + * dead-reckoning history (chronological), render immediately, then + * fire-and-forget an adsb.lol trace backfill (~24 h of real history). + * @param {string} icao24 - ICAO hex identifier being tracked. + */ + + function _startTrail(icao24) { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + const history = flightState._positionHistory.get(icao24) || []; + // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The + // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them + // would draw the trail in front of the plane. They join via _appendTrailFix as they age. + const seedRenderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(), + -RENDER_DELAY_SEC, + flightState._scratchWarmupTime, + ); + for (const fix of history) { + if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + // Floor grounded-history seeds (round 2): a grounded contact's stored + // fixes carry the pre-datum default height — the trail copy sits on + // the surface instead (pure lift; airborne fixes pass through). + flightState._trailPositions.push( + _trailFloorPosition(Cesium.Cartesian3.clone(fix.position)), + ); + } + } + if (!flightState._trail && flightState._viewer) { + flightState._trail = createTrail(flightState._viewer, { + color: TRAIL_COLOR, width: 2.5, - material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads). - depthFailMaterial: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + }); + } + flightState._trail?.setVisible(!flightState._cockpitContactMode); + // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated + // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, + // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued + // to the 12 Hz icon instead of lagging ~1 s behind it. + if (!flightState._trailHeadEntity && flightState._viewer) { + flightState._trailHeadEntity = flightState._viewer.entities.add({ + // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // owner so a click on the head segment never reads as empty space. + id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`, + show: !flightState._cockpitContactMode, + polyline: { + positions: new Cesium.CallbackProperty(() => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the + // segment would draw IN FRONT of the plane. Likewise during warm-up the icon + // predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[ + flightState._trailPositions.length - 2 + ]; + // Floor the head too (round 2): a grounded tracked contact's DR + // display height is the pre-datum default — without this the last + // segment dives underground while taxiing. + const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. Measured against + // the FLOORED endpoint, which is the one actually drawn. + // See trailHeadStart. + const from = trailHeadStart( + start, + end, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, end]; + }, false), + width: 2.5, + material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), + // Round 4: the head must never vanish into the mesh either (dimmed + // when occluded so depth still reads). + depthFailMaterial: + Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + }, + }); + } + _refreshTrailDisplay(); + + const oldestFixEpochSec = history.length + ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + /** + * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * stride-thinned to the remaining vertex budget, then spliced strictly older + * than the oldest seeded fix AHEAD of the locally accumulated fine segment, + * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ + * malformed) silently keeps the local-only trail. + * @param {string} icao24 - ICAO hex identifier being tracked. + * @param {number} token - Backfill token captured at request time. + * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. + * @returns {Promise} + */ + + async function _backfillTrail(icao24, token, oldestFixEpochSec) { + let trace = null; + try { + const track = await flightState._source.getTrack?.( + flightState._flightData.get(icao24)?.sourceReference ?? icao24, + { + signal: AbortSignal.any([ + flightState.lifetime.signal, + AbortSignal.timeout(8000), + ]), + }, + ); + trace = track?.records ?? null; + } catch { + return; // silent fallback to the accumulated trail + } + if (!trace) return; + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a + // readsb trace point's altitude is barometric feet (same datum as the live + // alt_baro, NOT geometric), so a trail waypoint's render height is the same + // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) + // — geometrically approximate, not exact. + await ensureGeoidReady(); + + // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] + const parsed = []; + for (const point of trace) { + const t = point.observedAtMs / 1000; + const lat = point.latitude, + lon = point.longitude; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; + parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM }); + } + if (!parsed.length) return; + + // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse + // ellipsoidal ground along the trace and clamp every waypoint at it. A + // 'ground'/null point sits ON the local surface — the old fixed 50 m + // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m + // ellipsoidal) and dragged the whole pattern-work loop with it. + // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the + // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth + // lookup across a long path could stall the paint for seconds-to-timeout. + // Paint with whatever cells are warm; the resolve keeps filling the cache + // in the background for the next paint/select. + await resolveGroundFloorCellsBounded(parsed); + // Re-check the backfill token after the await (same guard as post-fetch): + // tracking may have moved on while the terrain race was in flight. + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + let older = []; + let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet + for (const { lat, lon, baroAltitudeM } of parsed) { + const baroM = + baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon); + let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); + // Ground/no-alt point with an unresolved floor: hold the previous + // waypoint's altitude (continuity — never a dive to a made-up depth). + // Leading points with nothing to carry keep the old low breadcrumb + // sentinel (an arbitrary placeholder, never a reported altitude). + if (altM == null) altM = lastAltM != null ? lastAltM : 50; + lastAltM = altM; + older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + } + + // Stride-thin the backfill so it plus the live fine segment fit the cap. + const budget = Math.max( + 1, + TRAIL_MAX_POINTS - flightState._trailPositions.length, + ); + if (older.length > budget) { + const stride = Math.ceil(older.length / budget); + const thinned = []; + for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); + older = thinned; + } + + flightState._trailPositions = older.concat(flightState._trailPositions); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { + flightState._trailPositions = flightState._trailPositions.slice( + flightState._trailPositions.length - TRAIL_MAX_POINTS, + ); + } + _refreshTrailDisplay(); + } + + /** + * Clear the rendered trail and accumulation; invalidate pending backfills. + */ + + function _clearTrail() { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + if (flightState._trail) flightState._trail.clear(); + if ( + flightState._trailHeadEntity && + flightState._viewer && + !flightState._viewer.isDestroyed() + ) { + try { + flightState._viewer.entities.remove(flightState._trailHeadEntity); + } catch { + /* already gone */ + } + } + flightState._trailHeadEntity = null; + } + + /** + * Destroy the trail primitive entirely (layer disable/teardown). + */ + + function _destroyTrail() { + _clearTrail(); + if (flightState._trail) { + flightState._trail.destroy(); + flightState._trail = null; + } + } + + /** + * Stop tracking the current aircraft and clean up all tracking state. + * Restores the original billboard, removes the tracked Entity, invalidates + * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo + * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back + * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms + * way up and loses my context"). The camera stays at its current + * position/orientation, immediately free to orbit/zoom. Applies to every + * deselect path: click-empty-space, Escape, aged-out plane, layer disable, + * and voice stopTracking. + * + * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the + * follow-camera: tear down our own state but leave viewer.trackedEntity + * alone (the new owner controls it). + * @param {object} [options] - Clear origin. + * @param {boolean} [options.evicted=false] - The contact aged out of the feed + * rather than being deselected. Consumers that keep a readout on screen + * (the Cockpit Contact panel) hold last-known values for an eviction and + * only tear down on a deliberate clear. + */ + + function _clearTracking( + skipViewerUntrack = false, + { evicted = false, origin = 'programmatic' } = {}, + ) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (!flightState._trackedIcao) { + clearFocusTarget('militaryFlights'); + return; + } + const clearedIcao = flightState._trackedIcao; + clearFocusTarget('militaryFlights', clearedIcao); + + // Restore the original billboard appearance. The rotation is re-seeded from + // the tracked entity's last rendered rotation and a rotation pass is forced + // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the + // STALE screen rotation it had when tracking began — up to a full + // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. + if (flightState._billboards.has(flightState._trackedIcao)) { + const bb = flightState._billboards.get(flightState._trackedIcao); + const meta = flightState._flightData.get(flightState._trackedIcao); + bb.show = true; + bb.width = 20; + bb.height = 20; + // Ground-aware restore (a plane untracked while taxiing comes back at + // ground scale; tint is full amber on the ground and in the air). + bb.scale = + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1); + bb.color = MIL_ICON_COLOR; + bb.rotation = flightState._lastTrackedRotation; + } + flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + + // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the + // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). + // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in + // place, so the view stays where the follow left it and the user can immediately orbit/zoom. + if (flightState._viewer && !skipViewerUntrack) { + flightState._viewer.trackedEntity = undefined; + } + if (flightState._trackedEntity) { + flightState._viewer.entities.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped + flightState._trackedIcao = null; + parts.rendering._applyFleetBillboardPresentation( + clearedIcao, + flightState._billboards.get(clearedIcao), + ); + clearTrackedSubjectContext('military'); + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId: 'military', + id: clearedIcao, + origin, + reason: evicted ? 'evicted' : 'deliberate', + }); + // Invalidate the per-frame DR cache so a same-frame re-track cannot read + // the previous aircraft's cached position. + parts.motion._resetTrackedDisplay(); + _clearTrail(); + } + + function _applyPendingTrackingRestore() { + const pending = flightState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== flightState._trackingIntentGeneration + ) + return false; + if ( + !flightState._billboardCollection?.show || + !flightState._billboards.has(pending.id) + ) + return false; + flightState._pendingTrackingRestore = null; + _trackFlight(pending.id, { origin: pending.origin }); + return true; + } + + function _cancelPendingTrackingRestore() { + flightState._trackingIntentGeneration += 1; + flightState._pendingTrackingRestore = null; + } + + /** + * Begin tracking a specific aircraft. Clears any previous tracking, hides the + * billboard, creates a new Entity with dead-reckoning CallbackProperties for + * smooth continuous motion, and sets the viewer's trackedEntity so the camera + * follows. + * @param {string} icao24 - ICAO hex identifier of the aircraft to track + */ + + function _trackFlight(icao24, { origin = 'programmatic' } = {}) { + _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + + const bb = flightState._billboards.get(icao24); + const info = flightState._flightData.get(icao24); + if (!bb || !info) return; + + flightState._trackedIcao = icao24; + _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget + flightState._cachedDRFrame = -1; + flightState._lastTrackedRotation = bb.rotation || 0; + // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its + // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover + // fleet model would be orphaned + double-rendered. + parts.rendering._releaseModel(icao24); + + // Hide the billboard -- the tracked entity replaces it visually + bb.show = false; + + // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — + // one computation shared by the position/rotation/trail-head callbacks, with + // discontinuity reconciliation). Falls back to the last billboard position when the + // aircraft has no fix. + const getTrackedPosition = () => + parts.motion._trackedDisplayPosition(icao24) || bb.position; + + // Dead-reckoning position property evaluated every render frame. + // isConstant = false so CesiumJS re-evaluates each frame. + const positionProperty = new Cesium.CallbackProperty(() => { + return getTrackedPosition(); + }, false); + + // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). + // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the + // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the + // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always + // resolves into an aircraft. + // + // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by + // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from + // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF + // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in + // would stall the centering until the model loaded. A shown-but-transparent billboard always + // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is + // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D + // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is + // what makes the follow-camera's bounding sphere always ready (see _trackedModel). + flightState._trackedEntity = flightState._viewer.entities.add({ + position: positionProperty, + // Keep Cesium's EntityView in the same ENU frame as the close-range + // camera guard; AUTO may otherwise alternate with a velocity frame. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState._flightData.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[ + flightState._flightData.get(flightState._trackedIcao)?.klass + ] || 1), + // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); + // transparent once the STANDALONE tracked model is actually up (ready + shown). + color: new Cesium.CallbackProperty( + () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR, + false, + ), + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the amber icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: new Cesium.CallbackProperty(() => { + const tracked = flightState._flightData.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._viewer) + return flightState._lastTrackedRotation; + const projected = screenProjectedRotation( + flightState._viewer.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; + }, false), }, }); - } - _refreshTrailDisplay(); + flightState._trackedEntity.gevSelectionOrigin = origin; + flightState._trackedEntity.gevTrackedId = `military:${icao24}`; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + _buildTrackedLabel(info, icao24), + '#ffd166', + ); - const oldestFixEpochSec = history.length - ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 - : Infinity; - _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); -} + // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is + // far too tight (the user had to scroll out to read the plane). Give the entity a + // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable + // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; + const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); + flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + 0, + -followRange * 0.8, + followRange * 0.55, + ); + // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked + // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires + // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. + // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a + // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). + flightState._trackedEntity.gevDisplayPosition = + parts.motion._trackedDisplayCached; + // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the + // follow-camera anti-jitter contract; presentation that must weld to the aircraft you + // can see reads `gevVisualPosition`. + flightState._trackedEntity.gevVisualPosition = + parts.motion._trackedVisualCached; + refreshTrackedReadout(flightState._trackedEntity); + flightState._viewer.camera.cancelFlight(); + // Camera follows the tracked entity + flightState._viewer.trackedEntity = flightState._trackedEntity; + flightState._trackedCameraFrameStop = + applyTrackedCameraFrame( + flightState._viewer, + flightState._trackedEntity, + flightState._trackedEntity.viewFrom, + ) || null; -/** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are - * stride-thinned to the remaining vertex budget, then spliced strictly older - * than the oldest seeded fix AHEAD of the locally accumulated fine segment, - * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ - * malformed) silently keeps the local-only trail. - * @param {string} icao24 - ICAO hex identifier being tracked. - * @param {number} token - Backfill token captured at request time. - * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. - * @returns {Promise} - */ + // Track-history trail (PRD F2): seed from local history + async backfill. + // Ground traffic draws NO trail — if the plane takes off while tracked, the + // ground→air transition in update() starts one. + // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing + // aircraft's history is retrievable on select. Trail positions are floored + // at the surface (_trailFloorPosition), so ground legs drape, never dive. + _startTrail(icao24); -async function _backfillTrail(icao24, token, oldestFixEpochSec) { - let trace = null; - try { - const track = await flightState._source.getTrack?.(flightState._flightData.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.any([flightState.lifetime.signal, AbortSignal.timeout(8000)]) }); - trace = track?.records ?? null; - } catch { - return; // silent fallback to the accumulated trail - } - if (!trace) return; - if (token !== flightState._trailBackfillToken || icao24 !== flightState._trackedIcao) return; - - // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a - // readsb trace point's altitude is barometric feet (same datum as the live - // alt_baro, NOT geometric), so a trail waypoint's render height is the same - // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) - // — geometrically approximate, not exact. - await ensureGeoidReady(); - - // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] - const parsed = []; - for (const point of trace) { - const t = point.observedAtMs / 1000; - const lat = point.latitude, lon = point.longitude; - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; - parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM }); - } - if (!parsed.length) return; - - // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse - // ellipsoidal ground along the trace and clamp every waypoint at it. A - // 'ground'/null point sits ON the local surface — the old fixed 50 m - // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m - // ellipsoidal) and dragged the whole pattern-work loop with it. - // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the - // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth - // lookup across a long path could stall the paint for seconds-to-timeout. - // Paint with whatever cells are warm; the resolve keeps filling the cache - // in the background for the next paint/select. - await resolveGroundFloorCellsBounded(parsed); - // Re-check the backfill token after the await (same guard as post-fetch): - // tracking may have moved on while the terrain race was in flight. - if (token !== flightState._trailBackfillToken || icao24 !== flightState._trackedIcao) return; - - let older = []; - let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet - for (const { lat, lon, baroAltitudeM } of parsed) { - const baroM = baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon); - let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); - // Ground/no-alt point with an unresolved floor: hold the previous - // waypoint's altitude (continuity — never a dive to a made-up depth). - // Leading points with nothing to carry keep the old low breadcrumb - // sentinel (an arbitrary placeholder, never a reported altitude). - if (altM == null) altM = lastAltM != null ? lastAltM : 50; - lastAltM = altM; - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + const callsign = + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24; + _publishTrackedSelection(icao24, origin); + console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); } - // Stride-thin the backfill so it plus the live fine segment fit the cap. - const budget = Math.max(1, TRAIL_MAX_POINTS - flightState._trailPositions.length); - if (older.length > budget) { - const stride = Math.ceil(older.length / budget); - const thinned = []; - for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); - older = thinned; - } + /** + * Global keydown handler: pressing Escape clears the active aircraft tracking. + * @param {KeyboardEvent} e - The keyboard event + */ - flightState._trailPositions = older.concat(flightState._trailPositions); - if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { - flightState._trailPositions = flightState._trailPositions.slice(flightState._trailPositions.length - TRAIL_MAX_POINTS); - } - _refreshTrailDisplay(); -} - - -/** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ - -function _clearTrail() { - flightState._trailBackfillToken += 1; - flightState._trailPositions = []; - if (flightState._trail) flightState._trail.clear(); - if (flightState._trailHeadEntity && flightState._viewer && !flightState._viewer.isDestroyed()) { - try { flightState._viewer.entities.remove(flightState._trailHeadEntity); } catch { /* already gone */ } - } - flightState._trailHeadEntity = null; -} - - -/** - * Destroy the trail primitive entirely (layer disable/teardown). - */ - -function _destroyTrail() { - _clearTrail(); - if (flightState._trail) { - flightState._trail.destroy(); - flightState._trail = null; - } -} - - -/** - * Stop tracking the current aircraft and clean up all tracking state. - * Restores the original billboard, removes the tracked Entity, invalidates - * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo - * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back - * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms - * way up and loses my context"). The camera stays at its current - * position/orientation, immediately free to orbit/zoom. Applies to every - * deselect path: click-empty-space, Escape, aged-out plane, layer disable, - * and voice stopTracking. - * - * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity - * alone (the new owner controls it). - * @param {object} [options] - Clear origin. - * @param {boolean} [options.evicted=false] - The contact aged out of the feed - * rather than being deselected. Consumers that keep a readout on screen - * (the Cockpit Contact panel) hold last-known values for an eviction and - * only tear down on a deliberate clear. - */ - -function _clearTracking(skipViewerUntrack = false, { - evicted = false, - origin = 'programmatic', -} = {}) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; - if (!flightState._trackedIcao) { - clearFocusTarget('militaryFlights'); - return; - } - const clearedIcao = flightState._trackedIcao; - clearFocusTarget('militaryFlights', clearedIcao); - - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (flightState._billboards.has(flightState._trackedIcao)) { - const bb = flightState._billboards.get(flightState._trackedIcao); - const meta = flightState._flightData.get(flightState._trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; - // Ground-aware restore (a plane untracked while taxiing comes back at - // ground scale; tint is full amber on the ground and in the air). - bb.scale = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); - bb.color = MIL_ICON_COLOR; - bb.rotation = flightState._lastTrackedRotation; - } - flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick - - // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the - // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). - // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in - // place, so the view stays where the follow left it and the user can immediately orbit/zoom. - if (flightState._viewer && !skipViewerUntrack) { - flightState._viewer.trackedEntity = undefined; - } - if (flightState._trackedEntity) { - flightState._viewer.entities.remove(flightState._trackedEntity); - flightState._trackedEntity = null; - } - parts.rendering._releaseTrackedModel(); - _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped - flightState._trackedIcao = null; - parts.rendering._applyFleetBillboardPresentation(clearedIcao, flightState._billboards.get(clearedIcao)); - clearTrackedSubjectContext('military'); - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'military', - id: clearedIcao, - origin, - reason: evicted ? 'evicted' : 'deliberate', - }); - // Invalidate the per-frame DR cache so a same-frame re-track cannot read - // the previous aircraft's cached position. - parts.motion._resetTrackedDisplay(); - _clearTrail(); -} - - - - - -function _applyPendingTrackingRestore() { - const pending = flightState._pendingTrackingRestore; - if (!pending || pending.generation !== flightState._trackingIntentGeneration) return false; - if (!flightState._billboardCollection?.show || !flightState._billboards.has(pending.id)) return false; - flightState._pendingTrackingRestore = null; - _trackFlight(pending.id, { origin: pending.origin }); - return true; -} - - - -function _cancelPendingTrackingRestore() { - flightState._trackingIntentGeneration += 1; - flightState._pendingTrackingRestore = null; -} - - -/** - * Begin tracking a specific aircraft. Clears any previous tracking, hides the - * billboard, creates a new Entity with dead-reckoning CallbackProperties for - * smooth continuous motion, and sets the viewer's trackedEntity so the camera - * follows. - * @param {string} icao24 - ICAO hex identifier of the aircraft to track - */ - -function _trackFlight(icao24, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - - const bb = flightState._billboards.get(icao24); - const info = flightState._flightData.get(icao24); - if (!bb || !info) return; - - flightState._trackedIcao = icao24; - _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget - flightState._cachedDRFrame = -1; - flightState._lastTrackedRotation = bb.rotation || 0; - // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its - // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover - // fleet model would be orphaned + double-rendered. - parts.rendering._releaseModel(icao24); - - // Hide the billboard -- the tracked entity replaces it visually - bb.show = false; - - // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — - // one computation shared by the position/rotation/trail-head callbacks, with - // discontinuity reconciliation). Falls back to the last billboard position when the - // aircraft has no fix. - const getTrackedPosition = () => parts.motion._trackedDisplayPosition(icao24) || bb.position; - - // Dead-reckoning position property evaluated every render frame. - // isConstant = false so CesiumJS re-evaluates each frame. - const positionProperty = new Cesium.CallbackProperty(() => { - return getTrackedPosition(); - }, false); - - // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). - // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the - // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the - // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always - // resolves into an aircraft. - // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D - // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is - // what makes the follow-camera's bounding sphere always ready (see _trackedModel). - flightState._trackedEntity = flightState._viewer.entities.add({ - position: positionProperty, - // Keep Cesium's EntityView in the same ENU frame as the close-range - // camera guard; AUTO may otherwise alternate with a velocity frame. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: aircraftIcon(parts.rendering._iconKind(flightState._trackedIcao, flightState._flightData.get(flightState._trackedIcao)?.klass), TRACKED_ICON_PX), - width: 28, - height: 28, - scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[flightState._flightData.get(flightState._trackedIcao)?.klass] || 1), - // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: new Cesium.CallbackProperty(() => ( - parts.rendering._modelOwnsVisual(flightState._trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR - ), false), - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the amber icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: new Cesium.CallbackProperty(() => { - const tracked = flightState._flightData.get(flightState._trackedIcao); - const pos = getTrackedPosition(); - if (!tracked || !pos || !flightState._viewer) return flightState._lastTrackedRotation; - const projected = screenProjectedRotation( - flightState._viewer.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rot = stabilizeScreenRotation(flightState._lastTrackedRotation, projected); - if (rot !== null) flightState._lastTrackedRotation = rot; - return flightState._lastTrackedRotation; - }, false), - }, - }); - flightState._trackedEntity.gevSelectionOrigin = origin; - flightState._trackedEntity.gevTrackedId = `military:${icao24}`; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(info, icao24), - '#ffd166', - ); - - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) - const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; - const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); - flightState._trackedEntity.viewFrom = new Cesium.Cartesian3(0, -followRange * 0.8, followRange * 0.55); - - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked - // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires - // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. - // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a - // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). - flightState._trackedEntity.gevDisplayPosition = parts.motion._trackedDisplayCached; - // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the - // follow-camera anti-jitter contract; presentation that must weld to the aircraft you - // can see reads `gevVisualPosition`. - flightState._trackedEntity.gevVisualPosition = parts.motion._trackedVisualCached; - refreshTrackedReadout(flightState._trackedEntity); - flightState._viewer.camera.cancelFlight(); - // Camera follows the tracked entity - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; - - // Track-history trail (PRD F2): seed from local history + async backfill. - // Ground traffic draws NO trail — if the plane takes off while tracked, the - // ground→air transition in update() starts one. - // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing - // aircraft's history is retrievable on select. Trail positions are floored - // at the surface (_trailFloorPosition), so ground legs drape, never dive. - _startTrail(icao24); - - const callsign = parts.queries._toCleanText(info.callsign) || parts.queries._toCleanText(info.registration) || icao24; - _publishTrackedSelection(icao24, origin); - console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); -} - - -/** - * Global keydown handler: pressing Escape clears the active aircraft tracking. - * @param {KeyboardEvent} e - The keyboard event - */ - -function _onKeyDown(e) { - if (e.key === 'Escape' && flightState._trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - - -/** - * Install a left-click handler on the scene canvas for aircraft selection. - * Clicking a military billboard starts tracking that aircraft; clicking empty - * space or a non-military object deselects. Also attaches the Escape key - * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - */ - -function _installClickHandler(viewer) { - if (flightState._clickHandler) return; // already installed - - // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our - // tracking without touching viewer.trackedEntity (the new owner controls it). - if (!flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (flightState._trackedIcao && flightState._viewer && flightState._viewer.trackedEntity && flightState._viewer.trackedEntity !== flightState._trackedEntity) { - _clearTracking(true, { - origin: flightState._viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - flightState._clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { - // Camera drags never select or deselect, even if they finish over a plane. - // Duration alone is allowed through so a stationary long press can still - // select/switch contacts; the destructive empty-space branch below applies - // the full travel + duration click classifier. - if (!isTrackingSelectionGesture(gesture)) return; - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference. Empty globe clicks are inert until the user - // exits with C, Escape, or the dedicated button. - if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking the tracked entity itself -- ignore (don't deselect) - if (picked.id === flightState._trackedEntity) return; - - // Clicking the plane we're ALREADY tracking (its standalone 3D model or - // any pick carrying its icao) — same no-op as the 2D tracked-entity click - // above. H1: the model used to have no pick id, so this fell through to - // "empty space" and deselected the very plane being tracked. - if (flightState._trackedIcao) { - const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id; - if (picked.primitive === flightState._trackedModel || rawPick === flightState._trackedIcao) return; - } - - // BillboardCollection picks: CesiumJS may expose the billboard as - // picked.primitive (with .id = icao24) or directly as picked.id - const billboard = picked.primitive; - if (billboard && billboard.id && flightState._billboards.has(billboard.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Fallback: some CesiumJS versions surface the id string at picked.id - if (picked.id && typeof picked.id === 'string' && flightState._billboards.has(picked.id)) { - _cancelPendingTrackingRestore(); - _trackFlight(picked.id, { origin: 'user' }); - return; - } - } - - // A pick that belongs to a sibling layer (commercial flight, satellite, - // vessel, station, CCTV camera…) is not "empty space" — leave tracking - // alone and let that layer handle it. resolvePickId String()-coerces the - // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the - // registry predicates can recognize them (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; - } - - // Clicked empty space -- deselect only for a clean, short click. A slow - // stationary press may select above, but cannot release existing tracking. - if (!isTrackingClickGesture(gesture)) return; - if (flightState._trackedIcao) { + function _onKeyDown(e) { + if (e.key === 'Escape' && flightState._trackedIcao) { _cancelPendingTrackingRestore(); _clearTracking(false, { origin: 'user' }); } - }); + } - document.addEventListener('keydown', _onKeyDown); -} -return { _emitAwarenessEvent, _publishTrackedSelection, _contextSubjectMetadata, _isExplicitTrackingOrigin, _refreshCockpitNearContacts, _setCockpitContactMode, _applyCockpitState, _buildTrackedLabel, _updateTrackedLabelModel, _syncTrackedBillboardImage, _refreshTr3bContact, _refreshTr3bForStyle, _resetTrackedSelectionState, _trackedModelLoadAllowed, _noteTrackedModelLoadFailure, _trackedModelRegimeActive, _syncTracked2dRotation, _trailFloorPosition, _appendTrailFix, _refreshTrailDisplay, _startTrail, _backfillTrail, _clearTrail, _destroyTrail, _clearTracking, _applyPendingTrackingRestore, _cancelPendingTrackingRestore, _trackFlight, _onKeyDown, _installClickHandler }; + /** + * Install a left-click handler on the scene canvas for aircraft selection. + * Clicking a military billboard starts tracking that aircraft; clicking empty + * space or a non-military object deselects. Also attaches the Escape key + * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ + + function _installClickHandler(viewer) { + if (flightState._clickHandler) return; // already installed + + // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our + // tracking without touching viewer.trackedEntity (the new owner controls it). + if (!flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove = + viewer.trackedEntityChanged.addEventListener(() => { + if ( + flightState._trackedIcao && + flightState._viewer && + flightState._viewer.trackedEntity && + flightState._viewer.trackedEntity !== flightState._trackedEntity + ) { + _clearTracking(true, { + origin: + flightState._viewer.trackedEntity?.gevSelectionOrigin || + 'programmatic', + }); + } + }); + } + + flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + // Camera drags never select or deselect, even if they finish over a plane. + // Duration alone is allowed through so a stationary long press can still + // select/switch contacts; the destructive empty-space branch below applies + // the full travel + duration click classifier. + if (!isTrackingSelectionGesture(gesture)) return; + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference. Empty globe clicks are inert until the user + // exits with C, Escape, or the dedicated button. + if (document.body.classList.contains('cockpit-mode')) return; + const picked = viewer.scene.pick(click.position); + + if (picked) { + // Clicking the tracked entity itself -- ignore (don't deselect) + if (picked.id === flightState._trackedEntity) return; + + // Clicking the plane we're ALREADY tracking (its standalone 3D model or + // any pick carrying its icao) — same no-op as the 2D tracked-entity click + // above. H1: the model used to have no pick id, so this fell through to + // "empty space" and deselected the very plane being tracked. + if (flightState._trackedIcao) { + const rawPick = + typeof picked.id === 'string' ? picked.id : picked.primitive?.id; + if ( + picked.primitive === flightState._trackedModel || + rawPick === flightState._trackedIcao + ) + return; + } + + // BillboardCollection picks: CesiumJS may expose the billboard as + // picked.primitive (with .id = icao24) or directly as picked.id + const billboard = picked.primitive; + if ( + billboard && + billboard.id && + flightState._billboards.has(billboard.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(billboard.id, { origin: 'user' }); + return; + } + // Fallback: some CesiumJS versions surface the id string at picked.id + if ( + picked.id && + typeof picked.id === 'string' && + flightState._billboards.has(picked.id) + ) { + _cancelPendingTrackingRestore(); + _trackFlight(picked.id, { origin: 'user' }); + return; + } + } + + // A pick that belongs to a sibling layer (commercial flight, satellite, + // vessel, station, CCTV camera…) is not "empty space" — leave tracking + // alone and let that layer handle it. resolvePickId String()-coerces the + // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the + // registry predicates can recognize them (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; + } + + // Clicked empty space -- deselect only for a clean, short click. A slow + // stationary press may select above, but cannot release existing tracking. + if (!isTrackingClickGesture(gesture)) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + }); + + document.addEventListener('keydown', _onKeyDown); + } + return { + _emitAwarenessEvent, + _publishTrackedSelection, + _contextSubjectMetadata, + _isExplicitTrackingOrigin, + _refreshCockpitNearContacts, + _setCockpitContactMode, + _applyCockpitState, + _buildTrackedLabel, + _updateTrackedLabelModel, + _syncTrackedBillboardImage, + _refreshTr3bContact, + _refreshTr3bForStyle, + _resetTrackedSelectionState, + _trackedModelLoadAllowed, + _noteTrackedModelLoadFailure, + _trackedModelRegimeActive, + _syncTracked2dRotation, + _trailFloorPosition, + _appendTrailFix, + _refreshTrailDisplay, + _startTrail, + _backfillTrail, + _clearTrail, + _destroyTrail, + _clearTracking, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _trackFlight, + _onKeyDown, + _installClickHandler, + }; } diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index dd3529b3a..1b74b5424 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -5,8 +5,14 @@ import { fileURLToPath } from 'node:url'; /** Read actual component owners for structural regression assertions. */ export function readLayerSource(file) { const path = file instanceof URL ? fileURLToPath(file) : file; - if (!['flights.js', 'militaryFlights.js'].includes(basename(path))) return readFileSync(path, 'utf8'); - const directory = join(dirname(path), basename(path) === 'flights.js' ? '../layers/flights' : '../layers/military'); + if (!['flights.js', 'militaryFlights.js'].includes(basename(path))) + return readFileSync(path, 'utf8'); + const directory = join( + dirname(path), + basename(path) === 'flights.js' + ? '../layers/flights' + : '../layers/military', + ); return readdirSync(directory) .filter((name) => name.endsWith('.js')) .sort()