Read the followed target, not trackedEntity, in Core's follow readers

The tracked card, screen annotations, north-up, share links and Cockpit
ask data/followed.js what the camera follows, so they work the same when
a layer follows through the camera port. followed.js gains
entityFollowTarget (a FollowTarget whose presentation reads the drawn
entity's record), resumeFollowing and followedPosition.
This commit is contained in:
Sameh Khamis committed 2026-11-06 15:31:36 -08:00
1 parent 2adec097de
commit e5f8e5753a
16 files changed
+215 -84

No files matched your search

+4 -1
View File
@@ -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": []
},
+4 -4
View File
@@ -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;
+1 -1
View File
@@ -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,
+7 -7
View File
@@ -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,
+60 -1
View File
@@ -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
);
}
+52
View File
@@ -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);
});
+7 -7
View File
@@ -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(
+2 -2
View File
@@ -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({
+15 -14
View File
@@ -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 });
+4 -1
View File
@@ -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();
}),
+3 -2
View File
@@ -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,
+2 -1
View File
@@ -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;
}
+21 -11
View File
@@ -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();
+27 -26
View File
@@ -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;
+2 -1
View File
@@ -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]();
+4 -5
View File
@@ -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() {