Extract military-flight components and shared aircraft mechanics

This commit is contained in:
Sameh Khamis committed 2026-09-13 10:23:13 -07:00
1 parent b5e546daf1
commit cb790ddea1
25 files changed
+4849 -3930

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.
- 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.
+19
View File
@@ -1,5 +1,24 @@
# 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 normalized records from the supplied source. 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
+9 -118
View File
@@ -1,119 +1,10 @@
/**
* 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);
+143
View File
@@ -0,0 +1,143 @@
/** 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 snapshot = await requestSource.getSnapshot({}, { signal });
signal.throwIfAborted();
if (lifetime !== owner) return;
registerMilitaryIcaos(snapshot.records.map((record) => record.id));
} catch {
// Keep the existing classification through source outages.
} finally {
if (lifetime === owner) _polling = false;
}
}
function dispose() {
releaseSource?.();
_milIcaos.clear();
_lastRefreshMs = 0;
_activeChangeListeners.clear();
_militaryLayerActive = false;
}
if (source) configureSource(source);
return { configureSource, dispose, isMilitaryLayerActive, setMilitaryLayerActive,
onMilitaryLayerActiveChange, registerMilitaryIcaos, isMilitaryIcao, refreshMilitaryRegistryIfStale };
}
@@ -0,0 +1,93 @@
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);
});
+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';
+11
View File
@@ -0,0 +1,11 @@
export function createController({ flightState, services, parts, layer, resolveAsset }) {
function _abortActiveUpdates() {
for (const controller of flightState._activeUpdateControllers) controller.abort();
flightState._activeUpdateControllers.clear();
}
return { _abortActiveUpdates };
}
+28
View File
@@ -0,0 +1,28 @@
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';
+439
View File
@@ -0,0 +1,439 @@
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 };
}
+249
View File
@@ -0,0 +1,249 @@
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 };
}
+388
View File
@@ -0,0 +1,388 @@
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 };
}
+62
View File
@@ -0,0 +1,62 @@
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);
});
+291
View File
@@ -0,0 +1,291 @@
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;
+676
View File
@@ -0,0 +1,676 @@
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 };
}
+756
View File
@@ -0,0 +1,756 @@
import { CLASS_MODEL_REAL, CLASS_SCALE_3D, CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { visualCenterForModel, trailAnchorForModel } from '../../data/modelVisualAnchor.js';
import * as Cesium from 'cesium';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import { cameraPoseSignature, horizonOccluder, screenProjectedRotation } from '../../data/iconOrientation.js';
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import { PLANE_MODEL_SCALE, PLANE_MODEL_URL, PLANE_NATIVE_RADIUS_M, PLANE_BELLY_OFFSET_NATIVE, MODEL_SCALE, JET_MODEL_URL, MODEL_NATIVE_RADIUS_M, MODEL_BELLY_OFFSET_NATIVE, MODEL_HEADING_OFFSET_DEG, BILLBOARD_SCALE, GROUND_SCALE, COCKPIT_CONTACT_SIZE_PX, MIL_ICON_COLOR, TRACKED_ICON_COLOR, MODEL_ALT_CEIL_M, MODEL_MAX_ALL, MODEL_MAX, COCKPIT_MODEL_MAX, MODEL_ALL_ADD_M, MODEL_PROX_ADD_M, MODEL_ALL_KEEP_M, MODEL_PROX_KEEP_M, IR_RELOAD_BATCH, MODEL_MIN_PX, MODEL_COLOR_BLEND_AMOUNT, TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MAX_PX, FLEET_DR_INTERVAL_MS, COURSE_SLEW_DT_MAX_SEC, ROTATION_REFRESH_MS, COURSE_MAX_DPS } from './policy.js';
export function createRendering({ flightState, services, parts, layer, resolveAsset }) {
const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
const { trackedModelScaleForPixelCap } = services.camera;
const { focusNowMs, getFocusTarget, nearFarScalarValueAtDistance, advanceProjectedSpriteFocus } = services.focus;
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = services.recession;
/** Depth-test policy for aircraft billboards (mirror of flights.js — see the
* full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact
* renders depth-test-free at every distance — a uniform always-visible rule;
* the fleet tick's horizon occluder still removes far-side contacts. */
function _groundDepthDistance() {
return Number.POSITIVE_INFINITY;
}
function _modelSpec(klass) {
let spec = flightState._specCache.get(klass);
if (spec) return spec;
const real = CLASS_MODEL_REAL[klass];
if (real) {
spec = { url: real.url, scale: 1, nativeRadiusM: real.radiusM, bellyM: real.bellyM, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(real.url), trailAnchorNative: trailAnchorForModel(real.url) };
} else if (klass === 'airliner' || klass === 'quadjet' || klass === 'glider') {
const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
spec = { url: PLANE_MODEL_URL, scale, nativeRadiusM: PLANE_NATIVE_RADIUS_M, bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: 180, visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL) };
} else {
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
spec = { url: JET_MODEL_URL, scale, nativeRadiusM: MODEL_NATIVE_RADIUS_M, bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, visualCenterNative: visualCenterForModel(JET_MODEL_URL), trailAnchorNative: trailAnchorForModel(JET_MODEL_URL) };
}
flightState._specCache.set(klass, spec);
return spec;
}
function _normalBillboardScaleByDistance() {
// Match commercial flights and preserve the established close-range 3×
// default. Smaller owner-visible scaling must be proposed separately.
return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5);
}
function _cockpitBillboardScaleByDistance() {
return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65);
}
function _militaryBillboardScale(icao24) {
const meta = flightState._flightData.get(icao24);
return BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1);
}
/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */
function _applyFleetBillboardPresentation(icao24, bb) {
if (!bb) return;
const limbScale = flightState._billboardLimbScale.get(bb) ?? 1;
const isCockpitContact = flightState._cockpitContactMode && icao24 !== flightState._trackedIcao;
const isCockpitNear = isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = bb.color?.alpha ?? 1;
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
const meta = flightState._flightData.get(icao24);
bb.image = aircraftIcon(_iconKind(icao24, meta?.klass), bb._gevIconLarge ? TRACKED_ICON_PX : undefined);
bb.width = icao24 === flightState._trackedIcao ? 24 : 20;
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1);
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
* the ones the operator converted into a TR-3B (Easter egg), which draw the
* black-triangle glyph — its thermal-reactive variant while an IR style owns
* the scene. Routing EVERY `aircraftIcon()` call through this is what makes a
* conversion survive the poll reconciler and the two-tier raster swap. */
const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
return MIL_ICON_COLOR;
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
* view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms
* `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED
* contact does not route through here: it is one model, it is what the camera is aimed at,
* and it takes its own default-on, hysteretic zoom regime
* (`_trackedModelRegimeActive`). Mirror of flights.js. */
function _modelRegimeActive() {
if (!flightState._models3dEnabled) return false;
const h = flightState._viewer?.camera?.positionCartographic?.height ?? Infinity;
return h < MODEL_ALT_CEIL_M;
}
/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the
* SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the
* lower MODEL_MAX, silently degrading 'all' to 'proximity'. */
function _modelCap() {
const mapCap = flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX;
// `Math.min` on purpose: cockpit may only ever LOWER the GLB budget.
return flightState._cockpitContactMode ? Math.min(COCKPIT_MODEL_MAX, mapCap) : mapCap;
}
/** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */
function _modelAddDistM() {
return flightState._models3dMode === 'all' ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
}
function _modelKeepDistM() {
return flightState._models3dMode === 'all' ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
}
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
* `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable
* matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on
* the last-written transform, and the per-frame tracked write made it flicker). */
function _modelMatrix(pos, headingDeg, result = flightState._scratchModelMtx, offsetDeg = MODEL_HEADING_OFFSET_DEG) {
flightState._scratchModelHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg);
flightState._scratchModelHpr.pitch = 0;
flightState._scratchModelHpr.roll = 0;
return Cesium.Transforms.headingPitchRollToFixedFrame(
pos, flightState._scratchModelHpr, Cesium.Ellipsoid.WGS84, undefined, result,
);
}
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of
* flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its
* (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches
* picked-object IDs — every billboard AND model here carries its icao as `id` — plus
* the tracked entity object itself. Built lazily, only when a sample actually fires. */
function _groundSampleExclusions() {
const out = [...flightState._billboards.keys()];
if (flightState._trackedEntity) out.push(flightState._trackedEntity);
return out;
}
/** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there).
* Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin
* (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly
* offset so they sit on their
* gear; airborne planes pass through. Until the FIRST sample lands, callers keep the
* depth-test-free billboard visible and the model hidden while this returns null; a
* contact that has already resolved once holds that measurement through a later
* outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */
function _modelDisplayPosition(icao24, pos, result) {
const meta = flightState._flightData.get(icao24);
if (!meta || !meta.onGround) return pos;
const h = flightState._groundSnap.heightFor(flightState._viewer, icao24, pos, _groundSampleExclusions);
if (h == null) return null;
const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto);
carto.height = h + _modelSpec(meta.klass).bellyM;
return Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height, Cesium.Ellipsoid.WGS84, result);
}
/** Atomically hand one fleet contact from its billboard to a safely placed,
* ready 3D model. Missing/loading models and unresolved terrain always leave
* the billboard owning the visual, so no render frame can hide both.
* `beforeShow` runs only on the committing path — the per-tick model treatment
* belongs to a model that is about to draw, not to one still waiting. */
function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) {
if (!model) {
bb.show = true;
return false;
}
const displayPos = _modelDisplayPosition(icao24, pos, flightState._scratchGroundPos);
if (!displayPos) {
model.show = false; // no ground evidence → nothing safe to place a depth-tested model at
bb.show = true;
return false;
}
// The matrix is written BEFORE the readiness test on purpose: a model can flip
// `ready` during scene update after this tick, and a first rendered frame on a
// stale load-start matrix is the one-frame jump this ordering prevents.
_modelMatrix(
displayPos,
course,
model.modelMatrix,
_modelSpec(flightState._flightData.get(icao24)?.klass).headingOffsetDeg,
);
if (!model.ready) {
model.show = false; // not loaded yet → keep the 2D icon, no half-model flash
bb.show = true;
return false;
}
beforeShow?.();
if (!model.show) model.show = true;
if (bb.show) bb.show = false; // hand off ONLY once the model renders
return true;
}
/** A model owns the visual only while it is ready, shown, and (for the tracked
* primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */
function _modelOwnsVisual(icao24) {
if (icao24 === flightState._trackedIcao) {
return parts.tracking._trackedModelRegimeActive() && _modelIsRendering(flightState._trackedModel);
}
return _modelIsRendering(flightState._models.get(icao24));
}
/** `show` is sufficient evidence of a safe placement because only the handoff and
* the tracked driver ever set it true, and both do so after committing a matrix;
* everything else — admission, unresolved ground, an unready glTF, the limb cull,
* a regime exit — only ever clears it. Mirror of flights.js. */
function _modelIsRendering(model) {
return !!model && model.ready === true && model.show === true;
}
/** Spec identity for a LOADED model — mirror of flights.js: URL and scale
* together (same-URL classes differ by scale). */
const _specKeyFor = (klass) => {
const spec = _modelSpec(klass);
return `${spec.url}@${spec.scale}`;
};
/** Class-change model sync — mirror of flights.js: drop a live model or
* in-flight load whose spec no longer matches the class, re-showing the
* fleet billboard FIRST (gap-proof) and covering the tracked standalone. */
function _syncModelToClass(icao24) {
const key = _specKeyFor(flightState._flightData.get(icao24)?.klass);
const current = flightState._models.get(icao24);
if ((current && current._gevSpecKey !== key) || (!current && flightState._modelPending.has(icao24))) {
const bb = flightState._billboards.get(icao24);
if (bb && icao24 !== flightState._trackedIcao) bb.show = true;
_releaseModel(icao24);
}
if (icao24 === flightState._trackedIcao && flightState._trackedModel && flightState._trackedModel._gevSpecKey !== key) {
_releaseTrackedModel();
}
}
function _reloadModelsForIrBoost() {
flightState._irReloadQueue = [...flightState._models.keys()];
for (const icao of flightState._modelPending) {
if (!flightState._models.has(icao)) flightState._modelGen.set(icao, (flightState._modelGen.get(icao) || 0) + 1);
}
_releaseTrackedModel();
}
function _drainIrReloadQueue() {
if (!flightState._irReloadQueue) return;
const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH);
for (const icao of batch) {
const model = flightState._models.get(icao);
if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state
const bb = flightState._billboards.get(icao);
if (bb && icao !== flightState._trackedIcao) bb.show = true;
_releaseModel(icao);
}
if (flightState._irReloadQueue.length === 0) flightState._irReloadQueue = null;
}
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
async function _ensureModel(icao24) {
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
// tick skips it, so a model here would be orphaned + double-rendered.
if (icao24 === flightState._trackedIcao) return;
if (flightState._models.has(icao24) || flightState._modelPending.has(icao24)) return;
// Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads
// (the cap is rechecked post-await too, before the add).
if ((flightState._models.size + flightState._modelPending.size) >= _modelCap()) return;
const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead
const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale
flightState._modelPending.add(icao24);
let model = null;
// Spec identity captured at load START (mirror of flights.js): a mid-load
// reclassification makes the post-await admission reject the stale asset.
// Boost state likewise — creation options bake it in.
const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass);
const loadIrBoost = flightState._irBoost;
try {
const spec = _modelSpec(flightState._flightData.get(icao24)?.klass);
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
colorBlendMode: Cesium.ColorBlendMode.MIX,
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined,
id: icao24, // so scene.pick returns the icao for click-to-track
});
} catch {
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
// (a destroy/re-init may have swapped the globals while this load was in flight).
if (epoch === flightState._modelEpoch) { flightState._modelPending.delete(icao24); _cleanupModelGen(icao24); }
return;
}
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
if (epoch !== flightState._modelEpoch) { try { model.destroy(); } catch { /* gone */ } return; }
flightState._modelPending.delete(icao24);
// Post-await admission: reject (and DESTROY the loaded model) if anything changed during the
// load — a release bumped the generation (track/untrack/remove), the layer toggled off / was
// torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled.
// Recheck the shared Display 3D toggle and altitude ceiling after the async
// load. Cockpit uses the same OFF / Proximity / All contract as map Display.
const stale = (flightState._modelGen.get(icao24) || 0) !== gen
|| !_modelRegimeActive() || !flightState._modelCollection || flightState._modelCollection.isDestroyed()
|| !flightState._flightData.has(icao24) || icao24 === flightState._trackedIcao
|| flightState._models.has(icao24) || flightState._models.size >= _modelCap()
// Class reclassified mid-load → this GLB/scale is for the OLD class.
|| _specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|| flightState._irBoost !== loadIrBoost;
if (stale) {
try { model.destroy(); } catch { /* already gone */ }
_cleanupModelGen(icao24); // bound the map
return;
}
// Keep the pick identity explicit on the resolved primitive. This also
// protects injected/custom loaders that do not copy the creation option.
model.id = icao24;
model._gevSpecKey = specKey; // class-change sync compares against this
model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this
// Admitted, not yet the visual. Cesium's default is show=true, which would let
// an unplaced primitive claim ownership from the billboard for the frames
// between admission and the next fleet tick (and draw at the identity matrix,
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
model.show = false;
flightState._modelCollection.add(model);
flightState._models.set(icao24, model);
flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
}
/** Remove the 3D model for ONE aircraft (removal / track handoff).
* Bumps the load generation so any in-flight load for this icao is rejected on completion. */
function _releaseModel(icao24) {
const m = flightState._models.get(icao24);
const pending = flightState._modelPending.has(icao24);
// Only bump the generation when there's something to invalidate (an in-flight load or a
// live model) — so removing never-modeled aircraft doesn't grow _modelGen.
if (m || pending) {
flightState._modelGen.set(icao24, (flightState._modelGen.get(icao24) || 0) + 1);
}
if (m) {
if (flightState._modelCollection && !flightState._modelCollection.isDestroyed()) { try { flightState._modelCollection.remove(m); } catch { /* gone */ } }
flightState._models.delete(icao24);
}
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
// the resolving load's gen-check still rejects it.
_cleanupModelGen(icao24);
}
/** Drop an icao's generation entry once nothing references it (no live model, no in-flight
* load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */
function _cleanupModelGen(icao24) {
if (!flightState._modelPending.has(icao24) && !flightState._models.has(icao24)) flightState._modelGen.delete(icao24);
}
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
function _releaseModels() {
flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
// Invalidate in-flight loads so a completion after this bulk release can't add a model.
for (const icao of flightState._modelPending) flightState._modelGen.set(icao, (flightState._modelGen.get(icao) || 0) + 1);
if (flightState._modelCollection && !flightState._modelCollection.isDestroyed()) {
for (const m of flightState._models.values()) { try { flightState._modelCollection.remove(m); } catch { /* gone */ } }
}
flightState._models.clear();
}
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
function _releaseTrackedModel() {
flightState._trackedModelGen++;
flightState._trackedModelLoading = false;
if (flightState._trackedModel) {
if (flightState._modelCollection && !flightState._modelCollection.isDestroyed()) { try { flightState._modelCollection.remove(flightState._trackedModel); } catch { /* gone */ } }
flightState._trackedModel = null;
}
}
/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The
* tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */
function _updateTrackedModel() {
const active = flightState._trackedIcao && parts.tracking._trackedModelRegimeActive()
&& flightState._modelCollection && !flightState._modelCollection.isDestroyed();
if (!active) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; }
// Ask the frame-cached source directly. If the entity callback already ran,
// this is a no-op; if model loading completed between phases, it establishes
// this frame's single sample before the model renders. Camera, detection, and
// readout consumers then reuse that exact cached position.
const pos = parts.motion._trackedDisplayPosition(flightState._trackedIcao) || flightState._billboards.get(flightState._trackedIcao)?.position;
if (!pos) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; }
if (!flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed()) {
flightState._trackedModelLoading = true;
const gen = flightState._trackedModelGen;
const trackedSpec = _modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass);
const trackedKey = _specKeyFor(flightState._flightData.get(flightState._trackedIcao)?.klass);
const trackedIrBoost = flightState._irBoost;
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
colorBlendMode: Cesium.ColorBlendMode.MIX,
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined,
// Pick id (H1): without it, clicking the very plane being tracked read as
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
// deselect. With the icao, the click handler recognizes it as ours.
id: flightState._trackedIcao,
}).then((m) => {
if (gen !== flightState._trackedModelGen || !flightState._modelCollection || flightState._modelCollection.isDestroyed()) { try { m.destroy(); } catch { /* gone */ } return; }
// Class reclassified OR boost flipped mid-load (no release ran —
// _trackedModel was still null): drop the stale asset; driver reloads.
if (_specKeyFor(flightState._flightData.get(flightState._trackedIcao)?.klass) !== trackedKey || flightState._irBoost !== trackedIrBoost) {
try { m.destroy(); } catch { /* gone */ }
flightState._trackedModelLoading = false;
return;
}
// Assign after resolution as well as in the creation options so the
// standalone primitive always exposes the tracked aircraft pick id.
m.id = flightState._trackedIcao;
m._gevSpecKey = trackedKey; // class-change sync compares against this
m.show = false; // admitted, not yet the visual — the driver shows it once placed
// Seed the world transform before the primitive enters the scene. A model
// can become ready+shown between render phases; leaving Cesium's identity
// default here produces a one-frame jump to the Earth's center.
const currentPos = parts.motion._trackedDisplayCached()
|| flightState._billboards.get(flightState._trackedIcao)?.position;
if (currentPos) {
const displayPos = _modelDisplayPosition(flightState._trackedIcao, currentPos, flightState._scratchGroundPos);
if (displayPos) {
_modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), m.modelMatrix, trackedSpec.headingOffsetDeg);
}
}
flightState._trackedModel = m;
flightState._trackedModelLoading = false;
// A good load retires this selection's failure budget — a contact that
// recovers after a transient blip is not one attempt from giving up.
flightState._trackedModelFailIcao = null;
flightState._trackedModelFailCount = 0;
flightState._trackedModelRetryAtMs = 0;
flightState._modelCollection.add(m);
flightState._planeModelLoaded = true;
}).catch((err) => {
if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure
flightState._trackedModelLoading = false;
parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err);
});
return;
}
if (flightState._trackedModel) {
// Keep the transform current while GPU resources are still loading. Cesium
// can flip ready during scene update after this callback; waiting for ready
// here would let that first rendered frame use a stale load-start matrix.
const displayPos = _modelDisplayPosition(flightState._trackedIcao, pos, flightState._scratchGroundPos);
if (!displayPos) {
flightState._trackedModel.show = false; // no ground evidence → the billboard carries it
return;
}
const spec = _modelSpec(flightState._flightData.get(flightState._trackedIcao)?.klass);
_modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), flightState._trackedModel.modelMatrix, spec.headingOffsetDeg);
if (!flightState._trackedModel.ready) return;
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
nativeRadiusM: spec.nativeRadiusM,
rangeM: Cesium.Cartesian3.distance(flightState._viewer.camera.positionWC, displayPos),
viewportHeightPx: flightState._viewer.scene.canvas.clientHeight,
fovyRad: flightState._viewer.camera.frustum.fovy,
maximumPixelSize: TRACKED_MODEL_MAX_PX,
});
flightState._trackedModel.show = true;
}
}
/**
* Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard,
* horizon-culls billboards beyond the limb (no far-side depth with the globe
* hidden), and refreshes screen-projected icon rotations whenever the camera
* pose changed (plus a 1s drift catch-up while idle). Driven by
* scene.preRender — NOT camera.changed, whose granularity is globally
* degraded by other layers mutating camera.percentageChanged.
* @returns {void}
*/
function _fleetTick() {
if (!flightState._viewer || !flightState._billboardCollection || !flightState._billboardCollection.show) return;
const scene = flightState._viewer.scene;
const camera = flightState._viewer.camera;
const nowMs = focusNowMs(Date.now());
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
// primitive rebuild here. The body rebuilds only when a real fix arrives.)
if ((nowMs - flightState._lastFleetTickMs) < FLEET_DR_INTERVAL_MS) return;
const tickDtSec = flightState._lastFleetTickMs
? Math.min(COURSE_SLEW_DT_MAX_SEC, (nowMs - flightState._lastFleetTickMs) / 1000)
: 0.08;
flightState._lastFleetTickMs = nowMs;
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (flightState._cockpitContactMode) parts.tracking._refreshCockpitNearContacts();
const poseSig = cameraPoseSignature(camera);
// Only nearby Cockpit silhouettes need projected course; far dots remain
// rotation-free through the per-contact gate below.
const doRotations = (poseSig !== flightState._lastCamPoseSig || (nowMs - flightState._lastRotPassMs) >= ROTATION_REFRESH_MS);
if (doRotations) {
flightState._lastCamPoseSig = poseSig;
flightState._lastRotPassMs = nowMs;
}
const occluder = horizonOccluder(camera);
const focusTarget = getFocusTarget();
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
// Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume.
const useModels = _modelRegimeActive();
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
if (!useModels && (flightState._models.size || flightState._modelPending.size)) _releaseModels();
// 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap —
// mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen
// new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP
// is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding).
let modelEligible = null;
if (useModels) {
const cap = _modelCap();
const camPos = camera.positionWC;
const addM = _modelAddDistM();
const addDistSq = addM * addM;
const keepM = _modelKeepDistM();
const keepDistSq = keepM * keepM;
const cull = camera.frustum.computeCullingVolume(camPos, camera.directionWC, camera.upWC);
const cand = [];
for (const [icao, bb] of flightState._billboards) {
if (icao === flightState._trackedIcao) continue;
// A converted TR-3B renders as a billboard and can never take a model, so
// it must not occupy a CAP SLOT either (mirror of flights.js) — excluded
// at selection time, not just at the handoff below.
if (isTr3b(icao)) continue;
// Ground planes compete for model slots like everyone else (owner decision
// 2026-07-03, mirror of flights.js — no air/ground distinction; grounded
// placement is handled by the one-shot ground snap in _modelDisplayPosition).
const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position);
if (d2 > keepDistSq) continue; // beyond keep radius → never eligible
Cesium.Cartesian3.clone(bb.position, flightState._scratchModelBS.center);
cand.push([icao, d2, cull.computeVisibility(flightState._scratchModelBS) !== Cesium.Intersect.OUTSIDE]);
}
cand.sort((a, b) => a[1] - b[1]);
if (cand.length > cap && (nowMs - flightState._lastModelCapWarnMs) > 5000) {
console.warn(`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen prioritized.`);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = new Set();
for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (inF && flightState._models.has(icao)) modelEligible.add(icao); } // 1. KEEP on-screen
for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 2. ADD on-screen
for (const [icao, , inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && flightState._models.has(icao)) modelEligible.add(icao); } // 3. KEEP off-screen (can't starve visible)
for (const [icao, d2, inF] of cand) { if (modelEligible.size >= cap) break; if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) modelEligible.add(icao); } // 4. ADD off-screen leftover
const toRelease = [];
for (const icao of flightState._models.keys()) {
if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) toRelease.push(icao);
}
for (const icao of toRelease) _releaseModel(icao);
}
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
// Gate the write — assigning Billboard.position dirties the whole
// collection's vertex buffer, so skip sub-meter moves.
if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) {
bb.position = dr;
}
// Round 6: occlusion-test a LIFTED point for contacts at/below the
// ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb
// read "beyond the horizon" and would hide low contacts awaiting floors).
const beyondHorizon = !occluder.isPointVisible(flightState._flightData.get(icao24)?.cullPosition || bb.position);
// A billboard flipping INTO view (horizon reveal while the camera idles)
// gets its rotation refreshed THIS tick even without a pose change —
// otherwise it reappears wearing its stale (often creation-north) nose for
// up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false
// here; harmless — the model branch below `continue`s past the rotation.)
const revealed = !beyondHorizon && !bb.show;
if (bb.show === beyondHorizon) bb.show = !beyondHorizon;
if (beyondHorizon) {
// Also hide any 3D model — otherwise a model that crossed the limb would keep
// rendering through the hidden globe at its last matrix.
const m = flightState._models.get(icao24);
if (m && m.show) m.show = false;
continue;
}
const info = flightState._flightData.get(icao24);
// One sprite-owned write site composes freshness × focus × limb haze and
// base class/ground scale × limb taper. The locked NearFarScalar remains
// untouched and multiplicative; there is no cull or zero-alpha path.
const cameraDistanceM = Cesium.Cartesian3.distance(camera.positionWC, bb.position);
const distanceScale = nearFarScalarValueAtDistance(bb.scaleByDistance, cameraDistanceM);
const focus = advanceProjectedSpriteFocus(
bb,
bb.position,
scene,
camera,
nowMs,
focusTarget,
undefined,
(bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5,
(bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5,
);
const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24);
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale: flightState._cockpitContactMode && !isCockpitNear ? 1 : _militaryBillboardScale(icao24),
baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1,
baseColor: MIL_ICON_COLOR,
focusFactor: focus.factor,
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
});
flightState._billboardLimbScale.set(bb, treatment.factors.scale);
// Two-tier glyph raster — mirror of flights.js: swap 64/192 px rasters on
// the billboard's ACTUAL on-screen size (post-treatment bb.scale, so
// focus/limb recession counts) with hysteresis (atlas has no mips).
if (!flightState._cockpitContactMode || isCockpitNear) {
const glyphDevPx = (bb.width || 20) * (bb.scale || 1)
* distanceScale * (globalThis.devicePixelRatio || 1);
const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76;
if (wantLarge !== !!bb._gevIconLarge) {
bb._gevIconLarge = wantLarge;
bb.image = aircraftIcon(_iconKind(icao24, flightState._flightData.get(icao24)?.klass), wantLarge ? TRACKED_ICON_PX : undefined);
}
}
// Smoothed display course: the path direction _deadReckon just reported
// for THIS aircraft (nothing else calls _deadReckon in between), rate-
// limited so segment-boundary course steps glide instead of snapping.
// The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering
// aircraft (hold flag) keeps its previous nose direction outright.
const rawCourse = flightState._drCourseDeg != null ? flightState._drCourseDeg : ((info && info.track) || 0);
const prevCourse = flightState._displayCourse.get(icao24);
const course = (flightState._drCourseHold && prevCourse != null)
? prevCourse
: limitCourseStep(
prevCourse, rawCourse,
courseSlewCapDps(flightState._drSpeedMps != null ? flightState._drSpeedMps : ((info && info.speedMps) ?? NaN), COURSE_MAX_DPS),
tickDtSec,
);
flightState._displayCourse.set(icao24, course);
// 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the
// billboard stays shown until the model is actually READY to render, so a plane is never both
// iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every
// tick regardless so it's framed the instant it becomes ready.
// Converted TR-3Bs stay 2D on purpose (mirror of flights.js): the Easter
// egg IS the triangle and there is no GLB for it, so the model handoff is
// suppressed. The billboard keeps rendering, so the contact still satisfies
// the getNearby/getDetectableObjects visibility guards.
if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) {
_ensureModel(icao24);
const model = flightState._models.get(icao24);
const ownsVisual = _driveFleetModelHandoff(
icao24,
model,
bb,
dr,
course,
() => {
applyAircraftModelTreatment({
model,
// IR boost must survive the per-tick treatment write; boosted
// models also skip the recession fade (mirror of flights.js —
// billboards keep their normal fade, hot MODELS stay full-strength).
baseColor: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
alpha: flightState._irBoost ? 1 : treatment.alpha,
});
},
);
if (ownsVisual) continue; // skip billboard rotation
}
if ((!flightState._cockpitContactMode || isCockpitNear) && (doRotations || revealed)) {
const rot = screenProjectedRotation(scene, bb.position, course, bb.rotation);
if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) {
bb.rotation = rot;
}
}
}
}
return { _groundDepthDistance, _modelSpec, _normalBillboardScaleByDistance, _cockpitBillboardScaleByDistance, _militaryBillboardScale, _applyFleetBillboardPresentation, _iconKind, _modelColor, _modelRegimeActive, _modelCap, _modelAddDistM, _modelKeepDistM, _modelMatrix, _groundSampleExclusions, _modelDisplayPosition, _driveFleetModelHandoff, _modelOwnsVisual, _modelIsRendering, _specKeyFor, _syncModelToClass, _reloadModelsForIrBoost, _drainIrReloadQueue, _ensureModel, _releaseModel, _cleanupModelGen, _releaseModels, _releaseTrackedModel, _updateTrackedModel, _fleetTick };
}
+443
View File
@@ -0,0 +1,443 @@
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;
}
+152
View File
@@ -0,0 +1,152 @@
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 };
}
+959
View File
@@ -0,0 +1,959 @@
import * as Cesium from 'cesium';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
import { screenProjectedRotation, stabilizeScreenRotation } from '../../data/iconOrientation.js';
import { trailHeadStart } from '../../data/modelVisualAnchor.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { bindTrackingClickGesture, isTrackingSelectionGesture, isTrackingClickGesture } from '../../data/trackingClickGesture.js';
import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, TRAIL_MAX_POINTS, RENDER_DELAY_SEC, TRAIL_COLOR, BILLBOARD_SCALE, GROUND_SCALE, MIL_ICON_COLOR, AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js';
export function createTracking({ flightState, services, parts, layer, resolveAsset }) {
const { selectTrackedSubjectContext, refreshTrackedSubjectContext, clearTrackedSubjectContext } = services.context;
const { tr3bTypeLabel, isTr3b, tr3bConvertedIds } = services.aircraftPresentation;
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = services.groundFloor;
const { createTrail } = services.trails;
const { ensureGeoidReady, geoidHeight } = services.geoid;
const { clearFocusTarget } = services.focus;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
function _emitAwarenessEvent(type, detail) {
if (typeof window === 'undefined' || !window.dispatchEvent || typeof CustomEvent === 'undefined') return;
window.dispatchEvent(new CustomEvent(type, { detail }));
}
function _publishTrackedSelection(icao24, origin = 'programmatic') {
const bb = flightState._billboards.get(icao24);
const info = flightState._flightData.get(icao24);
if (!bb?.position || !info) return false;
if (flightState._trackedEntity) flightState._trackedEntity.gevSelectionOrigin = origin;
_emitAwarenessEvent('gev:awareness-subject-selected', {
layerId: 'military',
id: icao24,
label: parts.queries._toCleanText(info.callsign) || parts.queries._toCleanText(info.registration) || icao24,
position: Cesium.Cartesian3.clone(bb.position),
origin,
});
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
return true;
}
/**
* Describe the selected contact for the shared context slot the voice tools
* and Cockpit read. Values are the LIVE descriptor, not a selection-time
* snapshot, so a long follow never narrates a position the plane has left.
* @param {string} icao24 Contact identity.
* @returns {object|null} Context metadata, or null when the contact is gone.
*/
function _contextSubjectMetadata(icao24) {
const described = parts.queries._describeFlight(icao24);
if (!described) return null;
const info = flightState._flightData.get(icao24);
const label = described.callsign || described.registration || icao24;
return {
id: icao24,
layerId: 'military',
layerName: 'Military Flights',
source: flightState._lastSource,
label,
latitude: described.latitude,
longitude: described.longitude,
// Flat text only: the voice payload compacts properties through a
// string cleaner that drops nested objects.
properties: {
name: label,
operator: parts.queries._toCleanText(info?.operator) || '',
callsign: described.callsign || '',
registration: described.registration || '',
// Converted contacts report their class as TR-3B — the same override
// the readout and Contacts card show.
type: tr3bTypeLabel(icao24, parts.queries._toCleanText(info?.type) || ''),
altitude: described.onGround ? 'on ground' : parts.queries._formatAltitude(info?.altitudeFt),
speed: Number.isFinite(described.velocityMps)
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track) ? `${Math.round(described.track)}°` : '',
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
}
function _isExplicitTrackingOrigin(origin) {
return origin === 'user' || origin === 'voice' || origin === 'tool';
}
/** Refresh the AIR-only Cockpit near band independently from model admission. */
function _refreshCockpitNearContacts() {
if (!flightState._cockpitContactMode || !flightState._viewer?.camera?.positionWC) {
if (flightState._cockpitNearContacts.size) flightState._cockpitNearContacts = new Set();
return;
}
const previous = flightState._cockpitNearContacts;
const distancesSquared = [];
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao || !bb?.position) continue;
distancesSquared.push([
icao24,
Cesium.Cartesian3.distanceSquared(flightState._viewer.camera.positionWC, bb.position),
]);
}
const next = nextCockpitNearContacts(
previous,
distancesSquared,
parts.rendering._modelAddDistM(),
parts.rendering._modelKeepDistM(),
);
flightState._cockpitNearContacts = next;
let presentationChanged = false;
for (const [icao24, bb] of flightState._billboards) {
if (previous.has(icao24) === next.has(icao24)) continue;
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
presentationChanged = true;
}
if (presentationChanged) flightState._lastCamPoseSig = '';
}
/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */
function _setCockpitContactMode(active) {
const next = active === true;
if (flightState._cockpitContactMode === next) return;
flightState._cockpitContactMode = next;
if (next) _refreshCockpitNearContacts();
else flightState._cockpitNearContacts = new Set();
// The collection stays visible in cockpit. Near AIR contacts retain their
// aircraft silhouette until a ready model takes over; far contacts are pips.
// Never bulk-destroy here: that stalls the renderer exactly as cockpit begins.
if (flightState._modelCollection) flightState._modelCollection.show = true;
flightState._trail?.setVisible(!next);
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
for (const [icao24, bb] of flightState._billboards) parts.rendering._applyFleetBillboardPresentation(icao24, bb);
flightState._lastCamPoseSig = '';
flightState._lastFleetTickMs = 0;
}
function _applyCockpitState(detail = {}) {
const active = detail?.active === true;
flightState._cockpitSubjectId = active
? String(detail?.subjectId || '').trim().toLowerCase() || null
: null;
_setCockpitContactMode(active);
}
/**
* Build the multi-line presentation text for the protected tracked host card.
* Lines: callsign, type/registration, operator/altitude.
* While the plane is in its missed-poll grace (coasting on dead reckoning
* with sticky metadata) the first line carries a "· STALE" cue — the fleet's
* 45%-alpha billboard fade doesn't apply to the tracked plane (its entity
* owns the visual), so without this the readout would present last-known
* altitude/speed as live.
* @param {Object|null} info - Flight metadata from _flightData
* @param {string} icao24 - ICAO hex identifier (fallback display name)
* @returns {string} Newline-separated label text
*/
function _buildTrackedLabel(info, icao24) {
const stale = (flightState._missingPolls.get(icao24) || flightState._backoff) ? ' · STALE' : '';
const callsign = (parts.queries._toCleanText(info?.callsign) || parts.queries._toCleanText(info?.registration) || icao24) + stale;
// Converted contacts report their class as TR-3B — that override is exactly
// what the Easter egg replaces the real type with.
const type = tr3bTypeLabel(icao24, parts.queries._toCleanText(info?.type) || 'Type unknown');
const registration = parts.queries._toCleanText(info?.registration) || 'Reg unknown';
const operator = parts.queries._toCleanText(info?.operator) || 'Operator unknown';
const altitude = parts.queries._formatAltitude(info?.altitudeFt);
const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null;
const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude;
return [
callsign,
`${type} · ${registration}`,
`${operator} · ${tail}`,
].join('\n');
}
/** Write the explicit tracked presentation model and refresh its host entry. */
function _updateTrackedLabelModel(icao24) {
if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) return;
flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText(
_buildTrackedLabel(flightState._flightData.get(icao24), icao24),
'#ffd166',
);
refreshTrackedReadout(flightState._trackedEntity);
// The readout and the context slot describe the same contact — refresh them
// together so voice never narrates a fix the card has already replaced.
refreshTrackedSubjectContext(_contextSubjectMetadata(icao24));
}
/** Re-image the tracked entity's billboard from the current class/conversion. */
function _syncTrackedBillboardImage() {
if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) return;
flightState._trackedEntity.billboard.image = aircraftIcon(
parts.rendering._iconKind(flightState._trackedIcao, flightState._flightData.get(flightState._trackedIcao)?.klass),
TRACKED_ICON_PX,
);
}
/**
* Re-render one contact after its TR-3B conversion (or the active IR style)
* changed. Mirror of flights.js: converting drops any 3D model so the triangle
* owns the visual, then the billboard, tracked entity, and card re-derive.
* @param {string} icao24 - ICAO 24-bit address.
* @returns {boolean} True when the layer owns this contact.
*/
function _refreshTr3bContact(icao24) {
const id = String(icao24 || '').trim().toLowerCase();
if (!id) return false;
if (isTr3b(id)) {
if (flightState._models.has(id) || flightState._modelPending.has(id)) parts.rendering._releaseModel(id);
const modelled = flightState._billboards.get(id);
if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick
if (id === flightState._trackedIcao) {
parts.rendering._releaseTrackedModel();
_syncTracked2dRotation();
}
}
const bb = flightState._billboards.get(id);
if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb);
if (id === flightState._trackedIcao) {
_syncTrackedBillboardImage();
_updateTrackedLabelModel(id);
}
flightState._viewer?.scene?.requestRender?.();
return flightState._billboards.has(id) || id === flightState._trackedIcao;
}
/** Re-image every converted contact this layer owns (IR style flip). */
function _refreshTr3bForStyle() {
for (const id of tr3bConvertedIds()) {
if (flightState._billboards.has(id) || id === flightState._trackedIcao) _refreshTr3bContact(id);
}
}
/** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and
* the load-failure bound. Called from each path that changes which contact is
* selected — deselect, re-track, cross-layer handoff, init, destroy.
*
* This must live in the production lifecycle, not only in the predicate's
* icao-change guard: a deselect followed by a same-turn re-track of the SAME
* icao (Contacts re-entry, a cross-layer round trip back to this layer) never
* makes `_trackedIcao` *observably* change, so the guard never fires. Without
* the reset, a contact dropped inside the hysteresis band comes back as a
* MODEL above the ENTER ceiling, and a contact whose GLB had already failed
* out would never get its retries back. Mirror of flights.js. */
function _resetTrackedSelectionState() {
flightState._trackedZoomLatched = false;
flightState._trackedZoomLatchIcao = null;
flightState._trackedModelFailIcao = null;
flightState._trackedModelFailCount = 0;
flightState._trackedModelRetryAtMs = 0;
}
/** Whether the driver may start another tracked-model load this frame. */
function _trackedModelLoadAllowed(nowMs = Date.now()) {
if (flightState._trackedModelFailIcao !== flightState._trackedIcao) return true; // untried selection
if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) return false;
return nowMs >= flightState._trackedModelRetryAtMs;
}
/** Record a rejected tracked-model load and arm the backoff / give-up latch. */
function _noteTrackedModelLoadFailure(url, err) {
if (flightState._trackedModelFailIcao !== flightState._trackedIcao) {
flightState._trackedModelFailIcao = flightState._trackedIcao;
flightState._trackedModelFailCount = 0;
}
flightState._trackedModelFailCount += 1;
flightState._trackedModelRetryAtMs = Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS;
if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) {
console.warn(
`[Data:Military] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — `
+ 'this contact stays 2D until another is selected',
err,
);
}
}
/**
* The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven
* (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the
* DISPLAY-rail `models3d` toggle, which keeps owning the FLEET
* (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live
* in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the
* owner's playtested swap distance, deliberately NEARER than the fleet's 800 km
* ceiling this used to inherit), hand back only above
* TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model.
* First-person means your own airframe is not drawn in cockpit — the eye sits
* metres from its origin.
*/
function _trackedModelRegimeActive() {
if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) {
flightState._trackedZoomLatchIcao = flightState._trackedIcao;
flightState._trackedZoomLatched = false;
}
// A converted TR-3B has no 3D asset — suppressing the regime keeps its
// tracked billboard fully opaque (the colour callback reads this too), so
// the triangle stays the visual all the way in. Mirror of flights.js.
if (!flightState._trackedIcao || flightState._cockpitContactMode || isTr3b(flightState._trackedIcao)) {
flightState._trackedZoomLatched = false;
return false;
}
flightState._trackedZoomLatched = trackedModelZoomActive(
flightState._viewer?.camera?.positionCartographic?.height,
flightState._trackedZoomLatched,
);
return flightState._trackedZoomLatched;
}
/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */
function _syncTracked2dRotation() {
if (!flightState._trackedIcao || !flightState._viewer) return;
const pos = parts.motion._trackedDisplayCached() || flightState._billboards.get(flightState._trackedIcao)?.position;
if (!pos) return;
const projected = screenProjectedRotation(
flightState._viewer.scene,
pos,
parts.motion._trackedDisplayCourse(),
flightState._lastTrackedRotation,
);
const rotation = stabilizeScreenRotation(flightState._lastTrackedRotation, projected, 0);
if (rotation !== null) flightState._lastTrackedRotation = rotation;
}
/**
* Field-test round 2 (2026-07-06): floors a trail-bound position at the warm
* coarse ground cell — a pure LIFT (above-floor positions pass through
* untouched, unknown floors change nothing). Grounded military billboards
* deliberately render at the pre-datum default height (T7 groundSnap
* invariant), so every position entering the TRAIL subsystem (seed, append,
* per-frame head) goes through this instead — the visible taxi history sits
* on the surface without touching the billboard/model machinery.
* @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely).
* @returns {Cesium.Cartesian3} The same or a lifted position.
*/
function _trailFloorPosition(position) {
const carto = Cesium.Cartographic.fromCartesian(position, Cesium.Ellipsoid.WGS84, flightState._scratchTrailCarto);
if (!carto) return position;
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
const floored = floorAltitudeM(carto.height, cachedGroundFloor(latDeg, lonDeg));
if (floored == null || floored === carto.height) return position;
return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored);
}
/**
* Append one fix to the tracked aircraft's trail accumulation and refresh
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
*/
function _appendTrailFix(position) {
flightState._trailPositions.push(_trailFloorPosition(position));
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) flightState._trailPositions.shift();
_refreshTrailDisplay();
}
/**
* Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That
* newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at
* now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane.
* The cheap per-frame _trailHeadEntity segment bridges the last body point to the
* delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll
* cadence), never at motion cadence.
*/
function _refreshTrailDisplay() {
if (!flightState._trail) return;
flightState._trail.setPositions(flightState._trailPositions.length > 1 ? flightState._trailPositions.slice(0, -1) : flightState._trailPositions);
}
/**
* Start the trail for a newly tracked aircraft: seed it with the short
* dead-reckoning history (chronological), render immediately, then
* fire-and-forget an adsb.lol trace backfill (~24 h of real history).
* @param {string} icao24 - ICAO hex identifier being tracked.
*/
function _startTrail(icao24) {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
const history = flightState._positionHistory.get(icao24) || [];
// Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), -RENDER_DELAY_SEC, flightState._scratchWarmupTime
);
for (const fix of history) {
if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) {
// Floor grounded-history seeds (round 2): a grounded contact's stored
// fixes carry the pre-datum default height — the trail copy sits on
// the surface instead (pure lift; airborne fixes pass through).
flightState._trailPositions.push(_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)));
}
}
if (!flightState._trail && flightState._viewer) {
flightState._trail = createTrail(flightState._viewer, { color: TRAIL_COLOR, width: 2.5 });
}
flightState._trail?.setVisible(!flightState._cockpitContactMode);
// Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated
// every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply,
// unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued
// to the 12 Hz icon instead of lagging ~1 s behind it.
if (!flightState._trailHeadEntity && flightState._viewer) {
flightState._trailHeadEntity = flightState._viewer.entities.add({
// 'gev-trail' namespace (round 6): claimed by trailRenderer's pick
// owner so a click on the head segment never reads as empty space.
id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`,
show: !flightState._cockpitContactMode,
polyline: {
positions: new Cesium.CallbackProperty(() => {
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
// start at the last DISPLAYED body point (index n−2). With a single fix that
// point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the
// segment would draw IN FRONT of the plane. Likewise during warm-up the icon
// predates all real history, so there is no valid body point behind it.
if (!flightState._trackedIcao || flightState._trailPositions.length < 2 || parts.motion._isTrackWarmingUp()) return [];
const head = parts.motion._trackedTrailCached() || parts.motion._trackedDisplayPosition(flightState._trackedIcao);
if (!head) return [];
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
// backward/reversing segment.
const start = flightState._trailPositions[flightState._trailPositions.length - 2];
// Floor the head too (round 2): a grounded tracked contact's DR
// display height is the pre-datum default — without this the last
// segment dives underground while taxiing.
const end = _trailFloorPosition(Cesium.Cartesian3.clone(head));
// On a contact that has not moved this segment runs from inside the
// model out to its own anchor — a line through the fuselage. The END
// never gives, so a moving trail still terminates on the tail; the
// START is what slides, from nothing on a parked contact out to the
// whole segment once it has cleared its own envelope. Measured against
// the FLOORED endpoint, which is the one actually drawn.
// See trailHeadStart.
const from = trailHeadStart(
start, end, parts.motion._trackedModelCenterWorld(), parts.motion._trackedModelEnvelopeM(), flightState._scratchTrailHead,
);
if (!from) return [];
return [from, end];
}, false),
width: 2.5,
material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9),
// Round 4: the head must never vanish into the mesh either (dimmed
// when occluded so depth still reads).
depthFailMaterial: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45),
arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords)
},
});
}
_refreshTrailDisplay();
const oldestFixEpochSec = history.length
? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000
: Infinity;
_backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec);
}
/**
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
* malformed) silently keeps the local-only trail.
* @param {string} icao24 - ICAO hex identifier being tracked.
* @param {number} token - Backfill token captured at request time.
* @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix.
* @returns {Promise<void>}
*/
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let trace = null;
try {
const track = await flightState._source.getTrack?.(flightState._flightData.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.any([flightState.lifetime.signal, AbortSignal.timeout(8000)]) });
trace = track?.records ?? null;
} catch {
return; // silent fallback to the accumulated trail
}
if (!trace) return;
if (token !== flightState._trailBackfillToken || icao24 !== flightState._trackedIcao) return;
// Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a
// readsb trace point's altitude is barometric feet (same datum as the live
// alt_baro, NOT geometric), so a trail waypoint's render height is the same
// documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon)
// — geometrically approximate, not exact.
await ensureGeoidReady();
// readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...]
const parsed = [];
for (const point of trace) {
const t = point.observedAtMs / 1000;
const lat = point.latitude, lon = point.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM });
}
if (!parsed.length) return;
// Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse
// ellipsoidal ground along the trace and clamp every waypoint at it. A
// 'ground'/null point sits ON the local surface — the old fixed 50 m
// sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m
// ellipsoidal) and dragged the whole pattern-work loop with it.
// Round-2 fix (owner: "trails suddenly much shorter / not loading"): the
// resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth
// lookup across a long path could stall the paint for seconds-to-timeout.
// Paint with whatever cells are warm; the resolve keeps filling the cache
// in the background for the next paint/select.
await resolveGroundFloorCellsBounded(parsed);
// Re-check the backfill token after the await (same guard as post-fetch):
// tracking may have moved on while the terrain race was in flight.
if (token !== flightState._trailBackfillToken || icao24 !== flightState._trackedIcao) return;
let older = [];
let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet
for (const { lat, lon, baroAltitudeM } of parsed) {
const baroM = baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon);
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// Ground/no-alt point with an unresolved floor: hold the previous
// waypoint's altitude (continuity — never a dive to a made-up depth).
// Leading points with nothing to carry keep the old low breadcrumb
// sentinel (an arbitrary placeholder, never a reported altitude).
if (altM == null) altM = lastAltM != null ? lastAltM : 50;
lastAltM = altM;
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
}
// Stride-thin the backfill so it plus the live fine segment fit the cap.
const budget = Math.max(1, TRAIL_MAX_POINTS - flightState._trailPositions.length);
if (older.length > budget) {
const stride = Math.ceil(older.length / budget);
const thinned = [];
for (let i = 0; i < older.length; i += stride) thinned.push(older[i]);
older = thinned;
}
flightState._trailPositions = older.concat(flightState._trailPositions);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
flightState._trailPositions = flightState._trailPositions.slice(flightState._trailPositions.length - TRAIL_MAX_POINTS);
}
_refreshTrailDisplay();
}
/**
* Clear the rendered trail and accumulation; invalidate pending backfills.
*/
function _clearTrail() {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
if (flightState._trail) flightState._trail.clear();
if (flightState._trailHeadEntity && flightState._viewer && !flightState._viewer.isDestroyed()) {
try { flightState._viewer.entities.remove(flightState._trailHeadEntity); } catch { /* already gone */ }
}
flightState._trailHeadEntity = null;
}
/**
* Destroy the trail primitive entirely (layer disable/teardown).
*/
function _destroyTrail() {
_clearTrail();
if (flightState._trail) {
flightState._trail.destroy();
flightState._trail = null;
}
}
/**
* Stop tracking the current aircraft and clean up all tracking state.
* Restores the original billboard, removes the tracked Entity, invalidates
* the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo
* (mirror of flights.js). Deselect used to fly an ~80 km pulled-back
* overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms
* way up and loses my context"). The camera stays at its current
* position/orientation, immediately free to orbit/zoom. Applies to every
* deselect path: click-empty-space, Escape, aged-out plane, layer disable,
* and voice stopTracking.
*
* @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the
* follow-camera: tear down our own state but leave viewer.trackedEntity
* alone (the new owner controls it).
* @param {object} [options] - Clear origin.
* @param {boolean} [options.evicted=false] - The contact aged out of the feed
* rather than being deselected. Consumers that keep a readout on screen
* (the Cockpit Contact panel) hold last-known values for an eviction and
* only tear down on a deliberate clear.
*/
function _clearTracking(skipViewerUntrack = false, {
evicted = false,
origin = 'programmatic',
} = {}) {
flightState._trackedCameraFrameStop?.();
flightState._trackedCameraFrameStop = null;
if (!flightState._trackedIcao) {
clearFocusTarget('militaryFlights');
return;
}
const clearedIcao = flightState._trackedIcao;
clearFocusTarget('militaryFlights', clearedIcao);
// Restore the original billboard appearance. The rotation is re-seeded from
// the tracked entity's last rendered rotation and a rotation pass is forced
// (_lastCamPoseSig below): the fleet billboard otherwise reappears with the
// STALE screen rotation it had when tracking began — up to a full
// ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release.
if (flightState._billboards.has(flightState._trackedIcao)) {
const bb = flightState._billboards.get(flightState._trackedIcao);
const meta = flightState._flightData.get(flightState._trackedIcao);
bb.show = true;
bb.width = 20;
bb.height = 20;
// Ground-aware restore (a plane untracked while taxiing comes back at
// ground scale; tint is full amber on the ground and in the air).
bb.scale = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1);
bb.color = MIL_ICON_COLOR;
bb.rotation = flightState._lastTrackedRotation;
}
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
// Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the
// follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now).
// Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in
// place, so the view stays where the follow left it and the user can immediately orbit/zoom.
if (flightState._viewer && !skipViewerUntrack) {
flightState._viewer.trackedEntity = undefined;
}
if (flightState._trackedEntity) {
flightState._viewer.entities.remove(flightState._trackedEntity);
flightState._trackedEntity = null;
}
parts.rendering._releaseTrackedModel();
_resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped
flightState._trackedIcao = null;
parts.rendering._applyFleetBillboardPresentation(clearedIcao, flightState._billboards.get(clearedIcao));
clearTrackedSubjectContext('military');
_emitAwarenessEvent('gev:awareness-subject-cleared', {
layerId: 'military',
id: clearedIcao,
origin,
reason: evicted ? 'evicted' : 'deliberate',
});
// Invalidate the per-frame DR cache so a same-frame re-track cannot read
// the previous aircraft's cached position.
parts.motion._resetTrackedDisplay();
_clearTrail();
}
function _applyPendingTrackingRestore() {
const pending = flightState._pendingTrackingRestore;
if (!pending || pending.generation !== flightState._trackingIntentGeneration) return false;
if (!flightState._billboardCollection?.show || !flightState._billboards.has(pending.id)) return false;
flightState._pendingTrackingRestore = null;
_trackFlight(pending.id, { origin: pending.origin });
return true;
}
function _cancelPendingTrackingRestore() {
flightState._trackingIntentGeneration += 1;
flightState._pendingTrackingRestore = null;
}
/**
* Begin tracking a specific aircraft. Clears any previous tracking, hides the
* billboard, creates a new Entity with dead-reckoning CallbackProperties for
* smooth continuous motion, and sets the viewer's trackedEntity so the camera
* follows.
* @param {string} icao24 - ICAO hex identifier of the aircraft to track
*/
function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
const bb = flightState._billboards.get(icao24);
const info = flightState._flightData.get(icao24);
if (!bb || !info) return;
flightState._trackedIcao = icao24;
_resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget
flightState._cachedDRFrame = -1;
flightState._lastTrackedRotation = bb.rotation || 0;
// Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its
// own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover
// fleet model would be orphaned + double-rendered.
parts.rendering._releaseModel(icao24);
// Hide the billboard -- the tracked entity replaces it visually
bb.show = false;
// Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition —
// one computation shared by the position/rotation/trail-head callbacks, with
// discontinuity reconciliation). Falls back to the last billboard position when the
// aircraft has no fix.
const getTrackedPosition = () => parts.motion._trackedDisplayPosition(icao24) || bb.position;
// Dead-reckoning position property evaluated every render frame.
// isConstant = false so CesiumJS re-evaluates each frame.
const positionProperty = new Cesium.CallbackProperty(() => {
return getTrackedPosition();
}, false);
// World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0).
// Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the
// TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the
// DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always
// resolves into an aircraft.
//
// The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by
// show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from
// the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF
// finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in
// would stall the centering until the model loaded. A shown-but-transparent billboard always
// supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is
// preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap.
// The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D
// model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is
// what makes the follow-camera's bounding sphere always ready (see _trackedModel).
flightState._trackedEntity = flightState._viewer.entities.add({
position: positionProperty,
// Keep Cesium's EntityView in the same ENU frame as the close-range
// camera guard; AUTO may otherwise alternate with a velocity frame.
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
billboard: {
image: aircraftIcon(parts.rendering._iconKind(flightState._trackedIcao, flightState._flightData.get(flightState._trackedIcao)?.klass), TRACKED_ICON_PX),
width: 28,
height: 28,
scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[flightState._flightData.get(flightState._trackedIcao)?.klass] || 1),
// Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(() => (
parts.rendering._modelOwnsVisual(flightState._trackedIcao) ? AMBER_TRANSPARENT : TRACKED_ICON_COLOR
), false),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
// The tracked target must never vanish into tile geometry — tracking a
// taxiing plane at street level would otherwise bury the amber icon inside
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
// its shared-host tracked card is top-composited separately.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
// mode where camera.heading lives in the entity's reference frame.
rotation: new Cesium.CallbackProperty(() => {
const tracked = flightState._flightData.get(flightState._trackedIcao);
const pos = getTrackedPosition();
if (!tracked || !pos || !flightState._viewer) return flightState._lastTrackedRotation;
const projected = screenProjectedRotation(
flightState._viewer.scene,
pos,
parts.motion._trackedDisplayCourse(),
flightState._lastTrackedRotation,
);
const rot = stabilizeScreenRotation(flightState._lastTrackedRotation, projected);
if (rot !== null) flightState._lastTrackedRotation = rot;
return flightState._lastTrackedRotation;
}, false),
},
});
flightState._trackedEntity.gevSelectionOrigin = origin;
flightState._trackedEntity.gevTrackedId = `military:${icao24}`;
flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText(
_buildTrackedLabel(info, icao24),
'#ffd166',
);
// A billboard has a ~zero bounding sphere, so Cesium's default follow distance is
// far too tight (the user had to scroll out to read the plane). Give the entity a
// calibrated viewFrom — behind + above, distance scaled to altitude — for a readable
// initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.)
const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500;
const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000);
flightState._trackedEntity.viewFrom = new Cesium.Cartesian3(0, -followRange * 0.8, followRange * 0.55);
// Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked
// entity's viewFrom on the first frame, so voice-initiated tracking (which often fires
// mid-fly_to_location) would follow the plane WITHOUT centering it like a click does.
// Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a
// postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights).
flightState._trackedEntity.gevDisplayPosition = parts.motion._trackedDisplayCached;
// Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the
// follow-camera anti-jitter contract; presentation that must weld to the aircraft you
// can see reads `gevVisualPosition`.
flightState._trackedEntity.gevVisualPosition = parts.motion._trackedVisualCached;
refreshTrackedReadout(flightState._trackedEntity);
flightState._viewer.camera.cancelFlight();
// Camera follows the tracked entity
flightState._viewer.trackedEntity = flightState._trackedEntity;
flightState._trackedCameraFrameStop = applyTrackedCameraFrame(
flightState._viewer,
flightState._trackedEntity,
flightState._trackedEntity.viewFrom,
) || null;
// Track-history trail (PRD F2): seed from local history + async backfill.
// Ground traffic draws NO trail — if the plane takes off while tracked, the
// ground→air transition in update() starts one.
// Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing
// aircraft's history is retrievable on select. Trail positions are floored
// at the surface (_trailFloorPosition), so ground legs drape, never dive.
_startTrail(icao24);
const callsign = parts.queries._toCleanText(info.callsign) || parts.queries._toCleanText(info.registration) || icao24;
_publishTrackedSelection(icao24, origin);
console.log(`[Data:Military] Tracking ${callsign} (${icao24})`);
}
/**
* Global keydown handler: pressing Escape clears the active aircraft tracking.
* @param {KeyboardEvent} e - The keyboard event
*/
function _onKeyDown(e) {
if (e.key === 'Escape' && flightState._trackedIcao) {
_cancelPendingTrackingRestore();
_clearTracking(false, { origin: 'user' });
}
}
/**
* Install a left-click handler on the scene canvas for aircraft selection.
* Clicking a military billboard starts tracking that aircraft; clicking empty
* space or a non-military object deselects. Also attaches the Escape key
* listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
*/
function _installClickHandler(viewer) {
if (flightState._clickHandler) return; // already installed
// Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our
// tracking without touching viewer.trackedEntity (the new owner controls it).
if (!flightState._trackedEntityChangedRemove) {
flightState._trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => {
if (flightState._trackedIcao && flightState._viewer && flightState._viewer.trackedEntity && flightState._viewer.trackedEntity !== flightState._trackedEntity) {
_clearTracking(true, {
origin: flightState._viewer.trackedEntity?.gevSelectionOrigin || 'programmatic',
});
}
});
}
flightState._clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => {
// Camera drags never select or deselect, even if they finish over a plane.
// Duration alone is allowed through so a stationary long press can still
// select/switch contacts; the destructive empty-space branch below applies
// the full travel + duration click classifier.
if (!isTrackingSelectionGesture(gesture)) return;
// Cockpit mode owns the camera and keeps the current aircraft as its
// first-person reference. Empty globe clicks are inert until the user
// exits with C, Escape, or the dedicated button.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself -- ignore (don't deselect)
if (picked.id === flightState._trackedEntity) return;
// Clicking the plane we're ALREADY tracking (its standalone 3D model or
// any pick carrying its icao) — same no-op as the 2D tracked-entity click
// above. H1: the model used to have no pick id, so this fell through to
// "empty space" and deselected the very plane being tracked.
if (flightState._trackedIcao) {
const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id;
if (picked.primitive === flightState._trackedModel || rawPick === flightState._trackedIcao) return;
}
// BillboardCollection picks: CesiumJS may expose the billboard as
// picked.primitive (with .id = icao24) or directly as picked.id
const billboard = picked.primitive;
if (billboard && billboard.id && flightState._billboards.has(billboard.id)) {
_cancelPendingTrackingRestore();
_trackFlight(billboard.id, { origin: 'user' });
return;
}
// Fallback: some CesiumJS versions surface the id string at picked.id
if (picked.id && typeof picked.id === 'string' && flightState._billboards.has(picked.id)) {
_cancelPendingTrackingRestore();
_trackFlight(picked.id, { origin: 'user' });
return;
}
}
// A pick that belongs to a sibling layer (commercial flight, satellite,
// vessel, station, CCTV camera…) is not "empty space" — leave tracking
// alone and let that layer handle it. resolvePickId String()-coerces the
// heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the
// registry predicates can recognize them (H2).
if (picked) {
const pickedId = resolvePickId(picked);
if (pickedId && isOwnedByOtherLayer('military', pickedId)) return;
}
// Clicked empty space -- deselect only for a clean, short click. A slow
// stationary press may select above, but cannot release existing tracking.
if (!isTrackingClickGesture(gesture)) return;
if (flightState._trackedIcao) {
_cancelPendingTrackingRestore();
_clearTracking(false, { origin: 'user' });
}
});
document.addEventListener('keydown', _onKeyDown);
}
return { _emitAwarenessEvent, _publishTrackedSelection, _contextSubjectMetadata, _isExplicitTrackingOrigin, _refreshCockpitNearContacts, _setCockpitContactMode, _applyCockpitState, _buildTrackedLabel, _updateTrackedLabelModel, _syncTrackedBillboardImage, _refreshTr3bContact, _refreshTr3bForStyle, _resetTrackedSelectionState, _trackedModelLoadAllowed, _noteTrackedModelLoadFailure, _trackedModelRegimeActive, _syncTracked2dRotation, _trailFloorPosition, _appendTrailFix, _refreshTrailDisplay, _startTrail, _backfillTrail, _clearTrail, _destroyTrail, _clearTracking, _applyPendingTrackingRestore, _cancelPendingTrackingRestore, _trackFlight, _onKeyDown, _installClickHandler };
}
+2 -2
View File
@@ -5,8 +5,8 @@ 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()