From 4cb7334da52a6b045305f962e6c977e01331a971 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 14 Sep 2026 15:55:37 -0700 Subject: [PATCH] refactor: separate civil-flight records and rendering --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 14 + docs/CURRENT-STATE.md | 7 + package.json | 4 +- scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 34 +- src/data/militaryAwareness.test.mjs | 2 +- src/data/tr3bRegistry.test.mjs | 2 +- src/layers/flights/controller.js | 4 +- src/layers/flights/enrichment.js | 10 +- src/layers/flights/evidence.js | 14 +- src/layers/flights/index.js | 26 +- src/layers/flights/ingestion.js | 677 +++---------------------- src/layers/flights/ingestion.test.mjs | 85 ++++ src/layers/flights/lifecycle.js | 52 +- src/layers/flights/motion.js | 22 +- src/layers/flights/ownership.test.mjs | 11 +- src/layers/flights/policy.js | 24 +- src/layers/flights/queries.js | 54 +- src/layers/flights/recordPolicy.js | 21 + src/layers/flights/records.js | 283 +++++++++++ src/layers/flights/records.test.mjs | 108 ++++ src/layers/flights/rendering.js | 30 +- src/layers/flights/snapshotRenderer.js | 313 ++++++++++++ src/layers/flights/state.js | 70 +-- src/layers/flights/testing.js | 17 +- src/layers/flights/tracking.js | 39 +- 27 files changed, 1113 insertions(+), 819 deletions(-) create mode 100644 src/layers/flights/ingestion.test.mjs create mode 100644 src/layers/flights/recordPolicy.js create mode 100644 src/layers/flights/records.js create mode 100644 src/layers/flights/records.test.mjs create mode 100644 src/layers/flights/snapshotRenderer.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 96850ce94..66365c73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Separate civil-flight acquisition and record reconciliation from Cesium resource updates, preserving source timing, ground placement and tracking behavior. + - Separate navigation, share restoration, visual settings and panel state into focused UI owners with explicit dependencies and terminal cleanup. - Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f3a3105d3..f29bc0ad4 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -477,3 +477,17 @@ These package groups contain their own dependencies and exclude application assembly. `applicationShell` retains composition and compatibility methods; those methods delegate to the state owner. Source-based regression checks inspect that implementation owner, and browser acceptance exercises the assembled UI. + +## Civil-flight records and acquisition + +`layers/flights/records` owns metadata, sticky observations, geoid values and +missing-poll admission. `layers/flights/ingestion` owns the source and request +lifetime, backoff and freshness state. Both are portable package groups with no +Cesium, viewer, billboard, model or application imports. + +The layer composes these with `snapshotRenderer`, which applies record changes +to Cesium history and primitives and coordinates existing follow operations. +Occlusion points live with rendering state. Eviction still releases tracking +before deleting records; incomplete snapshots retain recent contacts for the +existing bounded interval. The renderer retains its existing per-frame scratch +objects; reconciliation occurs on source refresh, not on each frame. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 842f22ac3..b3667faca 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Civil flights separate source acquisition, portable record reconciliation and +scene application. The record owner keeps sticky metadata, geoid cache and +bounded missing-poll state; ingestion owns cancellation, source freshness and +backoff. The snapshot renderer owns Cartesian history, billboard/model changes +and follow transitions. Aviation altitude remains separate from render height; +partial retention, military suppression and ground-sampling policies are unchanged. + The UI facade composes separate owners for navigation authority, share restoration, visual settings and panel chrome. Navigation owns request generations and follow handoff; restoration owns its coordinator, deferred notices, gestures and timer; diff --git a/package.json b/package.json index 0bf4821af..a6fde771b 100644 --- a/package.json +++ b/package.json @@ -200,6 +200,8 @@ "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", "./ui/visual-settings": "./src/ui/visualSettings.js", - "./ui/panel-chrome": "./src/ui/panelChrome.js" + "./ui/panel-chrome": "./src/ui/panelChrome.js", + "./layers/flights/records": "./src/layers/flights/records.js", + "./layers/flights/ingestion": "./src/layers/flights/ingestion.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8eb4517f8..7896b76d7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -595,5 +595,10 @@ "src/ui/visualSettings.js", "src/ui/panelChrome.js", "src/testSupport/readShellSource.mjs", - "src/ui/stateOwners.test.mjs" + "src/ui/stateOwners.test.mjs", + "src/layers/flights/records.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/records.test.mjs", + "src/layers/flights/ingestion.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 130179411..5422a7a82 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -623,7 +623,10 @@ "src/layers/flights/rendering.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", - "src/layers/flights/tracking.js" + "src/layers/flights/tracking.js", + "src/layers/flights/records.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/snapshotRenderer.js" ], "external": ["cesium"] }, @@ -1539,7 +1542,10 @@ "src/voice/realtimeController.js", "src/voice/voiceCost.js", "src/weatherEffectsMath.js", - "src/worldFocus.js" + "src/worldFocus.js", + "src/layers/flights/records.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/snapshotRenderer.js" ], "external": [ "@mapbox/vector-tile", @@ -1863,7 +1869,10 @@ "src/services/requests.js", "src/ui/panelRailGeometry.js", "src/ui/radioTunerModel.js", - "src/worldFocus.js" + "src/worldFocus.js", + "src/layers/flights/records.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/snapshotRenderer.js" ], "external": [ "cesium", @@ -1977,5 +1986,24 @@ "src/ui/uiLifetime.js" ], "external": [] + }, + "civil-flight-records": { + "exports": ["./layers/flights/records"], + "modules": [ + "src/layers/flights/records.js", + "src/layers/flights/recordPolicy.js", + "src/data/aircraftMeta.js", + "src/data/renderAltitude.js", + "src/data/aircraftClass.js" + ], + "external": [] + }, + "civil-flight-ingestion": { + "exports": ["./layers/flights/ingestion"], + "modules": [ + "src/layers/flights/ingestion.js", + "src/layers/flights/recordPolicy.js" + ], + "external": [] } } diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 4fd72612c..66d286756 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1535,7 +1535,7 @@ test('production eviction sites actually tag their clears', () => { for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) { assert.match( source, - /if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/, + /if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:(?:parts\.)?tracking\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/, `${name} must mark its aged-out cull as an eviction`, ); assert.match( diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 484768337..c081206bd 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -254,7 +254,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || []; assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`); for (const call of callSites) { - assert.match(call, /aircraftIcon\(\s*\s*(?:parts\.rendering\.)?_iconKind\(\s*/, + assert.match(call, /aircraftIcon\(\s*\s*(?:(?:parts\.)?rendering\.)?_iconKind\(\s*/, `${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`); } diff --git a/src/layers/flights/controller.js b/src/layers/flights/controller.js index 5e04c53e1..c38ac9175 100644 --- a/src/layers/flights/controller.js +++ b/src/layers/flights/controller.js @@ -8,9 +8,9 @@ export function createController({ resolveAsset, }) { function _abortActiveUpdates() { - for (const controller of flightState._activeUpdateControllers) + for (const controller of flightState.feed._activeUpdateControllers) controller.abort(); - flightState._activeUpdateControllers.clear(); + flightState.feed._activeUpdateControllers.clear(); } function _flightQuery(viewer) { diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index 935fb301e..aaf8ceb38 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -57,7 +57,7 @@ export function createEnrichment({ Promise.resolve() .then(() => { lifetime.signal.throwIfAborted(); - return flightState._source.getEnrichment?.(job.query, { + return flightState.feed._source.getEnrichment?.(job.query, { signal: lifetime.signal, }); }) @@ -81,7 +81,7 @@ export function createEnrichment({ `t:${icao24}`, { kind: 'type', id: icao24.toLowerCase() }, (data) => { - const meta = flightState._flightData.get(icao24); + const meta = flightState.records.data.get(icao24); if (!meta) return; // evicted while the lookup was in flight meta.typeCode = data.typeCode || meta.typeCode; meta.typeName = data.typeName || meta.typeName; @@ -109,7 +109,7 @@ export function createEnrichment({ } function _requestRouteEnrichment(icao24) { - const cs = String(flightState._flightData.get(icao24)?.callsign || '') + const cs = String(flightState.records.data.get(icao24)?.callsign || '') .trim() .toUpperCase(); if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve @@ -117,7 +117,7 @@ export function createEnrichment({ `r:${cs}`, { kind: 'route', id: cs }, (data) => { - const meta = flightState._flightData.get(icao24); + const meta = flightState.records.data.get(icao24); if (!meta) return; meta.airline = data.airline || meta.airline; if (data.origin && data.destination) @@ -198,7 +198,7 @@ export function createEnrichment({ for (const [icao24, bb] of flightState._billboards) { if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only - if (flightState._flightData.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) + if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb Cesium.Cartesian3.clone( bb.position, diff --git a/src/layers/flights/evidence.js b/src/layers/flights/evidence.js index 9a6f9da66..243fe9b9a 100644 --- a/src/layers/flights/evidence.js +++ b/src/layers/flights/evidence.js @@ -50,10 +50,10 @@ export function createEvidence({ for (const bb of flightState._billboards.values()) flightState._billboardCollection.remove(bb); flightState._billboards.clear(); - flightState._flightData.clear(); + flightState.records.data.clear(); flightState._positionHistory.clear(); flightState._displayCourse.clear(); - flightState._missingPolls.clear(); + flightState.records.missingPolls.clear(); flightState._focusEvidenceIds.clear(); for (const record of Array.isArray(records) ? records : []) { @@ -79,9 +79,9 @@ export function createEvidence({ lastContactEpochMs: Date.now(), rawLat: record.latitude ?? null, rawLon: record.longitude ?? null, - cullPosition: null, }; - flightState._flightData.set(id, meta); + flightState.records.data.set(id, meta); + flightState._cullPositions.delete(id); flightState._focusEvidenceIds.add(id); const bb = flightState._billboardCollection.add({ position, @@ -100,10 +100,10 @@ export function createEvidence({ }); flightState._billboards.set(id, bb); } - flightState._count = flightState._billboards.size; + flightState.feed._count = flightState._billboards.size; flightState._lastFleetTickMs = 0; flightState._viewer.scene.requestRender?.(); - return { ok: true, count: flightState._count }; + return { ok: true, count: flightState.feed._count }; } /** Update explicit evidence positions without rebuilding billboards. */ @@ -120,7 +120,7 @@ export function createEvidence({ const bb = flightState._billboards.get(id); if (!position || !bb) continue; bb.position = position; - const meta = flightState._flightData.get(id); + const meta = flightState.records.data.get(id); if (meta) { if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; if (Number.isFinite(record.velocityMps)) diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js index c652ca45b..f778c98b1 100644 --- a/src/layers/flights/index.js +++ b/src/layers/flights/index.js @@ -1,3 +1,4 @@ +import { createFlightSnapshotRenderer } from './snapshotRenderer.js'; import { createFlightState } from './state.js'; import { createRendering } from './rendering.js'; import { createMotion } from './motion.js'; @@ -24,11 +25,34 @@ export function createCivilFlightLayer({ parts.tracking = createTracking(context); parts.controller = createController(context); parts.enrichment = createEnrichment(context); - parts.ingestion = createIngestion(context); parts.lifecycle = createLifecycle(context); parts.evidence = createEvidence(context); parts.testing = createTesting(context); parts.queries = createQueries(context); + const applySnapshot = createFlightSnapshotRenderer({ + flightState, + records: flightState.records, + militaryRegistry: services.militaryRegistry, + groundFloor: services.groundFloor, + meshFloor: services.meshFloor, + rendering: parts.rendering, + tracking: parts.tracking, + motion: parts.motion, + enrichment: parts.enrichment, + queries: parts.queries, + }); + parts.ingestion = createIngestion({ + feed: flightState.feed, + getQuery: (viewer) => + parts.controller._flightQuery(viewer || flightState._viewer), + applySnapshot, + setSourceLabel: (source) => { + layer.source = source; + }, + applyPendingTrackingRestore: () => + parts.tracking._applyPendingTrackingRestore(), + }); + Object.assign( layer, parts.queries.methods, diff --git a/src/layers/flights/ingestion.js b/src/layers/flights/ingestion.js index 7a0a44731..5ab2fc95c 100644 --- a/src/layers/flights/ingestion.js +++ b/src/layers/flights/ingestion.js @@ -1,661 +1,112 @@ -import * as Cesium from 'cesium'; -import { stickyNumber, stickyText } from '../../data/aircraftMeta.js'; -import { - geoidSurfaceLastResortM, - pickRenderAltitudeM, -} from '../../data/renderAltitude.js'; -import { classifyAircraft } from '../../data/aircraftClass.js'; -import { - turnRateFromFixHistory, - liftRepeatedGroundFix, - synthesizeForwardKinematicsFix, -} from '../../data/motionModel.js'; -import { aircraftIcon } from '../../data/aircraftIcons.js'; -import { - GROUND_FLOOR_CLAMP_RADIUS_KM, - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './policy.js'; +import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js'; +/** Own acquisition, cancellation, freshness and backoff independently of rendering. */ export function createIngestion({ - flightState, - services, - parts, - layer, - resolveAsset, + feed, + getQuery, + applySnapshot, + setSourceLabel, + applyPendingTrackingRestore, }) { - const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = - services.militaryRegistry; - const { geoidHeight } = services.geoid; - const { cachedGroundFloor, floorAltitudeM, warmGroundFloor } = - services.groundFloor; - const { sampleMeshFloorCells } = services.meshFloor; - const flightsLayer = layer; - const methods = { - /** - * Fetch the latest aircraft state vectors from the OpenSky proxy and - * reconcile them with the billboard collection. - * - * Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors - * with exponential-ish backoff. On success, adds/updates/removes - * billboards and position history, triggers lerp blending for the - * tracked aircraft, and updates its label text. - * - * @param {Cesium.Viewer} viewer - * @returns {Promise} - */ async update(viewer, { signal = null } = {}) { const nowMs = Date.now(); - const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch; - flightState._lastTrackingRefreshOutcome = { + const trackingRefreshEpoch = ++feed._trackingRefreshEpoch; + feed._lastTrackingRefreshOutcome = { epoch: trackingRefreshEpoch, status: 'source-unavailable', ids: new Set(), - source: flightState._lastSource, - coverage: flightState._lastCoverage, + source: feed._lastSource, + coverage: feed._lastCoverage, }; - if (flightState._retryAt && nowMs < flightState._retryAt) { - flightState._backoff = true; + if (feed._retryAt && nowMs < feed._retryAt) { + feed._backoff = true; return; } const resourceController = new AbortController(); - flightState._activeUpdateControllers.add(resourceController); + feed._activeUpdateControllers.add(resourceController); const updateSignal = signal ? AbortSignal.any([signal, resourceController.signal]) : resourceController.signal; try { updateSignal.throwIfAborted(); - const snapshot = await flightState._source.getSnapshot( - parts.controller._flightQuery(viewer || flightState._viewer), - { signal: updateSignal }, - ); + const snapshot = await feed._source.getSnapshot(getQuery(viewer), { + signal: updateSignal, + }); updateSignal.throwIfAborted(); - flightState._lastStatus = snapshot.status ?? 200; - const usableStates = snapshot.records; + feed._lastStatus = snapshot.status ?? 200; const sourceEpochMs = snapshot.observedAtMs; const sourceAgeMs = snapshot.ageMs; const sourceStale = snapshot.stale || snapshot.freshness === 'unknown'; - flightState._backoff = sourceStale; - flightState._retryAt = 0; - flightState._lastError = sourceStale + feed._backoff = sourceStale; + feed._retryAt = 0; + feed._lastError = sourceStale ? sourceAgeMs == null ? 'Source snapshot time unavailable' : `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old` : null; - flightState._lastSource = snapshot.source; - flightState._lastCoverage = snapshot.coverage; - flightsLayer.source = flightState._lastSource; - const currentIcaos = new Set(); - const acceptedSnapshotIcaos = new Set(); - const now = Cesium.JulianDate.now(); - // 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._viewer)?.camera?.positionCartographic || null; - const viewerLatDeg = viewerCarto - ? Cesium.Math.toDegrees(viewerCarto.latitude) - : null; - const viewerLonDeg = viewerCarto - ? Cesium.Math.toDegrees(viewerCarto.longitude) - : null; - const floorWarmPoints = []; - - // Classification and display policy consume source-independent observations. - refreshMilitaryRegistryIfStale(); - for (const observation of usableStates) { - const { - id: icao24, - callsign, - originCountry: origin_country, - longitude: lon, - latitude: lat, - baroAltitudeM: baro_alt, - onGround: on_ground, - speedMps: velocity, - courseDeg: true_track, - ellipsoidAltitudeM: geo_alt, - category, - verticalRateMps: vertical_rate, - } = observation; - acceptedSnapshotIcaos.add(icao24); - const onGround = on_ground === true; - - // Known-military aircraft: the dedicated military layer wins - // (icon + track + click) while it is enabled — suppress the - // OpenSky duplicate entirely (except a currently tracked one, - // which hands off on untrack). - const isMil = isMilitaryIcao(icao24); - if (isMil && parts.tracking._militaryLayerSuppresses(icao24)) { - const dupe = flightState._billboards.get(icao24); - if (dupe) { - flightState._billboardCollection.remove(dupe); - flightState._billboards.delete(icao24); - parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too - flightState._flightData.delete(icao24); - flightState._positionHistory.delete(icao24); - flightState._displayCourse.delete(icao24); - flightState._groundSnap.forget(icao24); - flightState._missingPolls.delete(icao24); - flightState._geoidNCache.delete(icao24); - } - continue; - } - - currentIcaos.add(icao24); - flightState._missingPolls.delete(icao24); - // Sticky merge: OpenSky intermittently drops callsign/velocity/track for - // aircraft it still positions — hold last-known-good instead of - // regressing to the ICAO hex / a 0° (north) heading. Bounded by the - // MISSING_POLL_LIMIT eviction below, which deletes the whole entry. - const prevMeta = flightState._flightData.get(icao24); - // Grounded planes with no baro reading sit at 0 m, not the 10 km - // airborne default (a parked plane must never float). - // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky - // barometric/MSL altitude read by labels (FL/altitude readout), - // route-plausibility, and follow-camera range heuristics. It is - // NEVER overwritten or renamed. Where the aircraft actually RENDERS - // on the ellipsoidal globe is a SEPARATE value (renderAltitudeM, - // below) — geo_altitude when OpenSky reports it (already WGS84 - // ellipsoidal), else baro+geoid as a visual fallback, else ground - // surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM, - // never `alt` directly. - const alt = stickyNumber( - baro_alt, - prevMeta?.altitude, - onGround ? 0 : 10000, - ); - - // 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); - } - - // on_ground surface prior: ONLY synchronous warm-cache reads here — - // never a per-aircraft network fetch inside the poll loop (see the - // batch resolve call below, which fills this cache for NEXT poll). A - // Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied): - // the grounded surface is the round-4 choke point and nothing else — - // rendered-mesh cell first, real (never fallback-poisoned) DEM cell - // second. The old exact-5-decimal warm chain is GONE: it minted a new - // key per parked-jitter poll for every grounded contact ON EARTH, - // hammering Re:Earth into the very failures that poisoned the cache. - let surfaceM = null; - if (onGround) { - surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell) - // Taxiing crosses into a fresh cold cell every poll — always one - // step ahead of the warm batch — so fall back to LAST poll's cell - // (warmed by last poll's batch; aprons are flat across adjacent - // 111 m cells). Round-5 verify caught taxiing contacts stuck at - // the geoid without this (round 2's lesson, at cell granularity). - if ( - surfaceM == null && - Number.isFinite(prevMeta?.rawLat) && - Number.isFinite(prevMeta?.rawLon) - ) { - surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon); - } - // Grounded contacts near the viewer feed the floor warm/sampler - // (the only ones whose exact height is visible; far contacts are - // subpixel and always-on-top anyway). - if ( - viewerLatDeg != null && - parts.queries._approxDistanceKm( - viewerLatDeg, - viewerLonDeg, - lat, - lon, - ) <= GROUND_FLOOR_CLAMP_RADIUS_KM - ) { - floorWarmPoints.push({ lat, lon }); - } - // Last synchronous resort for a BRAND-NEW grounded contact with NO - // altitude data at all (nothing warm yet, not even the coarse - // cell): the geoid surface. At the sea-level airports where most - // grounded traffic sits, geoidN IS the local ellipsoidal ground to - // within metres — instantly right — and at elevated fields it is - // far less wrong than the raw 0 m ellipsoid default for the one - // poll until the coarse cell warms. STRICTLY gated on "no geo, no - // baro": a reported baro already reflects the field elevation, and - // pickRenderAltitudeM's surfaceM branch would let this crude guess - // outrank it (caught by the ground-3d track regression). - // - // 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds - // one more gate — a contact that already HAS a render height keeps - // it. Leaving surfaceM null routes it through the sentinel path - // below, which holds that height. - if (surfaceM == null) { - surfaceM = geoidSurfaceLastResortM({ - geoAltM: geo_alt, - baroAltM: baro_alt, - priorRenderM: prevMeta?.renderAltitudeM, - geoidN, - }); - } - } - - const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null; - const pickedAltM = pickRenderAltitudeM({ - geoAltM: geoAltitudeM, - baroAltM: Number.isFinite(baro_alt) ? baro_alt : null, - onGround, - surfaceM, - geoidN, - }); - // pickRenderAltitudeM returns the sentinel `null` only when NEITHER - // geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks, - // in priority order: - // (1) hold the previous geoid-corrected render height if we have one — - // a one-poll baro dropout must NOT snap the plane down by the geoid - // undulation N (~46 m in London) and back up next poll. `alt` stays - // sticky for labels, so holding the last render height keeps the two - // layers consistent through the gap. - // (2) otherwise the SAME default policy `alt` already uses, so the two - // never disagree on the genuine "no data yet" case (a never-reported - // aircraft has no prior render height, so it lands here unchanged). - let renderAltitudeM; - if (pickedAltM != null) { - renderAltitudeM = pickedAltM; - } else if (Number.isFinite(prevMeta?.renderAltitudeM)) { - renderAltitudeM = prevMeta.renderAltitudeM; - } else { - renderAltitudeM = alt; - } - // Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3): - // floor a low airborne contact's render height at the local coarse - // ground so it can never dive below the mesh. Round 3 (Austin - // fleet-underground): baro can read BELOW an elevated field — SWA696 - // showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi - // traffic that OpenSky hasn't flagged on_ground yet renders from that - // baro, so the whole fleet sat buried at AUS in 2D. Clamping every - // global contact would need unbounded terrain resolution; instead the - // clamp covers the TRACKED contact (always) plus every low contact - // within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones - // whose burial is visible. Cells warm in one batch after the loop. - // Airborne only (grounded planes keep the surface-cache path above). - if ( - !onGround && - renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M && - (icao24 === flightState._trackedIcao || - (viewerLatDeg != null && - parts.queries._approxDistanceKm( - viewerLatDeg, - viewerLonDeg, - lat, - lon, - ) <= GROUND_FLOOR_CLAMP_RADIUS_KM)) - ) { - renderAltitudeM = floorAltitudeM( - renderAltitudeM, - cachedGroundFloor(lat, lon), - ); - floorWarmPoints.push({ lat, lon }); - } - - const position = Cesium.Cartesian3.fromDegrees( - lon, - lat, - renderAltitudeM, - ); - // Landing/takeoff transition: the on_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); - - // Store flight metadata for click-to-track labels - const cat = stickyNumber(category, prevMeta?.category, null); - const meta = { - sourceReference: observation.reference, - observedReceiptMs: Date.now(), - callsign: stickyText(callsign, prevMeta?.callsign), - altitude: alt, - // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the - // untouched aviation `altitude` — never rename/replace it (labels, - // FL readout, route-plausibility, and follow-camera range math all - // still read `altitude`/baro). - geoAltitudeM, - renderAltitudeM, - onGround, - // Round 7: sticky airborne history — the landed fast-cull only - // applies to contacts that actually flew this session. - wasAirborne: prevMeta?.wasAirborne === true || !onGround, - // Round 6: lifted occlusion-test point for contacts rendering - // at/below the ellipsoid (fleet pass reads it — see the occluder - // note there). Null for the overwhelmingly common airborne case. - cullPosition: - renderAltitudeM < 10 - ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) - : null, - velocity: stickyNumber(velocity, prevMeta?.velocity, 0), - true_track: stickyNumber(true_track, prevMeta?.true_track, 0), - category: cat, - // An adsbdb-enriched type code outranks the coarse OpenSky category. - klass: classifyAircraft({ - typeCode: prevMeta?.typeCode ?? null, - category: cat, - }), - turnRateDps: prevMeta?.turnRateDps || 0, - verticalRate: stickyNumber( - vertical_rate, - prevMeta?.verticalRate, - null, - ), - // Analyst seam: OpenSky origin_country (state[2]) — additive, sticky - // like callsign so a transient blank row doesn't blank the field. - originCountry: - stickyText(origin_country, prevMeta?.originCountry) || null, - // OpenSky distinguishes the last position epoch from the last - // transponder message. The fleet coast horizon uses this actual - // contact time so a temporarily old position does not hard-freeze - // while fresh velocity/track messages are still arriving. - lastContactEpochMs: stickyNumber( - observation.contactTimeMs, - prevMeta?.lastContactEpochMs, - null, - ), - // adsbdb enrichment — written by the enrichment callbacks, carried across polls: - typeCode: prevMeta?.typeCode ?? null, - typeName: prevMeta?.typeName ?? null, - registration: prevMeta?.registration ?? null, - airline: prevMeta?.airline ?? null, - route: prevMeta?.route ?? null, - // The RAW poll fix lat/lon (this tick's OpenSky state-vector - // coords, pre-dead-reckon) — kept distinct from the continuously - // dead-reckoned billboard position for any consumer that needs the - // actual reported fix. - 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 - // (time_position), not client receipt time — OpenSky positions arrive - // 5-15s stale and receipt-time stamping is what caused the - // back/forward oscillation. Only append when the fix actually - // advances, so stale repeats don't create zero-dt segments. - const fixEpochMs = - Number.isFinite(observation.positionTimeMs) && - observation.positionTimeMs > 0 - ? observation.positionTimeMs - : Date.now(); - 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.velocity, - track: meta.true_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 F1) - // so the visible trail keeps growing while tracked. Ground traffic - // appends nothing — a touchdown freezes the existing trail. - // Round 2 (owner): ground traffic appends too — taxi history stays - // live after touchdown (grounded flights positions are already - // surface-clamped via the surfaceM chain, so the ground leg drapes). - if (isTracked) parts.tracking._appendTrailFix(position.clone()); - } else { - const modelOwnsGroundVisual = - parts.rendering._modelOwnsVisual(icao24); - if (!modelOwnsGroundVisual) { - liftRepeatedGroundFix(newest, position, meta.onGround); - } - // Apply fresh kinematics only from a forward synthetic fix. Mutating - // the historical fix reprojects the entire stale interval and snaps - // the rendered aircraft when course or speed changes late. - const kinematicsChanged = - newest.velocity !== meta.velocity || - newest.track !== meta.true_track; - if (kinematicsChanged) { - const synthetic = synthesizeForwardKinematicsFix(newest, { - epochMs: Date.now(), - velocity: meta.velocity, - track: meta.true_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); - // 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 = parts.rendering - ._fleetBillboardColor(icao24) - .withAlpha(bb.color?.alpha ?? 1); - } - if ( - prevMeta?.klass !== meta.klass || - groundFlipped || - flightState._cockpitContactMode - ) { - parts.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) - parts.rendering._syncModelToClass(icao24); - } else { - const bb = flightState._billboardCollection.add({ - position, - image: aircraftIcon( - parts.rendering._iconKind(icao24, meta.klass), - ), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: parts.rendering._fleetBillboardScale(icao24, meta.klass), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked - ? Cesium.Color.CYAN - : parts.rendering._fleetBillboardColor(icao24), - 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) { - parts.tracking._updateTrackedLabelModel(icao24); - } - } - - // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. - // OpenSky routinely drops aircraft for a single poll; immediate removal - // made planes 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, _trackedLabelText appends the STALE cue so - // last-known velocity/altitude 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); - flightState._displayFloorState.delete(icao24); - } - - // Fresh courses arrived — force a rotation pass on the next fleet tick - flightState._lastCamPoseSig = ''; - - // Ambient type enrichment: give ON-SCREEN planes real types (bounded - // sweep — see _sweepAmbientEnrichment; internally fail-silent). - parts.enrichment._sweepAmbientEnrichment(); - - // 2026-08-19: the loop above only ever collects FIX cells, but a grounded - // contact renders across every cell its dead-reckoned position drifts - // through. Add those too, so the display clamp has data where the contact - // actually is instead of silently passing. - parts.motion._collectDisplayCorridorCells( - floorWarmPoints, - viewerLatDeg, - viewerLonDeg, - ); - - // Field-test round 3: one batch warm of the viewer-proximate low-contact - // floor cells collected in the loop (fire-and-forget, single-flight; - // read synchronously by NEXT poll's clamp — the military-layer pattern). - warmGroundFloor(floorWarmPoints); - // Round 4: sample the RENDERED mesh for those same cells (one-shot per - // cell, budget-capped, viewer-proximate, google-3d regime only). Own - // billboards/models are excluded so a vertical probe can't land on an - // aircraft instead of the pavement. - sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { - excludeObjects: [ - ...flightState._billboards.values(), - ...flightState._models.values(), - flightState._trackedModel, - ].filter(Boolean), - viewerLat: viewerLatDeg, - viewerLon: viewerLonDeg, - }); - - // Round 5: the grounded exact-key warm that used to live here is gone — - // see the note where _warmGroundedAircraftSurfaceCache was removed. The - // viewer-proximate coarse warm + mesh sampler above cover everything - // whose height is actually visible. - // Round 6: re-floor STALE grounded contacts. A parked plane whose - // transponder went quiet stops receiving poll updates, so a floor that - // warms AFTER its last fix never applied — it sat frozen at the geoid - // (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever). - // Grounded contacts are static, so lifting the stored fix + billboard - // in place is safe (the DR extrapolates a zero-velocity fix). - parts.motion._refloorStaleGroundedContacts(currentIcaos); - - flightState._count = flightState._billboards.size; + feed._lastSource = snapshot.source; + feed._lastCoverage = snapshot.coverage; + setSourceLabel(feed._lastSource); + const accepted = applySnapshot(snapshot, viewer); + feed._count = accepted.count; // Freshness belongs to the source snapshot, not the moment this browser // received a cached 200 response. - flightState._lastUpdate = sourceEpochMs; - flightState._lastTrackingRefreshOutcome = { + feed._lastUpdate = sourceEpochMs; + feed._lastTrackingRefreshOutcome = { epoch: trackingRefreshEpoch, status: 'accepted', - ids: acceptedSnapshotIcaos, - source: flightState._lastSource, - coverage: flightState._lastCoverage, + ids: accepted.ids, + source: feed._lastSource, + coverage: feed._lastCoverage, }; - console.log(`[Data:Flights] Updated: ${flightState._count} aircraft`); - parts.tracking._applyPendingTrackingRestore(); + console.log(`[Data:Flights] Updated: ${feed._count} aircraft`); + applyPendingTrackingRestore(); } catch (e) { if (updateSignal.aborted || e?.name === 'AbortError') { throw new DOMException('Flights update aborted', 'AbortError'); } console.warn('[Data:Flights] Fetch error:', e); - flightState._backoff = true; - flightState._retryAt = + feed._backoff = true; + feed._retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); - flightState._lastStatus = e?.status ?? null; + feed._lastStatus = e?.status ?? null; if (e?.source) { - flightState._lastSource = e.source; - this.source = flightState._lastSource; + feed._lastSource = e.source; + setSourceLabel(feed._lastSource); } - flightState._lastError = + feed._lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; } finally { - flightState._activeUpdateControllers.delete(resourceController); + feed._activeUpdateControllers.delete(resourceController); } }, }; return { methods }; } + +/** Construct a fresh source lifetime and its refresh status. */ +export function createFlightFeed(source) { + const feed = {}; + feed._source = source; + feed._count = 0; + feed._lastUpdate = null; + feed._backoff = false; + feed._retryAt = 0; + feed._lastError = null; + feed._activeUpdateControllers = new Set(); + feed._lastStatus = null; + feed._lastSource = source?.label || 'Aircraft'; + feed._lastCoverage = 'worldwide upstream snapshot'; + feed._trackingRefreshEpoch = 0; + feed._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: feed._lastSource, + coverage: null, + }; + return feed; +} diff --git a/src/layers/flights/ingestion.test.mjs b/src/layers/flights/ingestion.test.mjs new file mode 100644 index 000000000..18cb50d09 --- /dev/null +++ b/src/layers/flights/ingestion.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createIngestion } from './ingestion.js'; + +function setup(source) { + const feed = { + _source: source, + _activeUpdateControllers: new Set(), + _trackingRefreshEpoch: 0, + _lastSource: 'Fixture', + _lastCoverage: 'local', + _retryAt: 0, + }; + const accepted = []; + const labels = []; + const restores = []; + const { methods } = createIngestion({ + feed, + getQuery: () => ({ latitude: 39 }), + applySnapshot: (snapshot) => { + accepted.push(snapshot); + return { count: 2, ids: new Set(['abc123']) }; + }, + setSourceLabel: (label) => labels.push(label), + applyPendingTrackingRestore: () => restores.push(true), + }); + return { feed, accepted, labels, restores, update: methods.update }; +} + +test('civil acquisition rejects late completion after cancellation without publishing records', async () => { + let release; + const probe = setup({ + getSnapshot: () => + new Promise((resolve) => { + release = resolve; + }), + }); + const request = probe.update(null); + assert.equal(probe.feed._activeUpdateControllers.size, 1); + for (const controller of probe.feed._activeUpdateControllers) + controller.abort(); + release({ records: [] }); + await assert.rejects(request, { name: 'AbortError' }); + assert.equal(probe.accepted.length, 0); + assert.equal(probe.restores.length, 0); + assert.equal(probe.feed._activeUpdateControllers.size, 0); +}); + +test('civil acquisition publishes source time and uses a replaced source on the next request', async () => { + let calls = 0; + const probe = setup({ + async getSnapshot(query) { + assert.equal(query.latitude, 39); + calls += 1; + return { + records: [], + source: 'First', + observedAtMs: 1234, + freshness: 'fresh', + coverage: 'worldwide', + }; + }, + }); + await probe.update(null); + assert.equal(probe.feed._lastUpdate, 1234); + assert.equal(probe.feed._count, 2); + assert.deepEqual([...probe.feed._lastTrackingRefreshOutcome.ids], ['abc123']); + probe.feed._source = { + async getSnapshot() { + return { + records: [], + source: 'Second', + observedAtMs: 1250, + freshness: 'unknown', + }; + }, + }; + await probe.update(null); + assert.equal(calls, 1); + assert.equal(probe.feed._lastUpdate, 1250); + assert.equal(probe.feed._backoff, true); + assert.equal(probe.feed._lastError, 'Source snapshot time unavailable'); + assert.deepEqual(probe.labels, ['First', 'Second']); + assert.equal(probe.restores.length, 2); +}); diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 9dff82f89..0ca944647 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -26,9 +26,10 @@ export function createLifecycle({ 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; + flightState.feed._source = source; + flightState.feed._lastSource = + source.label || flightState.feed._lastSource; + this.source = flightState.feed._lastSource; }, /** @@ -40,7 +41,7 @@ export function createLifecycle({ init(viewer) { if (flightState._viewer) throw new Error('Flight layer is already initialized'); - if (typeof flightState._source?.getSnapshot !== 'function') + if (typeof flightState.feed._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); if (flightState.lifetime.signal.aborted) flightState.lifetime = new AbortController(); @@ -78,19 +79,21 @@ export function createLifecycle({ }); } flightState._billboards = new Map(); + flightState._cullPositions.clear(); flightState._detectionObjects = new Map(); - flightState._flightData = new Map(); + flightState.records.data = 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._lastSource = flightState._source.label || 'Aircraft'; - flightState._lastCoverage = 'worldwide upstream snapshot'; + flightState.feed._count = 0; + flightState.feed._lastUpdate = null; + flightState.feed._backoff = false; + flightState.feed._retryAt = 0; + flightState.feed._lastError = null; + flightState.feed._lastStatus = null; + flightState.feed._lastSource = + flightState.feed._source.label || 'Aircraft'; + flightState.feed._lastCoverage = 'worldwide upstream snapshot'; flightState._trackedIcao = null; parts.tracking._resetTrackedSelectionState(); flightState._trackedEntity = null; @@ -142,11 +145,11 @@ export function createLifecycle({ // 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) { + if (!flightState.records.geoidReady) { const lifetime = flightState.lifetime; ensureGeoidReady() .then(() => { - if (!lifetime.signal.aborted) flightState._geoidReady = true; + if (!lifetime.signal.aborted) flightState.records.geoidReady = true; }) .catch(() => { /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ @@ -300,8 +303,9 @@ export function createLifecycle({ } flightState._planeModelLoaded = false; flightState._billboards.clear(); + flightState._cullPositions.clear(); flightState._detectionObjects.clear(); - flightState._flightData.clear(); + flightState.records.data.clear(); flightState._positionHistory.clear(); flightState._displayCourse.clear(); flightState._groundSnap.clear(); @@ -312,20 +316,20 @@ export function createLifecycle({ clearTimeout(flightState._enrichDripTimer); flightState._enrichDripTimer = null; } - flightState._missingPolls.clear(); + flightState.records.missingPolls.clear(); flightState._focusEvidenceIds.clear(); - flightState._count = 0; - flightState._lastUpdate = null; + flightState.feed._count = 0; + flightState.feed._lastUpdate = null; flightState._cockpitContactMode = false; flightState._cockpitNearContacts = new Set(); flightState._cockpitSubjectId = null; - flightState._trackingRefreshEpoch += 1; - flightState._lastTrackingRefreshOutcome = { - epoch: flightState._trackingRefreshEpoch, + flightState.feed._trackingRefreshEpoch += 1; + flightState.feed._lastTrackingRefreshOutcome = { + epoch: flightState.feed._trackingRefreshEpoch, status: 'destroyed', ids: new Set(), - source: flightState._lastSource, - coverage: flightState._lastCoverage, + source: flightState.feed._lastSource, + coverage: flightState.feed._lastCoverage, }; parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling flightState._viewer = null; diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 42de84532..ca7e9fe9d 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -81,7 +81,7 @@ export function createMotion({ */ function _refloorStaleGroundedContacts(currentIcaos) { - for (const [icao24, info] of flightState._flightData) { + for (const [icao24, info] of flightState.records.data) { if (!info?.onGround || currentIcaos.has(icao24)) continue; if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) continue; @@ -94,7 +94,7 @@ export function createMotion({ ) continue; info.renderAltitudeM = lifted; - info.cullPosition = null; // above the ellipsoid now (or floors say otherwise next poll) + flightState._cullPositions.delete(icao24); // above the ellipsoid now (or floors say otherwise next poll) const position = Cesium.Cartesian3.fromDegrees( info.rawLon, info.rawLat, @@ -125,7 +125,7 @@ export function createMotion({ */ function _deadReckon(icao24, result) { - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { const position = flightState._billboards.get(icao24)?.position; flightState._drCourseDeg = info?.true_track || 0; @@ -323,7 +323,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState._flightData.get(icao24)?.klass, + flightState.records.data.get(icao24)?.klass, ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -349,7 +349,7 @@ export function createMotion({ const height = billboard?.height?.getValue(time) ?? 28; const scale = billboard?.scale?.getValue(time) ?? - (CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1); + (CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1); const scaleByDistance = billboard?.scaleByDistance?.getValue(time) ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE; @@ -392,7 +392,7 @@ export function createMotion({ } const nowMs = Date.now(); - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (sameTrack) { const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000); const speed = (info && info.velocity) || 0; @@ -499,7 +499,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -544,7 +544,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -558,7 +558,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.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 @@ -584,7 +584,7 @@ export function createMotion({ * the same aircraft can never disagree across the handoff. */ function _trackedDisplayCourse() { - const info = flightState._flightData.get(flightState._trackedIcao); + const info = flightState.records.data.get(flightState._trackedIcao); const fallback = (info && info.true_track) || 0; const cacheValid = flightState._drReconcileValid && @@ -1018,7 +1018,7 @@ export function createMotion({ } const candidates = []; - for (const [icao24, info] of flightState._flightData) { + for (const [icao24, info] of flightState.records.data) { if (!info?.onGround) continue; // T7: a contact whose 3D model is the visual never reads a display floor. if (parts.rendering._modelOwnsVisual(icao24)) continue; diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index c951616bb..813df5641 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -56,18 +56,25 @@ test('flight state owns distinct mutable records, scratch objects and floor samp const first = createFlightState({ services: services() }); const second = createFlightState({ services: services() }); for (const key of [ - '_flightData', + 'records', + 'feed', '_billboards', '_positionHistory', '_displayFloorState', '_groundSnap', '_scratchCarto', '_models', - '_activeUpdateControllers', 'lifetime', ]) { assert.notEqual(first[key], second[key], key); } + assert.notEqual(first.records.data, second.records.data); + assert.notEqual(first.records.missingPolls, second.records.missingPolls); + assert.notEqual(first.records.geoidNCache, second.records.geoidNCache); + assert.notEqual( + first.feed._activeUpdateControllers, + second.feed._activeUpdateControllers, + ); }); test('aborted enrichment cannot write into a later lifecycle or decrement its active count', async () => { diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 6ea4f0e77..f8358c98f 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -1,3 +1,11 @@ +export { + GROUND_FLOOR_CLAMP_RADIUS_KM, + GROUND_FLOOR_WARM_MAX_ALT_M, + POSITION_HISTORY_LIMIT, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, + ERROR_BACKOFF_INTERVAL, +} from './recordPolicy.js'; import * as Cesium from 'cesium'; export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; @@ -101,14 +109,6 @@ export const MODEL_BELLY_OFFSET_NATIVE = 6.719; export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); -/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */ - -export const ERROR_BACKOFF_INTERVAL = 20000; -// transient error retry -/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */ - -export const POSITION_HISTORY_LIMIT = 5; - export const COCKPIT_CONTACT_SIZE_PX = 6; export const COCKPIT_CIVILIAN_COLOR = @@ -151,8 +151,6 @@ export const RENDER_DELAY_SEC = 30; /** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */ -export const MISSING_POLL_LIMIT = 3; - // --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes // lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS // the actual landing, so a landed plane's last airborne-classified fixes show @@ -174,8 +172,6 @@ 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 rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only // contacts rendering below the alt ceiling are ever clamped/warmed (terrain // tops out well under it outside the extreme Himalaya; cruise traffic can't @@ -184,12 +180,8 @@ export const LANDED_MISSING_POLL_LIMIT = 1; // need unbounded terrain resolution (the tracked contact clamps regardless). /** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ -export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; - /** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */ -export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150; - // --------------------------------------------------------------------------- // Icon orientation (2026-06-10 playtest fix): rotation is computed by // projecting each aircraft's course vector into WINDOW coordinates diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 83657ee3d..5b64b2247 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -1,3 +1,4 @@ +import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js'; import { displayedKinematics } from '../../data/motionModel.js'; import * as Cesium from 'cesium'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; @@ -43,13 +44,6 @@ export function createQueries({ * @returns {number} Approximate great-circle distance in km. */ - function _approxDistanceKm(lat1, lon1, lat2, lon2) { - const dLat = (lat2 - lat1) * 111.32; - const dLon = - (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); - return Math.hypot(dLat, dLon); - } - /** * 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 @@ -59,7 +53,7 @@ export function createQueries({ */ function _likelyLanded(icao24) { - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (!info) return false; // Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes // never heal"): the fast cull only applies to contacts that were AIRBORNE @@ -136,7 +130,7 @@ export function createQueries({ // user-visible surface renders `position` as the plane's on-screen location — // every visual consumer reads the floored per-frame cache instead (see // `_trackedDisplayPosition`). If that ever changes, floor this path too. - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); const bb = flightState._billboards.get(icao24); const basePos = parts.motion._deadReckon(icao24) || (bb ? bb.position : null); @@ -170,7 +164,8 @@ export function createQueries({ velocityMps: displayed.speedMps, track: displayed.trackDeg, stale: Boolean( - flightState._missingPolls.get(icao24) || flightState._backoff, + flightState.records.missingPolls.get(icao24) || + flightState.feed._backoff, ), airline: info?.airline ?? null, // CLASS label follows the TR-3B conversion so every downstream card @@ -271,7 +266,7 @@ export function createQueries({ icon: '✈️', - source: flightState._lastSource, + source: flightState.feed._lastSource, // Browser-harness seam: isolates synthetic display-floor scenarios without // changing any production lifecycle or cache policy. @@ -429,7 +424,7 @@ export function createQueries({ 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 info = flightState.records.data.get(icao24); let object = flightState._detectionObjects.get(icao24); if (!object) { object = { @@ -496,7 +491,7 @@ export function createQueries({ * Best match with a cloned, dead-reckoned position, or null if none. */ findByQuery(query) { - if (!flightState._flightData || flightState._flightData.size === 0) + if (!flightState.records.data || flightState.records.data.size === 0) return null; const q = String(query || '') .trim() @@ -513,7 +508,7 @@ export function createQueries({ // 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) { + for (const [icao24, info] of flightState.records.data) { const candidate = { tier: rankContactMatch({ query: q, @@ -580,7 +575,7 @@ export function createQueries({ const distance = Cesium.Cartesian3.distance(center, pos); if (distance > maxRange) continue; - const info = flightState._flightData.get(icao24); + 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). @@ -664,7 +659,7 @@ export function createQueries({ flightState._scratchCarto, ); if (!carto) continue; - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); result.push({ id: icao24, // identity (trackById resolves this) label: _contactLabel(icao24, info), @@ -703,14 +698,14 @@ export function createQueries({ if ( !flightState._billboardCollection || !flightState._billboardCollection.show || - flightState._flightData.size === 0 + flightState.records.data.size === 0 ) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; const result = []; - for (const [icao24, info] of flightState._flightData) { + for (const [icao24, info] of flightState.records.data) { const routeOk = !!info?.route && parts.tracking._routeIsPlausible(icao24, info.route); result.push( @@ -754,7 +749,7 @@ export function createQueries({ }; const id = _normalizeTrackedIcao(icao24); if (!id) return { status: 'missing', reason: 'invalid-target' }; - const outcome = flightState._lastTrackingRefreshOutcome; + const outcome = flightState.feed._lastTrackingRefreshOutcome; if (outcome.status !== 'accepted') { return { status: 'source-unavailable', @@ -925,18 +920,21 @@ export function createQueries({ * @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)) + const retryInSec = flightState.feed._retryAt + ? Math.max( + 0, + Math.ceil((flightState.feed._retryAt - Date.now()) / 1000), + ) : 0; return { - count: flightState._count, - lastUpdate: flightState._lastUpdate, - stale: flightState._backoff, - error: flightState._lastError, - status: flightState._lastStatus, + count: flightState.feed._count, + lastUpdate: flightState.feed._lastUpdate, + stale: flightState.feed._backoff, + error: flightState.feed._lastError, + status: flightState.feed._lastStatus, retryInSec, - source: flightState._lastSource, - coverage: flightState._lastCoverage, + source: flightState.feed._lastSource, + coverage: flightState.feed._lastCoverage, }; }, }; diff --git a/src/layers/flights/recordPolicy.js b/src/layers/flights/recordPolicy.js new file mode 100644 index 000000000..ea284cb11 --- /dev/null +++ b/src/layers/flights/recordPolicy.js @@ -0,0 +1,21 @@ +export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150; + +export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; + +/** Maximum feed fixes retained for dead reckoning. */ +export const POSITION_HISTORY_LIMIT = 5; + +export const LANDED_MISSING_POLL_LIMIT = 1; + +export const MISSING_POLL_LIMIT = 3; + +/** Cooldown in milliseconds after a transient source error. */ +export const ERROR_BACKOFF_INTERVAL = 20000; + +/** Local equirectangular distance used for viewer-proximate floor sampling. */ +export function approxDistanceKm(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * 111.32; + const dLon = + (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + return Math.hypot(dLat, dLon); +} diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js new file mode 100644 index 000000000..43809f516 --- /dev/null +++ b/src/layers/flights/records.js @@ -0,0 +1,283 @@ +import { stickyNumber, stickyText } from '../../data/aircraftMeta.js'; +import { + geoidSurfaceLastResortM, + pickRenderAltitudeM, +} from '../../data/renderAltitude.js'; +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { + approxDistanceKm, + GROUND_FLOOR_CLAMP_RADIUS_KM, + GROUND_FLOOR_WARM_MAX_ALT_M, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, +} from './recordPolicy.js'; + +/** Source-independent aircraft metadata and bounded missing-poll retention. */ +export class FlightRecords { + constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) { + this.services = { + geoidHeight, + cachedGroundFloor, + floorAltitudeM, + }; + this.data = new Map(); + this.missingPolls = new Map(); + this.geoidNCache = new Map(); + this.geoidReady = false; + } + + receive( + observation, + { viewerLatDeg, viewerLonDeg, trackedId, floorWarmPoints }, + ) { + const { geoidHeight, cachedGroundFloor, floorAltitudeM } = this.services; + const { + id: icao24, + callsign, + originCountry: origin_country, + longitude: lon, + latitude: lat, + baroAltitudeM: baro_alt, + onGround: on_ground, + speedMps: velocity, + courseDeg: true_track, + ellipsoidAltitudeM: geo_alt, + category, + verticalRateMps: vertical_rate, + } = observation; + + const onGround = on_ground === true; + this.missingPolls.delete(icao24); + // Sticky merge: OpenSky intermittently drops callsign/velocity/track for + // aircraft it still positions — hold last-known-good instead of + // regressing to the ICAO hex / a 0° (north) heading. Bounded by the + // MISSING_POLL_LIMIT eviction below, which deletes the whole entry. + const prevMeta = this.data.get(icao24); + // Grounded planes with no baro reading sit at 0 m, not the 10 km + // airborne default (a parked plane must never float). + // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky + // barometric/MSL altitude read by labels (FL/altitude readout), + // route-plausibility, and follow-camera range heuristics. It is + // NEVER overwritten or renamed. Where the aircraft actually RENDERS + // on the ellipsoidal globe is a SEPARATE value (renderAltitudeM, + // below) — geo_altitude when OpenSky reports it (already WGS84 + // ellipsoidal), else baro+geoid as a visual fallback, else ground + // surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM, + // never `alt` directly. + const alt = stickyNumber( + baro_alt, + prevMeta?.altitude, + onGround ? 0 : 10000, + ); + + // 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 = this.geoidNCache.get(icao24); + if (geoidN === undefined && this.geoidReady) { + geoidN = geoidHeight(lat, lon); + this.geoidNCache.set(icao24, geoidN); + } + + // on_ground surface prior: ONLY synchronous warm-cache reads here — + // never a per-aircraft network fetch inside the poll loop (see the + // batch resolve call below, which fills this cache for NEXT poll). A + // Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied): + // the grounded surface is the round-4 choke point and nothing else — + // rendered-mesh cell first, real (never fallback-poisoned) DEM cell + // second. The old exact-5-decimal warm chain is GONE: it minted a new + // key per parked-jitter poll for every grounded contact ON EARTH, + // hammering Re:Earth into the very failures that poisoned the cache. + let surfaceM = null; + if (onGround) { + surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell) + // Taxiing crosses into a fresh cold cell every poll — always one + // step ahead of the warm batch — so fall back to LAST poll's cell + // (warmed by last poll's batch; aprons are flat across adjacent + // 111 m cells). Round-5 verify caught taxiing contacts stuck at + // the geoid without this (round 2's lesson, at cell granularity). + if ( + surfaceM == null && + Number.isFinite(prevMeta?.rawLat) && + Number.isFinite(prevMeta?.rawLon) + ) { + surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon); + } + // Grounded contacts near the viewer feed the floor warm/sampler + // (the only ones whose exact height is visible; far contacts are + // subpixel and always-on-top anyway). + if ( + viewerLatDeg != null && + approxDistanceKm(viewerLatDeg, viewerLonDeg, lat, lon) <= + GROUND_FLOOR_CLAMP_RADIUS_KM + ) { + floorWarmPoints.push({ lat, lon }); + } + // Last synchronous resort for a BRAND-NEW grounded contact with NO + // altitude data at all (nothing warm yet, not even the coarse + // cell): the geoid surface. At the sea-level airports where most + // grounded traffic sits, geoidN IS the local ellipsoidal ground to + // within metres — instantly right — and at elevated fields it is + // far less wrong than the raw 0 m ellipsoid default for the one + // poll until the coarse cell warms. STRICTLY gated on "no geo, no + // baro": a reported baro already reflects the field elevation, and + // pickRenderAltitudeM's surfaceM branch would let this crude guess + // outrank it (caught by the ground-3d track regression). + // + // 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds + // one more gate — a contact that already HAS a render height keeps + // it. Leaving surfaceM null routes it through the sentinel path + // below, which holds that height. + if (surfaceM == null) { + surfaceM = geoidSurfaceLastResortM({ + geoAltM: geo_alt, + baroAltM: baro_alt, + priorRenderM: prevMeta?.renderAltitudeM, + geoidN, + }); + } + } + + const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null; + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: Number.isFinite(baro_alt) ? baro_alt : null, + onGround, + surfaceM, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks, + // in priority order: + // (1) hold the previous geoid-corrected render height if we have one — + // a one-poll baro dropout must NOT snap the plane down by the geoid + // undulation N (~46 m in London) and back up next poll. `alt` stays + // sticky for labels, so holding the last render height keeps the two + // layers consistent through the gap. + // (2) otherwise the SAME default policy `alt` already uses, so the two + // never disagree on the genuine "no data yet" case (a never-reported + // aircraft has no prior render height, so it lands here unchanged). + let renderAltitudeM; + if (pickedAltM != null) { + renderAltitudeM = pickedAltM; + } else if (Number.isFinite(prevMeta?.renderAltitudeM)) { + renderAltitudeM = prevMeta.renderAltitudeM; + } else { + renderAltitudeM = alt; + } + // Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3): + // floor a low airborne contact's render height at the local coarse + // ground so it can never dive below the mesh. Round 3 (Austin + // fleet-underground): baro can read BELOW an elevated field — SWA696 + // showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi + // traffic that OpenSky hasn't flagged on_ground yet renders from that + // baro, so the whole fleet sat buried at AUS in 2D. Clamping every + // global contact would need unbounded terrain resolution; instead the + // clamp covers the TRACKED contact (always) plus every low contact + // within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones + // whose burial is visible. Cells warm in one batch after the loop. + // Airborne only (grounded planes keep the surface-cache path above). + if ( + !onGround && + renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M && + (icao24 === trackedId || + (viewerLatDeg != null && + approxDistanceKm(viewerLatDeg, viewerLonDeg, lat, lon) <= + GROUND_FLOOR_CLAMP_RADIUS_KM)) + ) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + floorWarmPoints.push({ lat, lon }); + } + + // Landing/takeoff transition: the on_ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Store flight metadata for click-to-track labels + const cat = stickyNumber(category, prevMeta?.category, null); + const meta = { + sourceReference: observation.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + altitude: alt, + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitude` — never rename/replace it (labels, + // FL readout, route-plausibility, and follow-camera range math all + // still read `altitude`/baro). + geoAltitudeM, + renderAltitudeM, + onGround, + // Round 7: sticky airborne history — the landed fast-cull only + // applies to contacts that actually flew this session. + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + velocity: stickyNumber(velocity, prevMeta?.velocity, 0), + true_track: stickyNumber(true_track, prevMeta?.true_track, 0), + category: cat, + // An adsbdb-enriched type code outranks the coarse OpenSky category. + klass: classifyAircraft({ + typeCode: prevMeta?.typeCode ?? null, + category: cat, + }), + turnRateDps: prevMeta?.turnRateDps || 0, + verticalRate: stickyNumber(vertical_rate, prevMeta?.verticalRate, null), + // Analyst seam: OpenSky origin_country (state[2]) — additive, sticky + // like callsign so a transient blank row doesn't blank the field. + originCountry: + stickyText(origin_country, prevMeta?.originCountry) || null, + // OpenSky distinguishes the last position epoch from the last + // transponder message. The fleet coast horizon uses this actual + // contact time so a temporarily old position does not hard-freeze + // while fresh velocity/track messages are still arriving. + lastContactEpochMs: stickyNumber( + observation.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + // adsbdb enrichment — written by the enrichment callbacks, carried across polls: + typeCode: prevMeta?.typeCode ?? null, + typeName: prevMeta?.typeName ?? null, + registration: prevMeta?.registration ?? null, + airline: prevMeta?.airline ?? null, + route: prevMeta?.route ?? null, + // The RAW poll fix lat/lon (this tick's OpenSky state-vector + // coords, pre-dead-reckon) — kept distinct from the continuously + // dead-reckoned billboard position for any consumer that needs the + // actual reported fix. + rawLat: lat, + rawLon: lon, + }; + this.data.set(icao24, meta); + + const fixEpochMs = + Number.isFinite(observation.positionTimeMs) && + observation.positionTimeMs > 0 + ? observation.positionTimeMs + : Date.now(); + return { icao24, prevMeta, meta, groundFlipped, fixEpochMs }; + } + + absence(id, { complete, likelyLanded }) { + // Partial admissions do not prove absence, but retention remains bounded. + if ( + !complete && + Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000 + ) + return 'retain'; + const misses = (this.missingPolls.get(id) || 0) + 1; + const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; + if (misses < limit) { + this.missingPolls.set(id, misses); + return 'stale'; + } + this.missingPolls.delete(id); + return 'remove'; + } + + forget(id) { + this.data.delete(id); + this.missingPolls.delete(id); + this.geoidNCache.delete(id); + } +} diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs new file mode 100644 index 000000000..d001c7811 --- /dev/null +++ b/src/layers/flights/records.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FlightRecords } from './records.js'; + +function records() { + return new FlightRecords({ + geoidHeight: () => 40, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + approxDistanceKm: () => 0, + }); +} +const view = () => ({ + viewerLatDeg: null, + viewerLonDeg: null, + trackedId: null, + floorWarmPoints: [], +}); +const observation = (fields = {}) => ({ + id: 'abc123', + reference: 'abc123', + longitude: -77, + latitude: 39, + callsign: 'TEST1', + baroAltitudeM: 1000, + ellipsoidAltitudeM: null, + onGround: false, + speedMps: 120, + courseDeg: 90, + positionTimeMs: 1700000000000, + ...fields, +}); + +test('civil records keep aviation units separate from render height and survive missing fields', () => { + const store = records(); + store.geoidReady = true; + const first = store.receive(observation(), view()); + assert.equal(first.meta.altitude, 1000); + assert.equal(first.meta.renderAltitudeM, 1040); + assert.equal(first.fixEpochMs, 1700000000000); + first.meta.typeCode = 'B738'; + const second = store.receive( + observation({ + callsign: '', + baroAltitudeM: null, + speedMps: null, + courseDeg: null, + }), + view(), + ); + assert.equal(second.prevMeta, first.meta); + assert.equal(second.meta.callsign, 'TEST1'); + assert.equal(second.meta.altitude, 1000); + assert.equal(second.meta.renderAltitudeM, 1040); + assert.equal(second.meta.velocity, 120); + assert.equal(second.meta.true_track, 90); + assert.equal(second.meta.typeCode, 'B738'); + assert.equal(Object.hasOwn(second.meta, 'cullPosition'), false); + assert.deepEqual(JSON.parse(JSON.stringify(second.meta)), second.meta); +}); + +test('civil record owners isolate state and preserve partial, missing-poll and landed retention', () => { + const store = records(); + const other = records(); + const { meta } = store.receive(observation(), view()); + assert.equal(other.data.size, 0); + assert.equal( + store.absence('abc123', { complete: false, likelyLanded: false }), + 'retain', + ); + assert.equal(store.missingPolls.size, 0); + meta.observedReceiptMs = Date.now() - 300001; + assert.equal( + store.absence('abc123', { complete: false, likelyLanded: false }), + 'stale', + ); + assert.equal( + store.absence('abc123', { complete: true, likelyLanded: false }), + 'stale', + ); + assert.equal( + store.absence('abc123', { complete: true, likelyLanded: false }), + 'remove', + ); + // Admission decides removal; follow teardown can still read the record first. + assert.equal(store.data.get('abc123'), meta); + store.forget('abc123'); + assert.equal(store.data.size, 0); + assert.equal(store.missingPolls.size, 0); + assert.equal(store.geoidNCache.size, 0); + store.receive(observation(), view()); + assert.equal( + store.absence('abc123', { complete: true, likelyLanded: true }), + 'remove', + ); +}); + +test('ground transitions preserve last render height while a surface cell is cold', () => { + const store = records(); + store.receive(observation({ ellipsoidAltitudeM: 1040 }), view()); + const next = store.receive( + observation({ onGround: true, baroAltitudeM: null }), + view(), + ); + assert.equal(next.groundFlipped, true); + assert.equal(next.meta.renderAltitudeM, 1040); + assert.equal(next.meta.wasAirborne, true); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 761d47536..74ee4541b 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -77,7 +77,7 @@ export function createRendering({ function _fleetBillboardScale(icao24, klass) { return ( (CLASS_SCALE_2D[klass] || 1) * - (flightState._flightData.get(icao24)?.onGround ? GROUND_SCALE : 1) + (flightState.records.data.get(icao24)?.onGround ? GROUND_SCALE : 1) ); } @@ -171,7 +171,7 @@ export function createRendering({ return; } - const meta = flightState._flightData.get(icao24); + const meta = flightState.records.data.get(icao24); bb.image = aircraftIcon( _iconKind(icao24, meta?.klass), bb._gevIconLarge ? TRACKED_ICON_PX : undefined, @@ -303,7 +303,7 @@ export function createRendering({ * because a resample is mid-backoff. */ function _modelDisplayPosition(icao24, pos, result) { - const meta = flightState._flightData.get(icao24); + const meta = flightState.records.data.get(icao24); if (!meta || !meta.onGround) return pos; const h = flightState._groundSnap.heightFor( flightState._viewer, @@ -438,7 +438,7 @@ export function createRendering({ * the same rule (its billboard entity is always the fallback visual). */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -510,10 +510,10 @@ export function createRendering({ // aircraft mid-load, the post-await admission below rejects the stale asset. // Boost state likewise: the creation options bake it in, so a mid-load // toggle must reject too (the reload queue only covers ADMITTED models). - const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState._flightData.get(icao24)?.klass); + const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); model = await Cesium.Model.fromGltfAsync({ url: resolveAsset(spec.url), asynchronous: false, @@ -559,12 +559,12 @@ export function createRendering({ !_modelRegimeActive() || !flightState._modelCollection || flightState._modelCollection.isDestroyed() || - !flightState._flightData.has(icao24) || + !flightState.records.data.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 || + _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -714,10 +714,10 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); const trackedKey = _specKeyFor( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); const trackedIrBoost = flightState._irBoost; Cesium.Model.fromGltfAsync({ @@ -756,7 +756,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -829,7 +829,7 @@ export function createRendering({ ); if (!flightState._trackedModel.ready) return; const spec = _modelSpec( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); flightState._trackedModel.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -989,7 +989,7 @@ export function createRendering({ for (const [icao24, bb] of flightState._billboards) { if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); // The dead-reckoned point drifts away from the fix whose cell supplied the @@ -1018,7 +1018,7 @@ export function createRendering({ // pass would hide a plane that is really just low over high-N terrain // waiting for its floor to warm (ATL grounded contacts at geoid −31 m). const beyondHorizon = !occluder.isPointVisible( - info?.cullPosition || bb.position, + flightState._cullPositions.get(icao24) || bb.position, ); // A billboard flipping INTO view (horizon reveal while the camera idles) // gets its rotation refreshed THIS tick even without a pose change — @@ -1074,7 +1074,7 @@ export function createRendering({ flightState._cockpitContactMode && !isCockpitNear ? 1 : _fleetBillboardScale(icao24, info?.klass), - baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1, baseColor, focusFactor: focus.factor, cameraDistanceM, diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js new file mode 100644 index 000000000..626488a90 --- /dev/null +++ b/src/layers/flights/snapshotRenderer.js @@ -0,0 +1,313 @@ +import * as Cesium from 'cesium'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; + +/** Apply reconciled aircraft records to Cesium resources and follow state. */ +export function createFlightSnapshotRenderer({ + flightState, + records, + militaryRegistry, + groundFloor, + meshFloor, + rendering, + tracking, + motion, + enrichment, + queries, +}) { + const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry; + const { warmGroundFloor } = groundFloor; + const { sampleMeshFloorCells } = meshFloor; + return function applySnapshot(snapshot, viewer) { + 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._viewer)?.camera?.positionCartographic || null; + const viewerLatDeg = viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : null; + const viewerLonDeg = viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : null; + const floorWarmPoints = []; + + // Classification and display policy consume source-independent observations. + refreshMilitaryRegistryIfStale(); + for (const observation of snapshot.records) { + const icao24 = observation.id; + acceptedSnapshotIcaos.add(icao24); + + // Known-military aircraft: the dedicated military layer wins + // (icon + track + click) while it is enabled — suppress the + // OpenSky duplicate entirely (except a currently tracked one, + // which hands off on untrack). + const isMil = isMilitaryIcao(icao24); + if (isMil && tracking._militaryLayerSuppresses(icao24)) { + const dupe = flightState._billboards.get(icao24); + if (dupe) { + flightState._billboardCollection.remove(dupe); + flightState._billboards.delete(icao24); + rendering._releaseModel(icao24); // military-suppression: drop any 3D model too + records.forget(icao24); + flightState._cullPositions.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + } + continue; + } + + currentIcaos.add(icao24); + const { prevMeta, meta, groundFlipped, fixEpochMs } = records.receive( + observation, + { + viewerLatDeg, + viewerLonDeg, + trackedId: flightState._trackedIcao, + floorWarmPoints, + }, + ); + const position = Cesium.Cartesian3.fromDegrees( + meta.rawLon, + meta.rawLat, + meta.renderAltitudeM, + ); + // Either flip retires the previous one-shot ground sample. + if (groundFlipped) flightState._groundSnap.forget(icao24); + flightState._cullPositions.set( + icao24, + meta.renderAltitudeM < 10 + ? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) + : null, + ); + const isTracked = icao24 === flightState._trackedIcao; + + // Append to position history stamped with the FEED's fix epoch + // (time_position), not client receipt time — OpenSky positions arrive + // 5-15s stale and receipt-time stamping is what caused the + // back/forward oscillation. Only append when the fix actually + // advances, so stale repeats don't create zero-dt segments. + 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.velocity, + track: meta.true_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 F1) + // so the visible trail keeps growing while tracked. Ground traffic + // appends nothing — a touchdown freezes the existing trail. + // Round 2 (owner): ground traffic appends too — taxi history stays + // live after touchdown (grounded flights positions are already + // surface-clamped via the surfaceM chain, so the ground leg drapes). + if (isTracked) tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + // Apply fresh kinematics only from a forward synthetic fix. Mutating + // the historical fix reprojects the entire stale interval and snaps + // the rendered aircraft when course or speed changes late. + const kinematicsChanged = + newest.velocity !== meta.velocity || newest.track !== meta.true_track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.velocity, + track: meta.true_track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + 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(bb.color?.alpha ?? 1); + } + 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 { + const bb = flightState._billboardCollection.add({ + position, + 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: Cesium.Cartesian3.ZERO, + color: isTracked + ? Cesium.Color.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); + } + + // 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) + tracking._startTrail(icao24); + + // If this is the tracked aircraft, update label text + // (position updates automatically via dead-reckoning CallbackProperty) + if (isTracked && flightState._trackedEntity) { + tracking._updateTrackedLabelModel(icao24); + } + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // OpenSky routinely drops aircraft for a single poll; immediate removal + // made planes 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; + const absence = records.absence(icao24, { + complete: snapshot.complete, + likelyLanded: queries._likelyLanded(icao24), + }); + if (absence === 'retain') continue; + if (absence === 'stale') { + 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, _trackedLabelText appends the STALE cue so + // last-known velocity/altitude aren't presented as live. + tracking._updateTrackedLabelModel(icao24); + } + continue; + } + + // 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) { + tracking._clearTracking(false, { evicted: true }); + } + + flightState._billboardCollection.remove(bb); + flightState._billboards.delete(icao24); + rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) + records.forget(icao24); + flightState._cullPositions.delete(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._displayFloorState.delete(icao24); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Ambient type enrichment: give ON-SCREEN planes real types (bounded + // sweep — see _sweepAmbientEnrichment; internally fail-silent). + enrichment._sweepAmbientEnrichment(); + + // 2026-08-19: the loop above only ever collects FIX cells, but a grounded + // contact renders across every cell its dead-reckoned position drifts + // through. Add those too, so the display clamp has data where the contact + // actually is instead of silently passing. + motion._collectDisplayCorridorCells( + floorWarmPoints, + viewerLatDeg, + viewerLonDeg, + ); + + // Field-test round 3: one batch warm of the viewer-proximate low-contact + // floor cells collected in the loop (fire-and-forget, single-flight; + // read synchronously by NEXT poll's clamp — the military-layer pattern). + warmGroundFloor(floorWarmPoints); + // Round 4: sample the RENDERED mesh for those same cells (one-shot per + // cell, budget-capped, viewer-proximate, google-3d regime only). Own + // billboards/models are excluded so a vertical probe can't land on an + // aircraft instead of the pavement. + sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerLatDeg, + viewerLon: viewerLonDeg, + }); + + // Round 5: the grounded exact-key warm that used to live here is gone — + // see the note where _warmGroundedAircraftSurfaceCache was removed. The + // viewer-proximate coarse warm + mesh sampler above cover everything + // whose height is actually visible. + // Round 6: re-floor STALE grounded contacts. A parked plane whose + // transponder went quiet stops receiving poll updates, so a floor that + // warms AFTER its last fix never applied — it sat frozen at the geoid + // (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever). + // Grounded contacts are static, so lifting the stored fix + billboard + // in place is safe (the DR extrapolates a zero-velocity fix). + motion._refloorStaleGroundedContacts(currentIcaos); + + return { count: flightState._billboards.size, ids: acceptedSnapshotIcaos }; + }; +} diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index cf825d648..cb870a3b3 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -1,11 +1,17 @@ +import { FlightRecords } from './records.js'; +import { createFlightFeed } from './ingestion.js'; import * as Cesium from 'cesium'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; const flightState = { lifetime: new AbortController() }; - - flightState._source = source; + flightState.feed = createFlightFeed(source); + flightState.records = new FlightRecords({ + ...services.geoid, + ...services.groundFloor, + }); + flightState._cullPositions = new Map(); /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs * vertex-baked to real-world METERS in the airplane.glb axis convention, so @@ -106,10 +112,6 @@ export function createFlightState({ source, services }) { flightState._detectionObjects = new Map(); - /** @type {Map} */ - - flightState._flightData = new Map(); - /** DEV-only explicit-position contacts used by qa-focus-evidence.mjs. */ flightState._focusEvidenceIds = new Set(); @@ -118,48 +120,6 @@ export function createFlightState({ source, services }) { flightState._positionHistory = new Map(); - /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()) */ - - flightState._geoidReady = false; - - /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ - - flightState._geoidNCache = 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 while in a backoff/cooldown window */ - - flightState._backoff = false; - - /** @type {number} Epoch ms — earliest time the next fetch is allowed */ - - flightState._retryAt = 0; - - /** @type {string|null} Human-readable error string shown in stats chip */ - - flightState._lastError = null; - - flightState._activeUpdateControllers = new Set(); - - /** @type {number|null} HTTP status of the most recent API response */ - - flightState._lastStatus = null; - - /** @type {string} Source used by the latest successful snapshot. */ - - flightState._lastSource = source?.label || 'Aircraft'; - - /** @type {string} Completeness boundary for the latest successful snapshot. */ - - flightState._lastCoverage = 'worldwide upstream snapshot'; - // --------------------------------------------------------------------------- // Click-to-track state // --------------------------------------------------------------------------- @@ -172,16 +132,6 @@ export function createFlightState({ source, services }) { flightState._trackingIntentGeneration = 0; - flightState._trackingRefreshEpoch = 0; - - flightState._lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - ids: new Set(), - source: flightState._lastSource, - coverage: null, - }; - /** @type {Cesium.Entity|null} Entity used for camera tracking */ flightState._trackedEntity = null; @@ -250,10 +200,6 @@ export function createFlightState({ source, services }) { flightState._trailBackfillToken = 0; - /** @type {Map} icao24 -> consecutive missed polls */ - - flightState._missingPolls = new Map(); - /** @type {number} Epoch ms of the last fleet dead-reckoning pass */ flightState._lastFleetTickMs = 0; diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 0c37d1152..ccb109ae1 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -69,14 +69,15 @@ export function createTesting({ flightState._modelCollection = modelCollection; flightState._billboardCollection = billboardCollection; flightState._billboards = new Map([[icao24, billboard]]); + flightState._cullPositions.clear(); 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.records.data = new Map([[icao24, meta]]); flightState._positionHistory = new Map([[icao24, history]]); - flightState._missingPolls = new Map(); + flightState.records.missingPolls = new Map(); flightState._displayCourse.clear(); - flightState._geoidNCache.clear(); + flightState.records.geoidNCache.clear(); flightState._trackedIcao = tracked ? icao24 : null; flightState._trackedEntity = tracked ? entity : null; flightState._trackedModel = null; @@ -86,8 +87,8 @@ export function createTesting({ // 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; + flightState.feed._backoff = false; + flightState.feed._retryAt = 0; } /** Seed the authoritative snapshot outcome used by share-Follow tests. */ @@ -98,8 +99,8 @@ export function createTesting({ source = 'OpenSky Network', coverage = 'test', } = {}) { - const epoch = ++flightState._trackingRefreshEpoch; - flightState._lastTrackingRefreshOutcome = { + const epoch = ++flightState.feed._trackingRefreshEpoch; + flightState.feed._lastTrackingRefreshOutcome = { epoch, status, ids: new Set(ids.map((id) => String(id).trim().toLowerCase())), @@ -117,7 +118,7 @@ export function createTesting({ history = [], }) { flightState._billboards.set(icao24, billboard); - flightState._flightData.set(icao24, meta); + flightState.records.data.set(icao24, meta); flightState._positionHistory.set(icao24, history); } diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 3db2ce862..7a2bbcfeb 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -60,7 +60,7 @@ export function createTracking({ function _publishTrackedSelection(icao24, origin = 'programmatic') { const bb = flightState._billboards.get(icao24); - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (!bb?.position || !info) return false; if (flightState._trackedEntity) flightState._trackedEntity.gevSelectionOrigin = origin; @@ -99,10 +99,10 @@ export function createTracking({ id: icao24, layerId: 'flights', layerName: 'Live Flights', - source: flightState._lastSource, + source: flightState.feed._lastSource, label: parts.queries._contactLabel( icao24, - flightState._flightData.get(icao24), + flightState.records.data.get(icao24), ), latitude: described.latitude, longitude: described.longitude, @@ -111,7 +111,7 @@ export function createTracking({ properties: { name: parts.queries._contactLabel( icao24, - flightState._flightData.get(icao24), + flightState.records.data.get(icao24), ), operator: described.airline || '', callsign: described.callsign || '', @@ -477,8 +477,8 @@ export function createTracking({ async function _backfillTrail(icao24, token, oldestFixEpochSec) { let path = null; try { - const track = await flightState._source.getTrack?.( - flightState._flightData.get(icao24)?.sourceReference ?? icao24, + const track = await flightState.feed._source.getTrack?.( + flightState.records.data.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.any([ flightState.lifetime.signal, @@ -645,7 +645,7 @@ export function createTracking({ bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao); bb.scale = parts.rendering._fleetBillboardScale( flightState._trackedIcao, - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); bb.rotation = flightState._lastTrackedRotation; } @@ -742,7 +742,7 @@ export function createTracking({ * velocity/altitude as live. */ function _trackedLabelText(icao24) { - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (!info) return icao24; // A whitespace-only callsign (" ") is truthy, so `(info.callsign || icao24)` // kept it, then .trim() emptied it → the callsign slot dropped out of the @@ -754,7 +754,7 @@ export function createTracking({ const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`; const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : ''; const stale = - flightState._missingPolls.get(icao24) || flightState._backoff + flightState.records.missingPolls.get(icao24) || flightState.feed._backoff ? 'STALE' : ''; const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')]; @@ -795,7 +795,7 @@ export function createTracking({ flightState._trackedEntity.billboard.image = aircraftIcon( parts.rendering._iconKind( flightState._trackedIcao, - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ), TRACKED_ICON_PX, ); @@ -850,7 +850,7 @@ export function createTracking({ * cache). Missing data → true (never hide what we can't judge). */ function _routeIsPlausible(icao24, route) { - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); const bb = flightState._billboards.get(icao24); if (!info || !bb || !bb.position) return true; const carto = Cesium.Cartographic.fromCartesian( @@ -890,7 +890,7 @@ export function createTracking({ _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over const bb = flightState._billboards.get(icao24); - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (!bb || !info) return; flightState._trackedIcao = icao24; @@ -945,7 +945,7 @@ export function createTracking({ image: aircraftIcon( parts.rendering._iconKind( flightState._trackedIcao, - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ), TRACKED_ICON_PX, ), @@ -953,7 +953,7 @@ export function createTracking({ height: 28, scale: CLASS_SCALE_2D[ - flightState._flightData.get(flightState._trackedIcao)?.klass + flightState.records.data.get(flightState._trackedIcao)?.klass ] || 1, // Solid cyan 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). @@ -975,7 +975,9 @@ export function createTracking({ // 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 tracked = flightState.records.data.get( + flightState._trackedIcao, + ); const pos = getTrackedPosition(); if (!tracked || !pos || !flightState._viewer) return flightState._lastTrackedRotation; @@ -1086,13 +1088,14 @@ export function createTracking({ flightState._billboardCollection.remove(bb); flightState._billboards.delete(icao24); parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too - flightState._flightData.delete(icao24); + flightState.records.data.delete(icao24); + flightState._cullPositions.delete(icao24); flightState._positionHistory.delete(icao24); flightState._displayCourse.delete(icao24); flightState._groundSnap.forget(icao24); - flightState._missingPolls.delete(icao24); + flightState.records.missingPolls.delete(icao24); } - flightState._count = flightState._billboards.size; + flightState.feed._count = flightState._billboards.size; } else if (flightState._billboardCollection.show) { // Fire-and-forget refresh; only while the layer is actually enabled. void flightsLayer.update(flightState._viewer);