feat(app): detection and readout on Navara

The shell creates detection and the tracked readout on the engine, so the
Navara mode has them too (before: not created without a viewer). Detection
reads the camera height from the engine. The tracked readout follows
engine.camera.following(): a follow's plain-data label
(FollowOptions.label: { id, model, position }, the readout card's model
and anchor) or, on Cesium, the followed Entity and its gevLabelModel as
before (Cesium layers keep publishing the same). refreshTrackedReadout
takes a follow handle too. A bare viewer without a registered engine
(tests) is still read for its trackedEntity.

In the app on Navara: "[Detection] Initialized", "[TrackedReadout]
Initialized", and a follow with a label paints its card.
This commit is contained in:
German Khokhlov committed 2026-10-16 21:14:59 +04:00
1 parent 7d5a484796
commit e1be2eac19
7 files changed
+129 -20

No files matched your search

+13 -5
View File
@@ -1,3 +1,4 @@
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { paintTransitBracket } from './detectionDraw.js';
import * as geo from '../geo/index.js';
import { governorRequestRender } from '../renderGovernor.js';
@@ -195,6 +196,14 @@ export function countFadingRenderEntries(renderEntries) {
/** @type {Cesium.Viewer|null} */
let _viewer = null;
/** The camera height (m): the engine's, else a bare viewer's (tests). */
function cameraHeight() {
const engine = engineFromViewer(_viewer);
return engine
? engine.camera.height()
: _viewer?.camera?.positionCartographic?.height;
}
/** @type {number} Current mode index into MODE_LABELS */
let _mode = MODE_OFF;
/** @type {HTMLCanvasElement|null} Shared host canvas used for QA diagnostics. */
@@ -281,7 +290,8 @@ let _cockpitModeListener = null;
/**
* Initializes detection inside the shared world-overlay host and stores
* references to data layers. The host owns the canvas and render listener.
* @param {Cesium.Viewer} viewer - The active Cesium viewer instance.
* @param {object} viewer - The globe engine (or a Cesium viewer; only the
* camera height is read, through the engine when one is registered).
* @param {Array} layers - Data layer modules that may implement getDetectableObjects().
* @param {Function} onModeChange - Callback invoked with the new mode label string on mode changes.
*/
@@ -1266,9 +1276,7 @@ function _drawOverlay(frame) {
profile: MODE_LABELS[_mode],
densityPct: _densityPct,
allocationStrategy: _allocationStrategy,
viewScale: viewScaleForAltitude(
_viewer?.camera?.positionCartographic?.height,
),
viewScale: viewScaleForAltitude(cameraHeight()),
candidateCount: 0,
observationCount: 0,
visibleCount: 0,
@@ -1497,7 +1505,7 @@ function _drawOverlay(frame) {
}
}
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const altitude = cameraHeight() ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
const ambientBudget = Math.max(
0,
+50 -9
View File
@@ -4,6 +4,7 @@ import {
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
/**
* @module trackedReadout
@@ -34,6 +35,9 @@ const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({
});
let _viewer = null;
// The globe engine, when the readout was given one (or a viewer with a
// registered engine): the followed target is engine.camera.following().
let _engine = null;
let _trackedEntityChangedRemove = null;
let _selectedContext = null;
let _contextSelectedHandler = null;
@@ -122,8 +126,35 @@ function entryIdFor(entity) {
return fallback ? `entity:${fallback}` : null;
}
// A follow's plain-data label as the subject the readout reads (the shape a
// Cesium layer's tracked Entity has): one subject per follow handle.
const _labelSubjects = new WeakMap();
function followSubject(handle) {
if (!handle) return null;
const { label } = handle;
if (!label) return handle.native ?? null;
let subject = _labelSubjects.get(handle);
if (!subject) {
subject = {
gevTrackedId: label.id,
get gevLabelModel() {
const { model } = handle.label ?? label;
return typeof model === 'function' ? model() : model;
},
gevDisplayPosition: () => (handle.label ?? label).position(),
};
_labelSubjects.set(handle, subject);
}
return subject;
}
function trackedSubject() {
if (_engine) return followSubject(_engine.camera.following());
return _viewer?.trackedEntity || null;
}
function activeEntity() {
return _viewer?.trackedEntity || _selectedContext?.entity || null;
return trackedSubject() || _selectedContext?.entity || null;
}
/**
@@ -210,7 +241,9 @@ function syncActiveEntity() {
* @param {Object} entity Entity whose model changed.
*/
export function refreshTrackedReadout(entity) {
if (entity && entity === activeEntity()) publishEntity(entity);
// A follow handle stands for its label subject.
const subject = _labelSubjects.get(entity) ?? entity;
if (subject && subject === activeEntity()) publishEntity(subject);
}
/** Current tracked host entry id, used to resolve its actual painted rectangle. */
@@ -224,16 +257,23 @@ const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
/**
* Initialize the model bridge and selection listeners. No render listener is
* installed; the already-initialized world-overlay host owns the frame lane.
* @param {Object} viewer Active Cesium viewer.
* @param {Object} target The globe engine (or a viewer whose engine is
* registered): the readout follows engine.camera.following(), its label
* (plain data) or its Cesium entity. A bare viewer (tests) is read for
* its trackedEntity.
*/
export function initTrackedReadout(viewer) {
if (!viewer || _viewer === viewer) return;
export function initTrackedReadout(target) {
if (!target || _viewer === target) return;
if (_viewer) destroyTrackedReadout();
_viewer = viewer;
_viewer = target;
_engine = engineFromViewer(target);
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
const changed = _engine
? _engine.camera.followChanged
: target.trackedEntityChanged;
_trackedEntityChangedRemove =
viewer.trackedEntityChanged?.addEventListener?.(() => {
if (viewer.trackedEntity) _selectedContext = null;
changed?.addEventListener?.(() => {
if (trackedSubject()) _selectedContext = null;
syncActiveEntity();
}) || null;
_contextSelectedHandler = (event) => {
@@ -259,7 +299,7 @@ export function initTrackedReadout(viewer) {
};
_aircraftSelectedHandler = () => {
_selectedContext = null;
if (!_viewer?.trackedEntity) clearTrackedSource();
if (!trackedSubject()) clearTrackedSource();
};
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
window.addEventListener(
@@ -299,6 +339,7 @@ export function destroyTrackedReadout() {
clearTrackedSource();
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false);
_viewer = null;
_engine = null;
}
/** Inject a host recorder for focused lifecycle tests; null restores production. */
+38
View File
@@ -304,3 +304,41 @@ test('civilian and military trail heads use the lower-centre model anchor and we
`${name} keeps diffuse texture contribution weak through code-side MIX`);
}
});
test("on a globe engine the readout follows engine.camera.follow's plain-data label", async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const originalWindow = globalThis.window;
globalThis.window = new EventTarget();
const recorder = makeHostRecorder();
_setTrackedOverlayHostForTest(recorder.host);
const engine = createFakeEngine();
const anchor = { x: 6_378_137, y: 0, z: 1000 };
let model = { title: 'ISS (ZARYA)', details: ['408 km'], accent: '#ffd166' };
try {
initTrackedReadout(engine);
assert.equal(getActiveTrackedReadoutId(), null);
const handle = engine.camera.follow({
position: () => anchor,
viewFrom: { x: 0, y: -5000, z: 2000 },
label: { id: 'satellites:25544', model: () => model, position: () => anchor },
});
assert.equal(getActiveTrackedReadoutId(), 'satellites:25544');
const entry = recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0];
assert.equal(entry.title, 'ISS (ZARYA)');
assert.equal(entry.position(), anchor);
// A layer republishes its model through the follow handle.
model = { ...model, details: ['409 km'] };
refreshTrackedReadout(handle);
assert.deepEqual(
recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details,
['409 km'],
);
handle.stop();
assert.equal(getActiveTrackedReadoutId(), null);
} finally {
destroyTrackedReadout();
_setTrackedOverlayHostForTest();
engine.destroy();
globalThis.window = originalWindow;
}
});
+1
View File
@@ -275,6 +275,7 @@ export function createCesiumFollow(viewer, { commanded }) {
}
state.entity = entity;
state.handle.native = options.native;
state.handle.label = options.label;
viewer.camera.cancelFlight();
viewer.trackedEntity = entity;
const viewFrom =
+4
View File
@@ -143,11 +143,15 @@
* @property {'enu'} [frame]
* @property {any} [native] Cesium only (layers' render/cesium.js): the
* Entity to put on viewer.trackedEntity instead of a proxy.
* @property {{ id: string, model: object | (() => object), position: () => Cartesian3 }} [label]
* The tracked readout's card (src/data/trackedReadout.js): its id, label
* model ({ title, details, accent, ... }) and anchor, as plain data.
*
* @typedef {object} FollowHandle
* @property {() => void} stop
* @property {(next: Partial<FollowOptions>) => void} retarget
* @property {any} [native]
* @property {object} [label] FollowOptions.label.
*
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
+15
View File
@@ -40,11 +40,24 @@ export function normalizeFollowOptions(options) {
throw new TypeError(`camera.follow minRange must be >= 0: ${minRange}`);
if (options.frame !== undefined && options.frame !== 'enu')
throw new TypeError("camera.follow frame must be 'enu'");
const { label } = options;
if (
label !== undefined &&
!(
label &&
typeof label.id === 'string' &&
typeof label.position === 'function'
)
)
throw new TypeError(
'camera.follow label needs { id, model, position: () => Cartesian3 }',
);
return {
position: options.position,
viewFrom: viewFromOf,
minRange,
native: options.native,
label,
};
}
@@ -305,6 +318,7 @@ export function createFollowController({
const handle = {
options,
native: options.native,
label: options.label,
offset: null,
previous: null,
stop: () => end(handle),
@@ -312,6 +326,7 @@ export function createFollowController({
const updated = normalizeFollowOptions({ ...options, ...next });
handle.options = updated;
handle.native = updated.native;
handle.label = updated.label;
handle.offset = null;
handle.previous = null;
requestRender();
+8 -6
View File
@@ -415,8 +415,9 @@ export class StyleManager extends ShellFacade {
// Full-globe sun/moon ring. It is a crisp screen-space overlay above the
// Cesium canvas but below the HUD/detection/readout z ladder.
// Viewer-bound modules (the ring, detection, tracked readout, cockpit)
// are not created without a viewer (the experimental Navara engine).
// Viewer-bound modules (the ring, cockpit) are not created without a
// viewer (the experimental Navara engine); detection and the tracked
// readout are the engine's.
this.celestialRing = viewer
? new CelestialRing(viewer, {
enabled: false,
@@ -482,10 +483,11 @@ export class StyleManager extends ShellFacade {
initWorldOverlay(engine);
// Initialize detection overlay BEFORE style stages so the composite
// stage is first in the post-process pipeline
if (viewer) {
// stage is first in the post-process pipeline. Both run on the engine
// (the Navara mode too: its layers arrive with Tasks 10a-13a).
{
initDetection(
viewer,
engine,
[
trafficLayer,
flightsLayer,
@@ -501,7 +503,7 @@ export class StyleManager extends ShellFacade {
this._updateDetectionButton(modeLabel);
},
);
initTrackedReadout(viewer);
initTrackedReadout(engine);
}
setDetectionStyle(this.activeStyle);
this._applyDetectionDensityFromUi();