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