mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Format military-flight and classification components
This commit is contained in:
1 parent
cb790ddea1
commit
1edebcb6f8
17 files changed
+5078
-3959
No files matched your search
+45
-12
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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<string>} Lowercase ICAO24 hexes known to be military. */
|
||||
const _milIcaos = new Set();
|
||||
/** @type {boolean} True while the dedicated military layer is enabled. */
|
||||
let _militaryLayerActive = false;
|
||||
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
|
||||
const _activeChangeListeners = new Set();
|
||||
/** @type {number} Epoch ms of the last registry refresh (any source). */
|
||||
let _lastRefreshMs = 0;
|
||||
/** @type {boolean} A self-poll fetch is in flight. */
|
||||
let _polling = false;
|
||||
let currentSource = null;
|
||||
let lifetime = null;
|
||||
let releaseSource = null;
|
||||
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
|
||||
const _milIcaos = new Set();
|
||||
/** @type {boolean} True while the dedicated military layer is enabled. */
|
||||
let _militaryLayerActive = false;
|
||||
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
|
||||
const _activeChangeListeners = new Set();
|
||||
/** @type {number} Epoch ms of the last registry refresh (any source). */
|
||||
let _lastRefreshMs = 0;
|
||||
/** @type {boolean} A self-poll fetch is in flight. */
|
||||
let _polling = false;
|
||||
let currentSource = null;
|
||||
let lifetime = null;
|
||||
let releaseSource = null;
|
||||
|
||||
/** Replace the classification source and cancel its outstanding work. */
|
||||
function configureSource(nextSource, { signal } = {}) {
|
||||
if (typeof nextSource?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
|
||||
releaseSource?.();
|
||||
const controller = new AbortController();
|
||||
lifetime = controller;
|
||||
currentSource = nextSource;
|
||||
_milIcaos.clear();
|
||||
_lastRefreshMs = 0;
|
||||
_polling = false;
|
||||
const release = () => {
|
||||
controller.abort();
|
||||
signal?.removeEventListener('abort', release);
|
||||
if (lifetime !== controller) return;
|
||||
currentSource = null;
|
||||
lifetime = null;
|
||||
releaseSource = null;
|
||||
/** 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<string>} icaos - ICAO24 hexes from a /v2/mil response.
|
||||
* @returns {void}
|
||||
*/
|
||||
function registerMilitaryIcaos(icaos) {
|
||||
for (const icao of icaos || []) {
|
||||
const hex = String(icao || '').trim().toLowerCase();
|
||||
if (hex) _milIcaos.add(hex);
|
||||
/**
|
||||
* 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<string>} icaos - ICAO24 hexes from a /v2/mil response.
|
||||
* @returns {void}
|
||||
*/
|
||||
function registerMilitaryIcaos(icaos) {
|
||||
for (const icao of icaos || []) {
|
||||
const hex = String(icao || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (hex) _milIcaos.add(hex);
|
||||
}
|
||||
_lastRefreshMs = now();
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
+533
-425
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
+287
-235
@@ -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 };
|
||||
}
|
||||
+511
-371
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 —
|
||||
|
||||
+787
-644
File diff suppressed because it is too large.
Load diff
+1087
-725
File diff suppressed because it is too large.
Load diff
+230
-273
@@ -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<string, Cesium.Model>} icao24 → model */
|
||||
|
||||
flightState._scratchGroundPos = new Cesium.Cartesian3();
|
||||
flightState._models = new Map();
|
||||
|
||||
/** @type {Cesium.PrimitiveCollection|null} */
|
||||
/** @type {Set<string>} icao24 currently loading (async) */
|
||||
|
||||
flightState._modelCollection = null;
|
||||
flightState._modelPending = new Set();
|
||||
|
||||
/** @type {Map<string, Cesium.Model>} icao24 → model */
|
||||
/** @type {Map<string, number>} 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<string>} 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<string, number>} 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<string, Cesium.Billboard>} 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<string, Object>} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */
|
||||
|
||||
flightState._flightData = new Map();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
/** @type {Map<string, Array<{time: Cesium.JulianDate, position: Cesium.Cartesian3}>>} 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<string, Cesium.Billboard>} 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<string, Object>} 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<string, Array<{time: Cesium.JulianDate, position: Cesium.Cartesian3}>>} 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<string, number>} 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<string, number>} 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<string, number>} 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<string, number>} 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;
|
||||
}
|
||||
+157
-144
@@ -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<Object>} [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<Object>} [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 };
|
||||
}
|
||||
+1125
-912
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user