mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #416 from bilawalsidhu/refactor/civil-flight-components
Extract civil-flight components with explicit scene services
This commit is contained in:
34 files changed
+7569
-5303
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application.
|
||||
|
||||
- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history.
|
||||
- Retain absent aircraft during partially admitted snapshots and bound source error messages.
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Civil-flight components
|
||||
|
||||
`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance
|
||||
owns its contacts, histories, model collections, scratch objects and lifecycle.
|
||||
State, ingestion, enrichment, motion/floor interpolation, rendering, tracking and
|
||||
queries live in separate files under `src/layers/flights`. The standalone
|
||||
`src/data/flights.js` assembles the existing OpenSky source and scene services.
|
||||
|
||||
Applications supply the existing floor/snap, geoid, picking, sprite, camera,
|
||||
trail, focus, readout, context, aircraft presentation and render services.
|
||||
The factory never constructs a second application registry. Configure a source
|
||||
before initialization; replacing it while initialized is rejected. Model loads
|
||||
use the supplied asset resolver, including the preload path. Enrichment receives
|
||||
an abort signal and cannot update a later lifecycle after destruction. Existing
|
||||
camera, terrain floor, trail, selection and measured model-size policies remain.
|
||||
|
||||
## Browser live-source observations
|
||||
|
||||
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
|
||||
|
||||
+2
-1
@@ -142,6 +142,7 @@
|
||||
"./ui/shell": "./src/ui/applicationShell.js",
|
||||
"./ui/styles": "./src/ui/styles.js",
|
||||
"./scenes": "./src/scenes/director.js",
|
||||
"./sources/live": "./src/sources/live/index.js"
|
||||
"./sources/live": "./src/sources/live/index.js",
|
||||
"./layers/flights": "./src/layers/flights/index.js"
|
||||
}
|
||||
}
|
||||
@@ -254,5 +254,21 @@
|
||||
"src/sources/live/contract.test.mjs",
|
||||
"src/sources/live/index.js",
|
||||
"src/sources/live/standalone.js",
|
||||
"src/sources/live/vessels.js"
|
||||
"src/sources/live/vessels.js",
|
||||
"src/data/flights.js",
|
||||
"src/testSupport/readLayerSource.mjs",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
"src/layers/flights/controller.js",
|
||||
"src/layers/flights/state.js",
|
||||
"src/layers/flights/policy.js",
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/ownership.test.mjs"
|
||||
]
|
||||
@@ -576,5 +576,39 @@
|
||||
"src/sources/live/vessels.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
"src/data/cockpitAirLod.js",
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/flights/controller.js",
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
"src/layers/flights/policy.js",
|
||||
"src/layers/flights/queries.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/state.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/tracking.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,17 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Space Missions is toggled by the Context cancellation checks below.
|
||||
// Its catalog is unrelated to aircraft/Cockpit behavior; keep the network
|
||||
// error gate meaningful without depending on Launch Library availability.
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/launches') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
|
||||
// unrelated upstream terrain outage out of the rendered interaction gate.
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
|
||||
|
||||
@@ -25,7 +25,7 @@ const TESTS = [
|
||||
'src/data/renderAltitude.test.mjs',
|
||||
];
|
||||
|
||||
const FLIGHTS = 'src/data/flights.js';
|
||||
const FLIGHTS = 'src/layers/flights/motion.js';
|
||||
const FLOOR = 'src/data/groundFloor.js';
|
||||
const ALT = 'src/data/renderAltitude.js';
|
||||
|
||||
@@ -94,7 +94,7 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the held floor is stretched without bound (no drift limit)',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' },
|
||||
{ file: FLIGHTS, from: '<= HELD_FLOOR_MAX_DRIFT_KM', to: '<= Infinity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,8 +102,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: 'false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -122,8 +122,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: 'next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -159,8 +159,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
to: ' if (next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: 'next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -191,8 +191,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS));',
|
||||
to: ' const closed = 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS);',
|
||||
from: 'FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),',
|
||||
to: 'Infinity, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -204,7 +204,7 @@ const MUTATIONS = [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.retiredMs == null) {',
|
||||
to: ' _displayFloorState.delete(icao24);\n if (false) {',
|
||||
to: ' flightState._displayFloorState.delete(icao24);\n if (false) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -213,8 +213,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;',
|
||||
to: ' return false;',
|
||||
from: 'state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS',
|
||||
to: 'false',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -291,12 +291,13 @@ for (const mut of MUTATIONS) {
|
||||
for (const e of mut.edits) {
|
||||
if (!backups.has(e.file)) backups.set(e.file, read(e.file));
|
||||
const cur = read(e.file);
|
||||
if (!cur.includes(e.from)) {
|
||||
const anchor = new RegExp(e.from.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*'));
|
||||
if (!anchor.test(cur)) {
|
||||
console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`);
|
||||
applied = false;
|
||||
break;
|
||||
}
|
||||
write(e.file, cur.replace(e.from, e.to));
|
||||
write(e.file, cur.replace(anchor, () => e.to.trim()));
|
||||
}
|
||||
if (applied) {
|
||||
const r = runTests();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -15,8 +16,8 @@ import militaryFlightsLayer, {
|
||||
const SUBJECT = 'abc123';
|
||||
const NEXT_SUBJECT = 'def456';
|
||||
|
||||
const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
|
||||
const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
|
||||
const FLIGHTS_SOURCE = readLayerSource(new URL('./flights.js', import.meta.url));
|
||||
const MILITARY_SOURCE = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
|
||||
|
||||
const LAYERS = [
|
||||
{
|
||||
@@ -108,11 +109,11 @@ test('Cockpit lifecycle publishes one normalized aircraft identity to both detec
|
||||
['commercial', FLIGHTS_SOURCE],
|
||||
['military', MILITARY_SOURCE],
|
||||
]) {
|
||||
const consumer = /function _applyCockpitState\(detail = \{\}\) \{[\s\S]*?\n\}/
|
||||
const consumer = /function\s*(?:parts\.\w+\.)?_applyCockpitState\(\s*detail\s*=\s*\{\},?\s*\)\s*\{[\s\S]*?\n {0,2}\}/
|
||||
.exec(source)?.[0];
|
||||
assert.ok(consumer, `${name} Cockpit consumer is defined`);
|
||||
assert.match(consumer, /detail\?\.subjectId/);
|
||||
assert.match(consumer, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.match(consumer, /\.trim\(\s*,?\s*\)\s*\.toLowerCase\(\s*,?\s*\)/);
|
||||
assert.doesNotMatch(consumer, /layerId/,
|
||||
`${name} must also suppress a duplicate subject originating in the sibling AIR feed`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -25,7 +26,7 @@ function numericConstant(source, name) {
|
||||
}
|
||||
|
||||
for (const layer of LAYERS) {
|
||||
const source = readFileSync(layer.path, 'utf8');
|
||||
const source = readLayerSource(layer.path);
|
||||
|
||||
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
|
||||
const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)];
|
||||
@@ -37,11 +38,11 @@ for (const layer of LAYERS) {
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
|
||||
const regime = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_modelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_models3dEnabled\) return false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
'OFF must keep Cockpit AIR contacts in 2D');
|
||||
assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/,
|
||||
assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/,
|
||||
'Cockpit must not bypass the owner-visible Display toggle');
|
||||
});
|
||||
|
||||
@@ -51,13 +52,13 @@ for (const layer of LAYERS) {
|
||||
// owns. The tracked regime is DEFAULT-ON by camera distance (2026-08-19), so
|
||||
// it no longer routes through the toggle-gated `_modelRegimeActive` — the
|
||||
// suppression is now an explicit early return.
|
||||
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_trackedModelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_trackedIcao \|\| _cockpitContactMode \|\|[\s\S]*?return false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
|
||||
'_trackedModelRegimeActive excludes cockpit');
|
||||
const tracked = /function _updateTrackedModel\(\)[\s\S]*?\n if \(!active\)/.exec(source)?.[0];
|
||||
const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(tracked, '_updateTrackedModel is defined');
|
||||
assert.match(tracked, /_trackedModelRegimeActive\(\)/,
|
||||
assert.match(tracked, /(?:parts\.\w+\.)?_trackedModelRegimeActive\(\s*,?\s*\)/,
|
||||
'the tracked-model driver uses the cockpit-aware predicate');
|
||||
});
|
||||
|
||||
@@ -68,15 +69,15 @@ for (const layer of LAYERS) {
|
||||
assert.equal(numericConstant(source, 'MODEL_ALL_KEEP_M'), 450_000);
|
||||
assert.equal(numericConstant(source, 'COCKPIT_MODEL_MAX'), 60);
|
||||
|
||||
const add = /function _modelAddDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const keep = /function _modelKeepDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(add, /_models3dMode === 'all' \? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /_models3dMode === 'all' \? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M/);
|
||||
const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/);
|
||||
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
|
||||
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
|
||||
|
||||
const cap = /function _modelCap\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(COCKPIT_MODEL_MAX/,
|
||||
const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/,
|
||||
'Cockpit keeps its 60-model performance ceiling');
|
||||
});
|
||||
|
||||
@@ -88,19 +89,19 @@ for (const layer of LAYERS) {
|
||||
// `_iconKind` is identity for every unconverted contact (see
|
||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||
// operator explicitly converted into a TR-3B.
|
||||
assert.match(source, /bb\.image = aircraftIcon\(_iconKind\(icao24, meta\?\.klass\)(, bb\._gevIconLarge \? TRACKED_ICON_PX : undefined)?\)/,
|
||||
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /bb\.rotation = 0;/,
|
||||
assert.match(source, /bb\.rotation\s*=\s*0;/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(!_cockpitContactMode \|\| isCockpitNear\) && \(doRotations \|\| revealed\)/,
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if \(bb\.show\) bb\.show = false; \/\/ hand off ONLY once the model renders/,
|
||||
assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
'the gap-proof billboard-to-model handoff remains intact');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
||||
const setMode = /function _setCockpitContactMode\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(setMode, /else _cockpitNearContacts = new Set\(\);/);
|
||||
assert.match(setMode, /for \(const \[icao24, bb\] of _billboards\) _applyFleetBillboardPresentation\(icao24, bb\);/);
|
||||
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
|
||||
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// src/data/detectionRenderDemand.test.mjs
|
||||
//
|
||||
// Detection must not hold the render loop open — and must not drop work on the
|
||||
@@ -427,8 +428,8 @@ test('the render-governor gate covers the parked case, with teeth on the painter
|
||||
// promptness goes with them, silently. This test is where that shows up.
|
||||
test('aircraft brackets stay prompt because the aircraft layers hold the render loop', async () => {
|
||||
for (const file of ['./flights.js', './militaryFlights.js']) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
const enable = /\n enable\([\s\S]*?\n \},/.exec(source)?.[0];
|
||||
const source = readLayerSource(new URL(file, import.meta.url));
|
||||
const enable = /\n([ \t]*)enable\([\s\S]*?\n\1\},/.exec(source)?.[0];
|
||||
assert.ok(enable, `${file}: enable() is still identifiable`);
|
||||
assert.match(
|
||||
enable,
|
||||
|
||||
+72
-5209
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -365,21 +366,21 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
// first-run session actually boots from.
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
assert.match(source, /^let _models3dEnabled = true;$/m,
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.match(source, /^\s*(?:let |flightState\.)_models3dEnabled = true;$/m,
|
||||
`${name}: the fleet starts armed, matching the codec default`);
|
||||
assert.match(source, /^let _models3dMode = 'proximity';/m,
|
||||
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
|
||||
`${name}: and starts in proximity, matching the codec default`);
|
||||
}
|
||||
const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /^\s*this\._models3dEnabled = true;$/m,
|
||||
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
|
||||
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
|
||||
assert.match(ui, /this\._models3dMode = 'proximity';/,
|
||||
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
||||
'ui.js: and believes the mode is proximity');
|
||||
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
|
||||
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
|
||||
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\._models3dEnabled\),?\s*\)/,
|
||||
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
|
||||
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only');
|
||||
assert.match(html, /class="pp-slider-row visible" id="models3d-mode-row"/,
|
||||
'index.html: and the Proximity/All row paints open with it');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -297,7 +298,7 @@ test('Contacts live repaint restores only stable identity and never retargets an
|
||||
});
|
||||
|
||||
test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url));
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
|
||||
@@ -1352,12 +1353,12 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
// disable() hides the collection but keeps the records, so a map lookup
|
||||
// alone would report a preserved subject as FRESH from hidden stale data.
|
||||
for (const [name, source, guard] of [
|
||||
['flights', fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'),
|
||||
/hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/],
|
||||
['militaryFlights', fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'),
|
||||
/hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/],
|
||||
['aisLiveVessels', fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'),
|
||||
/hasContact\(mmsi\) \{\s*\n\s*if \(!state\.enabled \|\| !state\.vesselMap \|\| state\.vesselMap\.size === 0\) return null;/],
|
||||
['flights', readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||
]) {
|
||||
assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`);
|
||||
}
|
||||
@@ -1527,14 +1528,14 @@ test('a deliberate source clear still fully clears the subject', () => {
|
||||
test('production eviction sites actually tag their clears', () => {
|
||||
// The event contract above is worthless if the real cull paths never set the
|
||||
// origin, so pin the three production call sites.
|
||||
const flightsSource = fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
|
||||
const militarySource = fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
|
||||
const vesselsSource = fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8');
|
||||
const flightsSource = readLayerSource(new URL('./flights.js', import.meta.url));
|
||||
const militarySource = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
|
||||
const vesselsSource = readLayerSource(new URL('./aisLiveVessels.js', import.meta.url));
|
||||
|
||||
for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) {
|
||||
assert.match(
|
||||
source,
|
||||
/if \(icao24 === _trackedIcao\) \{\s*\n\s*_clearTracking\(false, \{ evicted: true \}\);/,
|
||||
/if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/,
|
||||
`${name} must mark its aged-out cull as an eviction`,
|
||||
);
|
||||
assert.match(
|
||||
@@ -1553,7 +1554,7 @@ test('production eviction sites actually tag their clears', () => {
|
||||
// The tag alone is not enough — see the behavioral test in
|
||||
// firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod,
|
||||
// whose registration sweep deletes the record the clear needs to see.
|
||||
const firmsSource = fs.readFileSync(new URL('./firmsHeatmap.js', import.meta.url), 'utf8');
|
||||
const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url));
|
||||
const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });');
|
||||
const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);');
|
||||
assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction');
|
||||
@@ -1588,9 +1589,9 @@ test('cockpit blocks only non-aircraft Context camera flights', () => {
|
||||
});
|
||||
|
||||
test('vessel entry and selection framing both use the 3 km focus radius', () => {
|
||||
assert.match(militaryAwarenessSource, /const VESSEL_FOCUS_RADIUS_M = 3000;/);
|
||||
assert.match(militaryAwarenessSource, /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/);
|
||||
const focusRadiusUses = militaryAwarenessSource.match(
|
||||
/new Cesium\.BoundingSphere\(vessel\.position, VESSEL_FOCUS_RADIUS_M\)/g,
|
||||
/new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g,
|
||||
) || [];
|
||||
assert.equal(focusRadiusUses.length, 2);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// Regression test for the giant-military-jet bug: MODEL_SCALE must put every
|
||||
// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is.
|
||||
//
|
||||
@@ -358,7 +359,7 @@ function nativeVisualCenter(file) {
|
||||
// --- per-layer constants, read from source (see header) --------------------
|
||||
|
||||
function layerConstants(sourceFile) {
|
||||
const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8');
|
||||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||||
const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/);
|
||||
assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`);
|
||||
const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/);
|
||||
@@ -367,8 +368,8 @@ function layerConstants(sourceFile) {
|
||||
}
|
||||
|
||||
function normalBillboardScaleByDistance(sourceFile) {
|
||||
const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8');
|
||||
const fn = src.match(/function _normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||||
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||||
assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`);
|
||||
const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/);
|
||||
assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// src/data/tr3bRegistry.test.mjs
|
||||
// TR-3B conversion Easter egg: registry state, sprite-variant selection,
|
||||
// class-label override, and the render-path invariants that keep a converted
|
||||
@@ -227,7 +228,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
|
||||
|
||||
test('both flight layers keep a converted contact 2D and visible (render invariants)', async () => {
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
|
||||
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
|
||||
// no TR-3B GLB, so the triangle billboard stays the visual.
|
||||
@@ -237,13 +238,13 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
// (2026-08-19), so the suppression moved from a conjunct on
|
||||
// `_modelRegimeActive()` to an explicit early return. The invariant is
|
||||
// unchanged: a converted contact never reaches the model handoff.
|
||||
assert.match(source, /if \(!_trackedIcao \|\| _cockpitContactMode \|\| isTr3b\(_trackedIcao\)\) \{/,
|
||||
assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/,
|
||||
`${name}: the standalone tracked model is suppressed for a converted contact`);
|
||||
|
||||
// 2. The billboard is never hidden by that suppression — it must keep
|
||||
// satisfying the getNearby/getDetectableObjects visibility guards, so a
|
||||
// converted contact still works in Contacts and Cockpit.
|
||||
assert.match(source, /if \(bb && id !== _trackedIcao\) bb\.show = true;|if \(modelled && id !== _trackedIcao\) modelled\.show = true;/,
|
||||
assert.match(source, /if\s*\(\s*bb\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*bb\.show\s*=\s*true;|if\s*\(\s*modelled\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*modelled\.show\s*=\s*true;/,
|
||||
`${name}: converting restores the billboard the model handoff had hidden`);
|
||||
|
||||
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
|
||||
@@ -253,7 +254,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || [];
|
||||
assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`);
|
||||
for (const call of callSites) {
|
||||
assert.match(call, /aircraftIcon\(\s*_iconKind\(/,
|
||||
assert.match(call, /aircraftIcon\(\s*\s*(?:parts\.rendering\.)?_iconKind\(\s*/,
|
||||
`${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`);
|
||||
}
|
||||
|
||||
@@ -271,7 +272,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
|
||||
// should still show the triangle. Only a page reload resets it — so no
|
||||
// production code may clear the registry.
|
||||
for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.doesNotMatch(source, /clearTr3bRegistry/,
|
||||
`${name}: teardown must not clear session conversions`);
|
||||
}
|
||||
@@ -406,10 +407,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
|
||||
// in the fleet tick (no seam to drive headlessly), so this asserts the guard's
|
||||
// POSITION rather than replaying the four-pass selection.
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
|
||||
// ambient-enrichment candidate loop that also builds a `cand`.
|
||||
const loop = /const cand = \[\];\s*\n\s*for \(const \[icao, bb\] of _billboards\)[\s\S]*?cand\.push\(/.exec(source)?.[0];
|
||||
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0];
|
||||
assert.ok(loop, `${name}: the model-eligibility candidate loop is present`);
|
||||
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
||||
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// src/data/trackedModelRegime.test.mjs
|
||||
//
|
||||
// Zoom-driven 2D↔3D for the TRACKED contact (owner directive 2026-08-19).
|
||||
@@ -931,15 +932,15 @@ for (const fixture of LAYERS) {
|
||||
test('both layers gate the tracked-model load and record its failures', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
assert.match(source, /!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed\(\)/,
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/,
|
||||
`${name}: the driver asks permission before starting another tracked-model load`);
|
||||
assert.match(source, /_noteTrackedModelLoadFailure\(trackedSpec\.url, err\)/,
|
||||
assert.match(source, /(?:parts\.\w+\.)?_noteTrackedModelLoadFailure\(\s*trackedSpec\.url,\s*err,?\s*\)/,
|
||||
`${name}: a rejected load is recorded against this selection, not silently retried`);
|
||||
// The reset must be reachable from the real teardown, not only a test seam.
|
||||
assert.match(source, /_releaseTrackedModel\(\);\n _resetTrackedSelectionState\(\);/,
|
||||
assert.match(source, /(?:parts\.\w+\.)?_releaseTrackedModel\(\s*,?\s*\);\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/,
|
||||
`${name}: deselect clears the per-selection latches in the production path`);
|
||||
assert.match(source, /_trackedIcao = icao24;\n _resetTrackedSelectionState\(\);/,
|
||||
assert.match(source, /(?:flightState\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/,
|
||||
`${name}: selecting a contact starts from a clean per-selection state`);
|
||||
}
|
||||
});
|
||||
@@ -947,16 +948,16 @@ test('both layers gate the tracked-model load and record its failures', async ()
|
||||
test('the tracked regime never consults the fleet models3d toggle', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, `${name}: _trackedModelRegimeActive is defined`);
|
||||
assert.doesNotMatch(regime, /_models3dEnabled|_modelRegimeActive\(\)/,
|
||||
assert.doesNotMatch(regime, /(?:flightState\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/,
|
||||
`${name}: the tracked contact's handoff is default-on, not gated on the fleet toggle`);
|
||||
assert.match(regime, /trackedModelZoomActive\(/,
|
||||
`${name}: the tracked contact uses the shared hysteretic zoom policy`);
|
||||
// The FLEET must still obey the toggle — the budget decision stays the operator's.
|
||||
const fleet = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(fleet, /if \(!_models3dEnabled\) return false;/,
|
||||
const fleet = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(fleet, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
`${name}: the fleet regime still obeys the DISPLAY-rail 3D toggle`);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -244,13 +245,13 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
||||
'militaryFlights.js',
|
||||
'satellites.js',
|
||||
'militaryInstallations.js',
|
||||
].map(async (name) => [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]));
|
||||
].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))]));
|
||||
const sources = Object.fromEntries(files);
|
||||
for (const [name, source] of files) {
|
||||
assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`);
|
||||
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
|
||||
}
|
||||
assert.ok(sources['flights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
|
||||
assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
|
||||
@@ -260,11 +261,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
||||
|
||||
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
|
||||
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
||||
[name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]
|
||||
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
|
||||
)));
|
||||
for (const [name, source] of files) {
|
||||
assert.ok(
|
||||
source.includes('const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao);'),
|
||||
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
|
||||
`${name} trail head uses the dedicated lower-centre model anchor`,
|
||||
);
|
||||
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -110,16 +111,16 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno
|
||||
|
||||
test('civilian and military click handlers apply duration only at the deselect branch', () => {
|
||||
const sources = [
|
||||
readFileSync(new URL('./flights.js', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'),
|
||||
readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
];
|
||||
for (const source of sources) {
|
||||
assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/);
|
||||
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
||||
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
sources[0],
|
||||
/_trackedEntity = _viewer\.entities\.add\(\{\s*id:/,
|
||||
/(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/,
|
||||
'civilian tracked entities must retain Cesium-generated GUIDs',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createController({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
function _abortActiveUpdates() {
|
||||
for (const controller of flightState._activeUpdateControllers)
|
||||
controller.abort();
|
||||
flightState._activeUpdateControllers.clear();
|
||||
}
|
||||
|
||||
function _flightQuery(viewer) {
|
||||
const cartographic = viewer?.camera?.positionCartographic;
|
||||
return cartographic
|
||||
? {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
}
|
||||
: {};
|
||||
}
|
||||
return { _abortActiveUpdates, _flightQuery };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ENRICH_MAX_INFLIGHT,
|
||||
ENRICH_DISPATCH_GAP_MS,
|
||||
ENRICH_AMBIENT_BUDGET_CEIL,
|
||||
ENRICH_AMBIENT_REFILL_TOKENS,
|
||||
ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
||||
ENRICH_AMBIENT_PER_SWEEP,
|
||||
} from './policy.js';
|
||||
|
||||
export function createEnrichment({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
function _enqueueEnrich(key, query, onData, priority = false) {
|
||||
if (flightState.lifetime.signal.aborted) return;
|
||||
if (flightState._enrichSeen.has(key)) return;
|
||||
flightState._enrichSeen.add(key);
|
||||
const job = { query, onData };
|
||||
// Priority (tracked / model-eligible) goes to the FRONT so a deep ambient
|
||||
// backlog can never delay the plane the user just clicked or zoomed into.
|
||||
if (priority) flightState._enrichQueue.unshift(job);
|
||||
else flightState._enrichQueue.push(job);
|
||||
_drainEnrich();
|
||||
}
|
||||
|
||||
function _drainEnrich() {
|
||||
if (flightState.lifetime.signal.aborted) return;
|
||||
while (
|
||||
flightState._enrichActive < ENRICH_MAX_INFLIGHT &&
|
||||
flightState._enrichQueue.length
|
||||
) {
|
||||
// Drip: at most one dispatch per ENRICH_DISPATCH_GAP_MS. When the gap
|
||||
// hasn't elapsed yet, park a single wake-up timer and stop — completions
|
||||
// and enqueues in the meantime re-enter here harmlessly.
|
||||
const wait =
|
||||
ENRICH_DISPATCH_GAP_MS -
|
||||
(Date.now() - flightState._enrichLastDispatchMs);
|
||||
if (wait > 0) {
|
||||
if (!flightState._enrichDripTimer) {
|
||||
flightState._enrichDripTimer = setTimeout(() => {
|
||||
flightState._enrichDripTimer = null;
|
||||
_drainEnrich();
|
||||
}, wait);
|
||||
}
|
||||
return;
|
||||
}
|
||||
flightState._enrichLastDispatchMs = Date.now();
|
||||
const job = flightState._enrichQueue.shift();
|
||||
flightState._enrichActive += 1;
|
||||
const lifetime = flightState.lifetime;
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
lifetime.signal.throwIfAborted();
|
||||
return flightState._source.getEnrichment?.(job.query, {
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
})
|
||||
.then((data) => {
|
||||
if (!lifetime.signal.aborted && data && data.found) job.onData(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* enrichment never surfaces errors */
|
||||
})
|
||||
.finally(() => {
|
||||
if (lifetime.signal.aborted) return;
|
||||
flightState._enrichActive -= 1;
|
||||
_drainEnrich();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _requestTypeEnrichment(icao24, priority = false) {
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) return;
|
||||
_enqueueEnrich(
|
||||
`t:${icao24}`,
|
||||
{ kind: 'type', id: icao24.toLowerCase() },
|
||||
(data) => {
|
||||
const meta = flightState._flightData.get(icao24);
|
||||
if (!meta) return; // evicted while the lookup was in flight
|
||||
meta.typeCode = data.typeCode || meta.typeCode;
|
||||
meta.typeName = data.typeName || meta.typeName;
|
||||
meta.registration = data.registration || meta.registration;
|
||||
if (meta.typeCode) {
|
||||
const klass = classifyAircraft({
|
||||
typeCode: meta.typeCode,
|
||||
category: meta.category,
|
||||
});
|
||||
if (klass !== meta.klass) {
|
||||
meta.klass = klass;
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (bb)
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
// Hangar fleet: the class's GLB/scale may have changed — resync the
|
||||
// live model, any in-flight load, and the tracked standalone model.
|
||||
parts.rendering._syncModelToClass(icao24);
|
||||
}
|
||||
}
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity)
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
},
|
||||
priority,
|
||||
);
|
||||
}
|
||||
|
||||
function _requestRouteEnrichment(icao24) {
|
||||
const cs = String(flightState._flightData.get(icao24)?.callsign || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve
|
||||
_enqueueEnrich(
|
||||
`r:${cs}`,
|
||||
{ kind: 'route', id: cs },
|
||||
(data) => {
|
||||
const meta = flightState._flightData.get(icao24);
|
||||
if (!meta) return;
|
||||
meta.airline = data.airline || meta.airline;
|
||||
if (data.origin && data.destination)
|
||||
meta.route = { origin: data.origin, destination: data.destination };
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity)
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
},
|
||||
true,
|
||||
); // route lookups only fire for the TRACKED plane — front of the queue
|
||||
}
|
||||
|
||||
/** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the
|
||||
* bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens,
|
||||
* windowMs} — they cannot wait out a real 5-minute window. Read lazily each
|
||||
* refill so a pre-boot override (or a mid-run windowMs swap) applies.
|
||||
* Production never sets this; the constants above are the defaults. */
|
||||
|
||||
function _ambientBudgetKnobs() {
|
||||
const o =
|
||||
(typeof window !== 'undefined' && window.__GEV_ENRICH_AMBIENT_QA) || null;
|
||||
return {
|
||||
ceil:
|
||||
Number.isFinite(o?.ceil) && o.ceil > 0
|
||||
? o.ceil
|
||||
: ENRICH_AMBIENT_BUDGET_CEIL,
|
||||
refillTokens:
|
||||
Number.isFinite(o?.refillTokens) && o.refillTokens > 0
|
||||
? o.refillTokens
|
||||
: ENRICH_AMBIENT_REFILL_TOKENS,
|
||||
windowMs:
|
||||
Number.isFinite(o?.windowMs) && o.windowMs > 0
|
||||
? o.windowMs
|
||||
: ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/** Advance the token bucket: add refillTokens per FULLY elapsed window since
|
||||
* the anchor, clamp at the ceiling, and move the anchor forward by the whole
|
||||
* windows consumed (while the bucket sits full this still advances, so idle
|
||||
* time never banks more than one bucket's worth of burst). */
|
||||
|
||||
function _refillAmbientBudget(nowMs) {
|
||||
const { ceil, refillTokens, windowMs } = _ambientBudgetKnobs();
|
||||
if (!flightState._enrichAmbientRefillAnchorMs) {
|
||||
flightState._enrichAmbientRefillAnchorMs = nowMs;
|
||||
return;
|
||||
}
|
||||
const windows = Math.floor(
|
||||
(nowMs - flightState._enrichAmbientRefillAnchorMs) / windowMs,
|
||||
);
|
||||
if (windows <= 0) return;
|
||||
flightState._enrichAmbientBudget = Math.min(
|
||||
ceil,
|
||||
flightState._enrichAmbientBudget + windows * refillTokens,
|
||||
);
|
||||
flightState._enrichAmbientRefillAnchorMs += windows * windowMs;
|
||||
}
|
||||
|
||||
function _sweepAmbientEnrichment() {
|
||||
_refillAmbientBudget(Date.now());
|
||||
if (
|
||||
flightState._enrichAmbientBudget <= 0 ||
|
||||
!flightState._viewer ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const camera = flightState._viewer.camera;
|
||||
const camPos = camera.positionWC;
|
||||
const occluder = horizonOccluder(camera);
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const cand = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
|
||||
if (flightState._flightData.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||
Cesium.Cartesian3.clone(
|
||||
bb.position,
|
||||
flightState._scratchModelBS.center,
|
||||
);
|
||||
if (
|
||||
cull.computeVisibility(flightState._scratchModelBS) ===
|
||||
Cesium.Intersect.OUTSIDE
|
||||
)
|
||||
continue; // off-screen
|
||||
cand.push([
|
||||
icao24,
|
||||
Cesium.Cartesian3.distanceSquared(camPos, bb.position),
|
||||
]);
|
||||
}
|
||||
cand.sort((a, b) => a[1] - b[1]); // nearest first — what the user is looking at resolves first
|
||||
const n = Math.min(
|
||||
cand.length,
|
||||
ENRICH_AMBIENT_PER_SWEEP,
|
||||
flightState._enrichAmbientBudget,
|
||||
);
|
||||
for (let i = 0; i < n; i++) {
|
||||
flightState._enrichAmbientBudget -= 1;
|
||||
_requestTypeEnrichment(cand[i][0]); // non-priority: fills the back of the queue
|
||||
}
|
||||
} catch {
|
||||
/* ambient enrichment is best-effort — never disturb the poll loop */
|
||||
}
|
||||
}
|
||||
return {
|
||||
_enqueueEnrich,
|
||||
_drainEnrich,
|
||||
_requestTypeEnrichment,
|
||||
_requestRouteEnrichment,
|
||||
_ambientBudgetKnobs,
|
||||
_refillAmbientBudget,
|
||||
_sweepAmbientEnrichment,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
||||
|
||||
export function createEvidence({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
/** Resolve a JSON-safe evidence position into ECEF. DEV-only caller. */
|
||||
|
||||
function _focusEvidencePosition(record) {
|
||||
const cartesian = record?.cartesian;
|
||||
if (
|
||||
Array.isArray(cartesian) &&
|
||||
cartesian.length >= 3 &&
|
||||
cartesian.slice(0, 3).every(Number.isFinite)
|
||||
) {
|
||||
return Cesium.Cartesian3.fromElements(
|
||||
cartesian[0],
|
||||
cartesian[1],
|
||||
cartesian[2],
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(record?.longitude) ||
|
||||
!Number.isFinite(record?.latitude)
|
||||
)
|
||||
return null;
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000,
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace the real fleet with deterministic explicit-position contacts. */
|
||||
|
||||
function _setFocusEvidenceAircraft(records = []) {
|
||||
if (
|
||||
!FOCUS_EVIDENCE_DEV ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._viewer
|
||||
)
|
||||
return { ok: false, count: 0 };
|
||||
if (flightState._trackedIcao) parts.tracking._clearTracking();
|
||||
parts.rendering._releaseModels();
|
||||
for (const bb of flightState._billboards.values())
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.clear();
|
||||
flightState._flightData.clear();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._missingPolls.clear();
|
||||
flightState._focusEvidenceIds.clear();
|
||||
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
const id = String(record?.id || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const position = _focusEvidencePosition(record);
|
||||
if (!id || !position) continue;
|
||||
const klass = record.klass || 'airliner';
|
||||
const altitudeM = Number.isFinite(record.altitudeM)
|
||||
? record.altitudeM
|
||||
: Cesium.Cartographic.fromCartesian(position)?.height || 3_000;
|
||||
const meta = {
|
||||
callsign: String(record.callsign || id).toUpperCase(),
|
||||
altitude: altitudeM,
|
||||
renderAltitudeM: altitudeM,
|
||||
velocity: Number.isFinite(record.velocityMps) ? record.velocityMps : 0,
|
||||
true_track: Number.isFinite(record.trackDeg) ? record.trackDeg : 90,
|
||||
klass,
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
lastContactEpochMs: Date.now(),
|
||||
rawLat: record.latitude ?? null,
|
||||
rawLon: record.longitude ?? null,
|
||||
cullPosition: null,
|
||||
};
|
||||
flightState._flightData.set(id, meta);
|
||||
flightState._focusEvidenceIds.add(id);
|
||||
const bb = flightState._billboardCollection.add({
|
||||
position,
|
||||
image: aircraftIcon(parts.rendering._iconKind(id, klass)),
|
||||
width: 20,
|
||||
height: 20,
|
||||
scale: parts.rendering._fleetBillboardScale(id, klass),
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: parts.rendering._fleetBillboardColor(id),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: parts.rendering._normalBillboardScaleByDistance(),
|
||||
disableDepthTestDistance: parts.rendering._groundDepthDistance(),
|
||||
id,
|
||||
show: true,
|
||||
});
|
||||
flightState._billboards.set(id, bb);
|
||||
}
|
||||
flightState._count = flightState._billboards.size;
|
||||
flightState._lastFleetTickMs = 0;
|
||||
flightState._viewer.scene.requestRender?.();
|
||||
return { ok: true, count: flightState._count };
|
||||
}
|
||||
|
||||
/** Update explicit evidence positions without rebuilding billboards. */
|
||||
|
||||
function _moveFocusEvidenceAircraft(records = []) {
|
||||
if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 };
|
||||
let moved = 0;
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
const id = String(record?.id || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!flightState._focusEvidenceIds.has(id)) continue;
|
||||
const position = _focusEvidencePosition(record);
|
||||
const bb = flightState._billboards.get(id);
|
||||
if (!position || !bb) continue;
|
||||
bb.position = position;
|
||||
const meta = flightState._flightData.get(id);
|
||||
if (meta) {
|
||||
if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg;
|
||||
if (Number.isFinite(record.velocityMps))
|
||||
meta.velocity = record.velocityMps;
|
||||
}
|
||||
moved += 1;
|
||||
}
|
||||
flightState._lastFleetTickMs = 0;
|
||||
flightState._viewer?.scene?.requestRender?.();
|
||||
return { ok: true, moved };
|
||||
}
|
||||
|
||||
/** JSON-safe visual snapshot for the evidence report. */
|
||||
|
||||
function _focusEvidenceSnapshot() {
|
||||
if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return [];
|
||||
return [...flightState._focusEvidenceIds].map((id) => {
|
||||
const bb = flightState._billboards.get(id);
|
||||
const screen = bb?.position
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
flightState._viewer.scene,
|
||||
bb.position,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
show: bb?.show === true,
|
||||
scale: bb?.scale ?? null,
|
||||
alpha: bb?.color?.alpha ?? null,
|
||||
x: screen?.x ?? null,
|
||||
y: screen?.y ?? null,
|
||||
cameraDistanceM: bb?.position
|
||||
? Cesium.Cartesian3.distance(
|
||||
flightState._viewer.camera.positionWC,
|
||||
bb.position,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
_focusEvidencePosition,
|
||||
_setFocusEvidenceAircraft,
|
||||
_moveFocusEvidenceAircraft,
|
||||
_focusEvidenceSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createMotion } from './motion.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { createController } from './controller.js';
|
||||
import { createEnrichment } from './enrichment.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
/** Compose one civil-flight layer with application-owned scene services. */
|
||||
export function createCivilFlightLayer({
|
||||
source,
|
||||
services,
|
||||
resolveAsset = (url) => url,
|
||||
} = {}) {
|
||||
const flightState = createFlightState({ source, services });
|
||||
const parts = {};
|
||||
const layer = {};
|
||||
const context = { flightState, services, parts, layer, resolveAsset };
|
||||
parts.rendering = createRendering(context);
|
||||
parts.motion = createMotion(context);
|
||||
parts.tracking = createTracking(context);
|
||||
parts.controller = createController(context);
|
||||
parts.enrichment = createEnrichment(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.evidence = createEvidence(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion.methods,
|
||||
);
|
||||
Object.defineProperty(layer, 'testing', { value: parts.testing });
|
||||
return layer;
|
||||
}
|
||||
export { TRACKED_MODEL_MAX_PX } from './policy.js';
|
||||
@@ -0,0 +1,661 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { stickyNumber, stickyText } from '../../data/aircraftMeta.js';
|
||||
import {
|
||||
geoidSurfaceLastResortM,
|
||||
pickRenderAltitudeM,
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
POSITION_HISTORY_LIMIT,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './policy.js';
|
||||
|
||||
export function createIngestion({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } =
|
||||
services.militaryRegistry;
|
||||
const { geoidHeight } = services.geoid;
|
||||
const { cachedGroundFloor, floorAltitudeM, warmGroundFloor } =
|
||||
services.groundFloor;
|
||||
const { sampleMeshFloorCells } = services.meshFloor;
|
||||
const flightsLayer = layer;
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* Fetch the latest aircraft state vectors from the OpenSky proxy and
|
||||
* reconcile them with the billboard collection.
|
||||
*
|
||||
* Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors
|
||||
* with exponential-ish backoff. On success, adds/updates/removes
|
||||
* billboards and position history, triggers lerp blending for the
|
||||
* tracked aircraft, and updates its label text.
|
||||
*
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
};
|
||||
if (flightState._retryAt && nowMs < flightState._retryAt) {
|
||||
flightState._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
flightState._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await flightState._source.getSnapshot(
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
{ signal: updateSignal },
|
||||
);
|
||||
updateSignal.throwIfAborted();
|
||||
flightState._lastStatus = snapshot.status ?? 200;
|
||||
const usableStates = snapshot.records;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
flightState._backoff = sourceStale;
|
||||
flightState._retryAt = 0;
|
||||
flightState._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? 'Source snapshot time unavailable'
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
flightState._lastSource = snapshot.source;
|
||||
flightState._lastCoverage = snapshot.coverage;
|
||||
flightsLayer.source = flightState._lastSource;
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
const now = Cesium.JulianDate.now();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
// subpoint + collected floor cells for the viewer-proximate low-contact
|
||||
// clamp below — one carto read per poll, one batch warm after the loop.
|
||||
const viewerCarto =
|
||||
(viewer || flightState._viewer)?.camera?.positionCartographic || null;
|
||||
const viewerLatDeg = viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.latitude)
|
||||
: null;
|
||||
const viewerLonDeg = viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.longitude)
|
||||
: null;
|
||||
const floorWarmPoints = [];
|
||||
|
||||
// Classification and display policy consume source-independent observations.
|
||||
refreshMilitaryRegistryIfStale();
|
||||
for (const observation of usableStates) {
|
||||
const {
|
||||
id: icao24,
|
||||
callsign,
|
||||
originCountry: origin_country,
|
||||
longitude: lon,
|
||||
latitude: lat,
|
||||
baroAltitudeM: baro_alt,
|
||||
onGround: on_ground,
|
||||
speedMps: velocity,
|
||||
courseDeg: true_track,
|
||||
ellipsoidAltitudeM: geo_alt,
|
||||
category,
|
||||
verticalRateMps: vertical_rate,
|
||||
} = observation;
|
||||
acceptedSnapshotIcaos.add(icao24);
|
||||
const onGround = on_ground === true;
|
||||
|
||||
// Known-military aircraft: the dedicated military layer wins
|
||||
// (icon + track + click) while it is enabled — suppress the
|
||||
// OpenSky duplicate entirely (except a currently tracked one,
|
||||
// which hands off on untrack).
|
||||
const isMil = isMilitaryIcao(icao24);
|
||||
if (isMil && parts.tracking._militaryLayerSuppresses(icao24)) {
|
||||
const dupe = flightState._billboards.get(icao24);
|
||||
if (dupe) {
|
||||
flightState._billboardCollection.remove(dupe);
|
||||
flightState._billboards.delete(icao24);
|
||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
flightState._flightData.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._missingPolls.delete(icao24);
|
||||
flightState._geoidNCache.delete(icao24);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIcaos.add(icao24);
|
||||
flightState._missingPolls.delete(icao24);
|
||||
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
||||
// aircraft it still positions — hold last-known-good instead of
|
||||
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
|
||||
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
|
||||
const prevMeta = flightState._flightData.get(icao24);
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
// barometric/MSL altitude read by labels (FL/altitude readout),
|
||||
// route-plausibility, and follow-camera range heuristics. It is
|
||||
// NEVER overwritten or renamed. Where the aircraft actually RENDERS
|
||||
// on the ellipsoidal globe is a SEPARATE value (renderAltitudeM,
|
||||
// below) — geo_altitude when OpenSky reports it (already WGS84
|
||||
// ellipsoidal), else baro+geoid as a visual fallback, else ground
|
||||
// surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM,
|
||||
// never `alt` directly.
|
||||
const alt = stickyNumber(
|
||||
baro_alt,
|
||||
prevMeta?.altitude,
|
||||
onGround ? 0 : 10000,
|
||||
);
|
||||
|
||||
// geoid undulation N: cached per-aircraft (negligible drift — see
|
||||
// task brief) once the geoid grid has loaded; unavailable pre-load
|
||||
// just means the baro fallback branch below adds N=0 for a beat.
|
||||
let geoidN = flightState._geoidNCache.get(icao24);
|
||||
if (geoidN === undefined && flightState._geoidReady) {
|
||||
geoidN = geoidHeight(lat, lon);
|
||||
flightState._geoidNCache.set(icao24, geoidN);
|
||||
}
|
||||
|
||||
// on_ground surface prior: ONLY synchronous warm-cache reads here —
|
||||
// never a per-aircraft network fetch inside the poll loop (see the
|
||||
// batch resolve call below, which fills this cache for NEXT poll). A
|
||||
// Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied):
|
||||
// the grounded surface is the round-4 choke point and nothing else —
|
||||
// rendered-mesh cell first, real (never fallback-poisoned) DEM cell
|
||||
// second. The old exact-5-decimal warm chain is GONE: it minted a new
|
||||
// key per parked-jitter poll for every grounded contact ON EARTH,
|
||||
// hammering Re:Earth into the very failures that poisoned the cache.
|
||||
let surfaceM = null;
|
||||
if (onGround) {
|
||||
surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell)
|
||||
// Taxiing crosses into a fresh cold cell every poll — always one
|
||||
// step ahead of the warm batch — so fall back to LAST poll's cell
|
||||
// (warmed by last poll's batch; aprons are flat across adjacent
|
||||
// 111 m cells). Round-5 verify caught taxiing contacts stuck at
|
||||
// the geoid without this (round 2's lesson, at cell granularity).
|
||||
if (
|
||||
surfaceM == null &&
|
||||
Number.isFinite(prevMeta?.rawLat) &&
|
||||
Number.isFinite(prevMeta?.rawLon)
|
||||
) {
|
||||
surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon);
|
||||
}
|
||||
// Grounded contacts near the viewer feed the floor warm/sampler
|
||||
// (the only ones whose exact height is visible; far contacts are
|
||||
// subpixel and always-on-top anyway).
|
||||
if (
|
||||
viewerLatDeg != null &&
|
||||
parts.queries._approxDistanceKm(
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
lat,
|
||||
lon,
|
||||
) <= GROUND_FLOOR_CLAMP_RADIUS_KM
|
||||
) {
|
||||
floorWarmPoints.push({ lat, lon });
|
||||
}
|
||||
// Last synchronous resort for a BRAND-NEW grounded contact with NO
|
||||
// altitude data at all (nothing warm yet, not even the coarse
|
||||
// cell): the geoid surface. At the sea-level airports where most
|
||||
// grounded traffic sits, geoidN IS the local ellipsoidal ground to
|
||||
// within metres — instantly right — and at elevated fields it is
|
||||
// far less wrong than the raw 0 m ellipsoid default for the one
|
||||
// poll until the coarse cell warms. STRICTLY gated on "no geo, no
|
||||
// baro": a reported baro already reflects the field elevation, and
|
||||
// pickRenderAltitudeM's surfaceM branch would let this crude guess
|
||||
// outrank it (caught by the ground-3d track regression).
|
||||
//
|
||||
// 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds
|
||||
// one more gate — a contact that already HAS a render height keeps
|
||||
// it. Leaving surfaceM null routes it through the sentinel path
|
||||
// below, which holds that height.
|
||||
if (surfaceM == null) {
|
||||
surfaceM = geoidSurfaceLastResortM({
|
||||
geoAltM: geo_alt,
|
||||
baroAltM: baro_alt,
|
||||
priorRenderM: prevMeta?.renderAltitudeM,
|
||||
geoidN,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null;
|
||||
const pickedAltM = pickRenderAltitudeM({
|
||||
geoAltM: geoAltitudeM,
|
||||
baroAltM: Number.isFinite(baro_alt) ? baro_alt : null,
|
||||
onGround,
|
||||
surfaceM,
|
||||
geoidN,
|
||||
});
|
||||
// pickRenderAltitudeM returns the sentinel `null` only when NEITHER
|
||||
// geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks,
|
||||
// in priority order:
|
||||
// (1) hold the previous geoid-corrected render height if we have one —
|
||||
// a one-poll baro dropout must NOT snap the plane down by the geoid
|
||||
// undulation N (~46 m in London) and back up next poll. `alt` stays
|
||||
// sticky for labels, so holding the last render height keeps the two
|
||||
// layers consistent through the gap.
|
||||
// (2) otherwise the SAME default policy `alt` already uses, so the two
|
||||
// never disagree on the genuine "no data yet" case (a never-reported
|
||||
// aircraft has no prior render height, so it lands here unchanged).
|
||||
let renderAltitudeM;
|
||||
if (pickedAltM != null) {
|
||||
renderAltitudeM = pickedAltM;
|
||||
} else if (Number.isFinite(prevMeta?.renderAltitudeM)) {
|
||||
renderAltitudeM = prevMeta.renderAltitudeM;
|
||||
} else {
|
||||
renderAltitudeM = alt;
|
||||
}
|
||||
// Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3):
|
||||
// floor a low airborne contact's render height at the local coarse
|
||||
// ground so it can never dive below the mesh. Round 3 (Austin
|
||||
// fleet-underground): baro can read BELOW an elevated field — SWA696
|
||||
// showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi
|
||||
// traffic that OpenSky hasn't flagged on_ground yet renders from that
|
||||
// baro, so the whole fleet sat buried at AUS in 2D. Clamping every
|
||||
// global contact would need unbounded terrain resolution; instead the
|
||||
// clamp covers the TRACKED contact (always) plus every low contact
|
||||
// within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones
|
||||
// whose burial is visible. Cells warm in one batch after the loop.
|
||||
// Airborne only (grounded planes keep the surface-cache path above).
|
||||
if (
|
||||
!onGround &&
|
||||
renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M &&
|
||||
(icao24 === flightState._trackedIcao ||
|
||||
(viewerLatDeg != null &&
|
||||
parts.queries._approxDistanceKm(
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
lat,
|
||||
lon,
|
||||
) <= GROUND_FLOOR_CLAMP_RADIUS_KM))
|
||||
) {
|
||||
renderAltitudeM = floorAltitudeM(
|
||||
renderAltitudeM,
|
||||
cachedGroundFloor(lat, lon),
|
||||
);
|
||||
floorWarmPoints.push({ lat, lon });
|
||||
}
|
||||
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
renderAltitudeM,
|
||||
);
|
||||
// Landing/takeoff transition: the on_ground flip restyles IN PLACE.
|
||||
const groundFlipped =
|
||||
!!prevMeta && (prevMeta.onGround === true) !== onGround;
|
||||
// Either flip direction retires the model's ground snap: a departing plane
|
||||
// flies free of it, a landing plane earns a fresh sample where it rolls out.
|
||||
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
||||
|
||||
// Store flight metadata for click-to-track labels
|
||||
const cat = stickyNumber(category, prevMeta?.category, null);
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
altitude: alt,
|
||||
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
||||
// untouched aviation `altitude` — never rename/replace it (labels,
|
||||
// FL readout, route-plausibility, and follow-camera range math all
|
||||
// still read `altitude`/baro).
|
||||
geoAltitudeM,
|
||||
renderAltitudeM,
|
||||
onGround,
|
||||
// Round 7: sticky airborne history — the landed fast-cull only
|
||||
// applies to contacts that actually flew this session.
|
||||
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
||||
// Round 6: lifted occlusion-test point for contacts rendering
|
||||
// at/below the ellipsoid (fleet pass reads it — see the occluder
|
||||
// note there). Null for the overwhelmingly common airborne case.
|
||||
cullPosition:
|
||||
renderAltitudeM < 10
|
||||
? Cesium.Cartesian3.fromDegrees(lon, lat, 12)
|
||||
: null,
|
||||
velocity: stickyNumber(velocity, prevMeta?.velocity, 0),
|
||||
true_track: stickyNumber(true_track, prevMeta?.true_track, 0),
|
||||
category: cat,
|
||||
// An adsbdb-enriched type code outranks the coarse OpenSky category.
|
||||
klass: classifyAircraft({
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
category: cat,
|
||||
}),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
verticalRate: stickyNumber(
|
||||
vertical_rate,
|
||||
prevMeta?.verticalRate,
|
||||
null,
|
||||
),
|
||||
// Analyst seam: OpenSky origin_country (state[2]) — additive, sticky
|
||||
// like callsign so a transient blank row doesn't blank the field.
|
||||
originCountry:
|
||||
stickyText(origin_country, prevMeta?.originCountry) || null,
|
||||
// OpenSky distinguishes the last position epoch from the last
|
||||
// transponder message. The fleet coast horizon uses this actual
|
||||
// contact time so a temporarily old position does not hard-freeze
|
||||
// while fresh velocity/track messages are still arriving.
|
||||
lastContactEpochMs: stickyNumber(
|
||||
observation.contactTimeMs,
|
||||
prevMeta?.lastContactEpochMs,
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: prevMeta?.airline ?? null,
|
||||
route: prevMeta?.route ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
// actual reported fix.
|
||||
rawLat: lat,
|
||||
rawLon: lon,
|
||||
};
|
||||
flightState._flightData.set(icao24, meta);
|
||||
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
|
||||
// Append to position history stamped with the FEED's fix epoch
|
||||
// (time_position), not client receipt time — OpenSky positions arrive
|
||||
// 5-15s stale and receipt-time stamping is what caused the
|
||||
// back/forward oscillation. Only append when the fix actually
|
||||
// advances, so stale repeats don't create zero-dt segments.
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs));
|
||||
if (!flightState._positionHistory.has(icao24)) {
|
||||
flightState._positionHistory.set(icao24, []);
|
||||
}
|
||||
const history = flightState._positionHistory.get(icao24);
|
||||
const newest = history[history.length - 1];
|
||||
if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) {
|
||||
// Per-fix kinematics: the fix's own velocity/track ride along so the
|
||||
// extrapolation paths use the values that BELONG to the fix they
|
||||
// extend, not whatever the latest poll reported.
|
||||
history.push({
|
||||
time: fixTime,
|
||||
epochMs: fixEpochMs,
|
||||
position: position.clone(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
});
|
||||
if (history.length > POSITION_HISTORY_LIMIT) {
|
||||
history.shift();
|
||||
}
|
||||
// Turn rate from the fix-track history — computed once per new fix
|
||||
// (≤5 samples), consumed by the extrapolation paths at tick rate.
|
||||
meta.turnRateDps = turnRateFromFixHistory(history);
|
||||
// Trail accumulation is separate from the 5-fix DR history (PRD F1)
|
||||
// so the visible trail keeps growing while tracked. Ground traffic
|
||||
// appends nothing — a touchdown freezes the existing trail.
|
||||
// Round 2 (owner): ground traffic appends too — taxi history stays
|
||||
// live after touchdown (grounded flights positions are already
|
||||
// surface-clamped via the surfaceM chain, so the ground leg drapes).
|
||||
if (isTracked) parts.tracking._appendTrailFix(position.clone());
|
||||
} else {
|
||||
const modelOwnsGroundVisual =
|
||||
parts.rendering._modelOwnsVisual(icao24);
|
||||
if (!modelOwnsGroundVisual) {
|
||||
liftRepeatedGroundFix(newest, position, meta.onGround);
|
||||
}
|
||||
// Apply fresh kinematics only from a forward synthetic fix. Mutating
|
||||
// the historical fix reprojects the entire stale interval and snaps
|
||||
// the rendered aircraft when course or speed changes late.
|
||||
const kinematicsChanged =
|
||||
newest.velocity !== meta.velocity ||
|
||||
newest.track !== meta.true_track;
|
||||
if (kinematicsChanged) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
});
|
||||
if (synthetic) {
|
||||
history.push(synthetic);
|
||||
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
|
||||
meta.turnRateDps = turnRateFromFixHistory(history);
|
||||
if (isTracked)
|
||||
parts.tracking._appendTrailFix(synthetic.position.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flightState._billboards.has(icao24)) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
// Reclassify if the category resolved/changed (first extended poll);
|
||||
// a ground flip (landing/takeoff) re-scales the SAME billboard in
|
||||
// place — the transition is a restyle, never a removal.
|
||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
||||
// course changes land on the next rotation pass (forced below).
|
||||
if (!isTracked) {
|
||||
// Refresh affiliation hue without clobbering the tick-owned
|
||||
// freshness × focus × horizon alpha composition.
|
||||
bb.color = parts.rendering
|
||||
._fleetBillboardColor(icao24)
|
||||
.withAlpha(bb.color?.alpha ?? 1);
|
||||
}
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
groundFlipped ||
|
||||
flightState._cockpitContactMode
|
||||
) {
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
// Poll-path class change (category updates): same model resync rule
|
||||
// as the enrichment path — the class's GLB/scale may have changed.
|
||||
if (prevMeta?.klass !== meta.klass)
|
||||
parts.rendering._syncModelToClass(icao24);
|
||||
} else {
|
||||
const bb = flightState._billboardCollection.add({
|
||||
position,
|
||||
image: aircraftIcon(
|
||||
parts.rendering._iconKind(icao24, meta.klass),
|
||||
),
|
||||
width: isTracked ? 24 : 20,
|
||||
height: isTracked ? 24 : 20,
|
||||
scale: parts.rendering._fleetBillboardScale(icao24, meta.klass),
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: isTracked
|
||||
? Cesium.Color.CYAN
|
||||
: parts.rendering._fleetBillboardColor(icao24),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance:
|
||||
parts.rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
// surface — render them depth-test-free so they never vanish up
|
||||
// close (_groundDepthDistance).
|
||||
disableDepthTestDistance: parts.rendering._groundDepthDistance(),
|
||||
id: icao24,
|
||||
show: !isTracked, // hidden if currently tracked (entity replaces it)
|
||||
});
|
||||
flightState._billboards.set(icao24, bb);
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
|
||||
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
||||
// from the fresh airborne history (touchdown needs no action — the
|
||||
// append gate above simply freezes the existing trail).
|
||||
if (isTracked && groundFlipped && !meta.onGround)
|
||||
parts.tracking._startTrail(icao24);
|
||||
|
||||
// If this is the tracked aircraft, update label text
|
||||
// (position updates automatically via dead-reckoning CallbackProperty)
|
||||
if (isTracked && flightState._trackedEntity) {
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove aircraft only after MISSING_POLL_LIMIT consecutive absences.
|
||||
// OpenSky routinely drops aircraft for a single poll; immediate removal
|
||||
// made planes blink and yanked the camera off actively tracked flights.
|
||||
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
||||
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
// Partial admissions do not prove absence, but stale retention is bounded.
|
||||
if (
|
||||
!snapshot.complete &&
|
||||
Date.now() -
|
||||
(flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) <
|
||||
300000
|
||||
)
|
||||
continue;
|
||||
const misses = (flightState._missingPolls.get(icao24) || 0) + 1;
|
||||
const limit = parts.queries._likelyLanded(icao24)
|
||||
? LANDED_MISSING_POLL_LIMIT
|
||||
: MISSING_POLL_LIMIT;
|
||||
if (misses < limit) {
|
||||
flightState._missingPolls.set(icao24, misses);
|
||||
if (
|
||||
icao24 === flightState._trackedIcao &&
|
||||
flightState._trackedEntity
|
||||
) {
|
||||
// Honest readout: the tracked plane has no faded billboard (its
|
||||
// entity owns the visual), so refresh the label — with icao24 now
|
||||
// in _missingPolls, _trackedLabelText appends the STALE cue so
|
||||
// last-known velocity/altitude aren't presented as live.
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flightState._missingPolls.delete(icao24);
|
||||
|
||||
// If the tracked flight is truly gone, clear tracking BEFORE deleting
|
||||
// its state (M3 ordering, keep it): teardown reads the maps this loop
|
||||
// is about to delete (billboard restore, DR cache reset), and we must
|
||||
// never leave the camera mid-follow with stale tracking state. The
|
||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||
if (icao24 === flightState._trackedIcao) {
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||
flightState._flightData.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._geoidNCache.delete(icao24);
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
}
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
// Ambient type enrichment: give ON-SCREEN planes real types (bounded
|
||||
// sweep — see _sweepAmbientEnrichment; internally fail-silent).
|
||||
parts.enrichment._sweepAmbientEnrichment();
|
||||
|
||||
// 2026-08-19: the loop above only ever collects FIX cells, but a grounded
|
||||
// contact renders across every cell its dead-reckoned position drifts
|
||||
// through. Add those too, so the display clamp has data where the contact
|
||||
// actually is instead of silently passing.
|
||||
parts.motion._collectDisplayCorridorCells(
|
||||
floorWarmPoints,
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
);
|
||||
|
||||
// Field-test round 3: one batch warm of the viewer-proximate low-contact
|
||||
// floor cells collected in the loop (fire-and-forget, single-flight;
|
||||
// read synchronously by NEXT poll's clamp — the military-layer pattern).
|
||||
warmGroundFloor(floorWarmPoints);
|
||||
// Round 4: sample the RENDERED mesh for those same cells (one-shot per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// billboards/models are excluded so a vertical probe can't land on an
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
excludeObjects: [
|
||||
...flightState._billboards.values(),
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
].filter(Boolean),
|
||||
viewerLat: viewerLatDeg,
|
||||
viewerLon: viewerLonDeg,
|
||||
});
|
||||
|
||||
// Round 5: the grounded exact-key warm that used to live here is gone —
|
||||
// see the note where _warmGroundedAircraftSurfaceCache was removed. The
|
||||
// viewer-proximate coarse warm + mesh sampler above cover everything
|
||||
// whose height is actually visible.
|
||||
// Round 6: re-floor STALE grounded contacts. A parked plane whose
|
||||
// transponder went quiet stops receiving poll updates, so a floor that
|
||||
// warms AFTER its last fix never applied — it sat frozen at the geoid
|
||||
// (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever).
|
||||
// Grounded contacts are static, so lifting the stored fix + billboard
|
||||
// in place is safe (the DR extrapolates a zero-velocity fix).
|
||||
parts.motion._refloorStaleGroundedContacts(currentIcaos);
|
||||
|
||||
flightState._count = flightState._billboards.size;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
flightState._lastUpdate = sourceEpochMs;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: acceptedSnapshotIcaos,
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
};
|
||||
console.log(`[Data:Flights] Updated: ${flightState._count} aircraft`);
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
flightState._backoff = true;
|
||||
flightState._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
flightState._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
flightState._lastSource = e.source;
|
||||
this.source = flightState._lastSource;
|
||||
}
|
||||
flightState._lastError =
|
||||
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
|
||||
} finally {
|
||||
flightState._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { PLANE_MODEL_URL } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { clearFocusTarget } = services.focus;
|
||||
const {
|
||||
registerSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
restoreSpriteOrderOnEnable,
|
||||
} = services.sprites;
|
||||
const { onMilitaryLayerActiveChange } = services.militaryRegistry;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { ensureGeoidReady } = services.geoid;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
const methods = {
|
||||
/** Configure the source before initialization; an active layer keeps its owner. */
|
||||
setSource(source) {
|
||||
if (flightState._viewer)
|
||||
throw new Error('Configure the source before layer initialization');
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
flightState._source = source;
|
||||
flightState._lastSource = source.label || flightState._lastSource;
|
||||
this.source = flightState._lastSource;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the flights layer.
|
||||
* Creates the BillboardCollection, resets all state, and installs the
|
||||
* click-to-track handler on the scene canvas.
|
||||
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
|
||||
*/
|
||||
init(viewer) {
|
||||
if (flightState._viewer)
|
||||
throw new Error('Flight layer is already initialized');
|
||||
if (typeof flightState._source?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
if (flightState.lifetime.signal.aborted)
|
||||
flightState.lifetime = new AbortController();
|
||||
clearFocusTarget('flights');
|
||||
flightState._focusEvidenceIds.clear();
|
||||
flightState._viewer = viewer;
|
||||
flightState._billboardCollection = new Cesium.BillboardCollection();
|
||||
viewer.scene.primitives.add(flightState._billboardCollection);
|
||||
registerSpriteCollection('flights', flightState._billboardCollection);
|
||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(flightState._modelCollection);
|
||||
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
||||
// (keeps the retained instance referenced; never rendered). Captured against this epoch so a
|
||||
// destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
|
||||
if (!flightState._preloadModel) {
|
||||
const epoch = flightState._modelEpoch;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(PLANE_MODEL_URL),
|
||||
asynchronous: false,
|
||||
})
|
||||
.then((m) => {
|
||||
if (epoch === flightState._modelEpoch) {
|
||||
flightState._preloadModel = m;
|
||||
flightState._planeModelLoaded = true;
|
||||
} else {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* tracked plane just stays a billboard a beat longer */
|
||||
});
|
||||
}
|
||||
flightState._billboards = new Map();
|
||||
flightState._detectionObjects = new Map();
|
||||
flightState._flightData = new Map();
|
||||
flightState._positionHistory = new Map();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._count = 0;
|
||||
flightState._lastUpdate = null;
|
||||
flightState._backoff = false;
|
||||
flightState._retryAt = 0;
|
||||
flightState._lastError = null;
|
||||
flightState._lastStatus = null;
|
||||
flightState._lastSource = flightState._source.label || 'Aircraft';
|
||||
flightState._lastCoverage = 'worldwide upstream snapshot';
|
||||
flightState._trackedIcao = null;
|
||||
parts.tracking._resetTrackedSelectionState();
|
||||
flightState._trackedEntity = null;
|
||||
flightState._cockpitSubjectId = null;
|
||||
flightState._cockpitContactMode =
|
||||
document.body.classList.contains('cockpit-mode');
|
||||
flightState._cockpitNearContacts = new Set();
|
||||
if (!flightState._cockpitModeListener) {
|
||||
flightState._cockpitModeListener = (event) =>
|
||||
parts.tracking._applyCockpitState(event?.detail);
|
||||
window.addEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
flightState._cockpitModeListener,
|
||||
);
|
||||
}
|
||||
// Fresh session — full bucket, anchor re-seeded on the first sweep.
|
||||
flightState._enrichAmbientBudget =
|
||||
parts.enrichment._ambientBudgetKnobs().ceil;
|
||||
flightState._enrichAmbientRefillAnchorMs = 0;
|
||||
|
||||
parts.tracking._installClickHandler(viewer);
|
||||
|
||||
// React to Military-layer toggles IMMEDIATELY (suppress/restore sweep)
|
||||
// instead of waiting out the 30 s poll (M2).
|
||||
if (!flightState._milActiveChangeUnsub) {
|
||||
flightState._milActiveChangeUnsub = onMilitaryLayerActiveChange(
|
||||
parts.tracking._onMilitaryActiveChange,
|
||||
);
|
||||
}
|
||||
|
||||
restoreSpriteOrder(viewer);
|
||||
|
||||
console.log('[Data:Flights] Initialized with billboard icons');
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the billboard collection and re-install the click handler.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
enable(viewer) {
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = true;
|
||||
holdContinuousRender('flights'); // per-frame animator (perf wave 2)
|
||||
if (flightState._modelCollection)
|
||||
flightState._modelCollection.show = true;
|
||||
parts.tracking._setCockpitContactMode(
|
||||
document.body.classList.contains('cockpit-mode'),
|
||||
);
|
||||
// Height-datum fix: warm the geoid grid once per layer-enable. The poll loop
|
||||
// only reads geoidHeight() synchronously after this resolves (guarded by
|
||||
// _geoidReady) — never awaited per-aircraft, never blocking a poll tick.
|
||||
if (!flightState._geoidReady) {
|
||||
const lifetime = flightState.lifetime;
|
||||
ensureGeoidReady()
|
||||
.then(() => {
|
||||
if (!lifetime.signal.aborted) flightState._geoidReady = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
|
||||
});
|
||||
}
|
||||
parts.tracking._installClickHandler(viewer);
|
||||
registerPickOwner('flights', (pickedId) =>
|
||||
flightState._billboards.has(pickedId),
|
||||
);
|
||||
// Force a fresh rotation pass on the first tick after re-enable
|
||||
flightState._lastCamPoseSig = '';
|
||||
if (!flightState._preRenderRemove && viewer?.scene) {
|
||||
flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
|
||||
parts.rendering._fleetTick,
|
||||
);
|
||||
}
|
||||
if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) {
|
||||
flightState._trackedModelPreUpdateRemove =
|
||||
viewer.scene.preUpdate.addEventListener(
|
||||
parts.rendering._updateTrackedModel,
|
||||
);
|
||||
}
|
||||
if (!flightState._moveEndRemove && viewer?.camera) {
|
||||
// Arrival polish (field test 2026-07-03: "planes look weird when you first
|
||||
// come to them"): when a camera move SETTLES (voice fly-to, fast pan
|
||||
// release), force a full rotation pass on the very next frame. The
|
||||
// pose-signature gate alone can eat the settle — the final easing frames
|
||||
// of a flight land inside one quantization bucket (10 m / 0.06°), leaving
|
||||
// every icon wearing its last mid-flight rotation for up to
|
||||
// ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs
|
||||
// on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one
|
||||
// extra rotation pass per completed camera gesture — nothing per-frame.
|
||||
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
() => {
|
||||
flightState._lastCamPoseSig = '';
|
||||
flightState._lastFleetTickMs = 0;
|
||||
},
|
||||
);
|
||||
}
|
||||
restoreSpriteOrderOnEnable('flights', viewer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide all flight billboards and tear down click/keyboard handlers.
|
||||
* Also clears any active flight tracking so the camera is released.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = false;
|
||||
releaseContinuousRender('flights');
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._modelCollection)
|
||||
flightState._modelCollection.show = false;
|
||||
parts.tracking._clearTracking();
|
||||
parts.tracking._destroyTrail();
|
||||
// Remove click handler + keydown listener while disabled to avoid
|
||||
// intercepting input when the layer is off
|
||||
if (flightState._clickHandler) {
|
||||
flightState._clickHandler.destroy();
|
||||
flightState._clickHandler = null;
|
||||
}
|
||||
if (flightState._trackedEntityChangedRemove) {
|
||||
flightState._trackedEntityChangedRemove();
|
||||
flightState._trackedEntityChangedRemove = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.tracking._onKeyDown);
|
||||
unregisterPickOwner('flights');
|
||||
if (flightState._preRenderRemove) {
|
||||
flightState._preRenderRemove();
|
||||
flightState._preRenderRemove = null;
|
||||
}
|
||||
if (flightState._trackedModelPreUpdateRemove) {
|
||||
flightState._trackedModelPreUpdateRemove();
|
||||
flightState._trackedModelPreUpdateRemove = null;
|
||||
}
|
||||
if (flightState._moveEndRemove) {
|
||||
flightState._moveEndRemove();
|
||||
flightState._moveEndRemove = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fully tear down the flights layer — remove primitives, handlers,
|
||||
* tracked entities, and clear all internal state maps.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.lifetime.abort();
|
||||
flightState._enrichActive = 0;
|
||||
flightState._enrichLastDispatchMs = 0;
|
||||
parts.controller._abortActiveUpdates();
|
||||
releaseContinuousRender('flights'); // direct-destroy path (perf wave 2 fix)
|
||||
parts.tracking._clearTracking();
|
||||
parts.tracking._destroyTrail();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._clickHandler) {
|
||||
flightState._clickHandler.destroy();
|
||||
flightState._clickHandler = null;
|
||||
}
|
||||
if (flightState._trackedEntityChangedRemove) {
|
||||
flightState._trackedEntityChangedRemove();
|
||||
flightState._trackedEntityChangedRemove = null;
|
||||
}
|
||||
if (flightState._milActiveChangeUnsub) {
|
||||
flightState._milActiveChangeUnsub();
|
||||
flightState._milActiveChangeUnsub = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.tracking._onKeyDown);
|
||||
if (flightState._cockpitModeListener) {
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
flightState._cockpitModeListener,
|
||||
);
|
||||
flightState._cockpitModeListener = null;
|
||||
}
|
||||
unregisterPickOwner('flights');
|
||||
if (flightState._preRenderRemove) {
|
||||
flightState._preRenderRemove();
|
||||
flightState._preRenderRemove = null;
|
||||
}
|
||||
if (flightState._trackedModelPreUpdateRemove) {
|
||||
flightState._trackedModelPreUpdateRemove();
|
||||
flightState._trackedModelPreUpdateRemove = null;
|
||||
}
|
||||
if (flightState._moveEndRemove) {
|
||||
flightState._moveEndRemove();
|
||||
flightState._moveEndRemove = null;
|
||||
}
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._billboardCollection) {
|
||||
viewer.scene.primitives.remove(flightState._billboardCollection);
|
||||
flightState._billboardCollection = null;
|
||||
}
|
||||
if (flightState._modelCollection) {
|
||||
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
||||
flightState._modelCollection = null;
|
||||
}
|
||||
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
|
||||
flightState._modelPending.clear();
|
||||
flightState._modelGen.clear();
|
||||
if (flightState._preloadModel) {
|
||||
try {
|
||||
flightState._preloadModel.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
flightState._preloadModel = null;
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
flightState._billboards.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState._flightData.clear();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._displayFloorState.clear();
|
||||
flightState._enrichQueue.length = 0;
|
||||
flightState._enrichSeen.clear();
|
||||
if (flightState._enrichDripTimer) {
|
||||
clearTimeout(flightState._enrichDripTimer);
|
||||
flightState._enrichDripTimer = null;
|
||||
}
|
||||
flightState._missingPolls.clear();
|
||||
flightState._focusEvidenceIds.clear();
|
||||
flightState._count = 0;
|
||||
flightState._lastUpdate = null;
|
||||
flightState._cockpitContactMode = false;
|
||||
flightState._cockpitNearContacts = new Set();
|
||||
flightState._cockpitSubjectId = null;
|
||||
flightState._trackingRefreshEpoch += 1;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
epoch: flightState._trackingRefreshEpoch,
|
||||
status: 'destroyed',
|
||||
ids: new Set(),
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
};
|
||||
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
|
||||
flightState._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCivilFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createEnrichment } from './enrichment.js';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
'picking',
|
||||
'sprites',
|
||||
'trails',
|
||||
'aircraftPresentation',
|
||||
'camera',
|
||||
'militaryRegistry',
|
||||
'labels',
|
||||
'groundFloor',
|
||||
'meshFloor',
|
||||
'geoid',
|
||||
'focus',
|
||||
'readout',
|
||||
'context',
|
||||
'render',
|
||||
'recession',
|
||||
];
|
||||
return {
|
||||
...Object.fromEntries(names.map((name) => [name, {}])),
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
};
|
||||
}
|
||||
|
||||
test('constructing independent flight layers performs no source request and isolates selection', () => {
|
||||
let requests = 0;
|
||||
const source = {
|
||||
label: 'Fixture aircraft',
|
||||
getSnapshot() {
|
||||
requests++;
|
||||
},
|
||||
};
|
||||
const first = createCivilFlightLayer({ source, services: services() });
|
||||
const second = createCivilFlightLayer({ source, services: services() });
|
||||
first.testing._armFlightTrackingRestoreForTest('abc123');
|
||||
assert.equal(first.testing._pendingFlightTrackingRestoreForTest(), 'abc123');
|
||||
assert.equal(second.testing._pendingFlightTrackingRestoreForTest(), null);
|
||||
first.testing._setFlightTrackingRefreshOutcomeForTest({ ids: ['abc123'] });
|
||||
assert.notEqual(first.testing, second.testing);
|
||||
assert.equal(first.getStats().source, 'Fixture aircraft');
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test('a layer without a source fails before touching the viewer', () => {
|
||||
const layer = createCivilFlightLayer({ services: services() });
|
||||
assert.throws(() => layer.init({}), /snapshot source/);
|
||||
});
|
||||
|
||||
test('flight state owns distinct mutable records, scratch objects and floor samplers', () => {
|
||||
const first = createFlightState({ services: services() });
|
||||
const second = createFlightState({ services: services() });
|
||||
for (const key of [
|
||||
'_flightData',
|
||||
'_billboards',
|
||||
'_positionHistory',
|
||||
'_displayFloorState',
|
||||
'_groundSnap',
|
||||
'_scratchCarto',
|
||||
'_models',
|
||||
'_activeUpdateControllers',
|
||||
'lifetime',
|
||||
]) {
|
||||
assert.notEqual(first[key], second[key], key);
|
||||
}
|
||||
});
|
||||
|
||||
test('aborted enrichment cannot write into a later lifecycle or decrement its active count', async () => {
|
||||
let resolve;
|
||||
let signal;
|
||||
const source = {
|
||||
getEnrichment(query, options) {
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ source, services: services() });
|
||||
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
|
||||
let writes = 0;
|
||||
enrichment._enqueueEnrich(
|
||||
'test',
|
||||
{ kind: 'type', id: 'abc123' },
|
||||
() => writes++,
|
||||
);
|
||||
await Promise.resolve();
|
||||
assert.equal(flightState._enrichActive, 1);
|
||||
flightState.lifetime.abort();
|
||||
flightState.lifetime = new AbortController();
|
||||
flightState._enrichActive = 0;
|
||||
resolve({ found: true });
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(flightState._enrichActive, 0);
|
||||
});
|
||||
|
||||
test('a synchronous enrichment failure stays bounded and releases its slot', async () => {
|
||||
const flightState = createFlightState({
|
||||
services: services(),
|
||||
source: {
|
||||
getEnrichment() {
|
||||
throw new Error('fixture failure');
|
||||
},
|
||||
},
|
||||
});
|
||||
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
|
||||
assert.doesNotThrow(() =>
|
||||
enrichment._enqueueEnrich('test', {}, () => assert.fail()),
|
||||
);
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(flightState._enrichActive, 0);
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */
|
||||
|
||||
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
||||
|
||||
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
||||
// taxiing and landing") -----------------------------------------------------------
|
||||
// Present-but-grounded planes are RENDERED instead of being skipped: same class
|
||||
// silhouette + rotation pipeline, clickable/trackable/detectable, sticky metadata
|
||||
// updating normally. Landing/takeoff is a TRANSITION — the on_ground flip restyles
|
||||
// the existing billboard in place, never a removal. Ground planes draw no trails
|
||||
// and are excluded from the ambient enrichment sweep (click-to-enrich still
|
||||
// works). In 3D mode they take model slots like airborne planes (owner decision
|
||||
// 2026-07-03 — no air/ground distinction), placed by the one-shot ground snap
|
||||
// (see _modelDisplayPosition).
|
||||
//
|
||||
// TINT: full-strength, same pipeline as airborne (white / amber-military /
|
||||
// cyan-tracked). Day 1 shipped a slate-gray 50%-alpha "muted" ground tint; the
|
||||
// owner killed it the same day ("just leave them as white, dude … in NYC I can
|
||||
// barely see them, extremely grayed out"). "On the ground" reads from the ×0.8
|
||||
// scale + missing trail; "feed-dropped, coasting" stays the 45%-alpha stale
|
||||
// fade — a full-alpha ground icon can never be confused with it.
|
||||
/** Ground billboards render slightly smaller so airport clutter stays visually minor. */
|
||||
|
||||
export const GROUND_SCALE = 0.8;
|
||||
|
||||
// --- 3D model rendering (B3) ---------------------------------------------------------
|
||||
// When enabled, aircraft render as 3D glTF models once the camera is below MODEL_ALT_CEIL_M
|
||||
// (zoomed in); higher up they stay flat billboards. Eligibility is FRUSTUM-based (on-screen), and
|
||||
// the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each
|
||||
// backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet).
|
||||
|
||||
export const PLANE_MODEL_URL = '/models/airplane.glb';
|
||||
|
||||
export const MODEL_ALT_CEIL_M = 800000;
|
||||
// m: below this camera altitude, draw 3D models (raised so it's easy to trigger)
|
||||
|
||||
export const MODEL_MIN_PX = 24;
|
||||
// floor so distant models stay visible WITHOUT ballooning into a giant
|
||||
// min-pixel blob (was 54 — far planes at the All radius became white
|
||||
// star-bursts); ~matches the 2D icon size so the model↔billboard read is consistent
|
||||
|
||||
export const TRACKED_MODEL_MIN_PX = 40;
|
||||
// keep the glTF silhouette comparable to the selected 2D glyph at handoff
|
||||
|
||||
export const TRACKED_MODEL_MAX_PX = 200;
|
||||
// owner-selected close-range tracked-target feel
|
||||
|
||||
export const MODEL_NATIVE_RADIUS_M = 34.41;
|
||||
|
||||
export const MODEL_SCALE = 1;
|
||||
// airplane.glb is transform-applied and baked to real-world meters
|
||||
// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost.
|
||||
|
||||
export const MODEL_MAX = 150;
|
||||
// 'proximity' cap (the planes immediately around you)
|
||||
|
||||
export const MODEL_MAX_ALL = 350;
|
||||
// 'all' cap (everything out to ~the horizon)
|
||||
// Per-mode ADD / KEEP radii. The two modes differ by RADIUS, not just cap — otherwise they look
|
||||
// IDENTICAL whenever fewer than a cap's worth of planes are in range (field bug: Proximity and All
|
||||
// rendered the same). 'proximity' = a tight ring; 'all' = roughly to the horizon (state-scale). Each
|
||||
// band has hysteresis (KEEP > ADD) so a plane doesn't release+reload its model when it straddles the
|
||||
// add edge (the zoom/pan flicker). Beyond ADD a model would force-clamp to minimumPixelSize into a
|
||||
// giant floating blob (the old 422 km airport-cluster bug), so far planes stay 2D dots; the cap +
|
||||
// on-screen priority then spend the model slots on planes you can actually see.
|
||||
|
||||
export const MODEL_PROX_ADD_M = 150000;
|
||||
// proximity: model NEW planes within 150 km
|
||||
|
||||
export const MODEL_PROX_KEEP_M = 185000;
|
||||
// proximity: KEEP modeled planes out to 185 km
|
||||
|
||||
export const COCKPIT_MODEL_MAX = 60;
|
||||
// max concurrent GLBs in cockpit (never raises the map cap)
|
||||
|
||||
export const MODEL_ALL_ADD_M = 400000;
|
||||
// all: model NEW planes within 400 km (~to the horizon)
|
||||
|
||||
export const MODEL_ALL_KEEP_M = 450000;
|
||||
// all: KEEP modeled planes out to 450 km
|
||||
|
||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||
// airplane.glb nose is opposite Cesium heading-0
|
||||
// Owner launch-polish direction: models should read as clean light silhouettes,
|
||||
// with only a weak diffuse contribution from the existing approved textures.
|
||||
|
||||
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||
|
||||
// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its
|
||||
// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as
|
||||
// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m
|
||||
// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled
|
||||
// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB
|
||||
// by modelScale.test.mjs.
|
||||
|
||||
export const MODEL_BELLY_OFFSET_NATIVE = 6.719;
|
||||
|
||||
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
||||
|
||||
/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */
|
||||
|
||||
export const ERROR_BACKOFF_INTERVAL = 20000;
|
||||
// transient error retry
|
||||
/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */
|
||||
|
||||
export const POSITION_HISTORY_LIMIT = 5;
|
||||
|
||||
export const COCKPIT_CONTACT_SIZE_PX = 6;
|
||||
|
||||
export const COCKPIT_CIVILIAN_COLOR =
|
||||
Cesium.Color.fromCssColorString('#DCEEFF');
|
||||
|
||||
export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||
1000,
|
||||
3.0,
|
||||
8000000,
|
||||
0.5,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track-history trail state (PRD WS-F F1/F4): a fading polyline behind the
|
||||
// tracked aircraft. The accumulation array is intentionally SEPARATE from
|
||||
// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning)
|
||||
// so the visible trail can grow to TRAIL_MAX_POINTS fixes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @constant {string} Civilian trail hue (PRD F4, pinned). */
|
||||
|
||||
export const TRAIL_COLOR = '#00d4ff';
|
||||
|
||||
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
|
||||
|
||||
export const TRAIL_MAX_POINTS = 400;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render-behind smoothing (PRD WS-C C2 — approved product decision):
|
||||
// the fleet renders at now - RENDER_DELAY_SEC so positions interpolate
|
||||
// BETWEEN two known fixes instead of extrapolating ahead and snapping back
|
||||
// when the next poll lands. All consumers (labels, HUD, detection,
|
||||
// frame_overhead) share this delayed clock; removing the delay reintroduces
|
||||
// the back/forward oscillation and is a regression.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @constant {number} Display latency in seconds (= one poll interval). */
|
||||
|
||||
export const RENDER_DELAY_SEC = 30;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
|
||||
|
||||
export const MISSING_POLL_LIMIT = 3;
|
||||
|
||||
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
|
||||
// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS
|
||||
// the actual landing, so a landed plane's last airborne-classified fixes show
|
||||
// it low + slow on the runway; when such a plane then drops out of the poll,
|
||||
// it has landed (the feed reclassified it to ground traffic we filter out),
|
||||
// not hit a transient gap — evict after ONE missed poll instead of the full
|
||||
// grace. Thresholds: below ~150 m baro (MSL, so this only fires near
|
||||
// sea-level-ish fields — deliberately conservative; a high-elevation airport
|
||||
// ghost just falls back to the normal grace) AND below ~45 kts ground speed
|
||||
// (≈23 m/s — rollout/taxi; nothing in normal FLIGHT is this slow, so cruise
|
||||
// planes always keep the full grace that absorbs real feed gaps).
|
||||
/** @constant {number} Max baro altitude (m, MSL) for the landed fast cull. */
|
||||
|
||||
export const LANDED_ALT_MAX_M = 150;
|
||||
|
||||
/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */
|
||||
|
||||
export const LANDED_SPEED_MAX_MPS = 23;
|
||||
|
||||
/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */
|
||||
|
||||
export const LANDED_MISSING_POLL_LIMIT = 1;
|
||||
|
||||
// Field-test rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only
|
||||
// contacts rendering below the alt ceiling are ever clamped/warmed (terrain
|
||||
// tops out well under it outside the extreme Himalaya; cruise traffic can't
|
||||
// be below ground and costs zero lookups). The radius bounds the FLEET clamp
|
||||
// to viewer-visible traffic — clamping thousands of global contacts would
|
||||
// need unbounded terrain resolution (the tracked contact clamps regardless).
|
||||
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
|
||||
|
||||
export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500;
|
||||
|
||||
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
|
||||
|
||||
export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon orientation (2026-06-10 playtest fix): rotation is computed by
|
||||
// projecting each aircraft's course vector into WINDOW coordinates
|
||||
// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera
|
||||
// pitch/heading, including tracked-entity orbit mode. Rotation passes run on
|
||||
// the fleet tick only when the camera pose changed (or 1s drift catch-up).
|
||||
// The same tick horizon-culls billboards: with the Cesium globe hidden there
|
||||
// is no far-side depth, so planes otherwise show through the planet.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
||||
|
||||
export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
/** @constant {number} Max ms between rotation passes while the camera is idle. */
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
export const COURSE_MAX_DPS = 60;
|
||||
|
||||
/** Never spend a long render stall's full elapsed time in one visible course step. */
|
||||
|
||||
export const COURSE_SLEW_DT_MAX_SEC = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
|
||||
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
|
||||
// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally
|
||||
// caches per-key on disk forever, negative results included, so repeat
|
||||
// sessions never re-hit adsbdb). Each key is requested at most once per
|
||||
// session. Priority jobs (tracked plane, model-eligible planes) jump the
|
||||
// queue; the ambient fleet sweep (below) fills the back at poll cadence.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ENRICH_MAX_INFLIGHT = 4;
|
||||
|
||||
/** Min ms between request dispatches — the drip that bounds the fan-out to ≤5/s. */
|
||||
|
||||
export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live
|
||||
// category field is 0/"no info" for ~94% of planes, so ambient classification
|
||||
// defaulted nearly the whole fleet to the airliner silhouette). Each poll,
|
||||
// ON-SCREEN planes — same horizon-occluder + frustum tests the fleet tick's
|
||||
// model-eligibility pass uses; no new per-plane raycast — that haven't been
|
||||
// requested this session are enqueued NEAREST-TO-CAMERA FIRST, bounded by:
|
||||
// - the shared queue's 4-concurrent / 200 ms-drip dispatch (above),
|
||||
// - ≤ ENRICH_AMBIENT_PER_SWEEP new enqueues per poll (= one poll interval
|
||||
// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and
|
||||
// - a ROLLING token-bucket budget (below) bounding the sustained ambient
|
||||
// request rate (repeat sessions resolve instantly from the proxy's
|
||||
// permanent disk cache).
|
||||
// Fail-silent by contract: the sweep never throws into the poll loop, never
|
||||
// blocks rendering, and never touches tracking state. When a type answer
|
||||
// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale
|
||||
// in place (bb.scale composes multiplicatively with scaleByDistance).
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling ambient budget (2026-07-03 field fix). The old ONE-SHOT session cap
|
||||
// (300, refilled only in init) burned out in the first two polls of a busy
|
||||
// region and never recovered — an hours-long session showed airliner
|
||||
// monoculture in every NEW region until planes were clicked (the tracked path
|
||||
// is uncapped). Token bucket instead: starts full at the ceiling, refills
|
||||
// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped
|
||||
// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains
|
||||
// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with
|
||||
// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL
|
||||
// politeness bound on adsbdb; the 300 ceiling preserves the old first-look
|
||||
// burst so a fresh region still classifies quickly.
|
||||
/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */
|
||||
|
||||
export const ENRICH_AMBIENT_BUDGET_CEIL = 300;
|
||||
|
||||
/** Tokens added back per refill window. */
|
||||
|
||||
export const ENRICH_AMBIENT_REFILL_TOKENS = 150;
|
||||
|
||||
/** Refill window length (ms). */
|
||||
|
||||
export const ENRICH_AMBIENT_REFILL_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Max new ambient enqueues per poll sweep (≈ rate × poll interval). */
|
||||
|
||||
export const ENRICH_AMBIENT_PER_SWEEP = 150;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tracked-display reconciliation. _deadReckon gives the RAW position from real
|
||||
// fixes; at the warm-up→interpolation handoff (and on feed glitches / backfill
|
||||
// splices) that raw value can step discontinuously. We absorb a step into a
|
||||
// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked
|
||||
// icon, camera, and trail head never visibly jump — with ZERO steady-state lag
|
||||
// (the correction stays ~0 whenever motion is already continuous).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DR_CORRECTION_MS = 900;
|
||||
|
||||
/** Bounded on-demand loading for the tracked model. The tracked regime is
|
||||
* DEFAULT-ON and its driver runs every `scene.preUpdate`, so a missing or
|
||||
* corrupt GLB — or a dead network — would otherwise spin load→reject at frame
|
||||
* rate for as long as the contact stays selected. Failures are counted PER
|
||||
* SELECTION: a short backoff absorbs a transient blip, then the layer stops
|
||||
* asking until the operator selects something else. The billboard is the
|
||||
* visual throughout (the handoff only fades it once a model actually renders),
|
||||
* so a latched failure degrades to exactly the pre-3D presentation. */
|
||||
|
||||
export const TRACKED_MODEL_MAX_LOAD_FAILS = 3;
|
||||
|
||||
export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500;
|
||||
|
||||
/** @constant {number} Cap on NEW cells the display corridors may add to one
|
||||
* poll's warm/sample batch — a view full of ground traffic must not balloon
|
||||
* it. Cells the poll already collected are free (deduped before budgeting). */
|
||||
|
||||
export const DISPLAY_CORRIDOR_CELL_BUDGET = 64;
|
||||
|
||||
/** @constant {number} Cells any single contact may claim in the first pass, so
|
||||
* one long corridor cannot spend the whole budget while other contacts get
|
||||
* nothing. Leftovers are handed out in a second pass. */
|
||||
|
||||
export const DISPLAY_CORRIDOR_FAIR_SHARE = 4;
|
||||
|
||||
/** @constant {number} How far ahead a COASTING contact's corridor reaches:
|
||||
* two poll intervals, so the ground it covers before the next batch lands is
|
||||
* already warm. */
|
||||
|
||||
export const DISPLAY_CORRIDOR_LOOKAHEAD_SEC = RENDER_DELAY_SEC * 2;
|
||||
|
||||
/** @constant {number} Corridors are only collected this close to the viewer:
|
||||
* the mesh sampler ignores anything past 15 km, and a far contact's exact
|
||||
* datum is subpixel. */
|
||||
|
||||
export const DISPLAY_CORRIDOR_RADIUS_KM = 25;
|
||||
|
||||
/** @constant {number} How far a contact may travel from the cell that supplied
|
||||
* its held floor before that floor stops describing the ground under it.
|
||||
*
|
||||
* Sized to the same worst-case ground segment `CORRIDOR_MAX_CELLS` is sized
|
||||
* for — a 27 m/s rollout covers ~810 m in one poll interval — with headroom
|
||||
* for a couple of polls of coasting. That is ~9 cells: aprons, taxiways and
|
||||
* runways really are flat at that scale. An early draft used 5 km, which is
|
||||
* ~45 cells and can leave the airfield entirely — the KAUS note in this file
|
||||
* records a 21 m spread across the field alone — so the bound is the distance
|
||||
* the contact can actually have travelled since the measurement rather than a
|
||||
* comfortable-looking number. */
|
||||
|
||||
export const HELD_FLOOR_MAX_DRIFT_KM = 1;
|
||||
|
||||
/** @constant {number} Minimum gap between adjacent-cell probes for one contact
|
||||
* whose held floor is missing or came from a borrowed tier. A contact standing
|
||||
* on its own resolved floor never probes at all.
|
||||
*
|
||||
* This per-contact throttle is the ONLY rationing on the probe path, and that
|
||||
* is deliberate. A probe is eight synchronous `Map` reads against the shared
|
||||
* floor cache — no fetch, no `sampleHeight`, nothing async — so it cannot
|
||||
* queue work anywhere; every DEM request is driven by `warmGroundFloor` from
|
||||
* the poll loop, already bounded by DISPLAY_CORRIDOR_CELL_BUDGET and the
|
||||
* resolver's single-flight chain. Measured worst case (`scripts/qa-floorhold-
|
||||
* probe-cost.mjs`): 200 synchronized all-cold contacts probing on the SAME
|
||||
* tick cost 2.1 ms, 2.6% of one 80 ms fleet tick, and 2.1 ms per second of
|
||||
* wall clock sustained under this throttle. An earlier draft added a global
|
||||
* per-tick budget with a fairness queue on top of that; it protected ~2 ms and
|
||||
* cost two starvation defects, so it was deleted. Nothing can starve here
|
||||
* because there is no shared resource to be starved of. */
|
||||
|
||||
export const NEIGHBOR_FLOOR_PROBE_MS = 500;
|
||||
|
||||
/** @constant {number} Time constant of the downward floor ease. A floor that
|
||||
* drops UNDER a contact standing on a borrowed one is approached
|
||||
* exponentially: each tick closes `1 - e^(-dt/TAU)` of the remaining gap, so
|
||||
* at the 80 ms fleet cadence a tick moves ~20% of what is left and the value
|
||||
* is within a few centimetres inside ~1.6 s. FLOOR_EASE_MAX_STEP caps that
|
||||
* fraction so a delayed tick cannot close more.
|
||||
*
|
||||
* Exponential rather than a fixed-duration interpolation because the target
|
||||
* MOVES: a second, lower neighbour can warm mid-ease. A from/duration ease
|
||||
* re-evaluated against a new target jumps by the eased fraction of the change
|
||||
* (measured: a 100 m single-tick drop late in an ease). Approaching from the
|
||||
* CURRENTLY DISPLAYED value has no such seam — retargeting is just a different
|
||||
* destination for the same continuous follow, and the per-tick bound holds
|
||||
* however often the target moves.
|
||||
*
|
||||
* Rises are never eased: up is the safe direction, and easing up would park
|
||||
* the contact under the mesh for the duration — the exact failure this whole
|
||||
* path exists to prevent.
|
||||
*
|
||||
* Reachability note (2026-08-21, after neighborFloorM moved to a low lean):
|
||||
* the ordinary arrival flows no longer produce a downward move at all — a
|
||||
* borrowed floor is now the apron rather than a roof, so the contact rises
|
||||
* once and stays (`scripts/qa-floorhold-staircase.mjs`: one step, zero float,
|
||||
* in every scenario that settles). This machinery still guards the re-latch
|
||||
* paths — a lower neighbour warming later, or an own-cell resolve below a
|
||||
* hold — which the pins exercise directly. Whether those paths are worth the
|
||||
* code is a follow-up judgement, deliberately not made in this change. */
|
||||
|
||||
export const FLOOR_EASE_TAU_MS = 360;
|
||||
|
||||
/** @constant {number} Hard ceiling on the fraction of the remaining gap ONE
|
||||
* tick may close, whatever its dt. The exponential alone is timing-dependent:
|
||||
* it closes 19.9% at the 80 ms fleet cadence but 28.4% at 120 ms and 75% after
|
||||
* a 500 ms stall (a hidden tab, a long frame, a GC pause), which would turn a
|
||||
* delayed tick back into the snap this approach exists to prevent. Clamping
|
||||
* the fraction rather than dt keeps the never-snap property a property of the
|
||||
* code instead of a property of the schedule; a stalled tick simply resumes
|
||||
* the approach at full rate rather than jumping most of the way. */
|
||||
|
||||
export const FLOOR_EASE_MAX_STEP = 0.22;
|
||||
|
||||
/** @constant {number} Distance from the target at which the ease finishes
|
||||
* exactly, so a parked contact stops rebuilding its position. Two orders of
|
||||
* magnitude under GROUND_FLOOR_LIFT_M — invisible. */
|
||||
|
||||
export const FLOOR_EASE_EPSILON_M = 0.02;
|
||||
|
||||
/** @constant {number} How long a retired hold stays usable as a rehydration
|
||||
* seed — three poll intervals.
|
||||
*
|
||||
* Deleting the state outright was the first cut, and an owner sighting found
|
||||
* what that costs: VIR138M at JFK, 45 kt down the runway, "clearly on good
|
||||
* ground, then suddenly popped below the ground, then popped back up".
|
||||
* OpenSky's `on_ground` flag is not clean through a rotation — it flaps — and
|
||||
* the fix's own height source switches at the same moment, from the resolved
|
||||
* surface to baro + geoid N, which at a sea-level field IS the geoid and sits
|
||||
* below the runway. A single airborne poll therefore wiped the only thing that
|
||||
* was hiding that: the contact came back grounded with no prior, outrunning
|
||||
* its own floor cells at 23 m/s, and rendered at the geoid until something
|
||||
* ahead of it warmed (`scripts/qa-floorhold-staircase.mjs` §F1: 12 of 22
|
||||
* grounded ticks below the runway, and not recovering).
|
||||
*
|
||||
* So a retired hold is PARKED, not destroyed, and a contact that re-grounds
|
||||
* soon after picks it back up. What makes that safe is the bound already in
|
||||
* `_heldDisplayFloorM`: a seed only answers within HELD_FLOOR_MAX_DRIFT_KM of
|
||||
* where it was measured AND only while no fresh neighbour contradicts it, so a
|
||||
* genuine departure-and-landing-elsewhere still starts clean — the grace window
|
||||
* is belt to those braces, retiring the seed outright once a contact has been
|
||||
* airborne long enough to have gone anywhere. */
|
||||
|
||||
export const FLOOR_SEED_GRACE_MS = 90_000;
|
||||
|
||||
/** Flip the whole 3D fleet's boost state by dropping models so the eligibility
|
||||
* pass reloads them with creation-time boost options (both directions — a
|
||||
* boosted model must not stay flat white back in Normal). Destroying 350
|
||||
* GPU-backed models synchronously inside the style handler stalls the render
|
||||
* thread (review P1; same failure the cockpit path documents), so the release
|
||||
* is BATCHED through the fleet tick: each tick drops a bounded slice, showing
|
||||
* each plane's billboard first (gap-proof per plane, no double-image window).
|
||||
* Models are tagged with the boost state they loaded under, so queue entries
|
||||
* whose model already matches the current state (rapid style cycling, or a
|
||||
* reload that already happened) are skipped. In-flight loads are invalidated
|
||||
* immediately (cheap gen bumps); the tracked model is a single primitive and
|
||||
* reloads synchronously. */
|
||||
|
||||
export const IR_RELOAD_BATCH = 40;
|
||||
@@ -0,0 +1,954 @@
|
||||
import { displayedKinematics } from '../../data/motionModel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
|
||||
import { modelVisualAnchor } from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
rankContactMatch,
|
||||
contactMatchWins,
|
||||
CONTACT_MATCH_TIER,
|
||||
} from '../../data/contactMatch.js';
|
||||
import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js';
|
||||
import {
|
||||
LANDED_ALT_MAX_M,
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createQueries({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
|
||||
const { formatFlightLevel } = services.labels;
|
||||
const { isMilitaryIcao } = services.militaryRegistry;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const {
|
||||
setFocusDeemphasisParams,
|
||||
getFocusDeemphasisParams,
|
||||
setFocusEvidenceNowMs,
|
||||
advanceFocusEvidenceNowMs,
|
||||
} = services.focus;
|
||||
const { setAircraftRecessionParams, getAircraftRecessionParams } =
|
||||
services.recession;
|
||||
|
||||
/**
|
||||
* Cheap equirectangular distance (km) — plenty accurate for the ~150 km
|
||||
* ground-floor clamp gate; runs once per contact per poll, so no trig-heavy
|
||||
* haversine needed.
|
||||
* @param {number} lat1 @param {number} lon1 @param {number} lat2 @param {number} lon2
|
||||
* @returns {number} Approximate great-circle distance in km.
|
||||
*/
|
||||
|
||||
function _approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * 111.32;
|
||||
const dLon =
|
||||
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the aircraft's latest metadata reads "on or about the runway"
|
||||
* (low + slow) — see the landed fast-cull rationale above. Both gates must
|
||||
* hold, so a plane missing either datum keeps the normal grace.
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function _likelyLanded(icao24) {
|
||||
const info = flightState._flightData.get(icao24);
|
||||
if (!info) return false;
|
||||
// Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes
|
||||
// never heal"): the fast cull only applies to contacts that were AIRBORNE
|
||||
// this session — its original target, the post-LANDING ghost. OpenSky's
|
||||
// ground coverage flaps constantly, so fast-culling every grounded contact
|
||||
// put parked planes in an evict/re-enter churn: each re-entry was a
|
||||
// brand-new contact with cold floor state (geoid-height first poll), so
|
||||
// the apron looked half-empty AND perpetually sunken. First-seen-grounded
|
||||
// contacts now ride the normal MISSING_POLL_LIMIT grace and keep their
|
||||
// identity (and warmed floors) across feed flaps.
|
||||
if (info.wasAirborne !== true) return false;
|
||||
// Was airborne, then grounded, then VANISHED from the poll — landed ghost.
|
||||
if (info.onGround) return true;
|
||||
return (
|
||||
Number.isFinite(info.altitude) &&
|
||||
info.altitude < LANDED_ALT_MAX_M &&
|
||||
Number.isFinite(info.velocity) &&
|
||||
info.velocity < LANDED_SPEED_MAX_MPS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a value to a trimmed string. A whitespace-only field (" ") is
|
||||
* truthy, so every label chain must trim FIRST and then fall through.
|
||||
* @param {*} value - Any value (typically a metadata string or null).
|
||||
* @returns {string} Trimmed string, or '' if falsy.
|
||||
*/
|
||||
|
||||
function _toCleanText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer's ONE label convention: spoken callsign → tail registration → raw
|
||||
* ICAO hex. Mirrors militaryFlights.js so the same aircraft reads identically
|
||||
* in both layers.
|
||||
*
|
||||
* Registration is aircraft IDENTITY, not route, so unlike the origin/destination
|
||||
* line it is NOT plausibility-gated — an adsbdb tail number describes the
|
||||
* airframe itself and cannot go stale the way a leg can.
|
||||
*
|
||||
* This is a DISPLAY string only. Identity everywhere in this layer is `icao24`
|
||||
* (the `_billboards`/`_flightData` key, the `sourceId` detection declutter hashes,
|
||||
* the `id` that trackById/Context cohorts resolve) — never the label.
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address (the identity key).
|
||||
* @param {Object|null|undefined} info - `_flightData` record for this aircraft.
|
||||
* @returns {string} Display label; never empty.
|
||||
*/
|
||||
|
||||
function _contactLabel(icao24, info) {
|
||||
return (
|
||||
_toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public descriptor for one aircraft using its best current position
|
||||
* (dead-reckoned when history exists, billboard position otherwise).
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address.
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* Descriptor with a cloned position, or null if the aircraft is unknown.
|
||||
*/
|
||||
|
||||
function _describeFlight(icao24) {
|
||||
// DATA, NOT PIXELS — deliberately UNFLOORED (2026-08-19). The display floor
|
||||
// lifts grounded contacts onto the visible mesh so the sprite you see is not
|
||||
// buried; that is a rendering correction, not a measurement. This descriptor
|
||||
// feeds query/analyst/subject APIs — `findByQuery` (voice track-by-name),
|
||||
// `getTrackedInfo` (cockpit + readout altitude), `getTrackedSubject`
|
||||
// (proximity counts and distances) — where the honest answer is what the
|
||||
// aircraft REPORTED, not where its icon was nudged to avoid clipping tiles.
|
||||
// `altitudeM` is therefore the barometric/aviation value and `renderAltitudeM`
|
||||
// the fix-time datum, neither of them the floored display height. No
|
||||
// user-visible surface renders `position` as the plane's on-screen location —
|
||||
// every visual consumer reads the floored per-frame cache instead (see
|
||||
// `_trackedDisplayPosition`). If that ever changes, floor this path too.
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const basePos =
|
||||
parts.motion._deadReckon(icao24) || (bb ? bb.position : null);
|
||||
if (!basePos) return null;
|
||||
const displayed = displayedKinematics({
|
||||
derivedSpeedMps: flightState._drSpeedMps,
|
||||
derivedTrackDeg: flightState._drCourseDeg,
|
||||
reportedSpeedMps: info?.velocity,
|
||||
reportedTrackDeg: info?.true_track,
|
||||
});
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
basePos,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
flightState._scratchCarto,
|
||||
);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
icao24,
|
||||
callsign: String(info?.callsign || '').trim() || null,
|
||||
position: Cesium.Cartesian3.clone(basePos),
|
||||
latitude: Cesium.Math.toDegrees(carto.latitude),
|
||||
longitude: Cesium.Math.toDegrees(carto.longitude),
|
||||
// The cockpit instrument reports aviation altitude, not the Cesium
|
||||
// ellipsoid height of the camera/ground-clamped render position. The
|
||||
// latter can be slightly negative over terrain near the surface.
|
||||
altitudeM: Number.isFinite(info?.altitude) ? info.altitude : carto.height,
|
||||
renderAltitudeM: Number.isFinite(info?.renderAltitudeM)
|
||||
? info.renderAltitudeM
|
||||
: carto.height,
|
||||
onGround: info?.onGround === true,
|
||||
velocityMps: displayed.speedMps,
|
||||
track: displayed.trackDeg,
|
||||
stale: Boolean(
|
||||
flightState._missingPolls.get(icao24) || flightState._backoff,
|
||||
),
|
||||
airline: info?.airline ?? null,
|
||||
// CLASS label follows the TR-3B conversion so every downstream card
|
||||
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
|
||||
typeName: tr3bTypeLabel(icao24, info?.typeName ?? null),
|
||||
typeCode: tr3bTypeLabel(icao24, info?.typeCode ?? null),
|
||||
// IDENTITY, deliberately NOT converted: registration is the airframe's tail
|
||||
// number and feeds `_contactLabel`'s callsign → registration → hex chain, so
|
||||
// a converted contact keeps the label convention every other contact uses.
|
||||
// Trimmed like `callsign` above so every consumer (cockpit readout, voice
|
||||
// narration, getTrackedSubject) can use it as a label link without
|
||||
// re-guarding a whitespace-only enrichment value.
|
||||
registration: _toCleanText(info?.registration) || null,
|
||||
origin:
|
||||
info?.route && parts.tracking._routeIsPlausible(icao24, info.route)
|
||||
? info.route.origin.code
|
||||
: null,
|
||||
destination:
|
||||
info?.route && parts.tracking._routeIsPlausible(icao24, info.route)
|
||||
? info.route.destination.code
|
||||
: null,
|
||||
route:
|
||||
info?.route && parts.tracking._routeIsPlausible(icao24, info.route)
|
||||
? {
|
||||
origin: { ...info.route.origin },
|
||||
destination: { ...info.route.destination },
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function _normalizeTrackedIcao(candidate) {
|
||||
const normalized = String(candidate ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one aircraft's internal poll record to a plain JSON-safe analyst
|
||||
* record (analyst query engine seam). Pure — no Cesium types, no fetches;
|
||||
* enrichment fields read the CACHED adsbdb values only. Missing/unknown
|
||||
* fields are null, never NaN/undefined. The route-plausibility verdict is
|
||||
* computed by the CALLER (it needs the billboard position) and passed in,
|
||||
* so an implausible cached route is never surfaced as fact.
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address.
|
||||
* @param {Object|null|undefined} info - `_flightData` record for this aircraft.
|
||||
* @param {{military?: boolean, routeOk?: boolean}} [flags] - Shared-registry
|
||||
* military flag + route-plausibility verdict.
|
||||
* @returns {{id: string, icao24: string, callsign: string|null, lat: number|null,
|
||||
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
||||
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
||||
* military: boolean, aircraftClass: string|null, originCountry: string|null,
|
||||
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(
|
||||
icao24,
|
||||
info,
|
||||
{ military = false, routeOk = false } = {},
|
||||
) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
const callsign = text(info?.callsign);
|
||||
return {
|
||||
// Display identity for the narration layer. `id` is NOT a queryable field
|
||||
// (see ANALYST_LAYERS) and follow-ups carry whole records, so this is a
|
||||
// label, not a key — the engine keys on `icao24` below.
|
||||
id: callsign || text(info?.registration) || icao24,
|
||||
icao24,
|
||||
callsign,
|
||||
lat: num(info?.rawLat),
|
||||
lon: num(info?.rawLon),
|
||||
altitudeM: num(info?.altitude), // barometric/MSL — the aviation field, not the render height
|
||||
speedMps: num(info?.velocity),
|
||||
heading: num(info?.true_track),
|
||||
verticalRateMps: num(info?.verticalRate),
|
||||
onGround: info?.onGround === true,
|
||||
military,
|
||||
// A converted contact reports the class it RENDERS as, so an analyst
|
||||
// filter/superlative agrees with the triangle on screen.
|
||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||
originCountry: text(info?.originCountry),
|
||||
operator: text(info?.airline),
|
||||
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
|
||||
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
|
||||
};
|
||||
}
|
||||
const methods = {
|
||||
mapAnalystRecord,
|
||||
|
||||
id: 'flights',
|
||||
|
||||
name: 'Live Flights',
|
||||
|
||||
icon: '✈️',
|
||||
|
||||
source: flightState._lastSource,
|
||||
|
||||
// Browser-harness seam: isolates synthetic display-floor scenarios without
|
||||
// changing any production lifecycle or cache policy.
|
||||
_clearDisplayFloorStateForTest:
|
||||
parts.testing._clearDisplayFloorStateForTest,
|
||||
|
||||
/** @type {number} Polling interval (ms) between update() calls */
|
||||
updateInterval: 30000,
|
||||
|
||||
/**
|
||||
* Live layer params.
|
||||
* `models3d` toggles 3D glTF model rendering for the FLEET (altitude-gated): when on,
|
||||
* surrounding aircraft become 3D models once the camera is zoomed in past MODEL_ALT_CEIL_M.
|
||||
* The TRACKED contact is NOT gated by this — it takes its 3D model by camera distance
|
||||
* regardless (see `_trackedModelRegimeActive` / trackedModelRegime.js).
|
||||
* `models3dMode` is 'proximity' (nearest MODEL_MAX in view) or 'all' (every in-view plane).
|
||||
* @param {{models3d?: boolean, models3dMode?: 'proximity'|'all', selectedFlightsTrackingId?: string|null}} params
|
||||
*/
|
||||
setParams(params = {}, { origin = 'programmatic' } = {}) {
|
||||
if (
|
||||
isExplicitLayerStateOrigin(origin) &&
|
||||
!Object.hasOwn(params, 'selectedFlightsTrackingId')
|
||||
) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
}
|
||||
if (
|
||||
typeof params.models3d === 'boolean' &&
|
||||
params.models3d !== flightState._models3dEnabled
|
||||
) {
|
||||
flightState._models3dEnabled = params.models3d;
|
||||
if (!flightState._models3dEnabled) {
|
||||
parts.rendering._releaseModels();
|
||||
parts.tracking._syncTracked2dRotation();
|
||||
// Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the
|
||||
// tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it
|
||||
// here would double-image the tracked plane when 3D is turned off mid-track.
|
||||
if (flightState._billboardCollection)
|
||||
for (const [icao, bb] of flightState._billboards) {
|
||||
if (icao !== flightState._trackedIcao) bb.show = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
(params.models3dMode === 'proximity' ||
|
||||
params.models3dMode === 'all') &&
|
||||
params.models3dMode !== flightState._models3dMode
|
||||
) {
|
||||
// The next fleet tick re-derives the eligible set under the new cap and releases the overflow.
|
||||
flightState._models3dMode = params.models3dMode;
|
||||
if (flightState._cockpitContactMode) {
|
||||
parts.tracking._refreshCockpitNearContacts();
|
||||
flightState._lastFleetTickMs = 0;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof params.irBoost === 'boolean' &&
|
||||
params.irBoost !== flightState._irBoost
|
||||
) {
|
||||
flightState._irBoost = params.irBoost;
|
||||
parts.rendering._reloadModelsForIrBoost();
|
||||
// Sprites don't reload with the models — swap the TR-3B glyph between its
|
||||
// cold and thermal-reactive variants directly. Bounded by the operator's
|
||||
// own conversions, so this never touches the ordinary fleet.
|
||||
parts.tracking._refreshTr3bForStyle();
|
||||
}
|
||||
if (Object.hasOwn(params, 'selectedFlightsTrackingId')) {
|
||||
const requested = _normalizeTrackedIcao(
|
||||
params.selectedFlightsTrackingId,
|
||||
);
|
||||
if (requested === flightState._trackedIcao) {
|
||||
flightState._pendingTrackingRestore = null;
|
||||
} else if (requested === null) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { origin });
|
||||
} else {
|
||||
const generation = ++flightState._trackingIntentGeneration;
|
||||
flightState._pendingTrackingRestore = {
|
||||
id: requested,
|
||||
generation,
|
||||
origin,
|
||||
};
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { origin });
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
getParams() {
|
||||
return {
|
||||
models3d: flightState._models3dEnabled,
|
||||
models3dMode: flightState._models3dMode,
|
||||
irBoost: flightState._irBoost,
|
||||
selectedFlightsTrackingId: flightState._trackedIcao,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-render a contact whose TR-3B conversion just flipped (Easter egg).
|
||||
* Callers own the registry write; this only re-derives what renders.
|
||||
* @param {string} icao24 - ICAO 24-bit address.
|
||||
* @returns {boolean} True when this layer owns the contact.
|
||||
*/
|
||||
refreshTr3b(icao24) {
|
||||
return parts.tracking._refreshTr3bContact(icao24);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a subsample of currently visible aircraft for detection overlay
|
||||
* rendering (e.g. bounding boxes drawn on-screen by the CCTV detection layer).
|
||||
*
|
||||
* Uses a deterministic stride + seed to select a spatially distributed
|
||||
* subset without sorting or shuffling.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.maxCount] - Maximum number of objects to return.
|
||||
* @param {number} [options.seed] - Deterministic offset into the stride pattern.
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
// Compute a stride that evenly samples the billboard map.
|
||||
// seed shifts the starting offset so successive calls can sample
|
||||
// different aircraft without shuffling the underlying Map order.
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: flightState._billboards.size;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(flightState._billboards.size / maxCount),
|
||||
);
|
||||
const start = seed % stride;
|
||||
|
||||
const result = [];
|
||||
let idx = 0;
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const shouldTake = (idx - start) % stride === 0;
|
||||
idx++;
|
||||
if (!shouldTake) continue;
|
||||
if (
|
||||
flightState._cockpitContactMode &&
|
||||
icao24.toLowerCase() === flightState._cockpitSubjectId
|
||||
)
|
||||
continue;
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
|
||||
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
|
||||
const model = flightState._models.get(icao24);
|
||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
||||
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
|
||||
const info = flightState._flightData.get(icao24);
|
||||
let object = flightState._detectionObjects.get(icao24);
|
||||
if (!object) {
|
||||
object = {
|
||||
sourceId: icao24,
|
||||
type: 'AIR',
|
||||
_weldPos: new Cesium.Cartesian3(),
|
||||
};
|
||||
flightState._detectionObjects.set(icao24, object);
|
||||
}
|
||||
// WELD: anchor to whatever actually owns the visual. A model-owned contact is
|
||||
// read straight off the translation the fleet tick already wrote into its
|
||||
// modelMatrix, so bracket and label sit on the aircraft you can see instead of
|
||||
// on the buried billboard position, which for a grounded plane is ~100 m below
|
||||
// and rises only as the coarse ground-floor cell warms. Zero extra sampling and
|
||||
// no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep
|
||||
// `bb.position` — sprite and bracket are co-located there, so association holds.
|
||||
const spec = modelOwnsVisual
|
||||
? parts.rendering._modelSpec(info?.klass)
|
||||
: null;
|
||||
const pos = isTracked
|
||||
? parts.motion._trackedVisualCached() || bb.position
|
||||
: modelOwnsVisual
|
||||
? modelVisualAnchor(
|
||||
model.modelMatrix,
|
||||
spec.visualCenterNative,
|
||||
Number.isFinite(model.computedScale)
|
||||
? model.computedScale
|
||||
: spec.scale,
|
||||
object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
|
||||
)
|
||||
: bb.position;
|
||||
if (!pos) continue;
|
||||
object.position = pos;
|
||||
object.skipLabel = isTracked;
|
||||
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
|
||||
const id = _contactLabel(icao24, info);
|
||||
if (object.id !== id) object.id = id;
|
||||
const altitude = info?.altitude;
|
||||
if (object._altitude !== altitude) {
|
||||
object._altitude = altitude;
|
||||
object.metric = formatFlightLevel(altitude); // altitude is metres
|
||||
}
|
||||
result.push(object);
|
||||
if (result.length >= maxCount) break;
|
||||
}
|
||||
if (
|
||||
flightState._detectionObjects.size >
|
||||
flightState._billboards.size + 512
|
||||
) {
|
||||
for (const icao24 of flightState._detectionObjects.keys()) {
|
||||
if (!flightState._billboards.has(icao24))
|
||||
flightState._detectionObjects.delete(icao24);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find a single aircraft by free-text query.
|
||||
* Match priority: exact icao24 hex, exact callsign, callsign prefix,
|
||||
* then callsign substring (all case-insensitive, trimmed).
|
||||
* @param {string} query - ICAO24 hex or full/partial callsign.
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* Best match with a cloned, dead-reckoned position, or null if none.
|
||||
*/
|
||||
findByQuery(query) {
|
||||
if (!flightState._flightData || flightState._flightData.size === 0)
|
||||
return null;
|
||||
const q = String(query || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!q) return null;
|
||||
|
||||
// Registration is searched alongside callsign because it is what the
|
||||
// operator SEES: a callsign-less contact reads as its tail number on the
|
||||
// card, in the analyst's answer, and in the Contacts list. Matching only
|
||||
// callsigns meant "follow 6606" — and the analyst → track_entity handoff
|
||||
// the tool instructions prescribe — answered "nothing matched" for the
|
||||
// very identity the app had just shown. Ranking is shared with the
|
||||
// military layer (contactMatch.js) so the two cannot disagree, and it is
|
||||
// strictly tiered so a registration can never out-rank a real callsign on
|
||||
// feed order alone.
|
||||
let best = null;
|
||||
for (const [icao24, info] of flightState._flightData) {
|
||||
const candidate = {
|
||||
tier: rankContactMatch({
|
||||
query: q,
|
||||
hex: icao24,
|
||||
callsign: info?.callsign,
|
||||
registration: info?.registration,
|
||||
}),
|
||||
id: icao24,
|
||||
};
|
||||
if (!contactMatchWins(candidate, best)) continue;
|
||||
best = candidate;
|
||||
if (candidate.tier === CONTACT_MATCH_TIER.HEX_EXACT) break;
|
||||
}
|
||||
return best ? _describeFlight(best.id) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find aircraft near a given ECEF position, sorted ascending by distance.
|
||||
* Return shape mirrors militaryFlightsLayer.getNearby (id/icao24/position/distance).
|
||||
* @param {Cesium.Cartesian3} center - Reference position in ECEF coordinates.
|
||||
* @param {number} range - Maximum distance in meters (Infinity if not finite).
|
||||
* @param {number} [maxCount=50] - Maximum number of results to return.
|
||||
* @param {object} [options] Query membership options.
|
||||
* @param {boolean} [options.includeHidden=false] Include loaded horizon-hidden aircraft.
|
||||
* @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>}
|
||||
*/
|
||||
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
||||
if (
|
||||
!center ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 50;
|
||||
const maxRange =
|
||||
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
|
||||
|
||||
const now = Cesium.JulianDate.now();
|
||||
const nearby = [];
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
// Keep planes rendered as a 3D model (billboard hidden) so proximity counts
|
||||
// don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden.
|
||||
if (
|
||||
!aircraftIncludedInNearby({
|
||||
isTracked,
|
||||
billboardShown: bb.show,
|
||||
modelRendering: parts.rendering._modelOwnsVisual(icao24),
|
||||
includeHidden,
|
||||
})
|
||||
)
|
||||
continue;
|
||||
|
||||
const trackedPos = isTracked
|
||||
? parts.motion._trackedDisplayCached()
|
||||
: null; // cached, no recompute (anti-jitter)
|
||||
const pos = trackedPos || bb.position;
|
||||
if (!pos) continue;
|
||||
|
||||
const distance = Cesium.Cartesian3.distance(center, pos);
|
||||
if (distance > maxRange) continue;
|
||||
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const callsign = info?.callsign?.trim() || null;
|
||||
nearby.push({
|
||||
// Label. Callers that need identity read `icao24` (Context cohorts do).
|
||||
id: _contactLabel(icao24, info),
|
||||
icao24,
|
||||
callsign,
|
||||
position: pos,
|
||||
distance,
|
||||
// Filter surface: the cockpit next/previous path matches on THIS field
|
||||
// (militaryAwareness.aircraftClassMatchesFilter), so a converted contact
|
||||
// has to report the class it renders as or a `tr3b` filter skips it.
|
||||
aircraftClass: tr3bAircraftClass(
|
||||
icao24,
|
||||
String(info?.klass || '')
|
||||
.trim()
|
||||
.toLowerCase() || null,
|
||||
),
|
||||
altitudeM: info?.altitude ?? null,
|
||||
velocityMps: info?.velocity ?? null,
|
||||
track: info?.true_track ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
nearby.sort((a, b) => a.distance - b.distance);
|
||||
return nearby.slice(0, limit);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return id/label/position for up to maxCount currently rendered aircraft.
|
||||
* Cheap snapshot for voice-tool framing — billboard positions only, no
|
||||
* dead reckoning and no cloning.
|
||||
* @param {number} [maxCount=500] - Maximum number of entries to return.
|
||||
* @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>}
|
||||
*/
|
||||
/**
|
||||
* Whether this layer still carries a contact, in O(1).
|
||||
*
|
||||
* Presence consumers must not infer absence from `getAllPositions`: it stops
|
||||
* at its cap, and this layer routinely carries ~11k contacts against a
|
||||
* 1,000-row cap, so "not in the returned rows" is not "gone". Id matching
|
||||
* mirrors trackById: exact key first, then lowercase.
|
||||
* A disabled layer keeps its records but hides the collection, so it must
|
||||
* decline rather than answer from data the user can no longer see —
|
||||
* otherwise a preserved subject reads as fresh off stale hidden state.
|
||||
* @param {string} icao24 Contact identifier.
|
||||
* @returns {boolean|null} Presence, or null when the layer is disabled or
|
||||
* holds no data and therefore cannot answer.
|
||||
*/
|
||||
hasContact(icao24) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return null;
|
||||
if (!icao24) return false;
|
||||
const id = String(icao24).trim();
|
||||
return (
|
||||
flightState._billboards.has(id) ||
|
||||
flightState._billboards.has(id.toLowerCase())
|
||||
);
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 500) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 500;
|
||||
|
||||
const result = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const pos = bb.position;
|
||||
if (!pos) continue;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
pos,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
flightState._scratchCarto,
|
||||
);
|
||||
if (!carto) continue;
|
||||
const info = flightState._flightData.get(icao24);
|
||||
result.push({
|
||||
id: icao24, // identity (trackById resolves this)
|
||||
label: _contactLabel(icao24, info),
|
||||
position: pos,
|
||||
latitude: Cesium.Math.toDegrees(carto.latitude),
|
||||
longitude: Cesium.Math.toDegrees(carto.longitude),
|
||||
altitudeM: carto.height,
|
||||
airline: info?.airline ?? null,
|
||||
typeName: info?.typeName ?? null,
|
||||
typeCode: info?.typeCode ?? null,
|
||||
registration: info?.registration ?? null,
|
||||
origin:
|
||||
info?.route && parts.tracking._routeIsPlausible(icao24, info.route)
|
||||
? info.route.origin.code
|
||||
: null,
|
||||
destination:
|
||||
info?.route && parts.tracking._routeIsPlausible(icao24, info.route)
|
||||
? info.route.destination.code
|
||||
: null,
|
||||
});
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot the layer's in-memory records as plain JSON-safe objects for
|
||||
* the analyst query engine. On-demand only (called at most once per
|
||||
* spoken query) — zero per-frame cost, no listeners, no caching, no
|
||||
* enrichment fetches (cached adsbdb values only). Returns [] while the
|
||||
* layer is disabled or empty.
|
||||
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState._flightData.size === 0
|
||||
)
|
||||
return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const [icao24, info] of flightState._flightData) {
|
||||
const routeOk =
|
||||
!!info?.route && parts.tracking._routeIsPlausible(icao24, info.route);
|
||||
result.push(
|
||||
mapAnalystRecord(icao24, info, {
|
||||
military: isMilitaryIcao(icao24),
|
||||
routeOk,
|
||||
}),
|
||||
);
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start camera-tracking an aircraft by ICAO24 address.
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address.
|
||||
* @returns {boolean} True if the aircraft exists and tracking started.
|
||||
*/
|
||||
trackById(icao24, { origin = 'programmatic' } = {}) {
|
||||
if (!icao24) return false;
|
||||
let id = String(icao24).trim();
|
||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||
if (!flightState._billboards.has(id)) return false;
|
||||
if (parts.tracking._isExplicitTrackingOrigin(origin))
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._trackedIcao === id)
|
||||
return parts.tracking._publishTrackedSelection(id, origin);
|
||||
parts.tracking._trackFlight(id, { origin });
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Resolve a shared Follow target only against the latest accepted refresh. */
|
||||
async resolveTrackingRestoreTarget(
|
||||
icao24,
|
||||
{ signal = null, origin = 'share-restore' } = {},
|
||||
) {
|
||||
if (signal?.aborted)
|
||||
return {
|
||||
status: 'cancelled',
|
||||
reason: String(signal.reason || 'aborted'),
|
||||
};
|
||||
const id = _normalizeTrackedIcao(icao24);
|
||||
if (!id) return { status: 'missing', reason: 'invalid-target' };
|
||||
const outcome = flightState._lastTrackingRefreshOutcome;
|
||||
if (outcome.status !== 'accepted') {
|
||||
return {
|
||||
status: 'source-unavailable',
|
||||
reason: 'OpenSky snapshot unavailable',
|
||||
refreshEpoch: outcome.epoch,
|
||||
source: outcome.source,
|
||||
coverage: outcome.coverage,
|
||||
};
|
||||
}
|
||||
if (!outcome.ids.has(id)) {
|
||||
return {
|
||||
status: 'missing',
|
||||
reason: 'target-absent-from-snapshot',
|
||||
refreshEpoch: outcome.epoch,
|
||||
source: outcome.source,
|
||||
coverage: outcome.coverage,
|
||||
};
|
||||
}
|
||||
if (signal?.aborted)
|
||||
return {
|
||||
status: 'cancelled',
|
||||
reason: String(signal.reason || 'aborted'),
|
||||
};
|
||||
const followed = this.trackById(id, { origin });
|
||||
return followed
|
||||
? {
|
||||
status: 'found',
|
||||
refreshEpoch: outcome.epoch,
|
||||
source: outcome.source,
|
||||
coverage: outcome.coverage,
|
||||
}
|
||||
: {
|
||||
status: 'source-unavailable',
|
||||
reason: 'target-not-renderable',
|
||||
refreshEpoch: outcome.epoch,
|
||||
};
|
||||
},
|
||||
|
||||
/** Reapply the canonical follow frame without recreating the selected flight. */
|
||||
refocusTrackedById(icao24, { origin = 'programmatic' } = {}) {
|
||||
if (
|
||||
!icao24 ||
|
||||
flightState._cockpitContactMode ||
|
||||
!flightState._viewer ||
|
||||
!flightState._trackedEntity
|
||||
)
|
||||
return false;
|
||||
let id = String(icao24).trim();
|
||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||
if (
|
||||
id !== flightState._trackedIcao ||
|
||||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
||||
flightState._viewer.trackedEntity !== flightState._trackedEntity
|
||||
)
|
||||
return false;
|
||||
flightState._trackedCameraFrameStop?.();
|
||||
flightState._viewer.camera.cancelFlight();
|
||||
flightState._viewer.trackedEntity = flightState._trackedEntity;
|
||||
flightState._trackedCameraFrameStop =
|
||||
applyTrackedCameraFrame(
|
||||
flightState._viewer,
|
||||
flightState._trackedEntity,
|
||||
flightState._trackedEntity.viewFrom,
|
||||
) || null;
|
||||
parts.tracking._publishTrackedSelection(id, origin);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop tracking the currently followed aircraft (no-op if none).
|
||||
* @returns {boolean} Always true.
|
||||
*/
|
||||
stopTracking({ origin = 'programmatic' } = {}) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
parts.tracking._clearTracking(false, { origin });
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelPendingTrackingRestore() {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Describe the currently tracked aircraft at its dead-reckoned position.
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* Tracked aircraft info, or null when nothing is tracked.
|
||||
*/
|
||||
getTrackedInfo() {
|
||||
if (!flightState._trackedIcao) return null;
|
||||
const described = _describeFlight(flightState._trackedIcao);
|
||||
if (!described) return null;
|
||||
const { position, ...rest } = described;
|
||||
return rest;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the current aircraft as a Context subject without changing
|
||||
* tracking or camera ownership.
|
||||
* @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null}
|
||||
* Detached subject descriptor, or null when no aircraft is tracked.
|
||||
*/
|
||||
getTrackedSubject() {
|
||||
if (!flightState._trackedIcao) return null;
|
||||
const described = _describeFlight(flightState._trackedIcao);
|
||||
if (!described?.position) return null;
|
||||
return {
|
||||
layerId: 'flights',
|
||||
id: described.icao24,
|
||||
// Same label chain as getNearby/getDetectableObjects: a callsign-less
|
||||
// contact reads as its registration, never as the raw ICAO hex.
|
||||
label:
|
||||
described.callsign ||
|
||||
_toCleanText(described.registration) ||
|
||||
described.icao24,
|
||||
position: Cesium.Cartesian3.clone(described.position),
|
||||
};
|
||||
},
|
||||
|
||||
...(FOCUS_EVIDENCE_DEV
|
||||
? {
|
||||
__focusEvidence: Object.freeze({
|
||||
setAircraft: parts.evidence._setFocusEvidenceAircraft,
|
||||
moveAircraft: parts.evidence._moveFocusEvidenceAircraft,
|
||||
snapshot: parts.evidence._focusEvidenceSnapshot,
|
||||
setTuning({ focus = {}, horizon = {} } = {}) {
|
||||
return {
|
||||
focus: setFocusDeemphasisParams(focus),
|
||||
horizon: setAircraftRecessionParams(horizon),
|
||||
};
|
||||
},
|
||||
getTuning() {
|
||||
return {
|
||||
focus: { ...getFocusDeemphasisParams() },
|
||||
horizon: { ...getAircraftRecessionParams() },
|
||||
};
|
||||
},
|
||||
takeFrameClock(startMs = 1_000_000_000) {
|
||||
if (!flightState._viewer || !Number.isFinite(startMs))
|
||||
return { ok: false, nowMs: null };
|
||||
flightState._viewer.useDefaultRenderLoop = false;
|
||||
setFocusEvidenceNowMs(startMs);
|
||||
flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS;
|
||||
// Cross a browser task boundary, then close Cesium's pending-loop
|
||||
// latch. Any already-queued callback still observes the false gate,
|
||||
// while manual evidence renders can begin without waiting on VSYNC.
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
if (flightState._viewer?._cesiumWidget)
|
||||
flightState._viewer._cesiumWidget._renderLoopRunning = false;
|
||||
resolve({ ok: true, nowMs: startMs });
|
||||
}, 0),
|
||||
);
|
||||
},
|
||||
advanceFrameClock(deltaMs = FLEET_DR_INTERVAL_MS) {
|
||||
return advanceFocusEvidenceNowMs(deltaMs);
|
||||
},
|
||||
releaseFrameClock() {
|
||||
setFocusEvidenceNowMs(null);
|
||||
if (flightState._viewer)
|
||||
flightState._viewer.useDefaultRenderLoop = true;
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
|
||||
/**
|
||||
* Return layer health/status for the HUD stats chip.
|
||||
* @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}}
|
||||
*/
|
||||
getStats() {
|
||||
const retryInSec = flightState._retryAt
|
||||
? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000))
|
||||
: 0;
|
||||
return {
|
||||
count: flightState._count,
|
||||
lastUpdate: flightState._lastUpdate,
|
||||
stale: flightState._backoff,
|
||||
error: flightState._lastError,
|
||||
status: flightState._lastStatus,
|
||||
retryInSec,
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
_approxDistanceKm,
|
||||
_likelyLanded,
|
||||
_toCleanText,
|
||||
_contactLabel,
|
||||
_describeFlight,
|
||||
_normalizeTrackedIcao,
|
||||
mapAnalystRecord,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,501 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||
|
||||
export function createFlightState({ source, services }) {
|
||||
const { createGroundSnap } = services.groundSnap;
|
||||
const flightState = { lifetime: new AbortController() };
|
||||
|
||||
flightState._source = source;
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
* they render at scale 1 with their own measured belly lift and bounding
|
||||
* radius. Every other class keeps the shared-airplane.glb formula
|
||||
* (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap
|
||||
* math multiplies it by `scale`, so world radius = nativeRadiusM × scale in
|
||||
* both branches. The code-side MIX tint dominates every existing asset so
|
||||
* class silhouettes stay light without modifying third-party GLBs/textures. */
|
||||
/* Specs are static per class, and the detection weld now asks for one per
|
||||
* MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet
|
||||
* pass — so this is memoized, as militaryFlights.js already does. */
|
||||
|
||||
flightState._specCache = new Map();
|
||||
|
||||
/** One-shot cached tile-skin heights for MODELED grounded planes (see groundSnap.js). */
|
||||
|
||||
flightState._groundSnap = createGroundSnap();
|
||||
|
||||
flightState._scratchGroundCarto = new Cesium.Cartographic();
|
||||
|
||||
flightState._scratchGroundPos = new Cesium.Cartesian3();
|
||||
|
||||
/** @type {Cesium.PrimitiveCollection|null} */
|
||||
|
||||
flightState._modelCollection = null;
|
||||
|
||||
/** @type {Map<string, Cesium.Model>} icao24 → model */
|
||||
|
||||
flightState._models = new Map();
|
||||
|
||||
/** @type {Set<string>} icao24 currently loading (async) */
|
||||
|
||||
flightState._modelPending = new Set();
|
||||
|
||||
/** @type {Map<string, number>} icao24 → load generation; bumped on release to invalidate
|
||||
* an in-flight load (so a track/untrack/remove during fromGltfAsync can't add a stale model). */
|
||||
|
||||
flightState._modelGen = new Map();
|
||||
|
||||
/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle
|
||||
* against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */
|
||||
|
||||
flightState._modelEpoch = 0;
|
||||
|
||||
/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs
|
||||
* layer-state restoration, so this initializer — not the codec — is what the app
|
||||
* actually starts with; it must stay in lockstep with the `models3d` default in
|
||||
* `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would
|
||||
* light a button the layer has not armed. */
|
||||
|
||||
flightState._models3dEnabled = true;
|
||||
|
||||
flightState._models3dMode = 'proximity';
|
||||
// 'proximity' = nearest MODEL_MAX in view; 'all' = every in-view plane (≤ MODEL_MAX_ALL)
|
||||
|
||||
flightState._lastModelCapWarnMs = 0;
|
||||
// throttle the "more planes in view than the cap" console notice
|
||||
// The tracked entity's billboard goes transparent (rather than hidden) once the model takes
|
||||
// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its
|
||||
// alpha after the GLB is preloaded so the model is ready to render the instant the billboard
|
||||
// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not
|
||||
// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation.
|
||||
|
||||
flightState._planeModelLoaded = false;
|
||||
|
||||
/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */
|
||||
|
||||
flightState._preloadModel = null;
|
||||
|
||||
flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
|
||||
|
||||
flightState._scratchModelMtx = new Cesium.Matrix4();
|
||||
|
||||
flightState._scratchModelBS = new Cesium.BoundingSphere(
|
||||
new Cesium.Cartesian3(),
|
||||
1.0,
|
||||
);
|
||||
// frustum-visibility test
|
||||
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
|
||||
|
||||
flightState._billboardLimbScale = new WeakMap();
|
||||
// keep last N positions per aircraft
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level state: billboard collection and per-aircraft lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {Cesium.BillboardCollection|null} */
|
||||
|
||||
flightState._billboardCollection = null;
|
||||
|
||||
/** @type {Map<string, Cesium.Billboard>} icao24 -> billboard primitive */
|
||||
|
||||
flightState._billboards = new Map();
|
||||
|
||||
/** Stable lightweight records reused by the detection overlay between polls. */
|
||||
|
||||
flightState._detectionObjects = new Map();
|
||||
|
||||
/** @type {Map<string, {callsign:string, altitude:number, velocity:number, true_track:number}>} */
|
||||
|
||||
flightState._flightData = new Map();
|
||||
|
||||
/** DEV-only explicit-position contacts used by qa-focus-evidence.mjs. */
|
||||
|
||||
flightState._focusEvidenceIds = new Set();
|
||||
|
||||
/** @type {Map<string, Array<{time:Cesium.JulianDate, position:Cesium.Cartesian3}>>} */
|
||||
|
||||
flightState._positionHistory = new Map();
|
||||
|
||||
/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()) */
|
||||
|
||||
flightState._geoidReady = false;
|
||||
|
||||
/** @type {Map<string, number>} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */
|
||||
|
||||
flightState._geoidNCache = new Map();
|
||||
|
||||
/** @type {number} Current number of visible aircraft */
|
||||
|
||||
flightState._count = 0;
|
||||
|
||||
/** @type {number|null} Epoch ms of last successful API update */
|
||||
|
||||
flightState._lastUpdate = null;
|
||||
|
||||
/** @type {boolean} True while in a backoff/cooldown window */
|
||||
|
||||
flightState._backoff = false;
|
||||
|
||||
/** @type {number} Epoch ms — earliest time the next fetch is allowed */
|
||||
|
||||
flightState._retryAt = 0;
|
||||
|
||||
/** @type {string|null} Human-readable error string shown in stats chip */
|
||||
|
||||
flightState._lastError = null;
|
||||
|
||||
flightState._activeUpdateControllers = new Set();
|
||||
|
||||
/** @type {number|null} HTTP status of the most recent API response */
|
||||
|
||||
flightState._lastStatus = null;
|
||||
|
||||
/** @type {string} Source used by the latest successful snapshot. */
|
||||
|
||||
flightState._lastSource = source?.label || 'Aircraft';
|
||||
|
||||
/** @type {string} Completeness boundary for the latest successful snapshot. */
|
||||
|
||||
flightState._lastCoverage = 'worldwide upstream snapshot';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click-to-track state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {string|null} ICAO24 of the currently tracked aircraft */
|
||||
|
||||
flightState._trackedIcao = null;
|
||||
|
||||
flightState._pendingTrackingRestore = null;
|
||||
|
||||
flightState._trackingIntentGeneration = 0;
|
||||
|
||||
flightState._trackingRefreshEpoch = 0;
|
||||
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
epoch: 0,
|
||||
status: 'unavailable',
|
||||
ids: new Set(),
|
||||
source: flightState._lastSource,
|
||||
coverage: null,
|
||||
};
|
||||
|
||||
/** @type {Cesium.Entity|null} Entity used for camera tracking */
|
||||
|
||||
flightState._trackedEntity = null;
|
||||
|
||||
/** Disposes the single active tracked-camera framing owner. */
|
||||
|
||||
flightState._trackedCameraFrameStop = null;
|
||||
|
||||
/** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a
|
||||
* graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's
|
||||
* bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on
|
||||
* 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the
|
||||
* model rides in _modelCollection and is driven per-frame, fully decoupled from the camera. */
|
||||
|
||||
flightState._trackedModel = null;
|
||||
|
||||
/** Bumped on untrack/teardown so an in-flight tracked-model load resolves into a no-op. */
|
||||
|
||||
flightState._trackedModelGen = 0;
|
||||
|
||||
flightState._trackedModelLoading = false;
|
||||
|
||||
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */
|
||||
|
||||
flightState._clickHandler = null;
|
||||
|
||||
/** @type {Cesium.Viewer|null} Cached viewer reference */
|
||||
|
||||
flightState._viewer = null;
|
||||
|
||||
/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */
|
||||
|
||||
flightState._cockpitContactMode = false;
|
||||
|
||||
/** AIR contacts inside the selected Display range; independent from model admission/load/cap. */
|
||||
|
||||
flightState._cockpitNearContacts = new Set();
|
||||
|
||||
/** Normalized ICAO24 of the active Cockpit subject, omitted from detection candidates. */
|
||||
|
||||
flightState._cockpitSubjectId = null;
|
||||
|
||||
/** @type {((event: CustomEvent) => void)|null} */
|
||||
|
||||
flightState._cockpitModeListener = null;
|
||||
|
||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Shared fading-trail renderer */
|
||||
|
||||
flightState._trail = null;
|
||||
|
||||
/** @type {Cesium.Entity|null} Cheap 2-point head segment bridging the last fix to the LIVE
|
||||
* dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays
|
||||
* glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */
|
||||
|
||||
flightState._trailHeadEntity = null;
|
||||
|
||||
/** @type {number} Uniquifier for head-segment entity ids (Cesium requires unique ids). */
|
||||
|
||||
flightState._trailHeadSeq = 0;
|
||||
|
||||
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
|
||||
|
||||
flightState._trailPositions = [];
|
||||
|
||||
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
||||
|
||||
flightState._trailBackfillToken = 0;
|
||||
|
||||
/** @type {Map<string, number>} icao24 -> consecutive missed polls */
|
||||
|
||||
flightState._missingPolls = new Map();
|
||||
|
||||
/** @type {number} Epoch ms of the last fleet dead-reckoning pass */
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
/** @type {string} Camera pose signature at the last rotation pass */
|
||||
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
/** @type {number} Epoch ms of the last full rotation pass */
|
||||
|
||||
flightState._lastRotPassMs = 0;
|
||||
|
||||
/** @type {number} Last computed rotation for the tracked entity (radians) */
|
||||
|
||||
flightState._lastTrackedRotation = 0;
|
||||
|
||||
/** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */
|
||||
|
||||
flightState._preRenderRemove = null;
|
||||
|
||||
flightState._trackedModelPreUpdateRemove = null;
|
||||
|
||||
/** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */
|
||||
|
||||
flightState._moveEndRemove = null;
|
||||
|
||||
/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */
|
||||
|
||||
flightState._trackedEntityChangedRemove = null;
|
||||
|
||||
/** @type {(() => void)|null} militaryRegistry active-transition unsubscribe (M2 handoff sweep) */
|
||||
|
||||
flightState._milActiveChangeUnsub = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scratch (reusable) variables — avoid per-frame heap allocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
flightState._scratchOffset = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchCarto = new Cesium.Cartographic();
|
||||
|
||||
flightState._scratchEnu = new Cesium.Matrix4();
|
||||
|
||||
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||||
|
||||
flightState._scratchRenderTime = new Cesium.JulianDate();
|
||||
|
||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchWarmupTime = new Cesium.JulianDate();
|
||||
|
||||
flightState._trackedPosHolder = new Cesium.Cartesian3();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame cache for the tracked entity's dead-reckoned position.
|
||||
// The position, alignedAxis, and rotation CallbackProperties all fire each
|
||||
// render frame; caching avoids running _deadReckon three times.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {Cesium.Cartesian3|null} */
|
||||
|
||||
flightState._cachedDRPosition = null;
|
||||
|
||||
/** @type {number} Frame number for which _cachedDRPosition is valid */
|
||||
|
||||
flightState._cachedDRFrame = -1;
|
||||
|
||||
/** Course (deg) of the position `_deadReckon` most recently returned — set on
|
||||
* every branch of `_deadReckon`, read IMMEDIATELY by the caller (same
|
||||
* synchronous flow; module-scratch idiom, like the Cartesian scratches). */
|
||||
|
||||
flightState._drCourseDeg = null;
|
||||
|
||||
/** Sibling scratches of _drCourseDeg: the displayed ground speed of the motion
|
||||
* `_deadReckon` just returned, and whether that motion is too slow for ANY
|
||||
* course source to be trusted (hover/GPS drift — consumers HOLD their
|
||||
* previous display course instead of chasing noise). */
|
||||
|
||||
flightState._drSpeedMps = null;
|
||||
|
||||
flightState._drCourseHold = false;
|
||||
|
||||
/** Sibling scratch: whether the position `_deadReckon` just returned came from
|
||||
* EXTRAPOLATION (coasting past the newest fix, or the pre-history warm-up)
|
||||
* rather than interpolation between two known fixes. The display-floor
|
||||
* corridor needs it — an interpolating contact is walking TOWARD its newest
|
||||
* fix, a coasting one is walking AWAY from it along its course, and warming
|
||||
* the wrong end leaves a coaster permanently ahead of its floor data. */
|
||||
|
||||
flightState._drExtrapolating = false;
|
||||
|
||||
/** Frame-cached course for the tracked aircraft (sibling of _cachedDRPosition). */
|
||||
|
||||
flightState._cachedDRCourse = null;
|
||||
|
||||
/** Frame-cached siblings of _cachedDRCourse (same discipline). */
|
||||
|
||||
flightState._cachedDRSpeedMps = null;
|
||||
|
||||
flightState._cachedDRHold = false;
|
||||
|
||||
/** Wall-clock of the tracked course limiter's last advance (dt source only —
|
||||
* the course VALUE lives in the shared per-icao _displayCourse map below). */
|
||||
|
||||
flightState._trackedCourseMs = 0;
|
||||
|
||||
/** Per-aircraft smoothed display course — the SINGLE source of truth for the
|
||||
* nose direction an aircraft displays. The fleet pass reads/writes it at
|
||||
* tick cadence for untracked planes; _trackedDisplayCourse reads/writes the
|
||||
* SAME entry per frame for the tracked plane (the fleet pass skips the
|
||||
* tracked icao, so exactly one writer owns an entry at a time). Sharing the
|
||||
* entry — including its slew state — is what makes the tracked↔fleet
|
||||
* handoff seamless (2026-07-03 field fix: separate states froze the fleet
|
||||
* entry while tracked, so clicking / un-clicking a 65 kt helicopter FLIPPED
|
||||
* its nose — "looks like it's going in reverse"). */
|
||||
|
||||
flightState._displayCourse = new Map();
|
||||
|
||||
flightState._enrichActive = 0;
|
||||
|
||||
flightState._enrichLastDispatchMs = 0;
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>|null} pending drip wake-up */
|
||||
|
||||
flightState._enrichDripTimer = null;
|
||||
|
||||
flightState._enrichQueue = [];
|
||||
|
||||
flightState._enrichSeen = new Set();
|
||||
|
||||
flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL;
|
||||
|
||||
/** Epoch ms the bucket last accounted a refill window from (0 = unset). */
|
||||
|
||||
flightState._enrichAmbientRefillAnchorMs = 0;
|
||||
|
||||
flightState._drCorrection = new Cesium.Cartesian3(0, 0, 0);
|
||||
|
||||
flightState._drCorrectionStartMs = 0;
|
||||
|
||||
flightState._drPrevRaw = new Cesium.Cartesian3();
|
||||
|
||||
flightState._drPrevDisplay = new Cesium.Cartesian3();
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
flightState._drReconcileIcao = null;
|
||||
|
||||
/** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */
|
||||
|
||||
flightState._trackedVisualPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._trackedTrailPos = new Cesium.Cartesian3();
|
||||
|
||||
/** Scratch for the tracked model's world-space origin (the envelope centre). */
|
||||
|
||||
flightState._scratchTrailClip = new Cesium.Cartesian3();
|
||||
|
||||
/** Scratch for the shortened trail-head start. Owned by the head-segment
|
||||
* callback alone, so nothing else can overwrite it mid-frame. */
|
||||
|
||||
flightState._scratchTrailHead = new Cesium.Cartesian3();
|
||||
|
||||
/** Hysteresis latch for the tracked contact's zoom regime, plus the selection it
|
||||
* belongs to. Scoped per selection so a NEW target re-evaluates against the ENTER
|
||||
* ceiling instead of inheriting the previous target's looser EXIT band. */
|
||||
|
||||
flightState._trackedZoomLatched = false;
|
||||
|
||||
flightState._trackedZoomLatchIcao = null;
|
||||
|
||||
flightState._trackedModelFailIcao = null;
|
||||
|
||||
flightState._trackedModelFailCount = 0;
|
||||
|
||||
flightState._trackedModelRetryAtMs = 0;
|
||||
|
||||
/** @type {Cesium.Cartographic} Scratch for the grounded display-floor read. */
|
||||
|
||||
flightState._scratchDisplayCarto = new Cesium.Cartographic();
|
||||
|
||||
/** @type {Map<string, {cell: {lat: number, lon: number}, effectiveM: number|null,
|
||||
* in: Cesium.Cartesian3, out: Cesium.Cartesian3|null, heldM: number|null,
|
||||
* heldCell: {lat: number, lon: number}|null,
|
||||
* heldTier: 'own'|'neighbor'|null, heldActive: boolean, seeded: boolean,
|
||||
* probeMs: number|null, retiredMs: number|null,
|
||||
* easedM: number|null, easeMs: number|null}>} Per-grounded-contact
|
||||
* display-floor state: the cell it is currently reading (boundary hysteresis),
|
||||
* the last input position and the effective floor that produced the cached
|
||||
* output (rebuild skip), the last floor that actually RESOLVED for it plus the
|
||||
* tier it came from (the hold, below), whether that floor is a REHYDRATED SEED
|
||||
* rather than something measured while the contact stood here (`seeded` — it
|
||||
* ranks below live evidence), and the value the downward ease is currently
|
||||
* displaying while it approaches a lower floor.
|
||||
* Dropped with the contact, and whenever it stops being a grounded billboard. */
|
||||
|
||||
flightState._displayFloorState = new Map();
|
||||
|
||||
/** @type {number} Poll counter handed to the corridor allocator: it rotates
|
||||
* runs of EQUALLY needy contacts so a tie larger than the budget cycles across
|
||||
* polls instead of the same prefix winning forever. */
|
||||
|
||||
flightState._corridorEpoch = 0;
|
||||
|
||||
/** @type {Cesium.Cartographic} Scratch for the corridor's display-end read. */
|
||||
|
||||
flightState._scratchCorridorCarto = new Cesium.Cartographic();
|
||||
|
||||
/** @type {Cesium.Cartesian3} Scratch for the corridor's dead-reckon probe. */
|
||||
|
||||
flightState._scratchCorridorPos = new Cesium.Cartesian3();
|
||||
|
||||
/** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles
|
||||
* map LUMINANCE, so mid-gray textured models read cold and vanish into
|
||||
* terrain. While a boost style is active every model renders flat white
|
||||
* (hottest); per-spec color/tint restores on style exit. Driven by ui.js
|
||||
* setStyle via the `irBoost` layer param. */
|
||||
|
||||
flightState._irBoost = false;
|
||||
|
||||
/** Boosted models render UNLIT (owner cockpit-FLIR field rounds, 2026-08-16):
|
||||
* the white tint alone is applied to the MATERIAL, so Cesium still
|
||||
* sun-shades it — near-horizon viewing shows a plane's SIDE, ~90° to a high
|
||||
* sun, so it rendered near-BLACK in FLIR/NVG while sun-lit neighbors glowed.
|
||||
* LightingModel.UNLIT emits the flat white directly, orientation be damned.
|
||||
* CRITICAL (field-verified via scene.pick): assigning customShader to an
|
||||
* already-READY model is a silent no-op — the property sets but the shader
|
||||
* program never rebuilds. The boost therefore flips by RELEASE-AND-RELOAD
|
||||
* (see setParams), so every boosted model gets the shader AT CREATION.
|
||||
* One shared shader instance — stateless, safe across models. */
|
||||
|
||||
flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({
|
||||
lightingModel: Cesium.LightingModel.UNLIT,
|
||||
});
|
||||
|
||||
flightState._irReloadQueue = null;
|
||||
return flightState;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CYAN_TRANSPARENT } from './policy.js';
|
||||
|
||||
export function createTesting({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
/** Test seam for the display-floor clamp (unit-tested against real Cesium math
|
||||
* with seeded mesh cells — the drift mechanism is otherwise only reachable
|
||||
* through a live poll + render loop). */
|
||||
|
||||
function _floorGroundedDisplayPositionForTest(
|
||||
info,
|
||||
pos,
|
||||
modelOwnsVisual,
|
||||
icao24 = '__test__',
|
||||
nowMs = Date.now(),
|
||||
) {
|
||||
return parts.motion._floorGroundedDisplayPosition(
|
||||
icao24,
|
||||
info,
|
||||
pos,
|
||||
modelOwnsVisual,
|
||||
nowMs,
|
||||
);
|
||||
}
|
||||
|
||||
/** Test hook: drops the per-contact display-floor state (hysteresis + rebuild
|
||||
* cache) so each case starts clean. There is no cross-contact state to reset —
|
||||
* the clamp is per-contact all the way down. */
|
||||
|
||||
function _clearDisplayFloorStateForTest() {
|
||||
flightState._displayFloorState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
||||
* the production poll reconciler. Tests still call `flightsLayer.update()`;
|
||||
* this seam avoids constructing the browser-only Cesium layer lifecycle.
|
||||
* @param {object} state
|
||||
* @param {string} state.icao24
|
||||
* @param {object} state.entity
|
||||
* @param {object} state.meta
|
||||
* @param {object} state.billboard
|
||||
* @param {object} state.billboardCollection
|
||||
* @param {object} state.viewer
|
||||
* @param {Array<Object>} [state.history=[]]
|
||||
* @param {boolean} [state.tracked=true]
|
||||
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
|
||||
* by icao24, for the billboard-hidden/model-shown handoff state.
|
||||
*/
|
||||
|
||||
function _setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity,
|
||||
meta,
|
||||
billboard,
|
||||
billboardCollection,
|
||||
viewer,
|
||||
history = [],
|
||||
tracked = true,
|
||||
models = [],
|
||||
modelCollection = null,
|
||||
}) {
|
||||
flightState._viewer = viewer;
|
||||
flightState._modelCollection = modelCollection;
|
||||
flightState._billboardCollection = billboardCollection;
|
||||
flightState._billboards = new Map([[icao24, billboard]]);
|
||||
flightState._models.clear();
|
||||
for (const [key, model] of models) flightState._models.set(key, model);
|
||||
flightState._detectionObjects = new Map();
|
||||
flightState._flightData = new Map([[icao24, meta]]);
|
||||
flightState._positionHistory = new Map([[icao24, history]]);
|
||||
flightState._missingPolls = new Map();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._geoidNCache.clear();
|
||||
flightState._trackedIcao = tracked ? icao24 : null;
|
||||
flightState._trackedEntity = tracked ? entity : null;
|
||||
flightState._trackedModel = null;
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
flightState._trackedModelLoading = false;
|
||||
// NOTE: deliberately does NOT reset the per-selection latches. They are
|
||||
// production state owned by the tracking lifecycle (_resetTrackedSelectionState),
|
||||
// and clearing them here would mask exactly the deselect→re-track hole this
|
||||
// seam is used to test.
|
||||
flightState._backoff = false;
|
||||
flightState._retryAt = 0;
|
||||
}
|
||||
|
||||
/** Seed the authoritative snapshot outcome used by share-Follow tests. */
|
||||
|
||||
function _setFlightTrackingRefreshOutcomeForTest({
|
||||
status = 'accepted',
|
||||
ids = [],
|
||||
source = 'OpenSky Network',
|
||||
coverage = 'test',
|
||||
} = {}) {
|
||||
const epoch = ++flightState._trackingRefreshEpoch;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
epoch,
|
||||
status,
|
||||
ids: new Set(ids.map((id) => String(id).trim().toLowerCase())),
|
||||
source,
|
||||
coverage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add a cached contact so tests can model a target arriving on a later feed. */
|
||||
|
||||
function _addFlightTrackingCandidateForTest({
|
||||
icao24,
|
||||
meta,
|
||||
billboard,
|
||||
history = [],
|
||||
}) {
|
||||
flightState._billboards.set(icao24, billboard);
|
||||
flightState._flightData.set(icao24, meta);
|
||||
flightState._positionHistory.set(icao24, history);
|
||||
}
|
||||
|
||||
/** Expose the military-suppression decision for the civil duplicate. */
|
||||
|
||||
function _militaryLayerSuppressesForTest(icao24) {
|
||||
return parts.tracking._militaryLayerSuppresses(icao24);
|
||||
}
|
||||
|
||||
/** Arm the deferred restore latch directly, without a full setParams turn. */
|
||||
|
||||
function _armFlightTrackingRestoreForTest(id, origin = 'share-restore') {
|
||||
flightState._pendingTrackingRestore =
|
||||
id === null
|
||||
? null
|
||||
: { id, generation: flightState._trackingIntentGeneration, origin };
|
||||
}
|
||||
|
||||
/** Return the deferred restore target held by the production tracker. */
|
||||
|
||||
function _pendingFlightTrackingRestoreForTest() {
|
||||
return flightState._pendingTrackingRestore?.id ?? null;
|
||||
}
|
||||
|
||||
/** Exercise the production deferred-restore retry after a simulated feed refresh. */
|
||||
|
||||
function _applyPendingFlightTrackingRestoreForTest() {
|
||||
return parts.tracking._applyPendingTrackingRestore();
|
||||
}
|
||||
|
||||
/** Set the exact Cockpit subject through the production state transition for focused tests. */
|
||||
|
||||
function _setCockpitDetectionSubjectForTest(active, subjectId = null) {
|
||||
parts.tracking._applyCockpitState({ active, subjectId });
|
||||
}
|
||||
|
||||
/** Evaluate the TRACKED contact's zoom regime through the production predicate.
|
||||
* The decision is latch-bearing (default-on, hysteretic, cockpit/TR-3B-suppressed)
|
||||
* and otherwise only observable through a live scene, so tests drive it here. */
|
||||
|
||||
function _trackedModelRegimeActiveForTest() {
|
||||
return parts.tracking._trackedModelRegimeActive();
|
||||
}
|
||||
|
||||
/** Run one frame of the production tracked-model driver (normally a
|
||||
* `scene.preUpdate` listener) so tests can pin its bounded load retries. */
|
||||
|
||||
function _updateTrackedModelForTest() {
|
||||
return parts.rendering._updateTrackedModel();
|
||||
}
|
||||
|
||||
/** Evaluate the production tracked-billboard handoff colour for focused tests. */
|
||||
|
||||
function _trackedBillboardColorForTest() {
|
||||
return parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
? CYAN_TRANSPARENT
|
||||
: Cesium.Color.CYAN;
|
||||
}
|
||||
|
||||
/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */
|
||||
|
||||
function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) {
|
||||
return parts.rendering._driveFleetModelHandoff(
|
||||
icao24,
|
||||
flightState._models.get(icao24),
|
||||
flightState._billboards.get(icao24),
|
||||
position,
|
||||
course,
|
||||
);
|
||||
}
|
||||
|
||||
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
|
||||
|
||||
async function _ensureFleetModelForTest(icao24) {
|
||||
await parts.rendering._ensureModel(icao24);
|
||||
return flightState._models.get(icao24) || null;
|
||||
}
|
||||
return {
|
||||
_floorGroundedDisplayPositionForTest,
|
||||
_clearDisplayFloorStateForTest,
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
_setFlightTrackingRefreshOutcomeForTest,
|
||||
_addFlightTrackingCandidateForTest,
|
||||
_militaryLayerSuppressesForTest,
|
||||
_armFlightTrackingRestoreForTest,
|
||||
_pendingFlightTrackingRestoreForTest,
|
||||
_applyPendingFlightTrackingRestoreForTest,
|
||||
_setCockpitDetectionSubjectForTest,
|
||||
_trackedModelRegimeActiveForTest,
|
||||
_updateTrackedModelForTest,
|
||||
_trackedBillboardColorForTest,
|
||||
_driveFleetModelHandoffForTest,
|
||||
_ensureFleetModelForTest,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
@@ -24,10 +25,10 @@ function sweepLayerParamKeys() {
|
||||
const dataDir = new URL('../data/', import.meta.url);
|
||||
for (const entry of fs.readdirSync(dataDir)) {
|
||||
if (!entry.endsWith('.js')) continue;
|
||||
const source = fs.readFileSync(new URL(entry, dataDir), 'utf8');
|
||||
const source = readLayerSource(new URL(entry, dataDir));
|
||||
// Every getParams() in this codebase is a plain object return; take the
|
||||
// body up to its closing brace and read the keys it publishes.
|
||||
const body = source.match(/\n {2}getParams\(\)\s*\{[\s\S]*?\n {2}\},/);
|
||||
const body = source.match(/\n\s+getParams\(\)\s*\{[\s\S]*?\n\s+\},/);
|
||||
if (!body) continue;
|
||||
// A key always follows `{` or `,` — which matches both the multi-line
|
||||
// returns and the single-line `return { passive: … }` form, while a
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Read actual component owners for structural regression assertions. */
|
||||
export function readLayerSource(file) {
|
||||
const path = file instanceof URL ? fileURLToPath(file) : file;
|
||||
if (basename(path) !== 'flights.js') return readFileSync(path, 'utf8');
|
||||
const directory = join(dirname(path), '../layers/flights');
|
||||
return readdirSync(directory)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(directory, name), 'utf8'))
|
||||
.join('\n');
|
||||
}
|
||||
Reference in new issue
Block a user