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