mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Extract military-flight components and shared aircraft mechanics
This commit is contained in:
1 parent
b5e546daf1
commit
cb790ddea1
25 files changed
+4849
-3930
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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.
|
- 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.
|
- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history.
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# God's Eye View Current State
|
# 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
|
## Civil-flight components
|
||||||
|
|
||||||
`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance
|
`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance
|
||||||
|
|||||||
+3
-1
@@ -143,6 +143,8 @@
|
|||||||
"./ui/styles": "./src/ui/styles.js",
|
"./ui/styles": "./src/ui/styles.js",
|
||||||
"./scenes": "./src/scenes/director.js",
|
"./scenes": "./src/scenes/director.js",
|
||||||
"./sources/live": "./src/sources/live/index.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,5 +270,22 @@
|
|||||||
"src/layers/flights/queries.js",
|
"src/layers/flights/queries.js",
|
||||||
"src/layers/flights/index.js",
|
"src/layers/flights/index.js",
|
||||||
"src/layers/flights/enrichment.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"
|
||||||
]
|
]
|
||||||
@@ -610,5 +610,59 @@
|
|||||||
"src/layers/flights/tracking.js"
|
"src/layers/flights/tracking.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+32
-3805
File diff suppressed because it is too large.
Load diff
@@ -1,119 +1,10 @@
|
|||||||
/**
|
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||||
* Shared military-aircraft ICAO24 registry (2026-06-10 playtest fix).
|
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MIL_POLL_INTERVAL_MS = 60000;
|
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
|
||||||
|
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
|
||||||
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
|
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
|
||||||
const _milIcaos = new Set();
|
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
|
||||||
/** @type {boolean} True while the dedicated military layer is enabled. */
|
export const registerMilitaryIcaos = registry.registerMilitaryIcaos;
|
||||||
let _militaryLayerActive = false;
|
export const isMilitaryIcao = registry.isMilitaryIcao;
|
||||||
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
|
export const refreshMilitaryRegistryIfStale = registry.refreshMilitaryRegistryIfStale;
|
||||||
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;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
@@ -392,7 +392,7 @@ const LAYERS = [
|
|||||||
name: 'military',
|
name: 'military',
|
||||||
source: 'src/data/militaryFlights.js',
|
source: 'src/data/militaryFlights.js',
|
||||||
asset: (() => {
|
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 = '([^']+)';/);
|
const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/);
|
||||||
assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found');
|
assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found');
|
||||||
return m[1];
|
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'
|
// PLANE_* constants — pin them to the measured meter-scale GLB + flights'
|
||||||
// calibration so the copies cannot drift.
|
// calibration so the copies cannot drift.
|
||||||
test('military layer airplane.glb constants match the measured GLB and flights calibration', () => {
|
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 grab = (name) => {
|
||||||
const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`));
|
const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`));
|
||||||
assert.ok(m, `militaryFlights.js: ${name} not found`);
|
assert.ok(m, `militaryFlights.js: ${name} not found`);
|
||||||
|
|||||||
@@ -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.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
|
||||||
}
|
}
|
||||||
assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
|
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.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||||
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
|
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
|
||||||
assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false);
|
assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { fileURLToPath } from 'node:url';
|
|||||||
/** Read actual component owners for structural regression assertions. */
|
/** Read actual component owners for structural regression assertions. */
|
||||||
export function readLayerSource(file) {
|
export function readLayerSource(file) {
|
||||||
const path = file instanceof URL ? fileURLToPath(file) : file;
|
const path = file instanceof URL ? fileURLToPath(file) : file;
|
||||||
if (basename(path) !== 'flights.js') return readFileSync(path, 'utf8');
|
if (!['flights.js', 'militaryFlights.js'].includes(basename(path))) return readFileSync(path, 'utf8');
|
||||||
const directory = join(dirname(path), '../layers/flights');
|
const directory = join(dirname(path), basename(path) === 'flights.js' ? '../layers/flights' : '../layers/military');
|
||||||
return readdirSync(directory)
|
return readdirSync(directory)
|
||||||
.filter((name) => name.endsWith('.js'))
|
.filter((name) => name.endsWith('.js'))
|
||||||
.sort()
|
.sort()
|
||||||
|
|||||||
Reference in new issue
Block a user