diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 047cb1073..a06f450fb 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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;/], diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js index ee4d960c5..8b831d19c 100644 --- a/src/layers/flights/index.js +++ b/src/layers/flights/index.js @@ -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; }, diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index a238ebcdd..8768dfad0 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -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; }, }; diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index aa7ae94ea..6a6a28189 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -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(); + } +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index ad3fffe13..9b8537e18 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -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} 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)) diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 388e74d16..0c08ded31 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -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 }; } diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 33095edd6..8a1f0e828 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -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, diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 24e5fec18..4df72e5e3 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -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(); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index a58849a37..177ebde18 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -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, diff --git a/src/layers/flights/trackingRefresh.test.mjs b/src/layers/flights/trackingRefresh.test.mjs index 040bd130d..c1a3346bf 100644 --- a/src/layers/flights/trackingRefresh.test.mjs +++ b/src/layers/flights/trackingRefresh.test.mjs @@ -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( [