mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Hand features the engine instead of guessing it from a viewer
No code outside src/globe calls isGlobeEngine or engineFromViewer any more (14 calls left before this). The features that still sniffed take the engine from their caller: - Projection and surface helpers (focus de-emphasis, icon orientation, ground snap, the mesh floor sampler, ground picks) take the engine; the engine view's scene carries its projector instead of registering itself as a viewer. - Credits go only through engine.credits, so annotations and layers share one OSM credit state on Cesium too (it could show twice). - The annotation board and the resolver work on the engine: camera assists, visibility and picks go through engine.camera, engine.projector and src/geo, without Cesium. - The Recent Imagery box tool has one implementation for both engines, on capture-phase canvas pointer events with the layer's engine.ground preview; the Navara copy goes. On Cesium the preview outline is solid now, as on Navara. - Scene interactions take the engine, and the render governor has an engine variant beside the Cesium one. Remaining engine-specific features stay behind explicit seams until the contract covers them: the draw tool's Cesium surface (stock click actions cannot be suspended), the annotation drapes (animated materials), share follow, Cesium post effects and cyber sonar, and data packs (meshes). Coupling: Cesium-importing files fall from 31 to 27, `viewer` lines from 1367 to 1234, engine sniffing calls from 14 to 0.
This commit is contained in:
1 parent
406dd9122e
commit
f1ec9d4a22
61 files changed
+823
-1230
No files matched your search
@@ -2,15 +2,15 @@
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 330,
|
||||
"navaraLayerRendererLines": 2304,
|
||||
"cesiumImportFiles": 31,
|
||||
"viewerLines": 1367,
|
||||
"navaraKindChecks": 6,
|
||||
"engineSniffCalls": 14,
|
||||
"cesiumImportFiles": 27,
|
||||
"viewerLines": 1234,
|
||||
"navaraKindChecks": 5,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 12,
|
||||
"groundEffectPasses": 3,
|
||||
"cesiumSdkLoads": 4,
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 78
|
||||
"engineNameLines": 73
|
||||
},
|
||||
"measuredAt": "after engine.imagery with FIRMS heat and Recent Imagery on it, 2026-11-07"
|
||||
"measuredAt": "after the phase 7 feature moves, 2026-11-07"
|
||||
}
|
||||
@@ -1401,7 +1401,6 @@
|
||||
"scripts/fixtures/w2/radio.mjs",
|
||||
"scripts/qa-map-policy.mjs",
|
||||
"scripts/qa-map-policy.test.mjs",
|
||||
"src/ui/imageryBoxNavara.test.mjs",
|
||||
"scripts/qa-w2-imagery.mjs",
|
||||
"src/globe/navara/postSamplingPass.test.mjs",
|
||||
"src/globe/navara/emptySceneRender.test.mjs",
|
||||
|
||||
@@ -3415,7 +3415,6 @@
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/maps/sceneApply.js",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/ui/imageryBoxNavara.js",
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/navara/overlay.js",
|
||||
|
||||
@@ -6,9 +6,10 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
|
||||
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
return cameraOnlyEngine({
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
@@ -16,7 +17,7 @@ function viewerAt(lat, lon, height = 20_000) {
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
@@ -45,7 +46,7 @@ function harness(place = null) {
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine: viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
@@ -287,7 +288,7 @@ test('the engine keeps every part on the mark, drawn at once or upgraded', async
|
||||
destroy() {},
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'now'
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
@@ -22,16 +20,6 @@ import {
|
||||
} from '../sources/phaseTiming.js';
|
||||
import { formatDistanceText } from '../data/distanceFormat.js';
|
||||
|
||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||
// A getter, because the production build's Cesium global loads only with the
|
||||
// first Cesium globe.
|
||||
if (typeof window !== 'undefined' && !('__CESIUM__' in window))
|
||||
Object.defineProperty(window, '__CESIUM__', {
|
||||
configurable: true,
|
||||
get: () => Cesium,
|
||||
});
|
||||
|
||||
/**
|
||||
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
|
||||
*
|
||||
@@ -148,7 +136,7 @@ const DETACHED_RENDERER = Object.freeze({
|
||||
|
||||
export function createAnnotationEngine({
|
||||
placeSearch = defaultGeospatial,
|
||||
viewer = null,
|
||||
globe = null,
|
||||
renderer = DETACHED_RENDERER,
|
||||
outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||
resolveTarget = resolveAnnotationTarget,
|
||||
@@ -428,7 +416,7 @@ export function createAnnotationEngine({
|
||||
* @returns {Promise<{ok, drawn, failed, ids, results}>}
|
||||
*/
|
||||
async function annotate(requests, opts = {}) {
|
||||
if (destroyed || !viewer)
|
||||
if (destroyed || !globe)
|
||||
return {
|
||||
ok: false,
|
||||
drawn: 0,
|
||||
@@ -437,7 +425,7 @@ export function createAnnotationEngine({
|
||||
results: [],
|
||||
error: destroyed
|
||||
? 'destroyed'
|
||||
: 'Annotations are drawn on the Cesium globe only.',
|
||||
: 'Annotations are drawn on a globe only.',
|
||||
};
|
||||
const list = (Array.isArray(requests) ? requests : [requests]).map(
|
||||
promoteStreetHighlight,
|
||||
@@ -914,7 +902,7 @@ export function createAnnotationEngine({
|
||||
const type = normalizeType(spec?.type);
|
||||
// MANUAL geometry (drawTool.js): the person clicked the vertices, so there is
|
||||
// nothing to resolve. Same record shapes as a resolved spec, source 'manual'.
|
||||
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
|
||||
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, globe);
|
||||
if (type === 'route') {
|
||||
const points = Array.isArray(spec.points) ? spec.points : [];
|
||||
if (points.length < 2)
|
||||
@@ -925,7 +913,7 @@ export function createAnnotationEngine({
|
||||
const name = pt.target ?? pt.name ?? null;
|
||||
const r = await resolveTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine: globe,
|
||||
target: name,
|
||||
latitude: pt.latitude,
|
||||
longitude: pt.longitude,
|
||||
@@ -983,7 +971,7 @@ export function createAnnotationEngine({
|
||||
if (type === 'arrow') {
|
||||
const from = await resolveTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine: globe,
|
||||
target: spec.target,
|
||||
latitude: spec.latitude,
|
||||
longitude: spec.longitude,
|
||||
@@ -995,7 +983,7 @@ export function createAnnotationEngine({
|
||||
});
|
||||
const to = await resolveTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine: globe,
|
||||
target: spec.toTarget,
|
||||
latitude: spec.toLatitude,
|
||||
longitude: spec.toLongitude,
|
||||
@@ -1028,7 +1016,7 @@ export function createAnnotationEngine({
|
||||
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||
return resolveTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine: globe,
|
||||
target: spec.target,
|
||||
latitude: spec.latitude,
|
||||
longitude: spec.longitude,
|
||||
@@ -1669,7 +1657,7 @@ export function createAnnotationEngine({
|
||||
/** Fly the camera to one mark; a line is framed whole, not by its start. */
|
||||
function frame(id) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno || !viewer) return false;
|
||||
if (!anno || !globe) return false;
|
||||
if (anno.type === 'route' && anno.path?.length) {
|
||||
const ring = anno.path.map((p) => [p.lon, p.lat]);
|
||||
// Unwrap around the first vertex, as ringRange does, so a line across
|
||||
@@ -1739,7 +1727,7 @@ export function createAnnotationEngine({
|
||||
const cart = points.map((p) =>
|
||||
geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||
const sphere = geo.boundingSphere.fromPoints(cart);
|
||||
flyToSphere(sphere.center, sphere.radius, {
|
||||
pitch: geo.math.toRadians(-35),
|
||||
range: Math.max(900, sphere.radius * 2.6),
|
||||
@@ -1755,23 +1743,7 @@ export function createAnnotationEngine({
|
||||
function isPointOnScreen(p) {
|
||||
try {
|
||||
const pos = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
|
||||
if (isGlobeEngine(viewer)) return engineSeesPoint(viewer, pos);
|
||||
const camera = viewer.camera;
|
||||
const cv = camera.frustum.computeCullingVolume(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
camera.up,
|
||||
);
|
||||
if (
|
||||
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
|
||||
Cesium.Intersect.OUTSIDE
|
||||
)
|
||||
return false;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.position,
|
||||
);
|
||||
return occluder.isPointVisible(pos);
|
||||
return engineSeesPoint(globe, pos);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -1810,28 +1782,13 @@ export function createAnnotationEngine({
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly to a sphere keeping the camera's heading: Cesium's
|
||||
* flyToBoundingSphere on a viewer, the contract's flyToSphere on a globe
|
||||
* engine (the same offset semantics).
|
||||
* Fly to a sphere keeping the camera's heading (the contract's
|
||||
* flyToSphere: Cesium's flyToBoundingSphere offset).
|
||||
*/
|
||||
function flyToSphere(center, radius, { pitch, range, duration }) {
|
||||
if (isGlobeEngine(viewer)) {
|
||||
viewer.camera.flyToSphere(
|
||||
{ center, radius },
|
||||
{ heading: viewer.camera.pose().heading, pitch, range, duration },
|
||||
);
|
||||
return;
|
||||
}
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(center, radius),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
pitch,
|
||||
range,
|
||||
),
|
||||
duration,
|
||||
},
|
||||
globe.camera.flyToSphere(
|
||||
{ center, radius },
|
||||
{ heading: globe.camera.pose().heading, pitch, range, duration },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1861,7 +1818,7 @@ export function createAnnotationEngine({
|
||||
generation++;
|
||||
const previous = renderer;
|
||||
renderer = DETACHED_RENDERER;
|
||||
viewer = null;
|
||||
globe = null;
|
||||
for (const anno of annotations.values()) {
|
||||
anno.pendingOutline = false;
|
||||
try {
|
||||
@@ -1877,13 +1834,13 @@ export function createAnnotationEngine({
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Draw the board on a globe: `viewer` and the `renderer` built for it.
|
||||
* Marks kept while no globe drew them appear again as they were.
|
||||
* Draw the board on a globe: its `engine` and the `renderer` built for
|
||||
* it. Marks kept while no globe drew them appear again as they were.
|
||||
*/
|
||||
attachRenderer({ viewer: nextViewer, renderer: nextRenderer }) {
|
||||
attachRenderer({ engine: nextGlobe = null, renderer: nextRenderer }) {
|
||||
if (destroyed) return;
|
||||
engine.detachRenderer();
|
||||
viewer = nextViewer;
|
||||
globe = nextGlobe;
|
||||
renderer = nextRenderer ?? DETACHED_RENDERER;
|
||||
for (const anno of annotations.values()) {
|
||||
try {
|
||||
@@ -2093,7 +2050,7 @@ export function createAnnotationEngine({
|
||||
}) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
viewer?.camera.flyTo({
|
||||
globe?.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: {
|
||||
heading: geo.math.toRadians(heading),
|
||||
@@ -2371,7 +2328,7 @@ function manualPairs(list) {
|
||||
* (not synthesized) outline so it drapes solid; a line is a `route` with mode
|
||||
* 'manual', its length as distance and no travel time; a pin is a point.
|
||||
*/
|
||||
function resolveManualSpec(spec, type, viewer) {
|
||||
function resolveManualSpec(spec, type, globe) {
|
||||
if (type === 'route') {
|
||||
// height 0 is a placeholder, not a placement: the world renderer draws a
|
||||
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
|
||||
@@ -2412,7 +2369,7 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
return {
|
||||
lon,
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
height: sampleGroundHeight(globe, lon, lat),
|
||||
ring,
|
||||
...(polygons ? { polygons } : {}),
|
||||
footprintKind: 'area',
|
||||
@@ -2427,7 +2384,7 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
return {
|
||||
lon,
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
height: sampleGroundHeight(globe, lon, lat),
|
||||
ring: null,
|
||||
source: 'manual',
|
||||
};
|
||||
@@ -2490,8 +2447,8 @@ function appendDistance(baseLabel, distM) {
|
||||
|
||||
/**
|
||||
* Whether a globe engine shows a world point: in front of the camera, inside
|
||||
* the canvas and on the near side of the globe (what the Cesium path's
|
||||
* culling volume and ellipsoid occluder answer).
|
||||
* the canvas and on the near side of the globe (what Cesium's culling
|
||||
* volume and ellipsoid occluder answer).
|
||||
*/
|
||||
function engineSeesPoint(engine, position) {
|
||||
if (!engine.projector.horizonVisible(position)) return false;
|
||||
|
||||
@@ -241,7 +241,7 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight',
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
@@ -313,7 +313,7 @@ test('outline queue: clear drops queued-but-unstarted upgrades without a later f
|
||||
}),
|
||||
});
|
||||
engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
@@ -379,7 +379,7 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
target: 'PT2 rate-limit fixture',
|
||||
latitude: 12.345,
|
||||
longitude: 67.89,
|
||||
@@ -453,7 +453,7 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
target: 'PT2 transient fixture',
|
||||
latitude: -12.345,
|
||||
longitude: -67.89,
|
||||
@@ -603,8 +603,8 @@ test('outline upgrade updates the rendered element in place without remove/add',
|
||||
remove() { calls.remove += 1; },
|
||||
sync() {},
|
||||
};
|
||||
const viewer = {};
|
||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
|
||||
const globe = {};
|
||||
const engine = createAnnotationEngine({ globe, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
|
||||
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
|
||||
|
||||
const result = await engine.annotate([{
|
||||
@@ -715,7 +715,7 @@ test('fresh async building batch reserves slots together and releases failed/cle
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: h.renderer,
|
||||
resolveTarget: async ({ target }) => ({
|
||||
...BUILDING_FP,
|
||||
@@ -762,7 +762,7 @@ test('outline workers retain completion ownership and clear cancels resolved-but
|
||||
const h = controlledBuildingRenderer();
|
||||
const started = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: h.renderer,
|
||||
resolveTarget: async ({ target }) => ({
|
||||
...FIXED_POINT,
|
||||
@@ -805,7 +805,7 @@ test('building recaption timeout retains old route and a newer replacement owns
|
||||
installAnimationFrameStubs(t);
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: h.renderer,
|
||||
resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
@@ -860,7 +860,7 @@ test('replacement cleanup failure still removes the old route and retains failed
|
||||
installAnimationFrameStubs(t);
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
|
||||
await flushMicrotasks();
|
||||
@@ -905,7 +905,7 @@ test('identical refresh revokes an older building replacement even if cancellati
|
||||
installAnimationFrameStubs(t);
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
|
||||
await flushMicrotasks(); h.pending[0].finish(); await initial;
|
||||
@@ -936,7 +936,7 @@ test('successful replacement survives failed old-route retirement and keeps clea
|
||||
installAnimationFrameStubs(t);
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
|
||||
await flushMicrotasks(); h.pending[0].finish(); await initial;
|
||||
@@ -975,7 +975,7 @@ test('pending fresh predecessor failure cannot erase a newer building intent', a
|
||||
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
const first = engine.annotate([{ type: 'area', target: 'Capitol', label: 'First' }]);
|
||||
await flushMicrotasks();
|
||||
@@ -1004,7 +1004,7 @@ test('duplicate pending building results reflect final failure rather than a dea
|
||||
installAnimationFrameStubs(t);
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
const spec = { type: 'area', target: 'Capitol', label: 'Same' };
|
||||
const batch = engine.annotate([spec, spec]);
|
||||
@@ -1023,7 +1023,7 @@ test('separate duplicate callers observe the original building install outcome',
|
||||
for (const outcome of ['installed', 'failed', 'timeout', 'clear']) {
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: h.renderer,
|
||||
resolveTarget: async () => ({ ...BUILDING_FP }),
|
||||
});
|
||||
@@ -1077,7 +1077,7 @@ test('a duplicate joins the physical deferred install but not another outline lo
|
||||
const h = controlledBuildingRenderer();
|
||||
let lookups = 0;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: h.renderer,
|
||||
resolveTarget: async () => ({
|
||||
...FIXED_POINT,
|
||||
@@ -1119,7 +1119,7 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
|
||||
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
@@ -1173,7 +1173,7 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => {
|
||||
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
@@ -1237,7 +1237,7 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
|
||||
|
||||
const { renderer, failAddOnce, live } = partialStateRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
@@ -1278,7 +1278,7 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
|
||||
|
||||
const { renderer, failAddOnce, live } = partialStateRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
@@ -1335,7 +1335,7 @@ test('destroy releases the renderer and prevents subsequent annotation work', as
|
||||
renderer.destroy = () => {
|
||||
destroyed++;
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
const drawn = await engine.annotate([
|
||||
{ type: 'pin', longitude: -97.74, latitude: 30.27 },
|
||||
]);
|
||||
@@ -1360,7 +1360,7 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
|
||||
let release;
|
||||
let signal;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: (options) => {
|
||||
signal = options.signal;
|
||||
@@ -1385,9 +1385,10 @@ test('caller cancellation prevents a late annotation and camera mutation', async
|
||||
let resolverSignal;
|
||||
let flights = 0;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
globe: {
|
||||
camera: {
|
||||
flyToBoundingSphere() {
|
||||
pose: () => ({ heading: 0 }),
|
||||
flyToSphere() {
|
||||
flights += 1;
|
||||
},
|
||||
},
|
||||
@@ -1424,7 +1425,7 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
renderer.destroy = () => {};
|
||||
const received = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async (options) => {
|
||||
received.push(options);
|
||||
@@ -1448,7 +1449,7 @@ test('recaption inherits outline credit from borrowed geometry rather than its n
|
||||
const { renderer } = fakeRenderer();
|
||||
let first = true;
|
||||
const ring = [[-99,31],[-98.99,31],[-98.99,31.01],[-99,31]];
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer,
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer,
|
||||
resolveTarget: async () => {
|
||||
if (first) { first = false; return { ...FIXED_POINT, ring, footprintKind: 'area', outlineCredit: 'tiles' }; }
|
||||
return { ...FIXED_POINT, source: 'osm-local', resolveOutline: async () => null };
|
||||
@@ -1471,7 +1472,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
|
||||
globe: { camera: { pose: () => ({ heading: 0 }), flyToSphere: (sphere) => flights.push(sphere) } },
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
@@ -1540,10 +1541,10 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
globe: {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
||||
pose: () => ({ heading: 0 }),
|
||||
flyToSphere: (sphere) => flights.push(sphere),
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
@@ -1638,7 +1639,7 @@ test('hand-drawn marks: a voice mark or a hidden mark is not taken as their dupl
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
@@ -1691,7 +1692,7 @@ test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
@@ -1706,7 +1707,7 @@ test('a drawn line that rounds up to the next unit is labelled in it, not as 100
|
||||
installAnimationFrameStubs(t);
|
||||
const labels = [];
|
||||
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
for (const metres of [994, 996, 9940, 9960]) {
|
||||
await engine.annotate([
|
||||
@@ -1722,7 +1723,7 @@ test('hand-drawn marks: two unnamed pins close together are two pins, whatever t
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color });
|
||||
await engine.annotate([pin(-97.74, 'primary')], { persist: true });
|
||||
await engine.annotate([pin(-97.7402, 'primary')], { persist: true });
|
||||
@@ -1740,7 +1741,7 @@ test('hand-drawn marks: hiding a mark before its first frame takes it off at onc
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let syncs = 0;
|
||||
const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
const before = syncs;
|
||||
@@ -1759,7 +1760,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
|
||||
let renders = 0;
|
||||
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
await engine.annotate([
|
||||
{ type: 'pin', manual: true, latitude: 1, longitude: 1 },
|
||||
{ type: 'pin', manual: true, latitude: 2, longitude: 2 },
|
||||
@@ -1784,7 +1785,7 @@ test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off an
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
let syncs = 0;
|
||||
renderer.sync = () => (syncs += 1);
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
let changes = 0;
|
||||
@@ -1811,7 +1812,7 @@ test('hand-drawn marks: a mark shown again right after it was drawn appears at o
|
||||
});
|
||||
const tick = () => queued.splice(0).forEach((callback) => callback());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const engine = createAnnotationEngine({ globe: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
engine.setHidden(mark.id, true);
|
||||
@@ -1832,7 +1833,7 @@ test('removing one mark whose cleanup fails keeps it addressable for a retry', a
|
||||
calls.remove += 1;
|
||||
if (failRemove) throw new Error('renderer remove failed');
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: { camera: { heading: 0 } }, renderer });
|
||||
const engine = createAnnotationEngine({ globe: { camera: { pose: () => ({ heading: 0 }) } }, renderer });
|
||||
await engine.annotate([
|
||||
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp' },
|
||||
], { persist: true });
|
||||
@@ -1850,7 +1851,7 @@ test('a building install in the batch does not re-judge marks that never install
|
||||
installAnimationFrameStubs(t);
|
||||
const h = controlledBuildingRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: h.renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'Tower' ? { ...BUILDING_FP, lon: -97.7 } : { ...FIXED_POINT },
|
||||
@@ -1873,7 +1874,7 @@ test('plain recaption keeps the old mark addressable when its removal throws', a
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
await engine.annotate([{ type: 'point', target: 'Capitol', label: 'Capitol' }]);
|
||||
const old = engine.list()[0];
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
refineScope,
|
||||
isGroundsLikeAsk,
|
||||
} from './annotationResolver.js';
|
||||
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
|
||||
|
||||
// Build a square way of ~`areaM2` centred `dLatM`/`dLonM` metres from an anchor,
|
||||
// in Overpass `out geom` element shape ({ tags, geometry: [{lat,lon}...] }).
|
||||
@@ -157,7 +158,7 @@ test('refineScope: entityKind refines only an unresolved (auto) scope', () => {
|
||||
});
|
||||
|
||||
function closeViewportViewer() {
|
||||
return {
|
||||
return cameraOnlyEngine({
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: 30.2672 * Math.PI / 180,
|
||||
@@ -165,7 +166,7 @@ function closeViewportViewer() {
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function geocodePayload({ lat, lon, types, label }) {
|
||||
@@ -221,7 +222,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target: 'the Texas Capitol',
|
||||
});
|
||||
|
||||
@@ -245,7 +246,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target: 'state of Texas',
|
||||
});
|
||||
|
||||
@@ -286,7 +287,7 @@ for (const fixture of [
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target: fixture.target,
|
||||
});
|
||||
|
||||
@@ -312,7 +313,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target: 'Texas',
|
||||
});
|
||||
|
||||
@@ -351,7 +352,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
|
||||
|
||||
for (const target of fixtures.keys()) {
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target,
|
||||
});
|
||||
assert.ok(resolved);
|
||||
@@ -369,11 +370,11 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
|
||||
// Synthetic footprints reproduce the real Capitol/YOTEL selection without live APIs.
|
||||
const CAPITOL_ANCHOR = { lat: 38.8899389, lon: -77.0090505 };
|
||||
function capitolViewer() {
|
||||
return { camera: { positionCartographic: {
|
||||
return cameraOnlyEngine({ camera: { positionCartographic: {
|
||||
latitude: CAPITOL_ANCHOR.lat * Math.PI / 180,
|
||||
longitude: CAPITOL_ANCHOR.lon * Math.PI / 180,
|
||||
height: 1000,
|
||||
} } };
|
||||
} } });
|
||||
}
|
||||
function installCapitolMocks(t, elements, components = [
|
||||
{ long_name: 'Washington', types: ['locality', 'political'] },
|
||||
@@ -401,7 +402,7 @@ for (const [target, deferFootprint] of [
|
||||
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
|
||||
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' });
|
||||
installCapitolMocks(t, [hotel, capitol]);
|
||||
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint });
|
||||
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint });
|
||||
assert.ok(result);
|
||||
const outline = deferFootprint ? await result.resolveOutline() : result;
|
||||
assert.deepEqual(outline.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
||||
@@ -412,7 +413,7 @@ for (const [target, deferFootprint] of [
|
||||
test('address-only landmark without a matching outline keeps its geocoded point', async t => {
|
||||
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' });
|
||||
installCapitolMocks(t, [hotel]);
|
||||
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true });
|
||||
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true });
|
||||
assert.ok(result);
|
||||
assert.deepEqual([result.lat, result.lon], [CAPITOL_ANCHOR.lat, CAPITOL_ANCHOR.lon]);
|
||||
assert.equal(await result.resolveOutline(), null);
|
||||
@@ -422,7 +423,7 @@ test('missing address components do not turn a formatted city address into the l
|
||||
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
|
||||
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'YOTEL Washington DC' });
|
||||
installCapitolMocks(t, [hotel, capitol], []);
|
||||
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'United States Capitol building', footprint: true });
|
||||
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'United States Capitol building', footprint: true });
|
||||
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
||||
});
|
||||
|
||||
@@ -430,7 +431,7 @@ test('a genuine geocoded feature name still canonicalizes an alternate user name
|
||||
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
|
||||
const decoy = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'Congress meeting building' });
|
||||
installCapitolMocks(t, [decoy, capitol], [{ long_name: 'United States Capitol', types: ['landmark'] }]);
|
||||
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true });
|
||||
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'Congress meeting building', footprint: true });
|
||||
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
||||
});
|
||||
|
||||
@@ -479,7 +480,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target: 'Lady Bird Lake',
|
||||
});
|
||||
|
||||
@@ -534,7 +535,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
engine: closeViewportViewer(),
|
||||
target: 'the monument near the Texas Capitol, Austin',
|
||||
footprint: true,
|
||||
});
|
||||
@@ -581,11 +582,11 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
|
||||
});
|
||||
|
||||
const target = 'Waller Creek';
|
||||
assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null);
|
||||
assert.equal(await resolveAnnotationTarget({ engine: closeViewportViewer(), target }), null);
|
||||
const duringOutage = requests.length;
|
||||
|
||||
photonReachable = true;
|
||||
const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target });
|
||||
const retried = await resolveAnnotationTarget({ engine: closeViewportViewer(), target });
|
||||
|
||||
assert.ok(retried, 'the same target must resolve once the network is back');
|
||||
assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]);
|
||||
|
||||
@@ -47,7 +47,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as
|
||||
withAnimationFrames(t);
|
||||
const log = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: recordingRenderer(log, 'cesium'),
|
||||
resolveTarget: async () => ({ ...BUILDING }),
|
||||
});
|
||||
@@ -69,7 +69,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as
|
||||
|
||||
log.length = 0;
|
||||
engine.attachRenderer({
|
||||
viewer: {},
|
||||
engine: {},
|
||||
renderer: recordingRenderer(log, 'next'),
|
||||
});
|
||||
assert.deepEqual(log, ['next add Capitol']);
|
||||
@@ -83,13 +83,13 @@ test('detaching twice and attaching over an attached globe release each drawing
|
||||
withAnimationFrames(t);
|
||||
const log = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: recordingRenderer(log, 'a'),
|
||||
});
|
||||
engine.detachRenderer();
|
||||
engine.detachRenderer();
|
||||
engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'b') });
|
||||
engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'c') });
|
||||
engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'b') });
|
||||
engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'c') });
|
||||
assert.deepEqual(log, ['a destroy', 'b destroy']);
|
||||
engine.destroy();
|
||||
});
|
||||
+21
-14
@@ -67,15 +67,17 @@ const PREVIEW = DRAW_COLOR_CSS;
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* The globe it draws on is a Cesium `viewer` or a draw `surface` factory
|
||||
* (`surface({ session, cursor })`: the input and preview of a globe without
|
||||
* a viewer, src/annotations/drawInputNavara.js), which gets the open shape
|
||||
* and the rubber band's end as live getters.
|
||||
* @param {{viewer?: Cesium.Viewer, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* The globe it draws on is a Cesium `viewer` (with its `engine`, which
|
||||
* answers the picks) or a draw `surface` factory (`surface({ session,
|
||||
* cursor })`: the input and preview of a globe without a viewer,
|
||||
* src/annotations/drawInputNavara.js), which gets the open shape and the
|
||||
* rubber band's end as live getters.
|
||||
* @param {{viewer?: Cesium.Viewer, engine?: object, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({
|
||||
viewer = null,
|
||||
engine = null,
|
||||
surface: makeSurface = null,
|
||||
annotations,
|
||||
navigation = null,
|
||||
@@ -119,7 +121,7 @@ export function initDrawTool({
|
||||
const live = { session: () => session, cursor: () => cursor };
|
||||
const surface = makeSurface
|
||||
? makeSurface(live)
|
||||
: cesiumDrawSurface(viewer, live);
|
||||
: cesiumDrawSurface(viewer, engine, live);
|
||||
|
||||
const present = () =>
|
||||
publish({
|
||||
@@ -485,7 +487,7 @@ export function initDrawTool({
|
||||
* click actions on its own handler (select, track); they are borrowed while a
|
||||
* sketch is open and given back on the way out.
|
||||
*/
|
||||
function cesiumDrawSurface(viewer, live) {
|
||||
function cesiumDrawSurface(viewer, engine, live) {
|
||||
const previewEntities = [];
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -526,12 +528,15 @@ function cesiumDrawSurface(viewer, live) {
|
||||
});
|
||||
return {
|
||||
canvas: viewer.scene.canvas,
|
||||
/** The world point under a canvas position, or null. */
|
||||
/** The world point under a canvas position (the engine's picks), or null. */
|
||||
pick(position) {
|
||||
const canvas = viewer.scene.canvas;
|
||||
const w = canvas.clientWidth || canvas.width || 1;
|
||||
const h = canvas.clientHeight || canvas.height || 1;
|
||||
return pickWorldFromScreen(viewer, position.x / w, position.y / h);
|
||||
if (!engine) return null;
|
||||
const { width, height } = engine.host.size();
|
||||
return pickWorldFromScreen(
|
||||
engine,
|
||||
position.x / (width || 1),
|
||||
position.y / (height || 1),
|
||||
);
|
||||
},
|
||||
/** Show the open shape (null: none) in `color`. */
|
||||
sync(session, color) {
|
||||
@@ -657,8 +662,8 @@ function cesiumDrawSurface(viewer, live) {
|
||||
* The Draw control for the page session. The marks live on the annotation
|
||||
* board and the chosen shape and colour here; the drawing tool itself (its
|
||||
* preview, click handler and Escape/Enter keys) belongs to the attached
|
||||
* globe. `attachScene({ viewer, navigation })` starts the tool on a Cesium
|
||||
* viewer, `attachScene({ surface, navigation })` on a globe without one
|
||||
* globe. `attachScene({ viewer, engine, navigation })` starts the tool on
|
||||
* a Cesium viewer, `attachScene({ surface, navigation })` on a globe without one
|
||||
* (Navara: src/annotations/drawInputNavara.js), with the shape and colour
|
||||
* last chosen; `detachScene()` ends it, an open shape included. Without a
|
||||
* globe the controls answer nothing. Same handle as initDrawTool's
|
||||
@@ -731,6 +736,7 @@ export function createSessionDrawTool({
|
||||
whenSettled: () => inner?.whenSettled() ?? settled,
|
||||
attachScene({
|
||||
viewer = null,
|
||||
engine = null,
|
||||
surface = null,
|
||||
navigation: next = navigation,
|
||||
} = {}) {
|
||||
@@ -740,6 +746,7 @@ export function createSessionDrawTool({
|
||||
if (!viewer && !surface) return;
|
||||
inner = initDrawTool({
|
||||
viewer,
|
||||
engine,
|
||||
surface,
|
||||
annotations,
|
||||
navigation,
|
||||
|
||||
@@ -53,7 +53,7 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
'async function resolveSpec(spec, signal) {',
|
||||
);
|
||||
const manualAt = engine.indexOf(
|
||||
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);',
|
||||
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, globe);',
|
||||
resolveAt,
|
||||
);
|
||||
const routeAt = engine.indexOf("if (type === 'route') {", resolveAt);
|
||||
|
||||
@@ -24,9 +24,10 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
*/
|
||||
export function createHybridAnnotationRenderer(
|
||||
viewer,
|
||||
{ screen: screenOptions, world: worldRenderer, screenScene } = {},
|
||||
{ screen: screenOptions, world: worldRenderer, screenScene, engine } = {},
|
||||
) {
|
||||
const world = worldRenderer ?? createWorldAnnotationRenderer(viewer);
|
||||
const world =
|
||||
worldRenderer ?? createWorldAnnotationRenderer(viewer, { engine });
|
||||
const screen = createScreenAnnotationRenderer(
|
||||
screenScene ?? viewer,
|
||||
screenOptions,
|
||||
|
||||
@@ -5,11 +5,18 @@ import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { createCesiumCredits } from '../globe/cesium/credits.js';
|
||||
|
||||
/** The engine fakeViewer built for each viewer double (its credits). */
|
||||
const enginesOf = new WeakMap();
|
||||
|
||||
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||
function hybridWithBoard(viewer) {
|
||||
const store = useScreenAnnotationsStore(createPinia());
|
||||
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
|
||||
const renderer = createHybridAnnotationRenderer(viewer, {
|
||||
screen: { store },
|
||||
engine: enginesOf.get(viewer),
|
||||
});
|
||||
return { store, renderer };
|
||||
}
|
||||
|
||||
@@ -107,10 +114,7 @@ function fakeViewer(lon, lat) {
|
||||
scene.renderRequests += 1;
|
||||
},
|
||||
};
|
||||
return {
|
||||
dataSources,
|
||||
frames,
|
||||
viewer: {
|
||||
const viewer = {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
@@ -130,8 +134,14 @@ function fakeViewer(lon, lat) {
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// The Cesium engine's credits over the viewer's credit display.
|
||||
const engine = {
|
||||
credits: createCesiumCredits(viewer).credits,
|
||||
render: { request: () => scene.requestRender() },
|
||||
};
|
||||
enginesOf.set(viewer, engine);
|
||||
return { dataSources, frames, viewer, engine };
|
||||
}
|
||||
|
||||
function controlledFrames() {
|
||||
@@ -475,14 +485,14 @@ function workTrace() {
|
||||
|
||||
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { viewer, engine, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer, { engine });
|
||||
t.after(() => renderer.destroy());
|
||||
const first = buildingAnnotation('sampled-building');
|
||||
const second = buildingAnnotation('fallback-building', 0.01);
|
||||
second.outlineCredit = 'osm';
|
||||
const otherOwner = {};
|
||||
showOsmCredit(viewer, otherOwner);
|
||||
showOsmCredit(engine, otherOwner);
|
||||
const probes = [];
|
||||
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
|
||||
viewer.scene.clampToHeight = (position) => {
|
||||
@@ -583,7 +593,7 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
|
||||
'removing the last tile proxy retains the OSM building and other owner');
|
||||
renderer.remove(second);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
|
||||
hideOsmCredit(viewer, otherOwner);
|
||||
hideOsmCredit(engine, otherOwner);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
});
|
||||
|
||||
|
||||
+12
-10
@@ -12,22 +12,25 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
*/
|
||||
export function initAnnotations({
|
||||
viewer,
|
||||
engine: globe = null,
|
||||
tileset = null,
|
||||
placeSearch,
|
||||
resolver,
|
||||
}) {
|
||||
const engine = createSessionAnnotations({ placeSearch, resolver });
|
||||
engine.attachScene({ viewer, tileset });
|
||||
engine.attachScene({ viewer, engine: globe, tileset });
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's annotation board, kept across globe engine switches: the marks
|
||||
* belong to the page session, their drawing to the attached globe.
|
||||
* `attachScene({ viewer, tileset })` draws the board on a Cesium viewer;
|
||||
* `attachScene({ engine, renderer })` on a globe engine without a viewer
|
||||
* (Navara: src/annotations/navaraAnnotationRenderer.js, built by the caller
|
||||
* so this module never loads it); with neither the marks stay undrawn.
|
||||
* belong to the page session, their drawing to the attached globe. The board
|
||||
* works on the globe `engine` (camera, picks, surface); its drawing is the
|
||||
* `renderer` built for that engine (Navara:
|
||||
* src/annotations/navaraAnnotationRenderer.js, built by the caller so this
|
||||
* module never loads it), or on a Cesium `viewer` the hybrid renderer built
|
||||
* here. `attachScene({ engine, viewer, tileset })` or
|
||||
* `attachScene({ engine, renderer })`; with neither the marks stay undrawn.
|
||||
* `detachScene()` releases the drawing and keeps the marks.
|
||||
*/
|
||||
export function createSessionAnnotations({ placeSearch, resolver }) {
|
||||
@@ -41,8 +44,7 @@ export function createSessionAnnotations({ placeSearch, resolver }) {
|
||||
engine: globe = null,
|
||||
renderer = null,
|
||||
} = {}) => {
|
||||
if (!viewer && globe && renderer)
|
||||
return engine.attachRenderer({ viewer: globe, renderer });
|
||||
if (renderer) return engine.attachRenderer({ engine: globe, renderer });
|
||||
if (!viewer) return engine.detachRenderer();
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
if (tileset) {
|
||||
@@ -53,8 +55,8 @@ export function createSessionAnnotations({ placeSearch, resolver }) {
|
||||
}
|
||||
}
|
||||
engine.attachRenderer({
|
||||
viewer,
|
||||
renderer: createHybridAnnotationRenderer(viewer),
|
||||
engine: globe,
|
||||
renderer: createHybridAnnotationRenderer(viewer, { engine: globe }),
|
||||
});
|
||||
};
|
||||
engine.detachScene = () => engine.detachRenderer();
|
||||
|
||||
@@ -52,7 +52,7 @@ test('outlines sharing an outer ring but not their holes stay two marks', async
|
||||
footprintKind: 'area',
|
||||
});
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
@@ -108,7 +108,7 @@ test('the engine resolves a bare street highlight with its footprint', async (t)
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
@@ -138,7 +138,7 @@ test('a duplicate that fails to clean up never costs the new outline', async (t)
|
||||
const stuck = new Set();
|
||||
const outcomes = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: {
|
||||
...renderer,
|
||||
add(anno) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
createNominatimGate,
|
||||
} from '../../server/providers/regional/nominatimGate.js';
|
||||
import { createVoiceAreas } from '../voice/areaActions.js';
|
||||
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
|
||||
|
||||
const TILES = [6745, 6746].map((y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
@@ -924,15 +925,16 @@ const PLACES = {
|
||||
},
|
||||
};
|
||||
|
||||
const viewerAt = (lat, lon, height = 4000) => ({
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
const viewerAt = (lat, lon, height = 4000) =>
|
||||
cameraOnlyEngine({
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('default settings: outline, street and building asks send nothing to Overpass', async (t) => {
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
@@ -979,9 +981,9 @@ test('default settings: outline, street and building asks send nothing to Overpa
|
||||
const placeSearch = {
|
||||
geocode: async (q) => ({ place: PLACES[q] || null, answered: true }),
|
||||
};
|
||||
const resolve = async (viewer, args) => {
|
||||
const resolve = async (engine, args) => {
|
||||
const result = await resolver.resolveAnnotationTarget({
|
||||
viewer,
|
||||
engine,
|
||||
placeSearch,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
@@ -1000,7 +1002,7 @@ test('default settings: outline, street and building asks send nothing to Overpa
|
||||
});
|
||||
|
||||
const areas = createVoiceAreas({
|
||||
viewer: viewerAt(27.7, 85.3, 40_000),
|
||||
engine: viewerAt(27.7, 85.3, 40_000),
|
||||
annotationResolver: resolver,
|
||||
placeSearch,
|
||||
});
|
||||
|
||||
@@ -7,9 +7,10 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { polygonsContain } from '../data/adminBoundaries.js';
|
||||
import { streetFootprint } from './outlineRungs.js';
|
||||
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
return cameraOnlyEngine({
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
@@ -17,7 +18,7 @@ function viewerAt(lat, lon, height = 20_000) {
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
@@ -47,7 +48,7 @@ function harness(place = null, { outlineRungs } = {}) {
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine: viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
@@ -710,7 +711,7 @@ test('configured OSM geometry retains its origin through inline, deferred and ca
|
||||
});
|
||||
for (const deferFootprint of [false, true]) {
|
||||
const result = await resolver.resolveAnnotationTarget({
|
||||
viewer: viewerAt(31, -99, 3000),
|
||||
engine: viewerAt(31, -99, 3000),
|
||||
latitude: 31,
|
||||
longitude: -99,
|
||||
target: 'Library',
|
||||
|
||||
+66
-147
@@ -14,7 +14,6 @@ import {
|
||||
nearestPointOnLines,
|
||||
} from '../sources/featureGeometry.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
|
||||
import {
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
import { createOutlineRungs } from './outlineRungs.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
|
||||
/** Prepare only local dateline outlines for planar admission and centering. */
|
||||
function localOutlineFrame(fp) {
|
||||
@@ -180,7 +178,8 @@ export function createAnnotationResolver({
|
||||
* Resolve a single annotation target to a normalized world anchor.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Cesium.Viewer} opts.viewer
|
||||
* @param {object} [opts.engine] The globe engine (view bias, screen picks,
|
||||
* ground heights, credits).
|
||||
* @param {string} [opts.target] Place name to geocode.
|
||||
* @param {number} [opts.latitude] Explicit latitude (wins over target).
|
||||
* @param {number} [opts.longitude] Explicit longitude.
|
||||
@@ -202,7 +201,7 @@ export function createAnnotationResolver({
|
||||
*/
|
||||
async function resolveAnnotationTarget({
|
||||
placeSearch = unavailablePlaceSearch,
|
||||
viewer,
|
||||
engine,
|
||||
target,
|
||||
latitude,
|
||||
longitude,
|
||||
@@ -273,7 +272,7 @@ export function createAnnotationResolver({
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
|
||||
bundledCenter = center;
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
@@ -282,7 +281,7 @@ export function createAnnotationResolver({
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape = admin;
|
||||
else if (admin)
|
||||
return bundledAdminTarget(viewer, admin, trace.query, signal);
|
||||
return bundledAdminTarget(engine, admin, trace.query, signal);
|
||||
// Bundled US places and neighborhoods named at or near the view. A
|
||||
// campus, grounds or monument ask ("Stanford" as a compound, "the
|
||||
// Capitol grounds") wants that feature, not a district of its name,
|
||||
@@ -290,10 +289,10 @@ export function createAnnotationResolver({
|
||||
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
|
||||
const area = await bundledAreaNear(trace.query, center, {
|
||||
entityKind,
|
||||
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
|
||||
viewRadiusKm: viewportProximity(engine)?.radiusKm ?? null,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
|
||||
if (area) return bundledAdminTarget(engine, area, trace.query, signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +300,7 @@ export function createAnnotationResolver({
|
||||
const query = String(target || '').trim();
|
||||
if (query) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
|
||||
// Monument / grounds names scatter under Geocoding — try a view-biased Places Text Search FIRST.
|
||||
// A hit near the view centre is trusted (skips the proximity gate, like the osm-local snap); on a
|
||||
// miss we fall through to geocode + fetchLocalMonument below. The model's entityKind counts too:
|
||||
@@ -338,7 +337,7 @@ export function createAnnotationResolver({
|
||||
if (source !== 'places') {
|
||||
const geocoded = await geocodePlace(
|
||||
query,
|
||||
viewportBias(viewer),
|
||||
viewportBias(engine),
|
||||
signal,
|
||||
placeSearch,
|
||||
);
|
||||
@@ -411,7 +410,7 @@ export function createAnnotationResolver({
|
||||
Number.isFinite(Number(screenY))
|
||||
) {
|
||||
const picked = pickWorldFromScreen(
|
||||
viewer,
|
||||
engine,
|
||||
Number(screenX),
|
||||
Number(screenY),
|
||||
);
|
||||
@@ -424,7 +423,7 @@ export function createAnnotationResolver({
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return coarseCityHolds(bundledCenter)
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||||
? bundledAdminTarget(engine, coarseCityShape, trace.query, signal)
|
||||
: null;
|
||||
|
||||
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
|
||||
@@ -437,7 +436,7 @@ export function createAnnotationResolver({
|
||||
(isMonumentLikeQuery(target) || entityKind === 'point_feature')
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
|
||||
if (center) {
|
||||
const mon = await fetchLocalMonument(
|
||||
center.lat,
|
||||
@@ -470,7 +469,7 @@ export function createAnnotationResolver({
|
||||
// footprint resolution below re-checks its own recentered centroid against the same bound — a
|
||||
// polygon that would drag a good anchor out of plausibility is dropped (the mark stays an
|
||||
// honest point) rather than rejecting the whole annotation.
|
||||
const vpGate = viewportProximity(viewer);
|
||||
const vpGate = viewportProximity(engine);
|
||||
const gateDrift = (gLat, gLon) => {
|
||||
if (!vpGate) return null; // no camera info → pass
|
||||
const driftKm =
|
||||
@@ -491,7 +490,7 @@ export function createAnnotationResolver({
|
||||
);
|
||||
}
|
||||
return coarseCityHolds(bundledCenter)
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||||
? bundledAdminTarget(engine, coarseCityShape, trace.query, signal)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -581,7 +580,7 @@ export function createAnnotationResolver({
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (ne) {
|
||||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||||
registerDynamicCredit(engine, NATURAL_EARTH_CREDIT);
|
||||
traceBundledOutline(ne.name, 'natural-earth');
|
||||
const neCentroid = ringCentroid(ne.ring);
|
||||
return {
|
||||
@@ -594,7 +593,7 @@ export function createAnnotationResolver({
|
||||
lat: neCentroid?.lat ?? lat,
|
||||
lon: neCentroid?.lon ?? lon,
|
||||
height: sampleGroundHeight(
|
||||
viewer,
|
||||
engine,
|
||||
neCentroid?.lon ?? lon,
|
||||
neCentroid?.lat ?? lat,
|
||||
),
|
||||
@@ -642,7 +641,7 @@ export function createAnnotationResolver({
|
||||
signal?.throwIfAborted();
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape ||= admin;
|
||||
else if (admin) return bundledAdminOutline(viewer, admin, signal);
|
||||
else if (admin) return bundledAdminOutline(engine, admin, signal);
|
||||
}
|
||||
// A geocoded US city, town or CDP: the Census place that holds it.
|
||||
if (scope === 'city') {
|
||||
@@ -652,7 +651,7 @@ export function createAnnotationResolver({
|
||||
lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (place) return bundledAdminOutline(viewer, place, signal);
|
||||
if (place) return bundledAdminOutline(engine, place, signal);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
@@ -676,7 +675,7 @@ export function createAnnotationResolver({
|
||||
lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (wof) return bundledAdminOutline(viewer, wof, signal);
|
||||
if (wof) return bundledAdminOutline(engine, wof, signal);
|
||||
}
|
||||
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
|
||||
// returns a footprint, null (definitively no polygon), or undefined (transient
|
||||
@@ -875,7 +874,7 @@ export function createAnnotationResolver({
|
||||
...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}),
|
||||
lat: centroid.lat,
|
||||
lon: centroid.lon,
|
||||
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
|
||||
height: sampleGroundHeight(engine, centroid.lon, centroid.lat),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -891,11 +890,11 @@ export function createAnnotationResolver({
|
||||
pointLike,
|
||||
groundsLike,
|
||||
around: intent === 'around_the_thing',
|
||||
view: viewportProximity(viewer),
|
||||
view: viewportProximity(engine),
|
||||
signal,
|
||||
credit: (kind) =>
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
engine,
|
||||
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
|
||||
),
|
||||
};
|
||||
@@ -911,7 +910,7 @@ export function createAnnotationResolver({
|
||||
coarseCityHolds({ lat, lon }) &&
|
||||
!(result?.ring && !result.synthesized)
|
||||
)
|
||||
return bundledAdminOutline(viewer, coarseCityShape, signal);
|
||||
return bundledAdminOutline(engine, coarseCityShape, signal);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -939,7 +938,7 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
|
||||
const height = sampleGroundHeight(viewer, lon, lat);
|
||||
const height = sampleGroundHeight(engine, lon, lat);
|
||||
// One concise line per target so the resolution path is visible in the browser console.
|
||||
if (trace.query) {
|
||||
console.log(
|
||||
@@ -1004,13 +1003,13 @@ export function createAnnotationResolver({
|
||||
const COARSE_CITY_MAX_VERTICES = 32;
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
function creditAdminSource(engine, admin) {
|
||||
const credit = {
|
||||
'natural-earth': NATURAL_EARTH_CREDIT,
|
||||
'us-census': US_CENSUS_CREDIT,
|
||||
wof: WOF_CREDIT,
|
||||
}[admin.source];
|
||||
if (credit) registerDynamicCredit(viewer, credit);
|
||||
if (credit) registerDynamicCredit(engine, credit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1113,9 +1112,9 @@ export function createAnnotationResolver({
|
||||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||||
* centroid drift bound: the unit IS the asked scope.
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin, signal) {
|
||||
function bundledAdminOutline(engine, admin, signal) {
|
||||
signal?.throwIfAborted();
|
||||
creditAdminSource(viewer, admin);
|
||||
creditAdminSource(engine, admin);
|
||||
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
@@ -1128,7 +1127,7 @@ export function createAnnotationResolver({
|
||||
outlineSource: admin.source,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
height: sampleGroundHeight(engine, lon, lat),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1158,8 +1157,8 @@ export function createAnnotationResolver({
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query, signal) {
|
||||
const outline = bundledAdminOutline(viewer, admin, signal);
|
||||
function bundledAdminTarget(engine, admin, query, signal) {
|
||||
const outline = bundledAdminOutline(engine, admin, signal);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
@@ -2200,7 +2199,7 @@ export function createAnnotationResolver({
|
||||
return matches;
|
||||
}
|
||||
|
||||
// --- viewer helpers ---------------------------------------------------------
|
||||
// --- engine helpers ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Current view rectangle as a Google `bounds` string `swLat,swLng|neLat,neLng`,
|
||||
@@ -2216,12 +2215,9 @@ export function createAnnotationResolver({
|
||||
* Radius is scaled from camera height (a generous proxy for how much ground is on screen), with a
|
||||
* floor so a super-zoomed-in view doesn't over-reject genuinely nearby places.
|
||||
*/
|
||||
function viewportProximity(viewer) {
|
||||
function viewportProximity(engine) {
|
||||
try {
|
||||
// A globe engine (search) or a Cesium viewer (annotations until 1c).
|
||||
const carto = isGlobeEngine(viewer)
|
||||
? viewer.camera.pose().cartographic
|
||||
: viewer?.camera?.positionCartographic;
|
||||
const carto = engine?.camera.pose().cartographic;
|
||||
if (!carto) return null;
|
||||
const lat = geo.math.toDegrees(carto.latitude);
|
||||
const lon = geo.math.toDegrees(carto.longitude);
|
||||
@@ -2234,11 +2230,9 @@ export function createAnnotationResolver({
|
||||
}
|
||||
|
||||
/** Exported for searchAndFlyTo (src/locations.js), which shares this bias. */
|
||||
function viewportBias(viewer) {
|
||||
function viewportBias(engine) {
|
||||
try {
|
||||
const rect = isGlobeEngine(viewer)
|
||||
? viewer.camera.viewRectangle()
|
||||
: viewer?.camera?.computeViewRectangle?.();
|
||||
const rect = engine?.camera.viewRectangle();
|
||||
if (!rect) return null;
|
||||
const swLat = geo.math.toDegrees(rect.south).toFixed(4);
|
||||
const swLng = geo.math.toDegrees(rect.west).toFixed(4);
|
||||
@@ -2262,14 +2256,14 @@ export function createAnnotationResolver({
|
||||
* ({ lat, lon, label, types, viewport, distanceM, … }) or null.
|
||||
*/
|
||||
async function placesNearViewRecovery(
|
||||
viewer,
|
||||
engine,
|
||||
query,
|
||||
geocoded = null,
|
||||
signal = undefined,
|
||||
placeSearch = defaultGeospatial,
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
|
||||
if (!center) return null;
|
||||
const geocodeFar =
|
||||
!geocoded ||
|
||||
@@ -2313,9 +2307,28 @@ export function createAnnotationResolver({
|
||||
return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference);
|
||||
}
|
||||
|
||||
/** pickWorldFromScreen through a globe engine: the same cascade. */
|
||||
function enginePickWorldFromScreen(engine, nx, ny) {
|
||||
const { width: w, height: h } = engine.host.size();
|
||||
/**
|
||||
* Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into
|
||||
* a world lon/lat using the depth-aware pick cascade — the inverse of the
|
||||
* project-to-screen used by the renderers. This is the "point at the pixel"
|
||||
* fallback: the agent indicates a spot in the viewport screenshot when it can't
|
||||
* name the place, and we anchor the mark to the actual world point under it.
|
||||
*
|
||||
* Each stage is validated before it is accepted: a depth pick over empty sky
|
||||
* can return a NaN or centre-of-the-earth Cartesian. A degenerate pick IS a
|
||||
* missed pick, so it falls through to the next stage and ultimately to the
|
||||
* caller's null. The answer carries the height the pick landed on (a roof, a
|
||||
* hillside, or 0 on the ellipsoid): the draw tool's live preview needs it,
|
||||
* or the rubber band sinks to sea level while the pointer is on a hill.
|
||||
*/
|
||||
function pickWorldFromScreen(engine, nx, ny) {
|
||||
let size;
|
||||
try {
|
||||
size = engine?.host.size();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const { width: w, height: h } = size ?? {};
|
||||
if (!w || !h) return null;
|
||||
const screen = {
|
||||
x: Math.max(0, Math.min(1, nx)) * w,
|
||||
@@ -2343,107 +2356,13 @@ export function createAnnotationResolver({
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into
|
||||
* a world lon/lat using the depth-aware pick cascade — the inverse of the
|
||||
* project-to-screen used by the renderers. This is the "point at the pixel"
|
||||
* fallback: the agent indicates a spot in the viewport screenshot when it can't
|
||||
* name the place, and we anchor the mark to the actual world point under it.
|
||||
* Best-effort ground height at a coordinate. The globe is hidden behind
|
||||
* the Google 3D tiles, so we try to clamp onto the rendered surface; if the
|
||||
* tiles for that spot aren't loaded we fall back to the terrain, else the
|
||||
* ellipsoid (0).
|
||||
*/
|
||||
function pickWorldFromScreen(viewer, nx, ny) {
|
||||
if (isGlobeEngine(viewer)) return enginePickWorldFromScreen(viewer, nx, ny);
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return null;
|
||||
const canvas = scene.canvas;
|
||||
const w = canvas.clientWidth || canvas.width || 0;
|
||||
const h = canvas.clientHeight || canvas.height || 0;
|
||||
if (!w || !h) return null;
|
||||
const px = Math.max(0, Math.min(1, nx)) * w;
|
||||
const py = Math.max(0, Math.min(1, ny)) * h;
|
||||
const pos = new Cesium.Cartesian2(px, py);
|
||||
|
||||
// Each stage is validated before it is accepted: a depth pick over empty sky
|
||||
// can return a NaN or centre-of-the-earth Cartesian, and converting one of
|
||||
// those throws inside Cesium. A degenerate pick IS a missed pick, so it falls
|
||||
// through to the next stage and ultimately to the caller's null.
|
||||
let cart = null;
|
||||
if (
|
||||
scene.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
cart = scene.pickPosition(pos);
|
||||
} catch {
|
||||
cart = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(cart) &&
|
||||
typeof viewer.camera.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
cart = viewer.camera.pickEllipsoid(pos, Cesium.Ellipsoid.WGS84);
|
||||
} catch {
|
||||
cart = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(cart) &&
|
||||
typeof viewer.camera.getPickRay === 'function'
|
||||
) {
|
||||
try {
|
||||
const ray = viewer.camera.getPickRay(pos);
|
||||
cart = ray ? scene.globe?.pick(ray, scene) || null : null;
|
||||
} catch {
|
||||
cart = null;
|
||||
}
|
||||
}
|
||||
if (!isPickedWorldPosition(cart)) return null;
|
||||
const carto = geo.cartographic.fromCartesian(cart);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lat: geo.math.toDegrees(carto.latitude),
|
||||
lon: geo.math.toDegrees(carto.longitude),
|
||||
// The height the pick actually landed on — a roof, a hillside, or 0 on
|
||||
// the ellipsoid. Callers that only want a coordinate ignore it; the draw
|
||||
// tool's live preview needs it, or the rubber band sinks to sea level
|
||||
// while the pointer is on a hill.
|
||||
height: Number.isFinite(carto.height) ? carto.height : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort ground height at a coordinate. The Cesium globe is hidden behind
|
||||
* the Google 3D tiles, so we try to clamp onto the photoreal tile surface; if
|
||||
* the tiles for that spot aren't loaded we fall back to the ellipsoid (0).
|
||||
*/
|
||||
function sampleGroundHeight(viewer, lon, lat) {
|
||||
if (isGlobeEngine(viewer)) return engineGroundHeight(viewer, lon, lat);
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return 0;
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const carto = geo.cartographic.fromDegrees(lon, lat);
|
||||
const surface = geo.cartographic.toCartesian(carto);
|
||||
const clamped = scene.clampToHeight(surface);
|
||||
if (clamped) {
|
||||
const h = geo.cartographic.fromCartesian(clamped).height;
|
||||
if (Number.isFinite(h)) return h;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* tiles not ready — fall through */
|
||||
}
|
||||
const globeHeight = scene.globe?.getHeight?.(
|
||||
geo.cartographic.fromDegrees(lon, lat),
|
||||
);
|
||||
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
|
||||
}
|
||||
|
||||
/** sampleGroundHeight on a globe engine: the rendered surface, else terrain. */
|
||||
function engineGroundHeight(engine, lon, lat) {
|
||||
function sampleGroundHeight(engine, lon, lat) {
|
||||
if (!engine) return 0;
|
||||
try {
|
||||
if (engine.surface.clampToHeightSupported) {
|
||||
const clamped = engine.surface.clampToHeight(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// The view-centred search helpers read the camera through a globe engine as
|
||||
// well as through a viewer (src/locations.js searchAndFlyTo hands them the
|
||||
// engine).
|
||||
// The view-centred search helpers read the camera through the globe engine
|
||||
// (src/locations.js searchAndFlyTo and the annotation board hand it to them).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../geo/index.js';
|
||||
|
||||
@@ -47,7 +47,12 @@ const PALETTE = {
|
||||
* BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds.
|
||||
*/
|
||||
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {{ engine?: object }} [options] `engine`: the globe engine whose
|
||||
* credits show the OpenStreetMap attribution of the marks drawn from it.
|
||||
*/
|
||||
export function createWorldAnnotationRenderer(viewer, { engine = null } = {}) {
|
||||
const CLASSIFY = Cesium.ClassificationType.BOTH;
|
||||
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
@@ -522,7 +527,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}),
|
||||
);
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno);
|
||||
showOsmCredit(engine, anno);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
@@ -577,7 +582,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
) {
|
||||
// The actual world proxy owns both geometry and credit, including rollback.
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno, {
|
||||
showOsmCredit(engine, anno, {
|
||||
openMapTiles: anno.outlineCredit === 'tiles',
|
||||
});
|
||||
}
|
||||
@@ -600,7 +605,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
|
||||
function remove(anno) {
|
||||
pendingGround.get(anno)?.finish('cancelled');
|
||||
hideOsmCredit(viewer, anno);
|
||||
hideOsmCredit(engine, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
drawn.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
@@ -630,7 +635,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
for (const task of [...pendingGround.values()]) task.finish('cancelled');
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
for (const anno of osmAnnotations) hideOsmCredit(engine, anno);
|
||||
osmAnnotations.clear();
|
||||
drawn.clear();
|
||||
try {
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
} from './layers/renderers.js';
|
||||
import {
|
||||
governorRequestRender,
|
||||
installRenderGovernor,
|
||||
installEngineRenderGovernor,
|
||||
uninstallRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import { attachSessionVoice } from './sessionVoice.js';
|
||||
import { initNavaraImageryBoxTool } from '../ui/imageryBoxNavara.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import {
|
||||
attachNavaraAnnotations,
|
||||
attachNavaraDraw,
|
||||
@@ -368,7 +368,7 @@ export function attachViewerlessTools(
|
||||
});
|
||||
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
|
||||
if (recentImagery) {
|
||||
const tool = initNavaraImageryBoxTool({
|
||||
const tool = initImageryBoxTool({
|
||||
engine,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
onPreview: (box) => recentImagery.setBoxPreview(box),
|
||||
@@ -397,7 +397,7 @@ export function attachViewerlessTools(
|
||||
}
|
||||
// Holds drive engine.render.setContinuous; requests go to the engine. A
|
||||
// parked engine uninstalls it (createNavaraScene suspend).
|
||||
installRenderGovernor(engine);
|
||||
installEngineRenderGovernor(engine);
|
||||
const syncVisibility = () => {
|
||||
if (document.hidden && !isEmbeddedInline()) return;
|
||||
data.presentation.flushVisible();
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createApplicationScene } from './scene.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
installEngineRenderGovernor,
|
||||
isRenderGovernorInstalled,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
@@ -164,7 +164,7 @@ test('the Navara scene tears down the controller, then the globe', async (t) =>
|
||||
const run = await startScene();
|
||||
// The tools install the governor on the engine (src/app/tools.js); the
|
||||
// scene removes it before the engine goes.
|
||||
installRenderGovernor(run.engine);
|
||||
installEngineRenderGovernor(run.engine);
|
||||
const view = globalThis.window;
|
||||
await run.teardown();
|
||||
assert.equal(run.globeDestroyed(), 1);
|
||||
|
||||
+4
-3
@@ -275,7 +275,7 @@ function attachViewerTools(
|
||||
resolver: operations.annotationResolver,
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene({ viewer, tileset });
|
||||
annotations.attachScene({ viewer, engine, tileset });
|
||||
defer(() => annotations.detachScene());
|
||||
// UTILITIES ▸ Draw on this viewer (src/app/sessionTools.js): the tool
|
||||
// claims this viewer's pointer while a sketch is open.
|
||||
@@ -285,7 +285,7 @@ function attachViewerTools(
|
||||
styleManager,
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({ viewer, navigation: styleManager });
|
||||
drawTool?.attachScene({ viewer, engine, navigation: styleManager });
|
||||
defer(() => drawTool?.detachScene());
|
||||
// UTILITIES ▸ Import on this viewer: the next Cesium viewer reads the
|
||||
// session's files again. The drop listeners belong to this viewer.
|
||||
@@ -309,8 +309,9 @@ function attachViewerTools(
|
||||
if (recentImagery) {
|
||||
recentImagery.attachTileset(tileset);
|
||||
const imageryBoxTool = initImageryBoxTool({
|
||||
viewer,
|
||||
engine,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
onPreview: (box) => recentImagery.setBoxPreview(box),
|
||||
onCancel: (reason, message, box) => {
|
||||
if (message) recentImagery.reportBoxRefusal(message, box);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,16 @@ import {
|
||||
globeOutOfFrame,
|
||||
} from '../globeInView.js';
|
||||
|
||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||
// A getter, because the production build's Cesium global loads only with the
|
||||
// first Cesium globe.
|
||||
if (typeof window !== 'undefined' && !('__CESIUM__' in window))
|
||||
Object.defineProperty(window, '__CESIUM__', {
|
||||
configurable: true,
|
||||
get: () => Cesium,
|
||||
});
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
|
||||
|
||||
@@ -3569,7 +3569,7 @@ export function createBhoteKoshiEventLayer({
|
||||
else await _viewer.dataSources.add(_floodDataSource);
|
||||
if (!current()) return;
|
||||
}
|
||||
registerDynamicCredit(engine ?? viewer, BHOTE_KOSHI_CREDIT);
|
||||
registerDynamicCredit(engine, BHOTE_KOSHI_CREDIT);
|
||||
_scenePathPositionCache.clear();
|
||||
_lastCinematicProgress = null;
|
||||
const terrain = syncTerrainComparison();
|
||||
@@ -3640,7 +3640,7 @@ export function createBhoteKoshiEventLayer({
|
||||
try {
|
||||
const event = await loadEvent();
|
||||
throwIfEnableCancelled(signal, _enabled);
|
||||
registerDynamicCredit(_engine ?? viewer, BHOTE_KOSHI_CREDIT);
|
||||
registerDynamicCredit(_engine, BHOTE_KOSHI_CREDIT);
|
||||
if (_mapStackController) {
|
||||
_comparisonLease = acquireImageryComparison(_mapStackController, {
|
||||
owner: BHOTE_KOSHI_LAYER_ID,
|
||||
|
||||
@@ -291,7 +291,6 @@ export function createBhoteKoshiLocatorLayer({
|
||||
let _native = null;
|
||||
let _pausedApproach = null;
|
||||
const cameraForScene = () => _engine?.camera ?? _viewer?.camera;
|
||||
const creditTarget = () => _engine ?? _viewer;
|
||||
const requestRender = (reason) =>
|
||||
_engine?.kind === 'navara'
|
||||
? _engine.render.request(reason)
|
||||
@@ -1049,8 +1048,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
if (!_dataSource) return;
|
||||
if (_viewer) _viewer.dataSources.add(_dataSource);
|
||||
else if (_engine) _native = createNativeRenderer(_engine, _dataSource);
|
||||
registerDynamicCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_CREDIT);
|
||||
showOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
registerDynamicCredit(_engine, BHOTE_KOSHI_LOCATOR_CREDIT);
|
||||
showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
}
|
||||
|
||||
function attachRenderer(viewer, { engine = null } = {}) {
|
||||
@@ -1080,7 +1079,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
_native?.destroy();
|
||||
_native = null;
|
||||
if (_viewer && _dataSource) _viewer.dataSources.remove(_dataSource, false);
|
||||
hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
_engine = _viewer = null;
|
||||
}
|
||||
|
||||
@@ -1137,7 +1136,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
_pausedApproach = null;
|
||||
_enabled = false;
|
||||
_boundaryReady = false;
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as Cesium from 'cesium';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
|
||||
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
@@ -135,8 +136,8 @@ test('drawing bundled place and neighborhood outlines creates no Cesium label',
|
||||
3000,
|
||||
);
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
// The resolver reads only the camera (no scene to pick from).
|
||||
viewer: { camera: viewer.camera },
|
||||
// The resolver reads only the camera (no canvas to pick from).
|
||||
engine: cameraOnlyEngine({ camera: viewer.camera }),
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
|
||||
+13
-27
@@ -1,8 +1,5 @@
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
import { createCesiumCredits } from '../globe/cesium/credits.js';
|
||||
|
||||
/**
|
||||
* Per-layer data attribution registered into Cesium's credit display.
|
||||
* Per-layer data attribution registered into the globe engine's credits.
|
||||
*
|
||||
* Legal requirement (see DATA_SOURCES.md, findings H10/H11 in
|
||||
* docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can
|
||||
@@ -13,7 +10,7 @@ import { createCesiumCredits } from '../globe/cesium/credits.js';
|
||||
*
|
||||
* These credits are registered ONCE at init as STATIC credits with
|
||||
* showOnScreen=false, so they live in the expandable bottom-left "Data
|
||||
* attribution" lightbox (Cesium's credit popover) rather than cluttering the
|
||||
* attribution" lightbox (the engine's credit popover) rather than cluttering the
|
||||
* on-globe line. Always-present is intentional and reversible: the lightbox is
|
||||
* the app's canonical attribution surface and DATA_SOURCES.md is the
|
||||
* machine-readable index. Strings are copied verbatim from DATA_SOURCES.md — if
|
||||
@@ -49,29 +46,18 @@ export const ACARTIA_CREDIT = Object.freeze({
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
const googlePlacesDisplays = new WeakMap();
|
||||
const viewerCredits = new WeakMap();
|
||||
|
||||
/**
|
||||
* Credits of a globe engine, or of a Cesium viewer's credit display for
|
||||
* callers that still hold a viewer (Cesium layers); null without one.
|
||||
* Credits of a globe engine (engine.credits, shown on its next frame); null
|
||||
* without one.
|
||||
* @returns {{ addStatic: Function, request: () => void } | null}
|
||||
*/
|
||||
function creditsOf(target) {
|
||||
if (isGlobeEngine(target))
|
||||
return {
|
||||
addStatic: (html, options) => target.credits.addStatic(html, options),
|
||||
request: () => target.render.request('credits'),
|
||||
};
|
||||
if (typeof target?.creditDisplay?.addStaticCredit !== 'function') return null;
|
||||
let credits = viewerCredits.get(target);
|
||||
if (!credits) {
|
||||
credits = {
|
||||
addStatic: createCesiumCredits(target).credits.addStatic,
|
||||
request: () => target.scene?.requestRender?.(),
|
||||
};
|
||||
viewerCredits.set(target, credits);
|
||||
}
|
||||
return credits;
|
||||
function creditsOf(engine) {
|
||||
if (typeof engine?.credits?.addStatic !== 'function') return null;
|
||||
return {
|
||||
addStatic: (html, options) => engine.credits.addStatic(html, options),
|
||||
request: () => engine.render?.request('credits'),
|
||||
};
|
||||
}
|
||||
|
||||
function updateOsmDisplay(credits, state) {
|
||||
@@ -713,7 +699,7 @@ const dynamicCreditKeys = new WeakMap();
|
||||
* Register a conditional credit at the moment its data source activates.
|
||||
* Idempotent per `credit.key`; lands in the same "Data attribution" popover
|
||||
* as the static credits (showOnScreen=false).
|
||||
* @param {object} target — the globe engine, or the initialized Cesium viewer
|
||||
* @param {object} target — the globe engine
|
||||
* @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
|
||||
* @returns {boolean} True when the credit is (now) registered.
|
||||
*/
|
||||
@@ -730,10 +716,10 @@ export function registerDynamicCredit(target, credit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register every per-layer data credit into the viewer's credit display.
|
||||
* Register every per-layer data credit into the engine's credit display.
|
||||
* Idempotent: safe to call once at init. Credits are static and always
|
||||
* present in the "Data attribution" popover.
|
||||
* @param {object} target — the globe engine, or the initialized Cesium viewer
|
||||
* @param {object} target — the globe engine
|
||||
*/
|
||||
export function registerDataCredits(target, credits = DATA_CREDITS) {
|
||||
const display = creditsOf(target);
|
||||
|
||||
@@ -56,17 +56,25 @@ test('OpenStreetMap has one generic data credit with separate tile and names dis
|
||||
);
|
||||
});
|
||||
|
||||
// A Cesium engine's credits over a viewer double's credit display.
|
||||
async function cesiumCreditsEngine(viewer) {
|
||||
const { createCesiumCredits } = await import('../globe/cesium/credits.js');
|
||||
return {
|
||||
credits: createCesiumCredits(viewer).credits,
|
||||
render: { request() {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
const viewer = await cesiumCreditsEngine({
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
});
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
@@ -96,29 +104,31 @@ test('inline OSM attribution persists until its last display owner leaves', asyn
|
||||
assert.equal(visible.size, 1, 'reenabling restores the short credit');
|
||||
});
|
||||
|
||||
test('tile attribution follows source changes and viewers have independent ownership', async () => {
|
||||
test('tile attribution follows source changes and engines have independent ownership', async () => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const create = async () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
shown: credits,
|
||||
...(await cesiumCreditsEngine({
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
const a = await create(),
|
||||
b = await create();
|
||||
showOsmCredit(a, 'installations', { openMapTiles: true });
|
||||
showOsmCredit(b, 'installations');
|
||||
assert.equal(a.credits.size, 2);
|
||||
assert.equal(b.credits.size, 1);
|
||||
assert.equal(a.shown.size, 2);
|
||||
assert.equal(b.shown.size, 1);
|
||||
showOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
|
||||
assert.equal(a.shown.size, 1, 'wide names no longer use tiles');
|
||||
hideOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
assert.equal(a.shown.size, 0);
|
||||
assert.equal(b.shown.size, 1);
|
||||
});
|
||||
|
||||
test('data credits go through the globe engine credits', async () => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { worldToWindowCoordinates } from './render/cesiumProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// A window position through the engine's projector when `scene` has a
|
||||
// registered engine (a Cesium scene in the app, the Navara aircraft
|
||||
// renderer's scene adapter), else Cesium's SceneTransforms (bare scenes in
|
||||
// tests). The two agree on Cesium (the engine projector is SceneTransforms).
|
||||
// A window position through the engine projector the scene carries (the
|
||||
// layers' engine views, src/layers/shared/render/engineView.js), else
|
||||
// Cesium's SceneTransforms (a Cesium scene). The two agree on Cesium (the
|
||||
// engine projector is SceneTransforms).
|
||||
function worldToWindow(scene, position, result) {
|
||||
const projector = engineFromViewer(scene)?.projector;
|
||||
return projector
|
||||
? projector.worldToWindow(position, result)
|
||||
return scene.projector
|
||||
? scene.projector.worldToWindow(position, result)
|
||||
: worldToWindowCoordinates(scene, position, result);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,12 +163,9 @@ test('NearFarScalar rendered-size interpolation matches Cesium clamp endpoints',
|
||||
assert.ok(middle > 0.5 && middle < 3);
|
||||
});
|
||||
|
||||
test('a focus target projects through the engine registered for its scene', async () => {
|
||||
test('a focus target projects through the projector its scene carries', async () => {
|
||||
const { publishFocusTargetFromCachedPosition, clearFocusTarget } =
|
||||
await import('./focusDeemphasis.js');
|
||||
const { registerViewerEngine } = await import(
|
||||
'../globe/cesium/engineFromViewer.js'
|
||||
);
|
||||
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
|
||||
const engine = createFakeEngine();
|
||||
const projected = [];
|
||||
@@ -176,9 +173,11 @@ test('a focus target projects through the engine registered for its scene', asyn
|
||||
projected.push(position);
|
||||
return Object.assign(result, { x: 300, y: 200 });
|
||||
};
|
||||
// A scene with no Cesium behind it (the Navara renderer's adapter).
|
||||
const scene = { frameState: { frameNumber: 7 } };
|
||||
const unregister = registerViewerEngine(scene, engine);
|
||||
// A scene with no Cesium behind it (a layer's engine view).
|
||||
const scene = {
|
||||
frameState: { frameNumber: 7 },
|
||||
projector: engine.projector,
|
||||
};
|
||||
try {
|
||||
const target = publishFocusTargetFromCachedPosition({
|
||||
ownerLayer: 'flights',
|
||||
@@ -201,7 +200,6 @@ test('a focus target projects through the engine registered for its scene', asyn
|
||||
assert.equal(target.frameNumber, 7);
|
||||
} finally {
|
||||
clearFocusTarget('flights');
|
||||
unregister();
|
||||
engine.destroy();
|
||||
}
|
||||
});
|
||||
@@ -26,14 +26,12 @@ import {
|
||||
horizonOccluder,
|
||||
} from './render/cesiumProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// A window position through the engine's projector when `scene` has a
|
||||
// registered engine (engineFromViewer), else Cesium's SceneTransforms.
|
||||
// A window position through the engine projector the scene carries (the
|
||||
// layers' engine views), else Cesium's SceneTransforms (a Cesium scene).
|
||||
function worldToWindow(scene, position, result) {
|
||||
const projector = engineFromViewer(scene)?.projector;
|
||||
return projector
|
||||
? projector.worldToWindow(position, result)
|
||||
return scene.projector
|
||||
? scene.projector.worldToWindow(position, result)
|
||||
: worldToWindowCoordinates(scene, position, result);
|
||||
}
|
||||
|
||||
|
||||
@@ -746,13 +746,12 @@ test('the perspective helper is continuous through the sub-pixel band, not a 23
|
||||
);
|
||||
});
|
||||
|
||||
test('the exact projection asks the engine projector of a registered scene', async () => {
|
||||
test('the exact projection asks the engine projector its scene carries', async () => {
|
||||
const geo = await import('../geo/index.js');
|
||||
const { registerViewerEngine } = await import('../globe/cesium/engineFromViewer.js');
|
||||
const { perspectiveProjectedRotation: rotation } = await import('./iconOrientation.js');
|
||||
const scene = { camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } } };
|
||||
const asked = [];
|
||||
const unregister = registerViewerEngine({ scene }, {
|
||||
const scene = {
|
||||
camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } },
|
||||
projector: {
|
||||
// Screen-right for every point further east (the probe), so 90° on screen.
|
||||
worldToWindow(position, result = geo.cartesian2.create()) {
|
||||
@@ -760,12 +759,8 @@ test('the exact projection asks the engine projector of a registered scene', asy
|
||||
return Object.assign(result, { x: asked.length === 1 ? 100 : 120, y: 100 });
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null);
|
||||
assert.equal(asked.length, 2, 'the anchor and the probe');
|
||||
assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
};
|
||||
const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null);
|
||||
assert.equal(asked.length, 2, 'the anchor and the probe');
|
||||
assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`);
|
||||
});
|
||||
+7
-19
@@ -1,5 +1,3 @@
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
|
||||
/**
|
||||
* Validation for positions that came back from a Cesium screen pick.
|
||||
*
|
||||
@@ -54,28 +52,18 @@ export function isPickedWorldPosition(position) {
|
||||
* The world position under a screen point: the rendered surface where the
|
||||
* depth buffer has it, else the ellipsoid; null over the sky.
|
||||
*
|
||||
* @param {object} target The globe engine (`engine.picker.pickPosition`, then
|
||||
* `engine.camera.pickEllipsoid`), or a Cesium viewer from callers that
|
||||
* still hold one (`scene.pickPosition`, then `camera.pickEllipsoid`).
|
||||
* @param {object} engine The globe engine (`engine.picker.pickPosition`,
|
||||
* then `engine.camera.pickEllipsoid`); without one, null.
|
||||
* @param {{x: number, y: number}} screenPosition
|
||||
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
|
||||
*/
|
||||
export function pickGroundPosition(target, screenPosition) {
|
||||
if (isGlobeEngine(target)) {
|
||||
const { picker, camera } = target;
|
||||
return groundUnder(
|
||||
screenPosition,
|
||||
picker.pickPositionSupported ? picker : null,
|
||||
camera,
|
||||
);
|
||||
}
|
||||
const scene = target?.scene;
|
||||
export function pickGroundPosition(engine, screenPosition) {
|
||||
if (!engine) return null;
|
||||
const { picker, camera } = engine;
|
||||
return groundUnder(
|
||||
screenPosition,
|
||||
scene?.pickPositionSupported && typeof scene.pickPosition === 'function'
|
||||
? scene
|
||||
: null,
|
||||
typeof target?.camera?.pickEllipsoid === 'function' ? target.camera : null,
|
||||
picker.pickPositionSupported ? picker : null,
|
||||
camera,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,20 +162,6 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
|
||||
});
|
||||
|
||||
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
|
||||
const { pickGroundPosition } = await import('./scenePick.js');
|
||||
const ground = { x: 6378137, y: 0, z: 0 };
|
||||
const sky = { x: 500, y: 0, z: 0 };
|
||||
const viewer = (picked, ellipsoid) => ({
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: { pickEllipsoid: () => ellipsoid },
|
||||
});
|
||||
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
|
||||
assert.equal(pickGroundPosition(null, {}), null);
|
||||
});
|
||||
|
||||
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
|
||||
const { isSurfacePick } = await import('./scenePick.js');
|
||||
assert.equal(isSurfacePick(undefined), true);
|
||||
@@ -210,6 +196,9 @@ test('on a globe engine the ground is the picked surface, else the ellipsoid, ne
|
||||
unsupported.fake.script('pickEllipsoid', ground);
|
||||
assert.equal(pickGroundPosition(unsupported, screen), ground);
|
||||
assert.equal(unsupported.fake.callsOf('picker.pickPosition').length, 0);
|
||||
unsupported.fake.script('pickEllipsoid', undefined);
|
||||
assert.equal(pickGroundPosition(unsupported, screen), null);
|
||||
assert.equal(pickGroundPosition(null, screen), null, 'no engine');
|
||||
});
|
||||
|
||||
test('engine pick results classify like the Cesium picks they wrap', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||
import { createCctvDisplay } from '../layers/cctv/render/display.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createCesiumCredits } from '../globe/cesium/credits.js';
|
||||
import * as input from './inputOwnership.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||
|
||||
@@ -34,6 +35,11 @@ function viewer(t) {
|
||||
};
|
||||
return v;
|
||||
}
|
||||
// The Cesium engine's credits over the viewer double's credit display.
|
||||
const cesiumCredits = (v) => ({
|
||||
credits: createCesiumCredits(v).credits,
|
||||
render: { request: noop },
|
||||
});
|
||||
function assertCredit(v) {
|
||||
assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<')));
|
||||
}
|
||||
@@ -55,13 +61,13 @@ for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce th
|
||||
test('Nepal locator introduces the shared OSM credit', async (t) => {
|
||||
const v = viewer(t);
|
||||
const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop });
|
||||
await layer.init(v); await layer.enable(v, { origin: 'scene' });
|
||||
await layer.init(v, { engine: cesiumCredits(v) }); await layer.enable(v, { origin: 'scene' });
|
||||
assertCredit(v); await layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('voice route geometry introduces the shared OSM credit', (t) => {
|
||||
const v = viewer(t), renderer = createWorldAnnotationRenderer(v);
|
||||
const v = viewer(t), renderer = createWorldAnnotationRenderer(v, { engine: cesiumCredits(v) });
|
||||
renderer.add({ id: 'route', type: 'route', color: 'primary', anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] });
|
||||
assertCredit(v); renderer.destroy();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
|
||||
@@ -173,8 +173,8 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
|
||||
trackedEntity: undefined,
|
||||
entities: { remove() {} },
|
||||
};
|
||||
// The ground snap samples through the viewer's engine (engineFromViewer):
|
||||
// the engine surface over this stub's tileset and scene.sampleHeight.
|
||||
// The ground snap samples through the viewer's engine (the test
|
||||
// renderer's surfaceOwner, engineFromViewer): the engine surface over this stub's tileset and scene.sampleHeight.
|
||||
registerViewerEngine(viewer, {
|
||||
isDestroyed: () => false,
|
||||
surface: {
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
// rightWC, heading, pitch, roll, frustum.fovy) and `scene` scene-shaped
|
||||
// ({ camera, canvas.clientHeight, frameState.frameNumber }) for the shared
|
||||
// helpers that take them (iconOrientation, focusDeemphasis): adapters over
|
||||
// engine.camera.pose(), with the engine registered for the scene
|
||||
// (engineFromViewer) so projections go through its projector.
|
||||
// engine.camera.pose(), the scene carrying the engine's `projector` so
|
||||
// projections go through it.
|
||||
|
||||
/**
|
||||
* @typedef {object} AircraftView
|
||||
|
||||
@@ -1364,8 +1364,7 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape
|
||||
const fakes = boxToolFakes();
|
||||
const cancels = [];
|
||||
const tool = initImageryBoxTool({
|
||||
viewer: fakes.viewer,
|
||||
cesium: fakes.cesium,
|
||||
engine: fakes.engine,
|
||||
pickWorld: fakes.pickWorld,
|
||||
documentRef: fakes.documentRef,
|
||||
onBox: (box) => f.layer.setBox(box),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
|
||||
* responses, and the Cesium/viewer/document fakes the box tool needs.
|
||||
* responses, and the engine and document fakes the box tool needs.
|
||||
*/
|
||||
import { thumbnailOrder } from './model.js';
|
||||
|
||||
@@ -60,139 +60,116 @@ export function response({
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPES = {
|
||||
LEFT_DOWN: 'down',
|
||||
MOUSE_MOVE: 'move',
|
||||
LEFT_UP: 'up',
|
||||
LEFT_CLICK: 'click',
|
||||
LEFT_DOUBLE_CLICK: 'dblclick',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
|
||||
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
|
||||
*/
|
||||
export function boxToolFakes() {
|
||||
const handlers = [];
|
||||
class ScreenSpaceEventHandler {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.actions = new Map();
|
||||
this.destroyed = false;
|
||||
handlers.push(this);
|
||||
}
|
||||
setInputAction(fn, type) {
|
||||
this.actions.set(type, fn);
|
||||
}
|
||||
getInputAction(type) {
|
||||
return this.actions.get(type);
|
||||
}
|
||||
removeInputAction(type) {
|
||||
this.actions.delete(type);
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class CustomDataSource {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
const values = [];
|
||||
this.entities = {
|
||||
values,
|
||||
add: (entity) => (values.push(entity), entity),
|
||||
removeAll: () => values.splice(0),
|
||||
};
|
||||
}
|
||||
}
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const cesium = {
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType: EVENT_TYPES,
|
||||
CustomDataSource,
|
||||
CallbackProperty: class {},
|
||||
PolylineDashMaterialProperty: class {},
|
||||
ClassificationType: { BOTH: 'both' },
|
||||
Color: { fromCssColorString: () => color },
|
||||
Rectangle: { fromDegrees: () => ({}) },
|
||||
Cartesian3: { fromDegreesArray: (values) => values },
|
||||
};
|
||||
|
||||
const stock = new ScreenSpaceEventHandler('stock');
|
||||
const originalClick = () => 'select';
|
||||
const originalDouble = () => 'track';
|
||||
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
|
||||
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
renders: 0,
|
||||
scene: {
|
||||
canvas: { clientWidth: 1000, clientHeight: 500 },
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
requestRender: () => {
|
||||
viewer.renders += 1;
|
||||
},
|
||||
},
|
||||
screenSpaceEventHandler: stock,
|
||||
dataSources: {
|
||||
sources,
|
||||
add: (source) => (sources.push(source), Promise.resolve(source)),
|
||||
remove: (source) => sources.splice(sources.indexOf(source), 1),
|
||||
},
|
||||
};
|
||||
|
||||
/** A DOM event target that keeps its listeners by type. */
|
||||
function eventTarget(extra = {}) {
|
||||
const listeners = [];
|
||||
const classes = new Set();
|
||||
const documentRef = {
|
||||
return {
|
||||
listeners,
|
||||
classes,
|
||||
addEventListener: (type, fn) => listeners.push([type, fn]),
|
||||
removeEventListener: (type, fn) => {
|
||||
const index = listeners.findIndex((entry) => entry[1] === fn);
|
||||
const index = listeners.findIndex(
|
||||
(entry) => entry[0] === type && entry[1] === fn,
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
},
|
||||
dispatch(type, event) {
|
||||
for (const [name, fn] of [...listeners]) if (name === type) fn(event);
|
||||
return event;
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A pointer or key event that records whether it was consumed. */
|
||||
function domEvent(fields) {
|
||||
return {
|
||||
prevented: false,
|
||||
stopped: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
stopImmediatePropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Globe engine and document fakes for `initImageryBoxTool`. The canvas is
|
||||
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
|
||||
* `suspend: true` gives the engine input a counted suspendCameraInputs
|
||||
* (Navara's); without it the tool turns setCameraInputs off and on.
|
||||
*/
|
||||
export function boxToolFakes({ suspend = false } = {}) {
|
||||
const camera = { inputs: true, suspended: 0 };
|
||||
const captured = new Set();
|
||||
const canvas = eventTarget({
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
setPointerCapture: (id) => captured.add(id),
|
||||
hasPointerCapture: (id) => captured.has(id),
|
||||
releasePointerCapture: (id) => captured.delete(id),
|
||||
});
|
||||
const input = {
|
||||
setCameraInputs(enabled) {
|
||||
camera.inputs = Boolean(enabled);
|
||||
},
|
||||
};
|
||||
if (suspend)
|
||||
input.suspendCameraInputs = () => {
|
||||
camera.suspended += 1;
|
||||
return () => {
|
||||
camera.suspended -= 1;
|
||||
};
|
||||
};
|
||||
const engine = {
|
||||
host: { canvas, size: () => ({ width: 1000, height: 500 }) },
|
||||
input,
|
||||
};
|
||||
const windowRef = eventTarget();
|
||||
const classes = new Set();
|
||||
const documentRef = eventTarget({
|
||||
classes,
|
||||
defaultView: windowRef,
|
||||
body: {
|
||||
classList: {
|
||||
add: (name) => classes.add(name),
|
||||
remove: (name) => classes.delete(name),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pickWorld = (_viewer, nx, ny) =>
|
||||
});
|
||||
const pickWorld = (_engine, nx, ny) =>
|
||||
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
|
||||
/** The live (non-stock) canvas handler, if the tool has one bound. */
|
||||
const handler = () =>
|
||||
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
|
||||
const fire = (type, event) =>
|
||||
handler()?.actions.get(EVENT_TYPES[type])?.(event);
|
||||
const pointer = (at, fields = {}) =>
|
||||
domEvent({
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: at.x,
|
||||
clientY: at.y,
|
||||
...fields,
|
||||
});
|
||||
/** Pointer events as the browser sends them: a press on the canvas, moves and the release on the document. */
|
||||
const down = (at, fields) =>
|
||||
canvas.dispatch('pointerdown', pointer(at, fields));
|
||||
const move = (at, fields) =>
|
||||
documentRef.dispatch('pointermove', pointer(at, fields));
|
||||
const up = (at, fields) =>
|
||||
documentRef.dispatch('pointerup', pointer(at, fields));
|
||||
/** Dispatch a keydown to the tool's document listener. */
|
||||
const key = (name) => {
|
||||
const event = {
|
||||
key: name,
|
||||
prevented: false,
|
||||
stopped: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
stopImmediatePropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
listeners.find(([type]) => type === 'keydown')?.[1](event);
|
||||
return event;
|
||||
};
|
||||
const key = (name) =>
|
||||
documentRef.dispatch('keydown', domEvent({ key: name }));
|
||||
return {
|
||||
cesium,
|
||||
viewer,
|
||||
engine,
|
||||
canvas,
|
||||
camera,
|
||||
captured,
|
||||
documentRef,
|
||||
windowRef,
|
||||
pickWorld,
|
||||
handler,
|
||||
fire,
|
||||
down,
|
||||
move,
|
||||
up,
|
||||
key,
|
||||
types: EVENT_TYPES,
|
||||
originalClick,
|
||||
originalDouble,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// An engine-contract view shared by layer renderers and the celestial HUD:
|
||||
// `camera` camera-shaped and `scene`
|
||||
// scene-shaped adapters over engine.camera.pose() (refreshed once per frame,
|
||||
// and whenever the camera moved), with the engine registered for the scene
|
||||
// (engineFromViewer) so the shared helpers (iconOrientation, focus) project
|
||||
// through its projector; the frame number, frustum and horizon tests, the
|
||||
// window projection and the frame/camera events. No three, no Cesium.
|
||||
// and whenever the camera moved), the scene carrying the engine's projector
|
||||
// so the shared helpers (iconOrientation, focus) project through it; the
|
||||
// frame number, frustum and horizon tests, the window projection and the
|
||||
// frame/camera events. No three, no Cesium.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js';
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
@@ -87,9 +86,9 @@ export function createEngineLayerView(
|
||||
return frameNumber;
|
||||
},
|
||||
},
|
||||
// What the shared helpers project through.
|
||||
projector: engine.projector,
|
||||
};
|
||||
// Shared helpers project through the engine registered for their scene.
|
||||
removers.push(registerViewerEngine(scene, engine));
|
||||
const horizon = {
|
||||
isPointVisible: (position) => engine.projector.horizonVisible(position),
|
||||
};
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
// The view: `camera` camera-shaped (positionWC, positionCartographic,
|
||||
// rightWC, upWC, heading, pitch, roll, pickEllipsoid(screen)) and `scene`
|
||||
// scene-shaped ({ camera, canvas }) for the shared helpers that take them
|
||||
// (iconOrientation, the view-area request), with the engine registered for
|
||||
// the scene (engineFromViewer) so projections go through its projector.
|
||||
// (iconOrientation, the view-area request), the scene carrying the engine's
|
||||
// `projector` so projections go through it.
|
||||
|
||||
/**
|
||||
* @typedef {object} VesselView
|
||||
|
||||
@@ -10,11 +10,11 @@ export class MapStackController extends MapSourceController {
|
||||
const googleApiKey =
|
||||
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
// Sources for the page's engine: Navara builds its own from the
|
||||
// descriptions (src/maps/defaultSources.js).
|
||||
// descriptions (src/maps/defaultSources.js). Without an engine, Cesium's.
|
||||
const registry = createDefaultMapSources({
|
||||
...options,
|
||||
googleApiKey,
|
||||
engineKind: options.engine?.kind === 'navara' ? 'navara' : 'cesium',
|
||||
engineKind: options.engine?.kind ?? 'cesium',
|
||||
});
|
||||
super(viewer, {
|
||||
registry,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
|
||||
export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
|
||||
import {
|
||||
@@ -2727,7 +2726,8 @@ function createDevFacade() {
|
||||
* @param {import('../globe/contract.js').GlobeEngine} engine
|
||||
*/
|
||||
export function initWorldOverlay(engine) {
|
||||
if (!isGlobeEngine(engine))
|
||||
// What the host reads of the engine: a Cesium viewer has none of it.
|
||||
if (!engine?.projector || !engine.frames?.postRender || !engine.host)
|
||||
throw new TypeError('initWorldOverlay requires a globe engine');
|
||||
if (_engine === engine && _canvas && _removePostRender) return;
|
||||
if (_engine) destroyWorldOverlay();
|
||||
|
||||
@@ -410,7 +410,7 @@ test('annotation capability miss keeps its pin and exposes the unavailable label
|
||||
event,
|
||||
attempts = 0;
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
globe: {},
|
||||
renderer: {
|
||||
add() {},
|
||||
update(mark) {
|
||||
|
||||
+23
-17
@@ -38,12 +38,10 @@
|
||||
* `scene.requestRender()`. It needs no frame events (`engine.frames`).
|
||||
*
|
||||
* Any other globe engine (Navara) is the governor's backend instead: install
|
||||
* it on the engine and the owner-counted holds drive
|
||||
* `engine.render.setContinuous`, requests go to `engine.render.request`.
|
||||
* it on the engine (installEngineRenderGovernor) and the owner-counted holds
|
||||
* drive `engine.render.setContinuous`, requests go to `engine.render.request`.
|
||||
*/
|
||||
|
||||
import { isGlobeEngine } from './globe/contract.js';
|
||||
|
||||
let _viewer = null;
|
||||
/** The non-Cesium engine the governor drives, or null (Cesium: `_viewer`). */
|
||||
let _engine = null;
|
||||
@@ -75,22 +73,14 @@ function applyMode() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the governor on the viewer. Idempotent. Before install,
|
||||
* hold/release still record into the holds set (and apply at install time);
|
||||
* requests are safe no-ops — so modules can call all three unconditionally
|
||||
* in tests without a viewer.
|
||||
* @param {Cesium.Viewer | import('./globe/contract.js').GlobeEngine} viewer
|
||||
* The Cesium viewer, or a non-Cesium globe engine.
|
||||
* Install the governor on a Cesium viewer, as the Cesium engine's render
|
||||
* backend. Idempotent. Before install, hold/release still record into the
|
||||
* holds set (and apply at install time); requests are safe no-ops — so
|
||||
* modules can call all three unconditionally in tests without a viewer.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @returns {void}
|
||||
*/
|
||||
export function installRenderGovernor(viewer) {
|
||||
if (isGlobeEngine(viewer) && viewer.kind !== 'cesium') {
|
||||
_engine = viewer;
|
||||
_viewer = viewer;
|
||||
_installed = true;
|
||||
applyMode();
|
||||
return;
|
||||
}
|
||||
if (!viewer?.scene)
|
||||
throw new TypeError('installRenderGovernor requires a Cesium viewer');
|
||||
_engine = null;
|
||||
@@ -103,6 +93,22 @@ export function installRenderGovernor(viewer) {
|
||||
applyMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the governor on a globe engine that renders by itself (Navara):
|
||||
* the holds drive `engine.render.setContinuous`, requests go to
|
||||
* `engine.render.request`. Idempotent, like installRenderGovernor.
|
||||
* @param {import('./globe/contract.js').GlobeEngine} engine
|
||||
* @returns {void}
|
||||
*/
|
||||
export function installEngineRenderGovernor(engine) {
|
||||
if (typeof engine?.render?.setContinuous !== 'function')
|
||||
throw new TypeError('installEngineRenderGovernor requires a globe engine');
|
||||
_engine = engine;
|
||||
_viewer = engine;
|
||||
_installed = true;
|
||||
applyMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a continuous-render hold. Idempotent per owner.
|
||||
* Call where the owner's per-frame work BEGINS (scene listener installed,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
installEngineRenderGovernor,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
@@ -118,7 +119,7 @@ test('on a non-Cesium globe engine the governor drives engine.render with owner-
|
||||
_resetRenderGovernorForTest();
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
holdContinuousRender('before-install');
|
||||
installRenderGovernor(engine);
|
||||
installEngineRenderGovernor(engine);
|
||||
assert.equal(engine.render.isContinuous(), true, 'a hold taken before install applies');
|
||||
releaseContinuousRender('before-install');
|
||||
assert.equal(engine.render.isContinuous(), false);
|
||||
@@ -141,7 +142,7 @@ test('releasing an absent Navara hold does not wake an already idle engine', asy
|
||||
const { createFakeEngine } = await import('./globe/testing/fakeEngine.js');
|
||||
_resetRenderGovernorForTest();
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
installRenderGovernor(engine);
|
||||
installEngineRenderGovernor(engine);
|
||||
engine.fake.resetCalls();
|
||||
for (let i = 0; i < 22; i++) releaseContinuousRender('cctv-projection');
|
||||
assert.equal(engine.fake.callsOf('render.request').length, 0);
|
||||
|
||||
@@ -116,7 +116,7 @@ export class SceneDirector {
|
||||
});
|
||||
this._sharing = createSceneSharing(this);
|
||||
this._interactionTransitions = 0;
|
||||
this._interactions = createSceneInteractions(viewer, {
|
||||
this._interactions = createSceneInteractions({
|
||||
engine,
|
||||
available: () =>
|
||||
!this._destroyed && !this._running && !this.viewer?.trackedEntity,
|
||||
@@ -255,7 +255,7 @@ export class SceneDirector {
|
||||
});
|
||||
this._cameraHandoffUnsubscribe?.();
|
||||
this._removeCameraInput?.();
|
||||
this._interactions.setScene(null, null);
|
||||
this._interactions.setScene(null);
|
||||
this._dataPacks.setScene(null, null);
|
||||
this._flight?.suspend();
|
||||
this.engine = this.viewer = null;
|
||||
@@ -268,7 +268,7 @@ export class SceneDirector {
|
||||
this._isMapStackAvailable = isAvailable;
|
||||
try {
|
||||
this._dataPacks.setScene(viewer, native, engine.host.container);
|
||||
this._interactions.setScene(viewer, engine, this._dataPacks.getTargets());
|
||||
this._interactions.setScene(engine, this._dataPacks.getTargets());
|
||||
} catch (error) {
|
||||
// Release any packs already drawn before the caller parks the failed globe.
|
||||
this.suspendScene();
|
||||
|
||||
+16
-27
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { nextTick } from 'vue';
|
||||
import { isPointerFree } from '../data/inputOwnership.js';
|
||||
import { createInteractionSession } from '../director/interactions/session.js';
|
||||
@@ -9,21 +8,19 @@ import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
/**
|
||||
* Own the active shot's pick handler, accessible actions and plain-text
|
||||
* feedback. The Scene actions panel is presented to the scene interactions
|
||||
* store, which its view renders inside the viewer's container.
|
||||
* store, which its view renders inside the globe engine's container. A
|
||||
* feature is picked through engine.input (a click with its pick).
|
||||
*/
|
||||
// Keys each activation's panel, so a new one never reuses an old one's DOM.
|
||||
let activations = 0;
|
||||
|
||||
export function createSceneInteractions(
|
||||
viewer,
|
||||
{
|
||||
execute,
|
||||
engine = null,
|
||||
available = () => true,
|
||||
store = useSceneInteractionsStore(applicationPinia()),
|
||||
},
|
||||
) {
|
||||
let container = engine?.host?.container ?? viewer?.container;
|
||||
export function createSceneInteractions({
|
||||
execute,
|
||||
engine = null,
|
||||
available = () => true,
|
||||
store = useSceneInteractionsStore(applicationPinia()),
|
||||
}) {
|
||||
let container = engine?.host?.container;
|
||||
let panel = null,
|
||||
handler,
|
||||
items = [],
|
||||
@@ -79,7 +76,7 @@ export function createSceneInteractions(
|
||||
return ok;
|
||||
}
|
||||
function bindInput(owner) {
|
||||
if (!engine && !viewer) return;
|
||||
if (!engine) return;
|
||||
const click = (event) => {
|
||||
if (
|
||||
panel?.owner !== owner ||
|
||||
@@ -88,9 +85,7 @@ export function createSceneInteractions(
|
||||
!available()
|
||||
)
|
||||
return;
|
||||
const picked = engine
|
||||
? event.picked?.id
|
||||
: viewer.scene.pick(event.position)?.id;
|
||||
const picked = event.picked?.id;
|
||||
const matches = items.filter(
|
||||
(item) =>
|
||||
targets.get(
|
||||
@@ -108,32 +103,26 @@ export function createSceneInteractions(
|
||||
void nextTick(() => {
|
||||
if (panel?.owner !== owner || !shown()) return;
|
||||
const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id;
|
||||
(engine?.host.container ?? viewer?.container)
|
||||
engine?.host.container
|
||||
?.querySelector?.(
|
||||
`[data-director-interactions] [data-director-action="${id}"]`,
|
||||
)
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
if (engine) {
|
||||
handler = { destroy: engine.input.on('click', click, { pick: true }) };
|
||||
} else {
|
||||
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(click, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
handler = { destroy: engine.input.on('click', click, { pick: true }) };
|
||||
}
|
||||
return {
|
||||
clear,
|
||||
destroy: clear,
|
||||
getState: session.getState,
|
||||
setScene(nextViewer, nextEngine, registered) {
|
||||
setScene(nextEngine, registered) {
|
||||
handler?.destroy();
|
||||
handler = null;
|
||||
viewer = nextViewer;
|
||||
engine = nextEngine;
|
||||
if (registered) targets = registered;
|
||||
if (!panel || (!engine && !viewer)) return;
|
||||
container = engine?.host?.container ?? viewer?.container ?? container;
|
||||
if (!panel || !engine) return;
|
||||
container = engine.host?.container ?? container;
|
||||
update({ container });
|
||||
bindInput(panel.owner);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/scenes/interactionsView.test.mjs — the Scene actions panel renders in
|
||||
// the viewer's container from what the scene interactions owner presents.
|
||||
// the engine's container from what the scene interactions owner presents.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -8,6 +8,7 @@ import { createPinia } from 'pinia';
|
||||
import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js';
|
||||
import SceneInteractions from '../ui/views/panels/SceneInteractions.vue';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
const target = (featureId) => ({ packId: 'qa', featureId });
|
||||
const key = (featureId) => JSON.stringify(['qa', featureId]);
|
||||
@@ -36,25 +37,25 @@ function fixture(t, { execute = async () => true } = {}) {
|
||||
const app = createApp(SceneInteractions).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const executed = [];
|
||||
const interactions = createSceneInteractions(
|
||||
{ container, scene: { canvas, pick: () => null } },
|
||||
{
|
||||
execute: (action, signal) => {
|
||||
executed.push(action.type);
|
||||
return execute(action, signal);
|
||||
},
|
||||
store: useSceneInteractionsStore(pinia),
|
||||
const engine = createFakeEngine({ container, canvas });
|
||||
const interactions = createSceneInteractions({
|
||||
engine,
|
||||
execute: (action, signal) => {
|
||||
executed.push(action.type);
|
||||
return execute(action, signal);
|
||||
},
|
||||
);
|
||||
store: useSceneInteractionsStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
interactions.destroy();
|
||||
app.unmount();
|
||||
engine.destroy();
|
||||
});
|
||||
const $ = (selector) => container.querySelector(selector);
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
return { interactions, container, executed, $, settle, pinia };
|
||||
return { interactions, engine, container, executed, $, settle, pinia };
|
||||
}
|
||||
|
||||
test('a shot with actions shows its panel in the viewer container', async (t) => {
|
||||
@@ -175,11 +176,12 @@ test('a button reused before the next render still belongs to its own panel', as
|
||||
});
|
||||
|
||||
test("an earlier owner leaves a later owner's panel alone", async (t) => {
|
||||
const { interactions, container, $, settle, pinia } = fixture(t);
|
||||
const later = createSceneInteractions(
|
||||
{ container, scene: { canvas: $('canvas'), pick: () => null } },
|
||||
{ execute: async () => true, store: useSceneInteractionsStore(pinia) },
|
||||
);
|
||||
const { interactions, engine, $, settle, pinia } = fixture(t);
|
||||
const later = createSceneInteractions({
|
||||
engine,
|
||||
execute: async () => true,
|
||||
store: useSceneInteractionsStore(pinia),
|
||||
});
|
||||
t.after(() => later.destroy());
|
||||
interactions.activate(SHOT, new Map([[key('point'), {}]]));
|
||||
later.activate(SHOT, new Map([[key('point'), {}]]));
|
||||
@@ -190,3 +192,28 @@ test("an earlier owner leaves a later owner's panel alone", async (t) => {
|
||||
await settle();
|
||||
assert.equal(later.getState().selected, 'fly');
|
||||
});
|
||||
|
||||
test('a click on a feature of the shot outlines its actions through engine.input', async (t) => {
|
||||
const { interactions, engine, $, settle } = fixture(t);
|
||||
const feature = { kind: 'pack-feature' };
|
||||
interactions.activate(SHOT, new Map([[key('point'), feature]]));
|
||||
await settle();
|
||||
engine.fake.script('pick', { id: { kind: 'other' } });
|
||||
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||
await settle();
|
||||
assert.deepEqual(interactions.getState().outlined ?? [], []);
|
||||
assert.equal(
|
||||
$('[data-director-action="card"]').style.outline,
|
||||
'',
|
||||
'another object selects nothing',
|
||||
);
|
||||
engine.fake.script('pick', { id: feature });
|
||||
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||
await settle();
|
||||
assert.equal(
|
||||
$('[role=status]').textContent,
|
||||
'Selected feature: point. Choose an action.',
|
||||
);
|
||||
assert.ok($('[data-director-action="card"]').style.outline);
|
||||
assert.ok($('[data-director-action="fly"]').style.outline);
|
||||
});
|
||||
@@ -47,7 +47,6 @@
|
||||
*/
|
||||
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
|
||||
import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.js';
|
||||
|
||||
@@ -155,7 +154,7 @@ export function firstVisibleTileset(primitives) {
|
||||
/**
|
||||
* Create a per-layer ground-snap cache.
|
||||
* @returns {{
|
||||
* heightFor: (target: object, icao: string, pos: Cesium.Cartesian3,
|
||||
* heightFor: (engine: object, icao: string, pos: Cesium.Cartesian3,
|
||||
* getExclusions?: () => Array<unknown>) => number|null,
|
||||
* forget: (icao: string) => void,
|
||||
* clear: () => void,
|
||||
@@ -266,9 +265,8 @@ export function createGroundSnap({ groundFloor }) {
|
||||
* A contact that HAS resolved once keeps that answer through a resample outage;
|
||||
* see `heldSnapM`.
|
||||
*
|
||||
* @param {object} target - The globe engine (`engine.surface`:
|
||||
* sampleHeightDetailed and tilesLoaded), or a viewer whose engine is
|
||||
* registered (engineFromViewer); without one, no sample.
|
||||
* @param {object} engine - The globe engine (`engine.surface`:
|
||||
* sampleHeightDetailed and tilesLoaded); without one, no sample.
|
||||
* @param {string} icao - Cache key (per-layer icao space).
|
||||
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
|
||||
* is measured after projecting this input to the WGS84 ellipsoid, so a
|
||||
@@ -279,8 +277,7 @@ export function createGroundSnap({ groundFloor }) {
|
||||
* lands on its icon/model otherwise). Only invoked when a sample fires.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function heightFor(target, icao, pos, getExclusions) {
|
||||
const engine = engineFromViewer(target);
|
||||
function heightFor(engine, icao, pos, getExclusions) {
|
||||
const surfacePos = geo.ellipsoid.WGS84.scaleToGeodeticSurface(
|
||||
pos,
|
||||
scratchSurfacePos,
|
||||
|
||||
@@ -52,9 +52,7 @@ test('on a globe engine loading tiles or a miss return null without a sample cac
|
||||
assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 1);
|
||||
});
|
||||
|
||||
test('a coarse tile answer or only terrain is a miss; a viewer reaches its engine', async () => {
|
||||
const { registerViewerEngine } =
|
||||
await import('../globe/cesium/engineFromViewer.js');
|
||||
test('a coarse tile answer or only terrain is a miss', async () => {
|
||||
const snap = createGroundSnap({ groundFloor: groundFloor() });
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.script('sampleHeightDetailed', () => ({
|
||||
@@ -73,14 +71,8 @@ test('a coarse tile answer or only terrain is a miss; a viewer reaches its engin
|
||||
source: 'tiles',
|
||||
tileSizeM: 241,
|
||||
}));
|
||||
const viewer = {};
|
||||
const unregister = registerViewerEngine(viewer, engine);
|
||||
try {
|
||||
assert.equal(snap.heightFor(viewer, 'd3', KAUS), 122.5);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
assert.equal(snap.heightFor({}, 'd4', KAUS), null, 'no engine: no sample');
|
||||
assert.equal(snap.heightFor(engine, 'd3', KAUS), 122.5);
|
||||
assert.equal(snap.heightFor(null, 'd4', KAUS), null, 'no engine: no sample');
|
||||
});
|
||||
|
||||
const atDenverAirport = geo.cartesian3.fromDegrees(-104.6737, 39.8561, 0);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
|
||||
|
||||
/** Answers from tiles larger than this are too coarse to latch (m).
|
||||
@@ -86,9 +85,8 @@ export function createMeshFloorSampler({
|
||||
* layer poll (never per contact, never per frame). Synchronous — Cesium's
|
||||
* sampleHeight is a CPU-side query against loaded tiles.
|
||||
*
|
||||
* @param {object|undefined} target - The globe engine (engine.surface,
|
||||
* camera.height, maps.photorealActive), or a viewer or scene whose
|
||||
* engine is registered (engineFromViewer); without one, nothing.
|
||||
* @param {object|undefined} engine - The globe engine (engine.surface,
|
||||
* camera.height, maps.photorealActive); without one, nothing.
|
||||
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
|
||||
* @param {object} [options]
|
||||
* @param {Array<object>} [options.excludeObjects] - What the probe passes
|
||||
@@ -98,12 +96,11 @@ export function createMeshFloorSampler({
|
||||
* Viewer subpoint (computed once by the caller's poll).
|
||||
*/
|
||||
function sampleMeshFloorCells(
|
||||
target,
|
||||
engine,
|
||||
points,
|
||||
{ excludeObjects = [], viewerLat, viewerLon } = {},
|
||||
) {
|
||||
if (!meshFloorPreferred()) return;
|
||||
const engine = engineFromViewer(target);
|
||||
if (!engine || engine.isDestroyed?.() || !engine.surface.supported) return;
|
||||
if (!Array.isArray(points) || !points.length) return;
|
||||
// Round-5 hardening (the CCTV projectionTilesReady lesson, ported late):
|
||||
|
||||
@@ -74,11 +74,13 @@ export function aircraftViewerRenderer({
|
||||
get scene() {
|
||||
return viewer?.scene;
|
||||
},
|
||||
// The engine registered for the viewer (the surface samplers take an
|
||||
// engine).
|
||||
get surfaceOwner() {
|
||||
return viewer;
|
||||
return engine ?? engineFromViewer(viewer);
|
||||
},
|
||||
get surfaceScene() {
|
||||
return viewer?.scene;
|
||||
return engine ?? engineFromViewer(viewer);
|
||||
},
|
||||
frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1,
|
||||
viewportHeightPx: () => viewer.scene.canvas.clientHeight,
|
||||
|
||||
@@ -36,3 +36,19 @@ export function trackingEngine(viewer) {
|
||||
});
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* A globe engine with only the camera of a viewer test double: its pose at
|
||||
* the double's positionCartographic and its view rectangle. It has no canvas
|
||||
* (screen picks miss) and no surface (ground heights are 0).
|
||||
*/
|
||||
export function cameraOnlyEngine(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
return {
|
||||
camera: {
|
||||
pose: () => ({ cartographic: camera?.positionCartographic }),
|
||||
viewRectangle: () => camera?.computeViewRectangle?.(),
|
||||
},
|
||||
host: { size: () => ({ width: 0, height: 0 }) },
|
||||
};
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { validateBox } from '../layers/recentImagery/model.js';
|
||||
|
||||
/** Native DOM capture keeps a box drag out of Navara's camera controller. */
|
||||
export function initNavaraImageryBoxTool({
|
||||
engine,
|
||||
onBox,
|
||||
onPreview,
|
||||
onCancel,
|
||||
onActive,
|
||||
onEscape,
|
||||
documentRef = globalThis.document,
|
||||
}) {
|
||||
const canvas = engine.host.canvas;
|
||||
let active = false,
|
||||
destroyed = false,
|
||||
anchor = null,
|
||||
corner = null;
|
||||
let lease = null,
|
||||
thaw = null,
|
||||
pointerId = null;
|
||||
const box = () =>
|
||||
anchor && {
|
||||
west: Math.min(anchor.lon, corner.lon),
|
||||
east: Math.max(anchor.lon, corner.lon),
|
||||
south: Math.min(anchor.lat, corner.lat),
|
||||
north: Math.max(anchor.lat, corner.lat),
|
||||
};
|
||||
function point(event) {
|
||||
const rect = canvas.getBoundingClientRect(),
|
||||
at = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
const hit = engine.picker.pickPosition(at) || engine.picker.pickGlobe(at);
|
||||
const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
|
||||
return c
|
||||
? {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
const consume = (event) => {
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
};
|
||||
function endDrag() {
|
||||
anchor = corner = null;
|
||||
if (pointerId !== null && canvas.hasPointerCapture?.(pointerId))
|
||||
canvas.releasePointerCapture(pointerId);
|
||||
pointerId = null;
|
||||
thaw?.();
|
||||
thaw = null;
|
||||
onPreview(null);
|
||||
}
|
||||
function down(event) {
|
||||
if (
|
||||
!active ||
|
||||
anchor ||
|
||||
event.button !== 0 ||
|
||||
event.shiftKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
consume(event);
|
||||
const p = point(event);
|
||||
if (!p) {
|
||||
onCancel?.('sky', 'Press on the ground, not the sky');
|
||||
return;
|
||||
}
|
||||
anchor = corner = p;
|
||||
pointerId = event.pointerId;
|
||||
thaw = engine.input.suspendCameraInputs();
|
||||
canvas.setPointerCapture?.(pointerId);
|
||||
onPreview(box());
|
||||
}
|
||||
function move(event) {
|
||||
if (!anchor || event.pointerId !== pointerId) return;
|
||||
consume(event);
|
||||
corner = point(event) || corner;
|
||||
onPreview(box());
|
||||
}
|
||||
function up(event) {
|
||||
if (!anchor || event.pointerId !== pointerId) return;
|
||||
consume(event);
|
||||
corner = point(event) || corner;
|
||||
const selected = box(),
|
||||
result = validateBox(selected);
|
||||
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
|
||||
stop();
|
||||
if (result.ok) onBox(result.box);
|
||||
else onCancel?.(result.reason, result.message, selected);
|
||||
}
|
||||
function key(event) {
|
||||
if (!active || event.key !== 'Escape') return;
|
||||
consume(event);
|
||||
if (onEscape?.() !== true) cancel('escape');
|
||||
}
|
||||
function pointerCancel(event) {
|
||||
if (anchor && event.pointerId === pointerId) cancel('pointer-cancel');
|
||||
}
|
||||
const bindings = [
|
||||
[canvas, 'pointerdown', down],
|
||||
[documentRef, 'pointermove', move],
|
||||
[documentRef, 'pointerup', up],
|
||||
[documentRef, 'pointercancel', pointerCancel],
|
||||
[documentRef, 'keydown', key],
|
||||
];
|
||||
if (documentRef.defaultView)
|
||||
bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]);
|
||||
function stop() {
|
||||
if (!active) return;
|
||||
for (const [target, type, listener] of bindings)
|
||||
target.removeEventListener(type, listener, true);
|
||||
endDrag();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
active = false;
|
||||
documentRef.body?.classList.remove('gev-imagery-box');
|
||||
onActive?.(false);
|
||||
}
|
||||
function cancel(reason = 'cancel') {
|
||||
if (active) {
|
||||
stop();
|
||||
onCancel?.(reason);
|
||||
}
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
if (destroyed) return false;
|
||||
if (active) return true;
|
||||
lease = claimPointer('recent-imagery-box');
|
||||
if (!lease) {
|
||||
onCancel?.(
|
||||
'pointer-busy',
|
||||
`${pointerOwner()} is using the pointer — close it first`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
active = true;
|
||||
for (const [target, type, listener] of bindings)
|
||||
target.addEventListener(type, listener, true);
|
||||
documentRef.body?.classList.add('gev-imagery-box');
|
||||
onActive?.(true);
|
||||
return true;
|
||||
},
|
||||
cancel,
|
||||
isActive: () => active,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
stop();
|
||||
destroyed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { pointerOwner } from '../data/inputOwnership.js';
|
||||
import { initNavaraImageryBoxTool } from './imageryBoxNavara.js';
|
||||
|
||||
test('blur and destruction during a Navara box drag return input and pointer ownership', () => {
|
||||
const target = () => {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, fn) {
|
||||
listeners.set(type, fn);
|
||||
},
|
||||
removeEventListener(type) {
|
||||
listeners.delete(type);
|
||||
},
|
||||
};
|
||||
};
|
||||
const windowRef = target(),
|
||||
documentRef = {
|
||||
...target(),
|
||||
defaultView: windowRef,
|
||||
body: { classList: { add() {}, remove() {} } },
|
||||
};
|
||||
const canvas = {
|
||||
...target(),
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
setPointerCapture() {},
|
||||
hasPointerCapture: () => true,
|
||||
releasePointerCapture() {},
|
||||
};
|
||||
let suspended = 0,
|
||||
preview = null;
|
||||
const engine = {
|
||||
host: { canvas },
|
||||
picker: { pickPosition: () => geo.cartesian3.fromDegrees(10, 20, 0) },
|
||||
input: {
|
||||
suspendCameraInputs() {
|
||||
suspended++;
|
||||
return () => suspended--;
|
||||
},
|
||||
},
|
||||
};
|
||||
const tool = initNavaraImageryBoxTool({
|
||||
engine,
|
||||
documentRef,
|
||||
onBox() {},
|
||||
onPreview: (b) => (preview = b),
|
||||
});
|
||||
const down = () =>
|
||||
canvas.listeners.get('pointerdown')({
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
preventDefault() {},
|
||||
stopImmediatePropagation() {},
|
||||
});
|
||||
try {
|
||||
assert.equal(tool.start(), true);
|
||||
down();
|
||||
assert.equal(suspended, 1);
|
||||
assert.ok(preview);
|
||||
windowRef.listeners.get('blur')();
|
||||
assert.equal(suspended, 0);
|
||||
assert.equal(preview, null);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(tool.isActive(), false);
|
||||
tool.start();
|
||||
down();
|
||||
tool.destroy();
|
||||
tool.destroy();
|
||||
assert.equal(suspended, 0);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(canvas.listeners.size, 0);
|
||||
assert.equal(documentRef.listeners.size, 0);
|
||||
assert.equal(windowRef.listeners.size, 0);
|
||||
} finally {
|
||||
tool.destroy();
|
||||
}
|
||||
});
|
||||
+131
-207
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* Box selection for the Recent Imagery layer: press on the world, drag, and
|
||||
* release to hand the layer a degrees box. Follows the draw tool's ownership
|
||||
* rules — the pointer is claimed for the session, the viewer's own click
|
||||
* actions are borrowed and given back, and `destroy()` returns everything —
|
||||
* and freezes the camera only while a drag is in progress.
|
||||
* rules — the pointer is claimed for the session and `destroy()` returns
|
||||
* everything — and freezes the camera only while a drag is in progress.
|
||||
*
|
||||
* The same on every engine: the tool takes the pointer events of the
|
||||
* engine's canvas in the capture phase, ahead of the engine's own handlers,
|
||||
* so a press that starts a box and the drag it makes reach neither the
|
||||
* camera nor any click (a layer's, or Cesium's stock select and track).
|
||||
* Moves outside a drag pass through. The layer draws the preview
|
||||
* (`onPreview`, a ground cell on engine.ground).
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
@@ -18,35 +23,51 @@ import { msg } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
|
||||
|
||||
/**
|
||||
* The camera held still until the returned release: the engine's counted
|
||||
* suspension where it has one (Navara keeps the app's own setCameraInputs
|
||||
* choice under it), else camera inputs off and on again.
|
||||
*/
|
||||
function freezeCamera(input) {
|
||||
if (typeof input.suspendCameraInputs === 'function')
|
||||
return input.suspendCameraInputs();
|
||||
input.setCameraInputs(false);
|
||||
return () => input.setCameraInputs(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the box tool.
|
||||
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
|
||||
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
|
||||
* the key (it is still consumed) and leaves the tool armed.
|
||||
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
|
||||
* @param {{ engine: import('../globe/contract.js').GlobeEngine,
|
||||
* onBox: (box: object) => void, onPreview?: (box: object | null) => void,
|
||||
* onCancel?: (reason: string, message?: string, box?: object) => void,
|
||||
* onActive?: (active: boolean) => void, onEscape?: () => boolean,
|
||||
* pickWorld?: Function, documentRef?: object }} deps
|
||||
* `onPreview` gets the box being dragged, and null once the drag ends.
|
||||
* `onEscape` runs before the tool cancels on Escape; returning `true`
|
||||
* claims the key (it is still consumed) and leaves the tool armed.
|
||||
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => void } | null}
|
||||
*/
|
||||
export function initImageryBoxTool({
|
||||
viewer,
|
||||
engine,
|
||||
onBox,
|
||||
onPreview = () => {},
|
||||
onCancel,
|
||||
onActive,
|
||||
onEscape,
|
||||
cesium = Cesium,
|
||||
pickWorld = pickWorldFromScreen,
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
if (!viewer?.scene || typeof onBox !== 'function') return null;
|
||||
const canvas = engine?.host?.canvas;
|
||||
if (!canvas || typeof onBox !== 'function') return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
/** Drag corners in degrees; `anchor` is null while no drag is live. */
|
||||
let anchor = null;
|
||||
let corner = null;
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedSingleClick = null;
|
||||
let savedDoubleClick = null;
|
||||
let savedCameraInputs = null;
|
||||
let thaw = null;
|
||||
let pointerId = null;
|
||||
|
||||
const dragBox = () =>
|
||||
anchor && {
|
||||
@@ -55,241 +76,144 @@ export function initImageryBoxTool({
|
||||
east: Math.max(anchor.lon, corner.lon),
|
||||
north: Math.max(anchor.lat, corner.lat),
|
||||
};
|
||||
|
||||
const dataSource = new cesium.CustomDataSource(
|
||||
'gev-recent-imagery-box-preview',
|
||||
);
|
||||
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
|
||||
() => null,
|
||||
);
|
||||
const stroke = cesium.Color.fromCssColorString('#8be9ff');
|
||||
const previewRectangle = () => {
|
||||
const box = dragBox();
|
||||
return box
|
||||
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
|
||||
: undefined;
|
||||
};
|
||||
const previewOutline = () => {
|
||||
const box = dragBox();
|
||||
if (!box) return [];
|
||||
return cesium.Cartesian3.fromDegreesArray([
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
box.west,
|
||||
box.north,
|
||||
box.west,
|
||||
box.south,
|
||||
]);
|
||||
};
|
||||
const fill = dataSource.entities.add({
|
||||
show: false,
|
||||
rectangle: {
|
||||
coordinates: new cesium.CallbackProperty(previewRectangle, false),
|
||||
material: stroke.withAlpha(0.12),
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
// A rectangle graphic cannot draw a dashed edge on the ground, so the
|
||||
// outline is its own draped polyline traced round the same corners.
|
||||
const outline = dataSource.entities.add({
|
||||
show: false,
|
||||
polyline: {
|
||||
positions: new cesium.CallbackProperty(previewOutline, false),
|
||||
width: 2,
|
||||
clampToGround: true,
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
material: new cesium.PolylineDashMaterialProperty({
|
||||
color: stroke.withAlpha(0.95),
|
||||
dashLength: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const requestRender = () => viewer.scene.requestRender?.();
|
||||
const setPreview = (visible) => {
|
||||
fill.show = visible;
|
||||
outline.show = visible;
|
||||
requestRender();
|
||||
};
|
||||
const worldAt = (position) => {
|
||||
if (!position) return null;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const w = canvas?.clientWidth || canvas?.width || 1;
|
||||
const h = canvas?.clientHeight || canvas?.height || 1;
|
||||
const point = pickWorld(viewer, position.x / w, position.y / h);
|
||||
const worldAt = (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const { width, height } = engine.host.size();
|
||||
const point = pickWorld(
|
||||
engine,
|
||||
(event.clientX - rect.left) / (width || 1),
|
||||
(event.clientY - rect.top) / (height || 1),
|
||||
);
|
||||
const lon = Number(point?.lon);
|
||||
const lat = Number(point?.lat);
|
||||
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
|
||||
};
|
||||
const cameraController = () => viewer.scene.screenSpaceCameraController;
|
||||
|
||||
function freezeCamera() {
|
||||
const controller = cameraController();
|
||||
if (!controller || savedCameraInputs !== null) return;
|
||||
savedCameraInputs = controller.enableInputs;
|
||||
controller.enableInputs = false;
|
||||
}
|
||||
function thawCamera() {
|
||||
const controller = cameraController();
|
||||
if (controller && savedCameraInputs !== null)
|
||||
controller.enableInputs = savedCameraInputs;
|
||||
savedCameraInputs = null;
|
||||
}
|
||||
const consume = (event) => {
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
};
|
||||
|
||||
function endDrag() {
|
||||
anchor = null;
|
||||
corner = null;
|
||||
thawCamera();
|
||||
setPreview(false);
|
||||
if (pointerId !== null && canvas.hasPointerCapture?.(pointerId))
|
||||
canvas.releasePointerCapture(pointerId);
|
||||
pointerId = null;
|
||||
thaw?.();
|
||||
thaw = null;
|
||||
onPreview(null);
|
||||
}
|
||||
|
||||
const onDown = (event) => {
|
||||
if (!active || destroyed || anchor) return;
|
||||
const point = worldAt(event.position);
|
||||
function down(event) {
|
||||
if (
|
||||
!active ||
|
||||
anchor ||
|
||||
event.button !== 0 ||
|
||||
event.shiftKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
consume(event);
|
||||
const point = worldAt(event);
|
||||
if (!point) {
|
||||
onCancel?.('sky', msg('Press on the ground, not the sky'));
|
||||
return;
|
||||
}
|
||||
anchor = point;
|
||||
corner = point;
|
||||
freezeCamera();
|
||||
setPreview(true);
|
||||
};
|
||||
const onMove = (event) => {
|
||||
const point = anchor && !destroyed && worldAt(event.endPosition);
|
||||
if (!point) return;
|
||||
corner = point;
|
||||
requestRender();
|
||||
};
|
||||
const onUp = (event) => {
|
||||
if (!anchor || destroyed) return;
|
||||
corner = worldAt(event.position) || corner;
|
||||
pointerId = event.pointerId;
|
||||
thaw = freezeCamera(engine.input);
|
||||
canvas.setPointerCapture?.(pointerId);
|
||||
onPreview(dragBox());
|
||||
}
|
||||
function move(event) {
|
||||
if (!anchor || event.pointerId !== pointerId) return;
|
||||
consume(event);
|
||||
corner = worldAt(event) || corner;
|
||||
onPreview(dragBox());
|
||||
}
|
||||
function up(event) {
|
||||
if (!anchor || event.pointerId !== pointerId) return;
|
||||
consume(event);
|
||||
corner = worldAt(event) || corner;
|
||||
const box = dragBox();
|
||||
const result = validateBox(box);
|
||||
if (result.reason === 'degenerate')
|
||||
result.message = msg('Drag to size the box');
|
||||
endDrag();
|
||||
setActive(false);
|
||||
stop();
|
||||
if (result.ok) onBox(result.box);
|
||||
else onCancel?.(result.reason, result.message, box);
|
||||
};
|
||||
}
|
||||
// Escape is taken from a document capture listener, ahead of every other
|
||||
// handler, so the interceptor runs first: when it claims the key (the
|
||||
// panel exits a live comparison), the tool stays armed and the key still
|
||||
// goes no further; otherwise Escape cancels the session.
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || event.key !== 'Escape') return;
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
if (onEscape?.() === true) return;
|
||||
cancel('escape');
|
||||
};
|
||||
|
||||
function bindScene() {
|
||||
if (handler) return;
|
||||
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock) {
|
||||
savedSingleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
||||
savedDoubleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null;
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
}
|
||||
documentRef?.addEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.add?.('gev-imagery-box');
|
||||
function key(event) {
|
||||
if (!active || event.key !== 'Escape') return;
|
||||
consume(event);
|
||||
if (onEscape?.() !== true) cancel('escape');
|
||||
}
|
||||
// A drag the browser takes away (a touch turned into a scroll, the window
|
||||
// losing focus) ends the session and gives the camera back.
|
||||
function pointerCancel(event) {
|
||||
if (anchor && event.pointerId === pointerId) cancel('pointer-cancel');
|
||||
}
|
||||
const bindings = [
|
||||
[canvas, 'pointerdown', down],
|
||||
[documentRef, 'pointermove', move],
|
||||
[documentRef, 'pointerup', up],
|
||||
[documentRef, 'pointercancel', pointerCancel],
|
||||
[documentRef, 'keydown', key],
|
||||
];
|
||||
if (documentRef?.defaultView)
|
||||
bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]);
|
||||
|
||||
function releaseScene() {
|
||||
if (handler) {
|
||||
handler.destroy();
|
||||
handler = null;
|
||||
}
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock && savedSingleClick) {
|
||||
stock.setInputAction(
|
||||
savedSingleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
}
|
||||
if (stock && savedDoubleClick) {
|
||||
stock.setInputAction(
|
||||
savedDoubleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
||||
);
|
||||
}
|
||||
savedSingleClick = null;
|
||||
savedDoubleClick = null;
|
||||
documentRef?.removeEventListener?.('keydown', onKey, true);
|
||||
function stop() {
|
||||
if (!active) return;
|
||||
for (const [target, type, listener] of bindings)
|
||||
target?.removeEventListener?.(type, listener, true);
|
||||
endDrag();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
active = false;
|
||||
documentRef?.body?.classList?.remove?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function setActive(next) {
|
||||
if (next === active) return active;
|
||||
if (next) {
|
||||
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
|
||||
if (!lease) {
|
||||
onCancel?.(
|
||||
'pointer-busy',
|
||||
`${pointerOwner()} is using the pointer — close it first`,
|
||||
);
|
||||
return active;
|
||||
}
|
||||
active = true;
|
||||
bindScene();
|
||||
} else {
|
||||
if (anchor) endDrag();
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
active = false;
|
||||
}
|
||||
onActive?.(active);
|
||||
return active;
|
||||
onActive?.(false);
|
||||
}
|
||||
|
||||
function cancel(reason = 'cancel') {
|
||||
if (!active || destroyed) return;
|
||||
setActive(false);
|
||||
stop();
|
||||
onCancel?.(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Begin a box session. False when the pointer is held elsewhere. */
|
||||
start() {
|
||||
return !destroyed && setActive(true);
|
||||
if (destroyed) return false;
|
||||
if (active) return true;
|
||||
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
|
||||
if (!lease) {
|
||||
onCancel?.(
|
||||
'pointer-busy',
|
||||
`${pointerOwner()} is using the pointer — close it first`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
active = true;
|
||||
for (const [target, type, listener] of bindings)
|
||||
target?.addEventListener?.(type, listener, true);
|
||||
documentRef?.body?.classList?.add?.('gev-imagery-box');
|
||||
onActive?.(true);
|
||||
return true;
|
||||
},
|
||||
cancel,
|
||||
isActive() {
|
||||
return active;
|
||||
},
|
||||
/** Returns everything; resolves once the preview is detached. */
|
||||
isActive: () => active,
|
||||
/** Returns the pointer, the camera and every listener. */
|
||||
destroy() {
|
||||
if (destroyed) return attaching;
|
||||
if (active) setActive(false);
|
||||
if (destroyed) return;
|
||||
stop();
|
||||
destroyed = true;
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
thawCamera();
|
||||
dataSource.entities.removeAll();
|
||||
attaching = attaching.then(() => {
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
/* viewer already disposed */
|
||||
}
|
||||
});
|
||||
return attaching;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,53 +12,50 @@ import {
|
||||
} from './imageryBoxTool.js';
|
||||
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
|
||||
|
||||
function fixture(options = {}) {
|
||||
function fixture(options = {}, fakeOptions = {}) {
|
||||
resetPointerOwnership();
|
||||
const fakes = boxToolFakes();
|
||||
const fakes = boxToolFakes(fakeOptions);
|
||||
const boxes = [];
|
||||
const cancels = [];
|
||||
const actives = [];
|
||||
const previews = [];
|
||||
const tool = initImageryBoxTool({
|
||||
viewer: fakes.viewer,
|
||||
cesium: fakes.cesium,
|
||||
engine: fakes.engine,
|
||||
pickWorld: fakes.pickWorld,
|
||||
documentRef: fakes.documentRef,
|
||||
onBox: (box) => boxes.push(box),
|
||||
onPreview: (box) => previews.push(box),
|
||||
onCancel: (reason, message) => cancels.push([reason, message]),
|
||||
onActive: (active) => actives.push(active),
|
||||
...options,
|
||||
});
|
||||
const camera = fakes.viewer.scene.screenSpaceCameraController;
|
||||
const stockAction = (type) =>
|
||||
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
|
||||
const preview = () =>
|
||||
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
|
||||
return {
|
||||
...fakes,
|
||||
tool,
|
||||
boxes,
|
||||
cancels,
|
||||
actives,
|
||||
camera,
|
||||
stockAction,
|
||||
preview,
|
||||
};
|
||||
return { ...fakes, tool, boxes, cancels, actives, previews };
|
||||
}
|
||||
|
||||
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
|
||||
const listenerCount = (f) =>
|
||||
f.canvas.listeners.length +
|
||||
f.documentRef.listeners.length +
|
||||
f.windowRef.listeners.length;
|
||||
|
||||
test('a session claims the pointer, keeps the drag from the engine, freezes the camera only mid-drag and returns everything with the box', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.tool.start(), true);
|
||||
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
|
||||
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
|
||||
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.camera.enableInputs, false);
|
||||
assert.equal(f.preview(), true);
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
|
||||
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
|
||||
assert.equal(f.camera.inputs, true, 'not frozen before the press');
|
||||
const press = f.down({ x: 250, y: 125 });
|
||||
assert.equal(
|
||||
press.prevented && press.stopped,
|
||||
true,
|
||||
'the press reaches no other handler (camera, clicks)',
|
||||
);
|
||||
assert.equal(f.camera.inputs, false);
|
||||
assert.ok(f.captured.has(1), 'the drag holds the pointer capture');
|
||||
assert.ok(f.previews.at(-1), 'the preview shows');
|
||||
const drag = f.move({ x: 500, y: 250 });
|
||||
assert.equal(drag.stopped, true);
|
||||
f.move({ x: 500, y: 10 }); // sky: the corner stays
|
||||
f.up({ x: 500, y: 250 });
|
||||
assert.equal(f.boxes.length, 1);
|
||||
const { west, south, east, north } = f.boxes[0];
|
||||
assert.deepEqual(
|
||||
@@ -68,37 +65,49 @@ test('a session claims the pointer, borrows the stock clicks, freezes the camera
|
||||
assert.deepEqual(f.cancels, []);
|
||||
assert.equal(f.tool.isActive(), false);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(f.preview(), false);
|
||||
assert.equal(f.camera.inputs, true);
|
||||
assert.equal(f.captured.size, 0);
|
||||
assert.equal(f.previews.at(-1), null, 'the preview goes with the drag');
|
||||
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
|
||||
assert.equal(f.documentRef.listeners.length, 0);
|
||||
assert.equal(f.handler(), null);
|
||||
assert.equal(listenerCount(f), 0);
|
||||
assert.deepEqual(f.actives, [true, false]);
|
||||
await f.tool.destroy();
|
||||
f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a sky press, a click without a drag and Escape each say why', async () => {
|
||||
test('presses with a modifier or another button, and moves outside a drag, pass through', () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
|
||||
for (const fields of [{ shiftKey: true }, { ctrlKey: true }, { button: 2 }]) {
|
||||
const press = f.down({ x: 250, y: 125 }, fields);
|
||||
assert.equal(press.stopped, false, JSON.stringify(fields));
|
||||
}
|
||||
assert.equal(f.camera.inputs, true);
|
||||
assert.equal(f.move({ x: 300, y: 200 }).stopped, false, 'a hover');
|
||||
assert.equal(f.tool.isActive(), true);
|
||||
f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a sky press, a click without a drag and Escape each say why', () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.down({ x: 250, y: 10 });
|
||||
assert.deepEqual(f.cancels.at(-1), [
|
||||
'sky',
|
||||
'Press on the ground, not the sky',
|
||||
]);
|
||||
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.camera.inputs, true, 'and freezes nothing');
|
||||
f.down({ x: 250, y: 125 });
|
||||
f.up({ x: 250, y: 125 });
|
||||
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
|
||||
f.tool.start();
|
||||
assert.equal(f.key('Escape').prevented, true);
|
||||
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
|
||||
assert.equal(pointerOwner(), null);
|
||||
await f.tool.destroy();
|
||||
f.tool.destroy();
|
||||
});
|
||||
|
||||
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
|
||||
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', () => {
|
||||
let claim = true;
|
||||
let asked = 0;
|
||||
const f = fixture({
|
||||
@@ -122,33 +131,54 @@ test('an Escape interceptor that claims the key leaves the tool armed; the next
|
||||
f.tool.start();
|
||||
f.key('Enter');
|
||||
assert.equal(asked, 2, 'other keys never reach the interceptor');
|
||||
await f.tool.destroy();
|
||||
f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a held pointer refuses the session and nothing is borrowed', async () => {
|
||||
test('a held pointer refuses the session and nothing is bound', () => {
|
||||
const f = fixture();
|
||||
const lease = claimPointer('draw');
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.deepEqual(f.cancels, [
|
||||
['pointer-busy', 'draw is using the pointer — close it first'],
|
||||
]);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.handler(), null);
|
||||
assert.equal(listenerCount(f), 0);
|
||||
releasePointer(lease);
|
||||
assert.equal(f.tool.start(), true);
|
||||
await f.tool.destroy();
|
||||
f.tool.destroy();
|
||||
});
|
||||
|
||||
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
|
||||
test('destroy mid-drag restores the camera, releases the claim and clears the preview', () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.viewer.dataSources.sources.length, 1);
|
||||
await f.tool.destroy();
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
f.down({ x: 250, y: 125 });
|
||||
assert.equal(f.camera.inputs, false);
|
||||
f.tool.destroy();
|
||||
f.tool.destroy();
|
||||
assert.equal(f.camera.inputs, true);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.viewer.dataSources.sources.length, 0);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.previews.at(-1), null);
|
||||
assert.equal(listenerCount(f), 0);
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
|
||||
assert.equal(initImageryBoxTool({ engine: null, onBox() {} }), null);
|
||||
});
|
||||
|
||||
test('blur and a cancelled pointer during a drag return the camera, the preview and the pointer', () => {
|
||||
const f = fixture({}, { suspend: true });
|
||||
f.tool.start();
|
||||
f.down({ x: 250, y: 125 });
|
||||
assert.equal(f.camera.suspended, 1, "the engine's counted suspension");
|
||||
assert.equal(f.camera.inputs, true, 'the app choice is left alone');
|
||||
f.windowRef.dispatch('blur', {});
|
||||
assert.equal(f.camera.suspended, 0);
|
||||
assert.equal(f.previews.at(-1), null);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.tool.isActive(), false);
|
||||
assert.deepEqual(f.cancels.at(-1), ['blur', undefined]);
|
||||
f.tool.start();
|
||||
f.down({ x: 250, y: 125 });
|
||||
f.documentRef.dispatch('pointercancel', { pointerId: 1 });
|
||||
assert.equal(f.camera.suspended, 0);
|
||||
assert.deepEqual(f.cancels.at(-1), ['pointer-cancel', undefined]);
|
||||
assert.equal(listenerCount(f), 0);
|
||||
f.tool.destroy();
|
||||
});
|
||||
@@ -149,13 +149,13 @@ const cancelledResult = () => ({
|
||||
/**
|
||||
* Build the runner's area services.
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer
|
||||
* @param {object} options.engine The globe engine (view centre, credits).
|
||||
* @param {object|null} options.annotations Annotation engine.
|
||||
* @param {object} [options.placeSearch]
|
||||
* @param {object} [options.annotationResolver] The annotation outline ladder.
|
||||
*/
|
||||
export function createVoiceAreas({
|
||||
viewer,
|
||||
engine = null,
|
||||
annotations = null,
|
||||
placeSearch = unavailablePlaceSearch,
|
||||
annotationResolver = null,
|
||||
@@ -173,10 +173,10 @@ export function createVoiceAreas({
|
||||
|
||||
/** The ground at the centre of the view, else the camera's position. */
|
||||
function viewCenter() {
|
||||
const picked = annotationResolver?.pickWorldFromScreen?.(viewer, 0.5, 0.5);
|
||||
const picked = annotationResolver?.pickWorldFromScreen?.(engine, 0.5, 0.5);
|
||||
if (Number.isFinite(picked?.lat) && Number.isFinite(picked?.lon))
|
||||
return picked;
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
const carto = engine?.camera.pose().cartographic;
|
||||
return carto
|
||||
? {
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
@@ -223,7 +223,7 @@ export function createVoiceAreas({
|
||||
try {
|
||||
resolved = await annotationResolver.resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine,
|
||||
target: query,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
@@ -405,7 +405,7 @@ export function createVoiceAreas({
|
||||
'us-census': US_CENSUS_CREDIT,
|
||||
wof: WOF_CREDIT,
|
||||
}[source];
|
||||
if (credit) registerDynamicCredit(viewer, credit);
|
||||
if (credit) registerDynamicCredit(engine, credit);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -632,7 +632,7 @@ export function createVoiceAreas({
|
||||
// OpenFreeMap area holding it) is a bounded extra.
|
||||
resolved = await annotationResolver.resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
engine,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
|
||||
@@ -113,7 +113,6 @@ function fakeAnnotations() {
|
||||
function makeAreas(overrides = {}) {
|
||||
const annotations = overrides.annotations || fakeAnnotations();
|
||||
const areas = createVoiceAreas({
|
||||
viewer: {},
|
||||
annotations,
|
||||
annotationResolver: fakeLadder(),
|
||||
findRegion: async () => null,
|
||||
@@ -181,7 +180,6 @@ test('draw failures stay honest in the result, spoken line, and card', async (t)
|
||||
for (const entry of cases) {
|
||||
await t.test(entry.name, async () => {
|
||||
const areas = createVoiceAreas({
|
||||
viewer: {},
|
||||
annotations: entry.annotations,
|
||||
annotationResolver: fakeLadder(),
|
||||
findRegion: async () => null,
|
||||
@@ -732,7 +730,6 @@ test('a clear during a slow lookup is not undone by a late outline', async () =>
|
||||
release = resolve;
|
||||
});
|
||||
const areas = createVoiceAreas({
|
||||
viewer: {},
|
||||
annotations,
|
||||
findRegion: async () => null,
|
||||
findAdminCandidates: async () => [],
|
||||
@@ -781,7 +778,6 @@ test('an outlined area keeps its full geometry past store eviction', async () =>
|
||||
test('an outline from before a page reload asks to resolve again, never counts its drawing', async () => {
|
||||
const annotations = fakeAnnotations();
|
||||
const first = createVoiceAreas({
|
||||
viewer: {},
|
||||
annotations,
|
||||
annotationResolver: fakeLadder(),
|
||||
findRegion: async () => null,
|
||||
@@ -792,7 +788,6 @@ test('an outline from before a page reload asks to resolve again, never counts i
|
||||
// outline stays on the map. A mic restart keeps the runner (and handles);
|
||||
// see integrationContract.test.mjs.
|
||||
const second = createVoiceAreas({
|
||||
viewer: {},
|
||||
annotations,
|
||||
findRegion: async () => null,
|
||||
findAdminCandidates: async () => [],
|
||||
|
||||
@@ -378,7 +378,7 @@ export function createGevActionRunner({
|
||||
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
|
||||
// the same store.
|
||||
const voiceAreas = createVoiceAreas({
|
||||
viewer,
|
||||
engine,
|
||||
annotations,
|
||||
placeSearch,
|
||||
annotationResolver,
|
||||
@@ -1452,7 +1452,7 @@ export function createGevActionRunner({
|
||||
runOptions,
|
||||
),
|
||||
search: searchOsmFeatures,
|
||||
onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT),
|
||||
onCredit: () => registerDynamicCredit(engine, OSM_PLACES_CREDIT),
|
||||
// The listed places become the analyst's "last answer", so
|
||||
// "which of those…" filters them and never an older set.
|
||||
rememberResults: (rows, presentation, { truncated = false } = {}) => {
|
||||
|
||||
Reference in new issue
Block a user