mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Draw military flights from the shared GPU icon set
The flights icon planning and presentation move into `createAircraftIcons` (src/data/aircraftIconLayer.js), and the motion regime both layers dead-reckon from into src/data/aircraftMotionRegime.js. Flights keeps only its grounded-floor pass on top. Military now draws every contact as a row of that icon set, like flights: the billboards, the cull positions and the rotation-pass signature go. Tracking hides the row and untracking re-plans it from the tracked course. Queries read the drawn row position and judge the horizon as the shader does. Snapshots that arrive before init or after destroy are ignored, and per-contact lookups find no contact then instead of throwing. With about 120 live military contacts, preRender p95 drops from 0.7-0.8 ms to 0.2 ms on the same views. Render time and frame gaps are unchanged or slightly lower.
This commit is contained in:
1 parent
2c4c66c710
commit
9fde0a2a59
33 files changed
+1256
-1376
No files matched your search
@@ -1316,6 +1316,8 @@
|
|||||||
"src/layers/flights/queries.js",
|
"src/layers/flights/queries.js",
|
||||||
"src/layers/flights/rendering.js",
|
"src/layers/flights/rendering.js",
|
||||||
"src/layers/flights/icons.js",
|
"src/layers/flights/icons.js",
|
||||||
|
"src/data/aircraftIconLayer.js",
|
||||||
|
"src/data/aircraftMotionRegime.js",
|
||||||
"src/data/iconSetPrimitive.js",
|
"src/data/iconSetPrimitive.js",
|
||||||
"src/data/iconSet.js",
|
"src/data/iconSet.js",
|
||||||
"src/data/iconSegment.js",
|
"src/data/iconSegment.js",
|
||||||
@@ -1383,6 +1385,14 @@
|
|||||||
"src/layers/military/policy.js",
|
"src/layers/military/policy.js",
|
||||||
"src/layers/military/queries.js",
|
"src/layers/military/queries.js",
|
||||||
"src/layers/military/rendering.js",
|
"src/layers/military/rendering.js",
|
||||||
|
"src/layers/military/icons.js",
|
||||||
|
"src/data/aircraftIconLayer.js",
|
||||||
|
"src/data/aircraftMotionRegime.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/modelMirror.js",
|
||||||
"src/data/billboardMirror.js",
|
"src/data/billboardMirror.js",
|
||||||
"src/layers/aircraft/visualState.js",
|
"src/layers/aircraft/visualState.js",
|
||||||
@@ -2256,6 +2266,8 @@
|
|||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/flights/icons.js",
|
"src/layers/flights/icons.js",
|
||||||
|
"src/data/aircraftIconLayer.js",
|
||||||
|
"src/data/aircraftMotionRegime.js",
|
||||||
"src/sources/adsbFields.js",
|
"src/sources/adsbFields.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
"src/data/distanceFormat.js",
|
"src/data/distanceFormat.js",
|
||||||
@@ -2604,6 +2616,7 @@
|
|||||||
"src/layers/military/policy.js",
|
"src/layers/military/policy.js",
|
||||||
"src/layers/military/queries.js",
|
"src/layers/military/queries.js",
|
||||||
"src/layers/military/rendering.js",
|
"src/layers/military/rendering.js",
|
||||||
|
"src/layers/military/icons.js",
|
||||||
"src/layers/military/state.js",
|
"src/layers/military/state.js",
|
||||||
"src/layers/military/testing.js",
|
"src/layers/military/testing.js",
|
||||||
"src/layers/military/tracking.js",
|
"src/layers/military/tracking.js",
|
||||||
@@ -3256,6 +3269,8 @@
|
|||||||
"exports": ["./application/layers"],
|
"exports": ["./application/layers"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/flights/icons.js",
|
"src/layers/flights/icons.js",
|
||||||
|
"src/data/aircraftIconLayer.js",
|
||||||
|
"src/data/aircraftMotionRegime.js",
|
||||||
"src/data/iconSetPrimitive.js",
|
"src/data/iconSetPrimitive.js",
|
||||||
"src/data/iconSet.js",
|
"src/data/iconSet.js",
|
||||||
"src/data/iconSegment.js",
|
"src/data/iconSegment.js",
|
||||||
@@ -3521,6 +3536,7 @@
|
|||||||
"src/layers/military/policy.js",
|
"src/layers/military/policy.js",
|
||||||
"src/layers/military/queries.js",
|
"src/layers/military/queries.js",
|
||||||
"src/layers/military/rendering.js",
|
"src/layers/military/rendering.js",
|
||||||
|
"src/layers/military/icons.js",
|
||||||
"src/layers/military/state.js",
|
"src/layers/military/state.js",
|
||||||
"src/layers/military/testing.js",
|
"src/layers/military/testing.js",
|
||||||
"src/layers/military/tracking.js",
|
"src/layers/military/tracking.js",
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
|||||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||||
|
import { createIcons as createMilitaryIcons } from '../layers/military/icons.js';
|
||||||
|
import { createIconSet } from './iconSet.js';
|
||||||
|
import {
|
||||||
|
aircraftMotionRegime,
|
||||||
|
createMotionRegime,
|
||||||
|
} from './aircraftMotionRegime.js';
|
||||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||||
// and civil/military billboard, Cockpit and model tint regressions.
|
// and civil/military billboard, Cockpit and model tint regressions.
|
||||||
@@ -462,18 +468,16 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
|
||||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||||
const bb = {
|
|
||||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
|
||||||
};
|
|
||||||
Object.assign(flightState, {
|
Object.assign(flightState, {
|
||||||
_billboards: new Map([['abc123', bb]]),
|
_icons: createIconSet(),
|
||||||
_models: new Map(),
|
_models: new Map(),
|
||||||
_positionHistory: new Map(),
|
_positionHistory: new Map(),
|
||||||
_cullPositions: new Map(),
|
_displayCourse: new Map(),
|
||||||
_groundSnap: { forget() {} },
|
_groundSnap: { forget() {} },
|
||||||
_cockpitContactMode: false,
|
_cockpitContactMode: false,
|
||||||
|
_cockpitNearContacts: new Set(),
|
||||||
});
|
});
|
||||||
const records = new MilitaryFlightRecords({
|
const records = new MilitaryFlightRecords({
|
||||||
geoidHeight: () => 0,
|
geoidHeight: () => 0,
|
||||||
@@ -481,18 +485,42 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
|||||||
floorAltitudeM: (value) => value,
|
floorAltitudeM: (value) => value,
|
||||||
});
|
});
|
||||||
flightState.records = records;
|
flightState.records = records;
|
||||||
|
const parts = {
|
||||||
|
rendering: {
|
||||||
|
...rendering,
|
||||||
|
_iconKind: () => 'jet',
|
||||||
|
_syncModelToClass() {},
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
_motionRegime: (history, info, renderTime) =>
|
||||||
|
aircraftMotionRegime(
|
||||||
|
history,
|
||||||
|
info,
|
||||||
|
renderTime,
|
||||||
|
{ speed: 'speedMps', track: 'track' },
|
||||||
|
createMotionRegime(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const icons = createMilitaryIcons({
|
||||||
|
flightState,
|
||||||
|
services: { focus: { focusPassIsNeeded: () => false } },
|
||||||
|
parts,
|
||||||
|
});
|
||||||
|
const row = flightState._icons.add('abc123', {
|
||||||
|
layer: 'military',
|
||||||
|
id: 'abc123',
|
||||||
|
});
|
||||||
|
// The focus fade the fleet tick composes lives in its own column.
|
||||||
|
flightState._icons.setFocus(row, 0.65);
|
||||||
const apply = createMilitarySnapshotRenderer({
|
const apply = createMilitarySnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
records,
|
records,
|
||||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||||
meshFloor: { sampleMeshFloorCells() {} },
|
meshFloor: { sampleMeshFloorCells() {} },
|
||||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||||
rendering: {
|
rendering: parts.rendering,
|
||||||
...rendering,
|
icons,
|
||||||
_syncModelToClass() {},
|
|
||||||
_applyFleetBillboardPresentation() {},
|
|
||||||
},
|
|
||||||
motion: {},
|
|
||||||
tracking: {},
|
tracking: {},
|
||||||
queries: {},
|
queries: {},
|
||||||
});
|
});
|
||||||
@@ -518,7 +546,7 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
|||||||
{ observedAtMs: NOW_MS },
|
{ observedAtMs: NOW_MS },
|
||||||
);
|
);
|
||||||
apply(snapshot, {});
|
apply(snapshot, {});
|
||||||
assert.equal(bb.color.green < 0.5, red);
|
assert.equal(flightState._icons.color(row).green < 0.5, red);
|
||||||
assert.equal(bb.color.alpha, 0.65);
|
assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
COURSE_HOLD_SPEED_MPS,
|
||||||
|
courseSlewCapDps,
|
||||||
|
norm180,
|
||||||
|
} from './motionModel.js';
|
||||||
|
import {
|
||||||
|
createIconSet,
|
||||||
|
ICON_CULL_LIFT,
|
||||||
|
ICON_RECEDES,
|
||||||
|
ICON_ROTATES,
|
||||||
|
} from './iconSet.js';
|
||||||
|
import {
|
||||||
|
segmentCourse,
|
||||||
|
writeArcSegment,
|
||||||
|
writeCourseGlide,
|
||||||
|
writeHoldSegment,
|
||||||
|
writeLerpSegment,
|
||||||
|
} from './iconSegment.js';
|
||||||
|
import { IconSetPrimitive } from './iconSetPrimitive.js';
|
||||||
|
import { iconAtlas } from './iconAtlas.js';
|
||||||
|
import {
|
||||||
|
aircraftIcon,
|
||||||
|
FLEET_ICON_PX,
|
||||||
|
TRACKED_ICON_PX,
|
||||||
|
} from './aircraftIcons.js';
|
||||||
|
import { cockpitContactDotImage } from './cockpitContactDot.js';
|
||||||
|
import { horizonOccluder } from './iconOrientation.js';
|
||||||
|
|
||||||
|
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
|
||||||
|
const COCKPIT_DOT_PX = 16;
|
||||||
|
/** Contacts drawn below this height (m) are judged for the horizon a little
|
||||||
|
* above the ellipsoid (ICON_CULL_LIFT). */
|
||||||
|
const CULL_LIFT_BELOW_M = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aircraft layer's contacts as one icon set: each row's motion segment
|
||||||
|
* mirrors the layer's dead reckoning for the delayed display time, so the GPU
|
||||||
|
* moves every untracked contact between polls with no per-frame CPU pass.
|
||||||
|
*
|
||||||
|
* The layer's state supplies `_icons`, `_iconPrimitive`, `_scratchIconRenderTime`,
|
||||||
|
* `_iconFocusActive`, `_positionHistory`, `_displayCourse`, `_groundSnap`,
|
||||||
|
* `_models`, `_trackedIcao`, `_viewer`, `_cockpitContactMode`,
|
||||||
|
* `_cockpitNearContacts` and `records` (`data`, `missingPolls`, `forget`).
|
||||||
|
* `parts.motion._motionRegime` picks the motion; `parts.rendering` supplies
|
||||||
|
* the icon kind, scale, colours and distance scalars, and `_releaseModel`.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.state The layer's mutable state.
|
||||||
|
* @param {object} options.services The layer's services (`focus`).
|
||||||
|
* @param {object} options.parts The layer's parts (`motion`, `rendering`).
|
||||||
|
* @param {string} options.layerId Pick and focus owner id.
|
||||||
|
* @param {number} options.renderDelaySec How far behind real time contacts draw.
|
||||||
|
* @param {number} options.courseMaxDps The fleet's course slew cap.
|
||||||
|
* @param {number} options.cockpitContactSizePx Cockpit far-contact pip size.
|
||||||
|
* @param {(icao24: string) => boolean} [options.holdsPlan] True while something
|
||||||
|
* else writes this row's segment (it is not planned).
|
||||||
|
* @param {(icao24: string) => void} [options.beforeSettle] Runs before a row is
|
||||||
|
* planned afresh.
|
||||||
|
* @param {(icao24: string) => void} [options.afterSettle] Runs after it.
|
||||||
|
* @param {(icao24: string) => void} [options.onForget] Drops the layer's own
|
||||||
|
* per-contact state when a contact goes.
|
||||||
|
*/
|
||||||
|
export function createAircraftIcons({
|
||||||
|
state,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
layerId,
|
||||||
|
renderDelaySec,
|
||||||
|
courseMaxDps,
|
||||||
|
cockpitContactSizePx,
|
||||||
|
holdsPlan = () => false,
|
||||||
|
beforeSettle = () => {},
|
||||||
|
afterSettle = () => {},
|
||||||
|
onForget = () => {},
|
||||||
|
}) {
|
||||||
|
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),
|
||||||
|
-renderDelaySec,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _displayTau() {
|
||||||
|
return state._icons.seconds(_displayTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the set and its primitive for one layer lifetime. */
|
||||||
|
function _createIcons(viewer) {
|
||||||
|
state._icons = createIconSet({ epoch: _displayTime() });
|
||||||
|
state._iconPrimitive = new IconSetPrimitive({
|
||||||
|
set: state._icons,
|
||||||
|
renderTime: () => _displayTime(state._scratchIconRenderTime),
|
||||||
|
});
|
||||||
|
viewer.scene.primitives.add(state._iconPrimitive);
|
||||||
|
return state._iconPrimitive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a contact is drawn right now, or null when it has no row. */
|
||||||
|
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
return row < 0 ? null : set.position(row, _displayTau(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nose direction a contact is drawn with right now. */
|
||||||
|
function _iconCourse(icao24) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
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 = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
return row >= 0 && set.shown(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The camera's horizon test, or null when there is no placed camera. */
|
||||||
|
function _occluder() {
|
||||||
|
const camera = state._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(
|
||||||
|
state._icons.cullPoint(row, position, scratchCull),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the segment the layer's dead reckoning 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 = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0 || holdsPlan(icao24)) return;
|
||||||
|
const history = state._positionHistory.get(icao24);
|
||||||
|
if (!history?.length) return;
|
||||||
|
const info = state.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;
|
||||||
|
if (span > 0) {
|
||||||
|
writeLerpSegment(seg, row, {
|
||||||
|
from: a.position,
|
||||||
|
to: b.position,
|
||||||
|
t0: set.seconds(a.time),
|
||||||
|
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, courseMaxDps),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set.segmentWritten(row, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-plan every row whose regime ended before `tau`. */
|
||||||
|
function _replanExpired(tau) {
|
||||||
|
const set = state._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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, state._iconFocusActive)) return;
|
||||||
|
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
|
||||||
|
services.focus;
|
||||||
|
const set = state._icons;
|
||||||
|
let active = 0;
|
||||||
|
for (let row = 0; row < set.count; row++) {
|
||||||
|
if (!set.shown(row) && !state._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++;
|
||||||
|
}
|
||||||
|
state._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 = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0) return;
|
||||||
|
const rendering = parts.rendering;
|
||||||
|
const info = state.records.data.get(icao24);
|
||||||
|
const tracked = icao24 === state._trackedIcao;
|
||||||
|
const cockpitDot =
|
||||||
|
state._cockpitContactMode &&
|
||||||
|
!tracked &&
|
||||||
|
!state._cockpitNearContacts.has(icao24);
|
||||||
|
if (cockpitDot) {
|
||||||
|
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
|
||||||
|
set.setImage(row, dot, dot);
|
||||||
|
set.setSize(row, cockpitContactSizePx, cockpitContactSizePx);
|
||||||
|
set.setScale(row, 1);
|
||||||
|
cockpitScaleByDistance ??= rendering._cockpitBillboardScaleByDistance();
|
||||||
|
set.setScaleByDistance(row, cockpitScaleByDistance);
|
||||||
|
set.setColor(row, rendering._cockpitContactColor(icao24, info));
|
||||||
|
set.setFlag(row, ICON_ROTATES, false);
|
||||||
|
} else {
|
||||||
|
set.setImage(
|
||||||
|
row,
|
||||||
|
iconAtlas.slot(
|
||||||
|
aircraftIcon(rendering._iconKind(icao24, info?.klass)),
|
||||||
|
FLEET_ICON_PX,
|
||||||
|
),
|
||||||
|
iconAtlas.slot(
|
||||||
|
aircraftIcon(
|
||||||
|
rendering._iconKind(icao24, info?.klass),
|
||||||
|
TRACKED_ICON_PX,
|
||||||
|
),
|
||||||
|
TRACKED_ICON_PX,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const size = tracked ? 24 : 20;
|
||||||
|
set.setSize(row, size, size);
|
||||||
|
set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass));
|
||||||
|
normalScaleByDistance ??= rendering._normalBillboardScaleByDistance();
|
||||||
|
set.setScaleByDistance(row, normalScaleByDistance);
|
||||||
|
set.setColor(row, 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 < CULL_LIFT_BELOW_M,
|
||||||
|
);
|
||||||
|
set.setAlpha(row, state.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 = state._icons;
|
||||||
|
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||||
|
_applyIconPresentation(icao24);
|
||||||
|
_settleIcon(icao24);
|
||||||
|
set.setShown(row, icao24 !== state._trackedIcao);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan a row afresh, with the layer's own settle work on either side, and
|
||||||
|
* let a dimmed row relax: one that sat out the focus pass (tracked) may come
|
||||||
|
* back dimmed after the pass went idle, so it is counted to run the pass.
|
||||||
|
* @param {string} icao24
|
||||||
|
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
|
||||||
|
*/
|
||||||
|
function _settleIcon(icao24, options) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0) return;
|
||||||
|
beforeSettle(icao24);
|
||||||
|
if (set.focus(row) < 1) state._iconFocusActive += 1;
|
||||||
|
_planIcon(icao24, options);
|
||||||
|
afterSettle(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a contact and everything the layer keeps for it. */
|
||||||
|
function _forgetContact(icao24) {
|
||||||
|
state._icons?.remove(icao24);
|
||||||
|
parts.rendering._releaseModel(icao24);
|
||||||
|
state.records.forget(icao24);
|
||||||
|
state._positionHistory.delete(icao24);
|
||||||
|
state._displayCourse.delete(icao24);
|
||||||
|
state._groundSnap.forget(icao24);
|
||||||
|
onForget(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
|
||||||
|
function _iconIds() {
|
||||||
|
const set = state._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,
|
||||||
|
_focusIcons,
|
||||||
|
_applyIconPresentation,
|
||||||
|
_addIcon,
|
||||||
|
_forgetContact,
|
||||||
|
_iconIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seams over an aircraft layer's icon rows, shared by the flights and
|
||||||
|
* military `testing` parts.
|
||||||
|
* @param {object} state The layer state holding `_icons`.
|
||||||
|
* @param {{icons: ReturnType<typeof createAircraftIcons>}} parts Read at call time.
|
||||||
|
* @param {string} layerId
|
||||||
|
*/
|
||||||
|
export function aircraftIconTestSeams(state, parts, layerId) {
|
||||||
|
/** 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 set = state._icons;
|
||||||
|
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||||
|
if (billboard?.position)
|
||||||
|
writeHoldSegment(set.segments, row, billboard.position, 0);
|
||||||
|
set.segmentWritten(row);
|
||||||
|
set.setShown(row, billboard?.show ?? !tracked);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
|
||||||
|
function _setIconForTest(icao24, { shown, position } = {}) {
|
||||||
|
const set = state._icons;
|
||||||
|
const row = set?.row(icao24) ?? -1;
|
||||||
|
if (row < 0) return;
|
||||||
|
if (shown !== undefined) set.setShown(row, shown);
|
||||||
|
if (position) {
|
||||||
|
writeHoldSegment(set.segments, row, position, 0);
|
||||||
|
set.segmentWritten(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { _seedIconForTest, _iconForTest, _setIconForTest };
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
courseBetweenCartesians,
|
||||||
|
speedRamp,
|
||||||
|
staleCoastLimitSeconds,
|
||||||
|
} from './motionModel.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which record fields carry an aircraft layer's reported ground speed (m/s)
|
||||||
|
* and track (degrees clockwise from north).
|
||||||
|
* @typedef {{speed: string, track: string}} AircraftRecordFields
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A fix's own ground speed, else the record's. */
|
||||||
|
export function fixSpeedMps(fix, info, fields) {
|
||||||
|
return Number.isFinite(fix.velocity)
|
||||||
|
? fix.velocity
|
||||||
|
: (info && info[fields.speed]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fix's own track, else the record's. */
|
||||||
|
export function fixTrackDeg(fix, info, fields) {
|
||||||
|
return Number.isFinite(fix.track)
|
||||||
|
? fix.track
|
||||||
|
: (info && info[fields.track]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reusable result for `aircraftMotionRegime`. */
|
||||||
|
export function createMotionRegime() {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which motion an aircraft's history gives at `renderTime` — the one choice
|
||||||
|
* the CPU dead reckoning 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 a poll interval of history exists) runs the OLDEST fix BACKWARD,
|
||||||
|
* preserving the behind-real-time 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. A feed 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.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} history Non-empty, oldest first.
|
||||||
|
* @param {object|undefined} info The contact's record.
|
||||||
|
* @param {Cesium.JulianDate} renderTime
|
||||||
|
* @param {AircraftRecordFields} fields
|
||||||
|
* @param {object} result From `createMotionRegime`; returned filled in.
|
||||||
|
*/
|
||||||
|
export function aircraftMotionRegime(
|
||||||
|
history,
|
||||||
|
info,
|
||||||
|
renderTime,
|
||||||
|
fields,
|
||||||
|
result,
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
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[fields.speed]) || 0;
|
||||||
|
const fallbackTrack = (info && info[fields.track]) || 0;
|
||||||
|
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||||
|
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
|
||||||
|
result.kind = 'between';
|
||||||
|
result.a = a;
|
||||||
|
result.b = b;
|
||||||
|
result.span = span;
|
||||||
|
result.speed = speed;
|
||||||
|
result.trackFrom = trackFrom;
|
||||||
|
result.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||||
|
result.chordCourse =
|
||||||
|
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||||
|
result.blend = blend;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const newest = history[history.length - 1];
|
||||||
|
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||||
|
result.kind = 'fix';
|
||||||
|
result.fix = warming ? history[0] : newest;
|
||||||
|
result.speed = fixSpeedMps(result.fix, info, fields);
|
||||||
|
result.heading = fixTrackDeg(result.fix, info, fields);
|
||||||
|
result.turnRateDps = (info && info.turnRateDps) || 0;
|
||||||
|
result.minDt = -60;
|
||||||
|
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
|
||||||
|
// coasting forward on the same arc.
|
||||||
|
result.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 result;
|
||||||
|
}
|
||||||
@@ -83,24 +83,24 @@ for (const layer of LAYERS) {
|
|||||||
test(`${layer.name}: near AIR state is independent from model admission`, () => {
|
test(`${layer.name}: near AIR state is independent from model admission`, () => {
|
||||||
assert.match(source, /nextCockpitNearContacts\(/,
|
assert.match(source, /nextCockpitNearContacts\(/,
|
||||||
'Cockpit derives a separate near-contact hysteresis set');
|
'Cockpit derives a separate near-contact hysteresis set');
|
||||||
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)|!flightState\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
|
assert.match(source, /!state\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
|
||||||
'only out-of-range Cockpit contacts become dots');
|
'only out-of-range Cockpit contacts become dots');
|
||||||
// `_iconKind` is identity for every unconverted contact (see
|
// `_iconKind` is identity for every unconverted contact (see
|
||||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||||
// operator explicitly converted into a TR-3B.
|
// 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*\)|aircraftIcon\(\s*parts\.rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
|
assert.match(source, /aircraftIcon\(\s*(?:parts\.)?rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
|
||||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||||
assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);|set\.setFlag\(row, ICON_ROTATES, false\);/,
|
assert.match(source, /set\.setFlag\(row, ICON_ROTATES, false\);/,
|
||||||
'far dots are reset to a rotation-free presentation');
|
'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*\)|set\.setFlag\(row, ICON_ROTATES, true\);/,
|
assert.match(source, /set\.setFlag\(row, ICON_ROTATES, true\);/,
|
||||||
'near 2D silhouettes continue to receive projected course');
|
'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|icons\.setShown\(row, false\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
assert.match(source, /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');
|
'the gap-proof icon-to-model handoff remains intact');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
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];
|
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, /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*\);|for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
|
assert.match(setMode, /for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1665,7 +1665,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
|||||||
[
|
[
|
||||||
'militaryFlights',
|
'militaryFlights',
|
||||||
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
/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;/,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'aisLiveVessels',
|
'aisLiveVessels',
|
||||||
|
|||||||
@@ -32,5 +32,7 @@ export const _driveFleetModelHandoffForTest =
|
|||||||
militaryFlightsLayer.testing._driveFleetModelHandoffForTest;
|
militaryFlightsLayer.testing._driveFleetModelHandoffForTest;
|
||||||
export const _ensureFleetModelForTest =
|
export const _ensureFleetModelForTest =
|
||||||
militaryFlightsLayer.testing._ensureFleetModelForTest;
|
militaryFlightsLayer.testing._ensureFleetModelForTest;
|
||||||
|
export const _iconForTest = militaryFlightsLayer.testing._iconForTest;
|
||||||
|
export const _setIconForTest = militaryFlightsLayer.testing._setIconForTest;
|
||||||
export const mapAnalystRecord = militaryFlightsLayer.mapAnalystRecord;
|
export const mapAnalystRecord = militaryFlightsLayer.mapAnalystRecord;
|
||||||
export default militaryFlightsLayer;
|
export default militaryFlightsLayer;
|
||||||
@@ -14,7 +14,10 @@ import flightsLayer, {
|
|||||||
_iconForTest,
|
_iconForTest,
|
||||||
_setTrackedFlightRefreshStateForTest,
|
_setTrackedFlightRefreshStateForTest,
|
||||||
} from './flights.js';
|
} from './flights.js';
|
||||||
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
|
import militaryFlightsLayer, {
|
||||||
|
_iconForTest as _militaryIconForTest,
|
||||||
|
_setTrackedMilitaryRefreshStateForTest,
|
||||||
|
} from './militaryFlights.js';
|
||||||
|
|
||||||
const ICAO = 'abc123';
|
const ICAO = 'abc123';
|
||||||
const OTHER = 'def456';
|
const OTHER = 'def456';
|
||||||
@@ -66,7 +69,7 @@ const LAYERS = [
|
|||||||
name: 'military',
|
name: 'military',
|
||||||
layer: militaryFlightsLayer,
|
layer: militaryFlightsLayer,
|
||||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||||
spritePosition: () => contactBillboard(true).position,
|
spritePosition: () => _militaryIconForTest(ICAO).position,
|
||||||
seed({ billboardShow, models = [] }) {
|
seed({ billboardShow, models = [] }) {
|
||||||
_setTrackedMilitaryRefreshStateForTest({
|
_setTrackedMilitaryRefreshStateForTest({
|
||||||
icao24: ICAO,
|
icao24: ICAO,
|
||||||
|
|||||||
@@ -232,8 +232,8 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
|||||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||||
|
|
||||||
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
|
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
|
||||||
// no TR-3B GLB, so the triangle billboard stays the visual.
|
// no TR-3B GLB, so the triangle icon stays the visual.
|
||||||
assert.match(source, /modelEligible\.has\(icao24\) && !isTr3b\(icao24\)|for \(const icao24 of modelEligible\) \{\s*if \(isTr3b\(icao24\)\) continue;/,
|
assert.match(source, /for \(const icao24 of modelEligible\) \{\s*if \(isTr3b\(icao24\)\) continue;/,
|
||||||
`${name}: fleet model handoff skips converted contacts`);
|
`${name}: fleet model handoff skips converted contacts`);
|
||||||
// The tracked regime became default-on and camera-distance driven
|
// The tracked regime became default-on and camera-distance driven
|
||||||
// (2026-08-19), so the suppression moved from a conjunct on
|
// (2026-08-19), so the suppression moved from a conjunct on
|
||||||
@@ -242,11 +242,11 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
|||||||
assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/,
|
assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/,
|
||||||
`${name}: the standalone tracked model is suppressed for a converted contact`);
|
`${name}: the standalone tracked model is suppressed for a converted contact`);
|
||||||
|
|
||||||
// 2. The billboard is never hidden by that suppression — it must keep
|
// 2. The icon is never hidden by that suppression — it must keep
|
||||||
// satisfying the getNearby/getDetectableObjects visibility guards, so a
|
// satisfying the getNearby/getDetectableObjects visibility guards, so a
|
||||||
// converted contact still works in Contacts and Cockpit.
|
// 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*\);|if\s*\(\s*row >= 0\s*&&\s*id\s*!==\s*flightState\._trackedIcao\s*\)\s*flightState\._icons\.setShown\(row, true\);/,
|
assert.match(source, /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`);
|
`${name}: converting restores the icon the model handoff had hidden`);
|
||||||
|
|
||||||
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
|
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
|
||||||
// refresh path (poll reconciler, raster swap, presentation, tracked
|
// refresh path (poll reconciler, raster swap, presentation, tracked
|
||||||
@@ -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));
|
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||||
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
|
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
|
||||||
// ambient-enrichment candidate loop that also builds a `cand`.
|
// 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*|let row = 0; row < icons\.count; row\+\+)\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0];
|
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\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.ok(loop, `${name}: the model-eligibility candidate loop is present`);
|
||||||
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
||||||
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import militaryFlightsLayer, {
|
|||||||
_trackedBillboardColorForTest as militaryTrackedBillboardColor,
|
_trackedBillboardColorForTest as militaryTrackedBillboardColor,
|
||||||
_driveFleetModelHandoffForTest as militaryDriveFleetModel,
|
_driveFleetModelHandoffForTest as militaryDriveFleetModel,
|
||||||
_ensureFleetModelForTest as militaryEnsureFleetModel,
|
_ensureFleetModelForTest as militaryEnsureFleetModel,
|
||||||
|
_iconForTest as militaryIcon,
|
||||||
|
_setIconForTest as militarySetIcon,
|
||||||
} from './militaryFlights.js';
|
} from './militaryFlights.js';
|
||||||
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
|
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
|
||||||
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
|
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
|
||||||
@@ -188,21 +190,21 @@ function contactBillboard() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A flights contact's icon row, read and written the way these tests read a
|
/** A contact's icon row, read and written the way these tests read a
|
||||||
* billboard: where it is drawn and whether the layer shows it. */
|
* billboard: where it is drawn and whether the layer shows it. */
|
||||||
function flightIconView(icao24) {
|
function iconView(icao24, readIcon, setIcon) {
|
||||||
return {
|
return {
|
||||||
get position() {
|
get position() {
|
||||||
return flightsIcon(icao24)?.position;
|
return readIcon(icao24)?.position;
|
||||||
},
|
},
|
||||||
set position(position) {
|
set position(position) {
|
||||||
flightsSetIcon(icao24, { position });
|
setIcon(icao24, { position });
|
||||||
},
|
},
|
||||||
get show() {
|
get show() {
|
||||||
return flightsIcon(icao24)?.shown === true;
|
return readIcon(icao24)?.shown === true;
|
||||||
},
|
},
|
||||||
set show(shown) {
|
set show(shown) {
|
||||||
flightsSetIcon(icao24, { shown });
|
setIcon(icao24, { shown });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -237,7 +239,7 @@ const LAYERS = [
|
|||||||
modelCollection,
|
modelCollection,
|
||||||
meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround },
|
meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround },
|
||||||
});
|
});
|
||||||
return flightIconView(icao24);
|
return iconView(icao24, flightsIcon, flightsSetIcon);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -262,7 +264,7 @@ const LAYERS = [
|
|||||||
modelCollection,
|
modelCollection,
|
||||||
meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround },
|
meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround },
|
||||||
});
|
});
|
||||||
return billboard;
|
return iconView(icao24, militaryIcon, militarySetIcon);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+37
-361
@@ -1,35 +1,11 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { courseSlewCapDps, norm180 } from '../../data/motionModel.js';
|
||||||
import {
|
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,
|
segmentTargetCourse,
|
||||||
writeArcSegment,
|
|
||||||
writeCourseGlide,
|
writeCourseGlide,
|
||||||
writeHoldSegment,
|
writeHoldSegment,
|
||||||
writeLerpSegment,
|
|
||||||
} from '../../data/iconSegment.js';
|
} from '../../data/iconSegment.js';
|
||||||
import { IconSetPrimitive } from '../../data/iconSetPrimitive.js';
|
import { createAircraftIcons } from '../../data/aircraftIconLayer.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 {
|
import {
|
||||||
COCKPIT_CONTACT_SIZE_PX,
|
COCKPIT_CONTACT_SIZE_PX,
|
||||||
COURSE_MAX_DPS,
|
COURSE_MAX_DPS,
|
||||||
@@ -38,184 +14,48 @@ import {
|
|||||||
RENDER_DELAY_SEC,
|
RENDER_DELAY_SEC,
|
||||||
} from './policy.js';
|
} 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 (°). */
|
/** Pinned grounded rows rewrite only past this displayed move (m²) or turn (°). */
|
||||||
const PIN_MOVE_SQ_M = 1;
|
const PIN_MOVE_SQ_M = 1;
|
||||||
const PIN_TURN_DEG = 0.1;
|
const PIN_TURN_DEG = 0.1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The flights layer's contacts as one icon set: each row's motion segment
|
* The flights layer's contacts as one icon set (`createAircraftIcons`), plus
|
||||||
* mirrors `_deadReckon`'s regime for the delayed display time, so the GPU
|
* the grounded floor: a floor-pinned row holds a CPU position, so it is not
|
||||||
* moves every untracked contact between polls with no per-frame CPU pass.
|
* planned, and settling a grounded contact puts it straight onto the floor.
|
||||||
*/
|
*/
|
||||||
export function createIcons({ flightState, services, parts }) {
|
export function createIcons({ flightState, services, parts }) {
|
||||||
const { focusPassIsNeeded } = services.focus;
|
|
||||||
const scratchTime = new Cesium.JulianDate();
|
|
||||||
const scratchPosition = new Cesium.Cartesian3();
|
const scratchPosition = new Cesium.Cartesian3();
|
||||||
const scratchCull = new Cesium.Cartesian3();
|
const icons = createAircraftIcons({
|
||||||
const scratchScaleByDistance = new Cesium.NearFarScalar();
|
state: flightState,
|
||||||
let normalScaleByDistance = null;
|
services,
|
||||||
let cockpitScaleByDistance = null;
|
parts,
|
||||||
|
layerId: 'flights',
|
||||||
/** The delayed instant every untracked contact is drawn at. */
|
renderDelaySec: RENDER_DELAY_SEC,
|
||||||
function _displayTime(result = scratchTime) {
|
courseMaxDps: COURSE_MAX_DPS,
|
||||||
return Cesium.JulianDate.addSeconds(
|
cockpitContactSizePx: COCKPIT_CONTACT_SIZE_PX,
|
||||||
Cesium.JulianDate.now(result),
|
holdsPlan: (icao24) =>
|
||||||
-RENDER_DELAY_SEC,
|
flightState._focusEvidenceIds.has(icao24) ||
|
||||||
result,
|
flightState._floorPinned.has(icao24),
|
||||||
);
|
// A pin stops at the last floor pass, which skips a tracked contact.
|
||||||
}
|
beforeSettle: (icao24) => flightState._floorPinned.delete(icao24),
|
||||||
|
// Straight onto the floor under where it is drawn, not its fix's floor
|
||||||
function _displayTau() {
|
// until the next fleet tick raises it.
|
||||||
return flightState._icons.seconds(_displayTime());
|
afterSettle(icao24) {
|
||||||
}
|
if (!flightState._grounded.has(icao24)) return;
|
||||||
|
const renderTime = icons._displayTime();
|
||||||
/** Create the set and its primitive for one layer lifetime. */
|
_floorIcon(
|
||||||
function _createIcons(viewer) {
|
icao24,
|
||||||
flightState._icons = createIconSet({ epoch: _displayTime() });
|
renderTime,
|
||||||
flightState._iconPrimitive = new IconSetPrimitive({
|
flightState._icons.seconds(renderTime),
|
||||||
set: flightState._icons,
|
Date.now(),
|
||||||
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);
|
onForget(icao24) {
|
||||||
}
|
flightState._displayFloorState.delete(icao24);
|
||||||
|
flightState._grounded.delete(icao24);
|
||||||
/** Re-plan every row whose regime ended before `tau`. */
|
flightState._floorPinned.delete(icao24);
|
||||||
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
|
* Grounded contacts stand on the floor under where they are DRAWN, which the
|
||||||
@@ -272,7 +112,7 @@ export function createIcons({ flightState, services, parts }) {
|
|||||||
);
|
);
|
||||||
if (!display || display === raw) {
|
if (!display || display === raw) {
|
||||||
if (flightState._floorPinned.delete(icao24))
|
if (flightState._floorPinned.delete(icao24))
|
||||||
_planIcon(icao24, { fromCourseDeg: set.course(row, tau) });
|
icons._planIcon(icao24, { fromCourseDeg: set.course(row, tau) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const planned = set.expires(row) !== -Infinity;
|
const planned = set.expires(row) !== -Infinity;
|
||||||
@@ -303,169 +143,5 @@ export function createIcons({ flightState, services, parts }) {
|
|||||||
set.segmentWritten(row);
|
set.segmentWritten(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return { ...icons, _floorGroundedIcons };
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
+22
-113
@@ -2,9 +2,6 @@ import * as Cesium from 'cesium';
|
|||||||
import {
|
import {
|
||||||
COURSE_HOLD_SPEED_MPS,
|
COURSE_HOLD_SPEED_MPS,
|
||||||
lerpAngleDeg,
|
lerpAngleDeg,
|
||||||
speedRamp,
|
|
||||||
courseBetweenCartesians,
|
|
||||||
staleCoastLimitSeconds,
|
|
||||||
arcOffsetEnu,
|
arcOffsetEnu,
|
||||||
courseSlewCapDps,
|
courseSlewCapDps,
|
||||||
limitCourseStep,
|
limitCourseStep,
|
||||||
@@ -37,20 +34,15 @@ import {
|
|||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { modelState } from '../../data/modelMirror.js';
|
import { modelState } from '../../data/modelMirror.js';
|
||||||
import { readEntityRecord } from '../../data/entityRecords.js';
|
import { readEntityRecord } from '../../data/entityRecords.js';
|
||||||
|
import {
|
||||||
|
aircraftMotionRegime,
|
||||||
|
createMotionRegime,
|
||||||
|
fixSpeedMps,
|
||||||
|
fixTrackDeg,
|
||||||
|
} from '../../data/aircraftMotionRegime.js';
|
||||||
|
|
||||||
/** A fix's own ground speed, else the record's. */
|
/** Where a flights record carries its reported speed and track. */
|
||||||
function fixSpeedMps(fix, info) {
|
const RECORD_FIELDS = { speed: 'velocity', track: 'true_track' };
|
||||||
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({
|
export function createMotion({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -80,22 +72,7 @@ export function createMotion({
|
|||||||
// the local frame even when the existing Cartesian is mutated in place.
|
// the local frame even when the existing Cartesian is mutated in place.
|
||||||
const fixFrames = new WeakMap();
|
const fixFrames = new WeakMap();
|
||||||
/** `_motionRegime`'s reused result. */
|
/** `_motionRegime`'s reused result. */
|
||||||
const regime = {
|
const regime = createMotionRegime();
|
||||||
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
|
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
|
||||||
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
||||||
@@ -221,86 +198,18 @@ export function createMotion({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Which motion a contact's history gives at `renderTime` — the one choice
|
* Which motion a contact's history gives at `renderTime` — the one choice
|
||||||
* `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from.
|
* `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from
|
||||||
*
|
* (`aircraftMotionRegime`). The result is a reused scratch: read it before
|
||||||
* - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation
|
* the next call.
|
||||||
* 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) {
|
function _motionRegime(history, info, renderTime) {
|
||||||
for (let i = history.length - 1; i >= 1; i--) {
|
return aircraftMotionRegime(
|
||||||
const a = history[i - 1];
|
history,
|
||||||
const b = history[i];
|
info,
|
||||||
if (
|
renderTime,
|
||||||
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
|
RECORD_FIELDS,
|
||||||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
regime,
|
||||||
)
|
);
|
||||||
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 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -315,8 +224,8 @@ export function createMotion({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
||||||
const speed = fixSpeedMps(fix, info);
|
const speed = fixSpeedMps(fix, info, RECORD_FIELDS);
|
||||||
const heading = fixTrackDeg(fix, info);
|
const heading = fixTrackDeg(fix, info, RECORD_FIELDS);
|
||||||
flightState._drSpeedMps = speed;
|
flightState._drSpeedMps = speed;
|
||||||
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
||||||
flightState._drExtrapolating = true;
|
flightState._drExtrapolating = true;
|
||||||
@@ -533,7 +442,7 @@ export function createMotion({
|
|||||||
* readout run in postRender at a LATER frameNumber, so calling _trackedDisplayPosition there would
|
* readout run in postRender at a LATER frameNumber, so calling _trackedDisplayPosition there would
|
||||||
* re-run the dead-reckon on a fresh sample and double-advance the reconciliation → the label jitters
|
* re-run the dead-reckon on a fresh sample and double-advance the reconciliation → the label jitters
|
||||||
* against the now-stable plane (the model's jitter fix, resurfacing in the labels). Returns null when
|
* against the now-stable plane (the model's jitter fix, resurfacing in the labels). Returns null when
|
||||||
* there's no valid fix for the tracked aircraft, so callers fall back to the billboard position. */
|
* there's no valid fix for the tracked aircraft, so callers fall back to the icon position. */
|
||||||
|
|
||||||
function _trackedDisplayCached() {
|
function _trackedDisplayCached() {
|
||||||
return flightState._drReconcileValid &&
|
return flightState._drReconcileValid &&
|
||||||
|
|||||||
@@ -147,3 +147,17 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
|
|||||||
'0°',
|
'0°',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a flight layer without an icon set declines contact refreshes', () => {
|
||||||
|
// Before init and after destroy there are no icon rows: a TR-3B refresh
|
||||||
|
// reports that the layer owns no such contact instead of throwing.
|
||||||
|
const layer = createCivilFlightLayer({
|
||||||
|
source: { label: 'Fixture aircraft', getSnapshot() {} },
|
||||||
|
services: {
|
||||||
|
...services(),
|
||||||
|
aircraftPresentation: { isTr3b: () => true, tr3bConvertedIds: () => [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(layer.refreshTr3b('abc123'), false);
|
||||||
|
assert.equal(layer.trackById('abc123'), false);
|
||||||
|
});
|
||||||
@@ -181,23 +181,17 @@ export const LANDED_SPEED_MAX_MPS = 23;
|
|||||||
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
|
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Icon orientation (2026-06-10 playtest fix): rotation is computed by
|
// Fleet icons are one GPU icon set (iconSet.js): the shader dead-reckons each
|
||||||
// projecting each aircraft's course vector into WINDOW coordinates
|
// row's motion segment, projects its course into window coordinates and
|
||||||
// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera
|
// horizon-culls it (with the Cesium globe hidden there is no far-side depth,
|
||||||
// pitch/heading, including tracked-entity orbit mode. Rotation passes run on
|
// so planes otherwise show through the planet). The fleet tick only re-plans
|
||||||
// the fleet tick only when the camera pose changed (or 1s drift catch-up).
|
// expired rows and drives the model handoff.
|
||||||
// The same tick horizon-culls billboards: with the Cesium globe hidden there
|
|
||||||
// is no far-side depth, so planes otherwise show through the planet.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
||||||
|
|
||||||
export const FLEET_DR_INTERVAL_MS = 80;
|
export const FLEET_DR_INTERVAL_MS = 80;
|
||||||
|
|
||||||
/** @constant {number} Max ms between rotation passes while the camera is idle. */
|
|
||||||
|
|
||||||
export const ROTATION_REFRESH_MS = 1000;
|
|
||||||
|
|
||||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||||
|
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ export function createRendering({
|
|||||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
|
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
|
||||||
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
||||||
if (epoch === flightState._modelEpoch) {
|
if (epoch === flightState._modelEpoch) {
|
||||||
flightState._modelPending.delete(icao24);
|
flightState._modelPending.delete(icao24);
|
||||||
@@ -509,7 +509,7 @@ export function createRendering({
|
|||||||
visual.specKey = specKey; // class-change sync compares against this
|
visual.specKey = specKey; // class-change sync compares against this
|
||||||
visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this
|
visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this
|
||||||
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
||||||
// an unplaced primitive claim ownership from the billboard for the frames
|
// an unplaced primitive claim ownership from the icon for the frames
|
||||||
// between admission and the next fleet tick (and draw at the identity matrix,
|
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||||
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||||
setModel(model, 'show', false);
|
setModel(model, 'show', false);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function createFlightSnapshotRenderer({
|
|||||||
const { warmGroundFloor } = groundFloor;
|
const { warmGroundFloor } = groundFloor;
|
||||||
const { sampleMeshFloorCells } = meshFloor;
|
const { sampleMeshFloorCells } = meshFloor;
|
||||||
return function applySnapshot(snapshot, viewer) {
|
return function applySnapshot(snapshot, viewer) {
|
||||||
|
// Before init (or after destroy) there is no icon set to draw into.
|
||||||
|
if (!flightState._icons) return { count: 0, ids: new Set() };
|
||||||
const currentIcaos = new Set();
|
const currentIcaos = new Set();
|
||||||
const acceptedSnapshotIcaos = new Set();
|
const acceptedSnapshotIcaos = new Set();
|
||||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as Cesium from 'cesium';
|
|||||||
import { CYAN_TRANSPARENT } from './policy.js';
|
import { CYAN_TRANSPARENT } from './policy.js';
|
||||||
import { mirrorModel } from '../../data/modelMirror.js';
|
import { mirrorModel } from '../../data/modelMirror.js';
|
||||||
import { createIconSet } from '../../data/iconSet.js';
|
import { createIconSet } from '../../data/iconSet.js';
|
||||||
import { writeHoldSegment } from '../../data/iconSegment.js';
|
import { aircraftIconTestSeams } from '../../data/aircraftIconLayer.js';
|
||||||
|
|
||||||
export function createTesting({
|
export function createTesting({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -11,6 +11,8 @@ export function createTesting({
|
|||||||
layer,
|
layer,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
|
const { _seedIconForTest, _iconForTest, _setIconForTest } =
|
||||||
|
aircraftIconTestSeams(flightState, parts, 'flights');
|
||||||
/** Test seam for the display-floor clamp (unit-tested against real Cesium math
|
/** Test seam for the display-floor clamp (unit-tested against real Cesium math
|
||||||
* with seeded mesh cells — the drift mechanism is otherwise only reachable
|
* with seeded mesh cells — the drift mechanism is otherwise only reachable
|
||||||
* through a live poll + render loop). */
|
* through a live poll + render loop). */
|
||||||
@@ -39,19 +41,6 @@ export function createTesting({
|
|||||||
flightState._displayFloorState.clear();
|
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
|
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
||||||
* the production poll reconciler. Tests still call `flightsLayer.update()`;
|
* the production poll reconciler. Tests still call `flightsLayer.update()`;
|
||||||
@@ -212,38 +201,12 @@ export function createTesting({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
|
||||||
|
|
||||||
async function _ensureFleetModelForTest(icao24) {
|
async function _ensureFleetModelForTest(icao24) {
|
||||||
await parts.rendering._ensureModel(icao24);
|
await parts.rendering._ensureModel(icao24);
|
||||||
return flightState._models.get(icao24) || null;
|
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`. */
|
/** Run the fleet tick's grounded floor pass once, at `renderTime`. */
|
||||||
|
|
||||||
function _floorGroundedIconsForTest(renderTime, cameraPosition) {
|
function _floorGroundedIconsForTest(renderTime, cameraPosition) {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function createTracking({
|
|||||||
const distancesSquared = [];
|
const distancesSquared = [];
|
||||||
const icons = flightState._icons;
|
const icons = flightState._icons;
|
||||||
const tau = parts.icons._displayTau();
|
const tau = parts.icons._displayTau();
|
||||||
for (let row = 0; row < icons.count; row++) {
|
for (let row = 0; row < (icons?.count ?? 0); row++) {
|
||||||
const icao24 = icons.idAt(row);
|
const icao24 = icons.idAt(row);
|
||||||
if (icao24 === flightState._trackedIcao) continue;
|
if (icao24 === flightState._trackedIcao) continue;
|
||||||
const position = icons.position(row, tau, flightState._scratchCockpitPos);
|
const position = icons.position(row, tau, flightState._scratchCockpitPos);
|
||||||
@@ -987,7 +987,7 @@ export function createTracking({
|
|||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||||
// The tracked target must never vanish into tile geometry — tracking a
|
// The tracked target must never vanish into tile geometry — tracking a
|
||||||
// taxiing plane at street level would otherwise bury the cyan icon inside
|
// taxiing plane at street level would otherwise bury the cyan icon inside
|
||||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
// the runway skin, so it draws depth-test-free like the fleet icon set;
|
||||||
// its shared-host tracked card is top-composited separately.
|
// its shared-host tracked card is top-composited separately.
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createAircraftIcons } from '../../data/aircraftIconLayer.js';
|
||||||
|
import {
|
||||||
|
COCKPIT_CONTACT_SIZE_PX,
|
||||||
|
COURSE_MAX_DPS,
|
||||||
|
RENDER_DELAY_SEC,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
/** The military layer's contacts as one icon set (`createAircraftIcons`). */
|
||||||
|
export function createIcons({ flightState, services, parts }) {
|
||||||
|
return createAircraftIcons({
|
||||||
|
state: flightState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
layerId: 'military',
|
||||||
|
renderDelaySec: RENDER_DELAY_SEC,
|
||||||
|
courseMaxDps: COURSE_MAX_DPS,
|
||||||
|
cockpitContactSizePx: COCKPIT_CONTACT_SIZE_PX,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createMilitarySnapshotRenderer } from './snapshotRenderer.js';
|
import { createMilitarySnapshotRenderer } from './snapshotRenderer.js';
|
||||||
import { createFlightState } from './state.js';
|
import { createFlightState } from './state.js';
|
||||||
import { createRendering } from './rendering.js';
|
import { createRendering } from './rendering.js';
|
||||||
|
import { createIcons } from './icons.js';
|
||||||
import { createMotion } from './motion.js';
|
import { createMotion } from './motion.js';
|
||||||
import { createTracking } from './tracking.js';
|
import { createTracking } from './tracking.js';
|
||||||
import { createController } from './controller.js';
|
import { createController } from './controller.js';
|
||||||
@@ -19,6 +20,7 @@ export function createMilitaryFlightLayer({
|
|||||||
const layer = {};
|
const layer = {};
|
||||||
const context = { flightState, services, parts, layer, resolveAsset };
|
const context = { flightState, services, parts, layer, resolveAsset };
|
||||||
parts.rendering = createRendering(context);
|
parts.rendering = createRendering(context);
|
||||||
|
parts.icons = createIcons(context);
|
||||||
parts.motion = createMotion(context);
|
parts.motion = createMotion(context);
|
||||||
parts.tracking = createTracking(context);
|
parts.tracking = createTracking(context);
|
||||||
parts.controller = createController(context);
|
parts.controller = createController(context);
|
||||||
@@ -33,7 +35,7 @@ export function createMilitaryFlightLayer({
|
|||||||
militaryRegistry: services.militaryRegistry,
|
militaryRegistry: services.militaryRegistry,
|
||||||
rendering: parts.rendering,
|
rendering: parts.rendering,
|
||||||
tracking: parts.tracking,
|
tracking: parts.tracking,
|
||||||
motion: parts.motion,
|
icons: parts.icons,
|
||||||
queries: parts.queries,
|
queries: parts.queries,
|
||||||
});
|
});
|
||||||
parts.ingestion = createIngestion({
|
parts.ingestion = createIngestion({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function createLifecycle({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the layer: create the billboard collection, reset all state,
|
* Initialize the layer: create the icon set and its primitive, reset all state,
|
||||||
* and install the click-to-track handler.
|
* and install the click-to-track handler.
|
||||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||||
*/
|
*/
|
||||||
@@ -42,9 +42,7 @@ export function createLifecycle({
|
|||||||
flightState.lifetime = new AbortController();
|
flightState.lifetime = new AbortController();
|
||||||
clearFocusTarget('militaryFlights');
|
clearFocusTarget('militaryFlights');
|
||||||
flightState._viewer = viewer;
|
flightState._viewer = viewer;
|
||||||
flightState._billboardCollection = new Cesium.BillboardCollection();
|
registerSpriteCollection('military', parts.icons._createIcons(viewer));
|
||||||
viewer.scene.primitives.add(flightState._billboardCollection);
|
|
||||||
registerSpriteCollection('military', flightState._billboardCollection);
|
|
||||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
||||||
viewer.scene.primitives.add(flightState._modelCollection);
|
viewer.scene.primitives.add(flightState._modelCollection);
|
||||||
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
||||||
@@ -69,11 +67,10 @@ export function createLifecycle({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* tracked plane just stays a billboard a beat longer */
|
/* tracked plane just stays an icon a beat longer */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
flightState._billboards = new Map();
|
flightState._iconFocusActive = 0;
|
||||||
flightState._cullPositions.clear();
|
|
||||||
flightState._detectionObjects = new Map();
|
flightState._detectionObjects = new Map();
|
||||||
flightState.records.data = new Map();
|
flightState.records.data = new Map();
|
||||||
flightState._positionHistory = new Map();
|
flightState._positionHistory = new Map();
|
||||||
@@ -105,7 +102,7 @@ export function createLifecycle({
|
|||||||
|
|
||||||
restoreSpriteOrder(viewer);
|
restoreSpriteOrder(viewer);
|
||||||
|
|
||||||
console.log('[Data:Military] Initialized with billboard icons');
|
console.log('[Data:Military] Initialized with GPU icons');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,8 +110,7 @@ export function createLifecycle({
|
|||||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||||
*/
|
*/
|
||||||
enable(viewer) {
|
enable(viewer) {
|
||||||
if (flightState._billboardCollection)
|
if (flightState._icons) flightState._icons.show = true;
|
||||||
flightState._billboardCollection.show = true;
|
|
||||||
holdContinuousRender('military'); // per-frame animator (perf wave 2)
|
holdContinuousRender('military'); // per-frame animator (perf wave 2)
|
||||||
if (flightState._modelCollection)
|
if (flightState._modelCollection)
|
||||||
flightState._modelCollection.show = true;
|
flightState._modelCollection.show = true;
|
||||||
@@ -135,13 +131,12 @@ export function createLifecycle({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
parts.tracking._installClickHandler(viewer);
|
parts.tracking._installClickHandler(viewer);
|
||||||
registerPickOwner('military', (pickedId) =>
|
registerPickOwner(
|
||||||
flightState._billboards.has(pickedId),
|
'military',
|
||||||
|
(pickedId) => flightState._icons?.has(pickedId) === true,
|
||||||
);
|
);
|
||||||
// The flights layer suppresses its military duplicates while we render them
|
// The flights layer suppresses its military duplicates while we render them
|
||||||
setMilitaryLayerActive(true);
|
setMilitaryLayerActive(true);
|
||||||
// Force a fresh rotation pass on the first tick after re-enable
|
|
||||||
flightState._lastCamPoseSig = '';
|
|
||||||
if (!flightState._preRenderRemove && viewer?.scene) {
|
if (!flightState._preRenderRemove && viewer?.scene) {
|
||||||
flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
|
flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
|
||||||
parts.rendering._fleetTick,
|
parts.rendering._fleetTick,
|
||||||
@@ -154,14 +149,11 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!flightState._moveEndRemove && viewer?.camera) {
|
if (!flightState._moveEndRemove && viewer?.camera) {
|
||||||
// Arrival polish (mirror of flights.js — see the comment there): a settled
|
// A settled camera move (voice fly-to, fast pan release) runs the fleet
|
||||||
// camera move forces a full rotation pass on the very next frame, because
|
// pass on the very next preRender rather than up to FLEET_DR_INTERVAL_MS
|
||||||
// the pose-signature gate can eat the settle (final easing frames land
|
// later, so the model handoff and focus land with the camera.
|
||||||
// inside one quantization bucket) and leave stale noses for up to
|
|
||||||
// ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame.
|
|
||||||
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||||
() => {
|
() => {
|
||||||
flightState._lastCamPoseSig = '';
|
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -177,8 +169,7 @@ export function createLifecycle({
|
|||||||
disable(viewer) {
|
disable(viewer) {
|
||||||
parts.controller._abortActiveUpdates();
|
parts.controller._abortActiveUpdates();
|
||||||
parts.tracking._cancelPendingTrackingRestore();
|
parts.tracking._cancelPendingTrackingRestore();
|
||||||
if (flightState._billboardCollection)
|
if (flightState._icons) flightState._icons.show = false;
|
||||||
flightState._billboardCollection.show = false;
|
|
||||||
releaseContinuousRender('military');
|
releaseContinuousRender('military');
|
||||||
parts.rendering._releaseModels();
|
parts.rendering._releaseModels();
|
||||||
if (flightState._modelCollection)
|
if (flightState._modelCollection)
|
||||||
@@ -214,8 +205,8 @@ export function createLifecycle({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tear down the layer: clear tracking, remove handlers, remove the billboard
|
* Tear down the layer: clear tracking, remove handlers, remove the icon
|
||||||
* collection from the scene, and release all state.
|
* primitive from the scene, and release all state.
|
||||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||||
*/
|
*/
|
||||||
destroy(viewer) {
|
destroy(viewer) {
|
||||||
@@ -255,10 +246,12 @@ export function createLifecycle({
|
|||||||
flightState._moveEndRemove = null;
|
flightState._moveEndRemove = null;
|
||||||
}
|
}
|
||||||
parts.rendering._releaseModels();
|
parts.rendering._releaseModels();
|
||||||
if (flightState._billboardCollection) {
|
if (flightState._iconPrimitive) {
|
||||||
viewer.scene.primitives.remove(flightState._billboardCollection);
|
viewer.scene.primitives.remove(flightState._iconPrimitive); // removing destroys it
|
||||||
flightState._billboardCollection = null;
|
flightState._iconPrimitive = null;
|
||||||
}
|
}
|
||||||
|
flightState._icons?.clear();
|
||||||
|
flightState._icons = null;
|
||||||
if (flightState._modelCollection) {
|
if (flightState._modelCollection) {
|
||||||
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
||||||
flightState._modelCollection = null;
|
flightState._modelCollection = null;
|
||||||
@@ -275,8 +268,7 @@ export function createLifecycle({
|
|||||||
flightState._preloadModel = null;
|
flightState._preloadModel = null;
|
||||||
}
|
}
|
||||||
flightState._planeModelLoaded = false;
|
flightState._planeModelLoaded = false;
|
||||||
flightState._billboards.clear();
|
flightState._iconFocusActive = 0;
|
||||||
flightState._cullPositions.clear();
|
|
||||||
flightState._detectionObjects.clear();
|
flightState._detectionObjects.clear();
|
||||||
flightState.records.data.clear();
|
flightState.records.data.clear();
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
lerpAngleDeg,
|
lerpAngleDeg,
|
||||||
speedRamp,
|
|
||||||
courseBetweenCartesians,
|
|
||||||
COURSE_HOLD_SPEED_MPS,
|
COURSE_HOLD_SPEED_MPS,
|
||||||
staleCoastLimitSeconds,
|
|
||||||
arcOffsetEnu,
|
arcOffsetEnu,
|
||||||
courseSlewCapDps,
|
courseSlewCapDps,
|
||||||
limitCourseStep,
|
limitCourseStep,
|
||||||
@@ -26,6 +23,15 @@ import {
|
|||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { modelState } from '../../data/modelMirror.js';
|
import { modelState } from '../../data/modelMirror.js';
|
||||||
import { readEntityRecord } from '../../data/entityRecords.js';
|
import { readEntityRecord } from '../../data/entityRecords.js';
|
||||||
|
import {
|
||||||
|
aircraftMotionRegime,
|
||||||
|
createMotionRegime,
|
||||||
|
fixSpeedMps,
|
||||||
|
fixTrackDeg,
|
||||||
|
} from '../../data/aircraftMotionRegime.js';
|
||||||
|
|
||||||
|
/** Where a military record carries its reported speed and track. */
|
||||||
|
const RECORD_FIELDS = { speed: 'speedMps', track: 'track' };
|
||||||
|
|
||||||
export function createMotion({
|
export function createMotion({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -40,6 +46,8 @@ export function createMotion({
|
|||||||
clearFocusTarget,
|
clearFocusTarget,
|
||||||
publishFocusTargetFromCachedPosition,
|
publishFocusTargetFromCachedPosition,
|
||||||
} = services.focus;
|
} = services.focus;
|
||||||
|
/** `_motionRegime`'s reused result. */
|
||||||
|
const regime = createMotionRegime();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math.
|
* Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math.
|
||||||
@@ -74,90 +82,43 @@ export function createMotion({
|
|||||||
flightState._scratchRenderTime,
|
flightState._scratchRenderTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
|
const regime = _motionRegime(history, info, renderTime);
|
||||||
for (let i = history.length - 1; i >= 1; i--) {
|
if (regime.kind === 'between') {
|
||||||
const a = history[i - 1];
|
const { a, b, span } = regime;
|
||||||
const b = history[i];
|
const t =
|
||||||
if (
|
span > 0
|
||||||
Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) &&
|
? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span
|
||||||
Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
: 1.0;
|
||||||
) {
|
const trackCourse = lerpAngleDeg(regime.trackFrom, regime.trackTo, t);
|
||||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
flightState._drCourseDeg =
|
||||||
const t =
|
regime.chordCourse != null
|
||||||
span > 0
|
? lerpAngleDeg(trackCourse, regime.chordCourse, regime.blend)
|
||||||
? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span
|
: trackCourse;
|
||||||
: 1.0;
|
flightState._drSpeedMps = regime.speed;
|
||||||
// Course of the DISPLAYED motion. The chord is only trustworthy when the
|
flightState._drCourseHold = regime.speed < COURSE_HOLD_SPEED_MPS;
|
||||||
// segment covers real ground (at hover its direction is GPS jitter; on a
|
return Cesium.Cartesian3.lerp(a.position, b.position, t, out);
|
||||||
// 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.speedMps) || 0;
|
|
||||||
const fallbackTrack = (info && info.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;
|
|
||||||
return Cesium.Cartesian3.lerp(a.position, b.position, t, out);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const dt = Cesium.Math.clamp(
|
||||||
const newest = history[history.length - 1];
|
Cesium.JulianDate.secondsDifference(renderTime, regime.fix.time),
|
||||||
const elapsedSec = Cesium.JulianDate.secondsDifference(
|
regime.minDt,
|
||||||
renderTime,
|
regime.maxDt,
|
||||||
newest.time,
|
|
||||||
);
|
);
|
||||||
if (elapsedSec <= 0) {
|
return _extrapolateFix(regime.fix, info, dt, out, regime.turnRateDps);
|
||||||
// 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 behind-real-time
|
|
||||||
// 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 until enough history accrued, then jumped.)
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue through fresh adsb.lol contacts even when the position epoch has
|
/**
|
||||||
// not advanced, but keep an absolute stale-feed drift ceiling.
|
* Which motion a contact's history gives at `renderTime` — the one choice
|
||||||
const coastLimitSec = staleCoastLimitSeconds({
|
* `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from
|
||||||
// Captured at poll normalization so the fleet tick does not allocate a
|
* (`aircraftMotionRegime`). The result is a reused scratch: read it before
|
||||||
// Date for every aircraft twelve times per second.
|
* the next call.
|
||||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
*/
|
||||||
? newest.epochMs
|
function _motionRegime(history, info, renderTime) {
|
||||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
return aircraftMotionRegime(
|
||||||
lastContactEpochMs: info?.lastContactEpochMs,
|
history,
|
||||||
});
|
|
||||||
return _extrapolateFix(
|
|
||||||
newest,
|
|
||||||
info,
|
info,
|
||||||
Math.min(elapsedSec, coastLimitSec),
|
renderTime,
|
||||||
out,
|
RECORD_FIELDS,
|
||||||
(info && info.turnRateDps) || 0,
|
regime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,12 +134,8 @@ export function createMotion({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
||||||
const speed = Number.isFinite(fix.velocity)
|
const speed = fixSpeedMps(fix, info, RECORD_FIELDS);
|
||||||
? fix.velocity
|
const heading = fixTrackDeg(fix, info, RECORD_FIELDS);
|
||||||
: (info && info.speedMps) || 0;
|
|
||||||
const heading = Number.isFinite(fix.track)
|
|
||||||
? fix.track
|
|
||||||
: (info && info.track) || 0;
|
|
||||||
flightState._drSpeedMps = speed;
|
flightState._drSpeedMps = speed;
|
||||||
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
||||||
if (speed === 0 || dt === 0) {
|
if (speed === 0 || dt === 0) {
|
||||||
@@ -356,7 +313,7 @@ export function createMotion({
|
|||||||
/** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already
|
/** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already
|
||||||
* settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at
|
* settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at
|
||||||
* a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the
|
* a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the
|
||||||
* now-stable plane. Null when no valid fix (callers fall back to the billboard position). */
|
* now-stable plane. Null when no valid fix (callers fall back to the icon position). */
|
||||||
|
|
||||||
function _trackedDisplayCached() {
|
function _trackedDisplayCached() {
|
||||||
return flightState._drReconcileValid &&
|
return flightState._drReconcileValid &&
|
||||||
@@ -513,6 +470,7 @@ export function createMotion({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
_deadReckon,
|
_deadReckon,
|
||||||
|
_motionRegime,
|
||||||
_extrapolateFix,
|
_extrapolateFix,
|
||||||
_isTrackWarmingUp,
|
_isTrackWarmingUp,
|
||||||
_trackedFocusSizePx,
|
_trackedFocusSizePx,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ test('military state owns separate contact maps, motion scratch and ground sampl
|
|||||||
for (const key of [
|
for (const key of [
|
||||||
'records',
|
'records',
|
||||||
'feed',
|
'feed',
|
||||||
'_billboards',
|
'_displayCourse',
|
||||||
'_positionHistory',
|
'_positionHistory',
|
||||||
'_groundSnap',
|
'_groundSnap',
|
||||||
'_scratchCarto',
|
'_scratchCarto',
|
||||||
@@ -141,7 +141,7 @@ test('a normalized source can retain its stale reason without changing standalon
|
|||||||
test('a newly seen military contact appears at its delayed position, not ahead of it', async () => {
|
test('a newly seen military contact appears at its delayed position, not ahead of it', async () => {
|
||||||
// The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh
|
// The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh
|
||||||
// contact's first displayed position is its fix projected back to that
|
// 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
|
// delayed time. Creating the icon at the raw fix drew it ahead until
|
||||||
// the next fleet tick jumped it back.
|
// the next fleet tick jumped it back.
|
||||||
const Cesium = await import('cesium');
|
const Cesium = await import('cesium');
|
||||||
const { RENDER_DELAY_SEC } = await import('./policy.js');
|
const { RENDER_DELAY_SEC } = await import('./policy.js');
|
||||||
@@ -177,7 +177,6 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const added = [];
|
|
||||||
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||||
icao24: 'seed00',
|
icao24: 'seed00',
|
||||||
entity: null,
|
entity: null,
|
||||||
@@ -186,22 +185,14 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
|||||||
show: false,
|
show: false,
|
||||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
||||||
},
|
},
|
||||||
billboardCollection: {
|
billboardCollection: { show: false },
|
||||||
show: false,
|
|
||||||
add(options) {
|
|
||||||
const billboard = { ...options };
|
|
||||||
added.push(billboard);
|
|
||||||
return billboard;
|
|
||||||
},
|
|
||||||
remove() {},
|
|
||||||
},
|
|
||||||
viewer: { camera: { positionCartographic: null }, scene: {} },
|
viewer: { camera: { positionCartographic: null }, scene: {} },
|
||||||
tracked: false,
|
tracked: false,
|
||||||
});
|
});
|
||||||
await layer.update({ camera: { positionCartographic: null }, scene: {} });
|
await layer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||||
const billboard = added.find((entry) => entry.id === 'ae0001');
|
const icon = layer.testing._iconForTest('ae0001');
|
||||||
assert.ok(billboard, 'the new contact gets a billboard');
|
assert.ok(icon, 'the new contact gets an icon');
|
||||||
const shown = Cesium.Cartographic.fromCartesian(billboard.position);
|
const shown = Cesium.Cartographic.fromCartesian(icon.position);
|
||||||
const behindM = Cesium.Cartesian3.distance(
|
const behindM = Cesium.Cartesian3.distance(
|
||||||
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0),
|
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0),
|
||||||
Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0),
|
Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0),
|
||||||
@@ -289,3 +280,17 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
|
|||||||
'0°',
|
'0°',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a military layer without an icon set declines contact refreshes', () => {
|
||||||
|
// Before init and after destroy there are no icon rows: a TR-3B refresh
|
||||||
|
// reports that the layer owns no such contact instead of throwing.
|
||||||
|
const layer = createMilitaryFlightLayer({
|
||||||
|
source: { label: 'Fixture aircraft', getSnapshot() {} },
|
||||||
|
services: {
|
||||||
|
...services(),
|
||||||
|
aircraftPresentation: { isTr3b: () => true, tr3bConvertedIds: () => [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(layer.refreshTr3b('abc123'), false);
|
||||||
|
assert.equal(layer.trackById('abc123'), false);
|
||||||
|
});
|
||||||
@@ -5,28 +5,23 @@ import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
|||||||
* @module militaryFlights
|
* @module militaryFlights
|
||||||
* @description Real-time military flight tracking layer powered by the adsb.lol API.
|
* @description Real-time military flight tracking layer powered by the adsb.lol API.
|
||||||
*
|
*
|
||||||
* Renders aircraft as amber chevron billboards in a single BillboardCollection
|
* Renders aircraft as amber chevrons in one GPU icon set (iconSet.js), whose
|
||||||
* for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft
|
* shader dead-reckons and orients every row. Supports click-to-track: selecting an aircraft
|
||||||
* spawns an Entity the camera follows, whose position is driven by a
|
* spawns an Entity the camera follows, whose position is driven by a
|
||||||
* dead-reckoning CallbackProperty. The whole fleet renders at
|
* dead-reckoning CallbackProperty. The whole fleet renders at
|
||||||
* now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes
|
* now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes
|
||||||
* instead of extrapolating ahead and snapping back when the next poll lands.
|
* instead of extrapolating ahead and snapping back when the next poll lands.
|
||||||
*
|
|
||||||
* Billboard orientation uses alignedAxis set to the WGS84 surface normal at each
|
|
||||||
* position so that the rotation angle (heading/track) operates in the local tangent
|
|
||||||
* plane where 0 deg = north; near nadir the icons switch to screen-aligned
|
|
||||||
* rotation (camera.heading - track) with hysteresis.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @constant {number} Milliseconds to wait before retrying after a transient error */
|
/** @constant {number} Milliseconds to wait before retrying after a transient error */
|
||||||
|
|
||||||
/** @constant {number} Max position samples retained per aircraft for dead reckoning */
|
/** @constant {number} Max position samples retained per aircraft for dead reckoning */
|
||||||
|
|
||||||
/** @constant {number} Base billboard display scale */
|
/** @constant {number} Base icon display scale */
|
||||||
|
|
||||||
export const BILLBOARD_SCALE = 0.7;
|
export const BILLBOARD_SCALE = 0.7;
|
||||||
|
|
||||||
/** @constant {Cesium.Color} Default amber tint for untracked military billboards */
|
/** @constant {Cesium.Color} Default amber tint for untracked military icons */
|
||||||
|
|
||||||
export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
|
export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
|
||||||
|
|
||||||
@@ -44,7 +39,7 @@ export const EMERGENCY_ICON_COLOR =
|
|||||||
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
||||||
// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable,
|
// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable,
|
||||||
// sticky metadata updating normally. The on-ground flip restyles the existing
|
// sticky metadata updating normally. The on-ground flip restyles the existing
|
||||||
// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes
|
// icon IN PLACE (landing/takeoff transition, never a removal); ground planes
|
||||||
// draw no trails. In 3D mode they take model slots like airborne planes (owner
|
// draw no trails. In 3D mode they take model slots like airborne planes (owner
|
||||||
// decision 2026-07-03 — no air/ground distinction), placed by the one-shot
|
// decision 2026-07-03 — no air/ground distinction), placed by the one-shot
|
||||||
// ground snap (see _modelDisplayPosition).
|
// ground snap (see _modelDisplayPosition).
|
||||||
@@ -53,7 +48,7 @@ export const EMERGENCY_ICON_COLOR =
|
|||||||
// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can
|
// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can
|
||||||
// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail;
|
// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail;
|
||||||
// "feed-dropped, coasting" stays the 45%-alpha stale fade.
|
// "feed-dropped, coasting" stays the 45%-alpha stale fade.
|
||||||
/** Ground billboards render slightly smaller so airport clutter stays visually minor. */
|
/** Ground icons render slightly smaller so airport clutter stays visually minor. */
|
||||||
|
|
||||||
export const GROUND_SCALE = 0.8;
|
export const GROUND_SCALE = 0.8;
|
||||||
|
|
||||||
@@ -215,25 +210,10 @@ export const LANDED_SPEED_MAX_MPS = 23;
|
|||||||
// gets the floor.
|
// gets the floor.
|
||||||
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
|
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment
|
|
||||||
// degenerates when the camera looks straight down (the normal is parallel to
|
|
||||||
// the view direction and the shader's screen-projected angle is 0/0). Near
|
|
||||||
// nadir we switch to screen-aligned billboards with
|
|
||||||
// rotation = camera.heading - track (screen-up points at azimuth
|
|
||||||
// camera.heading; a CW track t appears t-h from screen-up; billboard rotation
|
|
||||||
// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations
|
|
||||||
// are continuous at the boundary with this formula.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
||||||
|
|
||||||
export const FLEET_DR_INTERVAL_MS = 80;
|
export const FLEET_DR_INTERVAL_MS = 80;
|
||||||
|
|
||||||
/** @constant {number} Max ms between rotation passes while the camera is idle. */
|
|
||||||
|
|
||||||
export const ROTATION_REFRESH_MS = 1000;
|
|
||||||
|
|
||||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '../../data/contactMatch.js';
|
} from '../../data/contactMatch.js';
|
||||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
|
||||||
import { modelState } from '../../data/modelMirror.js';
|
import { modelState } from '../../data/modelMirror.js';
|
||||||
|
|
||||||
export function createQueries({
|
export function createQueries({
|
||||||
@@ -88,7 +87,7 @@ export function createQueries({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a public descriptor for one aircraft using its best current position
|
* Build a public descriptor for one aircraft using its best current position
|
||||||
* (dead-reckoned when history exists, billboard position otherwise).
|
* (dead-reckoned when history exists, icon position otherwise).
|
||||||
* @param {string} icao24 - ICAO hex identifier of the aircraft.
|
* @param {string} icao24 - ICAO hex identifier of the aircraft.
|
||||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||||
* Descriptor with a cloned position, or null if the aircraft is unknown.
|
* Descriptor with a cloned position, or null if the aircraft is unknown.
|
||||||
@@ -96,10 +95,8 @@ export function createQueries({
|
|||||||
|
|
||||||
function _describeFlight(icao24) {
|
function _describeFlight(icao24) {
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
const bb = flightState._billboards.get(icao24);
|
|
||||||
const basePos =
|
const basePos =
|
||||||
parts.motion._deadReckon(icao24) ||
|
parts.motion._deadReckon(icao24) || parts.icons._iconPosition(icao24);
|
||||||
(bb ? billboardState(bb).position : null);
|
|
||||||
if (!basePos) return null;
|
if (!basePos) return null;
|
||||||
const displayed = displayedKinematics({
|
const displayed = displayedKinematics({
|
||||||
derivedSpeedMps: flightState._drSpeedMps,
|
derivedSpeedMps: flightState._drSpeedMps,
|
||||||
@@ -271,13 +268,8 @@ export function createQueries({
|
|||||||
if (!flightState._models3dEnabled) {
|
if (!flightState._models3dEnabled) {
|
||||||
parts.rendering._releaseModels();
|
parts.rendering._releaseModels();
|
||||||
parts.tracking._syncTracked2dRotation();
|
parts.tracking._syncTracked2dRotation();
|
||||||
// Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked
|
// (Releasing the models handed each fleet contact back to its icon, never
|
||||||
// plane's own fleet billboard — its tracked entity is the visual (avoids a double-image).
|
// the tracked plane's own — its tracked entity is the visual.)
|
||||||
if (flightState._billboardCollection)
|
|
||||||
for (const [icao, bb] of flightState._billboards) {
|
|
||||||
if (icao !== flightState._trackedIcao)
|
|
||||||
setBillboard(bb, 'show', true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -357,12 +349,11 @@ export function createQueries({
|
|||||||
* @returns {Array<Object>} Sorted array of nearby aircraft descriptors
|
* @returns {Array<Object>} Sorted array of nearby aircraft descriptors
|
||||||
*/
|
*/
|
||||||
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
||||||
if (
|
const icons = flightState._icons;
|
||||||
!center ||
|
if (!center || !icons || !icons.show) return [];
|
||||||
!flightState._billboardCollection ||
|
const occluder = parts.icons._occluder();
|
||||||
!flightState._billboardCollection.show
|
const tau = parts.icons._displayTau();
|
||||||
)
|
const scratch = flightState._scratchNearbyPos;
|
||||||
return [];
|
|
||||||
|
|
||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
@@ -372,28 +363,30 @@ export function createQueries({
|
|||||||
|
|
||||||
const nearby = [];
|
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;
|
const isTracked = icao24 === flightState._trackedIcao;
|
||||||
// Keep planes rendered as a 3D model (billboard hidden) so proximity counts
|
const trackedPos = isTracked
|
||||||
// don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden.
|
? 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 (
|
if (
|
||||||
!aircraftIncludedInNearby({
|
!aircraftIncludedInNearby({
|
||||||
isTracked,
|
isTracked,
|
||||||
billboardShown: billboardState(bb).show,
|
billboardShown:
|
||||||
|
icons.shown(row) &&
|
||||||
|
parts.icons._aboveHorizon(row, drawn, occluder),
|
||||||
modelRendering: parts.rendering._modelOwnsVisual(icao24),
|
modelRendering: parts.rendering._modelOwnsVisual(icao24),
|
||||||
includeHidden,
|
includeHidden,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const trackedPos = isTracked
|
const distance = Cesium.Cartesian3.distance(center, drawn);
|
||||||
? 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);
|
|
||||||
if (distance > maxRange) continue;
|
if (distance > maxRange) continue;
|
||||||
|
const pos = trackedPos || Cesium.Cartesian3.clone(drawn);
|
||||||
|
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
nearby.push({
|
nearby.push({
|
||||||
@@ -434,40 +427,32 @@ export function createQueries({
|
|||||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||||
*/
|
*/
|
||||||
getDetectableObjects(options = {}) {
|
getDetectableObjects(options = {}) {
|
||||||
if (
|
const icons = flightState._icons;
|
||||||
!flightState._billboardCollection ||
|
if (!icons || !icons.show) return [];
|
||||||
!flightState._billboardCollection.show
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
const maxCount = Number.isFinite(options.maxCount)
|
const maxCount = Number.isFinite(options.maxCount)
|
||||||
? Math.max(1, Math.floor(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 seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||||
// Deterministic stride: evenly space selections across the billboard map
|
// Deterministic stride: evenly space selections across the icon rows
|
||||||
const stride = Math.max(
|
const stride = Math.max(1, Math.ceil(icons.count / maxCount));
|
||||||
1,
|
|
||||||
Math.ceil(flightState._billboards.size / maxCount),
|
|
||||||
);
|
|
||||||
const start = seed % stride;
|
const start = seed % stride;
|
||||||
|
const occluder = parts.icons._occluder();
|
||||||
|
const tau = parts.icons._displayTau();
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
let idx = 0;
|
for (let row = start; row < icons.count; row += stride) {
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
const icao24 = icons.idAt(row);
|
||||||
const shouldTake = (idx - start) % stride === 0;
|
|
||||||
idx++;
|
|
||||||
if (!shouldTake) continue;
|
|
||||||
if (
|
if (
|
||||||
flightState._cockpitContactMode &&
|
flightState._cockpitContactMode &&
|
||||||
icao24.toLowerCase() === flightState._cockpitSubjectId
|
icao24.toLowerCase() === flightState._cockpitSubjectId
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
const isTracked = icao24 === flightState._trackedIcao;
|
const isTracked = icao24 === flightState._trackedIcao;
|
||||||
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
|
// Keep planes rendered as a 3D model (icon hidden) so the detection box
|
||||||
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
|
// doesn't vanish on the 2D→3D handoff.
|
||||||
const model = flightState._models.get(icao24);
|
const model = flightState._models.get(icao24);
|
||||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
||||||
if (!isTracked && !billboardState(bb).show && !modelOwnsVisual)
|
if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue;
|
||||||
continue;
|
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
const callsign = info?.callsign?.trim();
|
const callsign = info?.callsign?.trim();
|
||||||
const registration = info?.registration?.trim();
|
const registration = info?.registration?.trim();
|
||||||
@@ -478,18 +463,20 @@ export function createQueries({
|
|||||||
type: 'AIR',
|
type: 'AIR',
|
||||||
tier: 'military',
|
tier: 'military',
|
||||||
_weldPos: new Cesium.Cartesian3(),
|
_weldPos: new Cesium.Cartesian3(),
|
||||||
|
_iconPos: new Cesium.Cartesian3(),
|
||||||
};
|
};
|
||||||
flightState._detectionObjects.set(icao24, object);
|
flightState._detectionObjects.set(icao24, object);
|
||||||
}
|
}
|
||||||
// WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned
|
// WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned
|
||||||
// contacts read the translation the fleet tick already wrote into their
|
// contacts read the translation the fleet tick already wrote into their
|
||||||
// modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket
|
// modelMatrix; icon-owned contacts keep the drawn icon position, where icon and
|
||||||
// are co-located anyway.
|
// bracket are co-located anyway.
|
||||||
const spec = modelOwnsVisual
|
const spec = modelOwnsVisual
|
||||||
? parts.rendering._modelSpec(info?.klass)
|
? parts.rendering._modelSpec(info?.klass)
|
||||||
: null;
|
: null;
|
||||||
const pos = isTracked
|
const pos = isTracked
|
||||||
? parts.motion._trackedVisualCached() || billboardState(bb).position
|
? parts.motion._trackedVisualCached() ||
|
||||||
|
icons.position(row, tau, object._iconPos)
|
||||||
: modelOwnsVisual
|
: modelOwnsVisual
|
||||||
? modelVisualAnchor(
|
? modelVisualAnchor(
|
||||||
modelState(model).modelMatrix,
|
modelState(model).modelMatrix,
|
||||||
@@ -499,8 +486,15 @@ export function createQueries({
|
|||||||
: spec.scale,
|
: spec.scale,
|
||||||
object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
|
object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
|
||||||
)
|
)
|
||||||
: billboardState(bb).position;
|
: icons.position(row, tau, object._iconPos);
|
||||||
if (!pos) continue;
|
// 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.position = pos;
|
||||||
object.skipLabel = isTracked;
|
object.skipLabel = isTracked;
|
||||||
const id = callsign || registration || icao24;
|
const id = callsign || registration || icao24;
|
||||||
@@ -519,13 +513,9 @@ export function createQueries({
|
|||||||
result.push(object);
|
result.push(object);
|
||||||
if (result.length >= maxCount) break;
|
if (result.length >= maxCount) break;
|
||||||
}
|
}
|
||||||
if (
|
if (flightState._detectionObjects.size > icons.count + 512) {
|
||||||
flightState._detectionObjects.size >
|
|
||||||
flightState._billboards.size + 512
|
|
||||||
) {
|
|
||||||
for (const icao24 of flightState._detectionObjects.keys()) {
|
for (const icao24 of flightState._detectionObjects.keys()) {
|
||||||
if (!flightState._billboards.has(icao24))
|
if (!icons.has(icao24)) flightState._detectionObjects.delete(icao24);
|
||||||
flightState._detectionObjects.delete(icao24);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -577,8 +567,8 @@ export function createQueries({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return id/label/position for up to maxCount currently rendered aircraft.
|
* Return id/label/position for up to maxCount currently rendered aircraft.
|
||||||
* Cheap snapshot for voice-tool framing — billboard positions only, no
|
* Cheap snapshot for voice-tool framing — drawn icon positions, no
|
||||||
* dead reckoning and no cloning.
|
* extra dead reckoning.
|
||||||
* @param {number} [maxCount=500] - Maximum number of entries to return.
|
* @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}>}
|
* @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>}
|
||||||
*/
|
*/
|
||||||
@@ -595,34 +585,25 @@ export function createQueries({
|
|||||||
* holds no data and therefore cannot answer.
|
* holds no data and therefore cannot answer.
|
||||||
*/
|
*/
|
||||||
hasContact(icao24) {
|
hasContact(icao24) {
|
||||||
if (
|
const icons = flightState._icons;
|
||||||
!flightState._billboardCollection ||
|
if (!icons || !icons.show || icons.count === 0) return null;
|
||||||
!flightState._billboardCollection.show ||
|
|
||||||
flightState._billboards.size === 0
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
if (!icao24) return false;
|
if (!icao24) return false;
|
||||||
const id = String(icao24).trim();
|
const id = String(icao24).trim();
|
||||||
return (
|
return icons.has(id) || icons.has(id.toLowerCase());
|
||||||
flightState._billboards.has(id) ||
|
|
||||||
flightState._billboards.has(id.toLowerCase())
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getAllPositions(maxCount = 500) {
|
getAllPositions(maxCount = 500) {
|
||||||
if (
|
const icons = flightState._icons;
|
||||||
!flightState._billboardCollection ||
|
if (!icons || icons.count === 0) return [];
|
||||||
flightState._billboards.size === 0
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
: 500;
|
: 500;
|
||||||
|
const tau = parts.icons._displayTau();
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
for (let row = 0; row < icons.count; row++) {
|
||||||
const pos = billboardState(bb).position;
|
const icao24 = icons.idAt(row);
|
||||||
if (!pos) continue;
|
const pos = icons.position(row, tau);
|
||||||
const carto = Cesium.Cartographic.fromCartesian(
|
const carto = Cesium.Cartographic.fromCartesian(
|
||||||
pos,
|
pos,
|
||||||
Cesium.Ellipsoid.WGS84,
|
Cesium.Ellipsoid.WGS84,
|
||||||
@@ -657,11 +638,7 @@ export function createQueries({
|
|||||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||||
*/
|
*/
|
||||||
getAnalystRecords(maxCount = 2000) {
|
getAnalystRecords(maxCount = 2000) {
|
||||||
if (
|
if (!flightState._icons?.show || flightState.records.data.size === 0)
|
||||||
!flightState._billboardCollection ||
|
|
||||||
!flightState._billboardCollection.show ||
|
|
||||||
flightState.records.data.size === 0
|
|
||||||
)
|
|
||||||
return [];
|
return [];
|
||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
@@ -682,8 +659,8 @@ export function createQueries({
|
|||||||
trackById(icao24, { origin = 'programmatic' } = {}) {
|
trackById(icao24, { origin = 'programmatic' } = {}) {
|
||||||
if (!icao24) return false;
|
if (!icao24) return false;
|
||||||
let id = String(icao24).trim();
|
let id = String(icao24).trim();
|
||||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
if (!flightState._icons?.has(id)) id = id.toLowerCase();
|
||||||
if (!flightState._billboards.has(id)) return false;
|
if (!flightState._icons?.has(id)) return false;
|
||||||
if (parts.tracking._isExplicitTrackingOrigin(origin))
|
if (parts.tracking._isExplicitTrackingOrigin(origin))
|
||||||
parts.tracking._cancelPendingTrackingRestore();
|
parts.tracking._cancelPendingTrackingRestore();
|
||||||
if (flightState._trackedIcao === id)
|
if (flightState._trackedIcao === id)
|
||||||
@@ -752,7 +729,7 @@ export function createQueries({
|
|||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
let id = String(icao24).trim();
|
let id = String(icao24).trim();
|
||||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
if (!flightState._icons?.has(id)) id = id.toLowerCase();
|
||||||
if (
|
if (
|
||||||
id !== flightState._trackedIcao ||
|
id !== flightState._trackedIcao ||
|
||||||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ export class MilitaryFlightRecords {
|
|||||||
// GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a
|
// GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a
|
||||||
// grounded plane's MODEL already rides groundSnap.js's one-shot tileset
|
// grounded plane's MODEL already rides groundSnap.js's one-shot tileset
|
||||||
// sample (_modelDisplayPosition), which is the visual on the ground, and
|
// sample (_modelDisplayPosition), which is the visual on the ground, and
|
||||||
// its billboard is depth-test-free (_groundDepthDistance) so its exact
|
// its icon is depth-test-free (the icon set draws on the near plane) so its exact
|
||||||
// height is cosmetic. Deliberately pass surfaceM=null so
|
// height is cosmetic. Deliberately pass surfaceM=null so
|
||||||
// pickRenderAltitudeM's on-ground surface branch never fires here:
|
// pickRenderAltitudeM's on-ground surface branch never fires here:
|
||||||
// 1. It would be the SECOND correction of the same grounded plane
|
// 1. It would be the SECOND correction of the same grounded plane
|
||||||
// (model tileset-snap is the first) — the exact double-correct the
|
// (model tileset-snap is the first) — the exact double-correct the
|
||||||
// brief forbids.
|
// brief forbids.
|
||||||
// 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the
|
// 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the
|
||||||
// grounded billboard sits at 0 m until a surface value warms; letting
|
// grounded icon sits at 0 m until a surface value warms; letting
|
||||||
// surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls
|
// surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls
|
||||||
// drags the model's ground-snap input past groundSnap's 50 m
|
// drags the model's ground-snap input past groundSnap's 50 m
|
||||||
// move-invalidation threshold and forces a re-sample every time the
|
// move-invalidation threshold and forces a re-sample every time the
|
||||||
@@ -95,7 +95,7 @@ export class MilitaryFlightRecords {
|
|||||||
// ellipsoidal ground (warm-cache read only — the batch warm below
|
// ellipsoidal ground (warm-cache read only — the batch warm below
|
||||||
// fills cells for later polls). Grounded contacts are deliberately
|
// fills cells for later polls). Grounded contacts are deliberately
|
||||||
// NOT touched: their model rides groundSnap's tileset sample and
|
// NOT touched: their model rides groundSnap's tileset sample and
|
||||||
// their billboard is depth-test-free (see the surfaceM:null block
|
// their icon is depth-test-free (see the surfaceM:null block
|
||||||
// above — same one-shot-invariant reasoning).
|
// above — same one-shot-invariant reasoning).
|
||||||
if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) {
|
if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) {
|
||||||
renderAltitudeM = floorAltitudeM(
|
renderAltitudeM = floorAltitudeM(
|
||||||
@@ -105,10 +105,10 @@ export class MilitaryFlightRecords {
|
|||||||
_floorWarmPoints.push({ lat, lon });
|
_floorWarmPoints.push({ lat, lon });
|
||||||
} else if (onGround) {
|
} else if (onGround) {
|
||||||
// Grounded contacts: warm the floor cell, and — round 4 — when NO
|
// Grounded contacts: warm the floor cell, and — round 4 — when NO
|
||||||
// 3D model owns this contact's visual, lift the billboard itself
|
// 3D model owns this contact's visual, lift the icon itself
|
||||||
// onto the floor (mesh-first): the R20053 heli sat "straight up in
|
// onto the floor (mesh-first): the R20053 heli sat "straight up in
|
||||||
// the ground" because grounded rows render at the legacy ~0 m.
|
// the ground" because grounded rows render at the legacy ~0 m.
|
||||||
// With a model present the billboard stays put (it hides behind
|
// With a model present the icon stays put (it hides behind
|
||||||
// the tileset-snapped model, and moving it would drag groundSnap's
|
// the tileset-snapped model, and moving it would drag groundSnap's
|
||||||
// input past its move-invalidation threshold — the T7 one-shot
|
// input past its move-invalidation threshold — the T7 one-shot
|
||||||
// invariant the track regression locks).
|
// invariant the track regression locks).
|
||||||
|
|||||||
+177
-348
@@ -9,15 +9,8 @@ import {
|
|||||||
} from '../../data/modelVisualAnchor.js';
|
} from '../../data/modelVisualAnchor.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.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 { isEmergency } from '../../data/aircraftEmergency.js';
|
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||||
import {
|
import {
|
||||||
PLANE_MODEL_SCALE,
|
PLANE_MODEL_SCALE,
|
||||||
@@ -31,7 +24,6 @@ import {
|
|||||||
MODEL_HEADING_OFFSET_DEG,
|
MODEL_HEADING_OFFSET_DEG,
|
||||||
BILLBOARD_SCALE,
|
BILLBOARD_SCALE,
|
||||||
GROUND_SCALE,
|
GROUND_SCALE,
|
||||||
COCKPIT_CONTACT_SIZE_PX,
|
|
||||||
MIL_ICON_COLOR,
|
MIL_ICON_COLOR,
|
||||||
TRACKED_ICON_COLOR,
|
TRACKED_ICON_COLOR,
|
||||||
EMERGENCY_ICON_COLOR,
|
EMERGENCY_ICON_COLOR,
|
||||||
@@ -49,13 +41,9 @@ import {
|
|||||||
TRACKED_MODEL_MIN_PX,
|
TRACKED_MODEL_MIN_PX,
|
||||||
TRACKED_MODEL_MAX_PX,
|
TRACKED_MODEL_MAX_PX,
|
||||||
FLEET_DR_INTERVAL_MS,
|
FLEET_DR_INTERVAL_MS,
|
||||||
COURSE_SLEW_DT_MAX_SEC,
|
|
||||||
ROTATION_REFRESH_MS,
|
|
||||||
COURSE_MAX_DPS,
|
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
import { aircraftVisuals } from '../aircraft/visualState.js';
|
import { aircraftVisuals } from '../aircraft/visualState.js';
|
||||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
|
||||||
import { modelState, setModel } from '../../data/modelMirror.js';
|
import { modelState, setModel } from '../../data/modelMirror.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
@@ -67,23 +55,8 @@ export function createRendering({
|
|||||||
}) {
|
}) {
|
||||||
const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
|
const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
|
||||||
const { trackedModelScaleForPixelCap } = services.camera;
|
const { trackedModelScaleForPixelCap } = services.camera;
|
||||||
const {
|
const { focusNowMs, getFocusTarget } = services.focus;
|
||||||
focusNowMs,
|
const { applyAircraftModelTreatment } = services.recession;
|
||||||
getFocusTarget,
|
|
||||||
nearFarScalarValueAtDistance,
|
|
||||||
advanceProjectedSpriteFocus,
|
|
||||||
} = services.focus;
|
|
||||||
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
|
||||||
services.recession;
|
|
||||||
|
|
||||||
/** Depth-test policy for aircraft billboards (mirror of flights.js — see the
|
|
||||||
* full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact
|
|
||||||
* renders depth-test-free at every distance — a uniform always-visible rule;
|
|
||||||
* the fleet tick's horizon occluder still removes far-side contacts. */
|
|
||||||
|
|
||||||
function _groundDepthDistance() {
|
|
||||||
return Number.POSITIVE_INFINITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _modelSpec(klass) {
|
function _modelSpec(klass) {
|
||||||
let spec = flightState._specCache.get(klass);
|
let spec = flightState._specCache.get(klass);
|
||||||
@@ -140,7 +113,9 @@ export function createRendering({
|
|||||||
return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65);
|
return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _militaryBillboardScale(icao24) {
|
/** Fleet icon scale: per-class scale, ×GROUND_SCALE while grounded. */
|
||||||
|
|
||||||
|
function _fleetBillboardScale(icao24) {
|
||||||
const meta = flightState.records.data.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
return (
|
return (
|
||||||
BILLBOARD_SCALE *
|
BILLBOARD_SCALE *
|
||||||
@@ -149,42 +124,7 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */
|
/** Sprite kind for one contact's icon. Identity for every aircraft except
|
||||||
|
|
||||||
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 = _fleetBillboardColor(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', _militaryBillboardScale(icao24) * limbScale);
|
|
||||||
setBillboard(bb, 'scaleByDistance', _normalBillboardScaleByDistance());
|
|
||||||
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
|
|
||||||
cyberSonarBaseAlpha(bb),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
|
||||||
* the ones the operator converted into a TR-3B (Easter egg), which draw the
|
* the ones the operator converted into a TR-3B (Easter egg), which draw the
|
||||||
* black-triangle glyph — its thermal-reactive variant while an IR style owns
|
* black-triangle glyph — its thermal-reactive variant while an IR style owns
|
||||||
* the scene. Routing EVERY `aircraftIcon()` call through this is what makes a
|
* the scene. Routing EVERY `aircraftIcon()` call through this is what makes a
|
||||||
@@ -210,6 +150,10 @@ export function createRendering({
|
|||||||
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
|
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cockpit far-contact pips wear the fleet tint. */
|
||||||
|
|
||||||
|
const _cockpitContactColor = _fleetBillboardColor;
|
||||||
|
|
||||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||||
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
|
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
|
||||||
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
|
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
|
||||||
@@ -280,12 +224,12 @@ export function createRendering({
|
|||||||
|
|
||||||
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of
|
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of
|
||||||
* flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its
|
* flights.js): 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. Exclusion matches
|
* (or a parked neighbor's) icon/model instead of the tile skin. Exclusion matches
|
||||||
* picked-object IDs — every billboard AND model here carries its icao as `id` — plus
|
* picked-object IDs — every icon AND model here carries its icao as `id` — plus
|
||||||
* the tracked entity object itself. Built lazily, only when a sample actually fires. */
|
* the tracked entity object itself. Built lazily, only when a sample actually fires. */
|
||||||
|
|
||||||
function _groundSampleExclusions() {
|
function _groundSampleExclusions() {
|
||||||
const out = [...flightState._billboards.keys()];
|
const out = parts.icons._iconIds();
|
||||||
if (flightState._trackedEntity) out.push(flightState._trackedEntity);
|
if (flightState._trackedEntity) out.push(flightState._trackedEntity);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -295,7 +239,7 @@ export function createRendering({
|
|||||||
* (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly
|
* (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly
|
||||||
* offset so they sit on their
|
* offset so they sit on their
|
||||||
* gear; airborne planes pass through. Until the FIRST sample lands, callers keep the
|
* gear; airborne planes pass through. Until the FIRST sample lands, callers keep the
|
||||||
* depth-test-free billboard visible and the model hidden while this returns null; a
|
* icon visible and the model hidden while this returns null; a
|
||||||
* contact that has already resolved once holds that measurement through a later
|
* contact that has already resolved once holds that measurement through a later
|
||||||
* outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */
|
* outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */
|
||||||
|
|
||||||
@@ -324,15 +268,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
|
* 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
|
* `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. */
|
* 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) {
|
if (!model) {
|
||||||
setBillboard(bb, 'show', true);
|
icons.setShown(row, true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const displayPos = _modelDisplayPosition(
|
const displayPos = _modelDisplayPosition(
|
||||||
@@ -342,7 +294,7 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
if (!displayPos) {
|
if (!displayPos) {
|
||||||
setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
||||||
@@ -361,12 +313,12 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
if (!model.ready) {
|
if (!model.ready) {
|
||||||
setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
beforeShow?.();
|
beforeShow?.();
|
||||||
if (!modelState(model).show) setModel(model, 'show', true);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +354,7 @@ export function createRendering({
|
|||||||
|
|
||||||
/** Class-change model sync — mirror of flights.js: drop a live model or
|
/** Class-change model sync — mirror of flights.js: drop a live model or
|
||||||
* in-flight load whose spec no longer matches the class, re-showing the
|
* in-flight load whose spec no longer matches the class, re-showing the
|
||||||
* fleet billboard FIRST (gap-proof) and covering the tracked standalone. */
|
* fleet icon (on release, gap-proof) and covering the tracked standalone. */
|
||||||
|
|
||||||
function _syncModelToClass(icao24) {
|
function _syncModelToClass(icao24) {
|
||||||
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||||
@@ -411,9 +363,6 @@ export function createRendering({
|
|||||||
(current && aircraftVisuals.read(current)?.specKey !== key) ||
|
(current && aircraftVisuals.read(current)?.specKey !== key) ||
|
||||||
(!current && flightState._modelPending.has(icao24))
|
(!current && flightState._modelPending.has(icao24))
|
||||||
) {
|
) {
|
||||||
const bb = flightState._billboards.get(icao24);
|
|
||||||
if (bb && icao24 !== flightState._trackedIcao)
|
|
||||||
setBillboard(bb, 'show', true);
|
|
||||||
_releaseModel(icao24);
|
_releaseModel(icao24);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -447,16 +396,13 @@ export function createRendering({
|
|||||||
aircraftVisuals.read(model)?.irBoost === flightState._irBoost
|
aircraftVisuals.read(model)?.irBoost === flightState._irBoost
|
||||||
)
|
)
|
||||||
continue; // already right state
|
continue; // already right state
|
||||||
const bb = flightState._billboards.get(icao);
|
|
||||||
if (bb && icao !== flightState._trackedIcao)
|
|
||||||
setBillboard(bb, 'show', true);
|
|
||||||
_releaseModel(icao);
|
_releaseModel(icao);
|
||||||
}
|
}
|
||||||
if (flightState._irReloadQueue.length === 0)
|
if (flightState._irReloadQueue.length === 0)
|
||||||
flightState._irReloadQueue = null;
|
flightState._irReloadQueue = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
|
/** Lazily create the glTF model for an aircraft (fire-and-forget; its icon shows until ready). */
|
||||||
|
|
||||||
async function _ensureModel(icao24) {
|
async function _ensureModel(icao24) {
|
||||||
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
|
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
|
||||||
@@ -501,7 +447,7 @@ export function createRendering({
|
|||||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
|
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
|
||||||
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
||||||
if (epoch === flightState._modelEpoch) {
|
if (epoch === flightState._modelEpoch) {
|
||||||
flightState._modelPending.delete(icao24);
|
flightState._modelPending.delete(icao24);
|
||||||
@@ -554,7 +500,7 @@ export function createRendering({
|
|||||||
visual.specKey = specKey; // class-change sync compares against this
|
visual.specKey = specKey; // class-change sync compares against this
|
||||||
visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this
|
visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this
|
||||||
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
||||||
// an unplaced primitive claim ownership from the billboard for the frames
|
// an unplaced primitive claim ownership from the icon for the frames
|
||||||
// between admission and the next fleet tick (and draw at the identity matrix,
|
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||||
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||||
setModel(model, 'show', false);
|
setModel(model, 'show', false);
|
||||||
@@ -589,6 +535,7 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
flightState._models.delete(icao24);
|
flightState._models.delete(icao24);
|
||||||
|
_showReleasedIcon(icao24);
|
||||||
}
|
}
|
||||||
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
// 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
|
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
|
||||||
@@ -607,7 +554,7 @@ export function createRendering({
|
|||||||
flightState._modelGen.delete(icao24);
|
flightState._modelGen.delete(icao24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
|
/** Remove all live models (toggle-off / zoom-out); icons take back over at once. */
|
||||||
|
|
||||||
function _releaseModels() {
|
function _releaseModels() {
|
||||||
flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
|
flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
|
||||||
@@ -629,9 +576,20 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const icao24 of flightState._models.keys()) _showReleasedIcon(icao24);
|
||||||
flightState._models.clear();
|
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. */
|
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
|
||||||
|
|
||||||
function _releaseTrackedModel() {
|
function _releaseTrackedModel() {
|
||||||
@@ -672,8 +630,7 @@ export function createRendering({
|
|||||||
// readout consumers then reuse that exact cached position.
|
// readout consumers then reuse that exact cached position.
|
||||||
const pos =
|
const pos =
|
||||||
parts.motion._trackedDisplayPosition(flightState._trackedIcao) ||
|
parts.motion._trackedDisplayPosition(flightState._trackedIcao) ||
|
||||||
billboardState(flightState._billboards.get(flightState._trackedIcao))
|
parts.icons._iconPosition(flightState._trackedIcao);
|
||||||
?.position;
|
|
||||||
if (!pos) {
|
if (!pos) {
|
||||||
if (flightState._trackedModel)
|
if (flightState._trackedModel)
|
||||||
setModel(flightState._trackedModel, 'show', false);
|
setModel(flightState._trackedModel, 'show', false);
|
||||||
@@ -750,9 +707,7 @@ export function createRendering({
|
|||||||
// default here produces a one-frame jump to the Earth's center.
|
// default here produces a one-frame jump to the Earth's center.
|
||||||
const currentPos =
|
const currentPos =
|
||||||
parts.motion._trackedDisplayCached() ||
|
parts.motion._trackedDisplayCached() ||
|
||||||
billboardState(
|
parts.icons._iconPosition(flightState._trackedIcao);
|
||||||
flightState._billboards.get(flightState._trackedIcao),
|
|
||||||
)?.position;
|
|
||||||
if (currentPos) {
|
if (currentPos) {
|
||||||
const displayPos = _modelDisplayPosition(
|
const displayPos = _modelDisplayPosition(
|
||||||
flightState._trackedIcao,
|
flightState._trackedIcao,
|
||||||
@@ -832,57 +787,38 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard,
|
* ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and
|
||||||
* horizon-culls billboards beyond the limb (no far-side depth with the globe
|
* the two-tier glyph swap all run on the GPU from each row's segment, so this
|
||||||
* hidden), and refreshes screen-projected icon rotations whenever the camera
|
* only re-plans rows whose regime ended, advances focus de-emphasis while a
|
||||||
* pose changed (plus a 1s drift catch-up while idle). Driven by
|
* target exists, and drives the 3D-model handoff inside the model regime.
|
||||||
* scene.preRender — NOT camera.changed, whose granularity is globally
|
* Driven by scene.preRender.
|
||||||
* degraded by other layers mutating camera.percentageChanged.
|
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _fleetTick() {
|
function _fleetTick() {
|
||||||
if (
|
const icons = flightState._icons;
|
||||||
!flightState._viewer ||
|
if (!flightState._viewer || !icons || !icons.show) return;
|
||||||
!flightState._billboardCollection ||
|
|
||||||
!flightState._billboardCollection.show
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const scene = flightState._viewer.scene;
|
const scene = flightState._viewer.scene;
|
||||||
const camera = flightState._viewer.camera;
|
const camera = flightState._viewer.camera;
|
||||||
const nowMs = focusNowMs(Date.now());
|
const nowMs = focusNowMs(Date.now());
|
||||||
|
|
||||||
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
|
|
||||||
// primitive rebuild here. The body rebuilds only when a real fix arrives.)
|
|
||||||
|
|
||||||
if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return;
|
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;
|
flightState._lastFleetTickMs = nowMs;
|
||||||
|
// Every contact in this pass shares the same delayed display instant.
|
||||||
|
const renderTime = parts.icons._displayTime(
|
||||||
|
flightState._scratchFleetRenderTime,
|
||||||
|
);
|
||||||
|
const tau = icons.seconds(renderTime);
|
||||||
|
|
||||||
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||||
if (flightState._cockpitContactMode)
|
if (flightState._cockpitContactMode)
|
||||||
parts.tracking._refreshCockpitNearContacts();
|
parts.tracking._refreshCockpitNearContacts();
|
||||||
const poseSig = cameraPoseSignature(camera);
|
parts.icons._replanExpired(tau);
|
||||||
// Only nearby Cockpit silhouettes need projected course; far dots remain
|
|
||||||
// rotation-free through the per-contact gate below.
|
|
||||||
const doRotations =
|
|
||||||
poseSig !== flightState._lastCamPoseSig ||
|
|
||||||
nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS;
|
|
||||||
if (doRotations) {
|
|
||||||
flightState._lastCamPoseSig = poseSig;
|
|
||||||
flightState._lastRotPassMs = nowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const occluder = horizonOccluder(camera);
|
|
||||||
const focusTarget = getFocusTarget();
|
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.
|
// 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();
|
const useModels = _modelRegimeActive();
|
||||||
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
|
// 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).
|
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
|
||||||
@@ -891,240 +827,133 @@ export function createRendering({
|
|||||||
(flightState._models.size || flightState._modelPending.size)
|
(flightState._models.size || flightState._modelPending.size)
|
||||||
)
|
)
|
||||||
_releaseModels();
|
_releaseModels();
|
||||||
|
if (!useModels) return;
|
||||||
|
|
||||||
// 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap —
|
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
|
||||||
// mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen
|
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
|
||||||
// new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP
|
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
|
||||||
// is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding).
|
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
|
||||||
let modelEligible = null;
|
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
|
||||||
if (useModels) {
|
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
|
||||||
const cap = _modelCap();
|
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
|
||||||
const camPos = camera.positionWC;
|
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
|
||||||
const addM = _modelAddDistM();
|
// filtering dropped near off-screen planes entirely. This does both right.
|
||||||
const addDistSq = addM * addM;
|
const cap = _modelCap();
|
||||||
const keepM = _modelKeepDistM();
|
const camPos = camera.positionWC;
|
||||||
const keepDistSq = keepM * keepM;
|
const addM = _modelAddDistM();
|
||||||
const cull = camera.frustum.computeCullingVolume(
|
const addDistSq = addM * addM;
|
||||||
camPos,
|
const keepM = _modelKeepDistM();
|
||||||
camera.directionWC,
|
const keepDistSq = keepM * keepM;
|
||||||
camera.upWC,
|
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,
|
||||||
);
|
);
|
||||||
const cand = [];
|
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
|
||||||
for (const [icao, bb] of flightState._billboards) {
|
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||||
if (icao === flightState._trackedIcao) continue;
|
cand.push([
|
||||||
// A converted TR-3B renders as a billboard and can never take a model, so
|
icao,
|
||||||
// it must not occupy a CAP SLOT either (mirror of flights.js) — excluded
|
d2,
|
||||||
// at selection time, not just at the handoff below.
|
cull.computeVisibility(flightState._scratchModelBS) !==
|
||||||
if (isTr3b(icao)) continue;
|
Cesium.Intersect.OUTSIDE,
|
||||||
// Ground planes compete for model slots like everyone else (owner decision
|
]);
|
||||||
// 2026-07-03, mirror of flights.js — no air/ground distinction; 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 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]);
|
|
||||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
|
||||||
console.warn(
|
|
||||||
`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen 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);
|
|
||||||
}
|
}
|
||||||
|
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
console.warn(
|
||||||
const icon = billboardState(bb);
|
`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`,
|
||||||
|
|
||||||
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
|
|
||||||
// Gate the write — assigning Billboard.position dirties the whole
|
|
||||||
// collection's vertex buffer, so skip sub-meter moves.
|
|
||||||
if (dr && Cesium.Cartesian3.distanceSquared(dr, icon.position) > 1.0) {
|
|
||||||
setBillboard(bb, 'position', dr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 6: occlusion-test a LIFTED point for contacts at/below the
|
|
||||||
// ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb
|
|
||||||
// read "beyond the horizon" and would hide low contacts awaiting floors).
|
|
||||||
const beyondHorizon = !occluder.isPointVisible(
|
|
||||||
flightState.records.data.get(icao24)?.cullPosition || icon.position,
|
|
||||||
);
|
);
|
||||||
// A billboard flipping INTO view (horizon reveal while the camera idles)
|
flightState._lastModelCapWarnMs = nowMs;
|
||||||
// gets its rotation refreshed THIS tick even without a pose change —
|
}
|
||||||
// otherwise it reappears wearing its stale (often creation-north) nose for
|
const modelEligible = selectModelEligible(cand, {
|
||||||
// up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false
|
cap,
|
||||||
// here; harmless — the model branch below `continue`s past the rotation.)
|
addDistSq,
|
||||||
const revealed = !beyondHorizon && !icon.show;
|
isModeled: (icao) => flightState._models.has(icao),
|
||||||
if (icon.show === beyondHorizon) setBillboard(bb, 'show', !beyondHorizon);
|
});
|
||||||
if (beyondHorizon) {
|
const toRelease = [];
|
||||||
// Also hide any 3D model — otherwise a model that crossed the limb would keep
|
for (const icao of flightState._models.keys()) {
|
||||||
// rendering through the hidden globe at its last matrix.
|
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||||
|
toRelease.push(icao);
|
||||||
|
}
|
||||||
|
for (const icao of toRelease) _releaseModel(icao);
|
||||||
|
|
||||||
|
// 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);
|
const m = flightState._models.get(icao24);
|
||||||
if (m && modelState(m)?.show) setModel(m, 'show', false);
|
if (m && modelState(m)?.show) setModel(m, 'show', false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
_ensureModel(icao24);
|
||||||
const info = flightState.records.data.get(icao24);
|
const model = flightState._models.get(icao24);
|
||||||
|
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
|
||||||
// One sprite-owned write site composes freshness × focus × limb haze and
|
_driveFleetModelHandoff(
|
||||||
// base class/ground scale × limb taper. The locked NearFarScalar remains
|
icao24,
|
||||||
// untouched and multiplicative; there is no cull or zero-alpha path.
|
model,
|
||||||
const cameraDistanceM = Cesium.Cartesian3.distance(
|
row,
|
||||||
camera.positionWC,
|
position,
|
||||||
icon.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 baseColor = _fleetBillboardColor(icao24, info);
|
|
||||||
const treatment = applyAircraftBillboardTreatment({
|
|
||||||
billboard: bb,
|
|
||||||
baseScale:
|
|
||||||
flightState._cockpitContactMode && !isCockpitNear
|
|
||||||
? 1
|
|
||||||
: _militaryBillboardScale(icao24),
|
|
||||||
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 — mirror of flights.js: swap 64/192 px rasters on
|
|
||||||
// the billboard's ACTUAL on-screen size (post-treatment bb.scale, so
|
|
||||||
// focus/limb recession counts) with hysteresis (atlas has no mips).
|
|
||||||
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, flightState.records.data.get(icao24)?.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.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.speedMps) ?? 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 (mirror of flights.js): the Easter
|
|
||||||
// egg IS the triangle and there is no GLB for it, so the model handoff is
|
|
||||||
// suppressed. The billboard keeps rendering, so the contact still satisfies
|
|
||||||
// the getNearby/getDetectableObjects visibility guards.
|
|
||||||
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; boosted
|
|
||||||
// models also skip the recession fade (mirror of flights.js —
|
|
||||||
// billboards keep their normal fade, hot MODELS stay full-strength).
|
|
||||||
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 {
|
return {
|
||||||
_groundDepthDistance,
|
|
||||||
_modelSpec,
|
_modelSpec,
|
||||||
_normalBillboardScaleByDistance,
|
_normalBillboardScaleByDistance,
|
||||||
_cockpitBillboardScaleByDistance,
|
_cockpitBillboardScaleByDistance,
|
||||||
_militaryBillboardScale,
|
_fleetBillboardScale,
|
||||||
_applyFleetBillboardPresentation,
|
_cockpitContactColor,
|
||||||
_iconKind,
|
_iconKind,
|
||||||
_modelColor,
|
_modelColor,
|
||||||
_fleetBillboardColor,
|
_fleetBillboardColor,
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
|
||||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
|
||||||
import {
|
import {
|
||||||
turnRateFromFixHistory,
|
turnRateFromFixHistory,
|
||||||
liftRepeatedGroundFix,
|
liftRepeatedGroundFix,
|
||||||
synthesizeForwardKinematicsFix,
|
synthesizeForwardKinematicsFix,
|
||||||
} from '../../data/motionModel.js';
|
} from '../../data/motionModel.js';
|
||||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
|
||||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
|
||||||
import { addBillboard, setBillboard } from '../../data/billboardMirror.js';
|
|
||||||
|
|
||||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||||
export function createMilitarySnapshotRenderer({
|
export function createMilitarySnapshotRenderer({
|
||||||
@@ -20,7 +15,7 @@ export function createMilitarySnapshotRenderer({
|
|||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
rendering,
|
rendering,
|
||||||
tracking,
|
tracking,
|
||||||
motion,
|
icons,
|
||||||
queries,
|
queries,
|
||||||
}) {
|
}) {
|
||||||
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
|
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
|
||||||
@@ -28,6 +23,8 @@ export function createMilitarySnapshotRenderer({
|
|||||||
const { sampleMeshFloorCells } = meshFloor;
|
const { sampleMeshFloorCells } = meshFloor;
|
||||||
const { registerMilitaryIcaos } = militaryRegistry;
|
const { registerMilitaryIcaos } = militaryRegistry;
|
||||||
return function applySnapshot(snapshot, viewer) {
|
return function applySnapshot(snapshot, viewer) {
|
||||||
|
// Before init (or after destroy) there is no icon set to draw into.
|
||||||
|
if (!flightState._icons) return { count: 0, ids: new Set() };
|
||||||
const currentIcaos = new Set();
|
const currentIcaos = new Set();
|
||||||
const receiptNowMs = snapshot.observedAtMs;
|
const receiptNowMs = snapshot.observedAtMs;
|
||||||
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
|
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
|
||||||
@@ -55,12 +52,6 @@ export function createMilitarySnapshotRenderer({
|
|||||||
meta.renderAltitudeM,
|
meta.renderAltitudeM,
|
||||||
);
|
);
|
||||||
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
||||||
flightState._cullPositions.set(
|
|
||||||
icao24,
|
|
||||||
meta.renderAltitudeM < 10
|
|
||||||
? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
const isTracked = icao24 === flightState._trackedIcao;
|
const isTracked = icao24 === flightState._trackedIcao;
|
||||||
|
|
||||||
// Append to position history stamped with the FEED's fix epoch.
|
// Append to position history stamped with the FEED's fix epoch.
|
||||||
@@ -120,63 +111,19 @@ export function createMilitarySnapshotRenderer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flightState._billboards.has(icao24)) {
|
if (flightState._icons.has(icao24)) {
|
||||||
const bb = flightState._billboards.get(icao24);
|
// Reclassification, a ground flip (a restyle, never a removal), a
|
||||||
if (!isTracked) {
|
// freshness change and the cockpit contract all land on the row's
|
||||||
bb.color = rendering
|
// style; motion re-plans from the updated history.
|
||||||
._fleetBillboardColor(icao24, meta)
|
icons._applyIconPresentation(icao24);
|
||||||
.withAlpha(cyberSonarBaseAlpha(bb));
|
icons._planIcon(icao24);
|
||||||
}
|
|
||||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
|
||||||
// course changes land on the next rotation pass (forced below).
|
|
||||||
// Ground flips (landing/takeoff) restyle this SAME billboard in
|
|
||||||
// place — the transition is never a removal.
|
|
||||||
if (
|
|
||||||
prevMeta?.klass !== meta.klass ||
|
|
||||||
groundFlipped ||
|
|
||||||
flightState._cockpitContactMode
|
|
||||||
) {
|
|
||||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
|
||||||
}
|
|
||||||
// Per-class GLBs: a class change can mean a different asset OR scale —
|
// Per-class GLBs: a class change can mean a different asset OR scale —
|
||||||
// resync the live model, any in-flight load, and the tracked
|
// resync the live model, any in-flight load, and the tracked
|
||||||
// standalone (mirror of flights.js).
|
// standalone (mirror of flights.js).
|
||||||
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
||||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
|
||||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
|
||||||
} else {
|
} else {
|
||||||
// Start where the fleet pass will draw it: the fleet renders behind
|
// Hidden while tracked: the tracked entity replaces it.
|
||||||
// real time, so a fresh contact's displayed position is its fix
|
icons._addIcon(icao24);
|
||||||
// projected back to that delayed time. Creating it at the raw fix drew
|
|
||||||
// it ahead until the next fleet tick snapped it back.
|
|
||||||
const displayed =
|
|
||||||
motion._deadReckon(icao24, new Cesium.Cartesian3()) || position;
|
|
||||||
const bb = addBillboard(flightState._billboardCollection, {
|
|
||||||
position: displayed,
|
|
||||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
|
||||||
width: isTracked ? 24 : 20,
|
|
||||||
height: isTracked ? 24 : 20,
|
|
||||||
scale:
|
|
||||||
BILLBOARD_SCALE *
|
|
||||||
(CLASS_SCALE_2D[meta.klass] || 1) *
|
|
||||||
(meta.onGround ? GROUND_SCALE : 1),
|
|
||||||
// Screen-projected rotation lands on the next fleet tick.
|
|
||||||
rotation: 0,
|
|
||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
|
||||||
color: isTracked
|
|
||||||
? TRACKED_ICON_COLOR
|
|
||||||
: rendering._fleetBillboardColor(icao24, meta),
|
|
||||||
sizeInMeters: false,
|
|
||||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
|
||||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
|
||||||
// surface — render them depth-test-free so they never vanish up
|
|
||||||
// close (_groundDepthDistance).
|
|
||||||
disableDepthTestDistance: rendering._groundDepthDistance(),
|
|
||||||
id: icao24,
|
|
||||||
show: !isTracked, // hidden if currently tracked (entity replaces it)
|
|
||||||
});
|
|
||||||
flightState._billboards.set(icao24, bb);
|
|
||||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
||||||
@@ -200,7 +147,7 @@ export function createMilitarySnapshotRenderer({
|
|||||||
// Round 6: re-floor STALE grounded contacts (mirror of flights.js) —
|
// Round 6: re-floor STALE grounded contacts (mirror of flights.js) —
|
||||||
// a parked contact whose feed went quiet froze at its pre-warm height.
|
// a parked contact whose feed went quiet froze at its pre-warm height.
|
||||||
// The model-ownership gate matches the live grounded-clamp path (a
|
// The model-ownership gate matches the live grounded-clamp path (a
|
||||||
// tileset-snapped model owns its visual; moving its billboard would
|
// tileset-snapped model owns its visual; moving its icon would
|
||||||
// disturb groundSnap's input — the T7 one-shot invariant).
|
// disturb groundSnap's input — the T7 one-shot invariant).
|
||||||
for (const [icao24, info] of flightState.records.data) {
|
for (const [icao24, info] of flightState.records.data) {
|
||||||
if (!info?.onGround || currentIcaos.has(icao24)) continue;
|
if (!info?.onGround || currentIcaos.has(icao24)) continue;
|
||||||
@@ -216,7 +163,6 @@ export function createMilitarySnapshotRenderer({
|
|||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
info.renderAltitudeM = lifted;
|
info.renderAltitudeM = lifted;
|
||||||
flightState._cullPositions.delete(icao24);
|
|
||||||
const liftedPos = Cesium.Cartesian3.fromDegrees(
|
const liftedPos = Cesium.Cartesian3.fromDegrees(
|
||||||
info.rawLon,
|
info.rawLon,
|
||||||
info.rawLat,
|
info.rawLat,
|
||||||
@@ -226,18 +172,20 @@ export function createMilitarySnapshotRenderer({
|
|||||||
const newest = hist?.[hist.length - 1];
|
const newest = hist?.[hist.length - 1];
|
||||||
if (newest)
|
if (newest)
|
||||||
newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position);
|
newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position);
|
||||||
const bbStale = flightState._billboards.get(icao24);
|
if (flightState._icons.has(icao24)) {
|
||||||
if (bbStale) setBillboard(bbStale, 'position', liftedPos);
|
icons._applyIconPresentation(icao24);
|
||||||
|
icons._planIcon(icao24);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Round 4: sample the RENDERED mesh for those cells (one-shot per cell,
|
// Round 4: sample the RENDERED mesh for those cells (one-shot per cell,
|
||||||
// budget-capped, viewer-proximate, google-3d regime only), excluding
|
// budget-capped, viewer-proximate, google-3d regime only), excluding
|
||||||
// this layer's own billboards/models from the probe.
|
// this layer's own icons/models from the probe.
|
||||||
{
|
{
|
||||||
const viewerCarto =
|
const viewerCarto =
|
||||||
flightState._viewer?.camera?.positionCartographic || null;
|
flightState._viewer?.camera?.positionCartographic || null;
|
||||||
sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, {
|
sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, {
|
||||||
excludeObjects: [
|
excludeObjects: [
|
||||||
...flightState._billboards.values(),
|
...icons._iconIds(),
|
||||||
...flightState._models.values(),
|
...flightState._models.values(),
|
||||||
flightState._trackedModel,
|
flightState._trackedModel,
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
@@ -256,8 +204,12 @@ export function createMilitarySnapshotRenderer({
|
|||||||
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
||||||
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
||||||
// feed gap, and the full grace left phantom planes parked at airports.
|
// feed gap, and the full grace left phantom planes parked at airports.
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
const absentIds = [];
|
||||||
if (currentIcaos.has(icao24)) continue;
|
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, {
|
const absence = records.absence(icao24, {
|
||||||
complete: snapshot.complete,
|
complete: snapshot.complete,
|
||||||
retainIds: snapshot.retainIds,
|
retainIds: snapshot.retainIds,
|
||||||
@@ -265,8 +217,9 @@ export function createMilitarySnapshotRenderer({
|
|||||||
});
|
});
|
||||||
if (absence === 'retain') continue;
|
if (absence === 'retain') continue;
|
||||||
if (absence === 'stale') {
|
if (absence === 'stale') {
|
||||||
|
icons._applyIconPresentation(icao24); // the faded freshness alpha
|
||||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
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
|
// entity owns the visual), so refresh the label — with icao24 now
|
||||||
// in _missingPolls, _buildTrackedLabel appends the STALE cue so
|
// in _missingPolls, _buildTrackedLabel appends the STALE cue so
|
||||||
// last-known altitude/speed aren't presented as live.
|
// last-known altitude/speed aren't presented as live.
|
||||||
@@ -277,7 +230,7 @@ export function createMilitarySnapshotRenderer({
|
|||||||
|
|
||||||
// If the tracked flight is truly gone, clear tracking BEFORE deleting
|
// If the tracked flight is truly gone, clear tracking BEFORE deleting
|
||||||
// its state (M3 ordering, keep it): teardown reads the maps this loop
|
// its state (M3 ordering, keep it): teardown reads the maps this loop
|
||||||
// is about to delete (billboard restore, DR cache reset), and we must
|
// is about to delete (icon restore, DR cache reset), and we must
|
||||||
// never leave the camera mid-follow with stale tracking state. The
|
// never leave the camera mid-follow with stale tracking state. The
|
||||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||||
@@ -285,22 +238,12 @@ export function createMilitarySnapshotRenderer({
|
|||||||
tracking._clearTracking(false, { evicted: true });
|
tracking._clearTracking(false, { evicted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
flightState._billboardCollection.remove(bb);
|
icons._forgetContact(icao24); // aged out: its 3D model goes too (no orphan / cap leak)
|
||||||
flightState._billboards.delete(icao24);
|
|
||||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
|
||||||
records.forget(icao24);
|
|
||||||
flightState._cullPositions.delete(icao24);
|
|
||||||
flightState._positionHistory.delete(icao24);
|
|
||||||
flightState._displayCourse.delete(icao24);
|
|
||||||
flightState._groundSnap.forget(icao24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
|
||||||
flightState._lastCamPoseSig = '';
|
|
||||||
|
|
||||||
// Feed the shared registry so the flights layer can classify/suppress
|
// Feed the shared registry so the flights layer can classify/suppress
|
||||||
registerMilitaryIcaos(currentIcaos);
|
registerMilitaryIcaos(currentIcaos);
|
||||||
|
|
||||||
return { count: flightState._billboards.size, ids: currentIcaos };
|
return { count: flightState._icons.count, ids: currentIcaos };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,6 @@ export function createFlightState({ source, services }) {
|
|||||||
...services.geoid,
|
...services.geoid,
|
||||||
...services.groundFloor,
|
...services.groundFloor,
|
||||||
});
|
});
|
||||||
flightState._cullPositions = new Map();
|
|
||||||
|
|
||||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||||
@@ -87,22 +86,24 @@ export function createFlightState({ source, services }) {
|
|||||||
new Cesium.Cartesian3(),
|
new Cesium.Cartesian3(),
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
// frustum-visibility test
|
|
||||||
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
|
|
||||||
|
|
||||||
flightState._billboardLimbScale = new WeakMap();
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Module-level state
|
// Module-level state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */
|
/** Every contact's icon row (see icons.js); null outside a layer lifetime. */
|
||||||
|
|
||||||
flightState._billboardCollection = null;
|
flightState._icons = null;
|
||||||
|
|
||||||
/** @type {Map<string, Cesium.Billboard>} ICAO hex -> billboard primitive */
|
/** The primitive drawing `_icons`. */
|
||||||
|
|
||||||
flightState._billboards = new Map();
|
flightState._iconPrimitive = null;
|
||||||
|
|
||||||
|
flightState._scratchIconRenderTime = new Cesium.JulianDate();
|
||||||
|
|
||||||
|
/** 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. */
|
/** Stable lightweight records reused by the detection overlay between polls. */
|
||||||
|
|
||||||
@@ -187,14 +188,6 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._lastFleetTickMs = 0;
|
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) */
|
/** @type {number} Last computed rotation for the tracked entity (radians) */
|
||||||
|
|
||||||
flightState._lastTrackedRotation = 0;
|
flightState._lastTrackedRotation = 0;
|
||||||
@@ -225,8 +218,14 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._scratchRenderTime = new Cesium.JulianDate();
|
flightState._scratchRenderTime = new Cesium.JulianDate();
|
||||||
|
|
||||||
|
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
|
||||||
|
|
||||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||||
|
|
||||||
|
flightState._scratchCockpitPos = new Cesium.Cartesian3();
|
||||||
|
|
||||||
|
flightState._scratchNearbyPos = new Cesium.Cartesian3();
|
||||||
|
|
||||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||||
|
|
||||||
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js';
|
import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js';
|
||||||
import { mirrorBillboard } from '../../data/billboardMirror.js';
|
|
||||||
import { mirrorModel } from '../../data/modelMirror.js';
|
import { mirrorModel } from '../../data/modelMirror.js';
|
||||||
|
import { createIconSet } from '../../data/iconSet.js';
|
||||||
|
import { aircraftIconTestSeams } from '../../data/aircraftIconLayer.js';
|
||||||
|
|
||||||
export function createTesting({
|
export function createTesting({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -9,6 +11,8 @@ export function createTesting({
|
|||||||
layer,
|
layer,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
|
const { _seedIconForTest, _iconForTest, _setIconForTest } =
|
||||||
|
aircraftIconTestSeams(flightState, parts, 'military');
|
||||||
/**
|
/**
|
||||||
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
||||||
* the production military poll reconciler. Tests still call
|
* the production military poll reconciler. Tests still call
|
||||||
@@ -17,13 +21,14 @@ export function createTesting({
|
|||||||
* @param {string} state.icao24
|
* @param {string} state.icao24
|
||||||
* @param {object} state.entity
|
* @param {object} state.entity
|
||||||
* @param {object} state.meta
|
* @param {object} state.meta
|
||||||
* @param {object} state.billboard
|
* @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard]
|
||||||
* @param {object} state.billboardCollection
|
* Where the contact's icon is held, and whether it is shown.
|
||||||
|
* @param {{show?: boolean}} [state.billboardCollection] Layer visibility.
|
||||||
* @param {object} state.viewer
|
* @param {object} state.viewer
|
||||||
* @param {Array<Object>} [state.history=[]]
|
* @param {Array<Object>} [state.history=[]]
|
||||||
* @param {boolean} [state.tracked=true]
|
* @param {boolean} [state.tracked=true]
|
||||||
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
|
* @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 _setTrackedMilitaryRefreshStateForTest({
|
function _setTrackedMilitaryRefreshStateForTest({
|
||||||
@@ -40,9 +45,11 @@ export function createTesting({
|
|||||||
}) {
|
}) {
|
||||||
flightState._viewer = viewer;
|
flightState._viewer = viewer;
|
||||||
flightState._modelCollection = modelCollection;
|
flightState._modelCollection = modelCollection;
|
||||||
flightState._billboardCollection = billboardCollection;
|
flightState._icons = createIconSet({
|
||||||
flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]);
|
epoch: parts.icons._displayTime(new Cesium.JulianDate()),
|
||||||
flightState._cullPositions.clear();
|
});
|
||||||
|
flightState._icons.show = billboardCollection?.show ?? true;
|
||||||
|
_seedIconForTest(icao24, billboard, tracked);
|
||||||
flightState._models.clear();
|
flightState._models.clear();
|
||||||
for (const [key, model] of models)
|
for (const [key, model] of models)
|
||||||
flightState._models.set(key, mirrorModel(model));
|
flightState._models.set(key, mirrorModel(model));
|
||||||
@@ -89,7 +96,7 @@ export function createTesting({
|
|||||||
billboard,
|
billboard,
|
||||||
history = [],
|
history = [],
|
||||||
}) {
|
}) {
|
||||||
flightState._billboards.set(icao24, mirrorBillboard(billboard));
|
_seedIconForTest(icao24, billboard, false);
|
||||||
flightState.records.data.set(icao24, meta);
|
flightState.records.data.set(icao24, meta);
|
||||||
flightState._positionHistory.set(icao24, history);
|
flightState._positionHistory.set(icao24, history);
|
||||||
}
|
}
|
||||||
@@ -135,13 +142,13 @@ export function createTesting({
|
|||||||
: TRACKED_ICON_COLOR;
|
: TRACKED_ICON_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 }) {
|
function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) {
|
||||||
return parts.rendering._driveFleetModelHandoff(
|
return parts.rendering._driveFleetModelHandoff(
|
||||||
icao24,
|
icao24,
|
||||||
flightState._models.get(icao24),
|
flightState._models.get(icao24),
|
||||||
flightState._billboards.get(icao24),
|
flightState._icons.row(icao24),
|
||||||
position,
|
position,
|
||||||
course,
|
course,
|
||||||
);
|
);
|
||||||
@@ -153,6 +160,7 @@ export function createTesting({
|
|||||||
await parts.rendering._ensureModel(icao24);
|
await parts.rendering._ensureModel(icao24);
|
||||||
return flightState._models.get(icao24) || null;
|
return flightState._models.get(icao24) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_setTrackedMilitaryRefreshStateForTest,
|
_setTrackedMilitaryRefreshStateForTest,
|
||||||
_setMilitaryTrackingRefreshOutcomeForTest,
|
_setMilitaryTrackingRefreshOutcomeForTest,
|
||||||
@@ -164,6 +172,8 @@ export function createTesting({
|
|||||||
_updateTrackedModelForTest,
|
_updateTrackedModelForTest,
|
||||||
_trackedBillboardColorForTest,
|
_trackedBillboardColorForTest,
|
||||||
_driveFleetModelHandoffForTest,
|
_driveFleetModelHandoffForTest,
|
||||||
|
_iconForTest,
|
||||||
|
_setIconForTest,
|
||||||
_ensureFleetModelForTest,
|
_ensureFleetModelForTest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -21,14 +21,12 @@ import {
|
|||||||
RENDER_DELAY_SEC,
|
RENDER_DELAY_SEC,
|
||||||
TRAIL_COLOR,
|
TRAIL_COLOR,
|
||||||
BILLBOARD_SCALE,
|
BILLBOARD_SCALE,
|
||||||
GROUND_SCALE,
|
|
||||||
AMBER_TRANSPARENT,
|
AMBER_TRANSPARENT,
|
||||||
TRACKED_ICON_COLOR,
|
TRACKED_ICON_COLOR,
|
||||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
|
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||||
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
|
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
|
||||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
|
||||||
|
|
||||||
export function createTracking({
|
export function createTracking({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -64,9 +62,9 @@ export function createTracking({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _publishTrackedSelection(icao24, origin = 'programmatic') {
|
function _publishTrackedSelection(icao24, origin = 'programmatic') {
|
||||||
const bb = flightState._billboards.get(icao24);
|
const position = parts.icons._iconPosition(icao24);
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
if (!billboardState(bb)?.position || !info) return false;
|
if (!position || !info) return false;
|
||||||
if (flightState._trackedEntity)
|
if (flightState._trackedEntity)
|
||||||
entityRecord(flightState._trackedEntity).selectionOrigin = origin;
|
entityRecord(flightState._trackedEntity).selectionOrigin = origin;
|
||||||
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
||||||
@@ -76,7 +74,7 @@ export function createTracking({
|
|||||||
parts.queries._toCleanText(info.callsign) ||
|
parts.queries._toCleanText(info.callsign) ||
|
||||||
parts.queries._toCleanText(info.registration) ||
|
parts.queries._toCleanText(info.registration) ||
|
||||||
icao24,
|
icao24,
|
||||||
position: Cesium.Cartesian3.clone(billboardState(bb).position),
|
position,
|
||||||
origin,
|
origin,
|
||||||
});
|
});
|
||||||
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
||||||
@@ -152,9 +150,12 @@ export function createTracking({
|
|||||||
}
|
}
|
||||||
const previous = flightState._cockpitNearContacts;
|
const previous = flightState._cockpitNearContacts;
|
||||||
const distancesSquared = [];
|
const distancesSquared = [];
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
const icons = flightState._icons;
|
||||||
const position = billboardState(bb)?.position;
|
const tau = parts.icons._displayTau();
|
||||||
if (icao24 === flightState._trackedIcao || !position) continue;
|
for (let row = 0; row < (icons?.count ?? 0); row++) {
|
||||||
|
const icao24 = icons.idAt(row);
|
||||||
|
if (icao24 === flightState._trackedIcao) continue;
|
||||||
|
const position = icons.position(row, tau, flightState._scratchCockpitPos);
|
||||||
distancesSquared.push([
|
distancesSquared.push([
|
||||||
icao24,
|
icao24,
|
||||||
Cesium.Cartesian3.distanceSquared(
|
Cesium.Cartesian3.distanceSquared(
|
||||||
@@ -170,13 +171,10 @@ export function createTracking({
|
|||||||
parts.rendering._modelKeepDistM(),
|
parts.rendering._modelKeepDistM(),
|
||||||
);
|
);
|
||||||
flightState._cockpitNearContacts = next;
|
flightState._cockpitNearContacts = next;
|
||||||
let presentationChanged = false;
|
for (const icao24 of previous)
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
if (!next.has(icao24)) parts.icons._applyIconPresentation(icao24);
|
||||||
if (previous.has(icao24) === next.has(icao24)) continue;
|
for (const icao24 of next)
|
||||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
if (!previous.has(icao24)) parts.icons._applyIconPresentation(icao24);
|
||||||
presentationChanged = true;
|
|
||||||
}
|
|
||||||
if (presentationChanged) flightState._lastCamPoseSig = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */
|
/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */
|
||||||
@@ -193,9 +191,9 @@ export function createTracking({
|
|||||||
if (flightState._modelCollection) flightState._modelCollection.show = true;
|
if (flightState._modelCollection) flightState._modelCollection.show = true;
|
||||||
flightState._trail?.setVisible(!next);
|
flightState._trail?.setVisible(!next);
|
||||||
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
||||||
for (const [icao24, bb] of flightState._billboards)
|
const icons = flightState._icons;
|
||||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
for (let row = 0; row < (icons?.count ?? 0); row++)
|
||||||
flightState._lastCamPoseSig = '';
|
parts.icons._applyIconPresentation(icons.idAt(row));
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,29 +295,28 @@ export function createTracking({
|
|||||||
if (isTr3b(id)) {
|
if (isTr3b(id)) {
|
||||||
if (flightState._models.has(id) || flightState._modelPending.has(id))
|
if (flightState._models.has(id) || flightState._modelPending.has(id))
|
||||||
parts.rendering._releaseModel(id);
|
parts.rendering._releaseModel(id);
|
||||||
const modelled = flightState._billboards.get(id);
|
const row = flightState._icons?.row(id) ?? -1;
|
||||||
if (modelled && id !== flightState._trackedIcao)
|
if (row >= 0 && id !== flightState._trackedIcao)
|
||||||
setBillboard(modelled, 'show', true); // horizon cull re-asserts next tick
|
flightState._icons.setShown(row, true);
|
||||||
if (id === flightState._trackedIcao) {
|
if (id === flightState._trackedIcao) {
|
||||||
parts.rendering._releaseTrackedModel();
|
parts.rendering._releaseTrackedModel();
|
||||||
_syncTracked2dRotation();
|
_syncTracked2dRotation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bb = flightState._billboards.get(id);
|
parts.icons._applyIconPresentation(id);
|
||||||
if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb);
|
|
||||||
if (id === flightState._trackedIcao) {
|
if (id === flightState._trackedIcao) {
|
||||||
_syncTrackedBillboardImage();
|
_syncTrackedBillboardImage();
|
||||||
_updateTrackedLabelModel(id);
|
_updateTrackedLabelModel(id);
|
||||||
}
|
}
|
||||||
flightState._viewer?.scene?.requestRender?.();
|
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). */
|
/** Re-image every converted contact this layer owns (IR style flip). */
|
||||||
|
|
||||||
function _refreshTr3bForStyle() {
|
function _refreshTr3bForStyle() {
|
||||||
for (const id of tr3bConvertedIds()) {
|
for (const id of tr3bConvertedIds()) {
|
||||||
if (flightState._billboards.has(id) || id === flightState._trackedIcao)
|
if (flightState._icons?.has(id) || id === flightState._trackedIcao)
|
||||||
_refreshTr3bContact(id);
|
_refreshTr3bContact(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,8 +412,7 @@ export function createTracking({
|
|||||||
if (!flightState._trackedIcao || !flightState._viewer) return;
|
if (!flightState._trackedIcao || !flightState._viewer) return;
|
||||||
const pos =
|
const pos =
|
||||||
parts.motion._trackedDisplayCached() ||
|
parts.motion._trackedDisplayCached() ||
|
||||||
billboardState(flightState._billboards.get(flightState._trackedIcao))
|
parts.icons._iconPosition(flightState._trackedIcao);
|
||||||
?.position;
|
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
const projected = screenProjectedRotation(
|
const projected = screenProjectedRotation(
|
||||||
flightState._viewer.scene,
|
flightState._viewer.scene,
|
||||||
@@ -745,7 +741,7 @@ export function createTracking({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop tracking the current aircraft and clean up all tracking state.
|
* Stop tracking the current aircraft and clean up all tracking state.
|
||||||
* Restores the original billboard, removes the tracked Entity, invalidates
|
* Hands the contact back to its icon, removes the tracked Entity, invalidates
|
||||||
* the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo
|
* the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo
|
||||||
* (mirror of flights.js). Deselect used to fly an ~80 km pulled-back
|
* (mirror of flights.js). Deselect used to fly an ~80 km pulled-back
|
||||||
* overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms
|
* overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms
|
||||||
@@ -777,34 +773,9 @@ export function createTracking({
|
|||||||
const clearedIcao = flightState._trackedIcao;
|
const clearedIcao = flightState._trackedIcao;
|
||||||
clearFocusTarget('militaryFlights', clearedIcao);
|
clearFocusTarget('militaryFlights', clearedIcao);
|
||||||
|
|
||||||
// Restore the original billboard appearance. The rotation is re-seeded from
|
// The nose the icon continues from (the shared _displayCourse entry), so
|
||||||
// the tracked entity's last rendered rotation and a rotation pass is forced
|
// release never flips it.
|
||||||
// (_lastCamPoseSig below): the fleet billboard otherwise reappears with the
|
const trackedCourseDeg = flightState._displayCourse.get(clearedIcao);
|
||||||
// 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);
|
|
||||||
const meta = flightState.records.data.get(flightState._trackedIcao);
|
|
||||||
setBillboard(bb, 'show', true);
|
|
||||||
setBillboard(bb, 'width', 20);
|
|
||||||
setBillboard(bb, 'height', 20);
|
|
||||||
// Ground-aware restore (a plane untracked while taxiing comes back at
|
|
||||||
// ground scale; the fleet tint is amber, or red while broadcasting an
|
|
||||||
// emergency, on the ground and in the air).
|
|
||||||
setBillboard(
|
|
||||||
bb,
|
|
||||||
'scale',
|
|
||||||
BILLBOARD_SCALE *
|
|
||||||
(CLASS_SCALE_2D[meta?.klass] || 1) *
|
|
||||||
(meta?.onGround ? GROUND_SCALE : 1),
|
|
||||||
);
|
|
||||||
bb.color = parts.rendering._fleetBillboardColor(
|
|
||||||
flightState._trackedIcao,
|
|
||||||
meta,
|
|
||||||
);
|
|
||||||
setBillboard(bb, 'rotation', flightState._lastTrackedRotation);
|
|
||||||
}
|
|
||||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
|
||||||
|
|
||||||
// Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the
|
// Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the
|
||||||
// follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now).
|
// follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now).
|
||||||
@@ -820,10 +791,13 @@ export function createTracking({
|
|||||||
parts.rendering._releaseTrackedModel();
|
parts.rendering._releaseTrackedModel();
|
||||||
_resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped
|
_resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped
|
||||||
flightState._trackedIcao = null;
|
flightState._trackedIcao = null;
|
||||||
parts.rendering._applyFleetBillboardPresentation(
|
// Hand the contact back to its icon only now, once it no longer reads as
|
||||||
clearedIcao,
|
// tracked (the settle plans it as a fleet contact).
|
||||||
flightState._billboards.get(clearedIcao),
|
if (flightState._icons?.has(clearedIcao)) {
|
||||||
);
|
flightState._icons.setShown(flightState._icons.row(clearedIcao), true);
|
||||||
|
parts.icons._settleIcon(clearedIcao, { fromCourseDeg: trackedCourseDeg });
|
||||||
|
}
|
||||||
|
parts.icons._applyIconPresentation(clearedIcao);
|
||||||
clearTrackedSubjectContext('military');
|
clearTrackedSubjectContext('military');
|
||||||
_emitAwarenessEvent('gev:awareness-subject-cleared', {
|
_emitAwarenessEvent('gev:awareness-subject-cleared', {
|
||||||
layerId: 'military',
|
layerId: 'military',
|
||||||
@@ -844,10 +818,7 @@ export function createTracking({
|
|||||||
pending.generation !== flightState._trackingIntentGeneration
|
pending.generation !== flightState._trackingIntentGeneration
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
if (
|
if (!flightState._icons?.show || !flightState._icons.has(pending.id))
|
||||||
!flightState._billboardCollection?.show ||
|
|
||||||
!flightState._billboards.has(pending.id)
|
|
||||||
)
|
|
||||||
return false;
|
return false;
|
||||||
flightState._pendingTrackingRestore = null;
|
flightState._pendingTrackingRestore = null;
|
||||||
_trackFlight(pending.id, { origin: pending.origin });
|
_trackFlight(pending.id, { origin: pending.origin });
|
||||||
@@ -861,7 +832,7 @@ export function createTracking({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin tracking a specific aircraft. Clears any previous tracking, hides the
|
* Begin tracking a specific aircraft. Clears any previous tracking, hides the
|
||||||
* billboard, creates a new Entity with dead-reckoning CallbackProperties for
|
* icon, creates a new Entity with dead-reckoning CallbackProperties for
|
||||||
* smooth continuous motion, and sets the viewer's trackedEntity so the camera
|
* smooth continuous motion, and sets the viewer's trackedEntity so the camera
|
||||||
* follows.
|
* follows.
|
||||||
* @param {string} icao24 - ICAO hex identifier of the aircraft to track
|
* @param {string} icao24 - ICAO hex identifier of the aircraft to track
|
||||||
@@ -870,29 +841,42 @@ export function createTracking({
|
|||||||
function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
|
function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
|
||||||
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
|
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
|
||||||
|
|
||||||
const bb = flightState._billboards.get(icao24);
|
const icons = flightState._icons;
|
||||||
|
const row = icons?.row(icao24) ?? -1;
|
||||||
const info = flightState.records.data.get(icao24);
|
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;
|
flightState._trackedIcao = icao24;
|
||||||
_resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget
|
_resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget
|
||||||
flightState._cachedDRFrame = -1;
|
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
|
// 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
|
// own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover
|
||||||
// fleet model would be orphaned + double-rendered.
|
// fleet model would be orphaned + double-rendered.
|
||||||
parts.rendering._releaseModel(icao24);
|
parts.rendering._releaseModel(icao24);
|
||||||
|
|
||||||
// Hide the billboard -- the tracked entity replaces it visually
|
// Hide the icon -- the tracked entity replaces it visually
|
||||||
setBillboard(bb, 'show', false);
|
icons.setShown(row, false);
|
||||||
|
|
||||||
// Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition —
|
// Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition —
|
||||||
// one computation shared by the position/rotation/trail-head callbacks, with
|
// one computation shared by the position/rotation/trail-head callbacks, with
|
||||||
// discontinuity reconciliation). Falls back to the last billboard position when the
|
// discontinuity reconciliation). Falls back to the icon's position when the
|
||||||
// aircraft has no fix.
|
// aircraft has no fix.
|
||||||
const getTrackedPosition = () =>
|
const getTrackedPosition = () =>
|
||||||
parts.motion._trackedDisplayPosition(icao24) ||
|
parts.motion._trackedDisplayPosition(icao24) ||
|
||||||
billboardState(bb).position;
|
parts.icons._iconPosition(icao24) ||
|
||||||
|
iconPosition;
|
||||||
|
|
||||||
// Dead-reckoning position property evaluated every render frame.
|
// Dead-reckoning position property evaluated every render frame.
|
||||||
// isConstant = false so CesiumJS re-evaluates each frame.
|
// isConstant = false so CesiumJS re-evaluates each frame.
|
||||||
@@ -958,7 +942,7 @@ export function createTracking({
|
|||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||||
// The tracked target must never vanish into tile geometry — tracking a
|
// The tracked target must never vanish into tile geometry — tracking a
|
||||||
// taxiing plane at street level would otherwise bury the amber icon inside
|
// taxiing plane at street level would otherwise bury the amber icon inside
|
||||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
// the runway skin, so it draws depth-test-free like the fleet icon set;
|
||||||
// its shared-host tracked card is top-composited separately.
|
// its shared-host tracked card is top-composited separately.
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||||
@@ -1120,26 +1104,12 @@ export function createTracking({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BillboardCollection picks: CesiumJS may expose the billboard as
|
// Icon picks carry the icao on picked.id; models carry it on the primitive.
|
||||||
// picked.primitive (with .id = icao24) or directly as picked.id
|
const pickedIcao =
|
||||||
const billboard = picked.primitive;
|
typeof picked.id === 'string' ? picked.id : picked.primitive?.id;
|
||||||
if (
|
if (pickedIcao && flightState._icons?.has(pickedIcao)) {
|
||||||
billboard &&
|
|
||||||
billboard.id &&
|
|
||||||
flightState._billboards.has(billboard.id)
|
|
||||||
) {
|
|
||||||
_cancelPendingTrackingRestore();
|
_cancelPendingTrackingRestore();
|
||||||
_trackFlight(billboard.id, { origin: 'user' });
|
_trackFlight(pickedIcao, { origin: 'user' });
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fallback: some CesiumJS versions surface the id string at picked.id
|
|
||||||
if (
|
|
||||||
picked.id &&
|
|
||||||
typeof picked.id === 'string' &&
|
|
||||||
flightState._billboards.has(picked.id)
|
|
||||||
) {
|
|
||||||
_cancelPendingTrackingRestore();
|
|
||||||
_trackFlight(picked.id, { origin: 'user' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,15 @@ export function readLayerSource(file) {
|
|||||||
? '../layers/cctv'
|
? '../layers/cctv'
|
||||||
: '../layers/vessels',
|
: '../layers/vessels',
|
||||||
);
|
);
|
||||||
|
// The aircraft layers draw through one shared icon layer; it is their code too.
|
||||||
|
const shared = ['flights.js', 'militaryFlights.js'].includes(basename(path))
|
||||||
|
? [join(dirname(path), 'aircraftIconLayer.js')]
|
||||||
|
: [];
|
||||||
return readdirSync(directory)
|
return readdirSync(directory)
|
||||||
.filter((name) => name.endsWith('.js'))
|
.filter((name) => name.endsWith('.js'))
|
||||||
.sort()
|
.sort()
|
||||||
.map((name) => readFileSync(join(directory, name), 'utf8'))
|
.map((name) => join(directory, name))
|
||||||
|
.concat(shared)
|
||||||
|
.map((file) => readFileSync(file, 'utf8'))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
@@ -706,7 +706,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
militaryRendering,
|
militaryRendering,
|
||||||
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
/alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
cyberStyles,
|
cyberStyles,
|
||||||
|
|||||||
Reference in new issue
Block a user