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/operations.js",
"src/app/scene.js", "src/app/scene.js",
"src/render/cesium/appRenderer.js", "src/render/cesium/appRenderer.js",
"src/render/rendererOf.js",
"src/render/cesium/ports.js", "src/render/cesium/ports.js",
"src/render/camera/events.js",
"src/render/contract/index.js", "src/render/contract/index.js",
"src/sources/sourceSlot.js", "src/sources/sourceSlot.js",
"src/app/sources.js", "src/app/sources.js",
@@ -5275,6 +5277,7 @@
"src/geo/quaternion.js", "src/geo/quaternion.js",
"src/render/stub/motion.js", "src/render/stub/motion.js",
"src/render/stub/camera.js", "src/render/stub/camera.js",
"src/render/camera/events.js",
"src/render/stub/geodesic.js", "src/render/stub/geodesic.js",
"src/render/stub/viewRectangle.js", "src/render/stub/viewRectangle.js",
"src/render/stub/orbits.js", "src/render/stub/orbits.js",
+33 -33
View File
@@ -3,7 +3,7 @@ import { applicationServices } from './services/application.js';
* @module hud * @module hud
* @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic. * @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 * 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 * 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 * 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. * 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 { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js'; 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 * Displays MGRS/lat-lon, altitude and off-nadir readouts from the live
* camera, UTC clocks, an approximate sun elevation, the selected vessel and a * 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 { export class IntelHUD {
/** /**
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * @param {object} viewer - The scene the HUD sits over.
* camera telemetry and coordinate derivation. * @param {object} [options]
* @param {import('./render/contract/camera').CameraPort} [options.camera]
* Camera telemetry; default the viewer's renderer's camera.
*/ */
constructor( constructor(
viewer, viewer,
@@ -238,8 +241,11 @@ export class IntelHUD {
basemapContext = {}, basemapContext = {},
summaryService = applicationServices.summary, summaryService = applicationServices.summary,
publish = () => {}, publish = () => {},
camera = rendererOf(viewer)?.camera,
} = {}, } = {},
) { ) {
if (!camera) throw new Error('IntelHUD needs a camera port');
this._camera = camera;
this._publish = publish; this._publish = publish;
this.summaryService = summaryService; this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy; this.summaryPolicy = summaryPolicy;
@@ -278,7 +284,7 @@ export class IntelHUD {
// A) kick a real AI summary once the intro fly-to settles. // A) kick a real AI summary once the intro fly-to settles.
this._firstMetricsShown = false; this._firstMetricsShown = false;
this._firstSummaryKicked = 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 // 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 // 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 // VISIBLE HUD, never at construction, so a hidden HUD costs nothing — and
@@ -343,8 +349,10 @@ export class IntelHUD {
: { active: false, text: t('AIS: --') }, : { active: false, text: t('AIS: --') },
}), }),
); );
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); this._offCamera = [
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); camera.on('moveStart', this._onCameraMoveStart),
camera.on('moveEnd', this._onCameraMoveEnd),
];
this._offLocale = onLocaleChange(() => this._relocalize()); this._offLocale = onLocaleChange(() => this._relocalize());
this._startTimers(); this._startTimers();
} }
@@ -495,18 +503,17 @@ export class IntelHUD {
/** /**
* Derive all camera-based telemetry and push values to the DOM. * 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 * lat/lon DMS, GSD, NIIRS, sun elevation, off-nadir angle, and
* collection timestamp. Stores results in {@link _latestMetrics}. * collection timestamp. Stores results in {@link _latestMetrics}.
*/ */
_updateCameraData() { _updateCameraData() {
const camera = this.viewer.camera; const pose = this._camera.pose();
const cartographic = camera.positionCartographic; // No position yet: the readouts keep their last values.
if (!cartographic) return; if (![pose.lon, pose.lat, pose.height].every(Number.isFinite)) return;
const lonDeg = pose.lon;
const lonDeg = Cesium.Math.toDegrees(cartographic.longitude); const latDeg = pose.lat;
const latDeg = Cesium.Math.toDegrees(cartographic.latitude); const altM = pose.height;
const altM = cartographic.height;
const latDMS = this._toDMS(latDeg, 'lat'); const latDMS = this._toDMS(latDeg, 'lat');
const lonDMS = this._toDMS(lonDeg, 'lon'); const lonDMS = this._toDMS(lonDeg, 'lon');
let mgrsLabel = '---'; let mgrsLabel = '---';
@@ -563,7 +570,7 @@ export class IntelHUD {
// Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down), // 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. // 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); const ona = Math.max(0, 90 + pitchDeg);
// i18n-ignore: sensor codes // i18n-ignore: sensor codes
readouts.ona = `ONA: ${ona.toFixed(1)}°`; readouts.ona = `ONA: ${ona.toFixed(1)}°`;
@@ -670,19 +677,19 @@ export class IntelHUD {
const declination = const declination =
23.45 * 23.45 *
Math.sin( Math.sin(
Cesium.Math.toRadians( toRadians(
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81), (360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
), ),
); );
const latRad = Cesium.Math.toRadians(lat); const latRad = toRadians(lat);
const decRad = Cesium.Math.toRadians(declination); const decRad = toRadians(declination);
const haRad = Cesium.Math.toRadians(hourAngle); const haRad = toRadians(hourAngle);
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) // Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
const sinEl = const sinEl =
Math.sin(latRad) * Math.sin(decRad) + Math.sin(latRad) * Math.sin(decRad) +
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad); Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
// Clamp to [-1,1] to guard against floating-point drift before asin // 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. * dimensions, or null if the view rectangle cannot be computed.
*/ */
_viewWindowKm(latDeg) { _viewWindowKm(latDeg) {
const rect = this.viewer.camera.computeViewRectangle(); const rect = this._camera.viewRectangle();
if (!rect) return null; if (!rect) return null;
const north = Cesium.Math.toDegrees(rect.north); const { north, south, east, west } = rect;
const south = Cesium.Math.toDegrees(rect.south);
let east = Cesium.Math.toDegrees(rect.east);
let west = Cesium.Math.toDegrees(rect.west);
let lonSpan = Math.abs(east - west); let lonSpan = Math.abs(east - west);
// Handle antimeridian wrap: if span exceeds 180 deg, take the shorter arc // Handle antimeridian wrap: if span exceeds 180 deg, take the shorter arc
if (lonSpan > 180) lonSpan = 360 - lonSpan; if (lonSpan > 180) lonSpan = 360 - lonSpan;
const latSpan = Math.abs(north - south); const latSpan = Math.abs(north - south);
// 111 km/deg is the approximate surface distance per degree of latitude; // 111 km/deg is the approximate surface distance per degree of latitude;
// longitude distance is scaled by cos(lat) to account for meridian convergence. // longitude distance is scaled by cos(lat) to account for meridian convergence.
const widthKm = Math.max( const widthKm = Math.max(0, lonSpan * 111 * Math.cos(toRadians(latDeg)));
0,
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
);
const heightKm = Math.max(0, latSpan * 111); const heightKm = Math.max(0, latSpan * 111);
return { widthKm, heightKm }; return { widthKm, heightKm };
} }
@@ -1218,8 +1219,7 @@ export class IntelHUD {
clearInterval(this._timestampInterval); clearInterval(this._timestampInterval);
clearInterval(this._summaryInterval); clearInterval(this._summaryInterval);
clearInterval(this._summaryTypingInterval); clearInterval(this._summaryTypingInterval);
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart); for (const off of this._offCamera.splice(0)) off();
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe?.();
this._vesselUnsubscribe?.(); this._vesselUnsubscribe?.();
this._summaryRequest?.abort(); this._summaryRequest?.abort();
+64 -34
View File
@@ -25,6 +25,8 @@ import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module'; import { registerHooks } from 'node:module';
import { ensureGeoidReady } from './data/geoid.js'; 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'; const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({ registerHooks({
@@ -65,39 +67,29 @@ const SFO_ELLIPSOIDAL_M = -15;
function installHudEnvironment() { function installHudEnvironment() {
const { elements, publish, view } = hudReadouts(); const { elements, publish, view } = hudReadouts();
const previousDocument = globalThis.document; 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 = { globalThis.document = {
getElementById: (id) => elements.get(id) ?? null, getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [], querySelectorAll: () => [],
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
}; };
const viewer = { const viewer = {};
camera: { const cameraListeners = {
pitch: -Math.PI / 2, moveStart: () => camera.emit('moveStart'),
positionCartographic: { moveEnd: () => camera.emit('moveEnd'),
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() {},
},
},
}; };
return { return {
elements, elements,
viewer, viewer,
camera,
publish, publish,
view, view,
cameraListeners, cameraListeners,
@@ -204,7 +196,7 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
const env = installHudEnvironment(); const env = installHudEnvironment();
let hud; let hud;
try { 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 alt = () => env.elements.get('hud-alt').textContent;
const summary = () => env.elements.get('hud-summary').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(); const env = installHudEnvironment();
// London: N is +46 m, so the correction moves the readout DOWN. A sign flip // London: N is +46 m, so the correction moves the readout DOWN. A sign flip
// that happens to look right at SFO fails here. // that happens to look right at SFO fails here.
env.viewer.camera.positionCartographic.latitude = (51.5072 * Math.PI) / 180; env.camera.setPose({
env.viewer.camera.positionCartographic.longitude = (-0.1275 * Math.PI) / 180; lat: deg((51.5072 * Math.PI) / 180),
env.viewer.camera.positionCartographic.height = 100; lon: deg((-0.1275 * Math.PI) / 180),
height: 100,
});
let hud; let hud;
try { 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 hud._updateCameraData(); // cold: requests the grid, paints uncorrected
assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/); assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/);
await ensureGeoidReady(); await ensureGeoidReady();
@@ -293,7 +287,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
}; };
let hud; let hud;
try { 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'; let place = 'Yellowstone';
hud._summaryContext = async () => ({ placeLabels: [place] }); hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent; 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; let hud;
try { 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'] }); hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary'); const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve)); 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; let hud;
try { 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._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData(); hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve)); 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; let hud;
try { 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. // The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] }); hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
const element = env.elements.get('hud-summary'); 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; let hud;
try { 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._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData(); hud._updateCameraData();
await hud._updateSummary(true); await hud._updateSummary(true);
@@ -578,7 +572,7 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => {
const env = installHudEnvironment(); const env = installHudEnvironment();
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer, { publish: env.publish }); hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish });
hud.setVariant('minimal'); hud.setVariant('minimal');
assert.equal(hud.getVariant(), 'minimal'); assert.equal(hud.getVariant(), 'minimal');
hud.setVariant('operator'); hud.setVariant('operator');
@@ -589,3 +583,39 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => {
env.restore(); 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 assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module'; 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'; const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({ registerHooks({
@@ -65,22 +67,19 @@ test('the telemetry tick paints the carried value into both readouts', () => {
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
}; };
const viewer = { 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() {} },
},
};
let hud; let hud;
try { 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(); hud._updateCameraData();
const latlon = elements.get('hud-latlon').textContent; const latlon = elements.get('hud-latlon').textContent;
const bottom = elements.get('hud-bottom-line').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 assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module'; 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'; const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({ registerHooks({
@@ -41,13 +43,6 @@ function installHiddenPage() {
removeEventListener() {}, removeEventListener() {},
}; };
const viewer = { 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 { return {
alt: () => elements.get('hud-alt').textContent, 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(); const page = installHiddenPage();
let hud; let hud;
try { 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; hud._visible = true;
t.mock.timers.tick(250); 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 assert from 'node:assert/strict';
import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { hudReadouts } from './testSupport/hudReadouts.mjs';
import { registerHooks } from 'node:module'; 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'; const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({ registerHooks({
@@ -48,13 +50,6 @@ function installPage() {
clearInterval, clearInterval,
}; };
const viewer = { 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 { return {
viewer, viewer,
@@ -73,6 +68,7 @@ function createHud(t, summaryService) {
t.mock.timers.enable({ apis: ['setInterval'] }); t.mock.timers.enable({ apis: ['setInterval'] });
const page = installPage(); const page = installPage();
const hud = new IntelHUD(page.viewer, { const hud = new IntelHUD(page.viewer, {
camera: fakeCameraPort({ pose: { lat: deg(0.53), lon: deg(-1.7), height: 600 } }).port,
summaryService, summaryService,
publish: page.publish, publish: page.publish,
}); });
+3
View File
@@ -2,6 +2,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import './testSupport/stubMgrs.mjs'; import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.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 { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js'); const { setLocale } = await import('./i18n/index.js');
@@ -50,6 +52,7 @@ function createHud(
}; };
const sent = []; const sent = [];
const hud = new IntelHUD(viewer, { const hud = new IntelHUD(viewer, {
camera: fakeCameraPort({ pose: { lat: deg(0.53), lon: deg(-1.7), height: 600 } }).port,
publish, publish,
summaryService: { summaryService: {
configured: async () => true, configured: async () => true,
+2 -1
View File
@@ -42,6 +42,7 @@ import {
setScopeMaskFeather, setScopeMaskFeather,
} from './scopeMask.js'; } from './scopeMask.js';
import { ShareLinkManager } from './sharelink.js'; import { ShareLinkManager } from './sharelink.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
// Follow the UI wiring and its extracted preset definitions. // Follow the UI wiring and its extracted preset definitions.
const uiSource = readShellSource() const uiSource = readShellSource()
@@ -71,7 +72,7 @@ function managerForHash(hash) {
roll: 0, 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. * still missing.
* Until a family is ported, its code reaches the Cesium viewer through * Until a family is ported, its code reaches the Cesium viewer through
* `cesiumViewerOf`, never through a renderer member, so no engine type * `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 { createApplicationViewer } from '../../app/viewer.js';
import { CONTRACT_VERSION } from '../contract/index.js'; import { CONTRACT_VERSION } from '../contract/index.js';
import { bindRenderer } from '../rendererOf.js';
import { createCesiumInput } from './input.js'; import { createCesiumInput } from './input.js';
import { cesiumCapabilities, createCesiumPorts } from './ports.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js';
@@ -101,6 +103,7 @@ export function createCesiumRendererPlugin({
creditContainer.remove(); creditContainer.remove();
throw error; throw error;
} }
let unbind = () => {};
const renderer = Object.freeze({ const renderer = Object.freeze({
id: 'cesium', id: 'cesium',
contractVersion: CONTRACT_VERSION, contractVersion: CONTRACT_VERSION,
@@ -118,6 +121,7 @@ export function createCesiumRendererPlugin({
}, },
destroy() { destroy() {
try { try {
unbind();
input.dispose(); input.dispose();
ports.dispose(); ports.dispose();
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
@@ -127,6 +131,7 @@ export function createCesiumRendererPlugin({
}, },
}); });
viewers.set(renderer, viewer); viewers.set(renderer, viewer);
unbind = bindRenderer(viewer, renderer);
return renderer; return renderer;
}, },
}); });
+8 -3
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { CONTRACT_VERSION, checkRendererShape } from '../contract/index.js'; import { CONTRACT_VERSION, checkRendererShape } from '../contract/index.js';
import { rendererOf } from '../rendererOf.js';
import { import {
ReadonlySet, ReadonlySet,
cesiumRenderer, cesiumRenderer,
@@ -49,10 +50,14 @@ function fakeViewer(log, { failDestroy = false } = {}) {
return { return {
listeners, listeners,
scene: { scene: {
camera: {}, camera: {
moveStart: fakeEvent(listeners),
moveEnd: fakeEvent(listeners),
},
primitives: { add() {}, remove() {} }, primitives: { add() {}, remove() {} },
requestRender() {}, requestRender() {},
preUpdate: fakeEvent(listeners), preUpdate: fakeEvent(listeners),
postUpdate: fakeEvent(listeners),
preRender: fakeEvent(listeners), preRender: fakeEvent(listeners),
postRender: 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.throws(() => renderer.capabilities.clear(), TypeError);
assert.ok(!('viewer' in renderer), 'no engine object is a member'); assert.ok(!('viewer' in renderer), 'no engine object is a member');
assert.equal(cesiumViewerOf(renderer).canvas, renderer.canvas); 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.ok(viewer.listeners.size > 0, 'the ports follow the frame phases');
assert.equal(typeof renderer.draw.icons, 'function'); 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.resize();
renderer.destroy(); renderer.destroy();
assert.equal(viewer.listeners.size, 0, 'and stop on 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(log, ['resize', 'destroy']);
assert.deepEqual(doc.children, [], 'credits leave with the renderer'); assert.deepEqual(doc.children, [], 'credits leave with the renderer');
renderer.destroy(); renderer.destroy();
@@ -340,12 +347,10 @@ const EXPECTED_MISSING = [
'camera.lookAt', 'camera.lookAt',
'camera.matrices', 'camera.matrices',
'camera.move', 'camera.move',
'camera.on',
'camera.onCommand', 'camera.onCommand',
'camera.release', 'camera.release',
'camera.rotateAround', 'camera.rotateAround',
'camera.setFov', 'camera.setFov',
'camera.viewRectangle',
'camera.zoom', 'camera.zoom',
'draw.atlas', 'draw.atlas',
'draw.clampState', 'draw.clampState',
+1
View File
@@ -14,6 +14,7 @@ import { createCesiumSceneRenderer, pointerFeedOf } from './sceneRenderer.js';
/** @type {ReadonlySet<string>} */ /** @type {ReadonlySet<string>} */
export const CESIUM_CHECKS = new Set([ export const CESIUM_CHECKS = new Set([
'camera.viewRectangle',
'draw.drillPick', 'draw.drillPick',
'draw.ids', 'draw.ids',
'draw.models', 'draw.models',
+39 -1
View File
@@ -13,6 +13,7 @@
*/ */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { heightsFor } from '../../queries/heights.js'; import { heightsFor } from '../../queries/heights.js';
import { createCameraEvents } from '../camera/events.js';
import { import {
atEndOfTick, atEndOfTick,
billboardLoadError, billboardLoadError,
@@ -788,7 +789,23 @@ export function createCesiumPorts(widget, { capabilities }) {
models: (spec) => makeSet('models', { ...spec }, buildModels), 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 = { const cameraPort = {
on: (event, cb, options) => cameraEvents.on(event, cb, options),
setView(spec) { setView(spec) {
const d = spec.destination; const d = spec.destination;
if (d && 'rect' in d) if (d && 'rect' in d)
@@ -804,9 +821,29 @@ export function createCesiumPorts(widget, { capabilities }) {
}); });
scene.requestRender(); 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() { pose() {
const v = (c) => ({ x: c.x, y: c.y, z: c.z }); 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 { return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
position: v(camera.positionWC), position: v(camera.positionWC),
direction: v(camera.directionWC), direction: v(camera.directionWC),
up: v(camera.upWC), up: v(camera.upWC),
@@ -814,7 +851,7 @@ export function createCesiumPorts(widget, { capabilities }) {
heading: camera.heading, heading: camera.heading,
pitch: camera.pitch, pitch: camera.pitch,
roll: camera.roll, roll: camera.roll,
height: camera.positionCartographic.height, height: carto.height,
fovy: camera.frustum.fovy, fovy: camera.frustum.fovy,
aspectRatio: camera.frustum.aspectRatio, aspectRatio: camera.frustum.aspectRatio,
near: camera.frustum.near, 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. // 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(rendererListeners)) list.clear();
for (const list of Object.values(frameListeners)) list.clear(); for (const list of Object.values(frameListeners)) list.clear();
cameraEvents.clear();
for (const done of [...pendingRenders]) done(); for (const done of [...pendingRenders]) done();
// Late model loads find no host and destroy themselves. // Late model loads find no host and destroy themselves.
for (const record of sets.values()) record.host = null; for (const record of sets.values()) record.host = null;
+117 -1
View File
@@ -6,6 +6,7 @@ import {
createCesiumPorts, createCesiumPorts,
listenerList, listenerList,
} from './ports.js'; } from './ports.js';
import { updateCameraChanged } from './internals.js';
test('capabilities follow what the scene supports', () => { test('capabilities follow what the scene supports', () => {
assert.deepEqual(cesiumCapabilities({}), ['models']); assert.deepEqual(cesiumCapabilities({}), ['models']);
@@ -85,13 +86,14 @@ function fakeWidget(overrides = {}) {
const calls = []; const calls = [];
const primitives = []; const primitives = [];
const scene = { const scene = {
camera: {}, camera: { moveStart: event(), moveEnd: event() },
primitives: { primitives: {
add: (p) => primitives.push(p), add: (p) => primitives.push(p),
remove: (p) => primitives.splice(primitives.indexOf(p), 1), remove: (p) => primitives.splice(primitives.indexOf(p), 1),
}, },
requestRender() {}, requestRender() {},
preUpdate: event(), preUpdate: event(),
postUpdate: event(),
preRender: event(), preRender: event(),
postRender: event(), postRender: event(),
sampleHeightSupported: true, sampleHeightSupported: true,
@@ -266,6 +268,7 @@ function tickingWidget() {
scene._renderRequested = true; scene._renderRequested = true;
}, },
preUpdate: event('preUpdate'), preUpdate: event('preUpdate'),
postUpdate: event('postUpdate'),
preRender: event('preRender'), preRender: event('preRender'),
postRender: event('postRender'), 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'); assert.equal(ports.frame.diagnostics().targetFps, 60, 'the widget’s cap');
ports.dispose(); 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/osmOverlays.js",
"src/data/trackedCamera.js", "src/data/trackedCamera.js",
"src/data/trailRenderer.js", "src/data/trailRenderer.js",
"src/hud.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/layers/airQuality/index.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, IDENTITY,
PI_OVER_TWO, PI_OVER_TWO,
TWO_PI, TWO_PI,
acosClamped,
add, add,
clone, clone,
cross, cross,
@@ -44,6 +43,7 @@ import {
vec, vec,
zeroToTwoPi, zeroToTwoPi,
} from './math.js'; } from './math.js';
import { createCameraEvents } from '../camera/events.js';
import { geodesicAt } from './geodesic.js'; import { geodesicAt } from './geodesic.js';
const DEFAULT_FOV = Math.PI / 3; const DEFAULT_FOV = Math.PI / 3;
@@ -94,13 +94,8 @@ export function createStubCamera({
const near = 1; const near = 1;
const far = 5e8; const far = 5e8;
const listeners = { const events = createCameraEvents({ pose, report });
changed: [], const { emit, on, stepChanged } = events;
moveStart: [],
moveEnd: [],
followChanged: [],
flightEnd: [],
};
const commandHooks = []; const commandHooks = [];
const holds = new Map(); const holds = new Map();
let holdSeq = 0; let holdSeq = 0;
@@ -727,89 +722,6 @@ export function createStubCamera({
// --- Events ---------------------------------------------------------------- // --- 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) { function sameView(a, b) {
return ( return (
a && a &&
+5 -4
View File
@@ -22,6 +22,7 @@ import {
fromLegacyShareState, fromLegacyShareState,
toLegacyShareState, toLegacyShareState,
} from './legacyShareState.js'; } from './legacyShareState.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const DEG = Math.PI / 180; const DEG = Math.PI / 180;
@@ -47,7 +48,7 @@ function parseLegacy(hash) {
globalThis.window = { globalThis.window = {
location: { hash: body, href: `http://localhost/${body}` }, location: { hash: body, href: `http://localhost/${body}` },
}; };
const manager = new ShareLinkManager(stubViewer()); const manager = new ShareLinkManager(bindFakeCesiumCamera(stubViewer()));
const parsed = manager.parseInitialHash(); const parsed = manager.parseInitialHash();
manager.destroy(); manager.destroy();
return parsed; return parsed;
@@ -209,7 +210,7 @@ function generatedLegacyHash(random) {
const int = (min, max) => min + Math.floor(random() * (max - min + 1)); const int = (min, max) => min + Math.floor(random() * (max - min + 1));
globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager( const manager = new ShareLinkManager(
stubViewer({ bindFakeCesiumCamera(stubViewer({
position: { position: {
latitude: (int(-899999, 899999) / 1e4) * DEG, latitude: (int(-899999, 899999) / 1e4) * DEG,
longitude: (int(-1799999, 1799999) / 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), heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0),
pitch: int(-90, 0) * DEG, pitch: int(-90, 0) * DEG,
roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]),
}), })),
); );
const style = pick(random, VIEW_STYLES); const style = pick(random, VIEW_STYLES);
manager.onStyleChange(style); 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', () => { 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. // A key the converter does not list would silently drop out of share codes.
globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; 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'); const parsed = manager._parseShareHash('v=2&lat=1&lon=2');
assert.deepEqual( assert.deepEqual(
Object.keys(parsed).sort(), 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 { createDefaultLayerState } from './data/layerState.js';
import { parseSharePath } from './share/shareLinkPath.js'; import { parseSharePath } from './share/shareLinkPath.js';
import { decodeShareView } from './share/viewCodec.js'; import { decodeShareView } from './share/viewCodec.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
/** The decoded view of a copied `/share/<code>` link. */ /** The decoded view of a copied `/share/<code>` link. */
const sharedState = (href) => const sharedState = (href) =>
@@ -51,7 +52,7 @@ function makeManager(hash = '') {
roll: 0, roll: 0,
}, },
}; };
return new ShareLinkManager(viewer); return new ShareLinkManager(bindFakeCesiumCamera(viewer));
} }
function installClipboard(writeText) { 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; } }, scene: { requestRender() { calls.renders += 1; } },
}; };
const manager = new ShareLinkManager(viewer); const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer));
manager.applyState({ manager.applyState({
lat: 40.7669, lat: 40.7669,
lon: -73.9909, lon: -73.9909,
@@ -472,7 +473,7 @@ test('newer navigation suppresses delayed share camera while non-camera state st
flyTo() { flights += 1; }, flyTo() { flights += 1; },
}, },
}; };
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
onRestore: (state) => { restored = state; }, onRestore: (state) => { restored = state; },
isNavigationCurrent: () => false, isNavigationCurrent: () => false,
}); });
@@ -702,7 +703,7 @@ test('share apply completion waits for both callback work and camera settlement'
}, },
scene: { requestRender() {} }, scene: { requestRender() {} },
}; };
const manager = new ShareLinkManager(viewer, { onRestore: () => restoreGate }); const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), { onRestore: () => restoreGate });
let settled = false; let settled = false;
const applying = manager.applyState({ const applying = manager.applyState({
lat: 40, lon: -74, alt: 500, heading: 0, pitch: -30, roll: 0, 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() {} }, scene: { requestRender() {} },
}; };
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
isNavigationCurrent: (token) => token === generation, isNavigationCurrent: (token) => token === generation,
}); });
manager.applyState({ manager.applyState({
@@ -755,7 +756,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
}, },
scene: { requestRender() {} }, scene: { requestRender() {} },
}; };
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
isNavigationCurrent: (token) => token === generation, isNavigationCurrent: (token) => token === generation,
cancelOwnedNavigation: () => { cancellations += 1; flight?.cancel?.(); }, 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(cancellations, 1);
assert.equal(setViews, 0); assert.equal(setViews, 0);
const newerManager = new ShareLinkManager(viewer, { const newerManager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
isNavigationCurrent: (token) => token === generation, isNavigationCurrent: (token) => token === generation,
cancelOwnedNavigation: () => { cancellations += 1; }, cancelOwnedNavigation: () => { cancellations += 1; },
}); });
+20 -17
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { fromDegrees, toDegrees, toRadians } from './geo/index.js'; 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 { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
import { import {
migrateDetectionState, migrateDetectionState,
@@ -69,9 +70,12 @@ export class ShareLinkManager {
isNavigationCurrent, isNavigationCurrent,
cancelOwnedNavigation, cancelOwnedNavigation,
initialShareCode = null, initialShareCode = null,
camera = rendererOf(viewer)?.camera,
} = {}, } = {},
) { ) {
if (!camera) throw new Error('ShareLinkManager needs a camera port');
this.viewer = viewer; this.viewer = viewer;
this._camera = camera;
// A host may hand over the share code to open instead of the address. // A host may hand over the share code to open instead of the address.
this._initialShareCode = this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null; typeof initialShareCode === 'string' ? initialShareCode : null;
@@ -133,12 +137,13 @@ export class ShareLinkManager {
? cancelOwnedNavigation ? cancelOwnedNavigation
: null; : null;
// Listen for camera changes // The hash follows the camera as it moves, and catches up with where
this._removeCameraChanged = this.viewer.camera.changed.addEventListener( // it comes to rest.
() => { const schedule = () => this._scheduleUpdate();
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. */ /** Build a deterministic snapshot without mutating history. */
_buildHashParams() { _buildHashParams() {
if (this._destroyed) return null; if (this._destroyed) return null;
const camera = this.viewer.camera; const pose = this._camera.pose();
const carto = camera.positionCartographic; if (!Number.isFinite(pose.lat) || !Number.isFinite(pose.lon)) return null;
if (!carto) return null;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('v', '2'); params.set('v', '2');
params.set('lat', toDegrees(carto.latitude).toFixed(4)); params.set('lat', pose.lat.toFixed(4));
params.set('lon', toDegrees(carto.longitude).toFixed(4)); params.set('lon', pose.lon.toFixed(4));
params.set('alt', Math.round(carto.height).toString()); params.set('alt', Math.round(pose.height).toString());
params.set('heading', Math.round(toDegrees(camera.heading)).toString()); params.set('heading', Math.round(toDegrees(pose.heading)).toString());
params.set('pitch', Math.round(toDegrees(camera.pitch)).toString()); params.set('pitch', Math.round(toDegrees(pose.pitch)).toString());
params.set('roll', Math.round(toDegrees(camera.roll)).toString()); params.set('roll', Math.round(toDegrees(pose.roll)).toString());
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal'); params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
params.set('bloom', this._bloomEnabled ? '1' : '0'); params.set('bloom', this._bloomEnabled ? '1' : '0');
params.set('sharpen', this._sharpenEnabled ? '1' : '0'); params.set('sharpen', this._sharpenEnabled ? '1' : '0');
@@ -711,8 +715,7 @@ export class ShareLinkManager {
this._destroyed = true; this._destroyed = true;
clearTimeout(this._debounceTimer); clearTimeout(this._debounceTimer);
this._debounceTimer = null; this._debounceTimer = null;
this._removeCameraChanged?.(); for (const off of this._cameraOffs.splice(0)) off();
this._removeCameraChanged = null;
this._unwatchNavigation?.(); this._unwatchNavigation?.();
this._layerStateProvider = null; this._layerStateProvider = null;
this._panelStateProvider = null; this._panelStateProvider = null;
+2 -1
View File
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import { ShareLinkManager } from './sharelink.js'; import { ShareLinkManager } from './sharelink.js';
import { resolveLocale } from './i18n/locale.js'; import { resolveLocale } from './i18n/locale.js';
import { persistLocale } from './i18n/index.js'; import { persistLocale } from './i18n/index.js';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
function makeManager(href) { function makeManager(href) {
globalThis.window = { location: { href, hash: new URL(href).hash } }; globalThis.window = { location: { href, hash: new URL(href).hash } };
@@ -18,7 +19,7 @@ function makeManager(href) {
roll: 0, roll: 0,
}, },
}; };
return new ShareLinkManager(viewer); return new ShareLinkManager(bindFakeCesiumCamera(viewer));
} }
function installClipboard(writeText) { function installClipboard(writeText) {
+7 -6
View File
@@ -10,6 +10,7 @@ import {
decodeLayerStateParams, decodeLayerStateParams,
} from './data/layerState.js'; } from './data/layerState.js';
import { LayerStateCoordinator } from './data/layerStateCoordinator.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_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'; 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) => { test('a hash navigation to a different share restores it and holds URL writes', (t) => {
const tab = installTab(t, SHARE_A); const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer()); const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
const initial = manager.parseInitialHash(); const initial = manager.parseInitialHash();
manager.completeInitialRestore(initial.restoreToken); manager.completeInitialRestore(initial.restoreToken);
const navigated = []; 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) => { test("the app's own hash writes never trigger a restore", (t) => {
installTab(t, SHARE_A); installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer()); const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.completeInitialRestore(manager.parseInitialHash().restoreToken); manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
let restores = 0; let restores = 0;
manager.watchNavigation(() => { 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) => { test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => {
const tab = installTab(t, SHARE_A); const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer()); const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.completeInitialRestore(manager.parseInitialHash().restoreToken); manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
// The app rewrites entry 0 with its own state; Back must return to that. // The app rewrites entry 0 with its own state; Back must return to that.
manager._updateHash(); 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 tab = installTab(t, SHARE_A);
const timers = installManualTimers(t); const timers = installManualTimers(t);
const manager = new ShareLinkManager(fakeViewer()); const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.completeInitialRestore(manager.parseInitialHash().restoreToken); manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
assert.equal(timers.size, 1, 'releasing suppression schedules a write'); assert.equal(timers.size, 1, 'releasing suppression schedules a write');
const navigated = []; 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) => { test('destroy removes the navigation listeners', (t) => {
const tab = installTab(t, SHARE_A); const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer()); const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()));
manager.parseInitialHash(); manager.parseInitialHash();
let restores = 0; let restores = 0;
manager.watchNavigation(() => { manager.watchNavigation(() => {
@@ -240,7 +241,7 @@ function restorationHarness(t, initialHash, navigationOverrides = {}) {
const restores = []; const restores = [];
let navigationGeneration = 0; let navigationGeneration = 0;
const manager = new ShareLinkManager(fakeViewer(), { const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()), {
onRestore: (state) => { onRestore: (state) => {
const gate = deferred(); const gate = deferred();
restores.push({ state, gate }); 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 { decodeShareView } from '../share/viewCodec.js';
import { applicationPinia } from './vueRuntime.js'; import { applicationPinia } from './vueRuntime.js';
import { useGlobeActionsStore } from './stores/globeActions.js'; import { useGlobeActionsStore } from './stores/globeActions.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
/** The decoded view of a `/share/<code>` link. */ /** The decoded view of a `/share/<code>` link. */
const sharedState = (href) => const sharedState = (href) =>
@@ -28,7 +29,7 @@ function makeManager(href, { positioned = true } = {}) {
roll: 0, roll: 0,
}, },
}; };
return new ShareLinkManager(viewer); return new ShareLinkManager(bindFakeCesiumCamera(viewer));
} }
function installClipboard(writeText) { function installClipboard(writeText) {
+3 -2
View File
@@ -19,6 +19,7 @@ import {
followOffsetToEnu, followOffsetToEnu,
} from '../share/viewCodec.js'; } from '../share/viewCodec.js';
import { entityRecord, withEntityRecord } from '../data/entityRecords.js'; import { entityRecord, withEntityRecord } from '../data/entityRecords.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const NOW_MS = Date.now(); const NOW_MS = Date.now();
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; 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 clock = installManualTimers(t);
const viewer = fakeViewer(); const viewer = fakeViewer();
const restores = []; const restores = [];
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), {
onRestore: async (state) => restores.push(state), onRestore: async (state) => restores.push(state),
isNavigationCurrent: () => true, isNavigationCurrent: () => true,
...managerOptions, ...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) => { 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'); 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. // A fresh session's look, as the app reports it at start-up.
manager.onToggleChange(false, true, { manager.onToggleChange(false, true, {
hudVisible: true, hudVisible: true,
+4 -10
View File
@@ -13,6 +13,8 @@ import { registerHooks } from 'node:module';
import { presentSelectedVessel } from '../../hudReadouts.js'; import { presentSelectedVessel } from '../../hudReadouts.js';
import { useHudStore } from '../stores/hud.js'; import { useHudStore } from '../stores/hud.js';
import IntelHudView from './IntelHudView.vue'; 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'; const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({ registerHooks({
@@ -35,7 +37,6 @@ registerHooks({
const { IntelHUD } = await import('../../hud.js'); const { IntelHUD } = await import('../../hud.js');
const listeners = () => ({ addEventListener() {}, removeEventListener() {} });
function mount(t) { function mount(t) {
document.body.replaceChildren(); document.body.replaceChildren();
@@ -44,16 +45,9 @@ function mount(t) {
const app = createApp(IntelHudView).use(pinia); const app = createApp(IntelHudView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div'))); app.mount(document.body.appendChild(document.createElement('div')));
const hud = new IntelHUD( const hud = new IntelHUD(
{},
{ {
camera: { camera: fakeCameraPort({ pose: { lat: deg(0.7), lon: deg(-1.2), height: 500 } }).port,
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
computeViewRectangle: () => undefined,
moveStart: listeners(),
moveEnd: listeners(),
},
},
{
// No AI summary: the readouts are what this test renders. // No AI summary: the readouts are what this test renders.
summaryService: { configured: async () => false }, summaryService: { configured: async () => false },
publish: (view) => store.publish(view), publish: (view) => store.publish(view),