diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2aab55914..b4aece251 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -879,6 +879,7 @@ "src/render/cesium/common.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", + "src/data/followed.js", "src/ui/cockpitDisplayPortal.js", "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", @@ -4412,6 +4413,7 @@ "src/navigationPolicy.js", "src/share/follow.js", "src/ui/shareRestoration.js", + "src/data/followed.js", "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -4426,7 +4428,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/render/rendererOf.js" ], "external": [] }, diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index fa43e21b7..cc45d9f71 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -1,3 +1,4 @@ +import { followedOf } from '../data/followed.js'; import * as Cesium from 'cesium'; import { getOverlayPaintRect } from '../overlays/worldOverlay.js'; import { @@ -8,7 +9,6 @@ import { t } from '../i18n/index.js'; import { nextTick } from 'vue'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { applicationPinia } from '../ui/vueRuntime.js'; -import { readEntityRecord } from '../data/entityRecords.js'; import { heightsFor } from '../queries/heights.js'; /** A label whose detailed outline could not be resolved. */ @@ -421,8 +421,8 @@ export function createScreenAnnotationRenderer( let top = painted?.y; let bottom = painted ? painted.y + painted.h : undefined; - const ent = viewer.trackedEntity; - const displayPosition = readEntityRecord(ent)?.displayPosition; + const followed = followedOf(viewer); + const displayPosition = followed?.displayPosition; const world = typeof displayPosition === 'function' ? displayPosition() : null; const win = world @@ -435,7 +435,7 @@ export function createScreenAnnotationRenderer( // Billboard box — centered on the anchor, magnified by scaleByDistance, from // the size the tracking layer recorded for its sprite. - const sprite = readEntityRecord(ent)?.sprite; + const sprite = followed?.sprite; if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) { const baseW = sprite.width ?? 28; const baseH = sprite.height ?? 28; diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 563b8dfd4..c5e48790a 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -45,7 +45,7 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', () 'if (!info || !entity?.position) return false;', 'this.onCameraTakeover?.();', 'cameraOf(this.viewer).cancelFlight();', - 'this.viewer.trackedEntity = undefined;', + 'stopFollowing(this.viewer);', ], 'Cockpit takeover'); assert.match( ui, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 580920d81..1cfd95b3c 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/); assert.match( keydown, - /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, + /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && followedOf\(this\.viewer\)\?\.entity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, ); }); @@ -295,14 +295,14 @@ test('the Contact panel never hides itself out from under its own NEXT button', ); }); -test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => { +test('the cockpit reads its aircraft from the layer the camera follows', () => { const read = readAircraftInfo.toString(); assert.ok(read, 'readAircraftInfo is missing'); assert.match(read, /resolveTrackedAircraftInfo\(\{/); - assert.match(read, /readEntityRecord\([^)]*\)\?\.trackedId/); - // In cockpit mode the controller moves the entity off viewer.trackedEntity, - // so its own handle is the tracked identity there. - assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); + assert.match(read, /trackedId: followed\?\.key/); + // In cockpit mode the controller stops the camera following, so its own + // handle is the tracked identity there. + assert.match(read, /followedOf\(this\.viewer\) \|\| this\.trackedFollow/); }); test('programmatic Context layer changes cannot bypass explicit expansion policy', () => { @@ -1171,7 +1171,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig test('cockpit aircraft handoff invalidates the prior world-position anchor', () => { const match = _adoptTrackedEntity.toString(); assert.ok(match, 'cockpit tracked-entity handoff block is missing'); - assert.match(match, /this\.viewer\.trackedEntity = undefined;/); + assert.match(match, /stopFollowing\(this\.viewer\);/); assert.match(match, /this\.cockpitAnchorValid = false;/); assert.match( match, diff --git a/src/data/followed.js b/src/data/followed.js index 9b8629c01..0f97c155b 100644 --- a/src/data/followed.js +++ b/src/data/followed.js @@ -7,7 +7,14 @@ * 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. + * `trackedEntity`, so they work either way. A layer that still draws its + * target as an entity follows through the port with `entityFollowTarget`, + * whose presentation fields read that same record. + * + * Core's readers (the tracked card, Cockpit, the share link's thing view, + * north-up) still read the drawn entity and its `displayPosition`: a port + * target without them is followed by the camera but not shown by them, + * until those readers move with the drawing to the renderer. */ import { findCamera } from '../render/rendererOf.js'; import { readEntityRecord } from './entityRecords.js'; @@ -125,3 +132,55 @@ export function stopFollowing(viewer) { if (camera?.followed) camera.follow(null); else if (viewer.trackedEntity) viewer.trackedEntity = undefined; } + +/** + * A FollowTarget for a layer that draws its target as an entity of its own: + * the camera fields given (`position`, `viewFrom`, `frame`, `minRange`, ...) + * plus what Core shows about it, read live from the entity's record, so the + * layer keeps writing that record as before. `entity` is the drawn entity. + * @param {object} entity + * @param {object} camera The FollowTarget's camera fields. + * @returns {Followed & object} + */ +export function entityFollowTarget(entity, camera) { + const target = Object.create(entityView(entity)); + // Defined, not assigned: the record's fields are getters, and a camera + // field of the same name (`key`, say) takes their place. + for (const [name, value] of Object.entries(camera)) + Object.defineProperty(target, name, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return target; +} + +/** + * Follow `followed` again after `stopFollowing`: a port target through the + * camera port, a layer's own tracked entity through `trackedEntity`. + * @param {object} viewer + * @param {Followed|null} followed What `followedOf` gave. + */ +export function resumeFollowing(viewer, followed) { + if (!viewer || !followed) return; + if (entityViews.get(followed.entity) === followed) + viewer.trackedEntity = followed.entity; + else findCamera(viewer)?.follow(followed); +} + +/** + * Where `followed` is shown this frame: the layer's display cache, else its + * entity's position at the viewer's time; null when neither answers. + * @param {object} viewer + * @param {Followed|null} followed + */ +export function followedPosition(viewer, followed) { + const display = followed?.displayPosition; + const shown = typeof display === 'function' ? display() : null; + return ( + shown || + followed?.entity?.position?.getValue?.(viewer?.clock?.currentTime) || + null + ); +} diff --git a/src/data/followed.test.mjs b/src/data/followed.test.mjs index a34866fd7..1c987fae2 100644 --- a/src/data/followed.test.mjs +++ b/src/data/followed.test.mjs @@ -3,9 +3,12 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { entityRecord } from './entityRecords.js'; import { + entityFollowTarget, followedOf, + followedPosition, isFollowing, onFollowChanged, + resumeFollowing, stopFollowing, } from './followed.js'; import { bindRenderer } from '../render/rendererOf.js'; @@ -121,3 +124,52 @@ test('a renderer bound after subscribing is still heard', (t) => { camera.follow(null); assert.deepEqual(seen, ['k', null]); }); + +test('an entity follow target reads its entity record live', () => { + const entity = new Cesium.Entity({ id: 'drawn', name: 'N2' }); + const position = () => ({ x: 1, y: 2, z: 3 }); + const target = entityFollowTarget(entity, { position, minRange: 150 }); + assert.equal(target.key, 'entity:drawn'); + entityRecord(entity).trackedId = 'flights:abc'; + entityRecord(entity).labelModel = { title: 'N2' }; + assert.equal(target.key, 'flights:abc'); + assert.equal(target.labelModel.title, 'N2'); + assert.equal(target.entity, entity); + assert.equal(target.position, position); + assert.equal(target.minRange, 150); + const keyed = entityFollowTarget(entity, { key: 'flights:given' }); + assert.equal(keyed.key, 'flights:given', 'a camera field takes the place'); + assert.equal(keyed.labelModel.title, 'N2'); +}); + +test('resumeFollowing follows a port target again, or re-tracks an entity', (t) => { + const viewer = trackingViewer(); + const entity = new Cesium.Entity({ id: 'legacy' }); + viewer.trackedEntity = entity; + const legacy = followedOf(viewer); + stopFollowing(viewer); + resumeFollowing(viewer, legacy); + assert.equal(viewer.trackedEntity, entity); + assert.equal(followedOf(viewer), legacy); + viewer.trackedEntity = undefined; + const camera = followingPort(); + t.after(bindRenderer(viewer, { camera })); + const target = entityFollowTarget(new Cesium.Entity({ id: 'p' }), {}); + camera.follow(target); + stopFollowing(viewer); + resumeFollowing(viewer, target); + assert.equal(camera.followed, target); + resumeFollowing(viewer, null); + assert.equal(camera.followed, target); +}); + +test('followedPosition prefers the display cache over the entity position', () => { + const at = new Cesium.Cartesian3(4, 5, 6); + const viewer = { clock: { currentTime: Cesium.JulianDate.now() } }; + const entity = new Cesium.Entity({ id: 'e', position: at }); + const view = entityFollowTarget(entity, {}); + assert.deepEqual(followedPosition(viewer, view), at); + entityRecord(entity).displayPosition = () => ({ x: 7, y: 8, z: 9 }); + assert.deepEqual(followedPosition(viewer, view), { x: 7, y: 8, z: 9 }); + assert.equal(followedPosition(viewer, null), null); +}); diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 921449866..ff339ea5b 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -1,3 +1,4 @@ +import { followedOf, isFollowing, onFollowChanged } from './followed.js'; import { clearOverlaySource, setOverlayEntries, @@ -126,7 +127,7 @@ function entryIdFor(entity) { } function activeEntity() { - return _viewer?.trackedEntity || _selectedContext?.entity || null; + return followedOf(_viewer)?.entity || _selectedContext?.entity || null; } /** @@ -235,11 +236,10 @@ export function initTrackedReadout(viewer) { if (_viewer) destroyTrackedReadout(); _viewer = viewer; _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); - _trackedEntityChangedRemove = - viewer.trackedEntityChanged?.addEventListener?.(() => { - if (viewer.trackedEntity) _selectedContext = null; - syncActiveEntity(); - }) || null; + _trackedEntityChangedRemove = onFollowChanged(viewer, (followed) => { + if (followed) _selectedContext = null; + syncActiveEntity(); + }); _contextSelectedHandler = (event) => { const record = event.detail; if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) { @@ -263,7 +263,7 @@ export function initTrackedReadout(viewer) { }; _aircraftSelectedHandler = () => { _selectedContext = null; - if (!_viewer?.trackedEntity) clearTrackedSource(); + if (!isFollowing(_viewer)) clearTrackedSource(); }; window.addEventListener('gev:entity-selected', _contextSelectedHandler); window.addEventListener( diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 36e72d776..69a96e20f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -196,8 +196,8 @@ export class StyleManager extends ShellFacade { this._showGlobalStatusNotice(message, options), feedback: this._feedback, updateFeedback: () => this._updateGlobalLoadingFeedback(), - applyFollowOffset: (entity, offset) => - applyShareFollowOffset(viewer, entity, offset), + applyFollowOffset: (followed, offset) => + applyShareFollowOffset(viewer, followed, offset), }); this._visualSettings = new VisualSettings({ diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 21a40b867..bd8e6f988 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -1,16 +1,17 @@ import * as Cesium from 'cesium'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { t } from '../i18n/index.js'; -import { readEntityRecord } from '../data/entityRecords.js'; +import { + followedOf, + followedPosition, + isFollowing, + onFollowChanged, +} from '../data/followed.js'; function trackedTarget(viewer) { - const entity = viewer.trackedEntity; - // Read the visual's sample first; sampling a flight CallbackProperty twice - // can advance dead reckoning ahead of the displayed aircraft. - return ( - readEntityRecord(entity)?.displayPosition?.() || - entity?.position?.getValue(viewer.clock?.currentTime) - ); + // The visual's sample first: sampling a flight CallbackProperty twice can + // advance dead reckoning ahead of the displayed aircraft. + return followedPosition(viewer, followedOf(viewer)); } export const OBLIQUE_PITCH = Cesium.Math.toRadians(-35); @@ -75,7 +76,7 @@ export function pickViewTarget(viewer) { /** Describe the camera as an orbit around the current viewport center. */ export function readCameraTargetFrame(viewer) { const camera = viewer?.camera; - const target = viewer?.trackedEntity + const target = isFollowing(viewer) ? trackedTarget(viewer) : pickViewTarget(viewer); if (!camera || !target || !isPickedWorldPosition(camera.positionWC)) @@ -112,7 +113,7 @@ function setCameraTargetFrame(viewer, frame) { const camera = viewer?.camera; if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; try { - const trackedTransform = viewer.trackedEntity + const trackedTransform = isFollowing(viewer) ? Cesium.Matrix4.clone(camera.transform) : null; camera.lookAt( @@ -200,20 +201,20 @@ export function createCameraOrientationAnimator( cancel(); if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0) return setCameraTargetFrame(viewer, { ...frame, ...destination }); - const entity = viewer.trackedEntity; + const followed = followedOf(viewer); pending = destination; const start = now(); const headingDelta = Cesium.Math.negativePiToPi( destination.heading - frame.heading, ); remove = viewer.scene.preUpdate.addEventListener(() => { - if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + if (viewer.isDestroyed?.() || followedOf(viewer) !== followed) { cancel(); return; } const progress = Cesium.Math.clamp((now() - start) / duration, 0, 1); const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress); - const target = entity ? trackedTarget(viewer) : frame.target; + const target = followed ? trackedTarget(viewer) : frame.target; if ( !isPickedWorldPosition(target) || !setCameraTargetFrame(viewer, { @@ -338,7 +339,7 @@ export function bindCameraOrientationControls({ // The exact tilt state is refreshed once per gesture, not once per frame — // moveEnd fires when the camera stops, which is where the pick is affordable. subscribe(viewer?.camera?.moveEnd, refreshTilt); - subscribe(viewer?.trackedEntityChanged, animator.cancel); + if (viewer) removers.push(onFollowChanged(viewer, animator.cancel)); for (const type of ['pointerdown', 'wheel']) { const canvas = viewer?.scene?.canvas; canvas?.addEventListener?.(type, animator.cancel, { passive: true }); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 577cd2375..7c77fc539 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -1,4 +1,5 @@ /** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ +import { onFollowChanged } from '../data/followed.js'; import { readAircraftInfo, dispatchCockpitModeChanged, @@ -87,6 +88,8 @@ export class CockpitViewController { /** Gives camera input back; held from enter to exit. */ this.releaseCameraInput = null; this.trackedEntity = null; + /** What the camera followed before Cockpit took it, to follow again. */ + this.trackedFollow = null; this.trackedEntityWasShown = true; this.heading = null; this.lastFrameMs = 0; @@ -201,7 +204,7 @@ export class CockpitViewController { // create a self-sustaining refinement loop under a moving cockpit camera. this._listenerRemovers.push( viewer.scene.preUpdate.addEventListener(() => this.update()), - viewer.trackedEntityChanged.addEventListener(() => { + onFollowChanged(viewer, () => { if (this.active) this._adoptTrackedEntity(performance.now()); else this.syncEntry(); }), diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 3c6168800..2718d6fb7 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -1,3 +1,4 @@ +import { followedOf } from '../data/followed.js'; import { nextTick } from 'vue'; import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js'; import { cockpitEntryAllowed } from '../contextModePolicy.js'; @@ -124,12 +125,12 @@ export class CockpitCoordinator { const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off - // `viewer.trackedEntity` on entry and NEXT puts one back, which made this + // the camera's follow on entry and NEXT puts one back, which made this // flip true/false between calls while `active` stayed true; readers // (including the voice model) read that as a broken half-entered state. const entryAllowed = !active && - Boolean(gateOpen && info && this.viewer?.trackedEntity?.position); + Boolean(gateOpen && info && followedOf(this.viewer)?.entity?.position); return { active, entryAllowed, diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 54b8f56a9..4b4096c4b 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -1,4 +1,5 @@ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */ +import { followedOf } from '../data/followed.js'; export function onKeyDown(event) { if (this.destroyed) return false; @@ -49,7 +50,7 @@ export function onKeyDown(event) { if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { if (!this.active) { const cockpitAttempt = !!( - this.readAircraftInfo() && this.viewer.trackedEntity?.position + this.readAircraftInfo() && followedOf(this.viewer)?.entity?.position ); if (!cockpitAttempt) return; } diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 24d89c3e8..bd87283ed 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -6,17 +6,21 @@ import { normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; -import { readEntityRecord } from '../data/entityRecords.js'; +import { + followedOf, + resumeFollowing, + stopFollowing, +} from '../data/followed.js'; import { inputFor } from '../input/dispatcher.js'; export function readAircraftInfo() { - // In cockpit mode the controller takes the entity off `viewer.trackedEntity` - // (see update()), so the cockpit's own handle is the tracked identity there. - const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; + // In cockpit mode the controller stops the camera following (see enter()), + // so the cockpit's own handle is the tracked identity there. + const followed = followedOf(this.viewer) || this.trackedFollow; return resolveTrackedAircraftInfo({ civilian: this.services.flightsLayer.getTrackedInfo?.() || null, military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, - trackedId: readEntityRecord(trackedEntity)?.trackedId || '', + trackedId: followed?.key || '', }); } @@ -80,7 +84,7 @@ export function syncEntry() { if (this.destroyed) return false; if (this.active) return; const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); + const trackedContact = !!(info && followedOf(this.viewer)?.entity?.position); this.syncTr3bToggle(trackedContact ? info : null); const available = !!(this.isEntryAllowed() && trackedContact); // Change-only publishes: this runs on a preUpdate cadence, and a fresh @@ -111,7 +115,8 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { - const nextEntity = this.viewer.trackedEntity; + const nextFollowed = followedOf(this.viewer); + const nextEntity = nextFollowed?.entity; if ( !this.active || !nextEntity?.position || @@ -124,9 +129,10 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { this.trackedEntity.show = this.trackedEntityWasShown; } this.trackedEntity = nextEntity; + this.trackedFollow = nextFollowed; this.trackedEntityWasShown = nextEntity.show; nextEntity.show = false; - this.viewer.trackedEntity = undefined; + stopFollowing(this.viewer); this.cockpitAnchorValid = false; this.heading = normalizeHeading(info.track ?? 0); this.lastFrameMs = nowMs; @@ -143,7 +149,8 @@ export function enter() { if (this.active) return false; if (!this.isEntryAllowed()) return false; const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; + const followed = followedOf(this.viewer); + const entity = followed?.entity; if (!info || !entity?.position) return false; this.entryFocusOrigin = document.activeElement instanceof HTMLElement @@ -153,6 +160,7 @@ export function enter() { this.onCameraTakeover?.(); cameraOf(this.viewer).cancelFlight(); this.trackedEntity = entity; + this.trackedFollow = followed; this.trackedEntityWasShown = entity.show; entity.show = false; this.heading = normalizeHeading(info.track ?? 0); @@ -172,7 +180,7 @@ export function enter() { // runs on rendered frames, so idle mode would freeze the cockpit solid. // (perf wave 2) this.services.holdContinuousRender('cockpit'); - this.viewer.trackedEntity = undefined; + stopFollowing(this.viewer); this.releaseCameraInput?.(); this.releaseCameraInput = inputFor(this.viewer).suspendCamera('cockpit'); document.body.classList.add('cockpit-mode'); @@ -217,9 +225,11 @@ export function enter() { export function exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; + const followed = this.trackedFollow; this.active = false; this.services.releaseContinuousRender('cockpit'); this.trackedEntity = null; + this.trackedFollow = null; this.heading = null; this.cockpitAnchorValid = false; this.surfaceWaitStartedMs = 0; @@ -248,7 +258,7 @@ export function exit({ restoreTracking = true } = {}) { this.trackedEntityWasShown = true; this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { - this.viewer.trackedEntity = entity; + resumeFollowing(this.viewer, followed); this.restoreTrackingFrame(entity); } this.syncEntry(); diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js index e9380c17c..3a14c298e 100644 --- a/src/ui/shareFollow.js +++ b/src/ui/shareFollow.js @@ -1,11 +1,11 @@ import * as Cesium from 'cesium'; -import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js'; +import { findCamera } from '../render/rendererOf.js'; +import { followedOf, followedPosition } from '../data/followed.js'; import { SHARE_VIEW_ENTITY_KINDS, followOffsetFromEnu, followOffsetToEnu, } from '../share/follow.js'; -import { readEntityRecord } from '../data/entityRecords.js'; /** * The bridge between the camera following an entity and a share link's thing @@ -18,6 +18,12 @@ const KIND_BY_LAYER = new Map( ); const MAX_FRAMES = 120; +/** Where `followed` is shown now, as a Cesium position, or null. */ +function shownAt(viewer, followed) { + const p = followedPosition(viewer, followed); + return p ? new Cesium.Cartesian3(p.x, p.y, p.z) : null; +} + /** `position`'s local east/north/up offset from `origin`, in metres. */ function enuOffset(origin, position) { const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin); @@ -39,19 +45,13 @@ function enuOffset(origin, position) { export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { const cameraPosition = viewer?.camera?.positionWC; if (!cameraPosition) return null; - const tracked = viewer.trackedEntity; - const trackedId = String(readEntityRecord(tracked)?.trackedId || ''); + const followed = followedOf(viewer); + const trackedId = String(followed?.key || ''); const separator = trackedId.indexOf(':'); if (separator <= 0) return null; const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); const id = trackedId.slice(separator + 1); - const target = kind - ? trackedDisplayPositionForCamera( - tracked, - viewer.clock.currentTime, - new Cesium.Cartesian3(), - ) - : null; + const target = kind ? shownAt(viewer, followed) : null; if (!kind || !id || !target) return null; const offset = followOffsetFromEnu(enuOffset(target, cameraPosition)); const fix = Cesium.Cartographic.fromCartesian(target); @@ -73,14 +73,14 @@ export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { const MAX_TURN_PER_FRAME = Math.PI / 3; /** - * Put the camera at a shared follow offset from `entity` once following it - * has framed the camera. Cesium keeps the offset afterwards, as it keeps one + * Put the camera at a shared follow offset from `followed` (what + * `followedOf` gave) once following it has framed the camera. Cesium keeps the offset afterwards, as it keeps one * the user orbits to. The camera swings round the entity at most 60° a frame: * the follow's own guard treats a jump to the far side as the camera crossing * through the entity and puts it back. Returns a disposer. */ -export function applyShareFollowOffset(viewer, entity, offset) { - if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0)) +export function applyShareFollowOffset(viewer, followed, offset) { + if (!viewer?.scene?.preUpdate || !followed || !(offset?.range > 0)) return () => {}; const local = followOffsetToEnu(offset); let frames = 0; @@ -90,23 +90,20 @@ export function applyShareFollowOffset(viewer, entity, offset) { remove = null; }; remove = viewer.scene.preUpdate.addEventListener(() => { - if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + if (viewer.isDestroyed?.() || followedOf(viewer) !== followed) { stop(); return; } frames += 1; const camera = viewer.camera; // Let the follow frame the camera first (its first frame), then move it. + const port = findCamera(viewer); const framed = frames > 1 && - !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); - const target = framed - ? trackedDisplayPositionForCamera( - entity, - viewer.clock.currentTime, - new Cesium.Cartesian3(), - ) - : null; + (port?.followed === followed + ? port.followFramed + : !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)); + const target = framed ? shownAt(viewer, followed) : null; if (!target) { if (frames >= MAX_FRAMES) stop(); return; @@ -136,8 +133,12 @@ export function applyShareFollowOffset(viewer, entity, offset) { : 0; const done = !(angle > MAX_TURN_PER_FRAME); const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME); - camera.lookAtTransform(camera.transform, next); - viewer.scene.requestRender?.(); + // Through the port when it follows: its close-range guard keeps the eye. + if (port?.followed === followed) port.followOffset.set(next); + else { + camera.lookAtTransform(camera.transform, next); + viewer.scene.requestRender?.(); + } if (done) stop(); }); return stop; diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs index 1ed147b89..d2c9e5dfc 100644 --- a/src/ui/shareLinks.test.mjs +++ b/src/ui/shareLinks.test.mjs @@ -20,6 +20,7 @@ import { } from '../share/viewCodec.js'; import { entityRecord, withEntityRecord } from '../data/entityRecords.js'; import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; +import { followedOf } from '../data/followed.js'; const NOW_MS = Date.now(); const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; @@ -494,7 +495,7 @@ test('a shared offset on the far side swings round, never jumping through the en }; // Looking south at the nose: the opposite side of the app's framing. const offset = { range: 5800, heading: 180, pitch: -35 }; - applyShareFollowOffset(viewer, entity, offset); + applyShareFollowOffset(viewer, followedOf(viewer), offset); let previous = viewer.camera.position; for (let frame = 0; frame < 10 && listeners.length; frame += 1) { listeners[0](); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 966318f84..3a29646ff 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -4,7 +4,7 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; -import { readEntityRecord } from '../data/entityRecords.js'; +import { followedOf } from '../data/followed.js'; /** How long a shared vessel may take to appear in the vessel feed. */ export const SHARED_VESSEL_WAIT_MS = 120_000; @@ -420,17 +420,16 @@ export class ShareRestoration { /** Frame a shared entity the camera now follows from the shared offset. */ _applySharedFollowOffset(result) { const view = this._sharedThingFor(result); - const entity = this.viewer?.trackedEntity; + const followed = followedOf(this.viewer); // Only a link made while following carries a real offset (with the fix); // a long link's follow keeps the app's own framing. if ( !view?.lastKnown || - String(readEntityRecord(entity)?.trackedId || '') !== - `${result.layerId}:${result.targetId}` + String(followed?.key || '') !== `${result.layerId}:${result.targetId}` ) return; this._stopFollowOffset?.(); - this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset); + this._stopFollowOffset = this.applyFollowOffset?.(followed, view.offset); } cancelSelection() {