From e1be2eac192844477144f1b47373f491f3366b1e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 21:14:59 +0400 Subject: [PATCH] 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. --- src/data/detection.js | 18 +++++++--- src/data/trackedReadout.js | 59 +++++++++++++++++++++++++++----- src/data/trackedReadout.test.mjs | 38 ++++++++++++++++++++ src/globe/cesium/follow.js | 1 + src/globe/contract.js | 4 +++ src/globe/follow.js | 15 ++++++++ src/ui/applicationShell.js | 14 ++++---- 7 files changed, 129 insertions(+), 20 deletions(-) diff --git a/src/data/detection.js b/src/data/detection.js index 0ab9b56b5..954c128ec 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -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, diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 486967bf3..a8056f8a6 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -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. */ diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 425a69ceb..c1c697b74 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -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; + } +}); diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index 5e6c983fc..2fc90b16e 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -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 = diff --git a/src/globe/contract.js b/src/globe/contract.js index 24bbfe709..59f51ae7a 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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) => void} retarget * @property {any} [native] + * @property {object} [label] FollowOptions.label. * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType * @typedef {'ctrl'|'shift'|'alt'} InputModifier diff --git a/src/globe/follow.js b/src/globe/follow.js index 56c749f07..abc4e87d9 100644 --- a/src/globe/follow.js +++ b/src/globe/follow.js @@ -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(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ab613939b..338e07387 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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();