mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #499 from bilawalsidhu/refactor/military-flight-records
Separate military-flight records and rendering
This commit is contained in:
23 files changed
+928
-665
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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<string, Object>} ICAO hex -> flight metadata (callsign, type, registration, operator, altitudeFt, speedMps, track) */
|
||||
|
||||
flightState._flightData = new Map();
|
||||
|
||||
/** @type {Map<string, Array<{time: Cesium.JulianDate, position: Cesium.Cartesian3}>>} ICAO hex -> recent position samples for dead reckoning */
|
||||
|
||||
flightState._positionHistory = new Map();
|
||||
|
||||
/** @type {number} Current number of visible aircraft */
|
||||
|
||||
flightState._count = 0;
|
||||
|
||||
/** @type {number|null} Epoch ms of last successful API update */
|
||||
|
||||
flightState._lastUpdate = null;
|
||||
|
||||
/** @type {boolean} True when in error-backoff mode */
|
||||
|
||||
flightState._backoff = false;
|
||||
|
||||
/** @type {number} Epoch ms after which the next retry is allowed */
|
||||
|
||||
flightState._retryAt = 0;
|
||||
|
||||
/** @type {string|null} Human-readable description of the last error */
|
||||
|
||||
flightState._lastError = null;
|
||||
|
||||
flightState._activeUpdateControllers = new Set();
|
||||
|
||||
/** @type {number|null} HTTP status code from the last API response */
|
||||
|
||||
flightState._lastStatus = null;
|
||||
|
||||
flightState._lastSource = source?.label || 'Aircraft';
|
||||
|
||||
/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */
|
||||
|
||||
flightState._geoidReady = false;
|
||||
|
||||
/** @type {Map<string, number>} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */
|
||||
|
||||
flightState._geoidNCache = new Map();
|
||||
|
||||
// -- Click-to-track state --
|
||||
/** @type {string|null} ICAO hex of the currently tracked aircraft */
|
||||
|
||||
@@ -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<string, number>} icao24 -> consecutive missed polls */
|
||||
|
||||
flightState._missingPolls = new Map();
|
||||
|
||||
/** @type {number} Epoch ms of the last fleet dead-reckoning pass */
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user