From 2adec097deab95a2e2a0b51cc9c1c92a1f09871b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 12:27:23 -0800 Subject: [PATCH] Read what the camera follows through one Core module src/data/followed.js answers what the camera follows: the camera port's target, or the entity a layer still tracks itself, seen through the same fields. Ownership checks, render holds, guards, voice actions, the CCTV pacing and the navigation release paths read it instead of Cesium's trackedEntity, so they keep working when layers follow through the port. The release paths stop following, cancel the flight and release the camera through the port. The rotate refusal names the followed target by its name or label title. --- scripts/package-boundaries.json | 35 +++++++- src/app/tools.js | 11 ++- src/cameraGlobeRotation.test.mjs | 9 ++- src/cameraGroundGuard.js | 4 +- src/cameraGroundGuard.test.mjs | 4 +- src/cameraHandoff.test.mjs | 10 +-- src/cameraVerbs.js | 18 ++--- src/cctvFocusPolicy.js | 17 ++-- src/cctvFocusPolicy.test.mjs | 12 +-- src/data/cctv.test.mjs | 8 +- src/data/followed.js | 127 ++++++++++++++++++++++++++++++ src/data/followed.test.mjs | 123 +++++++++++++++++++++++++++++ src/data/geoImportPins.js | 6 +- src/layers/alpr/index.js | 6 +- src/layers/alpr/presentation.js | 3 +- src/layers/awareness/subject.js | 16 ++-- src/layers/cctv/geometryQueue.js | 9 ++- src/layers/cctv/navigation.js | 3 +- src/layers/launches/replay.js | 3 +- src/layers/radio/navigation.js | 3 +- src/layers/vessels/selection.js | 14 ++-- src/render/cesium/camera.js | 9 ++- src/render/unboundCamera.test.mjs | 8 ++ src/scenes/director.js | 3 +- src/ui/applicationShell.js | 3 +- src/ui/layerBindings.js | 11 ++- src/ui/locationNavigation.js | 9 ++- src/ui/navigationController.js | 10 ++- src/voice/gevActions.js | 7 +- 29 files changed, 404 insertions(+), 97 deletions(-) create mode 100644 src/data/followed.js create mode 100644 src/data/followed.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 136e1cc6b..2aab55914 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -992,6 +992,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/data/followed.js", "src/render/cesium/camera.js", "src/render/cesium/follow.js", "src/render/rendererOf.js", @@ -1256,7 +1257,9 @@ "src/data/bhoteKoshiFloodPath.js", "src/maps/catalog.js", "src/scenes/director.js", + "src/data/followed.js", "src/render/cesium/camera.js", + "src/data/entityRecords.js", "src/render/cesium/follow.js", "src/geo/index.js", "src/render/rendererOf.js", @@ -1572,6 +1575,7 @@ "src/data/aircraftRecession.js", "src/data/motionModel.js", "src/layers/vessels/selection.js", + "src/data/followed.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", @@ -1600,6 +1604,7 @@ "src/input/sensorPick.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/rendererOf.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js" @@ -1753,6 +1758,7 @@ "src/layers/launches/controls.js", "src/layers/launches/index.js", "src/render/cesium/camera.js", + "src/data/entityRecords.js", "src/render/cesium/follow.js", "src/layers/launches/ingestion.js", "src/layers/launches/launchPad.js", @@ -1776,6 +1782,7 @@ "src/render/cesium/common.js", "src/layers/launches/rendering.js", "src/layers/launches/replay.js", + "src/data/followed.js", "src/layers/launches/selection.js", "src/layers/launches/source.js", "src/layers/launches/state.js", @@ -1917,6 +1924,7 @@ "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", + "src/data/followed.js", "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js", "src/i18n/index.js", @@ -2059,6 +2067,7 @@ "src/layers/cctv/geometry.js", "src/data/entityRecords.js", "src/layers/cctv/geometryQueue.js", + "src/data/followed.js", "src/layers/cctv/ground.js", "src/layers/cctv/headingConfidence.js", "src/geo/index.js", @@ -2225,6 +2234,7 @@ "modules": [ "src/data/headingDegrees.js", "src/layers/alpr/index.js", + "src/data/followed.js", "src/geo/index.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", @@ -2300,7 +2310,9 @@ "src/layers/radio/lifecycle.js", "src/layers/radio/model.js", "src/layers/radio/navigation.js", + "src/data/followed.js", "src/render/cesium/camera.js", + "src/data/entityRecords.js", "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", @@ -2647,6 +2659,7 @@ "src/app/stateChannel.js", "src/app/surfaceServices.js", "src/app/tools.js", + "src/data/followed.js", "src/app/viewer.js", "src/render/cesium/ctrlWheelZoom.js", "src/sky/starrySky.js", @@ -3726,6 +3739,7 @@ "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", "src/layers/alpr/index.js", + "src/data/followed.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", @@ -4255,6 +4269,7 @@ "src/layers/firms/anchors.js", "src/locations.js", "src/render/cesium/camera.js", + "src/data/entityRecords.js", "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", @@ -4267,6 +4282,7 @@ "src/render/cesium/common.js", "src/cityPois.js", "src/cameraGroundGuard.js", + "src/data/followed.js", "src/search/alpsGeocoder.js", "src/search/defaults.js", "src/search/geospatial.js", @@ -4350,7 +4366,10 @@ "exports": ["./ui/navigation"], "modules": [ "src/navigationPolicy.js", + "src/geo/index.js", "src/ui/navigationController.js", + "src/render/cesium/camera.js", + "src/data/followed.js", "src/app/stateChannel.js", "src/data/cameraArrival.js", "src/i18n/locale.js", @@ -4365,7 +4384,18 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/i18n/index.js" + "src/i18n/index.js", + "src/render/rendererOf.js", + "src/render/camera/events.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", + "src/geo/frames.js", + "src/geo/ellipsoid.js", + "src/geo/vector.js", + "src/render/cesium/follow.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", + "src/data/entityRecords.js" ], "external": ["cesium"] }, @@ -5169,6 +5199,7 @@ "src/i18n/locales/zh-Hant.json", "src/data/geoImportImages.js", "src/data/geoImportPins.js", + "src/data/followed.js", "src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/view.js", "src/data/inputOwnership.js", @@ -5177,6 +5208,8 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/rendererOf.js", + "src/data/entityRecords.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js", diff --git a/src/app/tools.js b/src/app/tools.js index fe647635b..b7dfcb375 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,3 +1,4 @@ +import { onFollowChanged } from '../data/followed.js'; import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; @@ -146,12 +147,10 @@ export function createApplicationTools({ // The follow camera recomputes the tracked target's dead-reckon position // every frame — tracking anything is a per-frame animation. (perf wave 2) - const removeTrackingListener = viewer.trackedEntityChanged.addEventListener( - () => { - if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); - else releaseContinuousRender('tracked-entity'); - }, - ); + const removeTrackingListener = onFollowChanged(viewer, (followed) => { + if (followed) holdContinuousRender('tracked-entity'); + else releaseContinuousRender('tracked-entity'); + }); // Hidden-state suspension (perf wave 2): when the window/tab is hidden, // stop the default render loop outright — a hidden canvas repaints for diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 1456981fd..febc84225 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -289,9 +289,12 @@ test('replaced viewer releases the old tracking listener without duplicate subsc true, ); const id = activeCameraMotionId(); - first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + // As Cesium's setter does: the value changes, then the event fires. + first.viewer.trackedEntity = { id: 'stale-aircraft' }; + first.viewer.trackedEntityChanged.raiseEvent(first.viewer.trackedEntity); assert.equal(activeCameraMotionId(), id); - next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + next.viewer.trackedEntity = { id: 'new-aircraft' }; + next.viewer.trackedEntityChanged.raiseEvent(next.viewer.trackedEntity); assert.equal(getActiveCameraMotion(), null); assert.equal(hasRenderHold(), false); }); @@ -598,7 +601,7 @@ for (const mode of ['once', 'continuous']) { test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { const { viewer, camera, target } = harness(t); - viewer.trackedEntity = { id: 'tracked-aircraft' }; + viewer.trackedEntity = { id: 'tracked-aircraft', name: 'tracked-aircraft' }; camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); const before = pose(camera); const transform = Cesium.Matrix4.clone(camera.transform); diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 225af25ec..220918dd0 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -14,6 +14,7 @@ * lifts the eye if it sits below a usable clearance. Heading and pitch are * held, so the correction reads as the shot settling. */ +import { onFollowChanged } from './data/followed.js'; import * as Cesium from 'cesium'; import { ownCameraArrival } from './data/cameraArrival.js'; import { heightsFor } from './queries/heights.js'; @@ -164,8 +165,7 @@ export function guardCameraAboveGround(viewer, at, options = {}) { release(); }; release = ownCameraArrival(viewer, stop); - const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop); - if (removeTracking) removers.push(removeTracking); + removers.push(onFollowChanged(viewer, stop)); documentRef?.addEventListener?.('keydown', stop, true); // A manual gesture ends the guard, as it interrupts camera motion elsewhere. for (const type of ['pointerdown', 'wheel']) diff --git a/src/cameraGroundGuard.test.mjs b/src/cameraGroundGuard.test.mjs index 0b994102e..8503594c4 100644 --- a/src/cameraGroundGuard.test.mjs +++ b/src/cameraGroundGuard.test.mjs @@ -205,7 +205,9 @@ for (const handoff of [ }, }); } else if (handoff === 'tracking') { - viewer.trackedEntityChanged.raiseEvent({ id: 'contact' }); + // As Cesium's setter does: the value changes, then the event fires. + viewer.trackedEntity = { id: 'contact' }; + viewer.trackedEntityChanged.raiseEvent(viewer.trackedEntity); } else if (handoff === 'follow' || handoff === 'UI') { navigation._stampNavigation({ cancelPendingSelection: false }); } else if (handoff === 'keyboard') { diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 6f33f9121..563b8dfd4 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -97,7 +97,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => // The transaction has exactly two camera-owner mutations, and each one // stamps: // 1. selectedLayer.trackById(id) -> viewer.trackedEntity - // -> viewer.trackedEntityChanged -> _stampNavigation() + // -> onFollowChanged(viewer) -> _stampNavigation() // 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation() const transaction = body( cockpitTracking, @@ -117,7 +117,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => // Seam 1: any tracker handoff stamps, so the adoption step is covered. assert.match( ui, - /viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, + /onFollowChanged\(\s*this\.viewer,\s*\(followed\) => \{\s*if \(followed && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, 'tracker handoff must stamp', ); // Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above. @@ -190,10 +190,10 @@ test('accepted navigation releases through PR15-aware ownership before flight', 'origin: trackingOrigin', 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', 'rocketLaunchesLayer.releaseCameraOwnership?.()', - 'this.viewer.trackedEntity = undefined;', + 'stopFollowing(this.viewer);', "interruptCameraMotion('explicit-navigation')", - 'this.viewer.camera.cancelFlight();', - 'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);', + 'camera?.cancelFlight();', + 'camera?.release();', ], 'follow release'); assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3ea4fe670..f0d1c11db 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -18,6 +18,7 @@ * globe motion, opposite to the camera's travel around Earth. */ +import { followedOf, isFollowing, onFollowChanged } from './data/followed.js'; import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; import { @@ -1041,7 +1042,7 @@ export function interruptCameraMotion(reason = 'interrupt') { // While an entity is TRACKED, the follow camera owns the reference frame — // resetting the lookAt transform here flings the camera into space (field // finding). Leave the transform to the tracker; it re-asserts every frame. - const tracking = Boolean(_viewer?.trackedEntity); + const tracking = isFollowing(_viewer); const wasRoute = wasActive && _active.kind === 'route'; if ( wasActive && @@ -1190,7 +1191,7 @@ function onTick() { // only describes Cesium's rotation while the frame is identity. if ( _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || - _viewer.trackedEntity || + isFollowing(_viewer) || _isCockpitActive?.() || !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) ) { @@ -1413,8 +1414,8 @@ export function initCameraVerbs( if (_trackingRemover) _trackingRemover(); // Cockpit can acquire then clear tracking before the next clock tick. // Yield on acquisition itself without changing legacy camera motions. - _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { - if (entity && _active?.kind === 'rotate_globe') + _trackingRemover = onFollowChanged(viewer, (followed) => { + if (followed && _active?.kind === 'rotate_globe') interruptCameraMotion('globe-camera-owner-changed'); }); for (const rm of _inputRemovers) rm(); @@ -1522,15 +1523,14 @@ export function moveCamera(args = {}, runNavigation = null) { if ( (motion === 'rotate_globe' || (motion === 'orbit' && typeof runNavigation !== 'function')) && - _viewer?.trackedEntity + isFollowing(_viewer) ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal // until tracked-orbit rides the follow camera natively (roadmap). + const followed = followedOf(_viewer); const label = - _viewer.trackedEntity?.name || - _viewer.trackedEntity?.id || - 'the tracked target'; + followed?.name || followed?.labelModel?.title || 'the tracked target'; return { ok: false, action: 'move_camera', @@ -1701,7 +1701,7 @@ export function moveCamera(args = {}, runNavigation = null) { const preserveCameraFlight = motion === 'orbit' && _viewer.scene.tweens.length > 0 && - !_viewer.trackedEntity; + !isFollowing(_viewer); return typeof runNavigation === 'function' ? runNavigation(start, { preserveCameraFlight }) : start(); diff --git a/src/cctvFocusPolicy.js b/src/cctvFocusPolicy.js index e11c1e1ca..1cabba37f 100644 --- a/src/cctvFocusPolicy.js +++ b/src/cctvFocusPolicy.js @@ -1,22 +1,23 @@ /** * Activates the nearest CCTV record on layer enable and flies only when no - * aircraft tracker or cockpit camera owns the view. + * followed target or cockpit camera owns the view. * @param {Object} options Layer-enable focus inputs. - * @param {*} options.trackedEntity Current Cesium tracked entity, if any. + * @param {*} options.followed What the camera follows, if anything + * (`followedOf`). * @param {boolean} options.cockpitActive Whether cockpit mode owns the camera. * @param {() => (string|null)} options.activate Nearest-camera activation. * @param {(cameraId: string) => *} options.fly Optional camera flight. * @returns {*} Activated camera ID for a held view, or the flight result. */ export function runCctvLayerEnableFocus({ - trackedEntity = null, + followed = null, cockpitActive = false, activate, fly, } = {}) { const cameraId = activate?.(); if (!cameraId) return false; - if (trackedEntity || cockpitActive) return cameraId; + if (followed || cockpitActive) return cameraId; return fly?.(cameraId); } @@ -25,11 +26,11 @@ export function runCctvLayerEnableFocus({ * chain. Either observation suppresses the optional CCTV focus flight. * @param {Object} [before={}] Ownership immediately before the await. * @param {Object} [after={}] Ownership immediately after the await. - * @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership. + * @returns {{ followed: *, cockpitActive: boolean }} Conservative ownership. */ function mergeCctvEnableOwnership(before = {}, after = {}) { return { - trackedEntity: before.trackedEntity || after.trackedEntity || null, + followed: before.followed || after.followed || null, cockpitActive: !!(before.cockpitActive || after.cockpitActive), }; } @@ -60,7 +61,7 @@ export async function runCctvLayerEnableTransition({ const ownershipBefore = readOwnership?.() || {}; if (target === true) { debug?.('[UI:CCTV] ownership before setEnabled await', { - trackedId: ownershipBefore.trackedEntity?.id ?? null, + trackedId: ownershipBefore.followed?.key ?? null, cockpitActive: !!ownershipBefore.cockpitActive, }); } @@ -68,7 +69,7 @@ export async function runCctvLayerEnableTransition({ const ownershipAfter = readOwnership?.() || {}; if (target === true) { debug?.('[UI:CCTV] ownership after setEnabled await', { - trackedId: ownershipAfter.trackedEntity?.id ?? null, + trackedId: ownershipAfter.followed?.key ?? null, cockpitActive: !!ownershipAfter.cockpitActive, }); } diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 333926a0f..fd0913294 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -8,8 +8,8 @@ import { test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => { for (const ownership of [ - { trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }, - { trackedEntity: null, cockpitActive: true }, + { followed: { key: 'tracked-plane' }, cockpitActive: false }, + { followed: null, cockpitActive: true }, ]) { const calls = []; const result = runCctvLayerEnableFocus({ @@ -48,15 +48,15 @@ test('CCTV layer enable activates nearest without stealing a tracked or cockpit }); test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => { - let trackedEntity = { id: 'tracked-plane' }; + let followed = { key: 'tracked-plane' }; let flyCalls = 0; const diagnostics = []; const result = await runCctvLayerEnableTransition({ target: true, - readOwnership: () => ({ trackedEntity, cockpitActive: false }), + readOwnership: () => ({ followed, cockpitActive: false }), setEnabled: async () => { await Promise.resolve(); - trackedEntity = null; + followed = null; }, shouldFocus: () => true, activate: () => 'cam-near', @@ -83,7 +83,7 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async const result = await runCctvLayerEnableTransition({ target: false, - readOwnership: () => ({ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }), + readOwnership: () => ({ followed: { key: 'tracked-plane' }, cockpitActive: false }), setEnabled: async (target) => transitions.push(target), shouldFocus: () => true, activate: () => 'cam-near', diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index d8a16cccf..4967d5624 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -378,7 +378,7 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ test('geometry drain pacing yields to tracked and cockpit camera ownership', () => { assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 }); assert.deepEqual( - cctvGeometryDrainPacing({ trackedEntity: { id: 'flight-1' } }), + cctvGeometryDrainPacing({ followed: { key: 'flights:1' } }), { batchSize: 2, delayMs: 250 }, ); assert.deepEqual(cctvGeometryDrainPacing({ cockpitActive: true }), { @@ -393,13 +393,13 @@ test('geometry drain pacing yields to tracked and cockpit camera ownership', () }); test('geometry drain rechecks pacing when tracking releases between batches', () => { - let trackedEntity = { id: 'flight-1' }; + let followed = { key: 'flights:1' }; const queue = Array.from({ length: 10 }, (_, index) => index + 1); const visited = []; const runBatch = () => processCctvGeometryDrainBatch({ queue, - readOwnership: () => ({ trackedEntity, cockpitActive: false }), + readOwnership: () => ({ followed, cockpitActive: false }), visit: (record) => visited.push(record), progress: () => {}, complete: () => {}, @@ -409,7 +409,7 @@ test('geometry drain rechecks pacing when tracking releases between batches', () assert.deepEqual(trackedBatch, { hasMore: true, batchSize: 2, delayMs: 250 }); assert.deepEqual(visited, [1, 2]); - trackedEntity = null; + followed = null; const untrackedBatch = runBatch(); assert.deepEqual(untrackedBatch, { hasMore: true, diff --git a/src/data/followed.js b/src/data/followed.js new file mode 100644 index 000000000..9b8629c01 --- /dev/null +++ b/src/data/followed.js @@ -0,0 +1,127 @@ +/** + * @module data/followed + * @description What the camera follows, for the Core code that reads it. + * + * A layer follows through the camera port with a FollowTarget that also + * carries what Core shows about it (key, display position, sprite, label). + * Until every layer does, a layer may still track an entity of its own + * through Cesium's `trackedEntity`; that entity is seen here through the + * same fields, read from its entity record. Readers ask this module, never + * `trackedEntity`, so they work either way. + */ +import { findCamera } from '../render/rendererOf.js'; +import { readEntityRecord } from './entityRecords.js'; + +/** + * @typedef {object} Followed + * @property {string|null} key Stable id, e.g. `'flights:a1b2c3'`. + * @property {string} [name] Display name, when the target has one. + * @property {() => object|null} [displayPosition] Where the follow camera + * frames the target this frame (the layer's display cache). + * @property {() => object|null} [visualPosition] Where the target is drawn. + * @property {object} [sprite] Size of the drawn billboard. + * @property {object} [labelModel] + * @property {string} [selectionOrigin] + * @property {object} [entity] The tracked entity, for a layer that still + * tracks one itself. + */ + +/** @type {WeakMap} */ +const entityViews = new WeakMap(); + +function entityView(entity) { + let view = entityViews.get(entity); + if (!view) { + const id = String(entity.id ?? '').trim(); + view = { + entity, + get key() { + const explicit = String(readEntityRecord(entity)?.trackedId ?? ''); + return explicit.trim() || (id ? `entity:${id}` : null); + }, + get name() { + return entity.name; + }, + get displayPosition() { + return readEntityRecord(entity)?.displayPosition; + }, + get visualPosition() { + return readEntityRecord(entity)?.visualPosition; + }, + get sprite() { + return readEntityRecord(entity)?.sprite; + }, + get labelModel() { + return readEntityRecord(entity)?.labelModel; + }, + get selectionOrigin() { + return readEntityRecord(entity)?.selectionOrigin; + }, + }; + entityViews.set(entity, view); + } + return view; +} + +/** + * What `viewer`'s camera follows, or null. The same object comes back for + * as long as the same thing is followed. + * @param {object|null|undefined} viewer + * @returns {Followed|null} + */ +export function followedOf(viewer) { + if (!viewer) return null; + const target = findCamera(viewer)?.followed; + if (target) return target; + const entity = viewer.trackedEntity; + return entity ? entityView(entity) : null; +} + +/** Whether `viewer`'s camera follows anything. */ +export function isFollowing(viewer) { + return followedOf(viewer) !== null; +} + +/** + * Run `cb` whenever what `viewer`'s camera follows changes. + * @param {object} viewer + * @param {(followed: Followed|null) => void} cb + * @returns {() => void} Off. + */ +export function onFollowChanged(viewer, cb) { + let last = followedOf(viewer); + let port = null; + let offPort = null; + // The port is looked up again on every change: a renderer bound after + // this subscribed replaces the one it listened to. A Cesium port's follow + // also sets `trackedEntity`, so the change still arrives through it. + const listenToPort = () => { + const current = findCamera(viewer); + if (current === port) return; + offPort?.(); + port = current; + offPort = port?.on('followChanged', check) ?? null; + }; + function check() { + listenToPort(); + const now = followedOf(viewer); + if (now === last) return; + last = now; + cb(now); + } + listenToPort(); + const offTracking = viewer.trackedEntityChanged?.addEventListener(check); + return () => { + offPort?.(); + offPort = null; + offTracking?.(); + }; +} + +/** Stop the camera following anything, a layer's own tracking included. */ +export function stopFollowing(viewer) { + if (!viewer) return; + const camera = findCamera(viewer); + if (camera?.followed) camera.follow(null); + else if (viewer.trackedEntity) viewer.trackedEntity = undefined; +} diff --git a/src/data/followed.test.mjs b/src/data/followed.test.mjs new file mode 100644 index 000000000..a34866fd7 --- /dev/null +++ b/src/data/followed.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { entityRecord } from './entityRecords.js'; +import { + followedOf, + isFollowing, + onFollowChanged, + stopFollowing, +} from './followed.js'; +import { bindRenderer } from '../render/rendererOf.js'; +import { createCameraEvents } from '../render/camera/events.js'; + +/** A viewer whose `trackedEntity` behaves as Cesium's setter does. */ +function trackingViewer() { + const trackedEntityChanged = new Cesium.Event(); + let tracked; + return { + trackedEntityChanged, + get trackedEntity() { + return tracked; + }, + set trackedEntity(value) { + if (tracked === value) return; + tracked = value; + trackedEntityChanged.raiseEvent(value); + }, + }; +} + +/** A camera port that follows, for a viewer bound to a renderer. */ +function followingPort() { + const events = createCameraEvents({ pose: () => ({}), report: () => {} }); + let followed = null; + return { + on: (event, cb) => events.on(event, cb), + get followed() { + return followed; + }, + follow(target) { + if (followed === target) return; + followed = target; + events.emit('followChanged'); + }, + }; +} + +test('a layer’s own tracked entity is seen through its entity record', () => { + const viewer = trackingViewer(); + assert.equal(followedOf(viewer), null); + assert.equal(isFollowing(viewer), false); + const entity = new Cesium.Entity({ id: 'e1', name: 'N1' }); + const record = entityRecord(entity); + record.trackedId = 'flights:abc'; + record.displayPosition = () => ({ x: 1, y: 2, z: 3 }); + record.sprite = { width: 28 }; + viewer.trackedEntity = entity; + const followed = followedOf(viewer); + assert.equal(followed, followedOf(viewer), 'the same view each time'); + assert.equal(followed.key, 'flights:abc'); + assert.equal(followed.name, 'N1'); + assert.deepEqual(followed.displayPosition(), { x: 1, y: 2, z: 3 }); + assert.equal(followed.sprite.width, 28); + assert.equal(followed.entity, entity); + viewer.trackedEntity = new Cesium.Entity({ id: 'plain' }); + assert.equal(followedOf(viewer).key, 'entity:plain', 'no tracked id'); +}); + +test('a port follow comes before any tracked entity, and is the target itself', (t) => { + const viewer = trackingViewer(); + const camera = followingPort(); + t.after(bindRenderer(viewer, { camera })); + viewer.trackedEntity = new Cesium.Entity({ id: 'helper' }); + const target = { key: 'flights:abc', position: () => null }; + camera.follow(target); + assert.equal(followedOf(viewer), target); +}); + +test('onFollowChanged fires once per change, from either source', (t) => { + const viewer = trackingViewer(); + const camera = followingPort(); + t.after(bindRenderer(viewer, { camera })); + const seen = []; + const off = onFollowChanged(viewer, (f) => seen.push(f?.key ?? null)); + const target = { key: 'flights:abc' }; + // A port follow on Cesium sets trackedEntity too: still one change. + camera.follow(target); + // Ending it, Cesium clears its tracking before the port announces. + viewer.trackedEntity = undefined; + camera.follow(null); + const entity = new Cesium.Entity({ id: 'legacy' }); + viewer.trackedEntity = entity; + viewer.trackedEntity = entity; + off(); + viewer.trackedEntity = undefined; + assert.deepEqual(seen, ['flights:abc', null, 'entity:legacy']); +}); + +test('stopFollowing ends a port follow, or a layer’s own tracking', (t) => { + const viewer = trackingViewer(); + viewer.trackedEntity = new Cesium.Entity({ id: 'legacy' }); + stopFollowing(viewer); + assert.equal(viewer.trackedEntity, undefined); + const camera = followingPort(); + t.after(bindRenderer(viewer, { camera })); + camera.follow({ key: 'k' }); + stopFollowing(viewer); + assert.equal(camera.followed, null); + stopFollowing(null); +}); + +test('a renderer bound after subscribing is still heard', (t) => { + const viewer = { trackedEntityChanged: new Cesium.Event() }; + const seen = []; + onFollowChanged(viewer, (f) => seen.push(f?.key ?? null)); + const camera = followingPort(); + t.after(bindRenderer(viewer, { camera })); + // Any change re-resolves the port; a Cesium follow raises one. + viewer.trackedEntityChanged.raiseEvent(); + camera.follow({ key: 'k' }); + camera.follow(null); + assert.deepEqual(seen, ['k', null]); +}); diff --git a/src/data/geoImportPins.js b/src/data/geoImportPins.js index 1828b5df7..d024a4d60 100644 --- a/src/data/geoImportPins.js +++ b/src/data/geoImportPins.js @@ -1,3 +1,4 @@ +import { followedOf, stopFollowing } from './followed.js'; import * as Cesium from 'cesium'; const STYLE = [ @@ -113,8 +114,9 @@ export function createImportedPins(dataSource, viewer) { destroy() { if (destroyed) return; destroyed = true; - if (viewer.trackedEntity && pins.includes(viewer.trackedEntity)) - viewer.trackedEntity = undefined; + const followedEntity = followedOf(viewer)?.entity; + if (followedEntity && pins.includes(followedEntity)) + stopFollowing(viewer); if (viewer.selectedEntity && pins.includes(viewer.selectedEntity)) viewer.selectedEntity = undefined; if (mounted && !viewer.scene.primitives?.isDestroyed?.()) diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 6be6523e6..2b6fde11e 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -1,3 +1,4 @@ +import { isFollowing } from '../../data/followed.js'; import { isUnavailableCapability } from '../../sources/capability.js'; import * as Cesium from 'cesium'; import { @@ -369,13 +370,12 @@ export function createAlprCamerasLayer({ source, services } = {}) { { id: 'find-camera', label: t('SHOW NEAREST'), - title: state.viewer?.trackedEntity + title: isFollowing(state.viewer) ? t( 'Stop following the current object before navigating to a camera', ) : t('Move to the nearest loaded camera and show its details'), - disabled: - !state.enabled || !count || Boolean(state.viewer?.trackedEntity), + disabled: !state.enabled || !count || isFollowing(state.viewer), onClick: focusNearest, }, ], diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 7932d0152..88aa880cb 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -1,3 +1,4 @@ +import { isFollowing } from '../../data/followed.js'; import * as Cesium from 'cesium'; import { inputFor } from '../../input/dispatcher.js'; import { @@ -499,7 +500,7 @@ export function createAlprPresentation({ state, services, source }) { function focusNearest() { const camera = state.viewer?.camera; - if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity) + if (!state.enabled || !camera?.positionWC || isFollowing(state.viewer)) return false; let nearest = null, distance = Infinity; diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index f116b6b92..732cc4bc1 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -1,3 +1,4 @@ +import { followedOf } from '../../data/followed.js'; import { AWARENESS_RADIUS_M, AWARENESS_RELATIONSHIP, @@ -10,7 +11,6 @@ import { SUBJECT_PRESENCE, AWARENESS_PAGE_SIZE, } from './policy.js'; -import { readEntityRecord } from '../../data/entityRecords.js'; // coverageLabel is machine-composed English from installations/controls.js // (pinned by src/data/militaryInstallations.test.mjs); translate the known @@ -192,9 +192,7 @@ export function createSubject({ state: layerState, services, parts, source }) { */ function currentTrackedFlightSubject() { - const trackedKey = normalizeContextId( - readEntityRecord(layerState.viewer?.trackedEntity)?.trackedId, - ); + const trackedKey = normalizeContextId(followedOf(layerState.viewer)?.key); if (!trackedKey) return null; const subjects = [ flightsLayer.getTrackedSubject?.(), @@ -261,19 +259,15 @@ export function createSubject({ state: layerState, services, parts, source }) { ) { if (!subject?.position) return null; if (subject.layerId === 'flights' || subject.layerId === 'military') { - const trackedPosition = readEntityRecord( - layerState.viewer?.trackedEntity, - )?.displayPosition?.(); + const followed = followedOf(layerState.viewer); + const trackedPosition = followed?.displayPosition?.(); if (trackedPosition) { return { position: Cesium.Cartesian3.clone(trackedPosition), // Only the follow camera's own contact proves presence this way; a // different tracked entity says nothing about this subject. presence: - String( - readEntityRecord(layerState.viewer?.trackedEntity)?.trackedId || - '', - ) === subjectKey(subject) + String(followed?.key || '') === subjectKey(subject) ? SUBJECT_PRESENCE.LIVE : SUBJECT_PRESENCE.UNCHECKED, }; diff --git a/src/layers/cctv/geometryQueue.js b/src/layers/cctv/geometryQueue.js index b78950aaa..dabed2373 100644 --- a/src/layers/cctv/geometryQueue.js +++ b/src/layers/cctv/geometryQueue.js @@ -1,3 +1,4 @@ +import { followedOf } from '../../data/followed.js'; import * as Cesium from 'cesium'; import { GEO_PROGRESS_NOTIFY_INTERVAL_MS, @@ -125,16 +126,16 @@ export function createGeometryQueue({ * throughput without restarting the queue. * * @param {Object} [ownership={}] Current camera-ownership state. - * @param {*} [ownership.trackedEntity] Cesium tracked entity, if any. + * @param {*} [ownership.followed] What the camera follows, if anything. * @param {boolean} [ownership.cockpitActive] Whether cockpit owns the camera. * @returns {{ batchSize: number, delayMs: number }} Drain pacing. */ function cctvGeometryDrainPacing({ - trackedEntity = null, + followed = null, cockpitActive = false, } = {}) { - if (trackedEntity || cockpitActive) { + if (followed || cockpitActive) { return { batchSize: GEO_TRACKING_BATCH_SIZE, delayMs: GEO_TRACKING_BATCH_DELAY_MS, @@ -213,7 +214,7 @@ export function createGeometryQueue({ const batchResult = processCctvGeometryDrainBatch({ queue: layerState._geoQueue, readOwnership: () => ({ - trackedEntity: layerState._viewer.trackedEntity, + followed: followedOf(layerState._viewer), cockpitActive: typeof document !== 'undefined' && document.body?.classList.contains('cockpit-mode'), diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js index 49a71bf6d..1d5641913 100644 --- a/src/layers/cctv/navigation.js +++ b/src/layers/cctv/navigation.js @@ -1,3 +1,4 @@ +import { isFollowing } from '../../data/followed.js'; import * as Cesium from 'cesium'; import { CCTV_FOCUS_RESULT } from './policy.js'; import { cameraOf } from '../../render/cesium/camera.js'; @@ -52,7 +53,7 @@ export function createNavigation({ console.debug('[Data:CCTV] focus ignored while cockpit owns the camera'); return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE; } - if (viewer.trackedEntity) { + if (isFollowing(viewer)) { console.debug( '[Data:CCTV] focus ignored while a tracked entity owns the camera', ); diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index a423faa09..f3324f2ca 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -1,3 +1,4 @@ +import { stopFollowing } from '../../data/followed.js'; import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { cameraOf } from '../../render/cesium/camera.js'; @@ -354,7 +355,7 @@ export function createReplay({ state: layerState, services, parts, source }) { syncReplayButton(); parts.overlays.syncMissionOverlayEntries(); parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); - layerState._viewer.trackedEntity = undefined; + stopFollowing(layerState._viewer); const camera = cameraOf(layerState._viewer); camera.cancelFlight(); layerState._animationStarts.set( diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js index 928ef6c4d..47ac48a02 100644 --- a/src/layers/radio/navigation.js +++ b/src/layers/radio/navigation.js @@ -1,3 +1,4 @@ +import { isFollowing } from '../../data/followed.js'; import * as Cesium from 'cesium'; import { HORIZON_CAMERA_MOVE_EPSILON_M, @@ -149,7 +150,7 @@ export function createNavigation({ /** Radio may move the globe only while no tracked entity owns the follow camera. */ function radioCameraNavigationAllowed(viewer = layerState._viewer) { - return Boolean(viewer?.camera) && !viewer.trackedEntity; + return Boolean(viewer?.camera) && !isFollowing(viewer); } function radioGlobeRecenterPlan(viewer = layerState._viewer) { diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 3200610c1..c7357b928 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,3 +1,4 @@ +import { onFollowChanged } from '../../data/followed.js'; import { inputFor } from '../../input/dispatcher.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; @@ -97,14 +98,11 @@ export function createSelection({ state.keyTarget = keyTarget; state.keydownHandler = onVesselKeyDown; keyTarget.addEventListener('keydown', state.keydownHandler); - // Vessels never set viewer.trackedEntity, so any new tracked entity belongs - // to another layer and takes interaction ownership of the scene. - state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener( - () => { - if (viewer.trackedEntity && state.selectedRecord) - clearVesselInspection(); - }, - ); + // Vessels never follow, so anything newly followed belongs to another + // layer and takes interaction ownership of the scene. + state.trackedEntityRemover = onFollowChanged(viewer, (followed) => { + if (followed && state.selectedRecord) clearVesselInspection(); + }); } /** Select one live vessel and request one UI-owned camera transfer. */ diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index 291b4b438..d61f1bc53 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -9,13 +9,14 @@ import { createCameraEvents } from '../camera/events.js'; import { listenerList, toCartesian, uncaught } from './common.js'; import { cameraFlightActive } from './internals.js'; import { createCesiumFollow } from './follow.js'; -import { cameraOf, provideUnboundCamera } from '../rendererOf.js'; +import { cameraOf, findCamera, provideUnboundCamera } from '../rendererOf.js'; /** - * `cameraOf`, for code that already reaches Cesium: importing it from here - * loads the factory a viewer with no renderer bound gets its camera from. + * `cameraOf` and `findCamera`, for code that already reaches Cesium: + * importing them from here loads the factory a viewer with no renderer + * bound gets its camera from. */ -export { cameraOf }; +export { cameraOf, findCamera }; /** The contract's easings as Cesium's. */ export const EASINGS = Object.freeze({ diff --git a/src/render/unboundCamera.test.mjs b/src/render/unboundCamera.test.mjs index 65a857b5b..ace7be1de 100644 --- a/src/render/unboundCamera.test.mjs +++ b/src/render/unboundCamera.test.mjs @@ -33,6 +33,13 @@ const INJECTED = { './layers/recent-imagery': 'src/layers/recentImagery/rendering.js', }; +/** + * Modules whose use of the bridge holds up without the factory: with no + * port they read Cesium's `trackedEntity` instead, which is all an unbound + * viewer can follow by. + */ +const FALLS_BACK = new Set(['src/data/followed.js']); + const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); /** The browser module an export resolves to, or null for Node-only ones. */ @@ -112,6 +119,7 @@ function reachable(entry, { eager = false } = {}) { for (const { specifier, lazy } of specifiersOf(file)) { if (eager && lazy) continue; const next = resolve(file, specifier); + if (next === BRIDGE && FALLS_BACK.has(file)) continue; if (next && !seen.has(next)) { seen.add(next); queue.push(next); diff --git a/src/scenes/director.js b/src/scenes/director.js index 050872168..2a5046025 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -12,6 +12,7 @@ * State is persisted to localStorage and can be exported/imported as JSON. */ +import { isFollowing } from '../data/followed.js'; import { cancelCameraArrival } from '../data/cameraArrival.js'; import { resolveCameraPose, resolveCameraMove } from '../director/camera.js'; import { createCameraMotion } from './cameraMotion.js'; @@ -114,7 +115,7 @@ export class SceneDirector { this._interactionTransitions = 0; this._interactions = createSceneInteractions(viewer, { available: () => - !this._destroyed && !this._running && !this.viewer.trackedEntity, + !this._destroyed && !this._running && !isFollowing(this.viewer), execute: (action, signal) => this._trackWork(this._executeInteraction(action, signal)), }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4383c6618..36e72d776 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1,3 +1,4 @@ +import { followedOf } from '../data/followed.js'; import { nextTick } from 'vue'; import { resolveImageryHost } from '../layers/weather/imageryHost.js'; import { ShellFacade } from './shellFacade.js'; @@ -1174,7 +1175,7 @@ export class StyleManager extends ShellFacade { setEnabled: (next) => this._dataManager.setEnabled('cctv', next, { origin: 'user' }), readOwnership: () => ({ - trackedEntity: this.viewer?.trackedEntity, + followed: followedOf(this.viewer), cockpitActive: !!this.cockpitView?.active, }), shouldFocus: () => diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index fb8f29119..8b84dd355 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -1,3 +1,4 @@ +import { onFollowChanged } from '../data/followed.js'; import { isExplicitLayerStateOrigin } from '../data/layerState.js'; import { registerCctvFocusRequestListener, @@ -80,11 +81,13 @@ export class LayerBindings { window, this._worldRequestFocusHandler, ); - this._navigationOwnerChangedRemover = - this.viewer.trackedEntityChanged.addEventListener((entity) => { - if (entity && !this._disposed) + this._navigationOwnerChangedRemover = onFollowChanged( + this.viewer, + (followed) => { + if (followed && !this._disposed) this._stampNavigation({ cancelPendingSelection: false }); - }); + }, + ); // Vessel/installation focus flies without ever assigning a tracked entity, // so it cannot reach the listener above. It announces instead. this._removeNavigationAuthorityListener = diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 6f9ae4158..713c3b817 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -1,3 +1,5 @@ +import { findCamera } from '../render/cesium/camera.js'; +import { stopFollowing } from '../data/followed.js'; import * as Cesium from 'cesium'; import { t } from '../i18n/index.js'; import { createStateChannel } from '../app/stateChannel.js'; @@ -367,9 +369,10 @@ export class LocationNavigation { } catch { /* best-effort release */ } - this.viewer.trackedEntity = undefined; - this.viewer.camera.cancelFlight(); - this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + stopFollowing(this.viewer); + const camera = findCamera(this.viewer); + camera?.cancelFlight(); + camera?.release(); this._beginWorldJumpTransition(); let resolveReset; diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 3ea54948b..7db0a42f2 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -1,6 +1,7 @@ +import { findCamera } from '../render/cesium/camera.js'; +import { stopFollowing } from '../data/followed.js'; import { cancelCameraArrival } from '../data/cameraArrival.js'; import { createStateChannel } from '../app/stateChannel.js'; -import * as Cesium from 'cesium'; import { beginDeferredNavigation, reassertNavigationHandoff, @@ -176,12 +177,13 @@ export class NavigationController { } catch { /* best-effort release */ } - this.viewer.trackedEntity = undefined; + stopFollowing(this.viewer); this.interruptCameraMotion('explicit-navigation'); this.stopOrbit(); - if (!preserveCameraFlight) this.viewer.camera.cancelFlight(); + const camera = findCamera(this.viewer); + if (!preserveCameraFlight) camera?.cancelFlight(); try { - this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + camera?.release(); } catch { /* teardown race */ } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 071b42f0f..1abef8906 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { isFollowing, stopFollowing } from '../data/followed.js'; import { interruptGlobeRotation } from '../cameraVerbs.js'; import { layerSnapshot, @@ -396,7 +397,7 @@ export function createGevActionRunner({ // Explicit navigation while TRACKING supersedes the follow camera — // otherwise the tracker drags the view back and "I flew there but can't // do anything" (field finding). track_entity manages its own handoff. - if (name === 'zoom_to_globe' && viewer.trackedEntity) { + if (name === 'zoom_to_globe' && isFollowing(viewer)) { stopAllTracking(viewer, dataManager); } @@ -1130,7 +1131,7 @@ export function createGevActionRunner({ return; } interruptCameraMotion('nav:fly_to_location'); - if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); + if (isFollowing(viewer)) stopAllTracking(viewer, dataManager); }, }); } @@ -2570,7 +2571,7 @@ function stopAllTracking(viewer, dataManager) { failed.add(layerId); } } - if (viewer) viewer.trackedEntity = undefined; + stopFollowing(viewer); if (failed.size) { const failedLayerIds = [...failed]; return {