Separate flight observations and tracking from the scene renderer

This commit is contained in:
German Khokhlov committed 2026-11-03 02:04:41 +04:00
1 parent 4bd7f97cc4
commit a0e5f04c30
10 files changed
+371 -187

No files matched your search

+1 -1
View File
@@ -1660,7 +1660,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
for (const [name, source, guard] of [
// Flights asks its renderer whether the fleet is shown (phase 3).
['flights', readLayerSource(new URL('./flights.js', import.meta.url)),
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_renderer\?\.fleet\.visible\(\)\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
/hasContact\(\s*icao24,?\s*\)\s*\{\s*if\s*\(\s*!flightState\.enabled\s*\|\|\s*flightState\.records\.data\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
// Military asks its renderer too (phase 3).
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_renderer\?\.fleet\.visible\(\)\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
+2 -2
View File
@@ -29,7 +29,7 @@ export function createCivilFlightLayer({
parts.evidence = createEvidence(context);
parts.testing = createTesting(context);
parts.queries = createQueries(context);
const applySnapshot = createFlightSnapshotRenderer({
parts.snapshots = createFlightSnapshotRenderer({
flightState,
records: flightState.records,
militaryRegistry: services.militaryRegistry,
@@ -45,7 +45,7 @@ export function createCivilFlightLayer({
feed: flightState.feed,
getQuery: (viewer) =>
parts.controller._flightQuery(viewer || flightState._renderer?.view),
applySnapshot,
applySnapshot: (snapshot) => parts.snapshots.applySnapshot(snapshot),
setSourceLabel: (source) => {
layer.source = source;
},
+124 -79
View File
@@ -13,10 +13,30 @@ export function createLifecycle({
const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking;
let initialized = false;
let resumeFollow = false;
function removeSceneListeners() {
parts.tracking._removeClickHandler();
unregisterPickOwner('flights');
if (flightState._preRenderRemove) {
flightState._preRenderRemove();
flightState._preRenderRemove = null;
}
if (flightState._trackedModelPreUpdateRemove) {
flightState._trackedModelPreUpdateRemove();
flightState._trackedModelPreUpdateRemove = null;
}
if (flightState._moveEndRemove) {
flightState._moveEndRemove();
flightState._moveEndRemove = null;
}
}
const methods = {
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (flightState._renderer)
if (initialized)
throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
@@ -35,36 +55,13 @@ export function createLifecycle({
* @param {{ engine?: object }} [context] - LayerLifecycle's context.
*/
init(viewer, { engine = null } = {}) {
if (flightState._renderer)
throw new Error('Flight layer is already initialized');
if (initialized) throw new Error('Flight layer is already initialized');
if (typeof flightState.feed._source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
if (flightState.lifetime.signal.aborted)
flightState.lifetime = new AbortController();
clearFocusTarget('flights');
flightState._focusEvidenceIds.clear();
const renderer = services.renderer.create({ viewer, engine });
flightState._renderer = renderer;
renderer.fleet.attach();
// 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;
renderer.models
.preload(resolveAsset(PLANE_MODEL_URL))
.then((m) => {
if (epoch === flightState._modelEpoch) {
flightState._preloadModel = m;
flightState._planeModelLoaded = true;
} else {
renderer.models.destroy(m);
}
})
.catch(() => {
/* tracked plane just stays a billboard a beat longer */
});
}
flightState._billboards = new Map();
flightState._cullPositions.clear();
flightState._detectionObjects = new Map();
@@ -86,9 +83,9 @@ export function createLifecycle({
flightState._trackedEntity = null;
flightState._cockpitSubjectId = null;
flightState._cockpitContactMode =
document.body.classList.contains('cockpit-mode');
globalThis.document?.body.classList.contains('cockpit-mode') === true;
flightState._cockpitNearContacts = new Set();
if (!flightState._cockpitModeListener) {
if (globalThis.window && !flightState._cockpitModeListener) {
flightState._cockpitModeListener = (event) =>
parts.tracking._applyCockpitState(event?.detail);
window.addEventListener(
@@ -101,8 +98,6 @@ export function createLifecycle({
parts.enrichment._ambientBudgetKnobs().ceil;
flightState._enrichAmbientRefillAnchorMs = 0;
parts.tracking._installClickHandler();
// React to Military-layer toggles IMMEDIATELY (suppress/restore sweep)
// instead of waiting out the 30 s poll (M2).
if (!flightState._milActiveChangeUnsub) {
@@ -111,9 +106,97 @@ export function createLifecycle({
);
}
renderer.fleet.restoreOrder();
initialized = true;
try {
if (viewer || engine) methods.attachRenderer(viewer, { engine });
} catch (error) {
methods.destroy(viewer);
throw error;
}
},
console.log('[Data:Flights] Initialized with billboard icons');
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized)
throw new Error('Initialize the flight layer before attaching');
if (flightState._renderer)
throw new Error('Flight renderer already attached');
const renderer = services.renderer.create({ viewer, engine });
flightState._renderer = renderer;
try {
renderer.fleet.attach();
// 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;
renderer.models
.preload(resolveAsset(PLANE_MODEL_URL))
.then((m) => {
if (epoch === flightState._modelEpoch) {
flightState._preloadModel = m;
flightState._planeModelLoaded = true;
} else {
renderer.models.destroy(m);
}
})
.catch(() => {
/* tracked plane just stays a billboard a beat longer */
});
}
parts.snapshots.presentRecords();
if (flightState._trackedIcao) {
parts.tracking._trackFlight(flightState._trackedIcao, {
resume: true,
follow: resumeFollow,
});
}
renderer.fleet.setVisible(flightState.enabled);
renderer.models.setVisible(flightState.enabled);
renderer.fleet.restoreOrder();
if (flightState.enabled) {
methods.enable();
parts.tracking._applyPendingTrackingRestore();
}
} catch (error) {
methods.detachRenderer(viewer);
throw error;
}
},
detachRenderer(viewer) {
const renderer = flightState._renderer;
if (!renderer) return;
removeSceneListeners();
releaseContinuousRender('flights');
flightState._trackedCameraFrameStop?.();
flightState._trackedCameraFrameStop = null;
if (flightState._trackedEntity) {
resumeFollow = renderer.follow.isFollowing(flightState._trackedEntity);
if (resumeFollow) renderer.follow.release();
renderer.tracked.remove(flightState._trackedEntity);
flightState._trackedEntity = null;
}
parts.rendering._releaseTrackedModel();
parts.tracking._detachTrail();
parts.rendering._releaseModels();
// Removing the model collection destroys it and its models.
renderer.fleet.detach(viewer);
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
flightState._modelPending.clear();
flightState._modelGen.clear();
if (flightState._preloadModel) {
renderer.models.destroy(flightState._preloadModel);
flightState._preloadModel = null;
}
flightState._planeModelLoaded = false;
flightState._billboards.clear();
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
flightState._displayFloorState.clear();
flightState._groundSnap.clear();
parts.motion._resetTrackedDisplay();
renderer.destroy();
flightState._renderer = null;
},
/**
@@ -121,11 +204,12 @@ export function createLifecycle({
*/
enable() {
const renderer = flightState._renderer;
flightState.enabled = true;
renderer?.fleet.setVisible(true);
holdContinuousRender('flights'); // per-frame animator (perf wave 2)
if (renderer) holdContinuousRender('flights'); // per-frame animator (perf wave 2)
renderer?.models.setVisible(true);
parts.tracking._setCockpitContactMode(
document.body.classList.contains('cockpit-mode'),
globalThis.document?.body.classList.contains('cockpit-mode') === true,
);
// Height-datum fix: warm the geoid grid once per layer-enable. The poll loop
// only reads geoidHeight() synchronously after this resolves (guarded by
@@ -140,6 +224,7 @@ export function createLifecycle({
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
});
}
if (!renderer) return;
parts.tracking._installClickHandler();
registerPickOwner('flights', (pickedId) =>
flightState._billboards.has(pickedId),
@@ -181,6 +266,7 @@ export function createLifecycle({
* Also clears any active flight tracking so the camera is released.
*/
disable() {
flightState.enabled = false;
parts.controller._abortActiveUpdates();
parts.tracking._cancelPendingTrackingRestore();
flightState._renderer?.fleet.setVisible(false);
@@ -189,22 +275,7 @@ export function createLifecycle({
flightState._renderer?.models.setVisible(false);
parts.tracking._clearTracking();
parts.tracking._destroyTrail();
// Leave the selection dispatcher (clicks, Escape) while disabled to
// avoid intercepting input when the layer is off
parts.tracking._removeClickHandler();
unregisterPickOwner('flights');
if (flightState._preRenderRemove) {
flightState._preRenderRemove();
flightState._preRenderRemove = null;
}
if (flightState._trackedModelPreUpdateRemove) {
flightState._trackedModelPreUpdateRemove();
flightState._trackedModelPreUpdateRemove = null;
}
if (flightState._moveEndRemove) {
flightState._moveEndRemove();
flightState._moveEndRemove = null;
}
removeSceneListeners();
},
/**
@@ -213,6 +284,8 @@ export function createLifecycle({
* @param {object|null} viewer - The viewer the layer was drawn on.
*/
destroy(viewer) {
initialized = false;
flightState.enabled = false;
flightState.lifetime.abort();
flightState._enrichActive = 0;
flightState._enrichLastDispatchMs = 0;
@@ -221,7 +294,6 @@ export function createLifecycle({
parts.tracking._clearTracking();
parts.tracking._destroyTrail();
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._removeClickHandler();
if (flightState._milActiveChangeUnsub) {
flightState._milActiveChangeUnsub();
flightState._milActiveChangeUnsub = null;
@@ -233,33 +305,7 @@ export function createLifecycle({
);
flightState._cockpitModeListener = null;
}
unregisterPickOwner('flights');
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();
// Removing the model collection destroys it and its models.
flightState._renderer?.fleet.detach(viewer);
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
flightState._modelPending.clear();
flightState._modelGen.clear();
if (flightState._preloadModel) {
flightState._renderer?.models.destroy(flightState._preloadModel);
flightState._preloadModel = null;
}
flightState._planeModelLoaded = false;
flightState._billboards.clear();
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
methods.detachRenderer(viewer);
flightState.records.data.clear();
flightState._positionHistory.clear();
flightState._displayCourse.clear();
@@ -272,6 +318,7 @@ export function createLifecycle({
flightState._enrichDripTimer = null;
}
flightState.records.missingPolls.clear();
flightState.records.geoidNCache.clear();
flightState._focusEvidenceIds.clear();
flightState.feed._count = 0;
flightState.feed._lastUpdate = null;
@@ -287,8 +334,6 @@ export function createLifecycle({
coverage: flightState.feed._lastCoverage,
};
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
flightState._renderer?.destroy();
flightState._renderer = null;
},
};
+97
View File
@@ -146,3 +146,100 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
'0°',
);
});
test('a live flight keeps observations, selection and freshness across renderer lifetimes', async () => {
const { createFakeEngine } =
await import('../../globe/testing/fakeEngine.js');
const { aircraftAtlasLayout, createAircraftNavaraRenderer } =
await import('../aircraft/render/navara.js');
const { createApplicationFlights } =
await import('../../app/layers/flights.js');
const first = createFakeEngine();
const second = createFakeEngine();
const epoch = Date.now();
let polls = 0;
const atl = aircraftAtlasLayout();
const source = {
label: 'QA',
async getSnapshot() {
polls++;
return {
source: 'QA',
observedAtMs: epoch + polls * 1000,
ageMs: 0,
complete: true,
records: [
{
id: '71be26',
callsign: 'QA',
latitude: 40.25,
longitude: -125.58 + polls / 1000,
ellipsoidAltitudeM: 9000,
baroAltitudeM: 8800,
courseDeg: 90,
speedMps: 200,
positionTimeMs: epoch + polls * 1000,
},
],
};
},
};
const layer = createApplicationFlights({
source,
surface: {
groundSnap: { createGroundSnap: () => ({ clear() {}, forget() {} }) },
groundFloor: {
cachedGroundFloor: () => null,
warmGroundFloor() {},
floorAltitudeM: (h) => h,
},
meshFloor: { sampleMeshFloorCells() {} },
},
militaryRegistry: {
isMilitaryIcao: () => false,
isMilitaryLayerActive: () => false,
refreshMilitaryRegistryIfStale() {},
onMilitaryLayerActiveChange: () => () => {},
},
renderer: {
create: ({ engine }) =>
createAircraftNavaraRenderer({
engine,
layerId: 'flights',
order: 1,
headingOffsetDeg: 180,
drawAtlas: async () => ({
canvas: { width: atl.width, height: atl.height },
cells: atl.cells,
}),
}),
},
});
try {
layer.init(null);
layer.enable();
await layer.update(null);
assert.equal(layer.hasContact('71BE26'), true);
assert.equal(layer.getAnalystRecords().length, 1);
assert.equal(layer.findByQuery('QA').icao24, '71be26');
layer.attachRenderer(null, { engine: first });
assert.equal(layer.qaDrawn().length, 1);
assert.equal(layer.trackById('71be26'), true);
layer.detachRenderer();
assert.equal(first.camera.following(), null);
assert.equal(layer.getTrackedInfo().icao24, '71be26');
await layer.update(null);
assert.equal(layer.getStats().lastUpdate, epoch + 2000);
layer.attachRenderer(null, { engine: second });
assert.equal(second.camera.following()?.label.id, 'flights:71be26');
assert.equal(layer.qaDrawn().length, 1);
assert.equal(polls, 2, 'attaching either renderer performs no source poll');
layer.disable();
assert.equal(layer.hasContact('71be26'), null);
assert.deepEqual(layer.getAnalystRecords(), []);
} finally {
layer.destroy(null);
first.destroy();
second.destroy();
}
});
+11 -14
View File
@@ -574,7 +574,7 @@ export function createQueries({
* @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>}
*/
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
if (!center || !flightState._renderer?.fleet.visible()) return [];
if (!center || !flightState.enabled) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
@@ -584,11 +584,14 @@ export function createQueries({
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
for (const [icao24, info] of flightState.records.data) {
const bb = flightState._billboards.get(icao24);
if (!bb && flightState._renderer) continue;
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 (
bb &&
!aircraftIncludedInNearby({
isTracked,
billboardShown: bb.show,
@@ -601,13 +604,13 @@ export function createQueries({
const trackedPos = isTracked
? parts.motion._trackedDisplayCached()
: null; // cached, no recompute (anti-jitter)
const pos = trackedPos || bb.position;
const pos =
trackedPos || bb?.position || parts.motion._deadReckon(icao24);
if (!pos) continue;
const distance = geo.cartesian3.distance(center, pos);
if (distance > maxRange) continue;
const info = flightState.records.data.get(icao24);
const callsign = info?.callsign?.trim() || null;
nearby.push({
// Label. Callers that need identity read `icao24` (Context cohorts do).
@@ -657,16 +660,13 @@ export function createQueries({
* holds no data and therefore cannot answer.
*/
hasContact(icao24) {
if (
!flightState._renderer?.fleet.visible() ||
flightState._billboards.size === 0
)
if (!flightState.enabled || flightState.records.data.size === 0)
return null;
if (!icao24) return false;
const id = String(icao24).trim();
return (
flightState._billboards.has(id) ||
flightState._billboards.has(id.toLowerCase())
flightState.records.data.has(id) ||
flightState.records.data.has(id.toLowerCase())
);
},
@@ -726,10 +726,7 @@ export function createQueries({
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (
!flightState._renderer?.fleet.visible() ||
flightState.records.data.size === 0
)
if (!flightState.enabled || flightState.records.data.size === 0)
return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
+96 -73
View File
@@ -25,14 +25,94 @@ export function createFlightSnapshotRenderer({
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
const { warmGroundFloor } = groundFloor;
const { sampleMeshFloorCells } = meshFloor;
return function applySnapshot(snapshot, viewer) {
function presentContact(icao24, meta, position, prevMeta, groundFlipped) {
const isTracked = icao24 === flightState._trackedIcao;
if (flightState._billboards.has(icao24)) {
const bb = flightState._billboards.get(icao24);
// Reclassify if the category resolved/changed (first extended poll);
// a ground flip (landing/takeoff) re-scales the SAME billboard in
// place — the transition is a restyle, never a removal.
// Round 5: depth policy is uniform (always depth-test-free, see
// _groundDepthDistance) — nothing to flip on landing/takeoff.
// Position AND rotation are owned by the fleet pass (_fleetTick);
// course changes land on the next rotation pass (forced below).
if (!isTracked) {
// Refresh affiliation hue without clobbering the tick-owned
// freshness × focus × horizon alpha composition.
bb.color = rendering
._fleetBillboardColor(icao24)
.withAlpha(cyberSonarBaseAlpha(bb));
}
if (
prevMeta?.klass !== meta.klass ||
groundFlipped ||
flightState._cockpitContactMode
) {
rendering._applyFleetBillboardPresentation(icao24, bb);
}
// Poll-path class change (category updates): same model resync rule
// as the enrichment path — the class's GLB/scale may have changed.
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time, and a grounded contact is then
// lifted onto the floor under that delayed position. Creating it at
// the raw fix drew it ahead until the next fleet tick snapped it back;
// skipping the floor started a taxiing contact low and then raised it.
const delayed = motion._deadReckon(icao24, geo.cartesian3.create());
const displayed =
(delayed &&
motion._floorGroundedDisplayPosition(
icao24,
flightState.records.data.get(icao24) || meta,
delayed,
rendering._modelOwnsVisual(icao24),
)) ||
position;
const bb = flightState._renderer.fleet.add({
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
scale: rendering._fleetBillboardScale(icao24, meta.klass),
// Screen-projected rotation lands on the next fleet tick.
rotation: 0,
alignedAxis: geo.cartesian3.ZERO,
color: isTracked ? CYAN : rendering._fleetBillboardColor(icao24),
sizeInMeters: false,
scaleByDistance: 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: rendering._groundDepthDistance(),
id: icao24,
show: !isTracked, // hidden if currently tracked (entity replaces it)
});
flightState._billboards.set(icao24, bb);
rendering._applyFleetBillboardPresentation(icao24, bb);
}
}
function presentRecords() {
for (const [id, meta] of records.data) {
const position = geo.cartesian3.fromDegrees(
meta.rawLon,
meta.rawLat,
meta.renderAltitudeM,
);
presentContact(id, meta, position, null, false);
}
}
function applySnapshot(snapshot) {
const currentIcaos = new Set();
const acceptedSnapshotIcaos = new Set();
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
// subpoint + collected floor cells for the viewer-proximate low-contact
// clamp below — one carto read per poll, one batch warm after the loop.
const viewerCarto =
(viewer || flightState._renderer?.view)?.camera?.positionCartographic ||
flightState._renderer?.view.camera?.positionCartographic ||
null;
const viewerLatDeg = viewerCarto
? geo.math.toDegrees(viewerCarto.latitude)
@@ -55,8 +135,8 @@ export function createFlightSnapshotRenderer({
const isMil = isMilitaryIcao(icao24);
if (isMil && tracking._militaryLayerSuppresses(icao24)) {
const dupe = flightState._billboards.get(icao24);
if (dupe) {
flightState._renderer.fleet.remove(dupe);
if (records.data.has(icao24)) {
if (dupe) flightState._renderer.fleet.remove(dupe);
flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
records.forget(icao24);
@@ -158,71 +238,8 @@ export function createFlightSnapshotRenderer({
}
}
if (flightState._billboards.has(icao24)) {
const bb = flightState._billboards.get(icao24);
// Reclassify if the category resolved/changed (first extended poll);
// a ground flip (landing/takeoff) re-scales the SAME billboard in
// place — the transition is a restyle, never a removal.
// Round 5: depth policy is uniform (always depth-test-free, see
// _groundDepthDistance) — nothing to flip on landing/takeoff.
// Position AND rotation are owned by the fleet pass (_fleetTick);
// course changes land on the next rotation pass (forced below).
if (!isTracked) {
// Refresh affiliation hue without clobbering the tick-owned
// freshness × focus × horizon alpha composition.
bb.color = rendering
._fleetBillboardColor(icao24)
.withAlpha(cyberSonarBaseAlpha(bb));
}
if (
prevMeta?.klass !== meta.klass ||
groundFlipped ||
flightState._cockpitContactMode
) {
rendering._applyFleetBillboardPresentation(icao24, bb);
}
// Poll-path class change (category updates): same model resync rule
// as the enrichment path — the class's GLB/scale may have changed.
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time, and a grounded contact is then
// lifted onto the floor under that delayed position. Creating it at
// the raw fix drew it ahead until the next fleet tick snapped it back;
// skipping the floor started a taxiing contact low and then raised it.
const delayed = motion._deadReckon(icao24, geo.cartesian3.create());
const displayed =
(delayed &&
motion._floorGroundedDisplayPosition(
icao24,
flightState.records.data.get(icao24) || meta,
delayed,
rendering._modelOwnsVisual(icao24),
)) ||
position;
const bb = flightState._renderer.fleet.add({
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
scale: rendering._fleetBillboardScale(icao24, meta.klass),
// Screen-projected rotation lands on the next fleet tick.
rotation: 0,
alignedAxis: geo.cartesian3.ZERO,
color: isTracked ? CYAN : rendering._fleetBillboardColor(icao24),
sizeInMeters: false,
scaleByDistance: 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: rendering._groundDepthDistance(),
id: icao24,
show: !isTracked, // hidden if currently tracked (entity replaces it)
});
flightState._billboards.set(icao24, bb);
rendering._applyFleetBillboardPresentation(icao24, bb);
}
if (!flightState._renderer) continue;
presentContact(icao24, meta, position, prevMeta, groundFlipped);
// Takeoff while TRACKED: ground traffic drew no trail, so start one
// from the fresh airborne history (touchdown needs no action — the
@@ -243,7 +260,7 @@ export function createFlightSnapshotRenderer({
// 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) {
for (const icao24 of records.data.keys()) {
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
@@ -272,7 +289,8 @@ export function createFlightSnapshotRenderer({
tracking._clearTracking(false, { evicted: true });
}
flightState._renderer.fleet.remove(bb);
const bb = flightState._billboards.get(icao24);
if (bb) flightState._renderer.fleet.remove(bb);
flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
records.forget(icao24);
@@ -286,6 +304,9 @@ export function createFlightSnapshotRenderer({
// Fresh courses arrived — force a rotation pass on the next fleet tick
flightState._lastCamPoseSig = '';
const accepted = { count: records.data.size, ids: acceptedSnapshotIcaos };
if (!flightState._renderer) return accepted;
// Ambient type enrichment: give ON-SCREEN planes real types (bounded
// sweep — see _sweepAmbientEnrichment; internally fail-silent).
enrichment._sweepAmbientEnrichment();
@@ -337,6 +358,8 @@ export function createFlightSnapshotRenderer({
// in place is safe (the DR extrapolates a zero-velocity fix).
motion._refloorStaleGroundedContacts(currentIcaos);
return { count: flightState._billboards.size, ids: acceptedSnapshotIcaos };
};
return accepted;
}
return { applySnapshot, presentRecords };
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
export function createFlightState({ source, services }) {
const { createGroundSnap } = services.groundSnap;
const flightState = { lifetime: new AbortController() };
const flightState = { lifetime: new AbortController(), enabled: false };
flightState.feed = createFlightFeed(source);
flightState.records = new FlightRecords({
...services.geoid,
+1
View File
@@ -70,6 +70,7 @@ export function createTesting({
viewer,
adopt: { billboardCollection, modelCollection },
});
flightState.enabled = flightState._renderer.fleet.visible();
flightState._billboards = new Map([[icao24, billboard]]);
flightState._cullPositions.clear();
flightState._models.clear();
+37 -16
View File
@@ -378,6 +378,14 @@ export function createTracking({
flightState._trailPositions.push(geo.cartesian3.clone(fix.position));
}
}
_attachTrail();
const oldestFixEpochSec = history.length
? history[0].timeMs / 1000
: Infinity;
_backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec);
}
function _attachTrail() {
if (!flightState._trail && flightState._renderer) {
flightState._trail = flightState._renderer.trails.create({
color: TRAIL_COLOR,
@@ -440,11 +448,6 @@ export function createTracking({
});
}
_refreshTrailDisplay();
const oldestFixEpochSec = history.length
? history[0].timeMs / 1000
: Infinity;
_backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec);
}
/**
@@ -565,6 +568,13 @@ export function createTracking({
function _destroyTrail() {
_clearTrail();
_detachTrail();
}
function _detachTrail() {
if (flightState._trailHeadEntity)
flightState._renderer?.trails.removeHead(flightState._trailHeadEntity);
flightState._trailHeadEntity = null;
if (flightState._trail) {
flightState._trail.destroy();
flightState._trail = null;
@@ -870,8 +880,11 @@ export function createTracking({
* @param {string} icao24 - ICAO 24-bit transponder address to track.
*/
function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
function _trackFlight(
icao24,
{ origin = 'programmatic', resume = false, follow = true } = {},
) {
if (!resume) _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
const bb = flightState._billboards.get(icao24);
const info = flightState.records.data.get(icao24);
@@ -998,14 +1011,20 @@ export function createTracking({
refreshTrackedReadout(flightState._trackedEntity);
// Camera follows the tracked entity (the renderer cancels any flight
// first and applies the one exact tracked frame).
flightState._trackedCameraFrameStop = flightState._renderer.follow.start(
flightState._trackedEntity,
);
if (follow) {
flightState._trackedCameraFrameStop = flightState._renderer.follow.start(
flightState._trackedEntity,
);
}
// Track-history trail (PRD F1): seed from local history + async backfill.
// Ground traffic draws NO trail (a taxi path is noise, not a track) — if the
// plane takes off while tracked, the on_ground→false transition in update()
// starts one.
if (resume) {
_attachTrail();
return;
}
parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue
parts.enrichment._requestRouteEnrichment(icao24);
// Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing
@@ -1040,23 +1059,24 @@ export function createTracking({
*/
function _onMilitaryActiveChange(active) {
if (!flightState._renderer?.fleet.attached()) return;
if (!flightState.enabled) return;
if (active) {
for (const [icao24, bb] of flightState._billboards) {
for (const icao24 of flightState.records.data.keys()) {
if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao)
continue;
flightState._renderer.fleet.remove(bb);
const bb = flightState._billboards.get(icao24);
if (bb) flightState._renderer.fleet.remove(bb);
flightState._billboards.delete(icao24);
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
flightState.records.data.delete(icao24);
flightState.records.forget(icao24);
flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24);
flightState._groundSnap.forget(icao24);
flightState.records.missingPolls.delete(icao24);
}
flightState.feed._count = flightState._billboards.size;
} else if (flightState._renderer.fleet.visible()) {
flightState.feed._count = flightState.records.data.size;
} else {
// Fire-and-forget refresh; only while the layer is actually enabled.
// The query reads the renderer's camera (no viewer argument needed).
void flightsLayer.update(null).catch((error) => {
@@ -1196,6 +1216,7 @@ export function createTracking({
_backfillTrail,
_clearTrail,
_destroyTrail,
_detachTrail,
_clearTracking,
_militaryLayerSuppresses,
_applyPendingTrackingRestore,
+1 -1
View File
@@ -5,7 +5,7 @@ import { createTracking } from './tracking.js';
function handoff(update) {
return createTracking({
flightState: {
_renderer: { fleet: { attached: () => true, visible: () => true } },
enabled: true,
},
services: Object.fromEntries(
[