mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor: separate civil-flight records and rendering
This commit is contained in:
1 parent
a658e63fed
commit
4cb7334da5
27 files changed
+1113
-819
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
# Changelog
|
# 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 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.
|
- 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;
|
assembly. `applicationShell` retains composition and compatibility methods;
|
||||||
those methods delegate to the state owner. Source-based regression checks inspect
|
those methods delegate to the state owner. Source-based regression checks inspect
|
||||||
that implementation owner, and browser acceptance exercises the assembled UI.
|
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
|
# 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,
|
The UI facade composes separate owners for navigation authority, share restoration,
|
||||||
visual settings and panel chrome. Navigation owns request generations and follow
|
visual settings and panel chrome. Navigation owns request generations and follow
|
||||||
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
||||||
|
|||||||
+3
-1
@@ -200,6 +200,8 @@
|
|||||||
"./ui/navigation": "./src/ui/navigationController.js",
|
"./ui/navigation": "./src/ui/navigationController.js",
|
||||||
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||||
"./ui/visual-settings": "./src/ui/visualSettings.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/visualSettings.js",
|
||||||
"src/ui/panelChrome.js",
|
"src/ui/panelChrome.js",
|
||||||
"src/testSupport/readShellSource.mjs",
|
"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/rendering.js",
|
||||||
"src/layers/flights/state.js",
|
"src/layers/flights/state.js",
|
||||||
"src/layers/flights/testing.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1539,7 +1542,10 @@
|
|||||||
"src/voice/realtimeController.js",
|
"src/voice/realtimeController.js",
|
||||||
"src/voice/voiceCost.js",
|
"src/voice/voiceCost.js",
|
||||||
"src/weatherEffectsMath.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": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
@@ -1863,7 +1869,10 @@
|
|||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/radioTunerModel.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": [
|
"external": [
|
||||||
"cesium",
|
"cesium",
|
||||||
@@ -1977,5 +1986,24 @@
|
|||||||
"src/ui/uiLifetime.js"
|
"src/ui/uiLifetime.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"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]]) {
|
for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) {
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
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`,
|
`${name} must mark its aged-out cull as an eviction`,
|
||||||
);
|
);
|
||||||
assert.match(
|
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) || [];
|
const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || [];
|
||||||
assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`);
|
assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`);
|
||||||
for (const call of callSites) {
|
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`);
|
`${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function createController({
|
|||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
function _abortActiveUpdates() {
|
function _abortActiveUpdates() {
|
||||||
for (const controller of flightState._activeUpdateControllers)
|
for (const controller of flightState.feed._activeUpdateControllers)
|
||||||
controller.abort();
|
controller.abort();
|
||||||
flightState._activeUpdateControllers.clear();
|
flightState.feed._activeUpdateControllers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _flightQuery(viewer) {
|
function _flightQuery(viewer) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function createEnrichment({
|
|||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
lifetime.signal.throwIfAborted();
|
lifetime.signal.throwIfAborted();
|
||||||
return flightState._source.getEnrichment?.(job.query, {
|
return flightState.feed._source.getEnrichment?.(job.query, {
|
||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -81,7 +81,7 @@ export function createEnrichment({
|
|||||||
`t:${icao24}`,
|
`t:${icao24}`,
|
||||||
{ kind: 'type', id: icao24.toLowerCase() },
|
{ kind: 'type', id: icao24.toLowerCase() },
|
||||||
(data) => {
|
(data) => {
|
||||||
const meta = flightState._flightData.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
if (!meta) return; // evicted while the lookup was in flight
|
if (!meta) return; // evicted while the lookup was in flight
|
||||||
meta.typeCode = data.typeCode || meta.typeCode;
|
meta.typeCode = data.typeCode || meta.typeCode;
|
||||||
meta.typeName = data.typeName || meta.typeName;
|
meta.typeName = data.typeName || meta.typeName;
|
||||||
@@ -109,7 +109,7 @@ export function createEnrichment({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _requestRouteEnrichment(icao24) {
|
function _requestRouteEnrichment(icao24) {
|
||||||
const cs = String(flightState._flightData.get(icao24)?.callsign || '')
|
const cs = String(flightState.records.data.get(icao24)?.callsign || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve
|
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}`,
|
`r:${cs}`,
|
||||||
{ kind: 'route', id: cs },
|
{ kind: 'route', id: cs },
|
||||||
(data) => {
|
(data) => {
|
||||||
const meta = flightState._flightData.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
meta.airline = data.airline || meta.airline;
|
meta.airline = data.airline || meta.airline;
|
||||||
if (data.origin && data.destination)
|
if (data.origin && data.destination)
|
||||||
@@ -198,7 +198,7 @@ export function createEnrichment({
|
|||||||
for (const [icao24, bb] of flightState._billboards) {
|
for (const [icao24, bb] of flightState._billboards) {
|
||||||
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
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 (!/^[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
|
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||||
Cesium.Cartesian3.clone(
|
Cesium.Cartesian3.clone(
|
||||||
bb.position,
|
bb.position,
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ export function createEvidence({
|
|||||||
for (const bb of flightState._billboards.values())
|
for (const bb of flightState._billboards.values())
|
||||||
flightState._billboardCollection.remove(bb);
|
flightState._billboardCollection.remove(bb);
|
||||||
flightState._billboards.clear();
|
flightState._billboards.clear();
|
||||||
flightState._flightData.clear();
|
flightState.records.data.clear();
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._missingPolls.clear();
|
flightState.records.missingPolls.clear();
|
||||||
flightState._focusEvidenceIds.clear();
|
flightState._focusEvidenceIds.clear();
|
||||||
|
|
||||||
for (const record of Array.isArray(records) ? records : []) {
|
for (const record of Array.isArray(records) ? records : []) {
|
||||||
@@ -79,9 +79,9 @@ export function createEvidence({
|
|||||||
lastContactEpochMs: Date.now(),
|
lastContactEpochMs: Date.now(),
|
||||||
rawLat: record.latitude ?? null,
|
rawLat: record.latitude ?? null,
|
||||||
rawLon: record.longitude ?? 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);
|
flightState._focusEvidenceIds.add(id);
|
||||||
const bb = flightState._billboardCollection.add({
|
const bb = flightState._billboardCollection.add({
|
||||||
position,
|
position,
|
||||||
@@ -100,10 +100,10 @@ export function createEvidence({
|
|||||||
});
|
});
|
||||||
flightState._billboards.set(id, bb);
|
flightState._billboards.set(id, bb);
|
||||||
}
|
}
|
||||||
flightState._count = flightState._billboards.size;
|
flightState.feed._count = flightState._billboards.size;
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
flightState._viewer.scene.requestRender?.();
|
flightState._viewer.scene.requestRender?.();
|
||||||
return { ok: true, count: flightState._count };
|
return { ok: true, count: flightState.feed._count };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update explicit evidence positions without rebuilding billboards. */
|
/** Update explicit evidence positions without rebuilding billboards. */
|
||||||
@@ -120,7 +120,7 @@ export function createEvidence({
|
|||||||
const bb = flightState._billboards.get(id);
|
const bb = flightState._billboards.get(id);
|
||||||
if (!position || !bb) continue;
|
if (!position || !bb) continue;
|
||||||
bb.position = position;
|
bb.position = position;
|
||||||
const meta = flightState._flightData.get(id);
|
const meta = flightState.records.data.get(id);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg;
|
if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg;
|
||||||
if (Number.isFinite(record.velocityMps))
|
if (Number.isFinite(record.velocityMps))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
|
||||||
import { createFlightState } from './state.js';
|
import { createFlightState } from './state.js';
|
||||||
import { createRendering } from './rendering.js';
|
import { createRendering } from './rendering.js';
|
||||||
import { createMotion } from './motion.js';
|
import { createMotion } from './motion.js';
|
||||||
@@ -24,11 +25,34 @@ export function createCivilFlightLayer({
|
|||||||
parts.tracking = createTracking(context);
|
parts.tracking = createTracking(context);
|
||||||
parts.controller = createController(context);
|
parts.controller = createController(context);
|
||||||
parts.enrichment = createEnrichment(context);
|
parts.enrichment = createEnrichment(context);
|
||||||
parts.ingestion = createIngestion(context);
|
|
||||||
parts.lifecycle = createLifecycle(context);
|
parts.lifecycle = createLifecycle(context);
|
||||||
parts.evidence = createEvidence(context);
|
parts.evidence = createEvidence(context);
|
||||||
parts.testing = createTesting(context);
|
parts.testing = createTesting(context);
|
||||||
parts.queries = createQueries(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(
|
Object.assign(
|
||||||
layer,
|
layer,
|
||||||
parts.queries.methods,
|
parts.queries.methods,
|
||||||
|
|||||||
+64
-613
@@ -1,661 +1,112 @@
|
|||||||
import * as Cesium from 'cesium';
|
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
|
||||||
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';
|
|
||||||
|
|
||||||
|
/** Own acquisition, cancellation, freshness and backoff independently of rendering. */
|
||||||
export function createIngestion({
|
export function createIngestion({
|
||||||
flightState,
|
feed,
|
||||||
services,
|
getQuery,
|
||||||
parts,
|
applySnapshot,
|
||||||
layer,
|
setSourceLabel,
|
||||||
resolveAsset,
|
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 = {
|
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 } = {}) {
|
async update(viewer, { signal = null } = {}) {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch;
|
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||||
flightState._lastTrackingRefreshOutcome = {
|
feed._lastTrackingRefreshOutcome = {
|
||||||
epoch: trackingRefreshEpoch,
|
epoch: trackingRefreshEpoch,
|
||||||
status: 'source-unavailable',
|
status: 'source-unavailable',
|
||||||
ids: new Set(),
|
ids: new Set(),
|
||||||
source: flightState._lastSource,
|
source: feed._lastSource,
|
||||||
coverage: flightState._lastCoverage,
|
coverage: feed._lastCoverage,
|
||||||
};
|
};
|
||||||
if (flightState._retryAt && nowMs < flightState._retryAt) {
|
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||||
flightState._backoff = true;
|
feed._backoff = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceController = new AbortController();
|
const resourceController = new AbortController();
|
||||||
flightState._activeUpdateControllers.add(resourceController);
|
feed._activeUpdateControllers.add(resourceController);
|
||||||
const updateSignal = signal
|
const updateSignal = signal
|
||||||
? AbortSignal.any([signal, resourceController.signal])
|
? AbortSignal.any([signal, resourceController.signal])
|
||||||
: resourceController.signal;
|
: resourceController.signal;
|
||||||
try {
|
try {
|
||||||
updateSignal.throwIfAborted();
|
updateSignal.throwIfAborted();
|
||||||
const snapshot = await flightState._source.getSnapshot(
|
const snapshot = await feed._source.getSnapshot(getQuery(viewer), {
|
||||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
signal: updateSignal,
|
||||||
{ signal: updateSignal },
|
});
|
||||||
);
|
|
||||||
updateSignal.throwIfAborted();
|
updateSignal.throwIfAborted();
|
||||||
flightState._lastStatus = snapshot.status ?? 200;
|
feed._lastStatus = snapshot.status ?? 200;
|
||||||
const usableStates = snapshot.records;
|
|
||||||
const sourceEpochMs = snapshot.observedAtMs;
|
const sourceEpochMs = snapshot.observedAtMs;
|
||||||
const sourceAgeMs = snapshot.ageMs;
|
const sourceAgeMs = snapshot.ageMs;
|
||||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||||
flightState._backoff = sourceStale;
|
feed._backoff = sourceStale;
|
||||||
flightState._retryAt = 0;
|
feed._retryAt = 0;
|
||||||
flightState._lastError = sourceStale
|
feed._lastError = sourceStale
|
||||||
? sourceAgeMs == null
|
? sourceAgeMs == null
|
||||||
? 'Source snapshot time unavailable'
|
? 'Source snapshot time unavailable'
|
||||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||||
: null;
|
: null;
|
||||||
flightState._lastSource = snapshot.source;
|
feed._lastSource = snapshot.source;
|
||||||
flightState._lastCoverage = snapshot.coverage;
|
feed._lastCoverage = snapshot.coverage;
|
||||||
flightsLayer.source = flightState._lastSource;
|
setSourceLabel(feed._lastSource);
|
||||||
const currentIcaos = new Set();
|
const accepted = applySnapshot(snapshot, viewer);
|
||||||
const acceptedSnapshotIcaos = new Set();
|
feed._count = accepted.count;
|
||||||
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;
|
|
||||||
// Freshness belongs to the source snapshot, not the moment this browser
|
// Freshness belongs to the source snapshot, not the moment this browser
|
||||||
// received a cached 200 response.
|
// received a cached 200 response.
|
||||||
flightState._lastUpdate = sourceEpochMs;
|
feed._lastUpdate = sourceEpochMs;
|
||||||
flightState._lastTrackingRefreshOutcome = {
|
feed._lastTrackingRefreshOutcome = {
|
||||||
epoch: trackingRefreshEpoch,
|
epoch: trackingRefreshEpoch,
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
ids: acceptedSnapshotIcaos,
|
ids: accepted.ids,
|
||||||
source: flightState._lastSource,
|
source: feed._lastSource,
|
||||||
coverage: flightState._lastCoverage,
|
coverage: feed._lastCoverage,
|
||||||
};
|
};
|
||||||
console.log(`[Data:Flights] Updated: ${flightState._count} aircraft`);
|
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||||
parts.tracking._applyPendingTrackingRestore();
|
applyPendingTrackingRestore();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||||
throw new DOMException('Flights update aborted', 'AbortError');
|
throw new DOMException('Flights update aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
console.warn('[Data:Flights] Fetch error:', e);
|
console.warn('[Data:Flights] Fetch error:', e);
|
||||||
flightState._backoff = true;
|
feed._backoff = true;
|
||||||
flightState._retryAt =
|
feed._retryAt =
|
||||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||||
flightState._lastStatus = e?.status ?? null;
|
feed._lastStatus = e?.status ?? null;
|
||||||
if (e?.source) {
|
if (e?.source) {
|
||||||
flightState._lastSource = e.source;
|
feed._lastSource = e.source;
|
||||||
this.source = flightState._lastSource;
|
setSourceLabel(feed._lastSource);
|
||||||
}
|
}
|
||||||
flightState._lastError =
|
feed._lastError =
|
||||||
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
|
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
|
||||||
} finally {
|
} finally {
|
||||||
flightState._activeUpdateControllers.delete(resourceController);
|
feed._activeUpdateControllers.delete(resourceController);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { methods };
|
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');
|
throw new Error('Configure the source before layer initialization');
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('A snapshot source is required');
|
throw new TypeError('A snapshot source is required');
|
||||||
flightState._source = source;
|
flightState.feed._source = source;
|
||||||
flightState._lastSource = source.label || flightState._lastSource;
|
flightState.feed._lastSource =
|
||||||
this.source = flightState._lastSource;
|
source.label || flightState.feed._lastSource;
|
||||||
|
this.source = flightState.feed._lastSource;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +41,7 @@ export function createLifecycle({
|
|||||||
init(viewer) {
|
init(viewer) {
|
||||||
if (flightState._viewer)
|
if (flightState._viewer)
|
||||||
throw new Error('Flight layer is already initialized');
|
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');
|
throw new TypeError('A snapshot source is required');
|
||||||
if (flightState.lifetime.signal.aborted)
|
if (flightState.lifetime.signal.aborted)
|
||||||
flightState.lifetime = new AbortController();
|
flightState.lifetime = new AbortController();
|
||||||
@@ -78,19 +79,21 @@ export function createLifecycle({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
flightState._billboards = new Map();
|
flightState._billboards = new Map();
|
||||||
|
flightState._cullPositions.clear();
|
||||||
flightState._detectionObjects = new Map();
|
flightState._detectionObjects = new Map();
|
||||||
flightState._flightData = new Map();
|
flightState.records.data = new Map();
|
||||||
flightState._positionHistory = new Map();
|
flightState._positionHistory = new Map();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._groundSnap.clear();
|
flightState._groundSnap.clear();
|
||||||
flightState._count = 0;
|
flightState.feed._count = 0;
|
||||||
flightState._lastUpdate = null;
|
flightState.feed._lastUpdate = null;
|
||||||
flightState._backoff = false;
|
flightState.feed._backoff = false;
|
||||||
flightState._retryAt = 0;
|
flightState.feed._retryAt = 0;
|
||||||
flightState._lastError = null;
|
flightState.feed._lastError = null;
|
||||||
flightState._lastStatus = null;
|
flightState.feed._lastStatus = null;
|
||||||
flightState._lastSource = flightState._source.label || 'Aircraft';
|
flightState.feed._lastSource =
|
||||||
flightState._lastCoverage = 'worldwide upstream snapshot';
|
flightState.feed._source.label || 'Aircraft';
|
||||||
|
flightState.feed._lastCoverage = 'worldwide upstream snapshot';
|
||||||
flightState._trackedIcao = null;
|
flightState._trackedIcao = null;
|
||||||
parts.tracking._resetTrackedSelectionState();
|
parts.tracking._resetTrackedSelectionState();
|
||||||
flightState._trackedEntity = null;
|
flightState._trackedEntity = null;
|
||||||
@@ -142,11 +145,11 @@ export function createLifecycle({
|
|||||||
// Height-datum fix: warm the geoid grid once per layer-enable. The poll loop
|
// Height-datum fix: warm the geoid grid once per layer-enable. The poll loop
|
||||||
// only reads geoidHeight() synchronously after this resolves (guarded by
|
// only reads geoidHeight() synchronously after this resolves (guarded by
|
||||||
// _geoidReady) — never awaited per-aircraft, never blocking a poll tick.
|
// _geoidReady) — never awaited per-aircraft, never blocking a poll tick.
|
||||||
if (!flightState._geoidReady) {
|
if (!flightState.records.geoidReady) {
|
||||||
const lifetime = flightState.lifetime;
|
const lifetime = flightState.lifetime;
|
||||||
ensureGeoidReady()
|
ensureGeoidReady()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!lifetime.signal.aborted) flightState._geoidReady = true;
|
if (!lifetime.signal.aborted) flightState.records.geoidReady = true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
|
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
|
||||||
@@ -300,8 +303,9 @@ export function createLifecycle({
|
|||||||
}
|
}
|
||||||
flightState._planeModelLoaded = false;
|
flightState._planeModelLoaded = false;
|
||||||
flightState._billboards.clear();
|
flightState._billboards.clear();
|
||||||
|
flightState._cullPositions.clear();
|
||||||
flightState._detectionObjects.clear();
|
flightState._detectionObjects.clear();
|
||||||
flightState._flightData.clear();
|
flightState.records.data.clear();
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._groundSnap.clear();
|
flightState._groundSnap.clear();
|
||||||
@@ -312,20 +316,20 @@ export function createLifecycle({
|
|||||||
clearTimeout(flightState._enrichDripTimer);
|
clearTimeout(flightState._enrichDripTimer);
|
||||||
flightState._enrichDripTimer = null;
|
flightState._enrichDripTimer = null;
|
||||||
}
|
}
|
||||||
flightState._missingPolls.clear();
|
flightState.records.missingPolls.clear();
|
||||||
flightState._focusEvidenceIds.clear();
|
flightState._focusEvidenceIds.clear();
|
||||||
flightState._count = 0;
|
flightState.feed._count = 0;
|
||||||
flightState._lastUpdate = null;
|
flightState.feed._lastUpdate = null;
|
||||||
flightState._cockpitContactMode = false;
|
flightState._cockpitContactMode = false;
|
||||||
flightState._cockpitNearContacts = new Set();
|
flightState._cockpitNearContacts = new Set();
|
||||||
flightState._cockpitSubjectId = null;
|
flightState._cockpitSubjectId = null;
|
||||||
flightState._trackingRefreshEpoch += 1;
|
flightState.feed._trackingRefreshEpoch += 1;
|
||||||
flightState._lastTrackingRefreshOutcome = {
|
flightState.feed._lastTrackingRefreshOutcome = {
|
||||||
epoch: flightState._trackingRefreshEpoch,
|
epoch: flightState.feed._trackingRefreshEpoch,
|
||||||
status: 'destroyed',
|
status: 'destroyed',
|
||||||
ids: new Set(),
|
ids: new Set(),
|
||||||
source: flightState._lastSource,
|
source: flightState.feed._lastSource,
|
||||||
coverage: flightState._lastCoverage,
|
coverage: flightState.feed._lastCoverage,
|
||||||
};
|
};
|
||||||
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
|
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
|
||||||
flightState._viewer = null;
|
flightState._viewer = null;
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function createMotion({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _refloorStaleGroundedContacts(currentIcaos) {
|
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 (!info?.onGround || currentIcaos.has(icao24)) continue;
|
||||||
if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon))
|
if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon))
|
||||||
continue;
|
continue;
|
||||||
@@ -94,7 +94,7 @@ export function createMotion({
|
|||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
info.renderAltitudeM = lifted;
|
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(
|
const position = Cesium.Cartesian3.fromDegrees(
|
||||||
info.rawLon,
|
info.rawLon,
|
||||||
info.rawLat,
|
info.rawLat,
|
||||||
@@ -125,7 +125,7 @@ export function createMotion({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _deadReckon(icao24, result) {
|
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)) {
|
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
|
||||||
const position = flightState._billboards.get(icao24)?.position;
|
const position = flightState._billboards.get(icao24)?.position;
|
||||||
flightState._drCourseDeg = info?.true_track || 0;
|
flightState._drCourseDeg = info?.true_track || 0;
|
||||||
@@ -323,7 +323,7 @@ export function createMotion({
|
|||||||
const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position);
|
const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position);
|
||||||
if (parts.rendering._modelOwnsVisual(icao24)) {
|
if (parts.rendering._modelOwnsVisual(icao24)) {
|
||||||
const spec = parts.rendering._modelSpec(
|
const spec = parts.rendering._modelSpec(
|
||||||
flightState._flightData.get(icao24)?.klass,
|
flightState.records.data.get(icao24)?.klass,
|
||||||
);
|
);
|
||||||
const scale = trackedModelScaleForPixelCap({
|
const scale = trackedModelScaleForPixelCap({
|
||||||
baseScale: spec.scale,
|
baseScale: spec.scale,
|
||||||
@@ -349,7 +349,7 @@ export function createMotion({
|
|||||||
const height = billboard?.height?.getValue(time) ?? 28;
|
const height = billboard?.height?.getValue(time) ?? 28;
|
||||||
const scale =
|
const scale =
|
||||||
billboard?.scale?.getValue(time) ??
|
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 =
|
const scaleByDistance =
|
||||||
billboard?.scaleByDistance?.getValue(time) ??
|
billboard?.scaleByDistance?.getValue(time) ??
|
||||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
||||||
@@ -392,7 +392,7 @@ export function createMotion({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
if (sameTrack) {
|
if (sameTrack) {
|
||||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||||
const speed = (info && info.velocity) || 0;
|
const speed = (info && info.velocity) || 0;
|
||||||
@@ -499,7 +499,7 @@ export function createMotion({
|
|||||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||||
) {
|
) {
|
||||||
const spec = parts.rendering._modelSpec(
|
const spec = parts.rendering._modelSpec(
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
return modelVisualAnchor(
|
return modelVisualAnchor(
|
||||||
flightState._trackedModel.modelMatrix,
|
flightState._trackedModel.modelMatrix,
|
||||||
@@ -544,7 +544,7 @@ export function createMotion({
|
|||||||
)
|
)
|
||||||
return 0;
|
return 0;
|
||||||
const spec = parts.rendering._modelSpec(
|
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)
|
const scale = Number.isFinite(flightState._trackedModel.computedScale)
|
||||||
? flightState._trackedModel.computedScale
|
? flightState._trackedModel.computedScale
|
||||||
@@ -558,7 +558,7 @@ export function createMotion({
|
|||||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||||
) {
|
) {
|
||||||
const spec = parts.rendering._modelSpec(
|
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
|
// Through the model's OWN render chain (modelVisualAnchor's hand-rolled
|
||||||
// half-correction put this offset on the lateral axis — see
|
// 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. */
|
* the same aircraft can never disagree across the handoff. */
|
||||||
|
|
||||||
function _trackedDisplayCourse() {
|
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 fallback = (info && info.true_track) || 0;
|
||||||
const cacheValid =
|
const cacheValid =
|
||||||
flightState._drReconcileValid &&
|
flightState._drReconcileValid &&
|
||||||
@@ -1018,7 +1018,7 @@ export function createMotion({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const [icao24, info] of flightState._flightData) {
|
for (const [icao24, info] of flightState.records.data) {
|
||||||
if (!info?.onGround) continue;
|
if (!info?.onGround) continue;
|
||||||
// T7: a contact whose 3D model is the visual never reads a display floor.
|
// T7: a contact whose 3D model is the visual never reads a display floor.
|
||||||
if (parts.rendering._modelOwnsVisual(icao24)) continue;
|
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 first = createFlightState({ services: services() });
|
||||||
const second = createFlightState({ services: services() });
|
const second = createFlightState({ services: services() });
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'_flightData',
|
'records',
|
||||||
|
'feed',
|
||||||
'_billboards',
|
'_billboards',
|
||||||
'_positionHistory',
|
'_positionHistory',
|
||||||
'_displayFloorState',
|
'_displayFloorState',
|
||||||
'_groundSnap',
|
'_groundSnap',
|
||||||
'_scratchCarto',
|
'_scratchCarto',
|
||||||
'_models',
|
'_models',
|
||||||
'_activeUpdateControllers',
|
|
||||||
'lifetime',
|
'lifetime',
|
||||||
]) {
|
]) {
|
||||||
assert.notEqual(first[key], second[key], key);
|
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 () => {
|
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';
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
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);
|
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_CONTACT_SIZE_PX = 6;
|
||||||
|
|
||||||
export const COCKPIT_CIVILIAN_COLOR =
|
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). */
|
/** @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
|
// --- 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
|
// 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
|
// 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). */
|
/** @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
|
// 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
|
// contacts rendering below the alt ceiling are ever clamped/warmed (terrain
|
||||||
// tops out well under it outside the extreme Himalaya; cruise traffic can't
|
// 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).
|
// need unbounded terrain resolution (the tracked contact clamps regardless).
|
||||||
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
|
/** @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. */
|
/** @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
|
// Icon orientation (2026-06-10 playtest fix): rotation is computed by
|
||||||
// projecting each aircraft's course vector into WINDOW coordinates
|
// 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 { displayedKinematics } from '../../data/motionModel.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
|
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
|
||||||
@@ -43,13 +44,6 @@ export function createQueries({
|
|||||||
* @returns {number} Approximate great-circle distance in km.
|
* @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"
|
* 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
|
* (low + slow) — see the landed fast-cull rationale above. Both gates must
|
||||||
@@ -59,7 +53,7 @@ export function createQueries({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _likelyLanded(icao24) {
|
function _likelyLanded(icao24) {
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
if (!info) return false;
|
if (!info) return false;
|
||||||
// Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes
|
// Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes
|
||||||
// never heal"): the fast cull only applies to contacts that were AIRBORNE
|
// 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 —
|
// user-visible surface renders `position` as the plane's on-screen location —
|
||||||
// every visual consumer reads the floored per-frame cache instead (see
|
// every visual consumer reads the floored per-frame cache instead (see
|
||||||
// `_trackedDisplayPosition`). If that ever changes, floor this path too.
|
// `_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 bb = flightState._billboards.get(icao24);
|
||||||
const basePos =
|
const basePos =
|
||||||
parts.motion._deadReckon(icao24) || (bb ? bb.position : null);
|
parts.motion._deadReckon(icao24) || (bb ? bb.position : null);
|
||||||
@@ -170,7 +164,8 @@ export function createQueries({
|
|||||||
velocityMps: displayed.speedMps,
|
velocityMps: displayed.speedMps,
|
||||||
track: displayed.trackDeg,
|
track: displayed.trackDeg,
|
||||||
stale: Boolean(
|
stale: Boolean(
|
||||||
flightState._missingPolls.get(icao24) || flightState._backoff,
|
flightState.records.missingPolls.get(icao24) ||
|
||||||
|
flightState.feed._backoff,
|
||||||
),
|
),
|
||||||
airline: info?.airline ?? null,
|
airline: info?.airline ?? null,
|
||||||
// CLASS label follows the TR-3B conversion so every downstream card
|
// CLASS label follows the TR-3B conversion so every downstream card
|
||||||
@@ -271,7 +266,7 @@ export function createQueries({
|
|||||||
|
|
||||||
icon: '✈️',
|
icon: '✈️',
|
||||||
|
|
||||||
source: flightState._lastSource,
|
source: flightState.feed._lastSource,
|
||||||
|
|
||||||
// Browser-harness seam: isolates synthetic display-floor scenarios without
|
// Browser-harness seam: isolates synthetic display-floor scenarios without
|
||||||
// changing any production lifecycle or cache policy.
|
// changing any production lifecycle or cache policy.
|
||||||
@@ -429,7 +424,7 @@ export function createQueries({
|
|||||||
const model = flightState._models.get(icao24);
|
const model = flightState._models.get(icao24);
|
||||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
||||||
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
|
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);
|
let object = flightState._detectionObjects.get(icao24);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
object = {
|
object = {
|
||||||
@@ -496,7 +491,7 @@ export function createQueries({
|
|||||||
* Best match with a cloned, dead-reckoned position, or null if none.
|
* Best match with a cloned, dead-reckoned position, or null if none.
|
||||||
*/
|
*/
|
||||||
findByQuery(query) {
|
findByQuery(query) {
|
||||||
if (!flightState._flightData || flightState._flightData.size === 0)
|
if (!flightState.records.data || flightState.records.data.size === 0)
|
||||||
return null;
|
return null;
|
||||||
const q = String(query || '')
|
const q = String(query || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -513,7 +508,7 @@ export function createQueries({
|
|||||||
// strictly tiered so a registration can never out-rank a real callsign on
|
// strictly tiered so a registration can never out-rank a real callsign on
|
||||||
// feed order alone.
|
// feed order alone.
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const [icao24, info] of flightState._flightData) {
|
for (const [icao24, info] of flightState.records.data) {
|
||||||
const candidate = {
|
const candidate = {
|
||||||
tier: rankContactMatch({
|
tier: rankContactMatch({
|
||||||
query: q,
|
query: q,
|
||||||
@@ -580,7 +575,7 @@ export function createQueries({
|
|||||||
const distance = Cesium.Cartesian3.distance(center, pos);
|
const distance = Cesium.Cartesian3.distance(center, pos);
|
||||||
if (distance > maxRange) continue;
|
if (distance > maxRange) continue;
|
||||||
|
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
const callsign = info?.callsign?.trim() || null;
|
const callsign = info?.callsign?.trim() || null;
|
||||||
nearby.push({
|
nearby.push({
|
||||||
// Label. Callers that need identity read `icao24` (Context cohorts do).
|
// Label. Callers that need identity read `icao24` (Context cohorts do).
|
||||||
@@ -664,7 +659,7 @@ export function createQueries({
|
|||||||
flightState._scratchCarto,
|
flightState._scratchCarto,
|
||||||
);
|
);
|
||||||
if (!carto) continue;
|
if (!carto) continue;
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
result.push({
|
result.push({
|
||||||
id: icao24, // identity (trackById resolves this)
|
id: icao24, // identity (trackById resolves this)
|
||||||
label: _contactLabel(icao24, info),
|
label: _contactLabel(icao24, info),
|
||||||
@@ -703,14 +698,14 @@ export function createQueries({
|
|||||||
if (
|
if (
|
||||||
!flightState._billboardCollection ||
|
!flightState._billboardCollection ||
|
||||||
!flightState._billboardCollection.show ||
|
!flightState._billboardCollection.show ||
|
||||||
flightState._flightData.size === 0
|
flightState.records.data.size === 0
|
||||||
)
|
)
|
||||||
return [];
|
return [];
|
||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
: 2000;
|
: 2000;
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const [icao24, info] of flightState._flightData) {
|
for (const [icao24, info] of flightState.records.data) {
|
||||||
const routeOk =
|
const routeOk =
|
||||||
!!info?.route && parts.tracking._routeIsPlausible(icao24, info.route);
|
!!info?.route && parts.tracking._routeIsPlausible(icao24, info.route);
|
||||||
result.push(
|
result.push(
|
||||||
@@ -754,7 +749,7 @@ export function createQueries({
|
|||||||
};
|
};
|
||||||
const id = _normalizeTrackedIcao(icao24);
|
const id = _normalizeTrackedIcao(icao24);
|
||||||
if (!id) return { status: 'missing', reason: 'invalid-target' };
|
if (!id) return { status: 'missing', reason: 'invalid-target' };
|
||||||
const outcome = flightState._lastTrackingRefreshOutcome;
|
const outcome = flightState.feed._lastTrackingRefreshOutcome;
|
||||||
if (outcome.status !== 'accepted') {
|
if (outcome.status !== 'accepted') {
|
||||||
return {
|
return {
|
||||||
status: 'source-unavailable',
|
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}}
|
* @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}}
|
||||||
*/
|
*/
|
||||||
getStats() {
|
getStats() {
|
||||||
const retryInSec = flightState._retryAt
|
const retryInSec = flightState.feed._retryAt
|
||||||
? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000))
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((flightState.feed._retryAt - Date.now()) / 1000),
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
return {
|
return {
|
||||||
count: flightState._count,
|
count: flightState.feed._count,
|
||||||
lastUpdate: flightState._lastUpdate,
|
lastUpdate: flightState.feed._lastUpdate,
|
||||||
stale: flightState._backoff,
|
stale: flightState.feed._backoff,
|
||||||
error: flightState._lastError,
|
error: flightState.feed._lastError,
|
||||||
status: flightState._lastStatus,
|
status: flightState.feed._lastStatus,
|
||||||
retryInSec,
|
retryInSec,
|
||||||
source: flightState._lastSource,
|
source: flightState.feed._lastSource,
|
||||||
coverage: flightState._lastCoverage,
|
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) {
|
function _fleetBillboardScale(icao24, klass) {
|
||||||
return (
|
return (
|
||||||
(CLASS_SCALE_2D[klass] || 1) *
|
(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = flightState._flightData.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
bb.image = aircraftIcon(
|
bb.image = aircraftIcon(
|
||||||
_iconKind(icao24, meta?.klass),
|
_iconKind(icao24, meta?.klass),
|
||||||
bb._gevIconLarge ? TRACKED_ICON_PX : undefined,
|
bb._gevIconLarge ? TRACKED_ICON_PX : undefined,
|
||||||
@@ -303,7 +303,7 @@ export function createRendering({
|
|||||||
* because a resample is mid-backoff. */
|
* because a resample is mid-backoff. */
|
||||||
|
|
||||||
function _modelDisplayPosition(icao24, pos, result) {
|
function _modelDisplayPosition(icao24, pos, result) {
|
||||||
const meta = flightState._flightData.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
if (!meta || !meta.onGround) return pos;
|
if (!meta || !meta.onGround) return pos;
|
||||||
const h = flightState._groundSnap.heightFor(
|
const h = flightState._groundSnap.heightFor(
|
||||||
flightState._viewer,
|
flightState._viewer,
|
||||||
@@ -438,7 +438,7 @@ export function createRendering({
|
|||||||
* the same rule (its billboard entity is always the fallback visual). */
|
* the same rule (its billboard entity is always the fallback visual). */
|
||||||
|
|
||||||
function _syncModelToClass(icao24) {
|
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);
|
const current = flightState._models.get(icao24);
|
||||||
if (
|
if (
|
||||||
(current && current._gevSpecKey !== key) ||
|
(current && current._gevSpecKey !== key) ||
|
||||||
@@ -510,10 +510,10 @@ export function createRendering({
|
|||||||
// aircraft mid-load, the post-await admission below rejects the stale asset.
|
// 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
|
// Boost state likewise: the creation options bake it in, so a mid-load
|
||||||
// toggle must reject too (the reload queue only covers ADMITTED models).
|
// 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;
|
const loadIrBoost = flightState._irBoost;
|
||||||
try {
|
try {
|
||||||
const spec = _modelSpec(flightState._flightData.get(icao24)?.klass);
|
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
||||||
model = await Cesium.Model.fromGltfAsync({
|
model = await Cesium.Model.fromGltfAsync({
|
||||||
url: resolveAsset(spec.url),
|
url: resolveAsset(spec.url),
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
@@ -559,12 +559,12 @@ export function createRendering({
|
|||||||
!_modelRegimeActive() ||
|
!_modelRegimeActive() ||
|
||||||
!flightState._modelCollection ||
|
!flightState._modelCollection ||
|
||||||
flightState._modelCollection.isDestroyed() ||
|
flightState._modelCollection.isDestroyed() ||
|
||||||
!flightState._flightData.has(icao24) ||
|
!flightState.records.data.has(icao24) ||
|
||||||
icao24 === flightState._trackedIcao ||
|
icao24 === flightState._trackedIcao ||
|
||||||
flightState._models.has(icao24) ||
|
flightState._models.has(icao24) ||
|
||||||
flightState._models.size >= _modelCap() ||
|
flightState._models.size >= _modelCap() ||
|
||||||
// Class reclassified mid-load → this GLB/scale is for the OLD class.
|
// 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.
|
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||||
flightState._irBoost !== loadIrBoost;
|
flightState._irBoost !== loadIrBoost;
|
||||||
if (stale) {
|
if (stale) {
|
||||||
@@ -714,10 +714,10 @@ export function createRendering({
|
|||||||
flightState._trackedModelLoading = true;
|
flightState._trackedModelLoading = true;
|
||||||
const gen = flightState._trackedModelGen;
|
const gen = flightState._trackedModelGen;
|
||||||
const trackedSpec = _modelSpec(
|
const trackedSpec = _modelSpec(
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
const trackedKey = _specKeyFor(
|
const trackedKey = _specKeyFor(
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
const trackedIrBoost = flightState._irBoost;
|
const trackedIrBoost = flightState._irBoost;
|
||||||
Cesium.Model.fromGltfAsync({
|
Cesium.Model.fromGltfAsync({
|
||||||
@@ -756,7 +756,7 @@ export function createRendering({
|
|||||||
// _trackedModel was still null): drop the stale asset; driver reloads.
|
// _trackedModel was still null): drop the stale asset; driver reloads.
|
||||||
if (
|
if (
|
||||||
_specKeyFor(
|
_specKeyFor(
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
) !== trackedKey ||
|
) !== trackedKey ||
|
||||||
flightState._irBoost !== trackedIrBoost
|
flightState._irBoost !== trackedIrBoost
|
||||||
) {
|
) {
|
||||||
@@ -829,7 +829,7 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
if (!flightState._trackedModel.ready) return;
|
if (!flightState._trackedModel.ready) return;
|
||||||
const spec = _modelSpec(
|
const spec = _modelSpec(
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
||||||
baseScale: spec.scale,
|
baseScale: spec.scale,
|
||||||
@@ -989,7 +989,7 @@ export function createRendering({
|
|||||||
for (const [icao24, bb] of flightState._billboards) {
|
for (const [icao24, bb] of flightState._billboards) {
|
||||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
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);
|
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
|
||||||
// The dead-reckoned point drifts away from the fix whose cell supplied the
|
// 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
|
// 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).
|
// waiting for its floor to warm (ATL grounded contacts at geoid −31 m).
|
||||||
const beyondHorizon = !occluder.isPointVisible(
|
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)
|
// A billboard flipping INTO view (horizon reveal while the camera idles)
|
||||||
// gets its rotation refreshed THIS tick even without a pose change —
|
// gets its rotation refreshed THIS tick even without a pose change —
|
||||||
@@ -1074,7 +1074,7 @@ export function createRendering({
|
|||||||
flightState._cockpitContactMode && !isCockpitNear
|
flightState._cockpitContactMode && !isCockpitNear
|
||||||
? 1
|
? 1
|
||||||
: _fleetBillboardScale(icao24, info?.klass),
|
: _fleetBillboardScale(icao24, info?.klass),
|
||||||
baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1,
|
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
|
||||||
baseColor,
|
baseColor,
|
||||||
focusFactor: focus.factor,
|
focusFactor: focus.factor,
|
||||||
cameraDistanceM,
|
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 * as Cesium from 'cesium';
|
||||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||||
|
|
||||||
export function createFlightState({ source, services }) {
|
export function createFlightState({ source, services }) {
|
||||||
const { createGroundSnap } = services.groundSnap;
|
const { createGroundSnap } = services.groundSnap;
|
||||||
const flightState = { lifetime: new AbortController() };
|
const flightState = { lifetime: new AbortController() };
|
||||||
|
flightState.feed = createFlightFeed(source);
|
||||||
flightState._source = 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
|
/** 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
|
* 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();
|
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. */
|
/** DEV-only explicit-position contacts used by qa-focus-evidence.mjs. */
|
||||||
|
|
||||||
flightState._focusEvidenceIds = new Set();
|
flightState._focusEvidenceIds = new Set();
|
||||||
@@ -118,48 +120,6 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._positionHistory = new Map();
|
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
|
// Click-to-track state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -172,16 +132,6 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._trackingIntentGeneration = 0;
|
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 */
|
/** @type {Cesium.Entity|null} Entity used for camera tracking */
|
||||||
|
|
||||||
flightState._trackedEntity = null;
|
flightState._trackedEntity = null;
|
||||||
@@ -250,10 +200,6 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._trailBackfillToken = 0;
|
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 */
|
/** @type {number} Epoch ms of the last fleet dead-reckoning pass */
|
||||||
|
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
|
|||||||
@@ -69,14 +69,15 @@ export function createTesting({
|
|||||||
flightState._modelCollection = modelCollection;
|
flightState._modelCollection = modelCollection;
|
||||||
flightState._billboardCollection = billboardCollection;
|
flightState._billboardCollection = billboardCollection;
|
||||||
flightState._billboards = new Map([[icao24, billboard]]);
|
flightState._billboards = new Map([[icao24, billboard]]);
|
||||||
|
flightState._cullPositions.clear();
|
||||||
flightState._models.clear();
|
flightState._models.clear();
|
||||||
for (const [key, model] of models) flightState._models.set(key, model);
|
for (const [key, model] of models) flightState._models.set(key, model);
|
||||||
flightState._detectionObjects = new Map();
|
flightState._detectionObjects = new Map();
|
||||||
flightState._flightData = new Map([[icao24, meta]]);
|
flightState.records.data = new Map([[icao24, meta]]);
|
||||||
flightState._positionHistory = new Map([[icao24, history]]);
|
flightState._positionHistory = new Map([[icao24, history]]);
|
||||||
flightState._missingPolls = new Map();
|
flightState.records.missingPolls = new Map();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._geoidNCache.clear();
|
flightState.records.geoidNCache.clear();
|
||||||
flightState._trackedIcao = tracked ? icao24 : null;
|
flightState._trackedIcao = tracked ? icao24 : null;
|
||||||
flightState._trackedEntity = tracked ? entity : null;
|
flightState._trackedEntity = tracked ? entity : null;
|
||||||
flightState._trackedModel = null;
|
flightState._trackedModel = null;
|
||||||
@@ -86,8 +87,8 @@ export function createTesting({
|
|||||||
// production state owned by the tracking lifecycle (_resetTrackedSelectionState),
|
// production state owned by the tracking lifecycle (_resetTrackedSelectionState),
|
||||||
// and clearing them here would mask exactly the deselect→re-track hole this
|
// and clearing them here would mask exactly the deselect→re-track hole this
|
||||||
// seam is used to test.
|
// seam is used to test.
|
||||||
flightState._backoff = false;
|
flightState.feed._backoff = false;
|
||||||
flightState._retryAt = 0;
|
flightState.feed._retryAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed the authoritative snapshot outcome used by share-Follow tests. */
|
/** Seed the authoritative snapshot outcome used by share-Follow tests. */
|
||||||
@@ -98,8 +99,8 @@ export function createTesting({
|
|||||||
source = 'OpenSky Network',
|
source = 'OpenSky Network',
|
||||||
coverage = 'test',
|
coverage = 'test',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const epoch = ++flightState._trackingRefreshEpoch;
|
const epoch = ++flightState.feed._trackingRefreshEpoch;
|
||||||
flightState._lastTrackingRefreshOutcome = {
|
flightState.feed._lastTrackingRefreshOutcome = {
|
||||||
epoch,
|
epoch,
|
||||||
status,
|
status,
|
||||||
ids: new Set(ids.map((id) => String(id).trim().toLowerCase())),
|
ids: new Set(ids.map((id) => String(id).trim().toLowerCase())),
|
||||||
@@ -117,7 +118,7 @@ export function createTesting({
|
|||||||
history = [],
|
history = [],
|
||||||
}) {
|
}) {
|
||||||
flightState._billboards.set(icao24, billboard);
|
flightState._billboards.set(icao24, billboard);
|
||||||
flightState._flightData.set(icao24, meta);
|
flightState.records.data.set(icao24, meta);
|
||||||
flightState._positionHistory.set(icao24, history);
|
flightState._positionHistory.set(icao24, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function createTracking({
|
|||||||
|
|
||||||
function _publishTrackedSelection(icao24, origin = 'programmatic') {
|
function _publishTrackedSelection(icao24, origin = 'programmatic') {
|
||||||
const bb = flightState._billboards.get(icao24);
|
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 (!bb?.position || !info) return false;
|
||||||
if (flightState._trackedEntity)
|
if (flightState._trackedEntity)
|
||||||
flightState._trackedEntity.gevSelectionOrigin = origin;
|
flightState._trackedEntity.gevSelectionOrigin = origin;
|
||||||
@@ -99,10 +99,10 @@ export function createTracking({
|
|||||||
id: icao24,
|
id: icao24,
|
||||||
layerId: 'flights',
|
layerId: 'flights',
|
||||||
layerName: 'Live Flights',
|
layerName: 'Live Flights',
|
||||||
source: flightState._lastSource,
|
source: flightState.feed._lastSource,
|
||||||
label: parts.queries._contactLabel(
|
label: parts.queries._contactLabel(
|
||||||
icao24,
|
icao24,
|
||||||
flightState._flightData.get(icao24),
|
flightState.records.data.get(icao24),
|
||||||
),
|
),
|
||||||
latitude: described.latitude,
|
latitude: described.latitude,
|
||||||
longitude: described.longitude,
|
longitude: described.longitude,
|
||||||
@@ -111,7 +111,7 @@ export function createTracking({
|
|||||||
properties: {
|
properties: {
|
||||||
name: parts.queries._contactLabel(
|
name: parts.queries._contactLabel(
|
||||||
icao24,
|
icao24,
|
||||||
flightState._flightData.get(icao24),
|
flightState.records.data.get(icao24),
|
||||||
),
|
),
|
||||||
operator: described.airline || '',
|
operator: described.airline || '',
|
||||||
callsign: described.callsign || '',
|
callsign: described.callsign || '',
|
||||||
@@ -477,8 +477,8 @@ export function createTracking({
|
|||||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||||
let path = null;
|
let path = null;
|
||||||
try {
|
try {
|
||||||
const track = await flightState._source.getTrack?.(
|
const track = await flightState.feed._source.getTrack?.(
|
||||||
flightState._flightData.get(icao24)?.sourceReference ?? icao24,
|
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||||
{
|
{
|
||||||
signal: AbortSignal.any([
|
signal: AbortSignal.any([
|
||||||
flightState.lifetime.signal,
|
flightState.lifetime.signal,
|
||||||
@@ -645,7 +645,7 @@ export function createTracking({
|
|||||||
bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao);
|
bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao);
|
||||||
bb.scale = parts.rendering._fleetBillboardScale(
|
bb.scale = parts.rendering._fleetBillboardScale(
|
||||||
flightState._trackedIcao,
|
flightState._trackedIcao,
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
bb.rotation = flightState._lastTrackedRotation;
|
bb.rotation = flightState._lastTrackedRotation;
|
||||||
}
|
}
|
||||||
@@ -742,7 +742,7 @@ export function createTracking({
|
|||||||
* velocity/altitude as live. */
|
* velocity/altitude as live. */
|
||||||
|
|
||||||
function _trackedLabelText(icao24) {
|
function _trackedLabelText(icao24) {
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
if (!info) return icao24;
|
if (!info) return icao24;
|
||||||
// A whitespace-only callsign (" ") is truthy, so `(info.callsign || icao24)`
|
// A whitespace-only callsign (" ") is truthy, so `(info.callsign || icao24)`
|
||||||
// kept it, then .trim() emptied it → the callsign slot dropped out of the
|
// 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 fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
|
||||||
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
||||||
const stale =
|
const stale =
|
||||||
flightState._missingPolls.get(icao24) || flightState._backoff
|
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||||
? 'STALE'
|
? 'STALE'
|
||||||
: '';
|
: '';
|
||||||
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
|
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
|
||||||
@@ -795,7 +795,7 @@ export function createTracking({
|
|||||||
flightState._trackedEntity.billboard.image = aircraftIcon(
|
flightState._trackedEntity.billboard.image = aircraftIcon(
|
||||||
parts.rendering._iconKind(
|
parts.rendering._iconKind(
|
||||||
flightState._trackedIcao,
|
flightState._trackedIcao,
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
),
|
),
|
||||||
TRACKED_ICON_PX,
|
TRACKED_ICON_PX,
|
||||||
);
|
);
|
||||||
@@ -850,7 +850,7 @@ export function createTracking({
|
|||||||
* cache). Missing data → true (never hide what we can't judge). */
|
* cache). Missing data → true (never hide what we can't judge). */
|
||||||
|
|
||||||
function _routeIsPlausible(icao24, route) {
|
function _routeIsPlausible(icao24, route) {
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
const bb = flightState._billboards.get(icao24);
|
const bb = flightState._billboards.get(icao24);
|
||||||
if (!info || !bb || !bb.position) return true;
|
if (!info || !bb || !bb.position) return true;
|
||||||
const carto = Cesium.Cartographic.fromCartesian(
|
const carto = Cesium.Cartographic.fromCartesian(
|
||||||
@@ -890,7 +890,7 @@ export function createTracking({
|
|||||||
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
|
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
|
||||||
|
|
||||||
const bb = flightState._billboards.get(icao24);
|
const bb = flightState._billboards.get(icao24);
|
||||||
const info = flightState._flightData.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
if (!bb || !info) return;
|
if (!bb || !info) return;
|
||||||
|
|
||||||
flightState._trackedIcao = icao24;
|
flightState._trackedIcao = icao24;
|
||||||
@@ -945,7 +945,7 @@ export function createTracking({
|
|||||||
image: aircraftIcon(
|
image: aircraftIcon(
|
||||||
parts.rendering._iconKind(
|
parts.rendering._iconKind(
|
||||||
flightState._trackedIcao,
|
flightState._trackedIcao,
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
),
|
),
|
||||||
TRACKED_ICON_PX,
|
TRACKED_ICON_PX,
|
||||||
),
|
),
|
||||||
@@ -953,7 +953,7 @@ export function createTracking({
|
|||||||
height: 28,
|
height: 28,
|
||||||
scale:
|
scale:
|
||||||
CLASS_SCALE_2D[
|
CLASS_SCALE_2D[
|
||||||
flightState._flightData.get(flightState._trackedIcao)?.klass
|
flightState.records.data.get(flightState._trackedIcao)?.klass
|
||||||
] || 1,
|
] || 1,
|
||||||
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
|
// 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).
|
// 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
|
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||||
// mode where camera.heading lives in the entity's reference frame.
|
// mode where camera.heading lives in the entity's reference frame.
|
||||||
rotation: new Cesium.CallbackProperty(() => {
|
rotation: new Cesium.CallbackProperty(() => {
|
||||||
const tracked = flightState._flightData.get(flightState._trackedIcao);
|
const tracked = flightState.records.data.get(
|
||||||
|
flightState._trackedIcao,
|
||||||
|
);
|
||||||
const pos = getTrackedPosition();
|
const pos = getTrackedPosition();
|
||||||
if (!tracked || !pos || !flightState._viewer)
|
if (!tracked || !pos || !flightState._viewer)
|
||||||
return flightState._lastTrackedRotation;
|
return flightState._lastTrackedRotation;
|
||||||
@@ -1086,13 +1088,14 @@ export function createTracking({
|
|||||||
flightState._billboardCollection.remove(bb);
|
flightState._billboardCollection.remove(bb);
|
||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
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._positionHistory.delete(icao24);
|
||||||
flightState._displayCourse.delete(icao24);
|
flightState._displayCourse.delete(icao24);
|
||||||
flightState._groundSnap.forget(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) {
|
} else if (flightState._billboardCollection.show) {
|
||||||
// Fire-and-forget refresh; only while the layer is actually enabled.
|
// Fire-and-forget refresh; only while the layer is actually enabled.
|
||||||
void flightsLayer.update(flightState._viewer);
|
void flightsLayer.update(flightState._viewer);
|
||||||
|
|||||||
Reference in new issue
Block a user