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 {