Merge pull request #417 from bilawalsidhu/refactor/military-flight-components

Extract military-flight components and shared aircraft helpers
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 10:44:52 -07:00
commit 8b3fad0672
29 files changed
+6062 -3929

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. Preserve known military identities even when the source has no position for them.
- Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application.
- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history.
+22
View File
@@ -1,5 +1,27 @@
# God's Eye View Current State
## Military-flight components and aircraft mechanics
`gods-eye-view/layers/military` exports `createMilitaryFlightLayer`. It uses the
same normalized observation contract as civil flights, with separate military
classification, styling, model and tracking policy. Each instance owns its
contacts, history, scratch objects, model loads and cancellation lifetime.
Applications supply the existing scene services and resolve model asset URLs;
`src/data/militaryFlights.js` keeps the standalone layer API and adsb.lol source.
A source may retain a bounded stale-status reason; the standalone cached-feed
behavior remains unchanged.
`gods-eye-view/aircraft` exports the existing shared classification, icon,
metadata, motion, altitude, model-anchor, proximity and selection calculations.
It also exports `createMilitaryRegistry`, an explicitly constructed owner for
known military identities and active-layer transitions. Its optional background
poll consumes the optional `getIdentities` capability, falling back to normalized
positioned records when a source has no identity-only capability. The adsb.lol
adapter preserves known identities without requiring positions; those entries
still cannot enter the renderer. Source replacement
and disposal abort pending work and clear retained identities; construction
starts no network request. Both standalone aircraft layers use one registry.
## Civil-flight components
`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance
+3 -1
View File
@@ -143,6 +143,8 @@
"./ui/styles": "./src/ui/styles.js",
"./scenes": "./src/scenes/director.js",
"./sources/live": "./src/sources/live/index.js",
"./layers/flights": "./src/layers/flights/index.js"
"./layers/flights": "./src/layers/flights/index.js",
"./layers/military": "./src/layers/military/index.js",
"./aircraft": "./src/layers/aircraft/index.js"
}
}
+18 -1
View File
@@ -270,5 +270,22 @@
"src/layers/flights/queries.js",
"src/layers/flights/index.js",
"src/layers/flights/enrichment.js",
"src/layers/flights/ownership.test.mjs"
"src/layers/flights/ownership.test.mjs",
"src/data/militaryFlights.js",
"src/data/militaryRegistry.js",
"src/layers/military/lifecycle.js",
"src/layers/military/motion.js",
"src/layers/military/controller.js",
"src/layers/military/state.js",
"src/layers/military/policy.js",
"src/layers/military/tracking.js",
"src/layers/military/rendering.js",
"src/layers/military/testing.js",
"src/layers/military/ingestion.js",
"src/layers/military/queries.js",
"src/layers/military/index.js",
"src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js",
"src/layers/military/ownership.test.mjs",
"src/layers/aircraft/classification.test.mjs"
]
+54
View File
@@ -610,5 +610,59 @@
"src/layers/flights/tracking.js"
],
"external": ["cesium"]
},
"military-flights": {
"exports": ["./layers/military"],
"modules": [
"src/data/aircraftClass.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
"src/data/cockpitAirLod.js",
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/layerState.js",
"src/data/modelVisualAnchor.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/layers/military/controller.js",
"src/layers/military/index.js",
"src/layers/military/ingestion.js",
"src/layers/military/lifecycle.js",
"src/layers/military/motion.js",
"src/layers/military/policy.js",
"src/layers/military/queries.js",
"src/layers/military/rendering.js",
"src/layers/military/state.js",
"src/layers/military/testing.js",
"src/layers/military/tracking.js"
],
"external": ["cesium"]
},
"aircraft-mechanics": {
"exports": ["./aircraft"],
"modules": [
"src/data/aircraftClass.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
"src/data/cockpitAirLod.js",
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/layerState.js",
"src/data/modelVisualAnchor.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/routePlausible.js",
"src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/layers/aircraft/classification.js",
"src/layers/aircraft/index.js"
],
"external": ["cesium"]
}
}
File diff suppressed because it is too large. Load diff
+10 -118
View File
@@ -1,119 +1,11 @@
/**
* Shared military-aircraft ICAO24 registry (2026-06-10 playtest fix).
*
* adsb.lol /v2/mil tags aircraft via its database's military flag; OpenSky
* carries no such tag, so military aircraft (e.g. ADAPT91/92) appeared in
* BOTH layers as duplicate icons and tracks. This registry reconciles them:
*
* - The military layer, while enabled, feeds every poll's ICAO set here and
* marks itself active — the commercial flights layer then SUPPRESSES its
* duplicates (military layer wins icon, track, and click).
* - While the military layer is OFF, the flights layer keeps a low-rate
* poll (60s against the dev proxy's cached /api/adsblol/mil) so known
* military aircraft are still classified and styled amber.
*/
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
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;
/**
* True when the dedicated military layer currently renders these aircraft
* (the flights layer should suppress duplicates rather than restyle them).
* @returns {boolean}
*/
export 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}
*/
export 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.
*/
export 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}
*/
export function registerMilitaryIcaos(icaos) {
for (const icao of icaos || []) {
const hex = String(icao || '').trim().toLowerCase();
if (hex) _milIcaos.add(hex);
}
_lastRefreshMs = Date.now();
}
/**
* Whether an aircraft is known military.
* @param {string} icao24 - ICAO24 hex (any case).
* @returns {boolean}
*/
export function isMilitaryIcao(icao24) {
return _milIcaos.has(String(icao24 || '').toLowerCase());
}
/**
* Refreshes the registry from /api/adsblol/mil when stale — used by the
* flights layer so classification works while the military layer is off
* (the military layer's own polls keep it fresh otherwise). The dev proxy
* caches upstream responses, so this is nearly free.
* @returns {void} Fire-and-forget; failures leave the current set intact.
*/
export function refreshMilitaryRegistryIfStale() {
if (_militaryLayerActive) return; // military layer's polls keep us fresh
if (_polling || (Date.now() - _lastRefreshMs) < MIL_POLL_INTERVAL_MS) return;
_polling = true;
(async () => {
try {
const response = await fetch('/api/adsblol/mil', { signal: AbortSignal.timeout(10000) });
if (!response.ok) return;
const data = await response.json();
const aircraft = Array.isArray(data?.ac) ? data.ac : [];
registerMilitaryIcaos(aircraft.map((entry) => entry?.hex));
} catch {
// keep the existing set on any failure
} finally {
_polling = false;
}
})();
}
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
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;
+2 -2
View File
@@ -392,7 +392,7 @@ const LAYERS = [
name: 'military',
source: 'src/data/militaryFlights.js',
asset: (() => {
const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8');
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/);
assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found');
return m[1];
@@ -492,7 +492,7 @@ for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) {
// PLANE_* constants — pin them to the measured meter-scale GLB + flights'
// calibration so the copies cannot drift.
test('military layer airplane.glb constants match the measured GLB and flights calibration', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8');
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
const grab = (name) => {
const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`));
assert.ok(m, `militaryFlights.js: ${name} not found`);
+1 -1
View File
@@ -252,7 +252,7 @@ test('tracking layers write gevLabelModel and expose only their cached display p
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
}
assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false);
+167
View File
@@ -0,0 +1,167 @@
/** 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;
/** @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;
_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;
}
/**
* 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);
}
_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 ids =
typeof requestSource.getIdentities === 'function'
? await requestSource.getIdentities({}, { signal })
: (await requestSource.getSnapshot({}, { signal })).records.map(
(record) => record.id,
);
signal.throwIfAborted();
if (lifetime !== owner) return;
if (!Array.isArray(ids))
throw new TypeError('Expected aircraft identities');
registerMilitaryIcaos(ids);
} 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,
};
}
+141
View File
@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMilitaryRegistry } from './classification.js';
test('classification instances have independent identities, listeners and active states', () => {
let requests = 0;
const source = {
getSnapshot() {
requests++;
},
};
const first = createMilitaryRegistry({ source });
const second = createMilitaryRegistry({ source });
let firstEvents = 0;
let secondEvents = 0;
first.onMilitaryLayerActiveChange(() => firstEvents++);
second.onMilitaryLayerActiveChange(() => secondEvents++);
first.registerMilitaryIcaos([' ABC123 ']);
first.setMilitaryLayerActive(true);
assert.equal(first.isMilitaryIcao('abc123'), true);
assert.equal(second.isMilitaryIcao('abc123'), false);
assert.equal(firstEvents, 1);
assert.equal(secondEvents, 0);
assert.equal(second.isMilitaryLayerActive(), false);
assert.equal(requests, 0);
first.dispose();
second.dispose();
});
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' }] };
},
},
});
await registry.refreshMilitaryRegistryIfStale();
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 1);
assert.equal(registry.isMilitaryIcao('abc123'), true);
time += 60000;
registry.setMilitaryLayerActive(true);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 1);
registry.setMilitaryLayerActive(false);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 2);
registry.dispose();
});
test('source replacement rejects an old response and disposal cancels the current request', async () => {
let resolveOld;
let resolveNew;
let oldSignal;
let newSignal;
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 },
);
const current = registry.refreshMilitaryRegistryIfStale();
resolveOld({ records: [{ id: 'abc123' }] });
await old;
application.abort();
resolveNew({ records: [{ id: 'def456' }] });
await current;
assert.equal(oldSignal.aborted, true);
assert.equal(newSignal.aborted, true);
assert.equal(registry.isMilitaryIcao('abc123'), false);
assert.equal(registry.isMilitaryIcao('def456'), false);
registry.dispose();
});
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');
},
},
});
registry.registerMilitaryIcaos(['abc123']);
time += 60000;
await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale());
assert.equal(calls, 1);
assert.equal(registry.isMilitaryIcao('abc123'), true);
registry.dispose();
assert.equal(registry.isMilitaryIcao('abc123'), false);
});
test('disposing a registry fed directly by its layer also clears classification', () => {
const registry = createMilitaryRegistry();
registry.registerMilitaryIcaos(['abc123']);
registry.dispose();
assert.equal(registry.isMilitaryIcao('abc123'), false);
});
test('classification prefers an explicit identity capability over positioned observations', async () => {
const registry = createMilitaryRegistry({
source: {
getSnapshot() {
assert.fail(
'position snapshots must not replace an available identity capability',
);
},
async getIdentities() {
return ['abc123'];
},
},
});
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
registry.dispose();
});
+17
View File
@@ -0,0 +1,17 @@
/** Common aircraft calculations and explicitly constructed classification. */
export { createMilitaryRegistry } from './classification.js';
export * from '../../data/aircraftClass.js';
export * from '../../data/aircraftIcons.js';
export * from '../../data/aircraftMeta.js';
export * from '../../data/aircraftNearbyPolicy.js';
export * from '../../data/cockpitAirLod.js';
export * from '../../data/cockpitContactDot.js';
export * from '../../data/contactMatch.js';
export * from '../../data/iconOrientation.js';
export * from '../../data/layerState.js';
export * from '../../data/modelVisualAnchor.js';
export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
export * from '../../data/routePlausible.js';
export * from '../../data/trackedModelRegime.js';
export * from '../../data/trackingClickGesture.js';
+14
View File
@@ -0,0 +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 };
}
+37
View File
@@ -0,0 +1,37 @@
import { createFlightState } from './state.js';
import { createRendering } from './rendering.js';
import { createMotion } from './motion.js';
import { createTracking } from './tracking.js';
import { createController } from './controller.js';
import { createIngestion } from './ingestion.js';
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 { TRACKED_MODEL_MAX_PX } from './policy.js';
+547
View File
@@ -0,0 +1,547 @@
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 { 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';
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;
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 };
}
+301
View File
@@ -0,0 +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;
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;
},
/**
* 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 */
}
}
})
.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);
restoreSpriteOrder(viewer);
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);
},
/**
* 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 };
}
+528
View File
@@ -0,0 +1,528 @@
import * as Cesium from 'cesium';
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';
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
*/
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);
}
}
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,
);
}
// 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,
);
}
/**
* 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,
});
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,
};
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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() {} }) },
};
}
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 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(requests, 0);
});
test('military source omission fails before viewer initialization', () => {
const layer = createMilitaryFlightLayer({ services: services() });
assert.throws(() => layer.init({}), /snapshot source/);
});
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',
]) {
assert.notEqual(first[key], second[key], key);
}
});
test('a normalized source can retain its stale reason without changing standalone cache policy', async () => {
let reason = 'Source is refreshing';
const supplied = services();
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,
};
},
},
});
await layer.update({});
assert.equal(layer.getStats().error, reason);
assert.equal(layer.getStats().lastUpdate, 123000);
assert.equal(layer.getStats().stale, true);
reason = null;
await layer.update({});
assert.equal(layer.getStats().error, null);
assert.equal(layer.getStats().stale, true);
});
+277
View File
@@ -0,0 +1,277 @@
import * as Cesium from 'cesium';
/**
* @module militaryFlights
* @description Real-time military flight tracking layer powered by the adsb.lol API.
*
* Renders aircraft as amber chevron billboards in a single BillboardCollection
* for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft
* spawns an Entity the camera follows, whose position is driven by a
* dead-reckoning CallbackProperty. The whole fleet renders at
* now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes
* instead of extrapolating ahead and snapping back when the next poll lands.
*
* Billboard orientation uses alignedAxis set to the WGS84 surface normal at each
* position so that the rotation angle (heading/track) operates in the local tangent
* plane where 0 deg = north; near nadir the icons switch to screen-aligned
* rotation (camera.heading - track) with hysteresis.
*/
/** @constant {number} Milliseconds to wait before retrying after a transient error */
export const ERROR_BACKOFF_INTERVAL = 20000;
/** @constant {number} Max position samples retained per aircraft for dead reckoning */
export const POSITION_HISTORY_LIMIT = 5;
/** @constant {number} Base billboard display scale */
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');
/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */
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
// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable,
// sticky metadata updating normally. The on-ground flip restyles the existing
// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes
// draw no trails. In 3D mode they take model slots like airborne planes (owner
// decision 2026-07-03 — no air/ground distinction), placed by the one-shot
// ground snap (see _modelDisplayPosition).
//
// TINT: full-strength amber, same as airborne (owner verdict 2026-07-03 field
// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can
// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail;
// "feed-dropped, coasting" stays the 45%-alpha stale fade.
/** Ground billboards render slightly smaller so airport clutter stays visually minor. */
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
// FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity')
// or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion
// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer
// apart from the commercial flights layer.
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)
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)
export const TRACKED_MODEL_MIN_PX = 40;
// 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
export const MODEL_NATIVE_RADIUS_M = 29.83;
// jet.glb is transform-applied at real-world scale — native bounding radius
// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching
// flights.js world sizes. At the old copied ×24, models rendered
// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize
// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by
// modelScale.test.mjs.
export const MODEL_SCALE = 1;
// Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity
// 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
export const MODEL_MAX_ALL = 350;
// 'all' cap
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
// 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)
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_HEADING_OFFSET_DEG = 180;
// 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.
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
// airplane.glb (the shared 747) constants for this layer's heavy classes. The
// asset has its former 24× runtime calibration baked into transform-applied
// meter-scale geometry; these values mirror flights.js and are regression-pinned.
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
// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node
// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03).
// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model
// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to
// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs.
export const MODEL_BELLY_OFFSET_NATIVE = 5.631;
/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */
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,
);
// ---------------------------------------------------------------------------
// Track-history trail state (PRD WS-F F2/F4): a fading polyline behind the
// tracked aircraft. The accumulation array is intentionally SEPARATE from
// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning)
// so the visible trail can grow to TRAIL_MAX_POINTS fixes.
// ---------------------------------------------------------------------------
/** @constant {string} Military trail hue (PRD F4, pinned). */
export const TRAIL_COLOR = '#FFB800';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
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
// instead of extrapolating ahead and snapping back when the next poll lands.
// Removing the delay reintroduces the back/forward oscillation and is a
// regression.
// ---------------------------------------------------------------------------
/** @constant {number} Display latency in seconds (= one poll interval). */
export const RENDER_DELAY_SEC = 15;
/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */
export const MISSING_POLL_LIMIT = 3;
// --- Landed-plane fast cull (mirror of flights.js; owner field report
// 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown).
// The feed's ground flag lags the actual landing, so a landed plane's last
// airborne fixes show it low + slow on the runway; when it then drops out of
// the poll it has landed, not hit a transient gap — evict after ONE missed
// poll. Thresholds: below ~500 ft baro (≈150 m MSL — near-sea-level fields
// only; a high-elevation airport ghost falls back to the normal grace) AND
// below ~45 kts ground speed (≈23 m/s — rollout/taxi; nothing in normal
// FLIGHT is this slow, so cruise planes always keep the full grace).
/** @constant {number} Max baro altitude (ft, MSL) for the landed fast cull (~150 m). */
export const LANDED_ALT_MAX_FT = 500;
/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */
export const LANDED_SPEED_MAX_MPS = 23;
/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */
export const LANDED_MISSING_POLL_LIMIT = 1;
// Field-test fix (RS46, 2026-07-06): only contacts rendering below this
// ellipsoidal height get the below-ground floor clamp + a coarse floor-cell
// warm. Terrain outside the extreme Himalaya tops out well under this, so
// cruise traffic (which can never be below ground) costs zero terrain
// lookups; low pattern/heli work — the class that actually clips hillsides —
// gets the floor.
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
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
// the view direction and the shader's screen-projected angle is 0/0). Near
// nadir we switch to screen-aligned billboards with
// rotation = camera.heading - track (screen-up points at azimuth
// camera.heading; a CW track t appears t-h from screen-up; billboard rotation
// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations
// are continuous at the boundary with this formula.
// ---------------------------------------------------------------------------
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
export const FLEET_DR_INTERVAL_MS = 80;
/** @constant {number} Max ms between rotation passes while the camera is idle. */
export const ROTATION_REFRESH_MS = 1000;
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
export const COURSE_MAX_DPS = 60;
/** Never spend a long render stall's full elapsed time in one visible course step. */
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,
// camera, and trail head never visibly jump — with zero steady-state lag. --
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
* load→reject at frame rate for as long as the contact stays selected.
* Failures are counted PER SELECTION: a short backoff absorbs a transient
* blip, then the layer stops asking until the operator selects something else.
* The billboard is the visual throughout (the handoff only fades it once a
* model actually renders), so a latched failure degrades to exactly the
* pre-3D presentation. */
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 —
* destroying the whole fleet synchronously in the style handler stalls the
* render thread). Models tagged with their load-time boost state; stale queue
* entries skip; in-flight loads invalidated immediately; tracked reloads
* synchronously (single primitive). */
export const IR_RELOAD_BATCH = 40;
+819
View File
@@ -0,0 +1,819 @@
import { displayedKinematics } from '../../data/motionModel.js';
import * as Cesium from 'cesium';
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js';
import { modelVisualAnchor } from '../../data/modelVisualAnchor.js';
import {
rankContactMatch,
contactMatchWins,
CONTACT_MATCH_TIER,
} from '../../data/contactMatch.js';
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
export function createQueries({
flightState,
services,
parts,
layer,
resolveAsset,
}) {
const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation;
const { formatFlightLevel } = services.labels;
const { applyTrackedCameraFrame } = services.camera;
/**
* Coerce a value to a finite number, returning null if not possible.
* Handles both numeric and string inputs (e.g. API fields that may arrive as strings).
* @param {*} value - Raw value from the API response
* @returns {number|null} Finite number or null
*/
function _toFiniteNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const n = Number.parseFloat(value.trim());
return Number.isFinite(n) ? n : null;
}
return null;
}
/**
* Safely convert a value to a trimmed string, defaulting to '' for falsy inputs.
* @param {*} value - Raw value to stringify
* @returns {string} Trimmed string
*/
function _toCleanText(value) {
return String(value || '').trim();
}
/**
* Format an altitude value in feet for display, with fallback text.
* @param {number|null|undefined} altitudeFt - Barometric altitude in feet
* @returns {string} Formatted altitude string (e.g. "35000 ft" or "Alt unknown")
*/
function _formatAltitude(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return 'Alt unknown';
return `${Math.round(altitudeFt)} ft`;
}
/**
* True when the aircraft's latest metadata reads "on or about the runway"
* (low + slow) — see the landed fast-cull rationale above. Both gates must
* hold, so a plane missing either datum keeps the normal grace.
* @param {string} icao24 - ICAO hex identifier of the aircraft
* @returns {boolean}
*/
function _likelyLanded(icao24) {
const info = flightState._flightData.get(icao24);
if (!info) return false;
// Round 7 (mirror of flights.js): fast cull only for contacts seen
// AIRBORNE this session — parked contacts ride the normal grace so feed
// flaps don't churn their identity/floor state.
if (info.wasAirborne !== true) return false;
if (info.onGround) return true;
return (
Number.isFinite(info.altitudeFt) &&
info.altitudeFt < LANDED_ALT_MAX_FT &&
Number.isFinite(info.speedMps) &&
info.speedMps < LANDED_SPEED_MAX_MPS
);
}
/**
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO hex identifier of the aircraft.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
function _describeFlight(icao24) {
const info = flightState._flightData.get(icao24);
const bb = flightState._billboards.get(icao24);
const basePos =
parts.motion._deadReckon(icao24) || (bb ? bb.position : null);
if (!basePos) return null;
const displayed = displayedKinematics({
derivedSpeedMps: flightState._drSpeedMps,
derivedTrackDeg: flightState._drCourseDeg,
reportedSpeedMps: info?.speedMps,
reportedTrackDeg: info?.track,
});
const carto = Cesium.Cartographic.fromCartesian(
basePos,
Cesium.Ellipsoid.WGS84,
flightState._scratchCarto,
);
if (!carto) return null;
return {
icao24,
callsign: _toCleanText(info?.callsign) || null,
// Additive: the label chain's middle link, trimmed like `callsign`, so
// getTrackedSubject and the voice narration can read it straight off the
// descriptor instead of reaching back into `_flightData`.
registration: _toCleanText(info?.registration) || null,
position: Cesium.Cartesian3.clone(basePos),
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
// Keep the cockpit readout on the reported aviation altitude. Render
// terrain height is a separate visual datum and may be below zero.
altitudeM: Number.isFinite(info?.altitudeM)
? info.altitudeM
: carto.height,
renderAltitudeM: Number.isFinite(info?.renderAltitudeM)
? info.renderAltitudeM
: carto.height,
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
stale: Boolean(
flightState._missingPolls.get(icao24) || flightState._backoff,
),
};
}
function _normalizeTrackedIcao(candidate) {
const normalized = String(candidate ?? '')
.trim()
.toLowerCase();
return normalized || null;
}
/**
* Map one military aircraft's internal poll record to a plain JSON-safe
* analyst record (analyst query engine seam) — same shape as the flights
* layer's mapAnalystRecord, with `military` always true. Pure — no Cesium
* types, no fetches. Missing/unknown fields are null, never NaN/undefined.
* adsb.lol carries no origin-country field and this layer has no route
* enrichment, so originCountry/routeOrigin/routeDestination are always null.
* @param {string} icao24 - ICAO hex identifier of the aircraft.
* @param {Object|null|undefined} info - `_flightData` record for this aircraft.
* @returns {{id: string, icao24: string, callsign: string|null, lat: number|null,
* lon: number|null, altitudeM: number|null, speedMps: number|null,
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
* military: boolean, aircraftClass: string|null, originCountry: null,
* operator: string|null, routeOrigin: null, routeDestination: null}}
*/
function mapAnalystRecord(icao24, info) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
const callsign = text(info?.callsign);
return {
id: callsign || text(info?.registration) || icao24,
icao24,
callsign,
lat: num(info?.rawLat),
lon: num(info?.rawLon),
// altitudeFt is the sticky barometric/MSL aviation field — converted to
// meters here for shape parity with the flights layer.
altitudeM: Number.isFinite(info?.altitudeFt)
? info.altitudeFt * 0.3048
: null,
speedMps: num(info?.speedMps),
heading: num(info?.track),
verticalRateMps: num(info?.verticalRateMps),
onGround: info?.onGround === true,
military: true,
// A converted contact reports the class it RENDERS as (mirror of
// flights.js), so an analyst filter/superlative agrees with the triangle.
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: null,
operator: text(info?.operator),
routeOrigin: null,
routeDestination: null,
};
}
const methods = {
mapAnalystRecord,
id: 'military',
name: 'Military Flights',
icon: '🎖️',
source: flightState._lastSource,
/** @type {number} Polling interval in ms between API fetches */
updateInterval: 15000,
/** @deprecated Compatibility alias for {@link enable}. */
show(viewer) {
this.enable(viewer);
},
/** @deprecated Compatibility alias for {@link disable}. */
hide(viewer) {
this.disable(viewer);
},
/**
* Live layer params.
* `models3d` toggles 3D glTF jet-model rendering for the FLEET (altitude-gated): when on,
* surrounding military aircraft become 3D models once the camera is zoomed in past
* MODEL_ALT_CEIL_M. The TRACKED contact is NOT gated by this — it takes its 3D model by
* camera distance regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js).
* `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane).
* @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedMilitaryTrackingId?: string|null}} params
*/
setParams(params = {}, { origin = 'programmatic' } = {}) {
if (
isExplicitLayerStateOrigin(origin) &&
!Object.hasOwn(params, 'selectedMilitaryTrackingId')
) {
parts.tracking._cancelPendingTrackingRestore();
}
if (
typeof params.models3d === 'boolean' &&
params.models3d !== flightState._models3dEnabled
) {
flightState._models3dEnabled = params.models3d;
if (!flightState._models3dEnabled) {
parts.rendering._releaseModels();
parts.tracking._syncTracked2dRotation();
// Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked
// plane's own fleet billboard — its tracked entity is the visual (avoids a double-image).
if (flightState._billboardCollection)
for (const [icao, bb] of flightState._billboards) {
if (icao !== flightState._trackedIcao) bb.show = true;
}
}
}
if (
(params.models3dMode === 'proximity' ||
params.models3dMode === 'all') &&
params.models3dMode !== flightState._models3dMode
) {
// The next fleet tick re-derives the eligible set under the new cap and releases the overflow.
flightState._models3dMode = params.models3dMode;
if (flightState._cockpitContactMode) {
parts.tracking._refreshCockpitNearContacts();
flightState._lastFleetTickMs = 0;
}
}
if (
typeof params.irBoost === 'boolean' &&
params.irBoost !== flightState._irBoost
) {
flightState._irBoost = params.irBoost;
parts.rendering._reloadModelsForIrBoost();
// Sprites don't reload with the models — swap the TR-3B glyph between its
// cold and thermal-reactive variants directly (mirror of flights.js).
parts.tracking._refreshTr3bForStyle();
}
if (Object.hasOwn(params, 'selectedMilitaryTrackingId')) {
const requested = _normalizeTrackedIcao(
params.selectedMilitaryTrackingId,
);
if (requested === flightState._trackedIcao) {
flightState._pendingTrackingRestore = null;
} else if (requested === null) {
parts.tracking._cancelPendingTrackingRestore();
if (flightState._trackedIcao)
parts.tracking._clearTracking(false, { origin });
} else {
const generation = ++flightState._trackingIntentGeneration;
flightState._pendingTrackingRestore = {
id: requested,
generation,
origin,
};
if (flightState._trackedIcao)
parts.tracking._clearTracking(false, { origin });
parts.tracking._applyPendingTrackingRestore();
}
}
return true;
},
getParams() {
return {
models3d: flightState._models3dEnabled,
models3dMode: flightState._models3dMode,
irBoost: flightState._irBoost,
selectedMilitaryTrackingId: flightState._trackedIcao,
};
},
/**
* Re-render a contact whose TR-3B conversion just flipped (Easter egg).
* Callers own the registry write; this only re-derives what renders.
* @param {string} icao24 - ICAO 24-bit address.
* @returns {boolean} True when this layer owns the contact.
*/
refreshTr3b(icao24) {
return parts.tracking._refreshTr3bContact(icao24);
},
/**
* Find military aircraft near a given ECEF position, sorted by distance.
* Used by proximity-based features (e.g. detection overlay, spatial queries).
* @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates
* @param {number} range - Maximum distance in meters (Infinity if not specified)
* @param {number} [maxCount=50] - Maximum number of results to return
* @param {object} [options] Query membership options.
* @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft.
* @returns {Array<Object>} Sorted array of nearby aircraft descriptors
*/
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
if (
!center ||
!flightState._billboardCollection ||
!flightState._billboardCollection.show
)
return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 50;
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so proximity counts
// don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden.
if (
!aircraftIncludedInNearby({
isTracked,
billboardShown: bb.show,
modelRendering: parts.rendering._modelOwnsVisual(icao24),
includeHidden,
})
)
continue;
const trackedPos = isTracked
? parts.motion._trackedDisplayCached()
: null; // cached, no recompute (anti-jitter)
const pos = trackedPos || bb.position;
if (!pos) continue;
const distance = Cesium.Cartesian3.distance(center, pos);
if (distance > maxRange) continue;
const info = flightState._flightData.get(icao24);
nearby.push({
id: info?.callsign?.trim() || info?.registration?.trim() || icao24,
icao24,
position: pos,
distance,
// Filter surface (mirror of flights.js): the cockpit next/previous path
// matches on this field, so it follows the conversion.
aircraftClass: tr3bAircraftClass(
icao24,
String(info?.klass || info?.type || '')
.trim()
.toLowerCase() || null,
),
track: info?.track ?? null,
// Display type — converted too, so a Contacts row can't still name the
// airframe the triangle replaced (the filter matcher reads this as a
// fallback candidate as well).
type: tr3bTypeLabel(icao24, info?.type || null),
registration: info?.registration || null,
operator: info?.operator || null,
altitudeFt: info?.altitudeFt ?? null,
});
}
nearby.sort((a, b) => a.distance - b.distance);
return nearby.slice(0, limit);
},
/**
* Return a subset of aircraft suitable for the detection overlay system.
* Uses a deterministic stride-based sampling to keep the count manageable
* while distributing selections evenly across the collection.
* @param {Object} [options={}] - Options
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all)
* @param {number} [options.seed] - Seed offset for stride sampling (for frame variation)
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
*/
getDetectableObjects(options = {}) {
if (
!flightState._billboardCollection ||
!flightState._billboardCollection.show
)
return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: flightState._billboards.size;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
// Deterministic stride: evenly space selections across the billboard map
const stride = Math.max(
1,
Math.ceil(flightState._billboards.size / maxCount),
);
const start = seed % stride;
const result = [];
let idx = 0;
for (const [icao24, bb] of flightState._billboards) {
const shouldTake = (idx - start) % stride === 0;
idx++;
if (!shouldTake) continue;
if (
flightState._cockpitContactMode &&
icao24.toLowerCase() === flightState._cockpitSubjectId
)
continue;
const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
const model = flightState._models.get(icao24);
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
const info = flightState._flightData.get(icao24);
const callsign = info?.callsign?.trim();
const registration = info?.registration?.trim();
let object = flightState._detectionObjects.get(icao24);
if (!object) {
object = {
sourceId: icao24,
type: 'AIR',
tier: 'military',
_weldPos: new Cesium.Cartesian3(),
};
flightState._detectionObjects.set(icao24, object);
}
// WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned
// contacts read the translation the fleet tick already wrote into their
// modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket
// are co-located anyway.
const spec = modelOwnsVisual
? parts.rendering._modelSpec(info?.klass)
: null;
const pos = isTracked
? parts.motion._trackedVisualCached() || bb.position
: modelOwnsVisual
? modelVisualAnchor(
model.modelMatrix,
spec.visualCenterNative,
Number.isFinite(model.computedScale)
? model.computedScale
: spec.scale,
object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
)
: bb.position;
if (!pos) continue;
object.position = pos;
object.skipLabel = isTracked;
const id = callsign || registration || icao24;
if (object.id !== id) object.id = id;
// Card surface: detectionDraw composes its secondary line from
// `[src.klass, src.metric]`, so a converted contact's card must name the
// TR-3B, not the airframe underneath it.
const klass = tr3bTypeLabel(icao24, info?.type || 'MIL');
if (object.klass !== klass) object.klass = klass;
const altitudeFt = info?.altitudeFt ?? 0;
if (object._altitudeFt !== altitudeFt) {
object._altitudeFt = altitudeFt;
// military metadata stores altitudeFt (feet); the helper takes metres
object.metric = formatFlightLevel(altitudeFt * 0.3048);
}
result.push(object);
if (result.length >= maxCount) break;
}
if (
flightState._detectionObjects.size >
flightState._billboards.size + 512
) {
for (const icao24 of flightState._detectionObjects.keys()) {
if (!flightState._billboards.has(icao24))
flightState._detectionObjects.delete(icao24);
}
}
return result;
},
/**
* Find a single aircraft by free-text query.
* Match priority: exact icao24 hex, exact callsign, callsign prefix,
* then callsign substring (all case-insensitive, trimmed).
* @param {string} query - ICAO hex or full/partial callsign.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* Best match with a cloned, dead-reckoned position, or null if none.
*/
findByQuery(query) {
if (!flightState._flightData || flightState._flightData.size === 0)
return null;
const q = String(query || '')
.trim()
.toLowerCase();
if (!q) return null;
// Registration is searched alongside callsign because it is what the
// operator SEES: a callsign-less contact reads as its tail number on the
// card, in the analyst's answer, and in the Contacts list. Matching only
// callsigns meant "follow 6606" — and the analyst → track_entity handoff
// the tool instructions prescribe — answered "nothing matched" for the
// very identity the app had just shown (owner field session 2026-08-21,
// 23:48: three failed track_entity retries before a fallback stuck).
// Ranking is shared with the flights layer (contactMatch.js) so the two
// cannot disagree, and it is strictly tiered so a registration can never
// out-rank a real callsign on feed order alone.
let best = null;
for (const [icao24, info] of flightState._flightData) {
const candidate = {
tier: rankContactMatch({
query: q,
hex: icao24,
callsign: info?.callsign,
registration: info?.registration,
}),
id: icao24,
};
if (!contactMatchWins(candidate, best)) continue;
best = candidate;
if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break;
}
return best ? _describeFlight(best.id) : null;
},
/**
* Return id/label/position for up to maxCount currently rendered aircraft.
* Cheap snapshot for voice-tool framing — billboard positions only, no
* dead reckoning and no cloning.
* @param {number} [maxCount=500] - Maximum number of entries to return.
* @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>}
*/
/**
* Whether this layer still carries a contact, in O(1).
*
* Mirror of `flights.hasContact`: presence consumers must not infer absence
* from the capped `getAllPositions` rows. Id matching follows trackById —
* exact key first, then lowercase.
* A disabled layer keeps its records but hides the collection, so it must
* decline rather than answer from data the user can no longer see.
* @param {string} icao24 Contact identifier.
* @returns {boolean|null} Presence, or null when the layer is disabled or
* holds no data and therefore cannot answer.
*/
hasContact(icao24) {
if (
!flightState._billboardCollection ||
!flightState._billboardCollection.show ||
flightState._billboards.size === 0
)
return null;
if (!icao24) return false;
const id = String(icao24).trim();
return (
flightState._billboards.has(id) ||
flightState._billboards.has(id.toLowerCase())
);
},
getAllPositions(maxCount = 500) {
if (
!flightState._billboardCollection ||
flightState._billboards.size === 0
)
return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 500;
const result = [];
for (const [icao24, bb] of flightState._billboards) {
const pos = bb.position;
if (!pos) continue;
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
flightState._scratchCarto,
);
if (!carto) continue;
const info = flightState._flightData.get(icao24);
result.push({
id: icao24, // identity (trackById/Context resolve this)
// Last surface still reading as the raw hex for a callsign-less
// contact — same chain as getNearby/getDetectableObjects/getTrackedSubject.
label:
_toCleanText(info?.callsign) ||
_toCleanText(info?.registration) ||
icao24,
position: pos,
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
});
if (result.length >= limit) break;
}
return result;
},
/**
* Snapshot the layer's in-memory records as plain JSON-safe objects for
* the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty.
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (
!flightState._billboardCollection ||
!flightState._billboardCollection.show ||
flightState._flightData.size === 0
)
return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const [icao24, info] of flightState._flightData) {
result.push(mapAnalystRecord(icao24, info));
if (result.length >= limit) break;
}
return result;
},
/**
* Start camera-tracking an aircraft by ICAO hex identifier.
* @param {string} icao24 - ICAO hex identifier of the aircraft.
* @returns {boolean} True if the aircraft exists and tracking started.
*/
trackById(icao24, { origin = 'programmatic' } = {}) {
if (!icao24) return false;
let id = String(icao24).trim();
if (!flightState._billboards.has(id)) id = id.toLowerCase();
if (!flightState._billboards.has(id)) return false;
if (parts.tracking._isExplicitTrackingOrigin(origin))
parts.tracking._cancelPendingTrackingRestore();
if (flightState._trackedIcao === id)
return parts.tracking._publishTrackedSelection(id, origin);
parts.tracking._trackFlight(id, { origin });
return true;
},
/** Resolve a shared Follow target only against the latest accepted refresh. */
async resolveTrackingRestoreTarget(
icao24,
{ signal = null, origin = 'share-restore' } = {},
) {
if (signal?.aborted)
return {
status: 'cancelled',
reason: String(signal.reason || 'aborted'),
};
const id = String(icao24 ?? '')
.trim()
.toLowerCase();
if (!id) return { status: 'missing', reason: 'invalid-target' };
const outcome = flightState._lastTrackingRefreshOutcome;
if (outcome.status !== 'accepted') {
return {
status: 'source-unavailable',
reason: `${flightState._lastSource} snapshot unavailable`,
refreshEpoch: outcome.epoch,
source: outcome.source,
};
}
if (!outcome.ids.has(id)) {
return {
status: 'missing',
reason: 'target-absent-from-snapshot',
refreshEpoch: outcome.epoch,
source: outcome.source,
};
}
if (signal?.aborted)
return {
status: 'cancelled',
reason: String(signal.reason || 'aborted'),
};
const followed = this.trackById(id, { origin });
return followed
? {
status: 'found',
refreshEpoch: outcome.epoch,
source: outcome.source,
}
: {
status: 'source-unavailable',
reason: 'target-not-renderable',
refreshEpoch: outcome.epoch,
};
},
/** Reapply the canonical follow frame without recreating the selected flight. */
refocusTrackedById(icao24, { origin = 'programmatic' } = {}) {
if (
!icao24 ||
flightState._cockpitContactMode ||
!flightState._viewer ||
!flightState._trackedEntity
)
return false;
let id = String(icao24).trim();
if (!flightState._billboards.has(id)) id = id.toLowerCase();
if (
id !== flightState._trackedIcao ||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
flightState._viewer.trackedEntity !== flightState._trackedEntity
)
return false;
flightState._trackedCameraFrameStop?.();
flightState._viewer.camera.cancelFlight();
flightState._viewer.trackedEntity = flightState._trackedEntity;
flightState._trackedCameraFrameStop =
applyTrackedCameraFrame(
flightState._viewer,
flightState._trackedEntity,
flightState._trackedEntity.viewFrom,
) || null;
parts.tracking._publishTrackedSelection(id, origin);
return true;
},
/**
* Stop tracking the currently followed aircraft (no-op if none).
* @returns {boolean} Always true.
*/
stopTracking({ origin = 'programmatic' } = {}) {
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._clearTracking(false, { origin });
return true;
},
cancelPendingTrackingRestore() {
parts.tracking._cancelPendingTrackingRestore();
},
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
if (!flightState._trackedIcao) return null;
const described = _describeFlight(flightState._trackedIcao);
if (!described) return null;
const { position, ...rest } = described;
return rest;
},
/**
* Return the current aircraft as a Context subject without changing
* tracking or camera ownership.
* @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null}
* Detached subject descriptor, or null when no aircraft is tracked.
*/
getTrackedSubject() {
if (!flightState._trackedIcao) return null;
const described = _describeFlight(flightState._trackedIcao);
if (!described?.position) return null;
return {
layerId: 'military',
id: described.icao24,
// Same label chain as getNearby/getDetectableObjects: a callsign-less
// contact reads as its registration, never as the raw ICAO hex.
label: described.callsign || described.registration || described.icao24,
position: Cesium.Cartesian3.clone(described.position),
};
},
/**
* Return current layer health/status for the HUD status chip.
* @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}}
*/
getStats() {
const retryInSec = flightState._retryAt
? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000))
: 0;
return {
count: flightState._count,
lastUpdate: flightState._lastUpdate,
stale: flightState._backoff,
error: flightState._lastError,
status: flightState._lastStatus,
retryInSec,
source: flightState._lastSource,
fallback: false,
};
},
};
return {
_toFiniteNumber,
_toCleanText,
_formatAltitude,
_likelyLanded,
_describeFlight,
_normalizeTrackedIcao,
mapAnalystRecord,
methods,
};
}
File diff suppressed because it is too large. Load diff
+400
View File
@@ -0,0 +1,400 @@
import * as Cesium from 'cesium';
export function createFlightState({ source, services }) {
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._specCache = new Map();
/** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */
flightState._groundSnap = createGroundSnap();
flightState._scratchGroundCarto = new Cesium.Cartographic();
flightState._scratchGroundPos = new Cesium.Cartesian3();
/** @type {Cesium.PrimitiveCollection|null} */
flightState._modelCollection = null;
/** @type {Map<string, Cesium.Model>} icao24 → model */
flightState._models = new Map();
/** @type {Set<string>} icao24 currently loading (async) */
flightState._modelPending = new Set();
/** @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._modelGen = new Map();
/** 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._modelEpoch = 0;
/** 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._models3dEnabled = true;
flightState._models3dMode = 'proximity';
// 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL)
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._planeModelLoaded = false;
/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */
flightState._preloadModel = null;
flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
flightState._scratchModelMtx = new Cesium.Matrix4();
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._billboardLimbScale = new WeakMap();
// ---------------------------------------------------------------------------
// Module-level state
// ---------------------------------------------------------------------------
/** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */
flightState._billboardCollection = null;
/** @type {Map<string, Cesium.Billboard>} ICAO hex -> billboard primitive */
flightState._billboards = new Map();
/** Stable lightweight records reused by the detection overlay between polls. */
flightState._detectionObjects = new Map();
/** @type {Map<string, Object>} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */
flightState._flightData = new Map();
/** @type {Map<string, Array<{time: Cesium.JulianDate, position: Cesium.Cartesian3}>>} ICAO hex -> recent position samples for dead reckoning */
flightState._positionHistory = new Map();
/** @type {number} Current number of visible aircraft */
flightState._count = 0;
/** @type {number|null} Epoch ms of last successful API update */
flightState._lastUpdate = null;
/** @type {boolean} True when in error-backoff mode */
flightState._backoff = false;
/** @type {number} Epoch ms after which the next retry is allowed */
flightState._retryAt = 0;
/** @type {string|null} Human-readable description of the last error */
flightState._lastError = null;
flightState._activeUpdateControllers = new Set();
/** @type {number|null} HTTP status code from the last API response */
flightState._lastStatus = null;
flightState._lastSource = source?.label || 'Aircraft';
/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */
flightState._geoidReady = false;
/** @type {Map<string, number>} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */
flightState._geoidNCache = new Map();
// -- Click-to-track state --
/** @type {string|null} ICAO hex of the currently tracked aircraft */
flightState._trackedIcao = null;
flightState._pendingTrackingRestore = null;
flightState._trackingIntentGeneration = 0;
flightState._trackingRefreshEpoch = 0;
flightState._lastTrackingRefreshOutcome = {
epoch: 0,
status: 'unavailable',
ids: new Set(),
source: flightState._lastSource,
};
/** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */
flightState._trackedEntity = null;
/** Disposes the single active tracked-camera framing owner. */
flightState._trackedCameraFrameStop = 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). */
flightState._trackedModel = null;
flightState._trackedModelGen = 0;
flightState._trackedModelLoading = false;
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */
flightState._clickHandler = null;
/** @type {Cesium.Viewer|null} Cached viewer reference */
flightState._viewer = null;
/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */
flightState._cockpitContactMode = false;
/** AIR contacts inside the selected Display range; independent from model admission/load/cap. */
flightState._cockpitNearContacts = new Set();
/** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */
flightState._cockpitSubjectId = null;
/** @type {((event: CustomEvent) => void)|null} */
flightState._cockpitModeListener = null;
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */
flightState._trail = null;
/** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */
flightState._trailHeadEntity = null;
/** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */
flightState._trailHeadSeq = 0;
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
flightState._trailPositions = [];
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
flightState._trailBackfillToken = 0;
/** @type {Map<string, number>} icao24 -> consecutive missed polls */
flightState._missingPolls = new Map();
/** @type {number} Epoch ms of the last fleet dead-reckoning pass */
flightState._lastFleetTickMs = 0;
/** @type {string} Camera pose signature at the last rotation pass */
flightState._lastCamPoseSig = '';
/** @type {number} Epoch ms of the last full rotation pass */
flightState._lastRotPassMs = 0;
/** @type {number} Last computed rotation for the tracked entity (radians) */
flightState._lastTrackedRotation = 0;
/** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */
flightState._preRenderRemove = null;
flightState._trackedModelPreUpdateRemove = null;
/** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */
flightState._moveEndRemove = null;
/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */
flightState._trackedEntityChangedRemove = null;
// -- Scratch variables (reused each frame to avoid GC pressure) --
flightState._scratchOffset = new Cesium.Cartesian3();
flightState._scratchCarto = new Cesium.Cartographic();
flightState._scratchEnu = new Cesium.Matrix4();
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
flightState._scratchRenderTime = new Cesium.JulianDate();
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
flightState._trackedPosHolder = new Cesium.Cartesian3();
// -- Per-frame dead-reckoning cache for the tracked entity --
/** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */
flightState._cachedDRPosition = null;
/** @type {number} Frame number the cache was last populated for */
flightState._cachedDRFrame = -1;
/** 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._drCourseDeg = 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._drSpeedMps = null;
flightState._drCourseHold = false;
/** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */
flightState._cachedDRCourse = null;
/** Frame-cached siblings of _cachedDRCourse (same discipline). */
flightState._cachedDRSpeedMps = null;
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;
}
+165
View File
@@ -0,0 +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,
}) {
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. */
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,
};
}
File diff suppressed because it is too large. Load diff
+13
View File
@@ -165,3 +165,16 @@ export function normalizeAircraftTrack(
];
});
}
/** Known source identities are useful for classification even without positions. */
export function readsbIdentities(payload) {
const admitted = admitRecords(
payload?.ac,
(row) => {
const id = cleanText(row?.hex).toLowerCase();
return id ? { id } : null;
},
'aircraft identities',
);
return admitted.records.map((record) => record.id);
}
+42
View File
@@ -271,3 +271,45 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
assert.equal(snapshot.records[0].positionTimeMs, null);
assert.equal(snapshot.records[0].contactTimeMs, null);
});
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({
fetchImpl: async () =>
response({
ac: [
{ hex: ' ABC123 ' },
{ hex: 'abc123' },
{ hex: 'def456', lat: 30, lon: -97 },
],
}),
});
assert.deepEqual(await source.getIdentities(), ['abc123', 'def456']);
const snapshot = await source.getSnapshot();
assert.deepEqual(
snapshot.records.map((record) => record.id),
['def456'],
);
assert.equal(snapshot.complete, false);
});
test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({
fetchImpl: async () => response({ ac: [{}] }),
});
await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController();
const source = createAdsbLolSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Headers(),
async json() {
abort.abort();
return { ac: [{ hex: 'abc123' }] };
},
}),
});
await assert.rejects(source.getIdentities({}, { signal: abort.signal }), {
name: 'AbortError',
});
});
+1
View File
@@ -5,6 +5,7 @@ export {
normalizeAircraftTrack,
openSkySnapshot,
readsbSnapshot,
readsbIdentities,
} from './aircraft.js';
export {
normalizeVesselObservation,
+11
View File
@@ -9,6 +9,7 @@ import {
normalizeAircraftTrack,
openSkySnapshot,
readsbSnapshot,
readsbIdentities,
} from './aircraft.js';
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
@@ -113,6 +114,16 @@ export function createAdsbLolSource({
} = {}) {
return {
label: 'adsb.lol',
async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
{ signal },
'adsb.lol',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
return readsbIdentities(payload);
},
async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
+8 -2
View File
@@ -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 (basename(path) !== 'flights.js') return readFileSync(path, 'utf8');
const directory = join(dirname(path), '../layers/flights');
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()