Read the HUD and share-link camera through the renderer's camera port

The first camera-family slice. The camera port's events move into a
shared module (render/camera/events.js) that the stub and the Cesium
port both use: listener lists, delivery with errors reported, and the
contract's per-listener `changed` measure.

The Cesium port gains:
- camera.on: `changed` measured per listener on every tick, where Cesium
  measures its single threshold (the counts match Cesium's own at each
  threshold); moveStart and moveEnd relayed from Cesium's camera;
- camera.viewRectangle, in degrees (now in CESIUM_CHECKS);
- lon and lat in pose, and NaN where Cesium has no cartographic position.

Code that still holds the viewer reaches the renderer through
rendererOf(viewer); the application renderer binds it on create and
unbinds on destroy.

The HUD reads its telemetry from the camera port and no longer imports
Cesium (off the allow-list). The share link writes the hash from the
pose. Its restore path stays on the viewer's camera for a later slice.

Behaviour changes:
- The share link listens to `changed` at its own 0.5 threshold, not at
  whatever global threshold layers had set, and also to moveEnd, so the
  hash always catches up with where the camera comes to rest.
- The HUD's and share link's camera callbacks run from the port's single
  relay, ahead of other moveStart/moveEnd listeners, and a throw in one
  is reported to the renderer's error listeners instead of escaping from
  Cesium's render.
This commit is contained in:
Sameh Khamis committed 2026-11-05 19:48:58 -08:00
1 parent 24bc8fce7e
commit de0ff6cd2d
28 files changed
+688 -243

No files matched your search

