mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Draw civil flights from an icon set moved on the GPU
Every flight is now a row in the layer's icon set. Its motion segment follows the same regime `_deadReckon` picks (interpolate between fixes, warm up backward from the oldest, coast forward from the newest), and one shared `_motionRegime` makes that choice for both. The shader moves every untracked contact between polls, so the 12 Hz fleet tick no longer touches each billboard. It now only re-plans expired rows, floors grounded contacts, advances focus while needed, and runs the 3D model regime. Grounded contacts the floor lifts are pinned to a hold at the floored position. Their nose glides on the GPU. Contacts within 50 km re-floor every tick, and farther ones take turns about once a second. Low contacts are judged for the horizon at no lower than 12 m above the ellipsoid, as before. Below hover speed a contact's nose holds. A row whose fleet model is drawn keeps its focus updated, because the model reads it. Untracking re-plans and re-floors the icon from the tracked course. On the same views with about 6,700 OpenSky flights, frame gaps at p95 drop from 28-40 ms to 17 ms, and the fleet tick from 16-37 ms to about 2 ms at most.
This commit is contained in:
1 parent
5f36eb0e0d
commit
2c4c66c710
33 files changed
+1598
-986
No files matched your search
@@ -1315,6 +1315,12 @@
|
||||
"src/layers/flights/policy.js",
|
||||
"src/layers/flights/queries.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/icons.js",
|
||||
"src/data/iconSetPrimitive.js",
|
||||
"src/data/iconSet.js",
|
||||
"src/data/iconSegment.js",
|
||||
"src/data/iconAtlas.js",
|
||||
"src/data/aircraftRecession.js",
|
||||
"src/data/modelMirror.js",
|
||||
"src/data/billboardMirror.js",
|
||||
"src/layers/aircraft/visualState.js",
|
||||
@@ -2249,6 +2255,7 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/flights/icons.js",
|
||||
"src/sources/adsbFields.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
@@ -3248,6 +3255,11 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/layers/flights/icons.js",
|
||||
"src/data/iconSetPrimitive.js",
|
||||
"src/data/iconSet.js",
|
||||
"src/data/iconSegment.js",
|
||||
"src/data/iconAtlas.js",
|
||||
"src/sources/adsbFields.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
|
||||
@@ -252,6 +252,8 @@ const _b64 = (s) =>
|
||||
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
|
||||
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
|
||||
const FLEET_RASTER_PX = 64;
|
||||
/** Raster size of the default fleet glyph. */
|
||||
export const FLEET_ICON_PX = FLEET_RASTER_PX;
|
||||
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
|
||||
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
|
||||
* it stays crisp at its biggest on-screen sizes. */
|
||||
|
||||
@@ -243,6 +243,40 @@ export function applyAircraftBillboardTreatment({
|
||||
return _treatmentResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* The alpha an aircraft draws with: base × max(combined floor, focus × limb
|
||||
* haze). Icon sets compose the same thing in their shader; models need it here.
|
||||
* @param {object} input
|
||||
* @param {number} input.baseAlpha
|
||||
* @param {number} input.focusFactor
|
||||
* @param {number} input.cameraDistanceM
|
||||
* @param {number} input.cameraHeightM
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function aircraftTreatmentAlpha({
|
||||
baseAlpha,
|
||||
focusFactor,
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
params,
|
||||
}) {
|
||||
const tuning = resolvedParams(params);
|
||||
const factors = aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
tuning,
|
||||
_scratchFactors,
|
||||
);
|
||||
return (
|
||||
baseAlpha *
|
||||
Math.max(
|
||||
tuning.combinedAlphaFloor,
|
||||
clamp(focusFactor, 0, 1) * factors.alpha,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
|
||||
@@ -83,24 +83,24 @@ for (const layer of LAYERS) {
|
||||
test(`${layer.name}: near AIR state is independent from model admission`, () => {
|
||||
assert.match(source, /nextCockpitNearContacts\(/,
|
||||
'Cockpit derives a separate near-contact hysteresis set');
|
||||
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)/,
|
||||
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)|!flightState\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
|
||||
'only out-of-range Cockpit contacts become dots');
|
||||
// `_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\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*aircraftVisuals\.read\(bb\)\?\.iconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/,
|
||||
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*aircraftVisuals\.read\(bb\)\?\.iconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)|aircraftIcon\(\s*parts\.rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);/,
|
||||
assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);|set\.setFlag\(row, ICON_ROTATES, false\);/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/,
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)|set\.setFlag\(row, ICON_ROTATES, true\);/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if\s*\(\s*billboardState\(bb\)\.show,?\s*\)\s*setBillboard\(\s*bb,\s*'show',\s*false\s*\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
assert.match(source, /if\s*\(\s*billboardState\(bb\)\.show,?\s*\)\s*setBillboard\(\s*bb,\s*'show',\s*false\s*\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders|icons\.setShown\(row, 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 = /^([ \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*\);/);
|
||||
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*\);|for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
|
||||
});
|
||||
}
|
||||
@@ -40,5 +40,9 @@ export const _driveFleetModelHandoffForTest =
|
||||
flightsLayer.testing._driveFleetModelHandoffForTest;
|
||||
export const _ensureFleetModelForTest =
|
||||
flightsLayer.testing._ensureFleetModelForTest;
|
||||
export const _iconForTest = flightsLayer.testing._iconForTest;
|
||||
export const _setIconForTest = flightsLayer.testing._setIconForTest;
|
||||
export const _floorGroundedIconsForTest =
|
||||
flightsLayer.testing._floorGroundedIconsForTest;
|
||||
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
|
||||
export default flightsLayer;
|
||||
+101
-28
@@ -14,6 +14,8 @@ import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
_floorGroundedDisplayPositionForTest,
|
||||
_clearDisplayFloorStateForTest,
|
||||
_iconForTest,
|
||||
_floorGroundedIconsForTest,
|
||||
mapAnalystRecord,
|
||||
} from './flights.js';
|
||||
import { setMilitaryLayerActive } from './militaryRegistry.js';
|
||||
@@ -225,18 +227,9 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a
|
||||
test('a newly seen aircraft appears at its delayed position, not ahead of it', async () => {
|
||||
// The fleet renders RENDER_DELAY_SEC behind real time, so a fresh contact's
|
||||
// first displayed position is its fix projected back to that delayed time.
|
||||
// Creating the billboard at the raw fix drew it ahead until the next fleet
|
||||
// Creating the icon at the raw fix drew it ahead until the next fleet
|
||||
// tick, which then jumped it back about one delay of travel.
|
||||
const added = [];
|
||||
const billboardCollection = {
|
||||
show: false,
|
||||
add(options) {
|
||||
const billboard = { ...options };
|
||||
added.push(billboard);
|
||||
return billboard;
|
||||
},
|
||||
remove() {},
|
||||
};
|
||||
const billboardCollection = { show: false };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: 'seed00',
|
||||
@@ -293,9 +286,9 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
const billboard = added.find((entry) => entry.id === icao24);
|
||||
assert.ok(billboard, 'the new contact gets a billboard');
|
||||
const shown = Cesium.Cartographic.fromCartesian(billboard.position);
|
||||
const icon = _iconForTest(icao24);
|
||||
assert.ok(icon, 'the new contact gets an icon');
|
||||
const shown = Cesium.Cartographic.fromCartesian(icon.position);
|
||||
const fix = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_700);
|
||||
const behindM = Cesium.Cartesian3.distance(
|
||||
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 10_700),
|
||||
@@ -1066,21 +1059,12 @@ const _floorCarto = (pos) =>
|
||||
|
||||
test('a newly seen taxiing aircraft starts on the floor under its delayed position', async () => {
|
||||
// A grounded contact is drawn at its delayed position lifted onto the floor
|
||||
// under it. Creating the billboard without that lift started it on the
|
||||
// under it. Creating the icon without that lift started it on the
|
||||
// floor of its reported fix, then the next fleet tick raised it.
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
const added = [];
|
||||
const billboardCollection = {
|
||||
show: false,
|
||||
add(options) {
|
||||
const billboard = { ...options };
|
||||
added.push(billboard);
|
||||
return billboard;
|
||||
},
|
||||
remove() {},
|
||||
};
|
||||
const billboardCollection = { show: false };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: 'seed00',
|
||||
@@ -1142,9 +1126,9 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
const billboard = added.find((entry) => entry.id === icao24);
|
||||
assert.ok(billboard, 'the new contact gets a billboard');
|
||||
const shown = _floorCarto(billboard.position);
|
||||
const icon = _iconForTest(icao24);
|
||||
assert.ok(icon, 'the new contact gets an icon');
|
||||
const shown = _floorCarto(icon.position);
|
||||
assert.ok(
|
||||
Cesium.Math.toDegrees(shown.longitude) < fixLon,
|
||||
'drawn behind the fix',
|
||||
@@ -1160,6 +1144,95 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi
|
||||
}
|
||||
});
|
||||
|
||||
test('far grounded contacts take turns on the floor; near ones refloor every tick', async () => {
|
||||
// The floor pass is the fleet tick's only per-contact CPU work. Past
|
||||
// FLOOR_NEAR_M a contact is refloored once per FLOOR_FAR_EVERY_TICKS.
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
billboard: {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
||||
},
|
||||
billboardCollection: { show: false },
|
||||
viewer,
|
||||
meta: { rawLat: 0, rawLon: 0, onGround: false },
|
||||
tracked: false,
|
||||
});
|
||||
const icao24 = 'taxi02';
|
||||
const fixLon = -97.66;
|
||||
const fixLat = 30.2;
|
||||
for (let k = -40; k <= 40; k += 1) {
|
||||
reportMeshFloorCell(fixLat, fixLon + k * 0.0001, 131.5);
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (!String(url).startsWith('/api/flights')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
time: nowSec,
|
||||
states: [
|
||||
[
|
||||
icao24,
|
||||
'TAXI02 ',
|
||||
'United States',
|
||||
nowSec - 2,
|
||||
nowSec - 2,
|
||||
fixLon,
|
||||
fixLat,
|
||||
null,
|
||||
true,
|
||||
10,
|
||||
90,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5,
|
||||
],
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
const start = Cesium.JulianDate.now();
|
||||
const at = (s) =>
|
||||
Cesium.JulianDate.addSeconds(start, s, new Cesium.JulianDate());
|
||||
const far = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 3_000_000);
|
||||
const near = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_000);
|
||||
let last = _iconForTest(icao24).position;
|
||||
const moves = (camera, from, to) => {
|
||||
let count = 0;
|
||||
for (let tick = from; tick <= to; tick += 1) {
|
||||
_floorGroundedIconsForTest(at(tick), camera);
|
||||
const now = _iconForTest(icao24).position;
|
||||
if (Cesium.Cartesian3.distance(now, last) > 1) count += 1;
|
||||
last = now;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
assert.equal(moves(far, 1, 24), 2, 'far: once per 12 ticks');
|
||||
assert.equal(moves(near, 25, 30), 6, 'near: every tick');
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
_clearMeshFloorCellsForTest();
|
||||
_clearDisplayFloorStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('display floor: a grounded contact that drifted onto a higher cell is lifted', () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
|
||||
+18
-2
@@ -45,7 +45,7 @@ const COURSE0 = 19; // deg
|
||||
const COURSE_SLOPE = 20; // lerp: deg per unit fraction
|
||||
const SLEW_OFFSET = 21; // deg still to glide at SLEW_T0
|
||||
const SLEW_T0 = 22; // s
|
||||
const SLEW_RATE = 23; // deg/s
|
||||
const SLEW_RATE = 23; // deg/s; negative holds the course at the glide's start
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const STRAIGHT_BELOW_RAD_PER_SEC = 1e-4;
|
||||
@@ -140,6 +140,8 @@ export function writeArcSegment(out, row, segment) {
|
||||
/**
|
||||
* Glide the displayed course from `fromCourseDeg` onto the segment's own course,
|
||||
* at most `rateDps`, starting at `t0` (the rate limit at a segment boundary).
|
||||
* A rate of zero or less holds `fromCourseDeg` for the whole segment instead
|
||||
* (a hover: the nose keeps its heading however the track moves).
|
||||
* Call after writing the segment.
|
||||
* @param {Float32Array} out
|
||||
* @param {number} row
|
||||
@@ -153,7 +155,7 @@ export function writeCourseGlide(out, row, fromCourseDeg, t0, rateDps) {
|
||||
const target = targetCourse(out, o, t0);
|
||||
out[o + SLEW_OFFSET] = norm180(fromCourseDeg - target);
|
||||
out[o + SLEW_T0] = t0;
|
||||
out[o + SLEW_RATE] = Math.max(0, rateDps);
|
||||
out[o + SLEW_RATE] = rateDps > 0 ? rateDps : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +197,8 @@ export function segmentCourse(seg, row, tau) {
|
||||
const o = row * SEGMENT_STRIDE;
|
||||
const course = targetCourse(seg, o, tau);
|
||||
const offset = seg[o + SLEW_OFFSET];
|
||||
if (seg[o + SLEW_RATE] < 0)
|
||||
return norm360(targetCourse(seg, o, seg[o + SLEW_T0]) + offset);
|
||||
if (offset === 0) return norm360(course);
|
||||
// limitCourseStep: the displayed course turns at the rate toward the segment's
|
||||
// own (possibly turning) course until it reaches it, then follows it. The
|
||||
@@ -207,6 +211,17 @@ export function segmentCourse(seg, row, tau) {
|
||||
return norm360(turned < gap ? start + offset + toward * turned : course);
|
||||
}
|
||||
|
||||
/**
|
||||
* The course the row glides toward at `tau` (its segment's own course).
|
||||
* @param {Float32Array} seg
|
||||
* @param {number} row
|
||||
* @param {number} tau
|
||||
* @returns {number}
|
||||
*/
|
||||
export function segmentTargetCourse(seg, row, tau) {
|
||||
return norm360(targetCourse(seg, row * SEGMENT_STRIDE, tau));
|
||||
}
|
||||
|
||||
/** The segment's own course at `tau`, before any glide. */
|
||||
function targetCourse(seg, o, tau) {
|
||||
const kind = seg[o + KIND];
|
||||
@@ -310,6 +325,7 @@ float iconTargetCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) {
|
||||
float iconSegmentCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) {
|
||||
float course = iconTargetCourse(seg3, seg4, seg5, tau);
|
||||
float offset = seg5.y;
|
||||
if (seg5.w < 0.0) return iconTargetCourse(seg3, seg4, seg5, seg5.z) + offset;
|
||||
if (offset == 0.0) return course;
|
||||
float toward = -sign(offset);
|
||||
float start = iconTargetCourse(seg3, seg4, seg5, seg5.z);
|
||||
|
||||
@@ -213,3 +213,35 @@ test('the course glide never outruns the rate while its target turns', () => {
|
||||
'then it follows the turn',
|
||||
);
|
||||
});
|
||||
|
||||
test('a zero-rate glide holds the course however the segment turns', () => {
|
||||
const seg = new Float32Array(SEGMENT_STRIDE);
|
||||
const to = Cesium.Cartesian3.fromDegrees(-97.04, 32.90001, 10_500);
|
||||
for (const from of [0, 30]) {
|
||||
writeLerpSegment(seg, 0, {
|
||||
from: fix,
|
||||
to,
|
||||
t0: 0,
|
||||
t1: 30,
|
||||
course0Deg: 0,
|
||||
courseSlopeDeg: 90,
|
||||
});
|
||||
writeCourseGlide(seg, 0, from, 0, 0);
|
||||
for (const tau of [0, 10, 20, 30, 60])
|
||||
assert.ok(
|
||||
courseGap(segmentCourse(seg, 0, tau), from) < 1e-4,
|
||||
`from ${from}° at τ=${tau}`,
|
||||
);
|
||||
}
|
||||
writeArcSegment(seg, 0, {
|
||||
fix,
|
||||
t0: 0,
|
||||
minDt: 0,
|
||||
maxDt: 300,
|
||||
speedMps: 1,
|
||||
trackDeg: 90,
|
||||
turnRateDps: 3,
|
||||
});
|
||||
writeCourseGlide(seg, 0, 90, 0, 0);
|
||||
assert.ok(courseGap(segmentCourse(seg, 0, 20), 90) < 1e-4, 'arc turn held');
|
||||
});
|
||||
+25
-1
@@ -43,8 +43,14 @@ export const ICON_SHOWN = 1;
|
||||
export const ICON_ROTATES = 2;
|
||||
/** Fade and shrink toward the globe's limb (aircraft recession). */
|
||||
export const ICON_RECEDES = 4;
|
||||
/** Lift the row 12 m along the normal before the horizon test (fixes near the ground). */
|
||||
/**
|
||||
* Judge the row at no lower than ICON_CULL_HEIGHT_M above the ellipsoid in the
|
||||
* horizon test. A fix near or below the ellipsoid (a grounded contact at geoid
|
||||
* −31 m before its floor warms) otherwise reads as beyond the limb.
|
||||
*/
|
||||
export const ICON_CULL_LIFT = 8;
|
||||
/** The ellipsoid height a CULL_LIFT row is raised to for the horizon test. */
|
||||
export const ICON_CULL_HEIGHT_M = 12;
|
||||
|
||||
/** Move the epoch forward once render times pass this many seconds. */
|
||||
const REBASE_AFTER_SEC = 1800;
|
||||
@@ -422,6 +428,24 @@ export function createIconSet({ capacity = 1024, epoch } = {}) {
|
||||
course(row, tau) {
|
||||
return segmentCourse(segments, row, tau);
|
||||
},
|
||||
/** The point the horizon test judges for a row drawn at `position`. */
|
||||
cullPoint(row, position, result = new Cesium.Cartesian3()) {
|
||||
if (!(style[row * STYLE_STRIDE + FLAGS] & ICON_CULL_LIFT))
|
||||
return Cesium.Cartesian3.clone(position, result);
|
||||
// Height above the ellipsoid along the geocentric ray, as the shader
|
||||
// computes it: exact to millimetres this close to the surface.
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
Cesium.Cartesian3.multiplyComponents(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84.oneOverRadii,
|
||||
result,
|
||||
);
|
||||
const height = radius * (1 - 1 / Cesium.Cartesian3.magnitude(result));
|
||||
const lift = Math.max(0, ICON_CULL_HEIGHT_M - height);
|
||||
Cesium.Cartesian3.normalize(position, result);
|
||||
Cesium.Cartesian3.multiplyByScalar(result, lift, result);
|
||||
return Cesium.Cartesian3.add(position, result, result);
|
||||
},
|
||||
};
|
||||
|
||||
function markRow(row) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
createIconSet,
|
||||
DirtyRows,
|
||||
ICON_CULL_HEIGHT_M,
|
||||
ICON_CULL_LIFT,
|
||||
ICON_ROTATES,
|
||||
ICON_SHOWN,
|
||||
} from './iconSet.js';
|
||||
@@ -225,3 +227,25 @@ test('rebasing moves the epoch without moving any row', () => {
|
||||
assert.equal(set.expires(row), 1100);
|
||||
assert.deepEqual(set.dirty.segments.runs(set.count), [[0, 0]]);
|
||||
});
|
||||
|
||||
test('a cull-lift row is judged at no lower than the cull height above the ellipsoid', () => {
|
||||
const set = createIconSet();
|
||||
const row = set.add('a');
|
||||
const height = (p) => Cesium.Cartographic.fromCartesian(p).height;
|
||||
const at = (h) => Cesium.Cartesian3.fromDegrees(-84.43, 33.64, h);
|
||||
assert.ok(
|
||||
Math.abs(height(set.cullPoint(row, at(-31))) - -31) < 1e-6,
|
||||
'unflagged: as drawn',
|
||||
);
|
||||
set.setFlag(row, ICON_CULL_LIFT, true);
|
||||
for (const h of [-31, 0, 5]) {
|
||||
assert.ok(
|
||||
Math.abs(height(set.cullPoint(row, at(h))) - ICON_CULL_HEIGHT_M) < 0.01,
|
||||
`${h} m is judged at ${ICON_CULL_HEIGHT_M} m`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(height(set.cullPoint(row, at(131))) - 131) < 1e-6,
|
||||
'a contact floored higher keeps its own height',
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { ICON_SEGMENT_GLSL, SEGMENT_STRIDE } from './iconSegment.js';
|
||||
import { ICON_CULL_LIFT, STYLE_STRIDE } from './iconSet.js';
|
||||
import { ICON_CULL_HEIGHT_M, ICON_CULL_LIFT, STYLE_STRIDE } from './iconSet.js';
|
||||
import { iconAtlas, MAX_ICON_SLOTS } from './iconAtlas.js';
|
||||
import {
|
||||
cameraLimbDistanceM,
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
|
||||
/** On-screen device pixels above which a row draws its near (large) image. */
|
||||
const NEAR_IMAGE_PX = 76;
|
||||
/** Rows lifted this far before the horizon test when flagged (low fixes). */
|
||||
const CULL_LIFT_M = 12;
|
||||
|
||||
const ATTRIBUTE_LOCATIONS = Object.freeze({
|
||||
corner: 0,
|
||||
@@ -80,6 +78,13 @@ bool iconFlag(float flags, float bit) {
|
||||
return mod(floor(flags / bit), 2.0) == 1.0;
|
||||
}
|
||||
|
||||
// Raise a near-ground point to at least the cull height (iconSet.cullPoint).
|
||||
vec3 iconCullPoint(vec3 world) {
|
||||
float radius = length(world);
|
||||
float height = radius * (1.0 - 1.0 / length(world * czm_ellipsoidInverseRadii));
|
||||
return world + world / radius * max(0.0, ${ICON_CULL_HEIGHT_M.toFixed(1)} - height);
|
||||
}
|
||||
|
||||
bool occludedByEllipsoid(vec3 world) {
|
||||
vec3 cv = u_horizon.xyz;
|
||||
float vh2 = u_horizon.w;
|
||||
@@ -101,7 +106,7 @@ void main() {
|
||||
|
||||
vec3 rte = iconSegmentPosition(seg0, seg1, seg2, seg3, seg4, u_tau);
|
||||
vec3 world = u_cameraPosition + rte;
|
||||
vec3 cullPoint = iconFlag(flags, ${ICON_CULL_LIFT.toFixed(1)}) ? world + normalize(world) * ${CULL_LIFT_M.toFixed(1)} : world;
|
||||
vec3 cullPoint = iconFlag(flags, ${ICON_CULL_LIFT.toFixed(1)}) ? iconCullPoint(world) : world;
|
||||
if (occludedByEllipsoid(cullPoint)) return;
|
||||
|
||||
vec4 positionEC = czm_modelViewRelativeToEye * vec4(rte, 1.0);
|
||||
|
||||
@@ -1660,7 +1660,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
[
|
||||
'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;/,
|
||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*const icons = flightState\._icons;\s*\n\s*if\s*\(\s*!icons\s*\|\|\s*!icons\.show\s*\|\|\s*icons\.count\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
],
|
||||
[
|
||||
'militaryFlights',
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js';
|
||||
import flightsLayer, {
|
||||
_iconForTest,
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
} from './flights.js';
|
||||
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
|
||||
|
||||
const ICAO = 'abc123';
|
||||
@@ -39,6 +42,8 @@ const LAYERS = [
|
||||
name: 'commercial',
|
||||
layer: flightsLayer,
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
// Where the icon is drawn: the seeded position as the GPU encodes it.
|
||||
spritePosition: () => _iconForTest(ICAO).position,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: ICAO,
|
||||
@@ -61,6 +66,7 @@ const LAYERS = [
|
||||
name: 'military',
|
||||
layer: militaryFlightsLayer,
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
spritePosition: () => contactBillboard(true).position,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: ICAO,
|
||||
@@ -149,8 +155,8 @@ for (const fixture of LAYERS) {
|
||||
const spritePosition = detectionPosition(fixture.layer);
|
||||
assert.ok(spritePosition, 'the 2D sprite publishes a detection candidate');
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(spritePosition, contactBillboard(true).position, 0, 1e-6),
|
||||
'the 2D bracket stays welded to the billboard position',
|
||||
Cesium.Cartesian3.equalsEpsilon(spritePosition, fixture.spritePosition(), 0, 1e-6),
|
||||
'the 2D bracket stays welded to the drawn sprite position',
|
||||
);
|
||||
|
||||
const modelMatrix = Cesium.Matrix4.fromRotationTranslation(MODEL_ROTATION, MODEL_POSITION);
|
||||
@@ -195,7 +201,7 @@ for (const fixture of LAYERS) {
|
||||
const loadingPosition = detectionPosition(fixture.layer);
|
||||
assert.ok(loadingPosition, 'the billboard remains detectable while its model loads');
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(loadingPosition, contactBillboard(true).position, 0, 1e-6),
|
||||
Cesium.Cartesian3.equalsEpsilon(loadingPosition, fixture.spritePosition(), 0, 1e-6),
|
||||
'a shown-but-unready zero-scale model cannot steal the detection anchor',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
import { createTracking } from '../layers/flights/tracking.js';
|
||||
import { addBillboard } from './billboardMirror.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' };
|
||||
@@ -116,15 +115,14 @@ test('unjudgeable endpoint geometry and missing coordinates keep the loose contr
|
||||
assert.equal(plausible(beyond, null, null), true);
|
||||
});
|
||||
|
||||
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => {
|
||||
const collection = new Cesium.BillboardCollection();
|
||||
t.after(() => collection.destroy());
|
||||
test('the actual native tracked label hides the wrong leg and keeps a healthy route', () => {
|
||||
const flightState = {
|
||||
records: { data: new Map(), missingPolls: new Map() },
|
||||
feed: { _backoff: false },
|
||||
_billboards: new Map(),
|
||||
_scratchCarto: new Cesium.Cartographic(),
|
||||
};
|
||||
// Where each contact's icon is drawn.
|
||||
const drawn = new Map();
|
||||
// Only application host services are supplied. Route admission, WGS84
|
||||
// billboard conversion and tracked-label formatting are the actual owners.
|
||||
const services = Object.fromEntries(
|
||||
@@ -141,7 +139,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const parts = {
|
||||
queries: { _contactLabel: (_, info) => info.callsign },
|
||||
icons: { _iconPosition: (id) => drawn.get(id) ?? null },
|
||||
};
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
@@ -155,18 +156,9 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
|
||||
verticalRate: 0,
|
||||
route,
|
||||
});
|
||||
flightState._billboards.set(
|
||||
drawn.set(
|
||||
id,
|
||||
addBillboard(collection, {
|
||||
id,
|
||||
width: 20,
|
||||
height: 20,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
10000,
|
||||
),
|
||||
}),
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, 10000),
|
||||
);
|
||||
assert.equal(
|
||||
tracking._trackedLabelText(id).includes('SFO → LAX'),
|
||||
|
||||
@@ -233,7 +233,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
|
||||
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
|
||||
// no TR-3B GLB, so the triangle billboard stays the visual.
|
||||
assert.match(source, /modelEligible\.has\(icao24\) && !isTr3b\(icao24\)/,
|
||||
assert.match(source, /modelEligible\.has\(icao24\) && !isTr3b\(icao24\)|for \(const icao24 of modelEligible\) \{\s*if \(isTr3b\(icao24\)\) continue;/,
|
||||
`${name}: fleet model handoff skips converted contacts`);
|
||||
// The tracked regime became default-on and camera-distance driven
|
||||
// (2026-08-19), so the suppression moved from a conjunct on
|
||||
@@ -245,7 +245,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
// 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\s*\(\s*(bb|modelled)\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*setBillboard\(\s*\1,\s*'show',\s*true,?\s*\);/,
|
||||
assert.match(source, /if\s*\(\s*(bb|modelled)\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*setBillboard\(\s*\1,\s*'show',\s*true,?\s*\);|if\s*\(\s*row >= 0\s*&&\s*id\s*!==\s*flightState\._trackedIcao\s*\)\s*flightState\._icons\.setShown\(row, true\);/,
|
||||
`${name}: converting restores the billboard the model handoff had hidden`);
|
||||
|
||||
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
|
||||
@@ -411,7 +411,7 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
|
||||
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\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];
|
||||
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\((?:\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*|let row = 0; row < icons\.count; row\+\+)\)[\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;/,
|
||||
|
||||
@@ -34,6 +34,8 @@ import flightsLayer, {
|
||||
_trackedBillboardColorForTest as flightsTrackedBillboardColor,
|
||||
_driveFleetModelHandoffForTest as flightsDriveFleetModel,
|
||||
_ensureFleetModelForTest as flightsEnsureFleetModel,
|
||||
_iconForTest as flightsIcon,
|
||||
_setIconForTest as flightsSetIcon,
|
||||
} from './flights.js';
|
||||
import militaryFlightsLayer, {
|
||||
_setTrackedMilitaryRefreshStateForTest,
|
||||
@@ -186,6 +188,25 @@ function contactBillboard() {
|
||||
};
|
||||
}
|
||||
|
||||
/** A flights contact's icon row, read and written the way these tests read a
|
||||
* billboard: where it is drawn and whether the layer shows it. */
|
||||
function flightIconView(icao24) {
|
||||
return {
|
||||
get position() {
|
||||
return flightsIcon(icao24)?.position;
|
||||
},
|
||||
set position(position) {
|
||||
flightsSetIcon(icao24, { position });
|
||||
},
|
||||
get show() {
|
||||
return flightsIcon(icao24)?.shown === true;
|
||||
},
|
||||
set show(shown) {
|
||||
flightsSetIcon(icao24, { shown });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Records what the driver hands to the scene, so a test can assert that a
|
||||
* failing loader never put a model up. */
|
||||
function recordingModelCollection() {
|
||||
@@ -216,7 +237,7 @@ const LAYERS = [
|
||||
modelCollection,
|
||||
meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround },
|
||||
});
|
||||
return billboard;
|
||||
return flightIconView(icao24);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
||||
ENRICH_AMBIENT_PER_SWEEP,
|
||||
} from './policy.js';
|
||||
import { billboardState } from '../../data/billboardMirror.js';
|
||||
|
||||
export function createEnrichment({
|
||||
flightState,
|
||||
@@ -94,9 +93,7 @@ export function createEnrichment({
|
||||
});
|
||||
if (klass !== meta.klass) {
|
||||
meta.klass = klass;
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (bb)
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
parts.icons._applyIconPresentation(icao24);
|
||||
// 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);
|
||||
@@ -182,8 +179,7 @@ export function createEnrichment({
|
||||
if (
|
||||
flightState._enrichAmbientBudget <= 0 ||
|
||||
!flightState._viewer ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
!flightState._icons?.show
|
||||
)
|
||||
return;
|
||||
try {
|
||||
@@ -196,13 +192,19 @@ export function createEnrichment({
|
||||
camera.upWC,
|
||||
);
|
||||
const cand = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const icons = flightState._icons;
|
||||
const tau = parts.icons._displayTau();
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
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.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||
const { position } = billboardState(bb);
|
||||
if (!position || !occluder.isPointVisible(position)) continue; // beyond the limb
|
||||
Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center);
|
||||
const position = icons.position(
|
||||
row,
|
||||
tau,
|
||||
flightState._scratchModelBS.center,
|
||||
);
|
||||
if (!occluder.isPointVisible(position)) continue; // beyond the limb
|
||||
if (
|
||||
cull.computeVisibility(flightState._scratchModelBS) ===
|
||||
Cesium.Intersect.OUTSIDE
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
||||
import { writeHoldSegment } from '../../data/iconSegment.js';
|
||||
import {
|
||||
addBillboard,
|
||||
billboardState,
|
||||
setBillboard,
|
||||
} from '../../data/billboardMirror.js';
|
||||
aircraftRecessionFactors,
|
||||
aircraftTreatmentAlpha,
|
||||
} from '../../data/aircraftRecession.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
|
||||
export function createEvidence({
|
||||
flightState,
|
||||
@@ -44,17 +44,14 @@ export function createEvidence({
|
||||
/** Replace the real fleet with deterministic explicit-position contacts. */
|
||||
|
||||
function _setFocusEvidenceAircraft(records = []) {
|
||||
if (
|
||||
!FOCUS_EVIDENCE_DEV ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._viewer
|
||||
)
|
||||
if (!FOCUS_EVIDENCE_DEV || !flightState._icons || !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();
|
||||
const icons = flightState._icons;
|
||||
icons.clear();
|
||||
flightState._grounded.clear();
|
||||
flightState._floorPinned.clear();
|
||||
flightState.records.data.clear();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
@@ -86,32 +83,19 @@ export function createEvidence({
|
||||
rawLon: record.longitude ?? null,
|
||||
};
|
||||
flightState.records.data.set(id, meta);
|
||||
flightState._cullPositions.delete(id);
|
||||
flightState._focusEvidenceIds.add(id);
|
||||
const bb = addBillboard(flightState._billboardCollection, {
|
||||
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);
|
||||
parts.icons._addIcon(id);
|
||||
const row = icons.row(id);
|
||||
writeHoldSegment(icons.segments, row, position, meta.true_track);
|
||||
icons.segmentWritten(row);
|
||||
}
|
||||
flightState.feed._count = flightState._billboards.size;
|
||||
flightState.feed._count = icons.count;
|
||||
flightState._lastFleetTickMs = 0;
|
||||
flightState._viewer.scene.requestRender?.();
|
||||
return { ok: true, count: flightState.feed._count };
|
||||
}
|
||||
|
||||
/** Update explicit evidence positions without rebuilding billboards. */
|
||||
/** Update explicit evidence positions without rebuilding rows. */
|
||||
|
||||
function _moveFocusEvidenceAircraft(records = []) {
|
||||
if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 };
|
||||
@@ -122,15 +106,21 @@ export function createEvidence({
|
||||
.toLowerCase();
|
||||
if (!flightState._focusEvidenceIds.has(id)) continue;
|
||||
const position = _focusEvidencePosition(record);
|
||||
const bb = flightState._billboards.get(id);
|
||||
if (!position || !bb) continue;
|
||||
setBillboard(bb, 'position', position);
|
||||
const row = flightState._icons?.row(id) ?? -1;
|
||||
if (!position || row < 0) continue;
|
||||
const meta = flightState.records.data.get(id);
|
||||
if (meta) {
|
||||
if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg;
|
||||
if (Number.isFinite(record.velocityMps))
|
||||
meta.velocity = record.velocityMps;
|
||||
}
|
||||
writeHoldSegment(
|
||||
flightState._icons.segments,
|
||||
row,
|
||||
position,
|
||||
meta?.true_track ?? 0,
|
||||
);
|
||||
flightState._icons.segmentWritten(row);
|
||||
moved += 1;
|
||||
}
|
||||
flightState._lastFleetTickMs = 0;
|
||||
@@ -142,27 +132,42 @@ export function createEvidence({
|
||||
|
||||
function _focusEvidenceSnapshot() {
|
||||
if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return [];
|
||||
const icons = flightState._icons;
|
||||
const { scene, camera } = flightState._viewer;
|
||||
const occluder = horizonOccluder(camera);
|
||||
const cameraHeightM = camera.positionCartographic?.height;
|
||||
return [...flightState._focusEvidenceIds].map((id) => {
|
||||
const bb = flightState._billboards.get(id);
|
||||
const screen = billboardState(bb)?.position
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
flightState._viewer.scene,
|
||||
billboardState(bb).position,
|
||||
)
|
||||
const row = icons?.row(id) ?? -1;
|
||||
const position = row < 0 ? null : parts.icons._iconPosition(id);
|
||||
const cameraDistanceM = position
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
: null;
|
||||
const screen = position
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position)
|
||||
: null;
|
||||
// What the icon shader composes: base scale × limb taper, and base
|
||||
// alpha × max(floor, focus × limb haze).
|
||||
const factors = position
|
||||
? aircraftRecessionFactors({ cameraDistanceM, cameraHeightM })
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
show: billboardState(bb)?.show === true,
|
||||
scale: billboardState(bb)?.scale ?? null,
|
||||
alpha: bb?.color?.alpha ?? null,
|
||||
show:
|
||||
!!position &&
|
||||
icons.shown(row) &&
|
||||
parts.icons._aboveHorizon(row, position, occluder),
|
||||
scale: factors ? icons.scale(row) * factors.scale : null,
|
||||
alpha: position
|
||||
? aircraftTreatmentAlpha({
|
||||
baseAlpha: icons.alpha(row),
|
||||
focusFactor: icons.focus(row),
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
})
|
||||
: null,
|
||||
x: screen?.x ?? null,
|
||||
y: screen?.y ?? null,
|
||||
cameraDistanceM: billboardState(bb)?.position
|
||||
? Cesium.Cartesian3.distance(
|
||||
flightState._viewer.camera.positionWC,
|
||||
billboardState(bb).position,
|
||||
)
|
||||
: null,
|
||||
cameraDistanceM,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
courseBetweenCartesians,
|
||||
courseSlewCapDps,
|
||||
norm180,
|
||||
speedRamp,
|
||||
staleCoastLimitSeconds,
|
||||
} from '../../data/motionModel.js';
|
||||
import {
|
||||
createIconSet,
|
||||
ICON_CULL_LIFT,
|
||||
ICON_RECEDES,
|
||||
ICON_ROTATES,
|
||||
} from '../../data/iconSet.js';
|
||||
import {
|
||||
segmentCourse,
|
||||
segmentTargetCourse,
|
||||
writeArcSegment,
|
||||
writeCourseGlide,
|
||||
writeHoldSegment,
|
||||
writeLerpSegment,
|
||||
} from '../../data/iconSegment.js';
|
||||
import { IconSetPrimitive } from '../../data/iconSetPrimitive.js';
|
||||
import { iconAtlas } from '../../data/iconAtlas.js';
|
||||
import {
|
||||
aircraftIcon,
|
||||
FLEET_ICON_PX,
|
||||
TRACKED_ICON_PX,
|
||||
} from '../../data/aircraftIcons.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import {
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COURSE_MAX_DPS,
|
||||
FLOOR_FAR_EVERY_TICKS,
|
||||
FLOOR_NEAR_M,
|
||||
RENDER_DELAY_SEC,
|
||||
} from './policy.js';
|
||||
|
||||
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
|
||||
const COCKPIT_DOT_PX = 16;
|
||||
/** Pinned grounded rows rewrite only past this displayed move (m²) or turn (°). */
|
||||
const PIN_MOVE_SQ_M = 1;
|
||||
const PIN_TURN_DEG = 0.1;
|
||||
|
||||
/**
|
||||
* The flights layer's contacts as one icon set: each row's motion segment
|
||||
* mirrors `_deadReckon`'s regime for the delayed display time, so the GPU
|
||||
* moves every untracked contact between polls with no per-frame CPU pass.
|
||||
*/
|
||||
export function createIcons({ flightState, services, parts }) {
|
||||
const { focusPassIsNeeded } = services.focus;
|
||||
const scratchTime = new Cesium.JulianDate();
|
||||
const scratchPosition = new Cesium.Cartesian3();
|
||||
const scratchCull = new Cesium.Cartesian3();
|
||||
const scratchScaleByDistance = new Cesium.NearFarScalar();
|
||||
let normalScaleByDistance = null;
|
||||
let cockpitScaleByDistance = null;
|
||||
|
||||
/** The delayed instant every untracked contact is drawn at. */
|
||||
function _displayTime(result = scratchTime) {
|
||||
return Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(result),
|
||||
-RENDER_DELAY_SEC,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function _displayTau() {
|
||||
return flightState._icons.seconds(_displayTime());
|
||||
}
|
||||
|
||||
/** Create the set and its primitive for one layer lifetime. */
|
||||
function _createIcons(viewer) {
|
||||
flightState._icons = createIconSet({ epoch: _displayTime() });
|
||||
flightState._iconPrimitive = new IconSetPrimitive({
|
||||
set: flightState._icons,
|
||||
renderTime: () => _displayTime(flightState._scratchIconRenderTime),
|
||||
});
|
||||
viewer.scene.primitives.add(flightState._iconPrimitive);
|
||||
return flightState._iconPrimitive;
|
||||
}
|
||||
|
||||
/** Where a contact is drawn right now, or null when it has no row. */
|
||||
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
|
||||
const set = flightState._icons;
|
||||
const row = set.row(icao24);
|
||||
return row < 0 ? null : set.position(row, _displayTau(), result);
|
||||
}
|
||||
|
||||
/** The nose direction a contact is drawn with right now. */
|
||||
function _iconCourse(icao24) {
|
||||
const set = flightState._icons;
|
||||
const row = set.row(icao24);
|
||||
return row < 0 ? null : set.course(row, _displayTau());
|
||||
}
|
||||
|
||||
/** Whether the layer draws this contact's icon (not tracked, not model-owned). */
|
||||
function _iconShown(icao24) {
|
||||
const set = flightState._icons;
|
||||
const row = set.row(icao24);
|
||||
return row >= 0 && set.shown(row);
|
||||
}
|
||||
|
||||
/** The camera's horizon test, or null when there is no placed camera. */
|
||||
function _occluder() {
|
||||
const camera = flightState._viewer?.camera;
|
||||
return camera?.positionWC ? horizonOccluder(camera) : null;
|
||||
}
|
||||
|
||||
/** Whether a drawn row at `position` clears the camera's horizon, judged
|
||||
* exactly as the icon shader judges it. No camera, no horizon. */
|
||||
function _aboveHorizon(row, position, occluder) {
|
||||
return (
|
||||
!occluder ||
|
||||
occluder.isPointVisible(
|
||||
flightState._icons.cullPoint(row, position, scratchCull),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the segment `_deadReckon` would follow from now on: interpolation
|
||||
* between the bracketing fixes, the warm-up arc back from the oldest fix, or
|
||||
* the coast arc past the newest. The displayed nose glides from where it is
|
||||
* onto the new course at the fleet's slew cap; a hovering contact keeps its
|
||||
* nose. The row expires when the regime would change.
|
||||
* @param {string} icao24
|
||||
* @param {{fromCourseDeg?: number|null}} [options] Nose to glide from when
|
||||
* the row's own segment is not what was on screen (tracking handoff).
|
||||
*/
|
||||
function _planIcon(icao24, { fromCourseDeg } = {}) {
|
||||
const set = flightState._icons;
|
||||
const row = set.row(icao24);
|
||||
if (row < 0) return;
|
||||
if (flightState._focusEvidenceIds.has(icao24)) return;
|
||||
if (flightState._floorPinned.has(icao24)) return;
|
||||
const history = flightState._positionHistory.get(icao24);
|
||||
if (!history?.length) return;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const renderTime = _displayTime();
|
||||
const tau = set.seconds(renderTime);
|
||||
const seg = set.segments;
|
||||
const shownCourse =
|
||||
fromCourseDeg ??
|
||||
(set.expires(row) === -Infinity ? null : set.course(row, tau));
|
||||
|
||||
const regime = parts.motion._motionRegime(history, info, renderTime);
|
||||
let speed;
|
||||
let expiresAt = Infinity;
|
||||
if (regime.kind === 'between') {
|
||||
const { a, b, span, trackFrom, trackTo, chordCourse } = regime;
|
||||
speed = regime.speed;
|
||||
const blend = chordCourse == null ? 0 : regime.blend;
|
||||
const t0 = set.seconds(a.time);
|
||||
if (span > 0) {
|
||||
writeLerpSegment(seg, row, {
|
||||
from: a.position,
|
||||
to: b.position,
|
||||
t0,
|
||||
t1: set.seconds(b.time),
|
||||
course0Deg:
|
||||
trackFrom + blend * norm180((chordCourse ?? 0) - trackFrom),
|
||||
courseSlopeDeg: norm180(trackTo - trackFrom) * (1 - blend),
|
||||
});
|
||||
} else {
|
||||
writeHoldSegment(
|
||||
seg,
|
||||
row,
|
||||
b.position,
|
||||
trackTo + blend * norm180((chordCourse ?? 0) - trackTo),
|
||||
);
|
||||
}
|
||||
expiresAt = set.seconds(b.time);
|
||||
} else {
|
||||
const { fix, heading } = regime;
|
||||
speed = regime.speed;
|
||||
const t0 = set.seconds(fix.time);
|
||||
if (speed === 0) {
|
||||
writeHoldSegment(seg, row, fix.position, heading);
|
||||
} else {
|
||||
writeArcSegment(seg, row, {
|
||||
fix: fix.position,
|
||||
t0,
|
||||
minDt: regime.minDt,
|
||||
maxDt: regime.maxDt,
|
||||
speedMps: speed,
|
||||
trackDeg: heading,
|
||||
turnRateDps: regime.turnRateDps,
|
||||
});
|
||||
}
|
||||
// A warm-up's arc ends at its fix; a coast's runs to its limit.
|
||||
if (regime.maxDt === 0) expiresAt = t0;
|
||||
}
|
||||
// Below hover speed the nose holds what is on screen (or, for a new row,
|
||||
// where this segment starts); above it, it glides on at the slew cap.
|
||||
const hold = speed < COURSE_HOLD_SPEED_MPS;
|
||||
if (hold || shownCourse != null) {
|
||||
writeCourseGlide(
|
||||
seg,
|
||||
row,
|
||||
shownCourse ?? segmentCourse(seg, row, tau),
|
||||
tau,
|
||||
hold ? 0 : courseSlewCapDps(speed, COURSE_MAX_DPS),
|
||||
);
|
||||
}
|
||||
set.segmentWritten(row, expiresAt);
|
||||
}
|
||||
|
||||
/** Re-plan every row whose regime ended before `tau`. */
|
||||
function _replanExpired(tau) {
|
||||
const set = flightState._icons;
|
||||
const due = [];
|
||||
for (let row = 0; row < set.count; row++)
|
||||
if (set.expires(row) <= tau) due.push(set.idAt(row));
|
||||
for (const icao24 of due) _planIcon(icao24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grounded contacts stand on the floor under where they are DRAWN, which the
|
||||
* GPU cannot sample. While the floor lifts one, the CPU pins its row to a
|
||||
* hold at the floored dead-reckoned position each tick (rewritten only when
|
||||
* it moved); once nothing lifts it the row goes back to its planned segment.
|
||||
*/
|
||||
function _floorGroundedIcons(renderTime, tau, nowMs, cameraPosition) {
|
||||
const set = flightState._icons;
|
||||
// Past FLOOR_NEAR_M a taxiing contact moves well under a pixel per tick,
|
||||
// so far rows take turns: each refreshes every FLOOR_FAR_EVERY_TICKS. Their
|
||||
// eases onto a lower floor advance per call, so they run that much slower
|
||||
// out there too — sub-pixel at that range, and full rate once near.
|
||||
const turn = flightState._floorTick++ % FLOOR_FAR_EVERY_TICKS;
|
||||
const nearSq = FLOOR_NEAR_M * FLOOR_NEAR_M;
|
||||
for (const icao24 of flightState._grounded) {
|
||||
if (icao24 === flightState._trackedIcao) continue;
|
||||
const row = set.row(icao24);
|
||||
if (row < 0) continue;
|
||||
if (
|
||||
row % FLOOR_FAR_EVERY_TICKS !== turn &&
|
||||
cameraPosition &&
|
||||
Cesium.Cartesian3.distanceSquared(
|
||||
cameraPosition,
|
||||
set.position(row, tau, scratchPosition),
|
||||
) > nearSq
|
||||
)
|
||||
continue;
|
||||
_floorIcon(icao24, renderTime, tau, nowMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** One grounded contact's floor pass (see `_floorGroundedIcons`). */
|
||||
function _floorIcon(icao24, renderTime, tau, nowMs) {
|
||||
const set = flightState._icons;
|
||||
const row = set.row(icao24);
|
||||
if (row < 0) return;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const raw = parts.motion._deadReckon(
|
||||
icao24,
|
||||
flightState._scratchFleetPos,
|
||||
renderTime,
|
||||
);
|
||||
const rawCourse =
|
||||
flightState._drCourseDeg ?? (info && info.true_track) ?? 0;
|
||||
const hold = flightState._drCourseHold;
|
||||
const speed = flightState._drSpeedMps ?? info?.velocity ?? NaN;
|
||||
const display = parts.motion._floorGroundedDisplayPosition(
|
||||
icao24,
|
||||
info,
|
||||
raw,
|
||||
parts.rendering._modelOwnsVisual(icao24),
|
||||
nowMs,
|
||||
);
|
||||
if (!display || display === raw) {
|
||||
if (flightState._floorPinned.delete(icao24))
|
||||
_planIcon(icao24, { fromCourseDeg: set.course(row, tau) });
|
||||
return;
|
||||
}
|
||||
const planned = set.expires(row) !== -Infinity;
|
||||
const prev = planned ? set.course(row, tau) : null;
|
||||
// The pin holds the position; the GPU glides the course onto the target,
|
||||
// so the rate stays exact however seldom the pin is rewritten.
|
||||
const target = hold && prev != null ? prev : rawCourse;
|
||||
if (
|
||||
flightState._floorPinned.has(icao24) &&
|
||||
Cesium.Cartesian3.distanceSquared(
|
||||
display,
|
||||
set.position(row, tau, scratchPosition),
|
||||
) <= PIN_MOVE_SQ_M &&
|
||||
Math.abs(norm180(target - segmentTargetCourse(set.segments, row, tau))) <=
|
||||
PIN_TURN_DEG
|
||||
)
|
||||
return;
|
||||
flightState._floorPinned.add(icao24);
|
||||
writeHoldSegment(set.segments, row, display, target);
|
||||
if (prev != null)
|
||||
writeCourseGlide(
|
||||
set.segments,
|
||||
row,
|
||||
prev,
|
||||
tau,
|
||||
courseSlewCapDps(speed, COURSE_MAX_DPS),
|
||||
);
|
||||
set.segmentWritten(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance focus de-emphasis for every drawn row (and every row a fleet model
|
||||
* draws for, whose alpha reads it), only while a focus target exists or some
|
||||
* row is still relaxing back to full emphasis.
|
||||
*/
|
||||
function _focusIcons(tau, scene, camera, nowMs, target) {
|
||||
if (!focusPassIsNeeded(target, flightState._iconFocusActive)) return;
|
||||
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
|
||||
services.focus;
|
||||
const set = flightState._icons;
|
||||
let active = 0;
|
||||
for (let row = 0; row < set.count; row++) {
|
||||
if (!set.shown(row) && !flightState._models.has(set.idAt(row))) continue;
|
||||
const position = set.position(row, tau, scratchPosition);
|
||||
const distanceScale = nearFarScalarValueAtDistance(
|
||||
set.scaleByDistance(row, scratchScaleByDistance),
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
);
|
||||
const half = set.scale(row) * distanceScale * 0.5;
|
||||
const focus = advanceProjectedSpriteFocus(
|
||||
set.metaAt(row),
|
||||
position,
|
||||
scene,
|
||||
camera,
|
||||
nowMs,
|
||||
target,
|
||||
undefined,
|
||||
set.width(row) * half,
|
||||
set.height(row) * half,
|
||||
);
|
||||
set.setFocus(row, focus.factor);
|
||||
if (focus.active || focus.transitioning) active++;
|
||||
}
|
||||
flightState._iconFocusActive = active;
|
||||
}
|
||||
|
||||
/** Image, size, scale, tint and freshness for one row under the current
|
||||
* normal/cockpit contract. Motion and rotation stay with the segment. */
|
||||
function _applyIconPresentation(icao24) {
|
||||
const set = flightState._icons;
|
||||
const row = set.row(icao24);
|
||||
if (row < 0) return;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const tracked = icao24 === flightState._trackedIcao;
|
||||
const cockpitDot =
|
||||
flightState._cockpitContactMode &&
|
||||
!tracked &&
|
||||
!flightState._cockpitNearContacts.has(icao24);
|
||||
if (cockpitDot) {
|
||||
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
|
||||
set.setImage(row, dot, dot);
|
||||
set.setSize(row, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CONTACT_SIZE_PX);
|
||||
set.setScale(row, 1);
|
||||
cockpitScaleByDistance ??=
|
||||
parts.rendering._cockpitBillboardScaleByDistance();
|
||||
set.setScaleByDistance(row, cockpitScaleByDistance);
|
||||
set.setColor(row, parts.rendering._cockpitContactColor(icao24, info));
|
||||
set.setFlag(row, ICON_ROTATES, false);
|
||||
} else {
|
||||
set.setImage(
|
||||
row,
|
||||
iconAtlas.slot(
|
||||
aircraftIcon(parts.rendering._iconKind(icao24, info?.klass)),
|
||||
FLEET_ICON_PX,
|
||||
),
|
||||
iconAtlas.slot(
|
||||
aircraftIcon(
|
||||
parts.rendering._iconKind(icao24, info?.klass),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
);
|
||||
const size = tracked ? 24 : 20;
|
||||
set.setSize(row, size, size);
|
||||
set.setScale(
|
||||
row,
|
||||
parts.rendering._fleetBillboardScale(icao24, info?.klass),
|
||||
);
|
||||
normalScaleByDistance ??=
|
||||
parts.rendering._normalBillboardScaleByDistance();
|
||||
set.setScaleByDistance(row, normalScaleByDistance);
|
||||
set.setColor(row, parts.rendering._fleetBillboardColor(icao24, info));
|
||||
set.setFlag(row, ICON_ROTATES, true);
|
||||
}
|
||||
set.setFlag(row, ICON_RECEDES, true);
|
||||
set.setFlag(
|
||||
row,
|
||||
ICON_CULL_LIFT,
|
||||
Number.isFinite(info?.renderAltitudeM) && info.renderAltitudeM < 10,
|
||||
);
|
||||
set.setAlpha(row, flightState.records.missingPolls.get(icao24) ? 0.45 : 1);
|
||||
}
|
||||
|
||||
/** Add a row for a new contact, hidden while it is the tracked one. */
|
||||
function _addIcon(icao24) {
|
||||
const set = flightState._icons;
|
||||
const row = set.add(icao24, { layer: 'flights', id: icao24 });
|
||||
_applyIconPresentation(icao24);
|
||||
_settleIcon(icao24);
|
||||
set.setShown(row, icao24 !== flightState._trackedIcao);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a row afresh, dropping any floor pin (a pin stops at the last floor
|
||||
* pass, which skips a tracked contact), and let a dimmed row relax. A grounded contact goes straight onto
|
||||
* the floor under where it is drawn, not its fix's floor until the next fleet
|
||||
* tick raises it.
|
||||
* @param {string} icao24
|
||||
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
|
||||
*/
|
||||
function _settleIcon(icao24, options) {
|
||||
const set = flightState._icons;
|
||||
flightState._floorPinned.delete(icao24);
|
||||
// A row that sat out the focus pass (tracked) may come back dimmed after
|
||||
// the pass went idle; count it so the pass runs and relaxes it.
|
||||
if (set.focus(set.row(icao24)) < 1) flightState._iconFocusActive += 1;
|
||||
_planIcon(icao24, options);
|
||||
if (flightState._grounded.has(icao24)) {
|
||||
const renderTime = _displayTime();
|
||||
_floorIcon(icao24, renderTime, set.seconds(renderTime), Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a contact and everything the layer keeps for it. */
|
||||
function _forgetContact(icao24) {
|
||||
flightState._icons?.remove(icao24);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
flightState.records.forget(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
flightState._grounded.delete(icao24);
|
||||
flightState._floorPinned.delete(icao24);
|
||||
}
|
||||
|
||||
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
|
||||
function _iconIds() {
|
||||
const set = flightState._icons;
|
||||
const ids = new Array(set?.count ?? 0);
|
||||
for (let row = 0; row < ids.length; row++) ids[row] = set.idAt(row);
|
||||
return ids;
|
||||
}
|
||||
|
||||
return {
|
||||
_displayTime,
|
||||
_displayTau,
|
||||
_createIcons,
|
||||
_iconPosition,
|
||||
_iconCourse,
|
||||
_iconShown,
|
||||
_occluder,
|
||||
_aboveHorizon,
|
||||
_planIcon,
|
||||
_settleIcon,
|
||||
_replanExpired,
|
||||
_floorGroundedIcons,
|
||||
_focusIcons,
|
||||
_applyIconPresentation,
|
||||
_addIcon,
|
||||
_forgetContact,
|
||||
_iconIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createIcons } from './icons.js';
|
||||
import { createIconSet } from '../../data/iconSet.js';
|
||||
import { writeHoldSegment } from '../../data/iconSegment.js';
|
||||
|
||||
function services(focus = {}) {
|
||||
return {
|
||||
focus: { focusPassIsNeeded: () => true, ...focus },
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {}, forget() {} }) },
|
||||
};
|
||||
}
|
||||
|
||||
function setup({ focus, motion = {}, rendering = {} } = {}) {
|
||||
const svc = services(focus);
|
||||
const flightState = createFlightState({ services: svc });
|
||||
flightState._icons = createIconSet();
|
||||
const parts = {
|
||||
motion,
|
||||
rendering: { _modelOwnsVisual: () => false, ...rendering },
|
||||
};
|
||||
const icons = createIcons({ flightState, services: svc, parts });
|
||||
return { flightState, icons, set: flightState._icons };
|
||||
}
|
||||
|
||||
function hold(set, id, position, course = 0) {
|
||||
const row = set.add(id, { layer: 'flights', id });
|
||||
writeHoldSegment(set.segments, row, position, course);
|
||||
set.segmentWritten(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
const at = (lon, h = 1000) => Cesium.Cartesian3.fromDegrees(lon, 30, h);
|
||||
|
||||
test('focus advances for drawn rows and for rows a fleet model draws for', () => {
|
||||
const advanced = [];
|
||||
const { flightState, icons, set } = setup({
|
||||
focus: {
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus(meta) {
|
||||
advanced.push(meta.id);
|
||||
return { factor: 0.4, active: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
set.setShown(hold(set, 'drawn', at(0)), true);
|
||||
const modeled = hold(set, 'modeled', at(0.1));
|
||||
hold(set, 'hidden', at(0.2));
|
||||
flightState._models.set('modeled', {});
|
||||
|
||||
icons._focusIcons(0, {}, { positionWC: at(0, 50_000) }, 0, {});
|
||||
assert.deepEqual(advanced, ['drawn', 'modeled']);
|
||||
assert.ok(
|
||||
Math.abs(set.focus(modeled) - 0.4) < 1e-6,
|
||||
"the model's alpha reads it",
|
||||
);
|
||||
assert.equal(flightState._iconFocusActive, 2);
|
||||
});
|
||||
|
||||
test('settling a pinned grounded contact re-floors it where it is now, nose kept', () => {
|
||||
const taxied = at(0.01, 100);
|
||||
const floored = at(0.01, 131.5);
|
||||
const { flightState, icons, set } = setup({
|
||||
motion: {
|
||||
_motionRegime: () => ({
|
||||
kind: 'fix',
|
||||
fix: { position: taxied, time: Cesium.JulianDate.now() },
|
||||
speed: 0,
|
||||
heading: 200,
|
||||
turnRateDps: 0,
|
||||
minDt: -60,
|
||||
maxDt: 60,
|
||||
}),
|
||||
_deadReckon(icao24, result) {
|
||||
flightState._drCourseDeg = 200;
|
||||
flightState._drSpeedMps = 0;
|
||||
flightState._drCourseHold = true;
|
||||
return Cesium.Cartesian3.clone(taxied, result);
|
||||
},
|
||||
_floorGroundedDisplayPosition: () => floored,
|
||||
},
|
||||
});
|
||||
// Pinned before it was tracked, 1 km back, facing 10°.
|
||||
const row = hold(set, 'g', at(0, 131.5), 10);
|
||||
flightState._floorPinned.add('g');
|
||||
flightState._grounded.add('g');
|
||||
flightState.records.data.set('g', { onGround: true });
|
||||
flightState._positionHistory.set('g', [{}]);
|
||||
|
||||
icons._settleIcon('g', { fromCourseDeg: 123 });
|
||||
const tau = icons._displayTau();
|
||||
assert.ok(Cesium.Cartesian3.distance(set.position(row, tau), floored) < 0.01);
|
||||
assert.ok(Math.abs(set.course(row, tau) - 123) < 1e-3, 'tracked nose kept');
|
||||
assert.ok(flightState._floorPinned.has('g'), 'pinned on the new floor');
|
||||
});
|
||||
|
||||
test('a contact that comes back dimmed re-arms the idle focus pass', () => {
|
||||
const advanced = [];
|
||||
const { flightState, icons, set } = setup({
|
||||
focus: {
|
||||
focusPassIsNeeded: (target, active) => target != null || active > 0,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus(meta) {
|
||||
advanced.push(meta.id);
|
||||
return { factor: 1, active: false, transitioning: false };
|
||||
},
|
||||
},
|
||||
motion: { _motionRegime: () => null },
|
||||
});
|
||||
const row = hold(set, 'was-tracked', at(0));
|
||||
set.setFocus(row, 0.2);
|
||||
flightState._iconFocusActive = 0;
|
||||
icons._settleIcon('was-tracked');
|
||||
set.setShown(row, true);
|
||||
icons._focusIcons(0, {}, { positionWC: at(0, 50_000) }, 0, null);
|
||||
assert.deepEqual(advanced, ['was-tracked']);
|
||||
assert.equal(set.focus(row), 1);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIcons } from './icons.js';
|
||||
import { createMotion } from './motion.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { createController } from './controller.js';
|
||||
@@ -21,6 +22,7 @@ export function createCivilFlightLayer({
|
||||
const layer = {};
|
||||
const context = { flightState, services, parts, layer, resolveAsset };
|
||||
parts.rendering = createRendering(context);
|
||||
parts.icons = createIcons(context);
|
||||
parts.motion = createMotion(context);
|
||||
parts.tracking = createTracking(context);
|
||||
parts.controller = createController(context);
|
||||
@@ -38,6 +40,7 @@ export function createCivilFlightLayer({
|
||||
rendering: parts.rendering,
|
||||
tracking: parts.tracking,
|
||||
motion: parts.motion,
|
||||
icons: parts.icons,
|
||||
enrichment: parts.enrichment,
|
||||
queries: parts.queries,
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createLifecycle({
|
||||
|
||||
/**
|
||||
* Initialize the flights layer.
|
||||
* Creates the BillboardCollection, resets all state, and installs the
|
||||
* Creates the icon set and its primitive, resets all state, and installs the
|
||||
* click-to-track handler on the scene canvas.
|
||||
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
|
||||
*/
|
||||
@@ -48,9 +48,7 @@ export function createLifecycle({
|
||||
clearFocusTarget('flights');
|
||||
flightState._focusEvidenceIds.clear();
|
||||
flightState._viewer = viewer;
|
||||
flightState._billboardCollection = new Cesium.BillboardCollection();
|
||||
viewer.scene.primitives.add(flightState._billboardCollection);
|
||||
registerSpriteCollection('flights', flightState._billboardCollection);
|
||||
registerSpriteCollection('flights', parts.icons._createIcons(viewer));
|
||||
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
|
||||
@@ -78,8 +76,9 @@ export function createLifecycle({
|
||||
/* tracked plane just stays a billboard a beat longer */
|
||||
});
|
||||
}
|
||||
flightState._billboards = new Map();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._grounded.clear();
|
||||
flightState._floorPinned.clear();
|
||||
flightState._iconFocusActive = 0;
|
||||
flightState._detectionObjects = new Map();
|
||||
flightState.records.data = new Map();
|
||||
flightState._positionHistory = new Map();
|
||||
@@ -126,16 +125,15 @@ export function createLifecycle({
|
||||
|
||||
restoreSpriteOrder(viewer);
|
||||
|
||||
console.log('[Data:Flights] Initialized with billboard icons');
|
||||
console.log('[Data:Flights] Initialized with GPU icons');
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the billboard collection and re-install the click handler.
|
||||
* Show the icons and re-install the click handler.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
enable(viewer) {
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = true;
|
||||
if (flightState._icons) flightState._icons.show = true;
|
||||
holdContinuousRender('flights'); // per-frame animator (perf wave 2)
|
||||
if (flightState._modelCollection)
|
||||
flightState._modelCollection.show = true;
|
||||
@@ -156,11 +154,10 @@ export function createLifecycle({
|
||||
});
|
||||
}
|
||||
parts.tracking._installClickHandler(viewer);
|
||||
registerPickOwner('flights', (pickedId) =>
|
||||
flightState._billboards.has(pickedId),
|
||||
registerPickOwner(
|
||||
'flights',
|
||||
(pickedId) => flightState._icons?.has(pickedId) === true,
|
||||
);
|
||||
// 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,
|
||||
@@ -173,18 +170,11 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
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.
|
||||
// A settled camera move (voice fly-to, fast pan release) runs the fleet
|
||||
// pass on the very next preRender rather than up to FLEET_DR_INTERVAL_MS
|
||||
// later, so the model handoff and focus land with the camera.
|
||||
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
() => {
|
||||
flightState._lastCamPoseSig = '';
|
||||
flightState._lastFleetTickMs = 0;
|
||||
},
|
||||
);
|
||||
@@ -193,15 +183,14 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide all flight billboards and tear down click/keyboard handlers.
|
||||
* Hide all flight icons 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;
|
||||
if (flightState._icons) flightState._icons.show = false;
|
||||
releaseContinuousRender('flights');
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._modelCollection)
|
||||
@@ -282,10 +271,12 @@ export function createLifecycle({
|
||||
flightState._moveEndRemove = null;
|
||||
}
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._billboardCollection) {
|
||||
viewer.scene.primitives.remove(flightState._billboardCollection);
|
||||
flightState._billboardCollection = null;
|
||||
if (flightState._iconPrimitive) {
|
||||
viewer.scene.primitives.remove(flightState._iconPrimitive); // removing destroys it
|
||||
flightState._iconPrimitive = null;
|
||||
}
|
||||
flightState._icons?.clear();
|
||||
flightState._icons = null;
|
||||
if (flightState._modelCollection) {
|
||||
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
||||
flightState._modelCollection = null;
|
||||
@@ -302,8 +293,9 @@ export function createLifecycle({
|
||||
flightState._preloadModel = null;
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._grounded.clear();
|
||||
flightState._floorPinned.clear();
|
||||
flightState._iconFocusActive = 0;
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.records.data.clear();
|
||||
flightState._positionHistory.clear();
|
||||
|
||||
+140
-102
@@ -35,10 +35,23 @@ import {
|
||||
DISPLAY_CORRIDOR_CELL_BUDGET,
|
||||
DISPLAY_CORRIDOR_FAIR_SHARE,
|
||||
} from './policy.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
import { modelState } from '../../data/modelMirror.js';
|
||||
import { readEntityRecord } from '../../data/entityRecords.js';
|
||||
|
||||
/** A fix's own ground speed, else the record's. */
|
||||
function fixSpeedMps(fix, info) {
|
||||
return Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info.velocity) || 0;
|
||||
}
|
||||
|
||||
/** A fix's own track, else the record's. */
|
||||
function fixTrackDeg(fix, info) {
|
||||
return Number.isFinite(fix.track)
|
||||
? fix.track
|
||||
: (info && info.true_track) || 0;
|
||||
}
|
||||
|
||||
export function createMotion({
|
||||
flightState,
|
||||
services,
|
||||
@@ -66,6 +79,23 @@ export function createMotion({
|
||||
// Fixes leave the cache with their history; corrected positions invalidate
|
||||
// the local frame even when the existing Cartesian is mutated in place.
|
||||
const fixFrames = new WeakMap();
|
||||
/** `_motionRegime`'s reused result. */
|
||||
const regime = {
|
||||
kind: 'fix',
|
||||
a: null,
|
||||
b: null,
|
||||
span: 0,
|
||||
speed: 0,
|
||||
trackFrom: 0,
|
||||
trackTo: 0,
|
||||
chordCourse: null,
|
||||
blend: 0,
|
||||
fix: null,
|
||||
heading: 0,
|
||||
turnRateDps: 0,
|
||||
minDt: 0,
|
||||
maxDt: 0,
|
||||
};
|
||||
|
||||
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
|
||||
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
||||
@@ -81,7 +111,7 @@ export function createMotion({
|
||||
* coasting on its final meta — if that fix predated the floor warm, it sat
|
||||
* frozen underground forever. Runs once per poll over the (bounded) flight
|
||||
* map; touches only grounded, feed-absent, demonstrably-below-floor
|
||||
* contacts, and patches the stored fix + billboard in place (zero-velocity
|
||||
* contacts, and patches the stored fix + icon in place (zero-velocity
|
||||
* DR renders the patched fix verbatim).
|
||||
* @param {Set<string>} currentIcaos - Contacts present in THIS poll (already
|
||||
* floored by the live path — skipped here).
|
||||
@@ -101,7 +131,6 @@ export function createMotion({
|
||||
)
|
||||
continue;
|
||||
info.renderAltitudeM = lifted;
|
||||
flightState._cullPositions.delete(icao24); // above the ellipsoid now (or floors say otherwise next poll)
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
info.rawLon,
|
||||
info.rawLat,
|
||||
@@ -111,8 +140,9 @@ export function createMotion({
|
||||
const newest = history?.[history.length - 1];
|
||||
if (newest)
|
||||
newest.position = Cesium.Cartesian3.clone(position, newest.position);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (bb) setBillboard(bb, 'position', position);
|
||||
// Above the ellipsoid now (or floors say otherwise next poll).
|
||||
parts.icons._applyIconPresentation(icao24);
|
||||
parts.icons._planIcon(icao24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +164,7 @@ export function createMotion({
|
||||
function _deadReckon(icao24, result, sampledRenderTime) {
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
|
||||
const position = billboardState(
|
||||
flightState._billboards.get(icao24),
|
||||
)?.position;
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
flightState._drCourseDeg = info?.true_track || 0;
|
||||
flightState._drSpeedMps = info?.velocity || 0;
|
||||
flightState._drCourseHold =
|
||||
@@ -166,100 +194,113 @@ export function createMotion({
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
|
||||
const regime = _motionRegime(history, info, renderTime);
|
||||
if (regime.kind === 'between') {
|
||||
const { a, b, span } = regime;
|
||||
const t =
|
||||
span > 0
|
||||
? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span
|
||||
: 1.0;
|
||||
const trackCourse = lerpAngleDeg(regime.trackFrom, regime.trackTo, t);
|
||||
flightState._drCourseDeg =
|
||||
regime.chordCourse != null
|
||||
? lerpAngleDeg(trackCourse, regime.chordCourse, regime.blend)
|
||||
: trackCourse;
|
||||
flightState._drSpeedMps = regime.speed;
|
||||
flightState._drCourseHold = regime.speed < COURSE_HOLD_SPEED_MPS;
|
||||
flightState._drExtrapolating = false;
|
||||
return Cesium.Cartesian3.lerp(a.position, b.position, t, out);
|
||||
}
|
||||
const dt = Cesium.Math.clamp(
|
||||
Cesium.JulianDate.secondsDifference(renderTime, regime.fix.time),
|
||||
regime.minDt,
|
||||
regime.maxDt,
|
||||
);
|
||||
return _extrapolateFix(regime.fix, info, dt, out, regime.turnRateDps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which motion a contact's history gives at `renderTime` — the one choice
|
||||
* `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from.
|
||||
*
|
||||
* - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation
|
||||
* error and no snap-back. The course of the DISPLAYED motion blends the
|
||||
* time-interpolated reported track toward the chord by `blend`: the chord
|
||||
* is only trustworthy when the segment covers real ground (at hover its
|
||||
* direction is GPS jitter; on a slow tight turn it STEPS the whole
|
||||
* per-segment turn at each boundary), and helicopters never use it
|
||||
* (rotorcraft chords are noise-dominated at their typical speeds).
|
||||
* - `fix`: extrapolate `fix` by the elapsed time clamped to [minDt, maxDt].
|
||||
* Warm-up (renderTime predates ALL history: freshly seen or just tracked,
|
||||
* before RENDER_DELAY_SEC of history exists) runs the OLDEST fix BACKWARD,
|
||||
* preserving the 30s-behind invariant, so as history fills the icon glides
|
||||
* FORWARD into interpolation with no freeze and no backward snap (holding
|
||||
* the oldest fix froze the icon; extrapolating the newest to wall-clock
|
||||
* made it jump back a poll interval when interpolation took over).
|
||||
* Otherwise the newest fix coasts. OpenSky can still be receiving fresh
|
||||
* contact messages for that aircraft; freezing 60 s after the position
|
||||
* produced a stop → catch-up → stop cadence, so it coasts through the latest
|
||||
* real contact plus a bounded grace, with an absolute cap so a stale cached
|
||||
* feed cannot drift forever.
|
||||
*
|
||||
* The returned object is a reused scratch: read it before the next call.
|
||||
* @param {Array<object>} history Non-empty, oldest first.
|
||||
* @param {object|undefined} info The contact's record.
|
||||
* @param {Cesium.JulianDate} renderTime
|
||||
*/
|
||||
function _motionRegime(history, info, renderTime) {
|
||||
for (let i = history.length - 1; i >= 1; i--) {
|
||||
const a = history[i - 1];
|
||||
const b = history[i];
|
||||
if (
|
||||
Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) &&
|
||||
Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||
) {
|
||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||
const t =
|
||||
span > 0
|
||||
? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span
|
||||
: 1.0;
|
||||
// Course of the DISPLAYED motion. The chord is only trustworthy when the
|
||||
// segment covers real ground (at hover its direction is GPS jitter; on a
|
||||
// slow tight turn it STEPS the whole per-segment turn at each boundary),
|
||||
// so it is blended against the reported per-fix track by displayed
|
||||
// ground speed — and the track is TIME-INTERPOLATED between the fixes so
|
||||
// a slow turner's nose advances continuously through the segment instead
|
||||
// of snapping once per poll. Helicopters always use the reported track
|
||||
// (rotorcraft chords are noise-dominated at their typical speeds).
|
||||
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||
const segSpeed =
|
||||
span > 0 ? chordLenM / span : (info && info.velocity) || 0;
|
||||
const fallbackTrack = (info && info.true_track) || 0;
|
||||
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||
const trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||
const trackCourse = lerpAngleDeg(trackFrom, trackTo, t);
|
||||
const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed);
|
||||
const chordCourse =
|
||||
w > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||
flightState._drCourseDeg =
|
||||
chordCourse != null
|
||||
? lerpAngleDeg(trackCourse, chordCourse, w)
|
||||
: trackCourse;
|
||||
flightState._drSpeedMps = segSpeed;
|
||||
flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS;
|
||||
flightState._drExtrapolating = false;
|
||||
return Cesium.Cartesian3.lerp(a.position, b.position, t, out);
|
||||
}
|
||||
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
|
||||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||
)
|
||||
continue;
|
||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||
const speed = span > 0 ? chordLenM / span : (info && info.velocity) || 0;
|
||||
const fallbackTrack = (info && info.true_track) || 0;
|
||||
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
|
||||
regime.kind = 'between';
|
||||
regime.a = a;
|
||||
regime.b = b;
|
||||
regime.span = span;
|
||||
regime.speed = speed;
|
||||
regime.trackFrom = trackFrom;
|
||||
regime.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||
regime.chordCourse =
|
||||
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||
regime.blend = blend;
|
||||
return regime;
|
||||
}
|
||||
|
||||
const newest = history[history.length - 1];
|
||||
const elapsedSec = Cesium.JulianDate.secondsDifference(
|
||||
renderTime,
|
||||
newest.time,
|
||||
);
|
||||
if (elapsedSec <= 0) {
|
||||
// Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking
|
||||
// aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing
|
||||
// pair exists yet). Render at the DELAYED renderTime — preserving the 30s-behind
|
||||
// invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history
|
||||
// fills, renderTime advances toward the first fix and the icon glides FORWARD into
|
||||
// the bracketing interpolation above with NO freeze and NO backward snap. (Holding
|
||||
// the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made
|
||||
// the icon jump back ~one poll interval the instant interpolation took over.)
|
||||
const oldest = history[0];
|
||||
const lookbackSec = Cesium.JulianDate.secondsDifference(
|
||||
oldest.time,
|
||||
renderTime,
|
||||
); // ≥ 0
|
||||
return _extrapolateFix(
|
||||
oldest,
|
||||
info,
|
||||
-Math.min(lookbackSec, 60),
|
||||
out,
|
||||
(info && info.turnRateDps) || 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Newest POSITION is older than renderTime. OpenSky can still be receiving
|
||||
// fresh contact/kinematic messages for that aircraft; freezing at a hard
|
||||
// 60 s-after-position boundary produced the visible stop → catch-up → stop
|
||||
// cadence. Coast through the latest real contact plus a bounded grace
|
||||
// window, with an absolute cap so a stale cached feed cannot drift forever.
|
||||
const coastLimitSec = staleCoastLimitSeconds({
|
||||
// `epochMs` is captured once when the poll is normalized. Avoid allocating
|
||||
// a Date per aircraft on every 12 Hz fleet tick.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
// Permit one minute of contact grace but cap any cached-feed drift at
|
||||
// five minutes. Source backoff is exposed separately as a STALE cue.
|
||||
minimumSec: 60,
|
||||
maximumSec: 300,
|
||||
});
|
||||
return _extrapolateFix(
|
||||
newest,
|
||||
info,
|
||||
Math.min(elapsedSec, coastLimitSec),
|
||||
out,
|
||||
(info && info.turnRateDps) || 0,
|
||||
);
|
||||
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
regime.kind = 'fix';
|
||||
regime.fix = warming ? history[0] : newest;
|
||||
regime.speed = fixSpeedMps(regime.fix, info);
|
||||
regime.heading = fixTrackDeg(regime.fix, info);
|
||||
regime.turnRateDps = (info && info.turnRateDps) || 0;
|
||||
regime.minDt = -60;
|
||||
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
|
||||
// coasting forward on the same arc.
|
||||
regime.maxDt =
|
||||
warming && history.length > 1
|
||||
? 0
|
||||
: staleCoastLimitSeconds({
|
||||
// `epochMs` is captured once when the poll is normalized. Avoid
|
||||
// allocating a Date per aircraft on every 12 Hz fleet tick.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
// Permit one minute of contact grace but cap any cached-feed drift
|
||||
// at five minutes. Source backoff is exposed separately as a STALE cue.
|
||||
minimumSec: 60,
|
||||
maximumSec: 300,
|
||||
});
|
||||
return regime;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,12 +315,8 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
||||
const speed = Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info.velocity) || 0;
|
||||
const heading = Number.isFinite(fix.track)
|
||||
? fix.track
|
||||
: (info && info.true_track) || 0;
|
||||
const speed = fixSpeedMps(fix, info);
|
||||
const heading = fixTrackDeg(fix, info);
|
||||
flightState._drSpeedMps = speed;
|
||||
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
||||
flightState._drExtrapolating = true;
|
||||
@@ -1114,6 +1151,7 @@ export function createMotion({
|
||||
return {
|
||||
_refloorStaleGroundedContacts,
|
||||
_deadReckon,
|
||||
_motionRegime,
|
||||
_extrapolateFix,
|
||||
_isTrackWarmingUp,
|
||||
_trackedFocusSizePx,
|
||||
|
||||
@@ -59,7 +59,8 @@ test('flight state owns distinct mutable records, scratch objects and floor samp
|
||||
for (const key of [
|
||||
'records',
|
||||
'feed',
|
||||
'_billboards',
|
||||
'_grounded',
|
||||
'_floorPinned',
|
||||
'_positionHistory',
|
||||
'_displayFloorState',
|
||||
'_groundSnap',
|
||||
|
||||
@@ -207,6 +207,14 @@ export const COURSE_MAX_DPS = 60;
|
||||
|
||||
export const COURSE_SLEW_DT_MAX_SEC = 0.25;
|
||||
|
||||
/** Grounded contacts this close to the camera are floored every fleet tick. */
|
||||
|
||||
export const FLOOR_NEAR_M = 50_000;
|
||||
|
||||
/** Farther grounded contacts are floored once per this many fleet ticks (~1 s). */
|
||||
|
||||
export const FLOOR_FAR_EVERY_TICKS = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
|
||||
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
import { modelState } from '../../data/modelMirror.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -99,7 +98,7 @@ export function createQueries({
|
||||
* 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 `_icons`/`_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.
|
||||
@@ -134,10 +133,8 @@ export function createQueries({
|
||||
// every visual consumer reads the floored per-frame cache instead (see
|
||||
// `_trackedDisplayPosition`). If that ever changes, floor this path too.
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const basePos =
|
||||
parts.motion._deadReckon(icao24) ||
|
||||
(bb ? billboardState(bb).position : null);
|
||||
parts.motion._deadReckon(icao24) || parts.icons._iconPosition(icao24);
|
||||
if (!basePos) return null;
|
||||
const displayed = displayedKinematics({
|
||||
derivedSpeedMps: flightState._drSpeedMps,
|
||||
@@ -334,14 +331,8 @@ export function createQueries({
|
||||
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)
|
||||
setBillboard(bb, 'show', true);
|
||||
}
|
||||
// (Releasing the models handed each fleet contact back to its icon, never
|
||||
// the tracked plane's own — its tracked entity is the visual.)
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -424,42 +415,34 @@ export function createQueries({
|
||||
* @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.
|
||||
const icons = flightState._icons;
|
||||
if (!icons || !icons.show) return [];
|
||||
// Compute a stride that evenly samples the icon rows.
|
||||
// seed shifts the starting offset so successive calls can sample
|
||||
// different aircraft without shuffling the underlying Map order.
|
||||
// different aircraft without reordering the rows.
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: flightState._billboards.size;
|
||||
: icons.count;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(flightState._billboards.size / maxCount),
|
||||
);
|
||||
const stride = Math.max(1, Math.ceil(icons.count / maxCount));
|
||||
const start = seed % stride;
|
||||
const occluder = parts.icons._occluder();
|
||||
const tau = parts.icons._displayTau();
|
||||
|
||||
const result = [];
|
||||
let idx = 0;
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const shouldTake = (idx - start) % stride === 0;
|
||||
idx++;
|
||||
if (!shouldTake) continue;
|
||||
for (let row = start; row < icons.count; row += stride) {
|
||||
const icao24 = icons.idAt(row);
|
||||
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.
|
||||
// Keep planes rendered as a 3D model (icon hidden) so the detection box
|
||||
// doesn't vanish on the 2D→3D handoff.
|
||||
const model = flightState._models.get(icao24) ?? null;
|
||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model);
|
||||
if (!isTracked && !billboardState(bb).show && !modelOwnsVisual)
|
||||
continue;
|
||||
if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
let object = flightState._detectionObjects.get(icao24);
|
||||
if (!object) {
|
||||
@@ -467,21 +450,24 @@ export function createQueries({
|
||||
sourceId: icao24,
|
||||
type: 'AIR',
|
||||
_weldPos: new Cesium.Cartesian3(),
|
||||
_iconPos: 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
|
||||
// on the buried icon 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.
|
||||
// no `_modelDisplayPosition` call from postRender. Icon-owned contacts keep
|
||||
// the drawn icon position — icon and bracket are co-located there.
|
||||
const spec = modelOwnsVisual
|
||||
? parts.rendering._modelSpec(info?.klass)
|
||||
: null;
|
||||
const pos = isTracked
|
||||
? parts.motion._trackedVisualCached() || billboardState(bb).position
|
||||
? parts.motion._trackedVisualCached() ||
|
||||
icons.position(row, tau, object._iconPos)
|
||||
: modelOwnsVisual
|
||||
? modelVisualAnchor(
|
||||
modelState(model).modelMatrix,
|
||||
@@ -491,8 +477,15 @@ export function createQueries({
|
||||
: spec.scale,
|
||||
object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
|
||||
)
|
||||
: billboardState(bb).position;
|
||||
if (!pos) continue;
|
||||
: icons.position(row, tau, object._iconPos);
|
||||
// An icon past the horizon is not drawn, so it is not detectable.
|
||||
if (
|
||||
!pos ||
|
||||
(!isTracked &&
|
||||
!modelOwnsVisual &&
|
||||
!parts.icons._aboveHorizon(row, pos, occluder))
|
||||
)
|
||||
continue;
|
||||
object.position = pos;
|
||||
object.skipLabel = isTracked;
|
||||
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
|
||||
@@ -517,13 +510,9 @@ export function createQueries({
|
||||
result.push(object);
|
||||
if (result.length >= maxCount) break;
|
||||
}
|
||||
if (
|
||||
flightState._detectionObjects.size >
|
||||
flightState._billboards.size + 512
|
||||
) {
|
||||
if (flightState._detectionObjects.size > icons.count + 512) {
|
||||
for (const icao24 of flightState._detectionObjects.keys()) {
|
||||
if (!flightState._billboards.has(icao24))
|
||||
flightState._detectionObjects.delete(icao24);
|
||||
if (!icons.has(icao24)) flightState._detectionObjects.delete(icao24);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -583,12 +572,11 @@ export function createQueries({
|
||||
* @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 icons = flightState._icons;
|
||||
if (!center || !icons || !icons.show) return [];
|
||||
const occluder = parts.icons._occluder();
|
||||
const tau = parts.icons._displayTau();
|
||||
const scratch = flightState._scratchNearbyPos;
|
||||
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
@@ -598,28 +586,30 @@ export function createQueries({
|
||||
|
||||
const nearby = [];
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
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.
|
||||
const trackedPos = isTracked
|
||||
? parts.motion._trackedDisplayCached()
|
||||
: null; // cached, no recompute (anti-jitter)
|
||||
const drawn = trackedPos || icons.position(row, tau, scratch);
|
||||
// Keep planes rendered as a 3D model (icon hidden) so proximity counts
|
||||
// don't drop to zero on the 2D→3D handoff.
|
||||
if (
|
||||
!aircraftIncludedInNearby({
|
||||
isTracked,
|
||||
billboardShown: billboardState(bb).show,
|
||||
billboardShown:
|
||||
icons.shown(row) &&
|
||||
parts.icons._aboveHorizon(row, drawn, occluder),
|
||||
modelRendering: parts.rendering._modelOwnsVisual(icao24),
|
||||
includeHidden,
|
||||
})
|
||||
)
|
||||
continue;
|
||||
|
||||
const trackedPos = isTracked
|
||||
? parts.motion._trackedDisplayCached()
|
||||
: null; // cached, no recompute (anti-jitter)
|
||||
const pos = trackedPos || billboardState(bb).position;
|
||||
if (!pos) continue;
|
||||
|
||||
const distance = Cesium.Cartesian3.distance(center, pos);
|
||||
const distance = Cesium.Cartesian3.distance(center, drawn);
|
||||
if (distance > maxRange) continue;
|
||||
const pos = trackedPos || Cesium.Cartesian3.clone(drawn);
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const callsign = info?.callsign?.trim() || null;
|
||||
@@ -651,8 +641,8 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Cheap snapshot for voice-tool framing — drawn icon positions, no
|
||||
* extra dead reckoning.
|
||||
* @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}>}
|
||||
*/
|
||||
@@ -671,34 +661,25 @@ export function createQueries({
|
||||
* holds no data and therefore cannot answer.
|
||||
*/
|
||||
hasContact(icao24) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return null;
|
||||
const icons = flightState._icons;
|
||||
if (!icons || !icons.show || icons.count === 0) return null;
|
||||
if (!icao24) return false;
|
||||
const id = String(icao24).trim();
|
||||
return (
|
||||
flightState._billboards.has(id) ||
|
||||
flightState._billboards.has(id.toLowerCase())
|
||||
);
|
||||
return icons.has(id) || icons.has(id.toLowerCase());
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 500) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return [];
|
||||
const icons = flightState._icons;
|
||||
if (!icons || icons.count === 0) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 500;
|
||||
const tau = parts.icons._displayTau();
|
||||
|
||||
const result = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const pos = billboardState(bb).position;
|
||||
if (!pos) continue;
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
const pos = icons.position(row, tau);
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
pos,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
@@ -741,11 +722,7 @@ export function createQueries({
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState.records.data.size === 0
|
||||
)
|
||||
if (!flightState._icons?.show || flightState.records.data.size === 0)
|
||||
return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
@@ -773,8 +750,8 @@ export function createQueries({
|
||||
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 (!flightState._icons?.has(id)) id = id.toLowerCase();
|
||||
if (!flightState._icons?.has(id)) return false;
|
||||
if (parts.tracking._isExplicitTrackingOrigin(origin))
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._trackedIcao === id)
|
||||
@@ -844,7 +821,7 @@ export function createQueries({
|
||||
)
|
||||
return false;
|
||||
let id = String(icao24).trim();
|
||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||
if (!flightState._icons?.has(id)) id = id.toLowerCase();
|
||||
if (
|
||||
id !== flightState._trackedIcao ||
|
||||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
||||
|
||||
@@ -3,11 +3,9 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
billboardState,
|
||||
mirrorBillboard,
|
||||
setBillboard,
|
||||
} from '../../data/billboardMirror.js';
|
||||
import { createIcons } from './icons.js';
|
||||
import { createIconSet } from '../../data/iconSet.js';
|
||||
import { writeHoldSegment } from '../../data/iconSegment.js';
|
||||
import { mirrorModel, setModel } from '../../data/modelMirror.js';
|
||||
|
||||
function fixture() {
|
||||
@@ -16,14 +14,11 @@ function fixture() {
|
||||
registration: ' N123 ',
|
||||
altitude: 1000,
|
||||
};
|
||||
const bb = mirrorBillboard({
|
||||
show: true,
|
||||
position: new Cesium.Cartesian3(1, 2, 3),
|
||||
});
|
||||
const icons = createIconSet();
|
||||
const flightState = {
|
||||
feed: {},
|
||||
_billboardCollection: { show: true },
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_icons: icons,
|
||||
_scratchIconRenderTime: new Cesium.JulianDate(),
|
||||
_models: new Map(),
|
||||
_detectionObjects: new Map(),
|
||||
records: { data: new Map([['abc123', info]]) },
|
||||
@@ -48,8 +43,27 @@ function fixture() {
|
||||
motion: { _trackedVisualCached: () => null },
|
||||
};
|
||||
parts.rendering = createRendering({ flightState, services, parts });
|
||||
parts.icons = createIcons({ flightState, services, parts });
|
||||
const queries = createQueries({ flightState, services, parts }).methods;
|
||||
return { flightState, info, bb, parts, queries, formats: () => formats };
|
||||
const row = () => icons.row('abc123');
|
||||
/** Hold the contact's icon at `position`. */
|
||||
const place = (position) => {
|
||||
writeHoldSegment(icons.segments, row(), position, 0);
|
||||
icons.segmentWritten(row());
|
||||
};
|
||||
icons.add('abc123', { id: 'abc123' });
|
||||
place(new Cesium.Cartesian3(1, 2, 3));
|
||||
icons.setShown(row(), true);
|
||||
return {
|
||||
flightState,
|
||||
info,
|
||||
parts,
|
||||
queries,
|
||||
formats: () => formats,
|
||||
place,
|
||||
show: (shown) => icons.setShown(row(), shown),
|
||||
drawn: () => parts.icons._iconPosition('abc123'),
|
||||
};
|
||||
}
|
||||
|
||||
test('candidate text follows in-place enrichment without freezing identity or positions', () => {
|
||||
@@ -57,9 +71,9 @@ test('candidate text follows in-place enrichment without freezing identity or po
|
||||
const [first] = f.queries.getDetectableObjects();
|
||||
assert.equal(first.id, 'TEST123');
|
||||
assert.equal(first.sourceId, 'abc123');
|
||||
setBillboard(f.bb, 'position', new Cesium.Cartesian3(4, 5, 6));
|
||||
f.place(new Cesium.Cartesian3(4, 5, 6));
|
||||
assert.equal(f.queries.getDetectableObjects()[0], first);
|
||||
assert.equal(first.position, billboardState(f.bb).position);
|
||||
assert.deepEqual(first.position, f.drawn());
|
||||
assert.deepEqual(first.position, new Cesium.Cartesian3(4, 5, 6));
|
||||
assert.equal(f.formats(), 1);
|
||||
f.info.callsign = ' NEW456 ';
|
||||
@@ -107,7 +121,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) =>
|
||||
scale: 1,
|
||||
});
|
||||
f.flightState._models.set('abc123', model);
|
||||
setBillboard(f.bb, 'show', false);
|
||||
f.show(false);
|
||||
assert.deepEqual(f.queries.getDetectableObjects(), []);
|
||||
model.ready = true;
|
||||
assert.equal(f.queries.getDetectableObjects()[0], candidate);
|
||||
@@ -121,11 +135,8 @@ test('candidate collection resolves models once and keeps handoffs live', (t) =>
|
||||
assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60));
|
||||
setModel(model, 'show', false);
|
||||
assert.deepEqual(f.queries.getDetectableObjects(), []);
|
||||
setBillboard(f.bb, 'show', true);
|
||||
assert.equal(
|
||||
f.queries.getDetectableObjects()[0].position,
|
||||
billboardState(f.bb).position,
|
||||
);
|
||||
f.show(true);
|
||||
assert.deepEqual(f.queries.getDetectableObjects()[0].position, f.drawn());
|
||||
assert.equal(get.mock.callCount(), 6);
|
||||
setModel(model, 'show', true);
|
||||
assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true);
|
||||
|
||||
+153
-386
@@ -1,22 +1,14 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
cameraPoseSignature,
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
EMERGENCY_TINT,
|
||||
GROUND_SCALE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
MODEL_ALT_CEIL_M,
|
||||
MODEL_MAX_ALL,
|
||||
@@ -32,14 +24,9 @@ import {
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
import { aircraftVisuals } from '../aircraft/visualState.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
import { modelState, setModel } from '../../data/modelMirror.js';
|
||||
|
||||
export function createRendering({
|
||||
@@ -52,14 +39,8 @@ export function createRendering({
|
||||
const { isMilitaryIcao } = services.militaryRegistry;
|
||||
const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
|
||||
const { trackedModelScaleForPixelCap } = services.camera;
|
||||
const {
|
||||
focusNowMs,
|
||||
getFocusTarget,
|
||||
nearFarScalarValueAtDistance,
|
||||
advanceProjectedSpriteFocus,
|
||||
} = services.focus;
|
||||
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
||||
services.recession;
|
||||
const { focusNowMs, getFocusTarget } = services.focus;
|
||||
const { applyAircraftModelTreatment } = services.recession;
|
||||
|
||||
/** True while this contact's latest poll broadcast an emergency code. */
|
||||
|
||||
@@ -98,22 +79,6 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
/** Depth-test policy for aircraft billboards. Round 5 (owner directive
|
||||
* 2026-07-06: "I just want the planes and their lines to ALWAYS be
|
||||
* visible... evenly applied"): EVERY contact renders depth-test-free at
|
||||
* every distance — grounded, low, and airborne alike. The photoreal mesh
|
||||
* writes depth and residual baro/floor error will always leave some sprite
|
||||
* geometry at or below it; a uniform rule beats the grounded-only /
|
||||
* low-AGL-only conditions that kept leaving classes of contacts buried
|
||||
* (2026-07-03 Van Nuys grounded case; 2026-07-06 Austin QNH-below-field
|
||||
* case). Far-side planes are still removed by the fleet tick's horizon
|
||||
* occluder, which never depended on depth. Kept as a function so the
|
||||
* callers' restyle sites stay diff-stable. */
|
||||
|
||||
function _groundDepthDistance() {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function _modelSpec(klass) {
|
||||
const cached = flightState._specCache.get(klass);
|
||||
if (cached) return cached;
|
||||
@@ -141,53 +106,6 @@ export function createRendering({
|
||||
const _iconKind = (icao24, klass) =>
|
||||
tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
|
||||
|
||||
/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */
|
||||
|
||||
function _applyFleetBillboardPresentation(icao24, bb) {
|
||||
if (!bb) return;
|
||||
const limbScale = flightState._billboardLimbScale.get(bb) ?? 1;
|
||||
const isCockpitContact =
|
||||
flightState._cockpitContactMode && icao24 !== flightState._trackedIcao;
|
||||
const isCockpitNear =
|
||||
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
|
||||
if (isCockpitContact && !isCockpitNear) {
|
||||
const freshnessAlpha = cyberSonarBaseAlpha(bb);
|
||||
bb.image = cockpitContactDotImage();
|
||||
setBillboard(bb, 'width', COCKPIT_CONTACT_SIZE_PX);
|
||||
setBillboard(bb, 'height', COCKPIT_CONTACT_SIZE_PX);
|
||||
setBillboard(bb, 'scale', limbScale);
|
||||
setBillboard(bb, 'scaleByDistance', _cockpitBillboardScaleByDistance());
|
||||
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
|
||||
setBillboard(bb, 'rotation', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
bb.image = aircraftIcon(
|
||||
_iconKind(icao24, meta?.klass),
|
||||
aircraftVisuals.read(bb)?.iconLarge ? TRACKED_ICON_PX : undefined,
|
||||
);
|
||||
setBillboard(bb, 'width', icao24 === flightState._trackedIcao ? 24 : 20);
|
||||
setBillboard(bb, 'height', icao24 === flightState._trackedIcao ? 24 : 20);
|
||||
setBillboard(
|
||||
bb,
|
||||
'scale',
|
||||
_fleetBillboardScale(icao24, meta?.klass) * limbScale,
|
||||
);
|
||||
setBillboard(bb, 'scaleByDistance', _normalBillboardScaleByDistance());
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard,
|
||||
* horizon-culls billboards beyond the limb (no far-side depth with the globe
|
||||
* hidden), and refreshes screen-projected icon rotations whenever the camera
|
||||
* pose changed (plus a 1s drift catch-up while idle). Driven by
|
||||
* scene.preRender — NOT camera.changed, whose granularity is globally
|
||||
* degraded by other layers mutating camera.percentageChanged.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/** Model tint, mirroring the billboard color rules. */
|
||||
|
||||
function _modelColor(icao24) {
|
||||
@@ -272,12 +190,12 @@ export function createRendering({
|
||||
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
|
||||
* vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked
|
||||
* neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion
|
||||
* matches picked-object IDs, and every billboard AND model in this layer carries its
|
||||
* matches picked-object IDs, and every icon AND model in this layer carries its
|
||||
* icao as `id`, so the icao strings cover both; the tracked entity is excluded as the
|
||||
* object itself. Built lazily — only when a sample actually fires (one-shot). */
|
||||
|
||||
function _groundSampleExclusions() {
|
||||
const out = [...flightState._billboards.keys()];
|
||||
const out = parts.icons._iconIds();
|
||||
if (flightState._trackedEntity) out.push(flightState._trackedEntity);
|
||||
return out;
|
||||
}
|
||||
@@ -323,15 +241,23 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
/** Atomically hand one fleet contact from its billboard to a safely placed,
|
||||
/** Atomically hand one fleet contact from its icon to a safely placed,
|
||||
* ready 3D model. Missing/loading models and unresolved terrain always leave
|
||||
* the billboard owning the visual, so no render frame can hide both.
|
||||
* the icon owning the visual, so no render frame can hide both.
|
||||
* `beforeShow` runs only on the committing path — the per-tick model treatment
|
||||
* belongs to a model that is about to draw, not to one still waiting. */
|
||||
|
||||
function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) {
|
||||
function _driveFleetModelHandoff(
|
||||
icao24,
|
||||
model,
|
||||
row,
|
||||
pos,
|
||||
course,
|
||||
beforeShow,
|
||||
) {
|
||||
const icons = flightState._icons;
|
||||
if (!model) {
|
||||
setBillboard(bb, 'show', true);
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
const displayPos = _modelDisplayPosition(
|
||||
@@ -341,7 +267,7 @@ export function createRendering({
|
||||
);
|
||||
if (!displayPos) {
|
||||
setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at
|
||||
setBillboard(bb, 'show', true);
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
||||
@@ -350,12 +276,12 @@ export function createRendering({
|
||||
setModel(model, 'modelMatrix', _modelMatrix(displayPos, course));
|
||||
if (!model.ready) {
|
||||
setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash
|
||||
setBillboard(bb, 'show', true);
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
beforeShow?.();
|
||||
if (!modelState(model).show) setModel(model, 'show', true);
|
||||
if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders
|
||||
icons.setShown(row, false); // hand off ONLY once the model renders
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -443,9 +369,6 @@ export function createRendering({
|
||||
(current && aircraftVisuals.read(current)?.specKey !== key) ||
|
||||
(!current && flightState._modelPending.has(icao24))
|
||||
) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (bb && icao24 !== flightState._trackedIcao)
|
||||
setBillboard(bb, 'show', true);
|
||||
_releaseModel(icao24);
|
||||
}
|
||||
if (
|
||||
@@ -479,9 +402,6 @@ export function createRendering({
|
||||
aircraftVisuals.read(model)?.irBoost === flightState._irBoost
|
||||
)
|
||||
continue; // already right state
|
||||
const bb = flightState._billboards.get(icao);
|
||||
if (bb && icao !== flightState._trackedIcao)
|
||||
setBillboard(bb, 'show', true);
|
||||
_releaseModel(icao);
|
||||
}
|
||||
if (flightState._irReloadQueue.length === 0)
|
||||
@@ -624,6 +544,7 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
flightState._models.delete(icao24);
|
||||
_showReleasedIcon(icao24);
|
||||
}
|
||||
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
||||
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
|
||||
@@ -664,9 +585,20 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const icao24 of flightState._models.keys()) _showReleasedIcon(icao24);
|
||||
flightState._models.clear();
|
||||
}
|
||||
|
||||
/** A released fleet model hands the visual straight back to its icon (the
|
||||
* tracked contact's entity keeps its own). */
|
||||
|
||||
function _showReleasedIcon(icao24) {
|
||||
const icons = flightState._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
if (row >= 0 && icao24 !== flightState._trackedIcao)
|
||||
icons.setShown(row, true);
|
||||
}
|
||||
|
||||
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
|
||||
|
||||
function _releaseTrackedModel() {
|
||||
@@ -709,8 +641,7 @@ export function createRendering({
|
||||
// readout consumers then reuse that exact cached position.
|
||||
const pos =
|
||||
parts.motion._trackedDisplayPosition(flightState._trackedIcao) ||
|
||||
billboardState(flightState._billboards.get(flightState._trackedIcao))
|
||||
?.position;
|
||||
parts.icons._iconPosition(flightState._trackedIcao);
|
||||
if (!pos) {
|
||||
if (flightState._trackedModel)
|
||||
setModel(flightState._trackedModel, 'show', false);
|
||||
@@ -789,9 +720,7 @@ export function createRendering({
|
||||
// default here produces a one-frame jump to the Earth's center.
|
||||
const currentPos =
|
||||
parts.motion._trackedDisplayCached() ||
|
||||
billboardState(
|
||||
flightState._billboards.get(flightState._trackedIcao),
|
||||
)?.position;
|
||||
parts.icons._iconPosition(flightState._trackedIcao);
|
||||
if (currentPos) {
|
||||
const displayPos = _modelDisplayPosition(
|
||||
flightState._trackedIcao,
|
||||
@@ -859,54 +788,40 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and
|
||||
* the two-tier glyph swap all run on the GPU from each row's segment, so this
|
||||
* only re-plans rows whose regime ended, pins grounded contacts to the floor
|
||||
* under them, advances focus de-emphasis while a target exists, and drives
|
||||
* the 3D-model handoff inside the model regime. Driven by scene.preRender.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return;
|
||||
const icons = flightState._icons;
|
||||
if (!flightState._viewer || !icons || !icons.show) return;
|
||||
const scene = flightState._viewer.scene;
|
||||
const camera = flightState._viewer.camera;
|
||||
const nowMs = focusNowMs(Date.now());
|
||||
|
||||
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
|
||||
// primitive rebuild needed here anymore.)
|
||||
|
||||
if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return;
|
||||
const tickDtSec = flightState._lastFleetTickMs
|
||||
? Math.min(
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
(nowMs - flightState._lastFleetTickMs) / 1000,
|
||||
)
|
||||
: 0.08;
|
||||
flightState._lastFleetTickMs = nowMs;
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
const renderTime = parts.icons._displayTime(
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
const tau = icons.seconds(renderTime);
|
||||
|
||||
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||
if (flightState._cockpitContactMode)
|
||||
parts.tracking._refreshCockpitNearContacts();
|
||||
const poseSig = cameraPoseSignature(camera);
|
||||
// Only the nearby Cockpit silhouettes need projected course; far dots are
|
||||
// rotation-free. The per-contact gate below keeps the pip path cheap.
|
||||
const doRotations =
|
||||
poseSig !== flightState._lastCamPoseSig ||
|
||||
nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS;
|
||||
if (doRotations) {
|
||||
flightState._lastCamPoseSig = poseSig;
|
||||
flightState._lastRotPassMs = nowMs;
|
||||
}
|
||||
|
||||
const occluder = horizonOccluder(camera);
|
||||
parts.icons._replanExpired(tau);
|
||||
parts.icons._floorGroundedIcons(renderTime, tau, nowMs, camera.positionWC);
|
||||
const focusTarget = getFocusTarget();
|
||||
parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget);
|
||||
|
||||
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so icons resume.
|
||||
const useModels = _modelRegimeActive();
|
||||
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
|
||||
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
|
||||
@@ -915,6 +830,7 @@ export function createRendering({
|
||||
(flightState._models.size || flightState._modelPending.size)
|
||||
)
|
||||
_releaseModels();
|
||||
if (!useModels) return;
|
||||
|
||||
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
|
||||
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
|
||||
@@ -925,273 +841,124 @@ export function createRendering({
|
||||
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
|
||||
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
|
||||
// filtering dropped near off-screen planes entirely. This does both right.
|
||||
let modelEligible = null;
|
||||
if (useModels) {
|
||||
const cap = _modelCap();
|
||||
const camPos = camera.positionWC;
|
||||
const addM = _modelAddDistM();
|
||||
const addDistSq = addM * addM;
|
||||
const keepM = _modelKeepDistM();
|
||||
const keepDistSq = keepM * keepM;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
const cap = _modelCap();
|
||||
const camPos = camera.positionWC;
|
||||
const addM = _modelAddDistM();
|
||||
const addDistSq = addM * addM;
|
||||
const keepM = _modelKeepDistM();
|
||||
const keepDistSq = keepM * keepM;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||
const cand = [];
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao = icons.idAt(row);
|
||||
if (icao === flightState._trackedIcao) continue;
|
||||
// A converted TR-3B renders as an icon and can never take a model, so
|
||||
// it must not occupy a CAP SLOT either — excluded here at selection time,
|
||||
// not just at the handoff below, or accumulated conversions would starve
|
||||
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
|
||||
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||
// below already bound airport clusters; grounded placement is handled by the
|
||||
// one-shot ground snap in _modelDisplayPosition.
|
||||
const position = icons.position(
|
||||
row,
|
||||
tau,
|
||||
flightState._scratchModelBS.center,
|
||||
);
|
||||
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||
const cand = [];
|
||||
for (const [icao, bb] of flightState._billboards) {
|
||||
if (icao === flightState._trackedIcao) continue;
|
||||
// A converted TR-3B renders as a billboard and can never take a model, so
|
||||
// it must not occupy a CAP SLOT either — excluded here at selection time,
|
||||
// not just at the handoff below, or accumulated conversions would starve
|
||||
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
|
||||
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||
// below already bound airport clusters; grounded placement is handled by the
|
||||
// one-shot ground snap in _modelDisplayPosition.
|
||||
const { position } = billboardState(bb);
|
||||
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
|
||||
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||
Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center);
|
||||
cand.push([
|
||||
icao,
|
||||
d2,
|
||||
cull.computeVisibility(flightState._scratchModelBS) !==
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||
console.warn(
|
||||
`[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`,
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
toRelease.push(icao);
|
||||
}
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
|
||||
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||
cand.push([
|
||||
icao,
|
||||
d2,
|
||||
cull.computeVisibility(flightState._scratchModelBS) !==
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
const icon = billboardState(bb);
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(
|
||||
icao24,
|
||||
flightState._scratchFleetPos,
|
||||
renderTime,
|
||||
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||
console.warn(
|
||||
`[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`,
|
||||
);
|
||||
// The dead-reckoned point drifts away from the fix whose cell supplied the
|
||||
// height, so a grounded contact's sprite ends up under the mesh it taxied
|
||||
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
|
||||
// never while a 3D model owns the visual (T7).
|
||||
const display = parts.motion._floorGroundedDisplayPosition(
|
||||
icao24,
|
||||
info,
|
||||
dr,
|
||||
_modelOwnsVisual(icao24),
|
||||
nowMs,
|
||||
);
|
||||
// Gate the write — assigning Billboard.position dirties the whole
|
||||
// collection's vertex buffer, so skip sub-meter moves.
|
||||
if (
|
||||
display &&
|
||||
Cesium.Cartesian3.distanceSquared(display, icon.position) > 1.0
|
||||
) {
|
||||
setBillboard(bb, 'position', display);
|
||||
}
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
const modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
toRelease.push(icao);
|
||||
}
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
|
||||
// Round 6: occlusion-test a LIFTED point for contacts rendering below
|
||||
// (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a
|
||||
// sub-ellipsoid point near the limb "beyond the horizon" and the fleet
|
||||
// pass would hide a plane that is really just low over high-N terrain
|
||||
// waiting for its floor to warm (ATL grounded contacts at geoid −31 m).
|
||||
const beyondHorizon = !occluder.isPointVisible(
|
||||
flightState._cullPositions.get(icao24) || icon.position,
|
||||
);
|
||||
// A billboard flipping INTO view (horizon reveal while the camera idles)
|
||||
// gets its rotation refreshed THIS tick even without a pose change —
|
||||
// otherwise it reappears wearing its stale (often creation-north) nose for
|
||||
// up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false
|
||||
// here; harmless — the model branch below `continue`s past the rotation.)
|
||||
const revealed = !beyondHorizon && !icon.show;
|
||||
if (icon.show === beyondHorizon) setBillboard(bb, 'show', !beyondHorizon);
|
||||
if (beyondHorizon) {
|
||||
// Also hide any 3D model — otherwise a model that crossed the limb would keep
|
||||
// rendering through the hidden globe at its last matrix.
|
||||
// 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
|
||||
// until the model is actually READY to render, so a plane is never both iconless AND
|
||||
// modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
|
||||
// regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
|
||||
// purpose: the Easter egg IS the triangle, and there is no GLB for it.
|
||||
const occluder = horizonOccluder(camera);
|
||||
for (const icao24 of modelEligible) {
|
||||
if (isTr3b(icao24)) continue;
|
||||
const row = icons.row(icao24);
|
||||
if (row < 0) continue;
|
||||
const position = icons.position(row, tau, flightState._scratchFleetPos);
|
||||
if (!parts.icons._aboveHorizon(row, position, occluder)) {
|
||||
// A model that crossed the limb would keep rendering through the
|
||||
// hidden globe at its last matrix; the icon's own cull is on the GPU.
|
||||
const m = flightState._models.get(icao24);
|
||||
if (m && modelState(m)?.show) setModel(m, 'show', false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// One order-independent write site composes freshness × focus × limb haze
|
||||
// for alpha, and base class/ground scale × limb taper for scale. Cesium's
|
||||
// locked NearFarScalar remains a separate multiplicative stage. This
|
||||
// narrowly amends always-visible rendering without count culling or zero
|
||||
// alpha; nearer focus behavior remains tunable rather than universal.
|
||||
const cameraDistanceM = Cesium.Cartesian3.distance(
|
||||
camera.positionWC,
|
||||
icon.position,
|
||||
_ensureModel(icao24);
|
||||
const model = flightState._models.get(icao24);
|
||||
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
|
||||
_driveFleetModelHandoff(
|
||||
icao24,
|
||||
model,
|
||||
row,
|
||||
position,
|
||||
icons.course(row, tau),
|
||||
() => {
|
||||
applyAircraftModelTreatment({
|
||||
model,
|
||||
// IR boost must survive the per-tick treatment write — otherwise
|
||||
// any alpha change would repaint the ordinary tint over the hot
|
||||
// white. Boosted models also skip the recession fade: hot targets
|
||||
// stay full-strength at any range (icons keep their normal
|
||||
// fade — full-opacity glyph walls read as overwhelming).
|
||||
baseColor: flightState._irBoost
|
||||
? Cesium.Color.WHITE
|
||||
: _modelColor(icao24),
|
||||
alpha: flightState._irBoost
|
||||
? 1
|
||||
: aircraftTreatmentAlpha({
|
||||
baseAlpha: icons.alpha(row),
|
||||
focusFactor: icons.focus(row),
|
||||
cameraDistanceM,
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
const distanceScale = nearFarScalarValueAtDistance(
|
||||
icon.scaleByDistance,
|
||||
cameraDistanceM,
|
||||
);
|
||||
const focus = advanceProjectedSpriteFocus(
|
||||
bb,
|
||||
icon.position,
|
||||
scene,
|
||||
camera,
|
||||
nowMs,
|
||||
focusTarget,
|
||||
undefined,
|
||||
(icon.width || 20) * (icon.scale || 1) * distanceScale * 0.5,
|
||||
(icon.height || 20) * (icon.scale || 1) * distanceScale * 0.5,
|
||||
);
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const layerBaseColor =
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? _cockpitContactColor(icao24, info)
|
||||
: _fleetBillboardColor(icao24, info);
|
||||
const baseColor = layerBaseColor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? 1
|
||||
: _fleetBillboardScale(icao24, info?.klass),
|
||||
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
|
||||
baseColor,
|
||||
focusFactor: focus.factor,
|
||||
cameraDistanceM,
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
});
|
||||
flightState._billboardLimbScale.set(bb, treatment.factors.scale);
|
||||
// Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas
|
||||
// has no mipmaps, so no single texture stays crisp across the ~25–150
|
||||
// device-px range scaleByDistance produces. Swap between the 64 px fleet
|
||||
// raster and the 192 px close raster on the billboard's ACTUAL on-screen
|
||||
// size — post-treatment bb.scale, so focus/limb recession counts — with
|
||||
// hysteresis so zoom oscillation never thrashes the atlas.
|
||||
if (!flightState._cockpitContactMode || isCockpitNear) {
|
||||
const glyphDevPx =
|
||||
(icon.width || 20) *
|
||||
(icon.scale || 1) *
|
||||
distanceScale *
|
||||
(globalThis.devicePixelRatio || 1);
|
||||
const visual = aircraftVisuals.write(bb);
|
||||
const wantLarge = visual.iconLarge ? glyphDevPx > 56 : glyphDevPx > 76;
|
||||
if (wantLarge !== !!visual.iconLarge) {
|
||||
visual.iconLarge = wantLarge;
|
||||
bb.image = aircraftIcon(
|
||||
_iconKind(icao24, info?.klass),
|
||||
wantLarge ? TRACKED_ICON_PX : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Smoothed display course: the path direction _deadReckon just reported
|
||||
// for THIS aircraft (nothing else calls _deadReckon in between), rate-
|
||||
// limited so segment-boundary course steps glide instead of snapping.
|
||||
// The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering
|
||||
// aircraft (hold flag) keeps its previous nose direction outright.
|
||||
const rawCourse =
|
||||
flightState._drCourseDeg != null
|
||||
? flightState._drCourseDeg
|
||||
: (info && info.true_track) || 0;
|
||||
const prevCourse = flightState._displayCourse.get(icao24);
|
||||
const course =
|
||||
flightState._drCourseHold && prevCourse != null
|
||||
? prevCourse
|
||||
: limitCourseStep(
|
||||
prevCourse,
|
||||
rawCourse,
|
||||
courseSlewCapDps(
|
||||
flightState._drSpeedMps != null
|
||||
? flightState._drSpeedMps
|
||||
: ((info && info.velocity) ?? NaN),
|
||||
COURSE_MAX_DPS,
|
||||
),
|
||||
tickDtSec,
|
||||
);
|
||||
flightState._displayCourse.set(icao24, course);
|
||||
|
||||
// 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the
|
||||
// billboard stays shown until the model is actually READY to render, so a plane is never both
|
||||
// iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every
|
||||
// tick regardless so it's framed the instant it becomes ready.
|
||||
// Converted TR-3Bs stay 2D on purpose: the Easter egg IS the triangle, and
|
||||
// there is no GLB for it, so the model handoff is suppressed rather than
|
||||
// fed a stand-in mesh. The billboard keeps rendering (and keeps satisfying
|
||||
// the getNearby/getDetectableObjects `bb.show` visibility guards), so a
|
||||
// converted contact still works in Contacts and Cockpit.
|
||||
if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) {
|
||||
_ensureModel(icao24);
|
||||
const model = flightState._models.get(icao24);
|
||||
const ownsVisual = _driveFleetModelHandoff(
|
||||
icao24,
|
||||
model,
|
||||
bb,
|
||||
dr,
|
||||
course,
|
||||
() => {
|
||||
applyAircraftModelTreatment({
|
||||
model,
|
||||
// IR boost must survive the per-tick treatment write — otherwise
|
||||
// any alpha change would repaint the ordinary tint over the hot
|
||||
// white. Boosted models also skip the recession fade: hot targets
|
||||
// stay full-strength at any range (billboards keep their normal
|
||||
// fade — full-opacity glyph walls read as overwhelming).
|
||||
baseColor: flightState._irBoost
|
||||
? Cesium.Color.WHITE
|
||||
: _modelColor(icao24),
|
||||
alpha: flightState._irBoost ? 1 : treatment.alpha,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (ownsVisual) continue; // skip billboard rotation
|
||||
}
|
||||
|
||||
if (
|
||||
(!flightState._cockpitContactMode || isCockpitNear) &&
|
||||
(doRotations || revealed)
|
||||
) {
|
||||
const rot = screenProjectedRotation(
|
||||
scene,
|
||||
icon.position,
|
||||
course,
|
||||
icon.rotation,
|
||||
);
|
||||
if (rot !== null && Math.abs(rot - icon.rotation) > 0.002) {
|
||||
setBillboard(bb, 'rotation', rot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
_fleetBillboardColor,
|
||||
_fleetBillboardScale,
|
||||
_groundDepthDistance,
|
||||
_cockpitContactColor,
|
||||
_modelSpec,
|
||||
_normalBillboardScaleByDistance,
|
||||
_cockpitBillboardScaleByDistance,
|
||||
_iconKind,
|
||||
_applyFleetBillboardPresentation,
|
||||
_modelColor,
|
||||
_modelRegimeActive,
|
||||
_modelCap,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { addBillboard } from '../../data/billboardMirror.js';
|
||||
|
||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
||||
export function createFlightSnapshotRenderer({
|
||||
@@ -19,6 +16,7 @@ export function createFlightSnapshotRenderer({
|
||||
rendering,
|
||||
tracking,
|
||||
motion,
|
||||
icons,
|
||||
enrichment,
|
||||
queries,
|
||||
}) {
|
||||
@@ -53,17 +51,8 @@ export function createFlightSnapshotRenderer({
|
||||
// which hands off on untrack).
|
||||
const isMil = isMilitaryIcao(icao24);
|
||||
if (isMil && tracking._militaryLayerSuppresses(icao24)) {
|
||||
const dupe = flightState._billboards.get(icao24);
|
||||
if (dupe) {
|
||||
flightState._billboardCollection.remove(dupe);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
records.forget(icao24);
|
||||
flightState._cullPositions.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
}
|
||||
// Military-suppression: drop the duplicate with any 3D model too.
|
||||
if (flightState._icons.has(icao24)) icons._forgetContact(icao24);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -84,12 +73,12 @@ export function createFlightSnapshotRenderer({
|
||||
);
|
||||
// Either flip retires the previous one-shot ground sample.
|
||||
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
||||
flightState._cullPositions.set(
|
||||
icao24,
|
||||
meta.renderAltitudeM < 10
|
||||
? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
|
||||
: null,
|
||||
);
|
||||
if (meta.onGround) {
|
||||
flightState._grounded.add(icao24);
|
||||
} else if (flightState._grounded.delete(icao24)) {
|
||||
flightState._floorPinned.delete(icao24);
|
||||
motion._retireDisplayFloorState(icao24, Date.now());
|
||||
}
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
|
||||
// Append to position history stamped with the FEED's fix epoch
|
||||
@@ -153,72 +142,18 @@ export function createFlightSnapshotRenderer({
|
||||
}
|
||||
}
|
||||
|
||||
if (flightState._billboards.has(icao24)) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
// Reclassify if the category resolved/changed (first extended poll);
|
||||
// a ground flip (landing/takeoff) re-scales the SAME billboard in
|
||||
// place — the transition is a restyle, never a removal.
|
||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
||||
// course changes land on the next rotation pass (forced below).
|
||||
if (!isTracked) {
|
||||
// Refresh affiliation hue without clobbering the tick-owned
|
||||
// freshness × focus × horizon alpha composition.
|
||||
bb.color = rendering
|
||||
._fleetBillboardColor(icao24)
|
||||
.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
groundFlipped ||
|
||||
flightState._cockpitContactMode
|
||||
) {
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
if (flightState._icons.has(icao24)) {
|
||||
// Reclassification, a ground flip (a restyle, never a removal), a
|
||||
// freshness change and the cockpit contract all land on the row's
|
||||
// style; motion re-plans from the updated history.
|
||||
icons._applyIconPresentation(icao24);
|
||||
icons._planIcon(icao24);
|
||||
// Poll-path class change (category updates): same model resync rule
|
||||
// as the enrichment path — the class's GLB/scale may have changed.
|
||||
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
||||
} else {
|
||||
// Start where the fleet pass will draw it: the fleet renders behind
|
||||
// real time, so a fresh contact's displayed position is its fix
|
||||
// projected back to that delayed time, and a grounded contact is then
|
||||
// lifted onto the floor under that delayed position. Creating it at
|
||||
// the raw fix drew it ahead until the next fleet tick snapped it back;
|
||||
// skipping the floor started a taxiing contact low and then raised it.
|
||||
const delayed = motion._deadReckon(icao24, new Cesium.Cartesian3());
|
||||
const displayed =
|
||||
(delayed &&
|
||||
motion._floorGroundedDisplayPosition(
|
||||
icao24,
|
||||
flightState.records.data.get(icao24) || meta,
|
||||
delayed,
|
||||
rendering._modelOwnsVisual(icao24),
|
||||
)) ||
|
||||
position;
|
||||
const bb = addBillboard(flightState._billboardCollection, {
|
||||
position: displayed,
|
||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||
width: isTracked ? 24 : 20,
|
||||
height: isTracked ? 24 : 20,
|
||||
scale: rendering._fleetBillboardScale(icao24, meta.klass),
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: isTracked
|
||||
? Cesium.Color.CYAN
|
||||
: rendering._fleetBillboardColor(icao24),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
// surface — render them depth-test-free so they never vanish up
|
||||
// close (_groundDepthDistance).
|
||||
disableDepthTestDistance: rendering._groundDepthDistance(),
|
||||
id: icao24,
|
||||
show: !isTracked, // hidden if currently tracked (entity replaces it)
|
||||
});
|
||||
flightState._billboards.set(icao24, bb);
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
// Hidden while tracked: the tracked entity replaces it.
|
||||
icons._addIcon(icao24);
|
||||
}
|
||||
|
||||
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
||||
@@ -240,8 +175,12 @@ export function createFlightSnapshotRenderer({
|
||||
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
||||
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absentIds = [];
|
||||
for (let row = 0; row < flightState._icons.count; row++) {
|
||||
const icao24 = flightState._icons.idAt(row);
|
||||
if (!currentIcaos.has(icao24)) absentIds.push(icao24);
|
||||
}
|
||||
for (const icao24 of absentIds) {
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
@@ -249,8 +188,9 @@ export function createFlightSnapshotRenderer({
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
icons._applyIconPresentation(icao24); // the faded freshness alpha
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
// Honest readout: the tracked plane has no faded billboard (its
|
||||
// Honest readout: the tracked plane has no faded icon (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.
|
||||
@@ -269,20 +209,9 @@ export function createFlightSnapshotRenderer({
|
||||
tracking._clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||
records.forget(icao24);
|
||||
flightState._cullPositions.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
icons._forgetContact(icao24); // aged out: its 3D model goes too (no orphan / cap leak)
|
||||
}
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
// Ambient type enrichment: give ON-SCREEN planes real types (bounded
|
||||
// sweep — see _sweepAmbientEnrichment; internally fail-silent).
|
||||
enrichment._sweepAmbientEnrichment();
|
||||
@@ -307,7 +236,7 @@ export function createFlightSnapshotRenderer({
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
excludeObjects: [
|
||||
...flightState._billboards.values(),
|
||||
...icons._iconIds(),
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
].filter(Boolean),
|
||||
@@ -327,6 +256,6 @@ export function createFlightSnapshotRenderer({
|
||||
// in place is safe (the DR extrapolates a zero-velocity fix).
|
||||
motion._refloorStaleGroundedContacts(currentIcaos);
|
||||
|
||||
return { count: flightState._billboards.size, ids: acceptedSnapshotIcaos };
|
||||
return { count: flightState._icons.count, ids: acceptedSnapshotIcaos };
|
||||
};
|
||||
}
|
||||
+26
-16
@@ -11,7 +11,6 @@ export function createFlightState({ source, services }) {
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
@@ -91,22 +90,37 @@ export function createFlightState({ source, services }) {
|
||||
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} */
|
||||
/** Every contact's icon row (see icons.js); null outside a layer lifetime. */
|
||||
|
||||
flightState._billboardCollection = null;
|
||||
flightState._icons = null;
|
||||
|
||||
/** @type {Map<string, Cesium.Billboard>} icao24 -> billboard primitive */
|
||||
/** The primitive drawing `_icons`. */
|
||||
|
||||
flightState._billboards = new Map();
|
||||
flightState._iconPrimitive = null;
|
||||
|
||||
flightState._scratchIconRenderTime = new Cesium.JulianDate();
|
||||
|
||||
/** Contacts whose latest poll put them on the ground. */
|
||||
|
||||
flightState._grounded = new Set();
|
||||
|
||||
/** Grounded contacts the floor currently lifts; their rows hold CPU positions. */
|
||||
|
||||
flightState._floorPinned = new Set();
|
||||
|
||||
/** Fleet ticks since start; far grounded rows take their floor turn by it. */
|
||||
|
||||
flightState._floorTick = 0;
|
||||
|
||||
/** Rows still away from full focus emphasis after the last focus pass. */
|
||||
|
||||
flightState._iconFocusActive = 0;
|
||||
|
||||
/** Stable lightweight records reused by the detection overlay between polls. */
|
||||
|
||||
@@ -204,14 +218,6 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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;
|
||||
@@ -250,6 +256,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchCockpitPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchNearbyPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||
|
||||
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CYAN_TRANSPARENT } from './policy.js';
|
||||
import { mirrorBillboard } from '../../data/billboardMirror.js';
|
||||
import { mirrorModel } from '../../data/modelMirror.js';
|
||||
import { createIconSet } from '../../data/iconSet.js';
|
||||
import { writeHoldSegment } from '../../data/iconSegment.js';
|
||||
|
||||
export function createTesting({
|
||||
flightState,
|
||||
@@ -38,6 +39,19 @@ export function createTesting({
|
||||
flightState._displayFloorState.clear();
|
||||
}
|
||||
|
||||
/** Give a test contact an icon row held at `billboard.position`, shown as
|
||||
* `billboard.show` says (hidden by default while tracked). */
|
||||
|
||||
function _seedIconForTest(icao24, billboard, tracked) {
|
||||
const icons = flightState._icons;
|
||||
const row = icons.add(icao24, { layer: 'flights', id: icao24 });
|
||||
if (billboard?.position)
|
||||
writeHoldSegment(icons.segments, row, billboard.position, 0);
|
||||
icons.segmentWritten(row);
|
||||
icons.setShown(row, billboard?.show ?? !tracked);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
||||
* the production poll reconciler. Tests still call `flightsLayer.update()`;
|
||||
@@ -46,13 +60,14 @@ export function createTesting({
|
||||
* @param {string} state.icao24
|
||||
* @param {object} state.entity
|
||||
* @param {object} state.meta
|
||||
* @param {object} state.billboard
|
||||
* @param {object} state.billboardCollection
|
||||
* @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard]
|
||||
* Where the contact's icon is held, and whether it is shown.
|
||||
* @param {{show?: boolean}} [state.billboardCollection] Layer visibility.
|
||||
* @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.
|
||||
* by icao24, for the icon-hidden/model-shown handoff state.
|
||||
*/
|
||||
|
||||
function _setTrackedFlightRefreshStateForTest({
|
||||
@@ -69,9 +84,13 @@ export function createTesting({
|
||||
}) {
|
||||
flightState._viewer = viewer;
|
||||
flightState._modelCollection = modelCollection;
|
||||
flightState._billboardCollection = billboardCollection;
|
||||
flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]);
|
||||
flightState._cullPositions.clear();
|
||||
flightState._icons = createIconSet({
|
||||
epoch: parts.icons._displayTime(new Cesium.JulianDate()),
|
||||
});
|
||||
flightState._icons.show = billboardCollection?.show ?? true;
|
||||
flightState._grounded.clear();
|
||||
flightState._floorPinned.clear();
|
||||
_seedIconForTest(icao24, billboard, tracked);
|
||||
flightState._models.clear();
|
||||
for (const [key, model] of models)
|
||||
flightState._models.set(key, mirrorModel(model));
|
||||
@@ -120,7 +139,7 @@ export function createTesting({
|
||||
billboard,
|
||||
history = [],
|
||||
}) {
|
||||
flightState._billboards.set(icao24, mirrorBillboard(billboard));
|
||||
_seedIconForTest(icao24, billboard, false);
|
||||
flightState.records.data.set(icao24, meta);
|
||||
flightState._positionHistory.set(icao24, history);
|
||||
}
|
||||
@@ -181,25 +200,64 @@ export function createTesting({
|
||||
: Cesium.Color.CYAN;
|
||||
}
|
||||
|
||||
/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */
|
||||
/** Drive the exact fleet icon-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),
|
||||
flightState._icons.row(icao24),
|
||||
position,
|
||||
course,
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a contact's icon is drawn now and whether the layer shows it. */
|
||||
|
||||
function _iconForTest(icao24) {
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
return position
|
||||
? {
|
||||
position,
|
||||
shown: parts.icons._iconShown(icao24),
|
||||
course: parts.icons._iconCourse(icao24),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
|
||||
|
||||
function _setIconForTest(icao24, { shown, position } = {}) {
|
||||
const icons = flightState._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
if (shown !== undefined) icons.setShown(row, shown);
|
||||
if (position) {
|
||||
writeHoldSegment(icons.segments, row, position, 0);
|
||||
icons.segmentWritten(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the fleet tick's grounded floor pass once, at `renderTime`. */
|
||||
|
||||
function _floorGroundedIconsForTest(renderTime, cameraPosition) {
|
||||
const icons = flightState._icons;
|
||||
parts.icons._floorGroundedIcons(
|
||||
renderTime,
|
||||
icons.seconds(renderTime),
|
||||
Date.now(),
|
||||
cameraPosition,
|
||||
);
|
||||
}
|
||||
return {
|
||||
_floorGroundedIconsForTest,
|
||||
_setIconForTest,
|
||||
_floorGroundedDisplayPositionForTest,
|
||||
_clearDisplayFloorStateForTest,
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
@@ -214,6 +272,7 @@ export function createTesting({
|
||||
_updateTrackedModelForTest,
|
||||
_trackedBillboardColorForTest,
|
||||
_driveFleetModelHandoffForTest,
|
||||
_iconForTest,
|
||||
_ensureFleetModelForTest,
|
||||
};
|
||||
}
|
||||
+75
-101
@@ -27,7 +27,6 @@ import {
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -65,9 +64,9 @@ export function createTracking({
|
||||
}
|
||||
|
||||
function _publishTrackedSelection(icao24, origin = 'programmatic') {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (!billboardState(bb)?.position || !info) return false;
|
||||
if (!position || !info) return false;
|
||||
if (flightState._trackedEntity)
|
||||
entityRecord(flightState._trackedEntity).selectionOrigin = origin;
|
||||
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
||||
@@ -78,7 +77,7 @@ export function createTracking({
|
||||
// a callsign-less contact reached Context as its raw hex even once adsbdb
|
||||
// had supplied a registration. Identity below stays `icao24`.
|
||||
label: parts.queries._contactLabel(icao24, info),
|
||||
position: Cesium.Cartesian3.clone(billboardState(bb).position),
|
||||
position,
|
||||
origin,
|
||||
});
|
||||
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
||||
@@ -163,9 +162,12 @@ export function createTracking({
|
||||
}
|
||||
const previous = flightState._cockpitNearContacts;
|
||||
const distancesSquared = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const position = billboardState(bb)?.position;
|
||||
if (icao24 === flightState._trackedIcao || !position) continue;
|
||||
const icons = flightState._icons;
|
||||
const tau = parts.icons._displayTau();
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
if (icao24 === flightState._trackedIcao) continue;
|
||||
const position = icons.position(row, tau, flightState._scratchCockpitPos);
|
||||
distancesSquared.push([
|
||||
icao24,
|
||||
Cesium.Cartesian3.distanceSquared(
|
||||
@@ -181,13 +183,10 @@ export function createTracking({
|
||||
parts.rendering._modelKeepDistM(),
|
||||
);
|
||||
flightState._cockpitNearContacts = next;
|
||||
let presentationChanged = false;
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (previous.has(icao24) === next.has(icao24)) continue;
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
presentationChanged = true;
|
||||
}
|
||||
if (presentationChanged) flightState._lastCamPoseSig = '';
|
||||
for (const icao24 of previous)
|
||||
if (!next.has(icao24)) parts.icons._applyIconPresentation(icao24);
|
||||
for (const icao24 of next)
|
||||
if (!previous.has(icao24)) parts.icons._applyIconPresentation(icao24);
|
||||
}
|
||||
|
||||
/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */
|
||||
@@ -205,9 +204,9 @@ export function createTracking({
|
||||
if (flightState._modelCollection) flightState._modelCollection.show = true;
|
||||
flightState._trail?.setVisible(!next);
|
||||
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
||||
for (const [icao24, bb] of flightState._billboards)
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
flightState._lastCamPoseSig = '';
|
||||
const icons = flightState._icons;
|
||||
for (let row = 0; row < (icons?.count ?? 0); row++)
|
||||
parts.icons._applyIconPresentation(icons.idAt(row));
|
||||
flightState._lastFleetTickMs = 0;
|
||||
}
|
||||
|
||||
@@ -319,8 +318,7 @@ export function createTracking({
|
||||
if (!flightState._trackedIcao || !flightState._viewer) return;
|
||||
const pos =
|
||||
parts.motion._trackedDisplayCached() ||
|
||||
billboardState(flightState._billboards.get(flightState._trackedIcao))
|
||||
?.position;
|
||||
parts.icons._iconPosition(flightState._trackedIcao);
|
||||
if (!pos) return;
|
||||
const projected = screenProjectedRotation(
|
||||
flightState._viewer.scene,
|
||||
@@ -639,30 +637,9 @@ export function createTracking({
|
||||
const clearedIcao = flightState._trackedIcao;
|
||||
clearFocusTarget('flights', clearedIcao);
|
||||
|
||||
// Restore the original billboard appearance. The rotation is re-seeded from
|
||||
// the tracked entity's last rendered rotation and a rotation pass is forced
|
||||
// (_lastCamPoseSig below): the fleet billboard otherwise reappears with the
|
||||
// STALE screen rotation it had when tracking began — up to a full
|
||||
// ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release.
|
||||
if (flightState._billboards.has(flightState._trackedIcao)) {
|
||||
const bb = flightState._billboards.get(flightState._trackedIcao);
|
||||
setBillboard(bb, 'show', true);
|
||||
setBillboard(bb, 'width', 20);
|
||||
setBillboard(bb, 'height', 20);
|
||||
// Ground/military-aware restore (a plane untracked while taxiing must come
|
||||
// back muted-gray at ground scale, not white at full scale).
|
||||
bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao);
|
||||
setBillboard(
|
||||
bb,
|
||||
'scale',
|
||||
parts.rendering._fleetBillboardScale(
|
||||
flightState._trackedIcao,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
),
|
||||
);
|
||||
setBillboard(bb, 'rotation', flightState._lastTrackedRotation);
|
||||
}
|
||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
||||
// The nose the icon continues from (the shared _displayCourse entry), so
|
||||
// release never flips it.
|
||||
const trackedCourseDeg = flightState._displayCourse.get(clearedIcao);
|
||||
|
||||
// Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the
|
||||
// follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner
|
||||
@@ -679,10 +656,14 @@ export function createTracking({
|
||||
parts.rendering._releaseTrackedModel();
|
||||
_resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped
|
||||
flightState._trackedIcao = null;
|
||||
parts.rendering._applyFleetBillboardPresentation(
|
||||
clearedIcao,
|
||||
flightState._billboards.get(clearedIcao),
|
||||
);
|
||||
// Hand the contact back to its icon only now: settling floors a grounded
|
||||
// contact, and while it still read as tracked its tracked model owned the
|
||||
// visual, so the floor would have stepped aside.
|
||||
if (flightState._icons?.has(clearedIcao)) {
|
||||
flightState._icons.setShown(flightState._icons.row(clearedIcao), true);
|
||||
parts.icons._settleIcon(clearedIcao, { fromCourseDeg: trackedCourseDeg });
|
||||
}
|
||||
parts.icons._applyIconPresentation(clearedIcao);
|
||||
clearTrackedSubjectContext('flights');
|
||||
_emitAwarenessEvent('gev:awareness-subject-cleared', {
|
||||
layerId: 'flights',
|
||||
@@ -730,10 +711,7 @@ export function createTracking({
|
||||
pending.generation !== flightState._trackingIntentGeneration
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
!flightState._billboardCollection?.show ||
|
||||
!flightState._billboards.has(pending.id)
|
||||
)
|
||||
if (!flightState._icons?.show || !flightState._icons.has(pending.id))
|
||||
return false;
|
||||
flightState._pendingTrackingRestore = null;
|
||||
_trackFlight(pending.id, { origin: pending.origin });
|
||||
@@ -837,42 +815,42 @@ export function createTracking({
|
||||
// an already-loaded model would otherwise linger with the billboard hidden.
|
||||
if (flightState._models.has(id) || flightState._modelPending.has(id))
|
||||
parts.rendering._releaseModel(id);
|
||||
const bb = flightState._billboards.get(id);
|
||||
if (bb && id !== flightState._trackedIcao) setBillboard(bb, 'show', true); // horizon cull re-asserts next tick
|
||||
const row = flightState._icons?.row(id) ?? -1;
|
||||
if (row >= 0 && id !== flightState._trackedIcao)
|
||||
flightState._icons.setShown(row, true);
|
||||
if (id === flightState._trackedIcao) {
|
||||
parts.rendering._releaseTrackedModel();
|
||||
_syncTracked2dRotation();
|
||||
}
|
||||
}
|
||||
const bb = flightState._billboards.get(id);
|
||||
if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb);
|
||||
parts.icons._applyIconPresentation(id);
|
||||
if (id === flightState._trackedIcao) {
|
||||
_syncTrackedBillboardImage();
|
||||
_updateTrackedLabelModel(id);
|
||||
}
|
||||
flightState._viewer?.scene?.requestRender?.();
|
||||
return flightState._billboards.has(id) || id === flightState._trackedIcao;
|
||||
return flightState._icons?.has(id) || id === flightState._trackedIcao;
|
||||
}
|
||||
|
||||
/** Re-image every converted contact this layer owns (IR style flip). */
|
||||
|
||||
function _refreshTr3bForStyle() {
|
||||
for (const id of tr3bConvertedIds()) {
|
||||
if (flightState._billboards.has(id) || id === flightState._trackedIcao)
|
||||
if (flightState._icons?.has(id) || id === flightState._trackedIcao)
|
||||
_refreshTr3bContact(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plausibility check anchored to the plane's billboard position (coarse is
|
||||
/** Plausibility check anchored to the plane's icon position (coarse is
|
||||
* fine here — this gates a LABEL, and it must not touch the tracked frame
|
||||
* cache). Missing data → true (never hide what we can't judge). */
|
||||
|
||||
function _routeIsPlausible(icao24, route) {
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (!info || !bb || !billboardState(bb).position) return true;
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
if (!info || !position) return true;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
billboardState(bb).position,
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
flightState._scratchCarto,
|
||||
);
|
||||
@@ -907,29 +885,42 @@ export function createTracking({
|
||||
function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
|
||||
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
|
||||
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const icons = flightState._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (!bb || !info) return;
|
||||
if (row < 0 || !info) return;
|
||||
|
||||
// The tracked limiter continues from the nose the icon was drawn with, and
|
||||
// the entity starts with the rotation the icon had on screen.
|
||||
const tau = parts.icons._displayTau();
|
||||
const iconPosition = icons.position(row, tau);
|
||||
flightState._displayCourse.set(icao24, icons.course(row, tau));
|
||||
flightState._trackedIcao = icao24;
|
||||
_resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._lastTrackedRotation = billboardState(bb).rotation || 0;
|
||||
flightState._lastTrackedRotation =
|
||||
screenProjectedRotation(
|
||||
flightState._viewer.scene,
|
||||
iconPosition,
|
||||
icons.course(row, tau),
|
||||
0,
|
||||
) ?? 0;
|
||||
// Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its
|
||||
// own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover
|
||||
// fleet model would be orphaned + double-rendered.
|
||||
parts.rendering._releaseModel(icao24);
|
||||
|
||||
// Hide the billboard — the tracked entity replaces it visually
|
||||
setBillboard(bb, 'show', false);
|
||||
// Hide the icon — the tracked entity replaces it visually
|
||||
icons.setShown(row, false);
|
||||
|
||||
// Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition —
|
||||
// one computation shared by the position/alignedAxis/rotation/trail-head callbacks,
|
||||
// with discontinuity reconciliation). Falls back to the last billboard position when
|
||||
// with discontinuity reconciliation). Falls back to the icon's position when
|
||||
// the aircraft has no fix.
|
||||
const getTrackedPosition = () =>
|
||||
parts.motion._trackedDisplayPosition(icao24) ||
|
||||
billboardState(bb).position;
|
||||
parts.icons._iconPosition(icao24) ||
|
||||
iconPosition;
|
||||
|
||||
// Dead-reckoning position property — smooth continuous motion between API updates.
|
||||
const positionProperty = new Cesium.CallbackProperty(() => {
|
||||
@@ -1106,23 +1097,19 @@ export function createTracking({
|
||||
*/
|
||||
|
||||
function _onMilitaryActiveChange(active) {
|
||||
if (!flightState._viewer || !flightState._billboardCollection) return;
|
||||
const icons = flightState._icons;
|
||||
if (!flightState._viewer || !icons) return;
|
||||
if (active) {
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao)
|
||||
continue;
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
flightState.records.data.delete(icao24);
|
||||
flightState._cullPositions.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState.records.missingPolls.delete(icao24);
|
||||
const suppressed = [];
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
if (isMilitaryIcao(icao24) && icao24 !== flightState._trackedIcao)
|
||||
suppressed.push(icao24);
|
||||
}
|
||||
flightState.feed._count = flightState._billboards.size;
|
||||
} else if (flightState._billboardCollection.show) {
|
||||
// Military-suppression: drop each duplicate with any 3D model too.
|
||||
for (const icao24 of suppressed) parts.icons._forgetContact(icao24);
|
||||
flightState.feed._count = icons.count;
|
||||
} else if (icons.show) {
|
||||
// Fire-and-forget refresh; only while the layer is actually enabled.
|
||||
void flightsLayer.update(flightState._viewer);
|
||||
}
|
||||
@@ -1212,25 +1199,12 @@ export function createTracking({
|
||||
return;
|
||||
}
|
||||
|
||||
// For BillboardCollection picks, the billboard may be at picked.primitive or picked.id
|
||||
const billboard = picked.primitive;
|
||||
if (
|
||||
billboard &&
|
||||
billboard.id &&
|
||||
flightState._billboards.has(billboard.id)
|
||||
) {
|
||||
// Icon picks carry the icao on picked.id; models carry it on the primitive.
|
||||
const pickedIcao =
|
||||
typeof picked.id === 'string' ? picked.id : picked.primitive?.id;
|
||||
if (pickedIcao && flightState._icons?.has(pickedIcao)) {
|
||||
_cancelPendingTrackingRestore();
|
||||
_trackFlight(billboard.id, { origin: 'user' });
|
||||
return;
|
||||
}
|
||||
// Some CesiumJS versions surface the id as a string on picked.id instead
|
||||
if (
|
||||
picked.id &&
|
||||
typeof picked.id === 'string' &&
|
||||
flightState._billboards.has(picked.id)
|
||||
) {
|
||||
_cancelPendingTrackingRestore();
|
||||
_trackFlight(picked.id, { origin: 'user' });
|
||||
_trackFlight(pickedIcao, { origin: 'user' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,7 +702,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
|
||||
assert.match(
|
||||
civilRendering,
|
||||
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
||||
/alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
|
||||
);
|
||||
assert.match(
|
||||
militaryRendering,
|
||||
|
||||
Reference in new issue
Block a user