mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
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:
1 parent
24bc8fce7e
commit
de0ff6cd2d
28 files changed
+688
-243
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user