+3
View File
@@ -2490,7 +2490,9 @@
"src/app/operations.js",
"src/app/scene.js",
"src/render/cesium/appRenderer.js",
"src/render/rendererOf.js",
"src/render/cesium/ports.js",
"src/render/camera/events.js",
"src/render/contract/index.js",
"src/sources/sourceSlot.js",
"src/app/sources.js",
@@ -5275,6 +5277,7 @@
"src/geo/quaternion.js",
"src/render/stub/motion.js",
"src/render/stub/camera.js",
"src/render/camera/events.js",
"src/render/stub/geodesic.js",
"src/render/stub/viewRectangle.js",
"src/render/stub/orbits.js",
+33 -33
View File
@@ -3,7 +3,7 @@ import { applicationServices } from './services/application.js';
* @module hud
* @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic.
*
* Renders reconnaissance-styled telemetry over the Cesium canvas. The
* Renders reconnaissance-styled telemetry over the map canvas. The
* MGRS/lat-lon coordinates (the point below the camera), altitude and
* off-nadir angle come from the live camera, and the clocks show the current
* UTC time. Sun elevation is an approximate estimate from that position and
@@ -19,7 +19,8 @@ import { applicationServices } from './services/application.js';
* Color theming is driven by the active shader mode via CSS custom properties.
*/
import * as Cesium from 'cesium';
import { toDegrees, toRadians } from './geo/index.js';
import { rendererOf } from './render/rendererOf.js';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
@@ -215,7 +216,7 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
);
/**
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
* Full-screen intelligence HUD overlay rendered on top of the map canvas.
*
* Displays MGRS/lat-lon, altitude and off-nadir readouts from the live
* camera, UTC clocks, an approximate sun elevation, the selected vessel and a
@@ -227,8 +228,10 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
*/
export class IntelHUD {
/**
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation.
* @param {object} viewer - The scene the HUD sits over.
* @param {object} [options]
* @param {import('./render/contract/camera').CameraPort} [options.camera]
* Camera telemetry; default the viewer's renderer's camera.
*/
constructor(
viewer,
@@ -238,8 +241,11 @@ export class IntelHUD {
basemapContext = {},
summaryService = applicationServices.summary,
publish = () => {},
camera = rendererOf(viewer)?.camera,
} = {},
) {
if (!camera) throw new Error('IntelHUD needs a camera port');
this._camera = camera;
this._publish = publish;
this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy;
@@ -278,7 +284,7 @@ export class IntelHUD {
// A) kick a real AI summary once the intro fly-to settles.
this._firstMetricsShown = false;
this._firstSummaryKicked = false;
// ALT readout datum: the camera height Cesium reports is ELLIPSOIDAL, the
// ALT readout datum: the camera height the renderer reports is ELLIPSOIDAL, the
// number a viewer reads is MSL. N comes from the same lazy ~2.7 MB EGM96
// chunk the flight layers use — requested on the first telemetry tick of a
// VISIBLE HUD, never at construction, so a hidden HUD costs nothing — and
@@ -343,8 +349,10 @@ export class IntelHUD {
: { active: false, text: t('AIS: --') },
}),
);
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
this._offCamera = [
camera.on('moveStart', this._onCameraMoveStart),
camera.on('moveEnd', this._onCameraMoveEnd),
];
this._offLocale = onLocaleChange(() => this._relocalize());
this._startTimers();
}
@@ -495,18 +503,17 @@ export class IntelHUD {
/**
* Derive all camera-based telemetry and push values to the DOM.
* Reads the viewer camera's cartographic position and computes MGRS,
* Reads the camera's pose and computes MGRS,
* lat/lon DMS, GSD, NIIRS, sun elevation, off-nadir angle, and
* collection timestamp. Stores results in {@link _latestMetrics}.
*/
_updateCameraData() {
const camera = this.viewer.camera;
const cartographic = camera.positionCartographic;
if (!cartographic) return;
const lonDeg = Cesium.Math.toDegrees(cartographic.longitude);
const latDeg = Cesium.Math.toDegrees(cartographic.latitude);
const altM = cartographic.height;
const pose = this._camera.pose();
// No position yet: the readouts keep their last values.
if (![pose.lon, pose.lat, pose.height].every(Number.isFinite)) return;
const lonDeg = pose.lon;
const latDeg = pose.lat;
const altM = pose.height;
const latDMS = this._toDMS(latDeg, 'lat');
const lonDMS = this._toDMS(lonDeg, 'lon');
let mgrsLabel = '---';
@@ -563,7 +570,7 @@ export class IntelHUD {
// Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down),
// so ONA = 90 + pitch gives 0 at nadir and increases toward the horizon.
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
const pitchDeg = toDegrees(pose.pitch);
const ona = Math.max(0, 90 + pitchDeg);
// i18n-ignore: sensor codes
readouts.ona = `ONA: ${ona.toFixed(1)}°`;
@@ -670,19 +677,19 @@ export class IntelHUD {
const declination =
23.45 *
Math.sin(
Cesium.Math.toRadians(
toRadians(
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
),
);
const latRad = Cesium.Math.toRadians(lat);
const decRad = Cesium.Math.toRadians(declination);
const haRad = Cesium.Math.toRadians(hourAngle);
const latRad = toRadians(lat);
const decRad = toRadians(declination);
const haRad = toRadians(hourAngle);
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
const sinEl =
Math.sin(latRad) * Math.sin(decRad) +
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
// Clamp to [-1,1] to guard against floating-point drift before asin
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
return toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
}
/**
@@ -727,22 +734,16 @@ export class IntelHUD {
* dimensions, or null if the view rectangle cannot be computed.
*/
_viewWindowKm(latDeg) {
const rect = this.viewer.camera.computeViewRectangle();
const rect = this._camera.viewRectangle();
if (!rect) return null;
const north = Cesium.Math.toDegrees(rect.north);
const south = Cesium.Math.toDegrees(rect.south);
let east = Cesium.Math.toDegrees(rect.east);
let west = Cesium.Math.toDegrees(rect.west);
const { north, south, east, west } = rect;
let lonSpan = Math.abs(east - west);
// Handle antimeridian wrap: if span exceeds 180 deg, take the shorter arc
if (lonSpan > 180) lonSpan = 360 - lonSpan;
const latSpan = Math.abs(north - south);
// 111 km/deg is the approximate surface distance per degree of latitude;
// longitude distance is scaled by cos(lat) to account for meridian convergence.
const widthKm = Math.max(
0,
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
);
const widthKm = Math.max(0, lonSpan * 111 * Math.cos(toRadians(latDeg)));
const heightKm = Math.max(0, latSpan * 111);
return { widthKm, heightKm };
}
@@ -1218,8 +1219,7 @@ export class IntelHUD {
clearInterval(this._timestampInterval);
clearInterval(this._summaryInterval);
clearInterval(this._summaryTypingInterval);
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
for (const off of this._offCamera.splice(0)) off();
this._dataManagerUnsubscribe?.();
this._vesselUnsubscribe?.();
this._summaryRequest?.abort();
+64 -34
View File
@@ -25,6 +25,8 @@ import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { ensureGeoidReady } from './data/geoid.js';
import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs';
import { toDegrees as deg } from './geo/index.js';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
@@ -65,39 +67,29 @@ const SFO_ELLIPSOIDAL_M = -15;
function installHudEnvironment() {
const { elements, publish, view } = hudReadouts();
const previousDocument = globalThis.document;
const cameraListeners = {};
const camera = fakeCameraPort({
pose: {
pitch: -Math.PI / 2,
lat: deg((SFO.latDeg * Math.PI) / 180),
lon: deg((SFO.lonDeg * Math.PI) / 180),
height: SFO_ELLIPSOIDAL_M,
},
});
globalThis.document = {
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: {
latitude: (SFO.latDeg * Math.PI) / 180,
longitude: (SFO.lonDeg * Math.PI) / 180,
height: SFO_ELLIPSOIDAL_M,
},
computeViewRectangle: () => undefined,
moveStart: {
addEventListener(listener) {
cameraListeners.moveStart = listener;
},
removeEventListener() {},
},
moveEnd: {
addEventListener(listener) {
cameraListeners.moveEnd = listener;
},
removeEventListener() {},
},
},
const viewer = {};
const cameraListeners = {
moveStart: () => camera.emit('moveStart'),
moveEnd: () => camera.emit('moveEnd'),
};
return {
elements,
viewer,
camera,
publish,
view,
cameraListeners,
@@ -204,7 +196,7 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer, { publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish });
const alt = () => env.elements.get('hud-alt').textContent;
const summary = () => env.elements.get('hud-summary').textContent;
@@ -253,12 +245,14 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
const env = installHudEnvironment();
// London: N is +46 m, so the correction moves the readout DOWN. A sign flip
// that happens to look right at SFO fails here.
env.viewer.camera.positionCartographic.latitude = (51.5072 * Math.PI) / 180;
env.viewer.camera.positionCartographic.longitude = (-0.1275 * Math.PI) / 180;
env.viewer.camera.positionCartographic.height = 100;
env.camera.setPose({
lat: deg((51.5072 * Math.PI) / 180),
lon: deg((-0.1275 * Math.PI) / 180),
height: 100,
});
let hud;
try {
hud = new IntelHUD(env.viewer, { publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish });
hud._updateCameraData(); // cold: requests the grid, paints uncorrected
assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/);
await ensureGeoidReady();
@@ -293,7 +287,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish });
let place = 'Yellowstone';
hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent;
@@ -353,7 +347,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish });
hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
@@ -415,7 +409,7 @@ test('a cancelled summary retries the same context but completed answers stay re
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve));
@@ -472,7 +466,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish });
// The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
const element = env.elements.get('hud-summary');
@@ -537,7 +531,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
await hud._updateSummary(true);
@@ -578,7 +572,7 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => {
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer, { publish: env.publish });
hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish });
hud.setVariant('minimal');
assert.equal(hud.getVariant(), 'minimal');
hud.setVariant('operator');
@@ -589,3 +583,39 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => {
env.restore();
}
});
test('a camera with no position leaves the readouts as they were', () => {
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish });
hud._updateCameraData();
const alt = env.elements.get('hud-alt').textContent;
const metrics = hud._latestMetrics;
env.camera.setPose({ lon: NaN, lat: NaN, height: NaN });
hud._updateCameraData();
assert.equal(env.elements.get('hud-alt').textContent, alt);
assert.equal(hud._latestMetrics, metrics);
assert.doesNotMatch(alt, /NaN/);
} finally {
hud?.destroy();
env.restore();
}
});
test('the view window is measured from the camera’s rectangle in degrees', () => {
const env = installHudEnvironment();
let hud;
try {
hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish });
assert.equal(hud._viewWindowKm(0), null, 'no rectangle, no window');
env.camera.setViewRectangle({ west: -1, south: -0.5, east: 1, north: 0.5 });
assert.deepEqual(hud._viewWindowKm(0), { widthKm: 222, heightKm: 111 });
// Across the antimeridian: the short way round.
env.camera.setViewRectangle({ west: 179, south: 0, east: -179, north: 1 });
assert.equal(Math.round(hud._viewWindowKm(60).widthKm), 111);
} finally {
hud?.destroy();
env.restore();
}
});
+13 -14
View File
@@ -11,6 +11,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module';
import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs';
import { toDegrees as deg } from './geo/index.js';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
@@ -65,22 +67,19 @@ test('the telemetry tick paints the carried value into both readouts', () => {
addEventListener() {},
removeEventListener() {},
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: {
latitude: (37.75 * Math.PI) / 180,
longitude: (-122.35 * Math.PI) / 180,
height: 1500,
},
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
const viewer = {};
let hud;
try {
hud = new IntelHUD(viewer, { publish });
hud = new IntelHUD(viewer, {
publish,
camera: fakeCameraPort({
pose: {
lat: deg((37.75 * Math.PI) / 180),
lon: deg((-122.35 * Math.PI) / 180),
height: 1500,
},
}).port,
});
hud._updateCameraData();
const latlon = elements.get('hud-latlon').textContent;
const bottom = elements.get('hud-bottom-line').textContent;
+6 -8
View File
@@ -8,6 +8,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module';
import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs';
import { toDegrees as deg } from './geo/index.js';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
@@ -41,13 +43,6 @@ function installHiddenPage() {
removeEventListener() {},
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
alt: () => elements.get('hud-alt').textContent,
@@ -65,7 +60,10 @@ test('the HUD readout pauses in a hidden page but keeps updating in an inline pa
const page = installHiddenPage();
let hud;
try {
hud = new IntelHUD(page.viewer, { publish: page.publish });
hud = new IntelHUD(page.viewer, {
publish: page.publish,
camera: fakeCameraPort({ pose: { lat: deg(0.7), lon: deg(-1.2), height: 500 } }).port,
});
hud._visible = true;
t.mock.timers.tick(250);
+3 -7
View File
@@ -8,6 +8,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module';
import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs';
import { toDegrees as deg } from './geo/index.js';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
@@ -48,13 +50,6 @@ function installPage() {
clearInterval,
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
viewer,
@@ -73,6 +68,7 @@ function createHud(t, summaryService) {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installPage();
const hud = new IntelHUD(page.viewer, {
camera: fakeCameraPort({ pose: { lat: deg(0.53), lon: deg(-1.7), height: 600 } }).port,
summaryService,
publish: page.publish,
});
+3
View File
@@ -2,6 +2,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs';
import { toDegrees as deg } from './geo/index.js';
const { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js');
@@ -50,6 +52,7 @@ function createHud(
};
const sent = [];
const hud = new IntelHUD(viewer, {
camera: fakeCameraPort({ pose: { lat: deg(0.53), lon: deg(-1.7), height: 600 } }).port,
publish,
summaryService: {
configured: async () => true,
+2 -1
View File
@@ -42,6 +42,7 @@ import {
setScopeMaskFeather,
} from './scopeMask.js';
import { ShareLinkManager } from './sharelink.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
// Follow the UI wiring and its extracted preset definitions.
const uiSource = readShellSource()
@@ -71,7 +72,7 @@ function managerForHash(hash) {
roll: 0,
},
};
return new ShareLinkManager(viewer);
return new ShareLinkManager(bindFakeCesiumCamera(viewer));
}
// ---------------------------------------------------------------------------
+118
View File
@@ -0,0 +1,118 @@
/**
* @module render/camera/events
* @description The camera port's events, shared by every renderer: the
* listener lists, delivery (a throwing listener is reported, the rest still
* run), and the contract's per-listener `changed` measure. A renderer calls
* `stepChanged()` once per frame and `emit()` for the events its engine
* raises.
*/
import { acosClamped, distance, dot } from '../../geo/index.js';
const TWO_PI = Math.PI * 2;
/** The contract's camera events. */
export const CAMERA_EVENTS = Object.freeze([
'changed',
'moveStart',
'moveEnd',
'followChanged',
'flightEnd',
]);
/** How far an angle moved, as a fraction of π, the short way round. */
const angleDelta = (a, b) => {
const d = Math.abs(a - b) % TWO_PI;
return (d > Math.PI ? TWO_PI - d : d) / Math.PI;
};
/**
* @param {{pose: () => import('../contract/camera').CameraPose,
* report: (error: unknown) => void}} options `pose` is read only while a
* `changed` listener is registered.
*/
export function createCameraEvents({ pose, report }) {
const listeners = Object.fromEntries(CAMERA_EVENTS.map((e) => [e, []]));
function deliver(entry) {
if (!entry.live) return;
try {
entry.cb();
} catch (error) {
report(error);
}
}
function emit(event) {
for (const entry of listeners[event].slice()) deliver(entry);
}
/**
* @param {string} event
* @param {() => void} cb
* @param {{threshold?: number}} [options]
* @returns {() => void}
*/
function on(event, cb, options = {}) {
const list = listeners[event];
if (!list) throw new Error(`Unknown camera event: ${event}`);
const entry = { cb, live: true, threshold: options.threshold ?? 0.5 };
list.push(entry);
return () => {
entry.live = false;
const i = list.indexOf(entry);
if (i >= 0) list.splice(i, 1);
};
}
/**
* Per listener, the reference camera's formula: heading and roll each
* against their own last reported value, then direction and position
* against the last reported view. Each can report in the same frame.
*/
function stepChanged() {
const list = listeners.changed;
if (!list.length) return;
const current = pose();
for (const entry of list.slice()) {
if (!entry.live) continue;
const t = entry.threshold;
entry.heading ??= current.heading;
entry.roll ??= current.roll;
const headingF = angleDelta(entry.heading, current.heading);
const rollF = angleDelta(entry.roll, current.roll);
if (headingF > t) entry.heading = current.heading;
if (rollF > t) entry.roll = current.roll;
if (headingF > t || rollF > t) deliver(entry);
if (!entry.view) {
entry.view = {
position: current.position,
direction: current.direction,
};
continue;
}
const dirF =
acosClamped(dot(current.direction, entry.view.direction)) /
(current.fovy * 0.5);
const heightF =
distance(current.position, entry.view.position) / current.height;
if (dirF > t || heightF > t) {
entry.view = {
position: current.position,
direction: current.direction,
};
deliver(entry);
}
}
}
/** Drop every listener, including those of an event under way. */
function clear() {
for (const list of Object.values(listeners)) {
for (const entry of list) entry.live = false;
list.length = 0;
}
}
return { on, emit, stepChanged, clear };
}
+6 -1
View File
@@ -14,10 +14,12 @@
* still missing.
* Until a family is ported, its code reaches the Cesium viewer through
* `cesiumViewerOf`, never through a renderer member, so no engine type
* crosses the contract and each call marks code still to port.
* crosses the contract and each call marks code still to port. Ported code
* that still holds the viewer reaches the renderer through `rendererOf`.
*/
import { createApplicationViewer } from '../../app/viewer.js';
import { CONTRACT_VERSION } from '../contract/index.js';
import { bindRenderer } from '../rendererOf.js';
import { createCesiumInput } from './input.js';
import { cesiumCapabilities, createCesiumPorts } from './ports.js';
@@ -101,6 +103,7 @@ export function createCesiumRendererPlugin({
creditContainer.remove();
throw error;
}
let unbind = () => {};
const renderer = Object.freeze({
id: 'cesium',
contractVersion: CONTRACT_VERSION,
@@ -118,6 +121,7 @@ export function createCesiumRendererPlugin({
},
destroy() {
try {
unbind();
input.dispose();
ports.dispose();
if (!viewer.isDestroyed()) viewer.destroy();
@@ -127,6 +131,7 @@ export function createCesiumRendererPlugin({
},
});
viewers.set(renderer, viewer);
unbind = bindRenderer(viewer, renderer);
return renderer;
},
});
+8 -3
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { CONTRACT_VERSION, checkRendererShape } from '../contract/index.js';
import { rendererOf } from '../rendererOf.js';
import {
ReadonlySet,
cesiumRenderer,
@@ -49,10 +50,14 @@ function fakeViewer(log, { failDestroy = false } = {}) {
return {
listeners,
scene: {
camera: {},
camera: {
moveStart: fakeEvent(listeners),
moveEnd: fakeEvent(listeners),
},
primitives: { add() {}, remove() {} },
requestRender() {},
preUpdate: fakeEvent(listeners),
postUpdate: fakeEvent(listeners),
preRender: fakeEvent(listeners),
postRender: fakeEvent(listeners),
},
@@ -121,6 +126,7 @@ test('the plugin wraps the viewer it is given, behind the accessor only', async
assert.throws(() => renderer.capabilities.clear(), TypeError);
assert.ok(!('viewer' in renderer), 'no engine object is a member');
assert.equal(cesiumViewerOf(renderer).canvas, renderer.canvas);
assert.equal(rendererOf(viewer), renderer, 'the viewer finds its renderer');
assert.ok(viewer.listeners.size > 0, 'the ports follow the frame phases');
assert.equal(typeof renderer.draw.icons, 'function');
@@ -129,6 +135,7 @@ test('the plugin wraps the viewer it is given, behind the accessor only', async
renderer.resize();
renderer.destroy();
assert.equal(viewer.listeners.size, 0, 'and stop on destroy');
assert.equal(rendererOf(viewer), null, 'and lets go of the viewer');
assert.deepEqual(log, ['resize', 'destroy']);
assert.deepEqual(doc.children, [], 'credits leave with the renderer');
renderer.destroy();
@@ -340,12 +347,10 @@ const EXPECTED_MISSING = [
'camera.lookAt',
'camera.matrices',
'camera.move',
'camera.on',
'camera.onCommand',
'camera.release',
'camera.rotateAround',
'camera.setFov',
'camera.viewRectangle',
'camera.zoom',
'draw.atlas',
'draw.clampState',
+1
View File
@@ -14,6 +14,7 @@ import { createCesiumSceneRenderer, pointerFeedOf } from './sceneRenderer.js';
/** @type {ReadonlySet<string>} */
export const CESIUM_CHECKS = new Set([
'camera.viewRectangle',
'draw.drillPick',
'draw.ids',
'draw.models',
+39 -1
View File
@@ -13,6 +13,7 @@
*/
import * as Cesium from 'cesium';
import { heightsFor } from '../../queries/heights.js';
import { createCameraEvents } from '../camera/events.js';
import {
atEndOfTick,
billboardLoadError,
@@ -788,7 +789,23 @@ export function createCesiumPorts(widget, { capabilities }) {
models: (spec) => makeSet('models', { ...spec }, buildModels),
};
// Camera events: Cesium's own moveStart and moveEnd (500 ms after the
// last change, never while following), and `changed` measured per
// listener on every tick, where Cesium measures its single threshold.
const cameraEvents = createCameraEvents({
pose: () => cameraPort.pose(),
report,
});
removers.push(
scene.postUpdate.addEventListener(() => {
if (!disposed) cameraEvents.stepChanged();
}),
camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')),
camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')),
);
const cameraPort = {
on: (event, cb, options) => cameraEvents.on(event, cb, options),
setView(spec) {
const d = spec.destination;
if (d && 'rect' in d)
@@ -804,9 +821,29 @@ export function createCesiumPorts(widget, { capabilities }) {
});
scene.requestRender();
},
viewRectangle() {
const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84);
if (!rect) return null;
const deg = Cesium.Math.toDegrees;
return {
west: deg(rect.west),
south: deg(rect.south),
east: deg(rect.east),
north: deg(rect.north),
};
},
pose() {
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
// Cesium has no cartographic position for an eye at the earth's
// centre; the pose then says so with NaN.
const carto = camera.positionCartographic ?? {
longitude: NaN,
latitude: NaN,
height: NaN,
};
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
position: v(camera.positionWC),
direction: v(camera.directionWC),
up: v(camera.upWC),
@@ -814,7 +851,7 @@ export function createCesiumPorts(widget, { capabilities }) {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
height: camera.positionCartographic.height,
height: carto.height,
fovy: camera.frustum.fovy,
aspectRatio: camera.frustum.aspectRatio,
near: camera.frustum.near,
@@ -1183,6 +1220,7 @@ export function createCesiumPorts(widget, { capabilities }) {
// A listener that disposes the renderer stops the rest of its event.
for (const list of Object.values(rendererListeners)) list.clear();
for (const list of Object.values(frameListeners)) list.clear();
cameraEvents.clear();
for (const done of [...pendingRenders]) done();
// Late model loads find no host and destroy themselves.
for (const record of sets.values()) record.host = null;
+117 -1
View File
@@ -6,6 +6,7 @@ import {
createCesiumPorts,
listenerList,
} from './ports.js';
import { updateCameraChanged } from './internals.js';
test('capabilities follow what the scene supports', () => {
assert.deepEqual(cesiumCapabilities({}), ['models']);
@@ -85,13 +86,14 @@ function fakeWidget(overrides = {}) {
const calls = [];
const primitives = [];
const scene = {
camera: {},
camera: { moveStart: event(), moveEnd: event() },
primitives: {
add: (p) => primitives.push(p),
remove: (p) => primitives.splice(primitives.indexOf(p), 1),
},
requestRender() {},
preUpdate: event(),
postUpdate: event(),
preRender: event(),
postRender: event(),
sampleHeightSupported: true,
@@ -266,6 +268,7 @@ function tickingWidget() {
scene._renderRequested = true;
},
preUpdate: event('preUpdate'),
postUpdate: event('postUpdate'),
preRender: event('preRender'),
postRender: event('postRender'),
};
@@ -500,3 +503,116 @@ test('the loop starts on demand and never draws for scene time alone', () => {
assert.equal(ports.frame.diagnostics().targetFps, 60, 'the widget’s cap');
ports.dispose();
});
/** A city view over Austin, looking part-way down. */
function cityView(camera) {
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 2000),
orientation: { heading: 0, pitch: -0.5, roll: 0 },
});
}
/** How often Cesium's own `changed` fires at one threshold, over `steps`. */
function cesiumChangedCount(threshold, steps) {
const { scene } = tickingWidget();
const camera = scene.camera;
cityView(camera);
camera.percentageChanged = threshold;
let count = 0;
camera.changed.addEventListener(() => (count += 1));
updateCameraChanged(camera);
for (const step of steps) {
step(camera);
updateCameraChanged(camera);
}
return count;
}
test('changed fires per listener, at each threshold as Cesium would', () => {
const steps = [
...Array.from({ length: 24 }, () => (c) => c.lookRight(0.05)),
...Array.from({ length: 12 }, () => (c) => c.twistLeft(0.2)),
...Array.from({ length: 10 }, () => (c) => c.moveForward(400)),
];
const { widget, scene, raise } = tickingWidget();
cityView(scene.camera);
const ports = createCesiumPorts(widget, { capabilities: all });
const counts = { 0.5: 0, 0.1: 0 };
for (const t of [0.5, 0.1])
ports.camera.on('changed', () => (counts[t] += 1), { threshold: t });
raise('postUpdate');
for (const step of steps) {
step(scene.camera);
raise('postUpdate');
}
assert.ok(counts[0.1] > counts[0.5], JSON.stringify(counts));
for (const t of [0.5, 0.1])
assert.equal(counts[t], cesiumChangedCount(t, steps), `threshold ${t}`);
ports.dispose();
});
test('camera events: Cesium’s moves relayed, a throw reported, off and dispose stop them', () => {
const { widget, scene, raise } = tickingWidget();
cityView(scene.camera);
const ports = createCesiumPorts(widget, { capabilities: all });
const errors = [];
ports.on('error', (e) => errors.push(e.message));
const seen = [];
ports.camera.on('moveStart', () => {
throw new Error('first');
});
const off = ports.camera.on('moveStart', () => seen.push('start'));
ports.camera.on('moveEnd', () => seen.push('end'));
ports.camera.on('changed', () => seen.push('changed'), { threshold: 0.01 });
scene.camera.moveStart.raiseEvent();
scene.camera.moveEnd.raiseEvent();
assert.deepEqual(seen, ['start', 'end']);
assert.deepEqual(errors, ['first']);
off();
scene.camera.moveStart.raiseEvent();
assert.deepEqual(seen, ['start', 'end']);
assert.throws(() => ports.camera.on('turned', () => {}), /Unknown camera event/);
ports.dispose();
scene.camera.moveEnd.raiseEvent();
scene.camera.lookRight(1);
raise('postUpdate');
assert.deepEqual(seen, ['start', 'end'], 'nothing after dispose');
});
test('pose: degrees, and NaN where Cesium has no cartographic position', () => {
const { widget, scene } = tickingWidget();
cityView(scene.camera);
const ports = createCesiumPorts(widget, { capabilities: all });
const pose = ports.camera.pose();
assert.ok(Math.abs(pose.lon + 97.74) < 1e-9 && Math.abs(pose.lat - 30.27) < 1e-9);
assert.ok(Math.abs(pose.height - 2000) < 1e-6);
scene.camera.position = new Cesium.Cartesian3(0, 0, 0);
const centre = ports.camera.pose();
assert.ok(Number.isNaN(centre.lon) && Number.isNaN(centre.height));
ports.dispose();
});
test('a camera listener that disposes the port stops the rest of its event', () => {
const { widget, scene } = tickingWidget();
const ports = createCesiumPorts(widget, { capabilities: all });
const seen = [];
ports.camera.on('moveStart', () => ports.dispose());
ports.camera.on('moveStart', () => seen.push('second'));
scene.camera.moveStart.raiseEvent();
assert.deepEqual(seen, []);
});
test('viewRectangle is in degrees, around the point below a downward eye', () => {
const { widget, scene } = tickingWidget();
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 5000),
});
const ports = createCesiumPorts(widget, { capabilities: all });
const rect = ports.camera.viewRectangle();
assert.ok(
rect.west < -97.74 && rect.east > -97.74 && rect.east - rect.west < 0.2,
JSON.stringify(rect),
);
assert.ok(rect.south < 30.27 && rect.north > 30.27, JSON.stringify(rect));
ports.dispose();
});
-1
View File
@@ -41,7 +41,6 @@
"src/data/osmOverlays.js",
"src/data/trackedCamera.js",
"src/data/trailRenderer.js",
"src/hud.js",
"src/input/dispatcher.js",
"src/input/sensorPick.js",
"src/layers/airQuality/index.js",
+29
View File
@@ -0,0 +1,29 @@
/**
* @module render/rendererOf
* @description The renderer that draws a scene, found from the scene object
* the code already holds. It bridges the migration: code that still holds
* the scene reaches the renderer's ports through it, and the lookup goes
* once that code is handed the renderer itself.
*/
const renderers = new WeakMap();
/**
* Bind a renderer to its scene object.
* @param {object} host The scene object callers hold.
* @param {object} renderer
* @returns {() => void} Unbinds, if the binding is still this one.
*/
export function bindRenderer(host, renderer) {
if (renderers.has(host)) throw new Error('This scene already has a renderer');
renderers.set(host, renderer);
return () => {
if (renderers.get(host) === renderer) renderers.delete(host);
};
}
/**
* @param {object|null|undefined} host
* @returns {object|null} The renderer bound to `host`, or null.
*/
export const rendererOf = (host) => (host && renderers.get(host)) || null;
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bindRenderer, rendererOf } from './rendererOf.js';
test('a scene finds its renderer until it is unbound', () => {
const scene = {};
const renderer = { id: 'r' };
assert.equal(rendererOf(scene), null);
assert.equal(rendererOf(null), null);
const unbind = bindRenderer(scene, renderer);
assert.equal(rendererOf(scene), renderer);
assert.throws(() => bindRenderer(scene, { id: 'other' }), /already has/);
unbind();
assert.equal(rendererOf(scene), null);
});
test('a stale unbind leaves a later binding alone', () => {
const scene = {};
const unbindFirst = bindRenderer(scene, { id: 'first' });
unbindFirst();
const second = { id: 'second' };
bindRenderer(scene, second);
unbindFirst();
assert.equal(rendererOf(scene), second);
});
+3 -91
View File
@@ -11,7 +11,6 @@ import {
IDENTITY,
PI_OVER_TWO,
TWO_PI,
acosClamped,
add,
clone,
cross,
@@ -44,6 +43,7 @@ import {
vec,
zeroToTwoPi,
} from './math.js';
import { createCameraEvents } from '../camera/events.js';
import { geodesicAt } from './geodesic.js';
const DEFAULT_FOV = Math.PI / 3;
@@ -94,13 +94,8 @@ export function createStubCamera({
const near = 1;
const far = 5e8;
const listeners = {
changed: [],
moveStart: [],
moveEnd: [],
followChanged: [],
flightEnd: [],
};
const events = createCameraEvents({ pose, report });
const { emit, on, stepChanged } = events;
const commandHooks = [];
const holds = new Map();
let holdSeq = 0;
@@ -727,89 +722,6 @@ export function createStubCamera({
// --- Events ----------------------------------------------------------------
function emit(event) {
for (const entry of listeners[event].slice()) {
if (!entry.live) continue;
try {
entry.cb();
} catch (error) {
report(error);
}
}
}
function on(event, cb, options = {}) {
const entry = {
cb,
live: true,
threshold: options.threshold ?? 0.5,
ref: null,
};
listeners[event].push(entry);
return () => {
entry.live = false;
const list = listeners[event];
const i = list.indexOf(entry);
if (i >= 0) list.splice(i, 1);
};
}
/** How far the view moved from a listener's reference, as a fraction. */
const angleDelta = (a, b) => {
const d = Math.abs(a - b) % TWO_PI;
return (d > Math.PI ? TWO_PI - d : d) / Math.PI;
};
/**
* Per listener, the reference camera's formula: heading and roll each
* against their own last reported value, then direction and position
* against the last reported view. Each can report in the same frame.
*/
function stepChanged() {
const list = listeners.changed;
if (!list.length) return;
const current = pose();
for (const entry of list.slice()) {
if (!entry.live) continue;
const t = entry.threshold;
entry.heading ??= current.heading;
entry.roll ??= current.roll;
const headingF = angleDelta(entry.heading, current.heading);
const rollF = angleDelta(entry.roll, current.roll);
if (headingF > t) entry.heading = current.heading;
if (rollF > t) entry.roll = current.roll;
if (headingF > t || rollF > t) deliver(entry);
if (!entry.view) {
entry.view = {
position: current.position,
direction: current.direction,
};
continue;
}
const dirF =
acosClamped(dot(current.direction, entry.view.direction)) /
(current.fovy * 0.5);
const heightF =
distance(current.position, entry.view.position) / current.height;
if (dirF > t || heightF > t) {
entry.view = {
position: current.position,
direction: current.direction,
};
deliver(entry);
}
}
}
function deliver(entry) {
if (!entry.live) return;
try {
entry.cb();
} catch (error) {
report(error);
}
}
function sameView(a, b) {
return (
a &&
+5 -4
View File
@@ -22,6 +22,7 @@ import {
fromLegacyShareState,
toLegacyShareState,
} from './legacyShareState.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const DEG = Math.PI / 180;
@@ -47,7 +48,7 @@ function parseLegacy(hash) {
globalThis.window = {
location: { hash: body, href: `http://localhost/${body}` },
};
const manager = new ShareLinkManager(stubViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(stubViewer()));
const parsed = manager.parseInitialHash();
manager.destroy();
return parsed;
@@ -209,7 +210,7 @@ function generatedLegacyHash(random) {
const int = (min, max) => min + Math.floor(random() * (max - min + 1));
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager(
stubViewer({
bindFakeCesiumCamera(stubViewer({
position: {
latitude: (int(-899999, 899999) / 1e4) * DEG,
longitude: (int(-1799999, 1799999) / 1e4) * DEG,
@@ -218,7 +219,7 @@ function generatedLegacyHash(random) {
heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0),
pitch: int(-90, 0) * DEG,
roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]),
}),
})),
);
const style = pick(random, VIEW_STYLES);
manager.onStyleChange(style);
@@ -305,7 +306,7 @@ test('the converter ignores non-objects and restore bookkeeping', () => {
test('the converter knows every key of the long form’s parsed state', () => {
// A key the converter does not list would silently drop out of share codes.
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager(stubViewer({}));
const manager = new ShareLinkManager(bindFakeCesiumCamera(stubViewer({})));
const parsed = manager._parseShareHash('v=2&lat=1&lon=2');
assert.deepEqual(
Object.keys(parsed).sort(),
+63
View File
@@ -0,0 +1,63 @@
// The share link's hash follows the camera through the camera port: on
// `changed` at the port's own threshold, and on `moveEnd` when it settles.
import test from 'node:test';
import assert from 'node:assert/strict';
import { ShareLinkManager } from './sharelink.js';
import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs';
function setup(t) {
t.mock.timers.enable({ apis: ['setTimeout'] });
const written = [];
globalThis.window = {
location: { href: 'https://gev.example/', hash: '' },
};
globalThis.history = { replaceState: (_s, _t, href) => written.push(href) };
t.after(() => {
delete globalThis.window;
delete globalThis.history;
});
const camera = fakeCameraPort({
pose: { lon: -97.7431, lat: 30.2672, height: 600 },
});
const manager = new ShareLinkManager({}, { camera: camera.port });
const lat = () => new URL(written.at(-1)).hash.match(/lat=([^&]*)/)?.[1];
return { camera, manager, written, lat };
}
test('the hash catches up when the camera comes to rest', (t) => {
const { camera, written, lat } = setup(t);
camera.setPose({ lat: 30.3 });
camera.emit('moveEnd');
t.mock.timers.tick(500);
assert.equal(written.length, 1);
assert.equal(lat(), '30.3000');
});
test('the hash follows a move past the default threshold, not below it', (t) => {
const { camera, written, lat } = setup(t);
// The first step takes the reference view.
camera.stepChanged();
// A tenth of the height: below the threshold, no write.
camera.setPose({ position: { x: 60, y: 0, z: 0 } });
camera.stepChanged();
t.mock.timers.tick(500);
assert.equal(written.length, 0);
// Past half the height: written.
camera.setPose({ lat: 30.31, position: { x: 400, y: 0, z: 0 } });
camera.stepChanged();
t.mock.timers.tick(500);
assert.equal(written.length, 1);
assert.equal(lat(), '30.3100');
});
test('destroy lets go of both camera listeners', (t) => {
const { camera, manager, written } = setup(t);
assert.equal(camera.listenerCount('changed'), 1);
assert.equal(camera.listenerCount('moveEnd'), 1);
manager.destroy();
assert.equal(camera.listenerCount('changed'), 0);
assert.equal(camera.listenerCount('moveEnd'), 0);
camera.emit('moveEnd');
t.mock.timers.tick(500);
assert.equal(written.length, 0);
});
+8 -7
View File
@@ -10,6 +10,7 @@ import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.js';
import { parseSharePath } from './share/shareLinkPath.js';
import { decodeShareView } from './share/viewCodec.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
/** The decoded view of a copied `/share/<code>` link. */
const sharedState = (href) =>
@@ -51,7 +52,7 @@ function makeManager(hash = '') {
roll: 0,
},
};
return new ShareLinkManager(viewer);
return new ShareLinkManager(bindFakeCesiumCamera(viewer));
}
function installClipboard(writeText) {
@@ -443,7 +444,7 @@ test('share-link restore forces a final stationary render for Google 3D Tiles',
},
scene: { requestRender() { calls.renders += 1; } },
};
const manager = new ShareLinkManager(viewer);
const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer));
manager.applyState({
lat: 40.7669,
lon: -73.9909,
@@ -472,7 +473,7 @@ test('newer navigation suppresses delayed share camera while non-camera state st
flyTo() { flights += 1; },
},
};
const manager = new ShareLinkManager(viewer, {
const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
onRestore: (state) => { restored = state; },
isNavigationCurrent: () => false,
});
@@ -702,7 +703,7 @@ test('share apply completion waits for both callback work and camera settlement'
},
scene: { requestRender() {} },
};
const manager = new ShareLinkManager(viewer, { onRestore: () => restoreGate });
const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), { onRestore: () => restoreGate });
let settled = false;
const applying = manager.applyState({
lat: 40, lon: -74, alt: 500, heading: 0, pitch: -30, roll: 0,
@@ -731,7 +732,7 @@ test('a later navigation prevents share completion from resetting the final pose
},
scene: { requestRender() {} },
};
const manager = new ShareLinkManager(viewer, {
const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
isNavigationCurrent: (token) => token === generation,
});
manager.applyState({
@@ -755,7 +756,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
},
scene: { requestRender() {} },
};
const manager = new ShareLinkManager(viewer, {
const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
isNavigationCurrent: (token) => token === generation,
cancelOwnedNavigation: () => { cancellations += 1; flight?.cancel?.(); },
});
@@ -767,7 +768,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
assert.equal(cancellations, 1);
assert.equal(setViews, 0);
const newerManager = new ShareLinkManager(viewer, {
const newerManager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
isNavigationCurrent: (token) => token === generation,
cancelOwnedNavigation: () => { cancellations += 1; },
});
+20 -17
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { fromDegrees, toDegrees, toRadians } from './geo/index.js';
import { rendererOf } from './render/rendererOf.js';
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
import {
migrateDetectionState,
@@ -69,9 +70,12 @@ export class ShareLinkManager {
isNavigationCurrent,
cancelOwnedNavigation,
initialShareCode = null,
camera = rendererOf(viewer)?.camera,
} = {},
) {
if (!camera) throw new Error('ShareLinkManager needs a camera port');
this.viewer = viewer;
this._camera = camera;
// A host may hand over the share code to open instead of the address.
this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null;
@@ -133,12 +137,13 @@ export class ShareLinkManager {
? cancelOwnedNavigation
: null;
// Listen for camera changes
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
() => {
this._scheduleUpdate();
},
);
// The hash follows the camera as it moves, and catches up with where
// it comes to rest.
const schedule = () => this._scheduleUpdate();
this._cameraOffs = [
camera.on('changed', schedule),
camera.on('moveEnd', schedule),
];
}
/**
@@ -645,18 +650,17 @@ export class ShareLinkManager {
/** Build a deterministic snapshot without mutating history. */
_buildHashParams() {
if (this._destroyed) return null;
const camera = this.viewer.camera;
const carto = camera.positionCartographic;
if (!carto) return null;
const pose = this._camera.pose();
if (!Number.isFinite(pose.lat) || !Number.isFinite(pose.lon)) return null;
const params = new URLSearchParams();
params.set('v', '2');
params.set('lat', toDegrees(carto.latitude).toFixed(4));
params.set('lon', toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString());
params.set('heading', Math.round(toDegrees(camera.heading)).toString());
params.set('pitch', Math.round(toDegrees(camera.pitch)).toString());
params.set('roll', Math.round(toDegrees(camera.roll)).toString());
params.set('lat', pose.lat.toFixed(4));
params.set('lon', pose.lon.toFixed(4));
params.set('alt', Math.round(pose.height).toString());
params.set('heading', Math.round(toDegrees(pose.heading)).toString());
params.set('pitch', Math.round(toDegrees(pose.pitch)).toString());
params.set('roll', Math.round(toDegrees(pose.roll)).toString());
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
params.set('bloom', this._bloomEnabled ? '1' : '0');
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
@@ -711,8 +715,7 @@ export class ShareLinkManager {
this._destroyed = true;
clearTimeout(this._debounceTimer);
this._debounceTimer = null;
this._removeCameraChanged?.();
this._removeCameraChanged = null;
for (const off of this._cameraOffs.splice(0)) off();
this._unwatchNavigation?.();
this._layerStateProvider = null;
this._panelStateProvider = null;
+2 -1
View File
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import { ShareLinkManager } from './sharelink.js';
import { resolveLocale } from './i18n/locale.js';
import { persistLocale } from './i18n/index.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
function makeManager(href) {
globalThis.window = { location: { href, hash: new URL(href).hash } };
@@ -18,7 +19,7 @@ function makeManager(href) {
roll: 0,
},
};
return new ShareLinkManager(viewer);
return new ShareLinkManager(bindFakeCesiumCamera(viewer));
}
function installClipboard(writeText) {
+7 -6
View File
@@ -10,6 +10,7 @@ import {
decodeLayerStateParams,
} from './data/layerState.js';
import { LayerStateCoordinator } from './data/layerStateCoordinator.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e';
const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f';
@@ -102,7 +103,7 @@ function fakeViewer() {
test('a hash navigation to a different share restores it and holds URL writes', (t) => {
const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
const initial = manager.parseInitialHash();
manager.completeInitialRestore(initial.restoreToken);
const navigated = [];
@@ -124,7 +125,7 @@ test('a hash navigation to a different share restores it and holds URL writes',
test("the app's own hash writes never trigger a restore", (t) => {
installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
let restores = 0;
manager.watchNavigation(() => {
@@ -142,7 +143,7 @@ test("the app's own hash writes never trigger a restore", (t) => {
test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => {
const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
// The app rewrites entry 0 with its own state; Back must return to that.
manager._updateHash();
@@ -176,7 +177,7 @@ test('a newer navigation supersedes an older restore and its pending write', (t)
const tab = installTab(t, SHARE_A);
const timers = installManualTimers(t);
const manager = new ShareLinkManager(fakeViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
assert.equal(timers.size, 1, 'releasing suppression schedules a write');
const navigated = [];
@@ -204,7 +205,7 @@ test('a newer navigation supersedes an older restore and its pending write', (t)
test('destroy removes the navigation listeners', (t) => {
const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.parseInitialHash();
let restores = 0;
manager.watchNavigation(() => {
@@ -240,7 +241,7 @@ function restorationHarness(t, initialHash, navigationOverrides = {}) {
const restores = [];
let navigationGeneration = 0;
const manager = new ShareLinkManager(fakeViewer(), {
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()), {
onRestore: (state) => {
const gate = deferred();
restores.push({ state, gate });
+101
View File
@@ -0,0 +1,101 @@
/**
* A camera port for tests of code that reads the camera: a settable pose,
* a settable view rectangle, and events the test raises itself.
*/
import { createCameraEvents } from '../render/camera/events.js';
import { toDegrees } from '../geo/index.js';
import { bindRenderer, rendererOf } from '../render/rendererOf.js';
const DEFAULT_POSE = Object.freeze({
lon: 0,
lat: 0,
height: 1000,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
position: { x: 0, y: 0, z: 0 },
direction: { x: 0, y: 0, z: -1 },
up: { x: 0, y: 1, z: 0 },
right: { x: 1, y: 0, z: 0 },
fovy: Math.PI / 3,
aspectRatio: 1,
near: 1,
far: 5e8,
frameIsIdentity: true,
});
/**
* @param {{pose?: object, viewRectangle?: object|null}} [initial]
*/
export function fakeCameraPort({ pose = {}, viewRectangle = null } = {}) {
let current = { ...DEFAULT_POSE, ...pose };
let rect = viewRectangle;
const events = createCameraEvents({
pose: () => ({ ...current }),
report: (error) => {
throw error;
},
});
const counts = {};
const port = {
pose: () => ({ ...current }),
viewRectangle: () => (rect ? { ...rect } : null),
on(event, cb, options) {
const off = events.on(event, cb, options);
counts[event] = (counts[event] ?? 0) + 1;
let live = true;
return () => {
if (live) counts[event] -= 1;
live = false;
off();
};
},
};
return {
port,
/** Change the pose the port reports. */
setPose: (fields) => (current = { ...current, ...fields }),
setViewRectangle: (next) => (rect = next),
emit: (event) => events.emit(event),
/** Run the contract's `changed` measure, as a frame would. */
stepChanged: () => events.stepChanged(),
listenerCount: (event) => counts[event] ?? 0,
};
}
/**
* Bind to a fake viewer a camera port that reads its Cesium-shaped fake
* camera live (`positionCartographic`, `heading`, `pitch`, `roll`, and the
* `changed`/`moveStart`/`moveEnd` events it has), for tests whose code
* still moves that camera directly. Returns the viewer.
* @template {{camera: object}} V
* @param {V} viewer
* @returns {V}
*/
export function bindFakeCesiumCamera(viewer) {
if (rendererOf(viewer)) return viewer;
const camera = viewer.camera;
const port = {
pose() {
const c = camera.positionCartographic;
return {
...DEFAULT_POSE,
lon: c ? toDegrees(c.longitude) : NaN,
lat: c ? toDegrees(c.latitude) : NaN,
height: c ? c.height : NaN,
heading: camera.heading ?? 0,
pitch: camera.pitch ?? -Math.PI / 2,
roll: camera.roll ?? 0,
};
},
viewRectangle: () => null,
on(event, cb) {
const source = camera[event];
const off = source?.addEventListener?.(cb);
if (typeof off === 'function') return off;
return () => source?.removeEventListener?.(cb);
},
};
bindRenderer(viewer, { camera: port });
return viewer;
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { parseSharePath } from '../share/shareLinkPath.js';
import { decodeShareView } from '../share/viewCodec.js';
import { applicationPinia } from './vueRuntime.js';
import { useGlobeActionsStore } from './stores/globeActions.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
/** The decoded view of a `/share/<code>` link. */
const sharedState = (href) =>
@@ -28,7 +29,7 @@ function makeManager(href, { positioned = true } = {}) {
roll: 0,
},
};
return new ShareLinkManager(viewer);
return new ShareLinkManager(bindFakeCesiumCamera(viewer));
}
function installClipboard(writeText) {
+3 -2
View File
@@ -19,6 +19,7 @@ import {
followOffsetToEnu,
} from '../share/viewCodec.js';
import { entityRecord, withEntityRecord } from '../data/entityRecords.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const NOW_MS = Date.now();
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 };
@@ -127,7 +128,7 @@ function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
const clock = installManualTimers(t);
const viewer = fakeViewer();
const restores = [];
const manager = new ShareLinkManager(viewer, {
const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
onRestore: async (state) => restores.push(state),
isNavigationCurrent: () => true,
...managerOptions,
@@ -401,7 +402,7 @@ test('a shared vessel is selected once the vessel feed has it', async (t) => {
test('the share button links to /share/<code>, following the followed entity', (t) => {
installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2');
const manager = new ShareLinkManager(fakeViewer());
const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
// A fresh session's look, as the app reports it at start-up.
manager.onToggleChange(false, true, {
hudVisible: true,
+4 -10
View File
@@ -13,6 +13,8 @@ import { registerHooks } from 'node:module';
import { presentSelectedVessel } from '../../hudReadouts.js';
import { useHudStore } from '../stores/hud.js';
import IntelHudView from './IntelHudView.vue';
import { fakeCameraPort } from '../../testSupport/fakeCameraPort.mjs';
import { toDegrees as deg } from '../../geo/index.js';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
@@ -35,7 +37,6 @@ registerHooks({
const { IntelHUD } = await import('../../hud.js');
const listeners = () => ({ addEventListener() {}, removeEventListener() {} });
function mount(t) {
document.body.replaceChildren();
@@ -44,16 +45,9 @@ function mount(t) {
const app = createApp(IntelHudView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const hud = new IntelHUD(
{},
{
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
computeViewRectangle: () => undefined,
moveStart: listeners(),
moveEnd: listeners(),
},
},
{
camera: fakeCameraPort({ pose: { lat: deg(0.7), lon: deg(-1.2), height: 500 } }).port,
// No AI summary: the readouts are what this test renders.
summaryService: { configured: async () => false },
publish: (view) => store.publish(view),