From 9e84b03c5ec46c351c9b3123201d76d0bfec5ede Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 14 Sep 2026 16:17:06 -0700 Subject: [PATCH] refactor: separate military-flight records and rendering --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 6 + docs/CURRENT-STATE.md | 5 + package.json | 4 +- scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 34 +- src/layers/military/controller.js | 4 +- src/layers/military/index.js | 22 +- src/layers/military/ingestion.js | 557 +++--------------------- src/layers/military/ingestion.test.mjs | 68 +++ src/layers/military/lifecycle.js | 45 +- src/layers/military/motion.js | 16 +- src/layers/military/ownership.test.mjs | 11 +- src/layers/military/policy.js | 17 +- src/layers/military/queries.js | 46 +- src/layers/military/recordPolicy.js | 9 + src/layers/military/records.js | 201 +++++++++ src/layers/military/records.test.mjs | 100 +++++ src/layers/military/rendering.js | 34 +- src/layers/military/snapshotRenderer.js | 295 +++++++++++++ src/layers/military/state.js | 63 +-- src/layers/military/testing.js | 19 +- src/layers/military/tracking.js | 28 +- 23 files changed, 928 insertions(+), 665 deletions(-) create mode 100644 src/layers/military/ingestion.test.mjs create mode 100644 src/layers/military/recordPolicy.js create mode 100644 src/layers/military/records.js create mode 100644 src/layers/military/records.test.mjs create mode 100644 src/layers/military/snapshotRenderer.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 66365c73e..4277f6dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Separate military-flight records and acquisition from rendering while preserving ground-model ownership, source units and follow behavior. + - 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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f29bc0ad4..a655484bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -491,3 +491,9 @@ 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. + +`layers/military/records` and `layers/military/ingestion` have the same portable +ownership boundary. Military observations retain their aviation-foot readout, +source-time fallback and model-owned ground policy. The renderer supplies that +ownership fact and handles stale-ground lifting, Cesium history and primitives; +record reconciliation does not import the engine or application. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b3667faca..9ee7f6cb0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,10 @@ # God's Eye View Current State +Military flights also separate portable records and source acquisition from +Cesium snapshot application. The metadata owner retains aviation feet, source +fix times, sticky fields and partial-feed retention. Rendering retains ground +model ownership, motion history, military registration and tracking cleanup. + 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 diff --git a/package.json b/package.json index a6fde771b..43bbc2889 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,8 @@ "./ui/visual-settings": "./src/ui/visualSettings.js", "./ui/panel-chrome": "./src/ui/panelChrome.js", "./layers/flights/records": "./src/layers/flights/records.js", - "./layers/flights/ingestion": "./src/layers/flights/ingestion.js" + "./layers/flights/ingestion": "./src/layers/flights/ingestion.js", + "./layers/military/records": "./src/layers/military/records.js", + "./layers/military/ingestion": "./src/layers/military/ingestion.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7896b76d7..693cbb243 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -600,5 +600,10 @@ "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", "src/layers/flights/records.test.mjs", - "src/layers/flights/ingestion.test.mjs" + "src/layers/flights/ingestion.test.mjs", + "src/layers/military/records.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/snapshotRenderer.js", + "src/layers/military/records.test.mjs", + "src/layers/military/ingestion.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5422a7a82..e0cde1837 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -657,7 +657,10 @@ "src/layers/military/rendering.js", "src/layers/military/state.js", "src/layers/military/testing.js", - "src/layers/military/tracking.js" + "src/layers/military/tracking.js", + "src/layers/military/records.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/snapshotRenderer.js" ], "external": ["cesium"] }, @@ -1545,7 +1548,10 @@ "src/worldFocus.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js" + "src/layers/flights/snapshotRenderer.js", + "src/layers/military/records.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/snapshotRenderer.js" ], "external": [ "@mapbox/vector-tile", @@ -1872,7 +1878,10 @@ "src/worldFocus.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js" + "src/layers/flights/snapshotRenderer.js", + "src/layers/military/records.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/snapshotRenderer.js" ], "external": [ "cesium", @@ -2005,5 +2014,24 @@ "src/layers/flights/recordPolicy.js" ], "external": [] + }, + "military-flight-records": { + "exports": ["./layers/military/records"], + "modules": [ + "src/layers/military/records.js", + "src/layers/military/recordPolicy.js", + "src/data/aircraftMeta.js", + "src/data/renderAltitude.js", + "src/data/aircraftClass.js" + ], + "external": [] + }, + "military-flight-ingestion": { + "exports": ["./layers/military/ingestion"], + "modules": [ + "src/layers/military/ingestion.js", + "src/layers/military/recordPolicy.js" + ], + "external": [] } } diff --git a/src/layers/military/controller.js b/src/layers/military/controller.js index d1f172819..aad3aed6c 100644 --- a/src/layers/military/controller.js +++ b/src/layers/military/controller.js @@ -6,9 +6,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(); } return { _abortActiveUpdates }; } diff --git a/src/layers/military/index.js b/src/layers/military/index.js index c972bebb1..283c9b7f8 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -1,3 +1,4 @@ +import { createMilitarySnapshotRenderer } from './snapshotRenderer.js'; import { createFlightState } from './state.js'; import { createRendering } from './rendering.js'; import { createMotion } from './motion.js'; @@ -21,10 +22,29 @@ export function createMilitaryFlightLayer({ parts.motion = createMotion(context); parts.tracking = createTracking(context); parts.controller = createController(context); - parts.ingestion = createIngestion(context); parts.lifecycle = createLifecycle(context); parts.testing = createTesting(context); parts.queries = createQueries(context); + const applySnapshot = createMilitarySnapshotRenderer({ + flightState, + records: flightState.records, + groundFloor: services.groundFloor, + meshFloor: services.meshFloor, + militaryRegistry: services.militaryRegistry, + rendering: parts.rendering, + tracking: parts.tracking, + queries: parts.queries, + }); + parts.ingestion = createIngestion({ + feed: flightState.feed, + applySnapshot, + setSourceLabel: (source) => { + layer.source = source; + }, + applyPendingTrackingRestore: () => + parts.tracking._applyPendingTrackingRestore(), + }); + Object.assign( layer, parts.queries.methods, diff --git a/src/layers/military/ingestion.js b/src/layers/military/ingestion.js index 1dbc8e0ff..ee3dbc2d1 100644 --- a/src/layers/military/ingestion.js +++ b/src/layers/military/ingestion.js @@ -1,547 +1,102 @@ -import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; -import * as Cesium from 'cesium'; -import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; -import { classifyAircraft, CLASS_SCALE_2D } from '../../data/aircraftClass.js'; -import { - turnRateFromFixHistory, - liftRepeatedGroundFix, - synthesizeForwardKinematicsFix, -} from '../../data/motionModel.js'; -import { aircraftIcon } from '../../data/aircraftIcons.js'; -import { - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - BILLBOARD_SCALE, - GROUND_SCALE, - TRACKED_ICON_COLOR, - MIL_ICON_COLOR, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './policy.js'; +import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js'; +/** Own military source acquisition, cancellation, freshness and error backoff. */ export function createIngestion({ - flightState, - services, - parts, - layer, - resolveAsset, + feed, + applySnapshot, + setSourceLabel, + applyPendingTrackingRestore, }) { - const { geoidHeight } = services.geoid; - const { - floorAltitudeM, - cachedGroundFloor, - warmGroundFloor, - GROUND_FLOOR_LIFT_M, - } = services.groundFloor; - const { sampleMeshFloorCells } = services.meshFloor; - const { registerMilitaryIcaos } = services.militaryRegistry; - const militaryFlightsLayer = layer; - const methods = { - /** - * Fetch the latest military aircraft positions from adsb.lol and reconcile - * with the billboard collection. Handles error backoff, feed-time-stamped - * position history updates, and grace-period removal of absent aircraft. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance - * @returns {Promise} - */ 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, + source: feed._lastSource, }; - 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( + const snapshot = await feed._source.getSnapshot( {}, { signal: updateSignal }, ); updateSignal.throwIfAborted(); - flightState._lastStatus = snapshot.status ?? 200; - flightState._lastSource = snapshot.source; - militaryFlightsLayer.source = flightState._lastSource; - const usableAircraft = snapshot.records; - flightState._backoff = - snapshot.stale || snapshot.freshness === 'unknown'; - flightState._retryAt = 0; - flightState._lastError = + feed._lastStatus = snapshot.status ?? 200; + feed._lastSource = snapshot.source; + setSourceLabel(feed._lastSource); + feed._backoff = snapshot.stale || snapshot.freshness === 'unknown'; + feed._retryAt = 0; + feed._lastError = snapshot.reason || (snapshot.freshness === 'unknown' ? 'Source snapshot time unavailable' : null); - const currentIcaos = new Set(); - const receiptNowMs = snapshot.observedAtMs; - // Field-test fix (RS46): coarse floor cells to warm for the below-ground - // clamp — collected during the loop (low airborne contacts only), - // batch-resolved once after it. Never a fetch inside the loop. - const _floorWarmPoints = []; - - for (const aircraft of usableAircraft) { - const { id: icao24, longitude: lon, latitude: lat } = aircraft; - - currentIcaos.add(icao24); - flightState._missingPolls.delete(icao24); - - const prevMeta = flightState._flightData.get(icao24); - // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no - // separate boolean). Grounded planes fall back to their last known - // altitude (field elevation is unknowable here), else 0 m — never the - // 3 km airborne default (a parked plane must not float). - const onGround = aircraft.onGround; - const altitudeFt = - aircraft.baroAltitudeM == null - ? null - : aircraft.baroAltitudeM / 0.3048; - const altitudeM = - aircraft.baroAltitudeM ?? - (onGround - ? Number.isFinite(prevMeta?.altitudeFt) - ? prevMeta.altitudeFt * 0.3048 - : 0 - : 3048); - const track = aircraft.courseDeg || 0; - const speedMps = aircraft.speedMps ?? 0; - const verticalRateMps = aircraft.verticalRateMps; - const callsign = aircraft.callsign; - const type = aircraft.typeCode; - const registration = aircraft.registration; - const operator = aircraft.operator; - const geoAltitudeM = aircraft.ellipsoidAltitudeM; - const baroAltitudeM = aircraft.baroAltitudeM; - - // geoid undulation N: cached per-aircraft (negligible drift — see - // task brief) once the geoid grid has loaded; unavailable pre-load - // just means the baro fallback branch below adds N=0 for a beat. - let geoidN = flightState._geoidNCache.get(icao24); - if (geoidN === undefined && flightState._geoidReady) { - geoidN = geoidHeight(lat, lon); - flightState._geoidNCache.set(icao24, geoidN); - } - - // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a - // grounded plane's MODEL already rides groundSnap.js's one-shot tileset - // sample (_modelDisplayPosition), which is the visual on the ground, and - // its billboard is depth-test-free (_groundDepthDistance) so its exact - // height is cosmetic. Deliberately pass surfaceM=null so - // pickRenderAltitudeM's on-ground surface branch never fires here: - // 1. It would be the SECOND correction of the same grounded plane - // (model tileset-snap is the first) — the exact double-correct the - // brief forbids. - // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the - // grounded billboard sits at 0 m until a surface value warms; letting - // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls - // drags the model's ground-snap input past groundSnap's 50 m - // move-invalidation threshold and forces a re-sample every time the - // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant - // the track regression locks (qa: sampleHeight count must stay flat). - // Grounded planes therefore keep the pre-existing `altitudeM` default - // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what - // matters for AIRBORNE military planes — the actual "renders at MSL" bug. - const pickedAltM = pickRenderAltitudeM({ - geoAltM: geoAltitudeM, - baroAltM: baroAltitudeM, - onGround, - surfaceM: null, - geoidN, - }); - // pickRenderAltitudeM returns the sentinel `null` only when NEITHER - // alt_geom nor alt_baro was ever reported for this aircraft (not even - // stickily) — fall back to the SAME existing default policy `altitudeM` - // already uses (which also carries the on-ground 0 m / last-known-baro - // case), so the two never disagree on the "no data yet" case. - let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; - // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only - // AIRBORNE contact near steep terrain can compute a render height - // BELOW the local surface (no alt_geom; baro+N carries QNH error - // larger than the height above ground). Floor it at the coarse-grid - // ellipsoidal ground (warm-cache read only — the batch warm below - // fills cells for later polls). Grounded contacts are deliberately - // NOT touched: their model rides groundSnap's tileset sample and - // their billboard is depth-test-free (see the surfaceM:null block - // above — same one-shot-invariant reasoning). - if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { - renderAltitudeM = floorAltitudeM( - renderAltitudeM, - cachedGroundFloor(lat, lon), - ); - _floorWarmPoints.push({ lat, lon }); - } else if (onGround) { - // Grounded contacts: warm the floor cell, and — round 4 — when NO - // 3D model owns this contact's visual, lift the billboard itself - // onto the floor (mesh-first): the R20053 heli sat "straight up in - // the ground" because grounded rows render at the legacy ~0 m. - // With a model present the billboard stays put (it hides behind - // the tileset-snapped model, and moving it would drag groundSnap's - // input past its move-invalidation threshold — the T7 one-shot - // invariant the track regression locks). - _floorWarmPoints.push({ lat, lon }); - const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); - if (!modelOwnsVisual) { - const floor = cachedGroundFloor(lat, lon); - if (Number.isFinite(floor)) { - renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); - } - } - } - - const position = Cesium.Cartesian3.fromDegrees( - lon, - lat, - renderAltitudeM, - ); - // Landing/takeoff transition: the ground flip restyles IN PLACE. - const groundFlipped = - !!prevMeta && (prevMeta.onGround === true) !== onGround; - // Either flip direction retires the model's ground snap: a departing plane - // flies free of it, a landing plane earns a fresh sample where it rolls out. - if (groundFlipped) flightState._groundSnap.forget(icao24); - - // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold - // last-known-good (bounded by the layer's eviction, which deletes the entry). - const stickyType = stickyText(type, prevMeta?.type); - const meta = { - sourceReference: aircraft.reference, - observedReceiptMs: Date.now(), - callsign: stickyText(callsign, prevMeta?.callsign), - type: stickyType, - // Type outranks category automatically inside classifyAircraft. - klass: classifyAircraft({ - typeCode: stickyType, - category: aircraft?.category, - }), - registration: stickyText(registration, prevMeta?.registration), - operator: stickyText(operator, prevMeta?.operator), - altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), - // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the - // untouched aviation `altitudeFt` — never rename/replace it (labels, - // the FL readout, and the landed-fast-cull heuristic all still read - // altitudeFt/baro). - geoAltitudeM, - renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), - // Analyst seam (additive): sticky like the other kinematics. - verticalRateMps: stickyNumber( - verticalRateMps, - prevMeta?.verticalRateMps, - null, - ), - lastContactEpochMs: stickyNumber( - aircraft.contactTimeMs, - prevMeta?.lastContactEpochMs, - null, - ), - turnRateDps: prevMeta?.turnRateDps || 0, - onGround, - // Round 7: sticky airborne history (see _likelyLanded). - wasAirborne: prevMeta?.wasAirborne === true || !onGround, - // Round 6: lifted occlusion-test point for at/below-ellipsoid - // renders (mirror of flights.js). - cullPosition: - renderAltitudeM < 10 - ? Cesium.Cartesian3.fromDegrees(lon, lat, 12) - : null, - // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded - // re-floor sweep keys floors off these (mirror of flights.js). - rawLat: lat, - rawLon: lon, - }; - flightState._flightData.set(icao24, meta); - - const isTracked = icao24 === flightState._trackedIcao; - - // Append to position history stamped with the FEED's fix epoch. - // adsb.lol's seen_pos is the AGE in seconds of the last position - // report, so the fix epoch is receipt time minus that age. Only - // append when the fix actually advances, so stale repeats don't - // create zero-dt segments. - const fixEpochMs = aircraft.positionTimeMs ?? receiptNowMs; - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); - if (!flightState._positionHistory.has(icao24)) { - flightState._positionHistory.set(icao24, []); - } - const history = flightState._positionHistory.get(icao24); - const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { - // Per-fix kinematics: the fix's own velocity/track ride along so the - // extrapolation paths use the values that BELONG to the fix they - // extend, not whatever the latest poll reported. - history.push({ - time: fixTime, - epochMs: fixEpochMs, - position: position.clone(), - velocity: meta.speedMps, - track: meta.track, - }); - if (history.length > POSITION_HISTORY_LIMIT) { - history.shift(); - } - // Turn rate from the fix-track history — computed once per new fix - // (≤5 samples), consumed by the extrapolation paths at tick rate. - meta.turnRateDps = turnRateFromFixHistory(history); - // Trail accumulation is separate from the 5-fix DR history (PRD F2) - // so the visible trail keeps growing while tracked. Round 2 - // (owner): ground traffic appends too — taxi history stays live - // after touchdown; _appendTrailFix floors grounded positions at - // the surface so the ground leg drapes instead of diving. - if (isTracked) parts.tracking._appendTrailFix(position.clone()); - } else { - const modelOwnsGroundVisual = - parts.rendering._modelOwnsVisual(icao24); - if (!modelOwnsGroundVisual) { - liftRepeatedGroundFix(newest, position, meta.onGround); - } - const kinematicsChanged = - newest.velocity !== meta.speedMps || newest.track !== meta.track; - if (kinematicsChanged) { - const synthetic = synthesizeForwardKinematicsFix(newest, { - epochMs: Date.now(), - velocity: meta.speedMps, - track: meta.track, - turnRateDps: meta.turnRateDps, - }); - if (synthetic) { - history.push(synthetic); - if (history.length > POSITION_HISTORY_LIMIT) history.shift(); - meta.turnRateDps = turnRateFromFixHistory(history); - if (isTracked) - parts.tracking._appendTrailFix(synthetic.position.clone()); - } - } - } - - if (flightState._billboards.has(icao24)) { - const bb = flightState._billboards.get(icao24); - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - // Ground flips (landing/takeoff) restyle this SAME billboard in - // place — the transition is never a removal. - if ( - prevMeta?.klass !== meta.klass || - groundFlipped || - flightState._cockpitContactMode - ) { - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) - parts.rendering._syncModelToClass(icao24); - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - } else { - const bb = flightState._billboardCollection.add({ - position, - image: aircraftIcon( - parts.rendering._iconKind(icao24, meta.klass), - ), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: - BILLBOARD_SCALE * - (CLASS_SCALE_2D[meta.klass] || 1) * - (meta.onGround ? GROUND_SCALE : 1), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, - sizeInMeters: false, - scaleByDistance: - parts.rendering._normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: parts.rendering._groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - flightState._billboards.set(icao24, bb); - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - } - - // Takeoff while TRACKED: ground traffic drew no trail, so start one - // from the fresh airborne history (touchdown needs no action — the - // append gate above simply freezes the existing trail). - if (isTracked && groundFlipped && !meta.onGround) - parts.tracking._startTrail(icao24); - - // If this is the tracked aircraft, update label text - // (position updates automatically via dead-reckoning CallbackProperty) - if (isTracked && flightState._trackedEntity) { - const info = flightState._flightData.get(icao24); - parts.tracking._updateTrackedLabelModel(icao24); - } - } - - // Field-test fix (RS46): one batch warm of the low-airborne floor cells - // collected above — fire-and-forget, single-flight, results read - // synchronously by NEXT poll's clamp (same contract as flights.js's - // grounded-surface warm). - warmGroundFloor(_floorWarmPoints); - // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — - // a parked contact whose feed went quiet froze at its pre-warm height. - // The model-ownership gate matches the live grounded-clamp path (a - // tileset-snapped model owns its visual; moving its billboard would - // disturb groundSnap's input — the T7 one-shot invariant). - for (const [icao24, info] of flightState._flightData) { - if (!info?.onGround || currentIcaos.has(icao24)) continue; - if (parts.rendering._modelOwnsVisual(icao24)) continue; - if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) - continue; - const floor = cachedGroundFloor(info.rawLat, info.rawLon); - if (!Number.isFinite(floor)) continue; - const lifted = floor + GROUND_FLOOR_LIFT_M; - if ( - Number.isFinite(info.renderAltitudeM) && - info.renderAltitudeM >= floor - 1 - ) - continue; - info.renderAltitudeM = lifted; - info.cullPosition = null; - const liftedPos = Cesium.Cartesian3.fromDegrees( - info.rawLon, - info.rawLat, - lifted, - ); - const hist = flightState._positionHistory.get(icao24); - const newest = hist?.[hist.length - 1]; - if (newest) - newest.position = Cesium.Cartesian3.clone( - liftedPos, - newest.position, - ); - const bbStale = flightState._billboards.get(icao24); - if (bbStale) bbStale.position = liftedPos; - } - // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, - // budget-capped, viewer-proximate, google-3d regime only), excluding - // this layer's own billboards/models from the probe. - { - const viewerCarto = - flightState._viewer?.camera?.positionCartographic || null; - sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { - excludeObjects: [ - ...flightState._billboards.values(), - ...flightState._models.values(), - flightState._trackedModel, - ].filter(Boolean), - viewerLat: viewerCarto - ? Cesium.Math.toDegrees(viewerCarto.latitude) - : undefined, - viewerLon: viewerCarto - ? Cesium.Math.toDegrees(viewerCarto.longitude) - : undefined, - }); - } - - // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. - // adsb.lol routinely drops aircraft for a single poll; immediate removal - // made icons blink and yanked the camera off actively tracked flights. - // EXCEPTION: likely-landed planes (last fix low + slow) get only - // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a - // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of flightState._billboards) { - if (currentIcaos.has(icao24)) continue; - // Partial admissions do not prove absence, but stale retention is bounded. - if ( - !snapshot.complete && - Date.now() - - (flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) < - 300000 - ) - continue; - const misses = (flightState._missingPolls.get(icao24) || 0) + 1; - const limit = parts.queries._likelyLanded(icao24) - ? LANDED_MISSING_POLL_LIMIT - : MISSING_POLL_LIMIT; - if (misses < limit) { - flightState._missingPolls.set(icao24, misses); - if ( - icao24 === flightState._trackedIcao && - flightState._trackedEntity - ) { - // Honest readout: the tracked plane has no faded billboard (its - // entity owns the visual), so refresh the label — with icao24 now - // in _missingPolls, _buildTrackedLabel appends the STALE cue so - // last-known altitude/speed aren't presented as live. - parts.tracking._updateTrackedLabelModel(icao24); - } - continue; - } - flightState._missingPolls.delete(icao24); - - // If the tracked flight is truly gone, clear tracking BEFORE deleting - // its state (M3 ordering, keep it): teardown reads the maps this loop - // is about to delete (billboard restore, DR cache reset), and we must - // never leave the camera mid-follow with stale tracking state. The - // camera is then RELEASED IN PLACE — it stays where the follow left - // it, fully free (owner decision 2026-07-02: no overview flyTo). - if (icao24 === flightState._trackedIcao) { - parts.tracking._clearTracking(false, { evicted: true }); - } - - flightState._billboardCollection.remove(bb); - flightState._billboards.delete(icao24); - parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) - flightState._flightData.delete(icao24); - flightState._positionHistory.delete(icao24); - flightState._displayCourse.delete(icao24); - flightState._groundSnap.forget(icao24); - flightState._geoidNCache.delete(icao24); - } - - // Fresh courses arrived — force a rotation pass on the next fleet tick - flightState._lastCamPoseSig = ''; - - // Feed the shared registry so the flights layer can classify/suppress - registerMilitaryIcaos(currentIcaos); - - flightState._count = flightState._billboards.size; - flightState._lastUpdate = receiptNowMs; - flightState._lastTrackingRefreshOutcome = { + const accepted = applySnapshot(snapshot, viewer); + feed._count = accepted.count; + feed._lastUpdate = snapshot.observedAtMs; + feed._lastTrackingRefreshOutcome = { epoch: trackingRefreshEpoch, status: 'accepted', - ids: currentIcaos, - source: flightState._lastSource, + ids: accepted.ids, + source: feed._lastSource, }; - console.log(`[Data:Military] Updated: ${flightState._count} aircraft`); - parts.tracking._applyPendingTrackingRestore(); + console.log(`[Data:Military] Updated: ${feed._count} aircraft`); + applyPendingTrackingRestore(); } catch (e) { if (updateSignal.aborted || e?.name === 'AbortError') { throw new DOMException('Military update aborted', 'AbortError'); } console.warn('[Data:Military] Fetch error:', e); - flightState._backoff = true; - flightState._retryAt = + 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 an independent military source lifetime and status. */ +export function createMilitaryFeed(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._trackingRefreshEpoch = 0; + feed._lastTrackingRefreshOutcome = { + epoch: 0, + status: 'unavailable', + ids: new Set(), + source: feed._lastSource, + }; + return feed; +} diff --git a/src/layers/military/ingestion.test.mjs b/src/layers/military/ingestion.test.mjs new file mode 100644 index 000000000..394d34935 --- /dev/null +++ b/src/layers/military/ingestion.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createIngestion, createMilitaryFeed } from './ingestion.js'; +function setup(source) { + const feed = createMilitaryFeed(source); + const accepted = []; + const labels = []; + const restores = []; + const { methods } = createIngestion({ + feed, + applySnapshot: (snapshot) => { + accepted.push(snapshot); + return { count: 1, ids: new Set(['aaa001']) }; + }, + setSourceLabel: (label) => labels.push(label), + applyPendingTrackingRestore: () => restores.push(true), + }); + return { feed, accepted, labels, restores, update: methods.update }; +} + +test('military acquisition cancels late replies without publishing or restoring tracking', 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('military acquisition keeps the degraded source reason and respects retry admission', async () => { + let calls = 0; + const probe = setup({ + async getSnapshot() { + calls++; + return { + records: [], + source: 'Fixture', + observedAtMs: 1234, + freshness: 'stale', + stale: true, + reason: 'Upstream refreshing', + }; + }, + }); + await probe.update(null); + assert.equal(probe.feed._lastUpdate, 1234); + assert.equal(probe.feed._lastError, 'Upstream refreshing'); + assert.equal(probe.feed._backoff, true); + assert.deepEqual(probe.labels, ['Fixture']); + probe.feed._retryAt = Date.now() + 60000; + await probe.update(null); + assert.equal(calls, 1); + assert.equal(probe.restores.length, 1); + assert.equal( + probe.feed._lastTrackingRefreshOutcome.status, + 'source-unavailable', + ); +}); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 31d8b558e..c9e658e98 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -22,9 +22,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; }, /** @@ -35,7 +36,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(); @@ -72,17 +73,18 @@ 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.feed._count = 0; + flightState.feed._lastUpdate = null; + flightState.feed._backoff = false; + flightState.feed._retryAt = 0; + flightState.feed._lastError = null; + flightState.feed._lastStatus = null; flightState._trackedIcao = null; parts.tracking._resetTrackedSelectionState(); flightState._trackedEntity = null; @@ -122,11 +124,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 */ @@ -274,23 +276,24 @@ 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(); - flightState._missingPolls.clear(); - flightState._count = 0; - flightState._lastUpdate = null; + flightState.records.missingPolls.clear(); + 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, + source: flightState.feed._lastSource, }; parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling flightState._viewer = null; diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 004558e2e..27489400b 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -55,7 +55,7 @@ export function createMotion({ function _deadReckon(icao24, result) { const history = flightState._positionHistory.get(icao24); - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); if (!history || history.length === 0) { flightState._drCourseDeg = null; flightState._drSpeedMps = null; @@ -229,7 +229,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, @@ -256,7 +256,7 @@ export function createMotion({ const scale = billboard?.scale?.getValue(time) ?? BILLBOARD_SCALE * - (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; @@ -299,7 +299,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.speedMps) || 0; @@ -380,7 +380,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, @@ -423,7 +423,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 @@ -440,7 +440,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 @@ -466,7 +466,7 @@ export function createMotion({ * same aircraft can never disagree across the handoff (mirror of flights.js). */ function _trackedDisplayCourse() { - const info = flightState._flightData.get(flightState._trackedIcao); + const info = flightState.records.data.get(flightState._trackedIcao); const fallback = (info && info.track) || 0; const cacheValid = flightState._drReconcileValid && diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 75a559f3f..167900b7c 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -61,17 +61,24 @@ test('military state owns separate contact maps, motion scratch and ground sampl const first = createFlightState({ services: services() }); const second = createFlightState({ services: services() }); for (const key of [ - '_flightData', + 'records', + 'feed', '_billboards', '_positionHistory', '_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('a normalized source can retain its stale reason without changing standalone cache policy', async () => { diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index c72c06088..36d5ebde4 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -1,3 +1,10 @@ +export { + 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'; /** @@ -19,12 +26,8 @@ import * as Cesium from 'cesium'; /** @constant {number} Milliseconds to wait before retrying after a transient error */ -export const ERROR_BACKOFF_INTERVAL = 20000; - /** @constant {number} Max position samples retained per aircraft for dead reckoning */ -export const POSITION_HISTORY_LIMIT = 5; - /** @constant {number} Base billboard display scale */ export const BILLBOARD_SCALE = 0.7; @@ -186,8 +189,6 @@ export const RENDER_DELAY_SEC = 15; /** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */ -export const MISSING_POLL_LIMIT = 3; - // --- Landed-plane fast cull (mirror of flights.js; owner field report // 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown). // The feed's ground flag lags the actual landing, so a landed plane's last @@ -207,8 +208,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 fix (RS46, 2026-07-06): only contacts rendering below this // ellipsoidal height get the below-ground floor clamp + a coarse floor-cell // warm. Terrain outside the extreme Himalaya tops out well under this, so @@ -217,8 +216,6 @@ export const LANDED_MISSING_POLL_LIMIT = 1; // gets the floor. /** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */ -export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; - // --------------------------------------------------------------------------- // Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment // degenerates when the camera looks straight down (the normal is parallel to diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index a1e545d6d..421e9804d 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -67,7 +67,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 (mirror of flights.js): fast cull only for contacts seen // AIRBORNE this session — parked contacts ride the normal grace so feed @@ -91,7 +91,7 @@ export function createQueries({ */ function _describeFlight(icao24) { - 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); @@ -130,7 +130,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, ), }; } @@ -199,7 +200,7 @@ export function createQueries({ icon: '🎖️', - source: flightState._lastSource, + source: flightState.feed._lastSource, /** @type {number} Polling interval in ms between API fetches */ updateInterval: 15000, @@ -362,7 +363,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); nearby.push({ id: info?.callsign?.trim() || info?.registration?.trim() || icao24, icao24, @@ -434,7 +435,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); const callsign = info?.callsign?.trim(); const registration = info?.registration?.trim(); let object = flightState._detectionObjects.get(icao24); @@ -506,7 +507,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() @@ -524,7 +525,7 @@ export function createQueries({ // cannot disagree, and it is strictly tiered so a registration can never // out-rank a real callsign on feed order alone. let best = null; - for (const [icao24, info] of flightState._flightData) { + for (const [icao24, info] of flightState.records.data) { const candidate = { tier: rankContactMatch({ query: q, @@ -595,7 +596,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/Context resolve this) // Last surface still reading as the raw hex for a callsign-less @@ -626,14 +627,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) { result.push(mapAnalystRecord(icao24, info)); if (result.length >= limit) break; } @@ -672,11 +673,11 @@ export function createQueries({ .trim() .toLowerCase(); 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', - reason: `${flightState._lastSource} snapshot unavailable`, + reason: `${flightState.feed._lastSource} snapshot unavailable`, refreshEpoch: outcome.epoch, source: outcome.source, }; @@ -790,17 +791,20 @@ 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, + source: flightState.feed._lastSource, fallback: false, }; }, diff --git a/src/layers/military/recordPolicy.js b/src/layers/military/recordPolicy.js new file mode 100644 index 000000000..18f4a488d --- /dev/null +++ b/src/layers/military/recordPolicy.js @@ -0,0 +1,9 @@ +export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500; + +export const POSITION_HISTORY_LIMIT = 5; + +export const LANDED_MISSING_POLL_LIMIT = 1; + +export const MISSING_POLL_LIMIT = 3; + +export const ERROR_BACKOFF_INTERVAL = 20000; diff --git a/src/layers/military/records.js b/src/layers/military/records.js new file mode 100644 index 000000000..cbc62be79 --- /dev/null +++ b/src/layers/military/records.js @@ -0,0 +1,201 @@ +import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; +import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { + GROUND_FLOOR_WARM_MAX_ALT_M, + LANDED_MISSING_POLL_LIMIT, + MISSING_POLL_LIMIT, +} from './recordPolicy.js'; + +/** Portable military aircraft metadata and bounded missing-poll retention. */ +export class MilitaryFlightRecords { + 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( + aircraft, + { observedAtMs, floorWarmPoints: _floorWarmPoints, modelOwnsVisual }, + ) { + const { geoidHeight, cachedGroundFloor, floorAltitudeM } = this.services; + const { id: icao24, longitude: lon, latitude: lat } = aircraft; + this.missingPolls.delete(icao24); + const prevMeta = this.data.get(icao24); + // adsb.lol/readsb reports GROUND traffic as alt_baro === "ground" (no + // separate boolean). Grounded planes fall back to their last known + // altitude (field elevation is unknowable here), else 0 m — never the + // 3 km airborne default (a parked plane must not float). + const onGround = aircraft.onGround; + const altitudeFt = + aircraft.baroAltitudeM == null ? null : aircraft.baroAltitudeM / 0.3048; + const altitudeM = + aircraft.baroAltitudeM ?? + (onGround + ? Number.isFinite(prevMeta?.altitudeFt) + ? prevMeta.altitudeFt * 0.3048 + : 0 + : 3048); + const track = aircraft.courseDeg || 0; + const speedMps = aircraft.speedMps ?? 0; + const verticalRateMps = aircraft.verticalRateMps; + const callsign = aircraft.callsign; + const type = aircraft.typeCode; + const registration = aircraft.registration; + const operator = aircraft.operator; + const geoAltitudeM = aircraft.ellipsoidAltitudeM; + const baroAltitudeM = aircraft.baroAltitudeM; + + // geoid undulation N: cached per-aircraft (negligible drift — see + // task brief) once the geoid grid has loaded; unavailable pre-load + // just means the baro fallback branch below adds N=0 for a beat. + let geoidN = this.geoidNCache.get(icao24); + if (geoidN === undefined && this.geoidReady) { + geoidN = geoidHeight(lat, lon); + this.geoidNCache.set(icao24, geoidN); + } + + // GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a + // grounded plane's MODEL already rides groundSnap.js's one-shot tileset + // sample (_modelDisplayPosition), which is the visual on the ground, and + // its billboard is depth-test-free (_groundDepthDistance) so its exact + // height is cosmetic. Deliberately pass surfaceM=null so + // pickRenderAltitudeM's on-ground surface branch never fires here: + // 1. It would be the SECOND correction of the same grounded plane + // (model tileset-snap is the first) — the exact double-correct the + // brief forbids. + // 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the + // grounded billboard sits at 0 m until a surface value warms; letting + // surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls + // drags the model's ground-snap input past groundSnap's 50 m + // move-invalidation threshold and forces a re-sample every time the + // cache warms — breaking the ONE-SHOT-per-(camera,regime) invariant + // the track regression locks (qa: sampleHeight count must stay flat). + // Grounded planes therefore keep the pre-existing `altitudeM` default + // (last-known baro / 0). The datum fix (alt_geom -> baro+geoidN) is what + // matters for AIRBORNE military planes — the actual "renders at MSL" bug. + const pickedAltM = pickRenderAltitudeM({ + geoAltM: geoAltitudeM, + baroAltM: baroAltitudeM, + onGround, + surfaceM: null, + geoidN, + }); + // pickRenderAltitudeM returns the sentinel `null` only when NEITHER + // alt_geom nor alt_baro was ever reported for this aircraft (not even + // stickily) — fall back to the SAME existing default policy `altitudeM` + // already uses (which also carries the on-ground 0 m / last-known-baro + // case), so the two never disagree on the "no data yet" case. + let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM; + // Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only + // AIRBORNE contact near steep terrain can compute a render height + // BELOW the local surface (no alt_geom; baro+N carries QNH error + // larger than the height above ground). Floor it at the coarse-grid + // ellipsoidal ground (warm-cache read only — the batch warm below + // fills cells for later polls). Grounded contacts are deliberately + // NOT touched: their model rides groundSnap's tileset sample and + // their billboard is depth-test-free (see the surfaceM:null block + // above — same one-shot-invariant reasoning). + if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) { + renderAltitudeM = floorAltitudeM( + renderAltitudeM, + cachedGroundFloor(lat, lon), + ); + _floorWarmPoints.push({ lat, lon }); + } else if (onGround) { + // Grounded contacts: warm the floor cell, and — round 4 — when NO + // 3D model owns this contact's visual, lift the billboard itself + // onto the floor (mesh-first): the R20053 heli sat "straight up in + // the ground" because grounded rows render at the legacy ~0 m. + // With a model present the billboard stays put (it hides behind + // the tileset-snapped model, and moving it would drag groundSnap's + // input past its move-invalidation threshold — the T7 one-shot + // invariant the track regression locks). + _floorWarmPoints.push({ lat, lon }); + if (!modelOwnsVisual) { + const floor = cachedGroundFloor(lat, lon); + if (Number.isFinite(floor)) { + renderAltitudeM = floorAltitudeM(renderAltitudeM, floor); + } + } + } + + // Landing/takeoff transition: the ground flip restyles IN PLACE. + const groundFlipped = + !!prevMeta && (prevMeta.onGround === true) !== onGround; + // Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold + // last-known-good (bounded by the layer's eviction, which deletes the entry). + const stickyType = stickyText(type, prevMeta?.type); + const meta = { + sourceReference: aircraft.reference, + observedReceiptMs: Date.now(), + callsign: stickyText(callsign, prevMeta?.callsign), + type: stickyType, + // Type outranks category automatically inside classifyAircraft. + klass: classifyAircraft({ + typeCode: stickyType, + category: aircraft?.category, + }), + registration: stickyText(registration, prevMeta?.registration), + operator: stickyText(operator, prevMeta?.operator), + altitudeFt: stickyNumber(altitudeFt, prevMeta?.altitudeFt, null), + // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the + // untouched aviation `altitudeFt` — never rename/replace it (labels, + // the FL readout, and the landed-fast-cull heuristic all still read + // altitudeFt/baro). + geoAltitudeM, + renderAltitudeM, + speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), + track: stickyNumber(track, prevMeta?.track, null), + // Analyst seam (additive): sticky like the other kinematics. + verticalRateMps: stickyNumber( + verticalRateMps, + prevMeta?.verticalRateMps, + null, + ), + lastContactEpochMs: stickyNumber( + aircraft.contactTimeMs, + prevMeta?.lastContactEpochMs, + null, + ), + turnRateDps: prevMeta?.turnRateDps || 0, + onGround, + // Round 7: sticky airborne history (see _likelyLanded). + wasAirborne: prevMeta?.wasAirborne === true || !onGround, + // Raw poll-fix coords (pre-dead-reckon) — the stale-grounded + // re-floor sweep keys floors off these (mirror of flights.js). + rawLat: lat, + rawLon: lon, + }; + this.data.set(icao24, meta); + + return { + prevMeta, + meta, + groundFlipped, + fixEpochMs: aircraft.positionTimeMs ?? observedAtMs, + }; + } + absence(id, { complete, likelyLanded }) { + 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/military/records.test.mjs b/src/layers/military/records.test.mjs new file mode 100644 index 000000000..df42760ce --- /dev/null +++ b/src/layers/military/records.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MilitaryFlightRecords } from './records.js'; +const observation = (fields = {}) => ({ + id: 'aaa001', + reference: 'aaa001', + longitude: -77, + latitude: 39, + baroAltitudeM: 1000, + ellipsoidAltitudeM: null, + onGround: false, + callsign: 'MIL1', + typeCode: 'C17', + speedMps: 100, + courseDeg: 90, + verticalRateMps: 1, + positionTimeMs: 1700000000000, + ...fields, +}); +const context = (fields = {}) => ({ + observedAtMs: 1700000001000, + floorWarmPoints: [], + modelOwnsVisual: false, + ...fields, +}); +const store = () => + new MilitaryFlightRecords({ + geoidHeight: () => 40, + cachedGroundFloor: () => 200, + floorAltitudeM: (height, floor) => Math.max(height, floor + 1.5), + }); + +test('military records preserve feet for aviation, metres for rendering and source epochs', () => { + const records = store(); + records.geoidReady = true; + const first = records.receive(observation(), context()); + assert.equal(first.meta.altitudeFt, 1000 / 0.3048); + assert.equal(first.meta.renderAltitudeM, 1040); + assert.equal(first.fixEpochMs, 1700000000000); + const next = records.receive( + observation({ callsign: '', typeCode: '', positionTimeMs: null }), + context(), + ); + assert.equal(next.meta.callsign, 'MIL1'); + assert.equal(next.meta.type, 'C17'); + assert.equal(next.fixEpochMs, 1700000001000); + assert.equal(Object.hasOwn(next.meta, 'cullPosition'), false); +}); + +test('military ground records leave model-owned placement alone and warm the same floor cell', () => { + const records = store(); + const owned = context({ modelOwnsVisual: true }); + const first = records.receive( + observation({ onGround: true, baroAltitudeM: null }), + owned, + ); + assert.equal(first.meta.renderAltitudeM, 0); + assert.deepEqual(owned.floorWarmPoints, [{ lat: 39, lon: -77 }]); + const unowned = context(); + const second = records.receive( + observation({ onGround: true, baroAltitudeM: null }), + unowned, + ); + assert.equal(second.meta.renderAltitudeM, 201.5); + assert.deepEqual(unowned.floorWarmPoints, owned.floorWarmPoints); +}); + +test('military records keep bounded partial retention and release metadata only after removal admission', () => { + const first = store(); + const second = store(); + const { meta } = first.receive(observation(), context()); + assert.equal(second.data.size, 0); + assert.equal( + first.absence('aaa001', { complete: false, likelyLanded: true }), + 'retain', + ); + meta.observedReceiptMs = Date.now() - 300001; + assert.equal( + first.absence('aaa001', { complete: false, likelyLanded: false }), + 'stale', + ); + assert.equal( + first.absence('aaa001', { complete: true, likelyLanded: false }), + 'stale', + ); + assert.equal( + first.absence('aaa001', { complete: true, likelyLanded: false }), + 'remove', + ); + assert.equal(first.data.get('aaa001'), meta); + first.forget('aaa001'); + assert.equal(first.data.size, 0); + assert.equal(first.geoidNCache.size, 0); + assert.equal(first.missingPolls.size, 0); + first.receive(observation(), context()); + assert.equal( + first.absence('aaa001', { complete: true, likelyLanded: true }), + 'remove', + ); +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 1e5dbb573..79ba10b5b 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -133,7 +133,7 @@ export function createRendering({ } function _militaryBillboardScale(icao24) { - const meta = flightState._flightData.get(icao24); + const meta = flightState.records.data.get(icao24); return ( BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * @@ -162,7 +162,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, @@ -280,7 +280,7 @@ export function createRendering({ * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ function _modelDisplayPosition(icao24, pos, result) { - const meta = flightState._flightData.get(icao24); + const meta = flightState.records.data.get(icao24); if (!meta || !meta.onGround) return pos; const h = flightState._groundSnap.heightFor( flightState._viewer, @@ -332,7 +332,7 @@ export function createRendering({ displayPos, course, model.modelMatrix, - _modelSpec(flightState._flightData.get(icao24)?.klass).headingOffsetDeg, + _modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg, ); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash @@ -380,7 +380,7 @@ export function createRendering({ * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ 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) || @@ -450,10 +450,10 @@ export function createRendering({ // Spec identity captured at load START (mirror of flights.js): a mid-load // reclassification makes the post-await admission reject the stale asset. // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass); + const 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, @@ -498,12 +498,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) { @@ -651,10 +651,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({ @@ -691,7 +691,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 ) { @@ -760,7 +760,7 @@ export function createRendering({ return; } const spec = _modelSpec( - flightState._flightData.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao)?.klass, ); _modelMatrix( displayPos, @@ -933,7 +933,7 @@ export function createRendering({ // ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb // read "beyond the horizon" and would hide low contacts awaiting floors). const beyondHorizon = !occluder.isPointVisible( - flightState._flightData.get(icao24)?.cullPosition || bb.position, + flightState.records.data.get(icao24)?.cullPosition || bb.position, ); // A billboard flipping INTO view (horizon reveal while the camera idles) // gets its rotation refreshed THIS tick even without a pose change — @@ -950,7 +950,7 @@ export function createRendering({ continue; } - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); // One sprite-owned write site composes freshness × focus × limb haze and // base class/ground scale × limb taper. The locked NearFarScalar remains @@ -983,7 +983,7 @@ export function createRendering({ flightState._cockpitContactMode && !isCockpitNear ? 1 : _militaryBillboardScale(icao24), - baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1, + baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1, baseColor: MIL_ICON_COLOR, focusFactor: focus.factor, cameraDistanceM, @@ -1003,7 +1003,7 @@ export function createRendering({ if (wantLarge !== !!bb._gevIconLarge) { bb._gevIconLarge = wantLarge; bb.image = aircraftIcon( - _iconKind(icao24, flightState._flightData.get(icao24)?.klass), + _iconKind(icao24, flightState.records.data.get(icao24)?.klass), wantLarge ? TRACKED_ICON_PX : undefined, ); } diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js new file mode 100644 index 000000000..d36576cf7 --- /dev/null +++ b/src/layers/military/snapshotRenderer.js @@ -0,0 +1,295 @@ +import * as Cesium from 'cesium'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { + turnRateFromFixHistory, + liftRepeatedGroundFix, + synthesizeForwardKinematicsFix, +} from '../../data/motionModel.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; +import { + BILLBOARD_SCALE, + GROUND_SCALE, + TRACKED_ICON_COLOR, + MIL_ICON_COLOR, +} from './policy.js'; + +/** Apply military record changes to Cesium resources and existing follow operations. */ +export function createMilitarySnapshotRenderer({ + flightState, + records, + groundFloor, + meshFloor, + militaryRegistry, + rendering, + tracking, + queries, +}) { + const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } = + groundFloor; + const { sampleMeshFloorCells } = meshFloor; + const { registerMilitaryIcaos } = militaryRegistry; + return function applySnapshot(snapshot, viewer) { + const currentIcaos = new Set(); + const receiptNowMs = snapshot.observedAtMs; + // Field-test fix (RS46): coarse floor cells to warm for the below-ground + // clamp — collected during the loop (low airborne contacts only), + // batch-resolved once after it. Never a fetch inside the loop. + const _floorWarmPoints = []; + + for (const aircraft of snapshot.records) { + const icao24 = aircraft.id; + + currentIcaos.add(icao24); + const { prevMeta, meta, groundFlipped, fixEpochMs } = records.receive( + aircraft, + { + observedAtMs: receiptNowMs, + floorWarmPoints: _floorWarmPoints, + modelOwnsVisual: aircraft.onGround + ? rendering._modelOwnsVisual(icao24) + : false, + }, + ); + const position = Cesium.Cartesian3.fromDegrees( + meta.rawLon, + meta.rawLat, + meta.renderAltitudeM, + ); + 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. + // adsb.lol's seen_pos is the AGE in seconds of the last position + // report, so the fix epoch is receipt time minus that age. Only + // append when the fix actually advances, so stale repeats don't + // create zero-dt segments. + const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + if (!flightState._positionHistory.has(icao24)) { + flightState._positionHistory.set(icao24, []); + } + const history = flightState._positionHistory.get(icao24); + const newest = history[history.length - 1]; + if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + // Per-fix kinematics: the fix's own velocity/track ride along so the + // extrapolation paths use the values that BELONG to the fix they + // extend, not whatever the latest poll reported. + history.push({ + time: fixTime, + epochMs: fixEpochMs, + position: position.clone(), + velocity: meta.speedMps, + track: meta.track, + }); + if (history.length > POSITION_HISTORY_LIMIT) { + history.shift(); + } + // Turn rate from the fix-track history — computed once per new fix + // (≤5 samples), consumed by the extrapolation paths at tick rate. + meta.turnRateDps = turnRateFromFixHistory(history); + // Trail accumulation is separate from the 5-fix DR history (PRD F2) + // so the visible trail keeps growing while tracked. Round 2 + // (owner): ground traffic appends too — taxi history stays live + // after touchdown; _appendTrailFix floors grounded positions at + // the surface so the ground leg drapes instead of diving. + if (isTracked) tracking._appendTrailFix(position.clone()); + } else { + const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24); + if (!modelOwnsGroundVisual) { + liftRepeatedGroundFix(newest, position, meta.onGround); + } + const kinematicsChanged = + newest.velocity !== meta.speedMps || newest.track !== meta.track; + if (kinematicsChanged) { + const synthetic = synthesizeForwardKinematicsFix(newest, { + epochMs: Date.now(), + velocity: meta.speedMps, + track: meta.track, + turnRateDps: meta.turnRateDps, + }); + if (synthetic) { + history.push(synthetic); + if (history.length > POSITION_HISTORY_LIMIT) history.shift(); + meta.turnRateDps = turnRateFromFixHistory(history); + if (isTracked) tracking._appendTrailFix(synthetic.position.clone()); + } + } + } + + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + // Ground flips (landing/takeoff) restyle this SAME billboard in + // place — the transition is never a removal. + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Per-class GLBs: a class change can mean a different asset OR scale — + // resync the live model, any in-flight load, and the tracked + // standalone (mirror of flights.js). + if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + } else { + const bb = flightState._billboardCollection.add({ + position, + image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta.klass] || 1) * + (meta.onGround ? GROUND_SCALE : 1), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: Cesium.Cartesian3.ZERO, + color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + sizeInMeters: false, + scaleByDistance: 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) { + const info = flightState.records.data.get(icao24); + tracking._updateTrackedLabelModel(icao24); + } + } + + // Field-test fix (RS46): one batch warm of the low-airborne floor cells + // collected above — fire-and-forget, single-flight, results read + // synchronously by NEXT poll's clamp (same contract as flights.js's + // grounded-surface warm). + warmGroundFloor(_floorWarmPoints); + // Round 6: re-floor STALE grounded contacts (mirror of flights.js) — + // a parked contact whose feed went quiet froze at its pre-warm height. + // The model-ownership gate matches the live grounded-clamp path (a + // tileset-snapped model owns its visual; moving its billboard would + // disturb groundSnap's input — the T7 one-shot invariant). + for (const [icao24, info] of flightState.records.data) { + if (!info?.onGround || currentIcaos.has(icao24)) continue; + if (rendering._modelOwnsVisual(icao24)) continue; + if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon)) + continue; + const floor = cachedGroundFloor(info.rawLat, info.rawLon); + if (!Number.isFinite(floor)) continue; + const lifted = floor + GROUND_FLOOR_LIFT_M; + if ( + Number.isFinite(info.renderAltitudeM) && + info.renderAltitudeM >= floor - 1 + ) + continue; + info.renderAltitudeM = lifted; + flightState._cullPositions.delete(icao24); + const liftedPos = Cesium.Cartesian3.fromDegrees( + info.rawLon, + info.rawLat, + lifted, + ); + const hist = flightState._positionHistory.get(icao24); + const newest = hist?.[hist.length - 1]; + if (newest) + newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position); + const bbStale = flightState._billboards.get(icao24); + if (bbStale) bbStale.position = liftedPos; + } + // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, + // budget-capped, viewer-proximate, google-3d regime only), excluding + // this layer's own billboards/models from the probe. + { + const viewerCarto = + flightState._viewer?.camera?.positionCartographic || null; + sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { + excludeObjects: [ + ...flightState._billboards.values(), + ...flightState._models.values(), + flightState._trackedModel, + ].filter(Boolean), + viewerLat: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? Cesium.Math.toDegrees(viewerCarto.longitude) + : undefined, + }); + } + + // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. + // adsb.lol routinely drops aircraft for a single poll; immediate removal + // made icons blink and yanked the camera off actively tracked flights. + // EXCEPTION: likely-landed planes (last fix low + slow) get only + // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a + // feed gap, and the full grace left phantom planes parked at airports. + for (const [icao24, bb] of flightState._billboards) { + if (currentIcaos.has(icao24)) continue; + 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, _buildTrackedLabel appends the STALE cue so + // last-known altitude/speed 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); + } + + // Fresh courses arrived — force a rotation pass on the next fleet tick + flightState._lastCamPoseSig = ''; + + // Feed the shared registry so the flights layer can classify/suppress + registerMilitaryIcaos(currentIcaos); + + return { count: flightState._billboards.size, ids: currentIcaos }; + }; +} diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 8d5cc27cd..38ac280b4 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,10 +1,16 @@ +import { MilitaryFlightRecords } from './records.js'; +import { createMilitaryFeed } from './ingestion.js'; import * as Cesium from 'cesium'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; const flightState = { lifetime: new AbortController() }; - - flightState._source = source; + flightState.feed = createMilitaryFeed(source); + flightState.records = new MilitaryFlightRecords({ + ...services.geoid, + ...services.groundFloor, + }); + flightState._cullPositions = new Map(); /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: * military contacts should read as their WEIGHT CLASS, always in this layer's @@ -102,50 +108,10 @@ export function createFlightState({ source, services }) { flightState._detectionObjects = new Map(); - /** @type {Map} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */ - - flightState._flightData = new Map(); - /** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ flightState._positionHistory = new Map(); - /** @type {number} Current number of visible aircraft */ - - flightState._count = 0; - - /** @type {number|null} Epoch ms of last successful API update */ - - flightState._lastUpdate = null; - - /** @type {boolean} True when in error-backoff mode */ - - flightState._backoff = false; - - /** @type {number} Epoch ms after which the next retry is allowed */ - - flightState._retryAt = 0; - - /** @type {string|null} Human-readable description of the last error */ - - flightState._lastError = null; - - flightState._activeUpdateControllers = new Set(); - - /** @type {number|null} HTTP status code from the last API response */ - - flightState._lastStatus = null; - - flightState._lastSource = source?.label || 'Aircraft'; - - /** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */ - - flightState._geoidReady = false; - - /** @type {Map} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */ - - flightState._geoidNCache = new Map(); - // -- Click-to-track state -- /** @type {string|null} ICAO hex of the currently tracked aircraft */ @@ -155,15 +121,6 @@ export function createFlightState({ source, services }) { flightState._trackingIntentGeneration = 0; - flightState._trackingRefreshEpoch = 0; - - flightState._lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - ids: new Set(), - source: flightState._lastSource, - }; - /** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ flightState._trackedEntity = null; @@ -226,10 +183,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/military/testing.js b/src/layers/military/testing.js index 670676f4f..15ab14050 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -40,14 +40,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; @@ -57,8 +58,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. */ @@ -66,10 +67,10 @@ export function createTesting({ function _setMilitaryTrackingRefreshOutcomeForTest({ status = 'accepted', ids = [], - source = flightState._lastSource, + source = flightState.feed._lastSource, } = {}) { - 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())), @@ -86,7 +87,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/military/tracking.js b/src/layers/military/tracking.js index f4c6e2fc6..b0cfb0368 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -61,7 +61,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; @@ -90,13 +90,13 @@ export function createTracking({ function _contextSubjectMetadata(icao24) { const described = parts.queries._describeFlight(icao24); if (!described) return null; - const info = flightState._flightData.get(icao24); + const info = flightState.records.data.get(icao24); const label = described.callsign || described.registration || icao24; return { id: icao24, layerId: 'military', layerName: 'Military Flights', - source: flightState._lastSource, + source: flightState.feed._lastSource, label, latitude: described.latitude, longitude: described.longitude, @@ -218,7 +218,7 @@ export function createTracking({ function _buildTrackedLabel(info, icao24) { const stale = - flightState._missingPolls.get(icao24) || flightState._backoff + flightState.records.missingPolls.get(icao24) || flightState.feed._backoff ? ' · STALE' : ''; const callsign = @@ -251,7 +251,7 @@ export function createTracking({ if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) return; flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(flightState._flightData.get(icao24), icao24), + _buildTrackedLabel(flightState.records.data.get(icao24), icao24), '#ffd166', ); refreshTrackedReadout(flightState._trackedEntity); @@ -268,7 +268,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, ); @@ -606,8 +606,8 @@ export function createTracking({ async function _backfillTrail(icao24, token, oldestFixEpochSec) { let trace = 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, @@ -775,7 +775,7 @@ export function createTracking({ // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. if (flightState._billboards.has(flightState._trackedIcao)) { const bb = flightState._billboards.get(flightState._trackedIcao); - const meta = flightState._flightData.get(flightState._trackedIcao); + const meta = flightState.records.data.get(flightState._trackedIcao); bb.show = true; bb.width = 20; bb.height = 20; @@ -855,7 +855,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; @@ -908,7 +908,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, ), @@ -917,7 +917,7 @@ export function createTracking({ scale: BILLBOARD_SCALE * (CLASS_SCALE_2D[ - flightState._flightData.get(flightState._trackedIcao)?.klass + flightState.records.data.get(flightState._trackedIcao)?.klass ] || 1), // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); // transparent once the STANDALONE tracked model is actually up (ready + shown). @@ -939,7 +939,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;