mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Scope pick ownership and selection dispatch to each scene
Create pick registries and selection dispatchers for each scene attachment, publish them through scene.services, and release their predicates and input subscriptions before parking the renderer. Resolve layer ownership with the attached engine so identical layer IDs in simultaneous scenes cannot overwrite or remove one another. Re-register retained ALPR and cyclone picks on attachment, and resolve voice pointer picks against the engine that supplied the pick. Preserve Cesium's selection, sibling ownership, inert trails, touch expansion, drag and long-press rules on both engines. Extract the unchanged gesture predicates so portable picking does not acquire a native input dependency. Exercise scene isolation, teardown and reattachment on fake and headless Cesium engines, retaining the existing layer assertions. No coupling count falls or grows. Cesium and Navara renderer lines remain 0 and 189; external Cesium imports 3; viewer lines 910; Navara kind checks 2; engine sniff calls 0; extension reads 3; ground passes 1; SDK loads 3; screen-handler files 4; engine-name lines 33.
This commit is contained in:
1 parent
52c6f68bd5
commit
4252ed0798
53 files changed
+712
-277
No files matched your search
@@ -270,8 +270,15 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
unknown kind and no native extensions, draws fixtures, hides or releases them,
|
||||
and checks primitives, subscriptions and service records before engine
|
||||
destruction. The three self-drawing entries are named skips. This is a
|
||||
display-level rehearsal; application kind registration, scene-service
|
||||
singletons, queue consolidation and native debug access remain work.
|
||||
display-level rehearsal; application kind registration, the remaining scene
|
||||
services, queue consolidation and native debug access remain work.
|
||||
- **Scene pick ownership.** Each attachment owns `scene.services.picking` and
|
||||
`scene.services.selection` (`src/data/sceneServices.js`). Layers and displays
|
||||
resolve these services with their attached engine; simultaneous engines do
|
||||
not share owner predicates or click subscriptions. Detach releases both
|
||||
services before the renderer is parked. Reattachment creates fresh services,
|
||||
while retained layers register their current picks again. The overlay host,
|
||||
render governor and tracked readout still have module-owned state.
|
||||
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||
camera mode and URL state outlive a switch. A layer that is a session model
|
||||
gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
|
||||
|
||||
@@ -538,6 +538,15 @@ Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use
|
||||
these methods on every engine. Pointer claims remain separate from camera
|
||||
input suspension.
|
||||
|
||||
Pick ownership belongs to the scene attachment, not to an engine implementation.
|
||||
`scene.services.picking` owns its predicates and `scene.services.selection` owns
|
||||
its shared click dispatcher. Layer entry points resolve these services using
|
||||
their attached engine (`sceneServicesFor(engine)`); the pick-registry helper
|
||||
functions take that engine as their final argument. Standalone entries acquire
|
||||
the same services on first use. The application destroys them on detach and
|
||||
creates fresh services when reattaching a parked engine. Identical layer IDs in
|
||||
simultaneous scenes cannot replace or unregister one another's picks.
|
||||
|
||||
## Render effects
|
||||
|
||||
The optional `visualStyles`, `bloom` and `contactSonar` capabilities are
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
[
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/sceneServices.test.mjs",
|
||||
"src/data/trackingGesture.js",
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
|
||||
@@ -1405,6 +1405,7 @@
|
||||
"src/geoDistance.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/data/trackingGesture.js",
|
||||
"src/layers/flights/controller.js",
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
@@ -1476,6 +1477,7 @@
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/data/trackingGesture.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
@@ -1543,6 +1545,7 @@
|
||||
"src/geoDistance.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/data/trackingGesture.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/index.js",
|
||||
"src/data/inputOwnership.js",
|
||||
@@ -2584,6 +2587,8 @@
|
||||
"src/data/local_data/us_census_counties/counties.json",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/trackingGesture.js",
|
||||
"src/data/radio.js",
|
||||
"src/data/radioCountry.js",
|
||||
"src/data/regionalBrief.js",
|
||||
@@ -3741,6 +3746,8 @@
|
||||
"src/data/local_data/us_census_counties/counties.json",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/trackingGesture.js",
|
||||
"src/data/radioCountry.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/bundledJson.js",
|
||||
@@ -5030,6 +5037,8 @@
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/meshFloorSampler.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/trackingGesture.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/data/spriteOrder.js",
|
||||
"src/data/terrainHeights.js",
|
||||
@@ -5131,7 +5140,8 @@
|
||||
"src/styles/noir.js",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/exclusivePointer.js"
|
||||
"src/globe/exclusivePointer.js",
|
||||
"src/data/selectionDispatcher.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ test('the scene constructs the engine with the viewer and returns it beside the
|
||||
assert.match(scene, /tilesetRef\.current = tileset;/);
|
||||
assert.match(
|
||||
scene.replace(/\s+/g, ' '),
|
||||
/return \{ viewer, engine, tileset, mapStackController, operations,? \};/,
|
||||
/return \{ viewer, engine, tileset, mapStackController, operations, services,? \};/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApplicationScene } from './scene.js';
|
||||
import { sceneServicesFor } from '../data/sceneServices.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import {
|
||||
installEngineRenderGovernor,
|
||||
@@ -260,3 +261,19 @@ test('parking or tearing down the Navara scene releases a running camera verb',
|
||||
if (end === 'park') cache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test('the application scene owns and releases its pick services', async () => {
|
||||
const run = await startScene({ googleApiKey: '' });
|
||||
const { engine, services } = run.scene;
|
||||
try {
|
||||
assert.equal(sceneServicesFor(engine), services);
|
||||
services.selection.register('flights', { owns: (id) => id === 'contact' });
|
||||
assert.equal(services.picking.findPickOwner('contact'), 'flights');
|
||||
} finally {
|
||||
await run.teardown();
|
||||
}
|
||||
assert.equal(services.picking.findPickOwner('contact'), null);
|
||||
services.picking.registerPickOwner('late', () => true);
|
||||
assert.equal(services.picking.findPickOwner('contact'), null);
|
||||
assert.equal(engine.isDestroyed(), true);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSceneServices } from '../data/sceneServices.js';
|
||||
import { releaseCameraVerbs } from '../cameraVerbs.js';
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
import {
|
||||
@@ -79,6 +80,8 @@ export async function createApplicationScene({
|
||||
const { viewer, engine, tileset, mapStackController } = rendererCache
|
||||
? await rendererCache.acquire(options.engineKind, build, defer)
|
||||
: await build({ defer });
|
||||
const services = createSceneServices(engine);
|
||||
defer(services.destroy);
|
||||
if (layerSession) {
|
||||
// DEM cells belong to the session. Mesh samples came from the outgoing
|
||||
// scene, including when the incoming canvas was warm in the cache.
|
||||
@@ -93,6 +96,7 @@ export async function createApplicationScene({
|
||||
tileset,
|
||||
mapStackController,
|
||||
operations,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -600,7 +600,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
ordered(
|
||||
vesselClick,
|
||||
[
|
||||
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
|
||||
/isOwnedByOtherLayer\(\s*'ais-live-vessels',\s*pickedId,\s*state\.renderer\?\.engine,?\s*\)/,
|
||||
'_vesselOverlayHost.hitTest?.(',
|
||||
'selectAndFocusVessel(record)',
|
||||
],
|
||||
@@ -626,7 +626,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
ordered(
|
||||
fireClick,
|
||||
[
|
||||
'isOwnedByOtherLayer(id, pickedId)',
|
||||
/isOwnedByOtherLayer\(\s*id,\s*pickedId,\s*layerState\._renderer\?\.engine,?\s*\)/,
|
||||
'overlayHost.hitTest?.(',
|
||||
'selectAndFocusFire(carded)',
|
||||
],
|
||||
|
||||
@@ -32,7 +32,9 @@ import aisLiveVesselsLayer from './aisLiveVessels.js';
|
||||
import { registerEntityContext, selectEntityContext } from './contextStore.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { pickRegistryFor } from './pickRegistry.js';
|
||||
const wireEngine = {};
|
||||
const { registerPickOwner, unregisterPickOwner } = pickRegistryFor(wireEngine);
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import {
|
||||
@@ -959,7 +961,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
trackedEntityChanged,
|
||||
};
|
||||
const renderer = ({ billboardCollection }) =>
|
||||
vesselViewerRenderer({ viewer: followViewer, billboardCollection });
|
||||
({ ...vesselViewerRenderer({ viewer: followViewer, billboardCollection }), engine: wireEngine });
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
|
||||
@@ -383,12 +383,12 @@ const SIBLING_PICKS = [
|
||||
for (const [layerId, ownedId, picked] of SIBLING_PICKS) {
|
||||
test(`a ${layerId} pick shaped ${JSON.stringify(picked)} wins over a card hit`, () => {
|
||||
const fire = makeFire();
|
||||
registerPickOwner(layerId, (pickedId) => pickedId === ownedId);
|
||||
const h = harness({
|
||||
fires: [fire],
|
||||
picked,
|
||||
cardHit: `fire:${fireDetectionKey(fire)}`,
|
||||
});
|
||||
registerPickOwner(layerId, (pickedId) => pickedId === ownedId, h.engine);
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(
|
||||
@@ -398,7 +398,7 @@ for (const [layerId, ownedId, picked] of SIBLING_PICKS) {
|
||||
);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
unregisterPickOwner(layerId);
|
||||
unregisterPickOwner(layerId, h.engine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+143
-89
@@ -11,11 +11,9 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { sceneServicesFor } from './sceneServices.js';
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
|
||||
const _owners = new Map();
|
||||
|
||||
/**
|
||||
* Resolves a scene.pick() result to a String pick id for the ownership scan.
|
||||
*
|
||||
@@ -46,99 +44,155 @@ export function resolvePickId(picked) {
|
||||
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a pick-ownership predicate for a layer.
|
||||
* @param {string} layerId - Owning layer id (e.g. 'flights').
|
||||
* @param {(pickedId: string) => boolean} predicate - Returns true when the
|
||||
* picked primitive/entity id belongs to this layer.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function registerPickOwner(layerId, predicate) {
|
||||
if (!layerId || typeof predicate !== 'function') return;
|
||||
_owners.set(layerId, predicate);
|
||||
}
|
||||
/** Own the pick predicates for one scene. */
|
||||
export function createPickRegistry() {
|
||||
const _owners = new Map();
|
||||
let destroyed = false;
|
||||
|
||||
/**
|
||||
* Removes a layer's ownership predicate (call on disable/destroy).
|
||||
* @param {string} layerId - Owning layer id.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function unregisterPickOwner(layerId) {
|
||||
_owners.delete(layerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered layer that owns a picked id, or null.
|
||||
* @param {string} pickedId - Canonical pick id from {@link resolvePickId}.
|
||||
* @returns {string|null} Owning layer id.
|
||||
*/
|
||||
export function findPickOwner(pickedId) {
|
||||
if (!pickedId) return null;
|
||||
for (const [ownerId, predicate] of _owners) {
|
||||
try {
|
||||
if (predicate(pickedId)) return ownerId;
|
||||
} catch {
|
||||
// a broken predicate must never break picking
|
||||
}
|
||||
/**
|
||||
* Registers a pick-ownership predicate for a layer.
|
||||
* @param {string} layerId - Owning layer id (e.g. 'flights').
|
||||
* @param {(pickedId: string) => boolean} predicate - Returns true when the
|
||||
* picked primitive/entity id belongs to this layer.
|
||||
* @returns {void}
|
||||
*/
|
||||
function registerPickOwner(layerId, predicate) {
|
||||
if (destroyed || !layerId || typeof predicate !== 'function') return;
|
||||
_owners.set(layerId, predicate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when some OTHER registered layer owns the picked id.
|
||||
* @param {string} layerId - The asking layer's id (excluded from the scan).
|
||||
* @param {string} pickedId - Picked primitive/entity id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
if (!pickedId) return false;
|
||||
for (const [ownerId, predicate] of _owners) {
|
||||
if (ownerId === layerId) continue;
|
||||
try {
|
||||
if (predicate(pickedId)) return true;
|
||||
} catch {
|
||||
// a broken predicate must never break click handling
|
||||
}
|
||||
/**
|
||||
* Removes a layer's ownership predicate (call on disable/destroy).
|
||||
* @param {string} layerId - Owning layer id.
|
||||
* @returns {void}
|
||||
*/
|
||||
function unregisterPickOwner(layerId) {
|
||||
_owners.delete(layerId);
|
||||
}
|
||||
return false;
|
||||
|
||||
/**
|
||||
* The registered layer that owns a picked id, or null.
|
||||
* @param {string} pickedId - Canonical pick id from {@link resolvePickId}.
|
||||
* @returns {string|null} Owning layer id.
|
||||
*/
|
||||
function findPickOwner(pickedId) {
|
||||
if (!pickedId) return null;
|
||||
for (const [ownerId, predicate] of _owners) {
|
||||
try {
|
||||
if (predicate(pickedId)) return ownerId;
|
||||
} catch {
|
||||
// a broken predicate must never break picking
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when some OTHER registered layer owns the picked id.
|
||||
* @param {string} layerId - The asking layer's id (excluded from the scan).
|
||||
* @param {string} pickedId - Picked primitive/entity id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
if (!pickedId) return false;
|
||||
for (const [ownerId, predicate] of _owners) {
|
||||
if (ownerId === layerId) continue;
|
||||
try {
|
||||
if (predicate(pickedId)) return true;
|
||||
} catch {
|
||||
// a broken predicate must never break click handling
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
function pickContact(scene, position) {
|
||||
return nearbyContact(
|
||||
scene.pick(position),
|
||||
position,
|
||||
scene.drillPick?.bind(scene),
|
||||
);
|
||||
}
|
||||
|
||||
function nearbyContact(picked, position, drillPick) {
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
registerPickOwner('trails', (id) => String(id).startsWith('gev-trail:'));
|
||||
|
||||
return {
|
||||
resolvePickId,
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
findPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
pickContact,
|
||||
nearbyContact,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
_owners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
export function pickContact(scene, position) {
|
||||
return nearbyContact(
|
||||
scene.pick(position),
|
||||
position,
|
||||
scene.drillPick?.bind(scene),
|
||||
/** Pick ownership of the scene attached to this engine. */
|
||||
export function pickRegistryFor(engine) {
|
||||
return sceneServicesFor(engine)?.picking ?? null;
|
||||
}
|
||||
|
||||
export function registerPickOwner(layerId, predicate, engine) {
|
||||
return pickRegistryFor(engine)?.registerPickOwner(layerId, predicate);
|
||||
}
|
||||
|
||||
export function unregisterPickOwner(layerId, engine) {
|
||||
return pickRegistryFor(engine)?.unregisterPickOwner(layerId);
|
||||
}
|
||||
|
||||
export function findPickOwner(pickedId, engine) {
|
||||
return pickRegistryFor(engine)?.findPickOwner(pickedId) ?? null;
|
||||
}
|
||||
|
||||
export function isOwnedByOtherLayer(layerId, pickedId, engine) {
|
||||
return (
|
||||
pickRegistryFor(engine)?.isOwnedByOtherLayer(layerId, pickedId) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function nearbyContact(picked, position, drillPick) {
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
export function pickContact(scene, position, engine) {
|
||||
return pickRegistryFor(engine)?.pickContact(scene, position) ?? null;
|
||||
}
|
||||
|
||||
export function nearbyContact(picked, position, drillPick, engine) {
|
||||
return (
|
||||
pickRegistryFor(engine)?.nearbyContact(picked, position, drillPick) ??
|
||||
picked
|
||||
);
|
||||
}
|
||||
@@ -11,13 +11,8 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
resolvePickId,
|
||||
pickContact,
|
||||
} from './pickRegistry.js';
|
||||
import { createPickRegistry, resolvePickId } from './pickRegistry.js';
|
||||
const { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, pickContact } = createPickRegistry();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePickId — canonical String coercion for every layer's pick shape
|
||||
|
||||
@@ -97,16 +97,20 @@ function descriptorLabel(found, fallback) {
|
||||
* @param {object|null} picked Result of `scene.pick()`.
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.dataManager] Layer lifecycle (enabled state, modules).
|
||||
* @param {object} [options.engine] Engine of the scene that supplied the pick.
|
||||
* @returns {{layerId: string, id: string, label: string, kind: string, lat: number|null, lon: number|null, altitudeM?: number|null}|null}
|
||||
*/
|
||||
export function resolvePickedContext(picked, { dataManager = null } = {}) {
|
||||
export function resolvePickedContext(
|
||||
picked,
|
||||
{ dataManager = null, engine = dataManager?.engine } = {},
|
||||
) {
|
||||
if (!picked) return null;
|
||||
const record = contextCarrierRecord(picked, dataManager);
|
||||
if (record) return fromRecord(record);
|
||||
|
||||
const pickId = resolvePickId(picked);
|
||||
if (!pickId) return null;
|
||||
const layerId = findPickOwner(pickId);
|
||||
const layerId = findPickOwner(pickId, engine);
|
||||
if (!layerId || IGNORED_OWNERS.has(layerId)) return null;
|
||||
if (dataManager?.isEnabled && !dataManager.isEnabled(layerId)) return null;
|
||||
const layer = dataManager?.layers?.get?.(layerId);
|
||||
|
||||
@@ -11,11 +11,14 @@ globalThis.CustomEvent ||= class CustomEvent {
|
||||
globalThis.dispatchEvent ||= () => true;
|
||||
|
||||
const { registerEntityContext } = await import('./contextStore.js');
|
||||
const { registerPickOwner, unregisterPickOwner, findPickOwner } = await import('./pickRegistry.js');
|
||||
const engine = {};
|
||||
const { pickRegistryFor } = await import('./pickRegistry.js');
|
||||
const { registerPickOwner, unregisterPickOwner, findPickOwner } = pickRegistryFor(engine);
|
||||
const { resolvePickedContext, pickedKindForLayer } = await import('./pickedContext.js');
|
||||
|
||||
function manager({ enabled = new Set(['flights', 'local-datacenters']), modules = {} } = {}) {
|
||||
return {
|
||||
engine,
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
layers: new Map(Object.entries(modules).map(([id, module]) => [id, { name: id, module }])),
|
||||
};
|
||||
@@ -73,3 +76,23 @@ test('disabled layers, trails and empty picks never resolve', () => {
|
||||
assert.equal(resolvePickedContext({ id: 'unknown' }, { dataManager: manager() }), null);
|
||||
assert.equal(pickedKindForLayer('ais-live-vessels'), 'vessel');
|
||||
});
|
||||
|
||||
test('an explicit pointer engine resolves only that scene, without a manager', () => {
|
||||
const first = {};
|
||||
const second = {};
|
||||
const a = pickRegistryFor(first);
|
||||
const b = pickRegistryFor(second);
|
||||
a.registerPickOwner('flights', (id) => id === 'shared');
|
||||
b.registerPickOwner('satellites', (id) => id === 'shared');
|
||||
try {
|
||||
const picked = { id: 'shared' };
|
||||
assert.equal(resolvePickedContext(picked, { engine: first }).layerId, 'flights');
|
||||
assert.equal(resolvePickedContext(picked, { engine: second }).layerId, 'satellites');
|
||||
a.destroy();
|
||||
assert.equal(resolvePickedContext(picked, { engine: first }), null);
|
||||
assert.equal(resolvePickedContext(picked, { engine: second }).layerId, 'satellites');
|
||||
} finally {
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createPickRegistry } from './pickRegistry.js';
|
||||
import { createSelectionDispatcher } from './selectionDispatcher.js';
|
||||
|
||||
// Portable layers may attach directly to an engine, outside the application.
|
||||
// Their lookup reaches the same services the app publishes on its scene.
|
||||
const scenes = new WeakMap();
|
||||
|
||||
/** Create the services for one attachment, including reuse of a parked engine. */
|
||||
export function createSceneServices(engine, options = {}) {
|
||||
scenes.get(engine)?.destroy();
|
||||
const picking = createPickRegistry();
|
||||
const selection = createSelectionDispatcher(engine, { ...options, picking });
|
||||
let destroyed = false;
|
||||
const services = {
|
||||
picking,
|
||||
selection,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
selection.destroy();
|
||||
picking.destroy();
|
||||
},
|
||||
};
|
||||
scenes.set(engine, services);
|
||||
return services;
|
||||
}
|
||||
|
||||
/** A detached scene stays inert until an explicit new attachment replaces it. */
|
||||
export function sceneServicesFor(engine) {
|
||||
if (!engine) return null;
|
||||
return scenes.get(engine) ?? createSceneServices(engine);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
|
||||
import { createSceneServices, sceneServicesFor } from './sceneServices.js';
|
||||
import { pickRegistryFor } from './pickRegistry.js';
|
||||
import { selectionDispatcherFor } from './selectionDispatcher.js';
|
||||
import { createAlprCamerasLayer } from '../layers/alpr/index.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
|
||||
for (const makeHarness of GROUND_HARNESSES) {
|
||||
test(`${makeHarness.name}: an enabled ALPR layer transfers pick ownership on reattachment`, (t) => {
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const a = createSceneServices(first.engine);
|
||||
const b = createSceneServices(second.engine);
|
||||
const noop = () => {};
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: { fetch: () => assert.fail('Reattachment must not fetch') },
|
||||
services: { picking, render: { governorRequestRender: noop } },
|
||||
rendererFactory({ state }) {
|
||||
state.recordById.set('camera', {});
|
||||
return {
|
||||
init: noop,
|
||||
setVisible: noop,
|
||||
renderRecords: noop,
|
||||
hideOnMapCredit: noop,
|
||||
clearSelection: noop,
|
||||
clearRendered: noop,
|
||||
destroy: noop,
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
});
|
||||
layer.init(null, { engine: first.engine });
|
||||
layer.enable();
|
||||
assert.equal(a.picking.findPickOwner('camera'), 'alpr-cameras');
|
||||
assert.equal(b.picking.findPickOwner('camera'), null);
|
||||
layer.detachRenderer();
|
||||
assert.equal(a.picking.findPickOwner('camera'), null);
|
||||
layer.attachRenderer(null, { engine: second.engine });
|
||||
assert.equal(b.picking.findPickOwner('camera'), 'alpr-cameras');
|
||||
layer.disable();
|
||||
assert.equal(b.picking.findPickOwner('camera'), null);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: simultaneous scenes isolate ownership and clicks`, (t) => {
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const scenes = [first, second].map(({ engine }) => ({
|
||||
engine,
|
||||
services: createSceneServices(engine, { keyTarget: new EventTarget() }),
|
||||
}));
|
||||
t.after(() => {
|
||||
for (const scene of scenes) scene.services.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
});
|
||||
const calls = [[], []];
|
||||
scenes.forEach(({ engine, services }, i) => {
|
||||
assert.equal(pickRegistryFor(engine), services.picking);
|
||||
assert.equal(selectionDispatcherFor(engine), services.selection);
|
||||
services.selection.register('flights', {
|
||||
owns: (id) => id === `flight-${i}`,
|
||||
onSelect: (id) => calls[i].push(id),
|
||||
onEmptyClick: () => calls[i].push('empty'),
|
||||
});
|
||||
});
|
||||
const [a, b] = scenes.map((scene) => scene.services);
|
||||
assert.equal(a.picking.findPickOwner('flight-0'), 'flights');
|
||||
assert.equal(b.picking.findPickOwner('flight-0'), null);
|
||||
a.picking.registerPickOwner('cctv', (id) => id === 'camera');
|
||||
assert.equal(a.picking.isOwnedByOtherLayer('flights', 'camera'), true);
|
||||
assert.equal(b.picking.isOwnedByOtherLayer('flights', 'camera'), false);
|
||||
first.click({ id: 'flight-0' });
|
||||
second.click({ id: 'flight-1' });
|
||||
first.click({ id: 'camera' });
|
||||
second.click({ id: 'camera' });
|
||||
assert.deepEqual(calls, [['flight-0'], ['flight-1', 'empty']]);
|
||||
a.picking.unregisterPickOwner('flights');
|
||||
assert.equal(b.picking.findPickOwner('flight-1'), 'flights');
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: scene teardown and parked reattachment leave siblings live`, (t) => {
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const keys = [new EventTarget(), new EventTarget()];
|
||||
const a = createSceneServices(first.engine, { keyTarget: keys[0] });
|
||||
const b = createSceneServices(second.engine, { keyTarget: keys[1] });
|
||||
let next;
|
||||
t.after(() => {
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
next?.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
});
|
||||
const calls = [];
|
||||
const owner = (name) => ({
|
||||
owns: (id) => id === 'shared-id',
|
||||
onSelect: () => calls.push(name),
|
||||
onEscape: () => calls.push(`${name}-escape`),
|
||||
});
|
||||
const lateRemove = a.selection.register('flights', owner('first'));
|
||||
b.selection.register('flights', owner('second'));
|
||||
assert.equal(first.listeners(), 4);
|
||||
assert.equal(second.listeners(), 4);
|
||||
a.destroy();
|
||||
a.destroy();
|
||||
assert.equal(first.listeners(), 0);
|
||||
assert.equal(second.listeners(), 4);
|
||||
assert.equal(sceneServicesFor(first.engine), a);
|
||||
a.picking.registerPickOwner('late', () => true);
|
||||
a.selection.register('late', owner('late'));
|
||||
assert.equal(a.picking.findPickOwner('shared-id'), null);
|
||||
first.click({ id: 'shared-id' });
|
||||
second.click({ id: 'shared-id' });
|
||||
for (const target of keys)
|
||||
target.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.deepEqual(calls, ['second', 'second-escape']);
|
||||
next = createSceneServices(first.engine, { keyTarget: keys[0] });
|
||||
next.selection.register('flights', owner('next'));
|
||||
lateRemove();
|
||||
a.destroy();
|
||||
assert.equal(next.picking.findPickOwner('shared-id'), 'flights');
|
||||
first.click({ id: 'shared-id' });
|
||||
assert.deepEqual(calls, ['second', 'second-escape', 'next']);
|
||||
assert.equal(b.picking.findPickOwner('shared-id'), 'flights');
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nearbyContact } from './pickRegistry.js';
|
||||
import { sceneServicesFor } from './sceneServices.js';
|
||||
// One selection dispatcher for the layers that draw through the globe
|
||||
// engine: one click subscription with picks
|
||||
// (engine.input.on('click', …, { pick: true, drill: 8 })), the press
|
||||
@@ -27,16 +27,11 @@ import { nearbyContact } from './pickRegistry.js';
|
||||
// layer.
|
||||
|
||||
import { isPointerFree as pointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
findPickOwner,
|
||||
registerPickOwner,
|
||||
resolvePickId,
|
||||
unregisterPickOwner,
|
||||
} from './pickRegistry.js';
|
||||
import { pickRegistryFor, resolvePickId } from './pickRegistry.js';
|
||||
import {
|
||||
isTrackingClickGesture,
|
||||
isTrackingSelectionGesture,
|
||||
} from './trackingClickGesture.js';
|
||||
} from './trackingGesture.js';
|
||||
|
||||
const SELECTION_DRILL = 8;
|
||||
|
||||
@@ -47,11 +42,18 @@ const SELECTION_DRILL = 8;
|
||||
export function createSelectionDispatcher(
|
||||
engine,
|
||||
{
|
||||
picking = pickRegistryFor(engine),
|
||||
keyTarget = globalThis.document,
|
||||
now = () => performance.now(),
|
||||
isPointerFree = pointerFree,
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
findPickOwner,
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
nearbyContact,
|
||||
} = picking;
|
||||
const owners = new Map();
|
||||
let removers = [];
|
||||
let destroyed = false;
|
||||
@@ -254,21 +256,9 @@ export function createSelectionDispatcher(
|
||||
};
|
||||
}
|
||||
|
||||
const dispatchers = new WeakMap();
|
||||
|
||||
/**
|
||||
* The application's one dispatcher for `engine`, created on first use (the
|
||||
* layers that moved onto the engine share it).
|
||||
* @param {import('../globe/contract.js').GlobeEngine} engine
|
||||
*/
|
||||
/** The selection dispatcher owned by the engine's attached scene. */
|
||||
export function selectionDispatcherFor(engine) {
|
||||
if (!engine) return null;
|
||||
let dispatcher = dispatchers.get(engine);
|
||||
if (!dispatcher) {
|
||||
dispatcher = createSelectionDispatcher(engine);
|
||||
dispatchers.set(engine, dispatcher);
|
||||
}
|
||||
return dispatcher;
|
||||
return sceneServicesFor(engine)?.selection ?? null;
|
||||
}
|
||||
|
||||
function safely(run) {
|
||||
|
||||
@@ -78,8 +78,8 @@ test('a click on an owned object selects it; empty space deselects everyone', ()
|
||||
});
|
||||
|
||||
test("another layer's pick is not empty space, owned through the shared registry", () => {
|
||||
const { dispatcher, click } = setup({ 10: [{ id: 'cctv-1' }] });
|
||||
registerPickOwner('cctv', (id) => id.startsWith('cctv'));
|
||||
const { engine, dispatcher, click } = setup({ 10: [{ id: 'cctv-1' }] });
|
||||
registerPickOwner('cctv', (id) => id.startsWith('cctv'), engine);
|
||||
try {
|
||||
const flights = recorder();
|
||||
dispatcher.register('flights', {
|
||||
@@ -87,19 +87,21 @@ test("another layer's pick is not empty space, owned through the shared registry
|
||||
...flights.handlers,
|
||||
});
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('cctv', 'x'),
|
||||
isOwnedByOtherLayer('cctv', 'x', engine),
|
||||
true,
|
||||
'registered for legacy handlers',
|
||||
);
|
||||
click(10);
|
||||
assert.deepEqual(flights.calls, []);
|
||||
} finally {
|
||||
unregisterPickOwner('cctv');
|
||||
unregisterPickOwner('cctv', engine);
|
||||
}
|
||||
});
|
||||
|
||||
test('a pick no layer owns is empty space, as for the old handlers', () => {
|
||||
const { dispatcher, click } = setup({ 10: [{ id: 'unclaimed-entity' }] });
|
||||
const { engine, dispatcher, click } = setup({
|
||||
10: [{ id: 'unclaimed-entity' }],
|
||||
});
|
||||
const flights = recorder();
|
||||
dispatcher.register('flights', {
|
||||
owns: (id) => id === 'x',
|
||||
@@ -115,7 +117,7 @@ test('a pick no layer owns is empty space, as for the old handlers', () => {
|
||||
});
|
||||
|
||||
test('trails do not take clicks: the object under a trail is selected', () => {
|
||||
const { dispatcher, click } = setup({
|
||||
const { engine, dispatcher, click } = setup({
|
||||
10: [{ id: 'trail-ae01ce', owner: 'flights-trail' }, { id: 'ae01ce' }],
|
||||
});
|
||||
const flights = recorder();
|
||||
@@ -129,10 +131,10 @@ test('trails do not take clicks: the object under a trail is selected', () => {
|
||||
});
|
||||
|
||||
test("a drill-through owner finds its object under another layer's", () => {
|
||||
const { dispatcher, click } = setup({
|
||||
const { engine, dispatcher, click } = setup({
|
||||
10: [{ id: 'cctv-1' }, { id: 'adsb:abc' }],
|
||||
});
|
||||
registerPickOwner('cctv', (id) => id.startsWith('cctv'));
|
||||
registerPickOwner('cctv', (id) => id.startsWith('cctv'), engine);
|
||||
try {
|
||||
const local = recorder();
|
||||
dispatcher.register('local-adsb', {
|
||||
@@ -143,12 +145,12 @@ test("a drill-through owner finds its object under another layer's", () => {
|
||||
click(10);
|
||||
assert.deepEqual(local.calls, [['select', 'adsb:abc', undefined]]);
|
||||
} finally {
|
||||
unregisterPickOwner('cctv');
|
||||
unregisterPickOwner('cctv', engine);
|
||||
}
|
||||
});
|
||||
|
||||
test('a drag selects nothing and deselects nothing; a long press still selects', () => {
|
||||
const { dispatcher, click } = setup({ 10: [{ id: 'ae01ce' }] });
|
||||
const { engine, dispatcher, click } = setup({ 10: [{ id: 'ae01ce' }] });
|
||||
const flights = recorder();
|
||||
dispatcher.register('flights', {
|
||||
owns: (id) => id === 'ae01ce',
|
||||
@@ -189,7 +191,7 @@ test('unregister and destroy remove the subscriptions', () => {
|
||||
dispatcher.destroy();
|
||||
click(10);
|
||||
assert.deepEqual(flights.calls, []);
|
||||
assert.equal(isOwnedByOtherLayer('other', 'a'), false);
|
||||
assert.equal(isOwnedByOtherLayer('other', 'a', engine), false);
|
||||
assert.ok(engine.fake.callsOf('input.on').length >= 1);
|
||||
});
|
||||
|
||||
@@ -208,7 +210,7 @@ test('the application has one dispatcher per engine', () => {
|
||||
|
||||
test('a look-under owner (local ADS-B) keeps its own click rule beside the others', () => {
|
||||
// x: what lies under each column, top first.
|
||||
const { dispatcher, click } = setup({
|
||||
const { engine, dispatcher, click } = setup({
|
||||
10: [{ id: 'local-adsb:a' }], // its own marker
|
||||
20: [{ primitive: { tileset: true } }, { id: 'local-adsb:a' }], // under a tile
|
||||
30: [{ id: 'gev-trail:local-adsb-head-1' }, { id: 'local-adsb:b' }], // under a trail
|
||||
@@ -217,8 +219,8 @@ test('a look-under owner (local ADS-B) keeps its own click rule beside the other
|
||||
60: [{ id: 'cctv-1' }, { id: 'local-adsb:a' }], // a registry owner's on top
|
||||
70: [], // empty map
|
||||
});
|
||||
registerPickOwner('trails', (id) => id.startsWith('gev-trail:'));
|
||||
registerPickOwner('cctv', (id) => id.startsWith('cctv'));
|
||||
registerPickOwner('trails', (id) => id.startsWith('gev-trail:'), engine);
|
||||
registerPickOwner('cctv', (id) => id.startsWith('cctv'), engine);
|
||||
try {
|
||||
const local = recorder();
|
||||
const flights = recorder();
|
||||
@@ -252,8 +254,8 @@ test('a look-under owner (local ADS-B) keeps its own click rule beside the other
|
||||
['empty', 0],
|
||||
]);
|
||||
} finally {
|
||||
unregisterPickOwner('trails');
|
||||
unregisterPickOwner('cctv');
|
||||
unregisterPickOwner('trails', engine);
|
||||
unregisterPickOwner('cctv', engine);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,38 +1,12 @@
|
||||
import { trackingEventTypes } from '../globe/cesium/screenEvents.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
|
||||
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
|
||||
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
|
||||
|
||||
/**
|
||||
* Decide whether a completed press stayed spatially click-like. Duration is
|
||||
* deliberately ignored so a stationary long press may still select a contact;
|
||||
* callers apply the full click classifier before destructive deselection.
|
||||
* @param {{travelPx?: number}} gesture - Accumulated pointer path.
|
||||
* @returns {boolean} True when travel stays within the click limit.
|
||||
*/
|
||||
export function isTrackingSelectionGesture(gesture = {}) {
|
||||
const travelPx = Number.isFinite(gesture.travelPx)
|
||||
? Math.max(0, gesture.travelPx)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return travelPx <= MAX_TRACKING_CLICK_TRAVEL_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a completed press is a clean click suitable for deselection
|
||||
* or another action that requires both short duration and low travel.
|
||||
* @param {{travelPx?: number, durationMs?: number}} gesture - Accumulated path and press time.
|
||||
* @returns {boolean} True when the gesture stays within both click limits.
|
||||
*/
|
||||
export function isTrackingClickGesture(gesture = {}) {
|
||||
const durationMs = Number.isFinite(gesture.durationMs)
|
||||
? Math.max(0, gesture.durationMs)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return (
|
||||
isTrackingSelectionGesture(gesture) &&
|
||||
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
|
||||
);
|
||||
}
|
||||
export {
|
||||
MAX_TRACKING_CLICK_TRAVEL_PX,
|
||||
MAX_TRACKING_CLICK_DURATION_MS,
|
||||
isTrackingSelectionGesture,
|
||||
isTrackingClickGesture,
|
||||
} from './trackingGesture.js';
|
||||
|
||||
/**
|
||||
* Bind LEFT_DOWN/MOUSE_MOVE/LEFT_UP accounting ahead of a scene click.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
|
||||
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
|
||||
|
||||
/**
|
||||
* Decide whether a completed press stayed spatially click-like. Duration is
|
||||
* deliberately ignored so a stationary long press may still select a contact;
|
||||
* callers apply the full click classifier before destructive deselection.
|
||||
* @param {{travelPx?: number}} gesture - Accumulated pointer path.
|
||||
* @returns {boolean} True when travel stays within the click limit.
|
||||
*/
|
||||
export function isTrackingSelectionGesture(gesture = {}) {
|
||||
const travelPx = Number.isFinite(gesture.travelPx)
|
||||
? Math.max(0, gesture.travelPx)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return travelPx <= MAX_TRACKING_CLICK_TRAVEL_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a completed press is a clean click suitable for deselection
|
||||
* or another action that requires both short duration and low travel.
|
||||
* @param {{travelPx?: number, durationMs?: number}} gesture - Accumulated path and press time.
|
||||
* @returns {boolean} True when the gesture stays within both click limits.
|
||||
*/
|
||||
export function isTrackingClickGesture(gesture = {}) {
|
||||
const durationMs = Number.isFinite(gesture.durationMs)
|
||||
? Math.max(0, gesture.durationMs)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return (
|
||||
isTrackingSelectionGesture(gesture) &&
|
||||
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function createAircraftSceneBinding({
|
||||
let resumeOrigin = 'programmatic';
|
||||
function removeSceneListeners() {
|
||||
parts.tracking._removeClickHandler();
|
||||
services.picking.unregisterPickOwner(id);
|
||||
services.picking.unregisterPickOwner(id, flightState._renderer?.engine);
|
||||
if (flightState._preRenderRemove) {
|
||||
flightState._preRenderRemove();
|
||||
flightState._preRenderRemove = null;
|
||||
|
||||
@@ -68,6 +68,7 @@ export function createAlprCamerasLayer({
|
||||
renderSaturated: false,
|
||||
};
|
||||
let renderer = null;
|
||||
let sceneEngine = null;
|
||||
let restoringView = false;
|
||||
|
||||
function setAlprStatus(status, error = null) {
|
||||
@@ -280,6 +281,7 @@ export function createAlprCamerasLayer({
|
||||
if (renderer?.ready) throw new Error('ALPR layer is already initialized');
|
||||
if (typeof rendererFactory !== 'function')
|
||||
throw new TypeError('An ALPR renderer factory is required');
|
||||
sceneEngine = options.engine;
|
||||
renderer = rendererFactory({
|
||||
state,
|
||||
services,
|
||||
@@ -288,15 +290,22 @@ export function createAlprCamerasLayer({
|
||||
});
|
||||
renderer.init(viewer, { onMove: scheduleLoad });
|
||||
if (state.enabled) {
|
||||
registerPickOwner(
|
||||
LAYER_ID,
|
||||
(id) => state.recordById.has(id),
|
||||
sceneEngine,
|
||||
);
|
||||
renderer.setVisible(true);
|
||||
if (!restoringView) renderer.renderRecords();
|
||||
}
|
||||
},
|
||||
detachRenderer() {
|
||||
unregisterPickOwner(LAYER_ID, sceneEngine);
|
||||
restoringView = true;
|
||||
clearTimeout(state.debounceTimer);
|
||||
renderer?.destroy({ retainContext: true });
|
||||
renderer = null;
|
||||
sceneEngine = null;
|
||||
},
|
||||
resumeViewport() {
|
||||
if (!restoringView) return;
|
||||
@@ -309,14 +318,18 @@ export function createAlprCamerasLayer({
|
||||
enable() {
|
||||
if (state.enabled) return;
|
||||
state.enabled = true;
|
||||
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
|
||||
registerPickOwner(
|
||||
LAYER_ID,
|
||||
(id) => state.recordById.has(id),
|
||||
sceneEngine,
|
||||
);
|
||||
renderer.setVisible(true);
|
||||
// DataLayerManager calls update() right after enable(); it owns the first fetch.
|
||||
},
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
renderer?.hideOnMapCredit();
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
unregisterPickOwner(LAYER_ID, sceneEngine);
|
||||
clearUnavailableRetry();
|
||||
clearTimeout(state.debounceTimer);
|
||||
state.abort?.abort();
|
||||
@@ -337,6 +350,7 @@ export function createAlprCamerasLayer({
|
||||
this.disable();
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
sceneEngine = null;
|
||||
state.records = [];
|
||||
state.recordById = new Map();
|
||||
state.lastQueryBox = null;
|
||||
|
||||
@@ -306,7 +306,7 @@ export function createBikeshareDisplay(engine, context) {
|
||||
}
|
||||
function releaseInput() {
|
||||
parts.selection._uninstallClickHandler();
|
||||
unregisterPickOwner?.(LAYER_ID);
|
||||
unregisterPickOwner?.(LAYER_ID, engine);
|
||||
}
|
||||
|
||||
const methods = {
|
||||
@@ -336,8 +336,10 @@ export function createBikeshareDisplay(engine, context) {
|
||||
state._overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
parts.selection._installClickHandler();
|
||||
// Pick ownership: station point ids are render-map keys.
|
||||
registerPickOwner?.(LAYER_ID, (pickedId) =>
|
||||
state._stationRenderMap.has(pickedId),
|
||||
registerPickOwner?.(
|
||||
LAYER_ID,
|
||||
(pickedId) => state._stationRenderMap.has(pickedId),
|
||||
engine,
|
||||
);
|
||||
attachCamera();
|
||||
void parts.viewport.runProximityCheck();
|
||||
|
||||
@@ -462,7 +462,7 @@ export function createLifecycle({
|
||||
if (!parts.renderer) return;
|
||||
layerState._restoringViewport = true;
|
||||
parts.renderer.hideCredit();
|
||||
unregisterPickOwner('cctv');
|
||||
unregisterPickOwner('cctv', parts.renderer?.engine);
|
||||
if (layerState._mapStackListener) {
|
||||
window.removeEventListener(
|
||||
'gev:map-stack-changed',
|
||||
@@ -528,19 +528,23 @@ export function createLifecycle({
|
||||
parts.renderer?.showCredit();
|
||||
// Pick-ownership (H2): camera billboards use the camera id directly;
|
||||
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
|
||||
registerPickOwner('cctv', (pickedId) => {
|
||||
if (layerState._recordById.has(pickedId)) return true;
|
||||
if (
|
||||
typeof pickedId === 'string' &&
|
||||
pickedId.startsWith(GIZMO_ID_PREFIX)
|
||||
)
|
||||
return true;
|
||||
const coverage =
|
||||
/^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec(
|
||||
pickedId,
|
||||
);
|
||||
return Boolean(coverage && layerState._recordById.has(coverage[1]));
|
||||
});
|
||||
registerPickOwner(
|
||||
'cctv',
|
||||
(pickedId) => {
|
||||
if (layerState._recordById.has(pickedId)) return true;
|
||||
if (
|
||||
typeof pickedId === 'string' &&
|
||||
pickedId.startsWith(GIZMO_ID_PREFIX)
|
||||
)
|
||||
return true;
|
||||
const coverage =
|
||||
/^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec(
|
||||
pickedId,
|
||||
);
|
||||
return Boolean(coverage && layerState._recordById.has(coverage[1]));
|
||||
},
|
||||
parts.renderer?.engine,
|
||||
);
|
||||
const activeRecord = parts.selection.getActiveRecord();
|
||||
if (activeRecord) {
|
||||
parts.projection.ensureProjectionRuntime(activeRecord);
|
||||
@@ -573,7 +577,7 @@ export function createLifecycle({
|
||||
disable() {
|
||||
parts.renderer?.hideCredit();
|
||||
layerState._enabled = false;
|
||||
unregisterPickOwner('cctv');
|
||||
unregisterPickOwner('cctv', parts.renderer?.engine);
|
||||
// ADJUST mode does not survive a layer toggle — predictable re-entry.
|
||||
layerState._calibrationMode = false;
|
||||
releaseContinuousRender('cctv-adjust');
|
||||
@@ -606,7 +610,7 @@ export function createLifecycle({
|
||||
'visibilitychange',
|
||||
parts.cards.handleVisibilityChange,
|
||||
);
|
||||
unregisterPickOwner('cctv');
|
||||
unregisterPickOwner('cctv', parts.renderer?.engine);
|
||||
if (layerState._mapStackListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener(
|
||||
'gev:map-stack-changed',
|
||||
|
||||
@@ -590,6 +590,7 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
|
||||
let credit = false;
|
||||
|
||||
return {
|
||||
engine,
|
||||
attach() {
|
||||
if (destroyed) throw new Error('CCTV display is destroyed');
|
||||
initialized = true;
|
||||
|
||||
@@ -244,6 +244,13 @@ export function createCyclonesLayer({
|
||||
removeClickCapture = null;
|
||||
owner?.destroy();
|
||||
}
|
||||
function registerOwnership() {
|
||||
registerPickOwner(
|
||||
'weather-cyclones',
|
||||
(id) => enabled && rendering?.ownsPickId?.(id) === true,
|
||||
engine,
|
||||
);
|
||||
}
|
||||
const layer = {
|
||||
id: 'weather-cyclones',
|
||||
name: 'Cyclone advisories',
|
||||
@@ -260,6 +267,7 @@ export function createCyclonesLayer({
|
||||
? createRendering(engine, { overlayHost })
|
||||
: null;
|
||||
if (!enabled) return;
|
||||
registerOwnership();
|
||||
installSelection();
|
||||
if (rendering && snapshot && !snapshot.unavailable) {
|
||||
const active = rendering;
|
||||
@@ -269,6 +277,7 @@ export function createCyclonesLayer({
|
||||
}
|
||||
},
|
||||
detachRenderer() {
|
||||
unregisterPickOwner('weather-cyclones', engine);
|
||||
removeSelection();
|
||||
++navigationGeneration;
|
||||
rendering?.destroy();
|
||||
@@ -286,16 +295,13 @@ export function createCyclonesLayer({
|
||||
enable() {
|
||||
if (!destroyed && !enabled) {
|
||||
enabled = true;
|
||||
registerPickOwner(
|
||||
'weather-cyclones',
|
||||
(id) => enabled && rendering?.ownsPickId?.(id) === true,
|
||||
);
|
||||
registerOwnership();
|
||||
installSelection();
|
||||
}
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
unregisterPickOwner('weather-cyclones');
|
||||
unregisterPickOwner('weather-cyclones', engine);
|
||||
removeSelection();
|
||||
request?.abort();
|
||||
request = null;
|
||||
|
||||
@@ -105,13 +105,16 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
||||
layer.enable();
|
||||
layer.enable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center', h.engine),
|
||||
true,
|
||||
'flight tracking recognizes the cyclone pick and leaves the camera alone',
|
||||
);
|
||||
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
|
||||
isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center', h.engine),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign', h.engine),
|
||||
false,
|
||||
'registration recognizes exact current IDs, not a prefix',
|
||||
);
|
||||
@@ -222,7 +225,7 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center', h.engine),
|
||||
false,
|
||||
);
|
||||
assert.equal(layer.getDiagnostics().selectionActive, false);
|
||||
@@ -238,7 +241,10 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
||||
assert.equal(h.picks, afterDisable, 'disable removes the click listener');
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center', h.engine),
|
||||
true,
|
||||
);
|
||||
h.click(click);
|
||||
assert.equal(
|
||||
h.picks,
|
||||
@@ -248,7 +254,7 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
||||
assert.equal(h.subscriptions(), 2);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center', h.engine),
|
||||
false,
|
||||
);
|
||||
const afterDestroy = h.picks;
|
||||
@@ -894,12 +900,15 @@ test('advisories delivered between scenes keep a deliberate selection without an
|
||||
const pending = deferred();
|
||||
let reads = 0;
|
||||
const drawn = [];
|
||||
const first = createFakeEngine();
|
||||
const second = createFakeEngine();
|
||||
const layer = createCyclonesLayer({
|
||||
feed: {
|
||||
getSnapshot: () =>
|
||||
++reads === 1 ? snapshot([storm()]) : pending.promise,
|
||||
},
|
||||
createRendering: () => ({
|
||||
ownsPickId: (id) => id === 'cyclone:ep152026:center',
|
||||
setSnapshot(next) {
|
||||
drawn.push(next.storms.map((s) => s.advisoryNumber));
|
||||
return true;
|
||||
@@ -909,17 +918,39 @@ test('advisories delivered between scenes keep a deliberate selection without an
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
layer.init(null, { engine: createFakeEngine() });
|
||||
layer.init(null, { engine: first });
|
||||
layer.enable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', first),
|
||||
true,
|
||||
);
|
||||
await layer.update();
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
const refresh = layer.update();
|
||||
layer.detachRenderer();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', first),
|
||||
false,
|
||||
);
|
||||
pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }]));
|
||||
await refresh;
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
await layer.attachRenderer(null, { engine: createFakeEngine() });
|
||||
await layer.attachRenderer(null, { engine: second });
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', second),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', first),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(drawn, [['10'], ['11']]);
|
||||
assert.equal(reads, 2);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', second),
|
||||
false,
|
||||
);
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
});
|
||||
@@ -1537,7 +1537,7 @@ export function createDirectionsLayer({
|
||||
clearRouteGraphics();
|
||||
_selectedStep = selected;
|
||||
_removeClickHandler();
|
||||
services.picking.unregisterPickOwner('directions');
|
||||
services.picking.unregisterPickOwner('directions', _engine);
|
||||
_display?.destroy();
|
||||
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
|
||||
_display = _engine = null;
|
||||
@@ -1554,6 +1554,7 @@ export function createDirectionsLayer({
|
||||
'directions',
|
||||
(pickedId) =>
|
||||
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
|
||||
_engine,
|
||||
);
|
||||
syncRenderHold();
|
||||
},
|
||||
@@ -1564,7 +1565,7 @@ export function createDirectionsLayer({
|
||||
clearAll();
|
||||
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
|
||||
_removeClickHandler();
|
||||
services.picking.unregisterPickOwner('directions');
|
||||
services.picking.unregisterPickOwner('directions', _engine);
|
||||
_display?.show(false);
|
||||
syncRenderHold();
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createDroneAirspaceLayer({
|
||||
throw new TypeError('Drone airspace requires a snapshot source');
|
||||
let initialized = false;
|
||||
let renderer = null;
|
||||
let sceneEngine = null;
|
||||
/** The rows drawn, kept for the next globe's renderer. */
|
||||
let _rows = [];
|
||||
let _request = null;
|
||||
@@ -119,7 +120,11 @@ export function createDroneAirspaceLayer({
|
||||
}
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
|
||||
if (
|
||||
pickId &&
|
||||
picking.isOwnedByOtherLayer(layer.id, pickId, sceneEngine)
|
||||
)
|
||||
return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
_selectedId = null;
|
||||
@@ -168,6 +173,7 @@ export function createDroneAirspaceLayer({
|
||||
attachRenderer(viewer, { engine = null } = {}) {
|
||||
if (!initialized || renderer)
|
||||
throw new Error('Invalid drone airspace scene attachment');
|
||||
sceneEngine = engine;
|
||||
renderer = createRenderer({ engine });
|
||||
renderer.setVisible(_enabled);
|
||||
if (_rows.length) renderer.replace(_rows);
|
||||
@@ -185,6 +191,7 @@ export function createDroneAirspaceLayer({
|
||||
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false);
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
sceneEngine = null;
|
||||
},
|
||||
|
||||
enable() {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createLifecycle({
|
||||
components.selection.removeClickHandler();
|
||||
components.viewport.removeMoveEndWatcher();
|
||||
components.viewport.removeLodWatcher();
|
||||
unregisterPickOwner(id);
|
||||
unregisterPickOwner(id, layerState._renderer?.engine);
|
||||
components.rendering.removeDetectionCollections();
|
||||
releaseHeat();
|
||||
layerState._renderer?.destroy();
|
||||
@@ -89,8 +89,10 @@ export function createLifecycle({
|
||||
components.viewport.installLodWatcher();
|
||||
components.viewport.installMoveEndWatcher();
|
||||
components.selection.installClickHandler();
|
||||
registerPickOwner(id, (pickedId) =>
|
||||
layerState._pickIndexById.has(pickedId),
|
||||
registerPickOwner(
|
||||
id,
|
||||
(pickedId) => layerState._pickIndexById.has(pickedId),
|
||||
layerState._renderer?.engine,
|
||||
);
|
||||
components.rendering.renderCurrentLod(true);
|
||||
},
|
||||
@@ -117,7 +119,7 @@ export function createLifecycle({
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
components.selection.removeClickHandler();
|
||||
components.viewport.removeMoveEndWatcher();
|
||||
unregisterPickOwner(id);
|
||||
unregisterPickOwner(id, layerState._renderer?.engine);
|
||||
components.viewport.removeLodWatcher();
|
||||
},
|
||||
|
||||
@@ -131,7 +133,7 @@ export function createLifecycle({
|
||||
components.viewport.removeLodWatcher();
|
||||
components.viewport.removeMoveEndWatcher();
|
||||
components.selection.removeClickHandler();
|
||||
unregisterPickOwner(id);
|
||||
unregisterPickOwner(id, layerState._renderer?.engine);
|
||||
components.rendering.removeDetectionCollections();
|
||||
layerState._renderer?.destroy();
|
||||
layerState._renderer = null;
|
||||
|
||||
@@ -135,6 +135,7 @@ export function createFirmsDisplay(
|
||||
sprites = null;
|
||||
};
|
||||
return {
|
||||
engine,
|
||||
view: layerView.view,
|
||||
dataSource,
|
||||
project: (position, result) =>
|
||||
|
||||
@@ -45,7 +45,11 @@ export function createSelection({
|
||||
// "empty space" — leave the selection alone and let that layer handle it.
|
||||
if (picked) {
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer(id, pickedId)) return;
|
||||
if (
|
||||
pickedId &&
|
||||
isOwnedByOtherLayer(id, pickedId, layerState._renderer?.engine)
|
||||
)
|
||||
return;
|
||||
}
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
|
||||
@@ -135,8 +135,10 @@ export function createLifecycle({
|
||||
}
|
||||
if (!renderer) return;
|
||||
parts.tracking._installClickHandler();
|
||||
registerPickOwner('flights', (pickedId) =>
|
||||
flightState._billboards.has(pickedId),
|
||||
registerPickOwner(
|
||||
'flights',
|
||||
(pickedId) => flightState._billboards.has(pickedId),
|
||||
flightState._renderer?.engine,
|
||||
);
|
||||
// Force a fresh rotation pass on the first tick after re-enable
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
@@ -158,6 +158,7 @@ export function createGhostMapsLayer({
|
||||
if (typeof source?.getCatalog !== 'function')
|
||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||
let _display = null;
|
||||
let sceneEngine = null;
|
||||
let _enabled = false;
|
||||
let _catalog = null;
|
||||
let _lastError = null;
|
||||
@@ -538,6 +539,7 @@ export function createGhostMapsLayer({
|
||||
GHOSTMAPS_LAYER_ID,
|
||||
(pickedId) =>
|
||||
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
|
||||
sceneEngine,
|
||||
);
|
||||
_clickHandler = _display.onClick((click, pick) => {
|
||||
if (pointer && !pointer.isPointerFree()) return;
|
||||
@@ -560,7 +562,10 @@ export function createGhostMapsLayer({
|
||||
}
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId))
|
||||
if (
|
||||
pickId &&
|
||||
picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId, sceneEngine)
|
||||
)
|
||||
return;
|
||||
}
|
||||
if (_selectedKey) {
|
||||
@@ -573,7 +578,7 @@ export function createGhostMapsLayer({
|
||||
function removeClickHandler() {
|
||||
_clickHandler?.();
|
||||
_clickHandler = null;
|
||||
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID);
|
||||
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID, sceneEngine);
|
||||
}
|
||||
|
||||
function featureCount() {
|
||||
@@ -599,6 +604,7 @@ export function createGhostMapsLayer({
|
||||
if (_display) throw new Error('GhostMaps display is already attached');
|
||||
if (typeof createRenderer !== 'function')
|
||||
throw new TypeError('GhostMaps needs a renderer factory');
|
||||
sceneEngine = engine;
|
||||
_display = createRenderer(engine);
|
||||
if (!_enabled) return;
|
||||
installClickHandler();
|
||||
@@ -629,6 +635,7 @@ export function createGhostMapsLayer({
|
||||
overlayHost?.clearSource?.(GHOSTMAPS_OVERLAY_SOURCE_ID);
|
||||
_display?.destroy();
|
||||
_display = null;
|
||||
sceneEngine = null;
|
||||
},
|
||||
|
||||
enable() {
|
||||
|
||||
@@ -620,8 +620,10 @@ export function createInstallationsDisplay(
|
||||
namedMarkers.enable();
|
||||
namedMarkers.sync(state.records || []);
|
||||
claimCameraSensitivity(engine, LAYER_ID, 0.01);
|
||||
services.picking.registerPickOwner(LAYER_ID, (id) =>
|
||||
state.recordById.has(id),
|
||||
services.picking.registerPickOwner(
|
||||
LAYER_ID,
|
||||
(id) => state.recordById.has(id),
|
||||
engine,
|
||||
);
|
||||
setVisible(true);
|
||||
// The layer manager calls update() right after enable(), which owns
|
||||
@@ -639,7 +641,7 @@ export function createInstallationsDisplay(
|
||||
state.coverage = { kind: 'viewport' };
|
||||
state.cameraLoadKey = null;
|
||||
state.cameraLoadOwner = null;
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
services.picking.unregisterPickOwner(LAYER_ID, engine);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(state.timer);
|
||||
state.abort?.abort();
|
||||
@@ -664,7 +666,7 @@ export function createInstallationsDisplay(
|
||||
detach() {
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
services.picking.unregisterPickOwner(LAYER_ID, engine);
|
||||
releaseCameraSensitivity(engine, LAYER_ID);
|
||||
state.surfaceChangedRemove?.();
|
||||
state.surfaceChangedRemove = null;
|
||||
|
||||
@@ -647,7 +647,7 @@ export function createLocalAdsbLayer({
|
||||
const restore = selectedId;
|
||||
selectedId = null;
|
||||
renderer.setVisible(true);
|
||||
registerPickOwner(LAYER_ID, (id) => markers.has(id));
|
||||
registerPickOwner(LAYER_ID, (id) => markers.has(id), renderer?.engine);
|
||||
installInteraction();
|
||||
installFrame();
|
||||
sync();
|
||||
@@ -663,7 +663,7 @@ export function createLocalAdsbLayer({
|
||||
if (getSelectedEntityContext()?.id !== selectedId) selectedId = null;
|
||||
releaseInteraction();
|
||||
releaseFrame();
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
unregisterPickOwner(LAYER_ID, renderer?.engine);
|
||||
releaseTrail();
|
||||
models?.destroy();
|
||||
models = null;
|
||||
@@ -682,7 +682,11 @@ export function createLocalAdsbLayer({
|
||||
async enable() {
|
||||
enabled = true;
|
||||
renderer?.setVisible(true);
|
||||
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
|
||||
registerPickOwner(
|
||||
LAYER_ID,
|
||||
(pickedValue) => markers.has(pickedValue),
|
||||
renderer?.engine,
|
||||
);
|
||||
installInteraction();
|
||||
installFrame();
|
||||
clearInterval(tickTimer);
|
||||
@@ -704,7 +708,7 @@ export function createLocalAdsbLayer({
|
||||
tickTimer = null;
|
||||
releaseInteraction();
|
||||
releaseFrame();
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
unregisterPickOwner(LAYER_ID, renderer?.engine);
|
||||
sync();
|
||||
models?.clear();
|
||||
renderer?.setVisible(false);
|
||||
|
||||
@@ -1087,11 +1087,15 @@ test('a click on a local aircraft under an unowned or trail pick still selects i
|
||||
engine: harness.engine,
|
||||
});
|
||||
// The app's pick owners: the trails, and a Flights contact.
|
||||
registerPickOwner('trails', (id) => String(id).startsWith('gev-trail:'));
|
||||
registerPickOwner('flights', (id) => id === 'abc123');
|
||||
registerPickOwner(
|
||||
'trails',
|
||||
(id) => String(id).startsWith('gev-trail:'),
|
||||
harness.engine,
|
||||
);
|
||||
registerPickOwner('flights', (id) => id === 'abc123', harness.engine);
|
||||
t.after(() => {
|
||||
unregisterPickOwner('trails');
|
||||
unregisterPickOwner('flights');
|
||||
unregisterPickOwner('trails', harness.engine);
|
||||
unregisterPickOwner('flights', harness.engine);
|
||||
layer.destroy();
|
||||
harness.release();
|
||||
});
|
||||
|
||||
@@ -119,8 +119,10 @@ export function createLifecycle({
|
||||
setMilitaryLayerActive(true);
|
||||
if (!renderer) return;
|
||||
parts.tracking._installClickHandler();
|
||||
registerPickOwner('military', (pickedId) =>
|
||||
flightState._billboards.has(pickedId),
|
||||
registerPickOwner(
|
||||
'military',
|
||||
(pickedId) => flightState._billboards.has(pickedId),
|
||||
flightState._renderer?.engine,
|
||||
);
|
||||
// Force a fresh rotation pass on the first tick after re-enable
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createFirePerimetersLayer({
|
||||
throw new TypeError('Fire perimeters require a snapshot source');
|
||||
let initialized = false;
|
||||
let renderer = null;
|
||||
let sceneEngine = null;
|
||||
const geometry = new Map();
|
||||
let _request = null;
|
||||
let _linkRequest = null;
|
||||
@@ -230,7 +231,11 @@ export function createFirePerimetersLayer({
|
||||
// "empty space" — leave the selection alone and let that layer handle it.
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
|
||||
if (
|
||||
pickId &&
|
||||
picking.isOwnedByOtherLayer(layer.id, pickId, sceneEngine)
|
||||
)
|
||||
return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
abortLinkVerification();
|
||||
@@ -280,6 +285,7 @@ export function createFirePerimetersLayer({
|
||||
attachRenderer(viewer, { engine = null } = {}) {
|
||||
if (!initialized || renderer)
|
||||
throw new Error('Invalid fire perimeter scene attachment');
|
||||
sceneEngine = engine;
|
||||
renderer = createRenderer({ engine });
|
||||
renderer.setVisible(_enabled);
|
||||
if (geometry.size) renderer.replace([...geometry.values()]);
|
||||
@@ -296,6 +302,7 @@ export function createFirePerimetersLayer({
|
||||
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
sceneEngine = null;
|
||||
},
|
||||
|
||||
enable() {
|
||||
|
||||
@@ -54,7 +54,11 @@ export function createInteraction({
|
||||
const primaryStationId = stationFromPick(primaryPick);
|
||||
if (primaryStationId) return primaryStationId;
|
||||
const primaryId = resolvePickId(primaryPick);
|
||||
if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null;
|
||||
if (
|
||||
primaryId &&
|
||||
isOwnedByOtherLayer('radio', primaryId, parts.renderer?.engine)
|
||||
)
|
||||
return null;
|
||||
|
||||
if (typeof renderer.drillPick === 'function') {
|
||||
const drilled = renderer.drillPick(position, 16) || [];
|
||||
@@ -71,7 +75,11 @@ export function createInteraction({
|
||||
};
|
||||
const picked = renderer.pick(offsetPosition);
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
|
||||
if (
|
||||
pickedId &&
|
||||
isOwnedByOtherLayer('radio', pickedId, parts.renderer?.engine)
|
||||
)
|
||||
continue;
|
||||
const stationId = stationFromPick(picked);
|
||||
if (stationId) return stationId;
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export function createRadioDisplay(engine, { state, services, parts }) {
|
||||
};
|
||||
|
||||
const renderer = {
|
||||
engine,
|
||||
get ready() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
@@ -73,7 +73,11 @@ export function createInteraction({
|
||||
// the follow, which that sibling may have JUST started) alone (H2).
|
||||
if (picked) {
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return;
|
||||
if (
|
||||
pickedId &&
|
||||
isOwnedByOtherLayer('satellites', pickedId, layerState._engine)
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked empty space — deselect
|
||||
|
||||
@@ -89,7 +89,7 @@ export function createLifecycle({
|
||||
layerState._preRenderListener =
|
||||
null;
|
||||
document.removeEventListener('keydown', parts.interaction._onKeyDown);
|
||||
unregisterPickOwner('satellites');
|
||||
unregisterPickOwner('satellites', layerState._engine);
|
||||
if (layerState._trackedEntity) {
|
||||
layerState._handoffOrigin =
|
||||
layerState._trackedEntity.gevSelectionOrigin;
|
||||
@@ -205,10 +205,14 @@ export function createLifecycle({
|
||||
if (layerState._renderer) parts.interaction._installClickHandler();
|
||||
// Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers;
|
||||
// the registry hands predicates String()-coerced ids, so match via Number().
|
||||
registerPickOwner('satellites', (pickedId) => {
|
||||
const norad = Number(pickedId);
|
||||
return Number.isFinite(norad) && layerState._points.has(norad);
|
||||
});
|
||||
registerPickOwner(
|
||||
'satellites',
|
||||
(pickedId) => {
|
||||
const norad = Number(pickedId);
|
||||
return Number.isFinite(norad) && layerState._points.has(norad);
|
||||
},
|
||||
layerState._engine,
|
||||
);
|
||||
if (!layerState._preRenderListener && layerState._renderer) {
|
||||
layerState._preRenderListener = getRenderer().onFrame(
|
||||
parts.rendering._preRenderTick,
|
||||
@@ -237,7 +241,7 @@ export function createLifecycle({
|
||||
layerState._trackedEntityChangedRemove = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.interaction._onKeyDown);
|
||||
unregisterPickOwner('satellites');
|
||||
unregisterPickOwner('satellites', layerState._engine);
|
||||
if (layerState._preRenderListener) {
|
||||
layerState._preRenderListener();
|
||||
layerState._preRenderListener = null;
|
||||
@@ -261,7 +265,7 @@ export function createLifecycle({
|
||||
layerState._trackedEntityChangedRemove = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.interaction._onKeyDown);
|
||||
unregisterPickOwner('satellites');
|
||||
unregisterPickOwner('satellites', layerState._engine);
|
||||
if (layerState._preRenderListener) {
|
||||
layerState._preRenderListener();
|
||||
layerState._preRenderListener = null;
|
||||
|
||||
@@ -74,7 +74,11 @@ export function createSelection({ state, parts }) {
|
||||
if (state.clickHandler || !map?.engine) return;
|
||||
state.clickHandler = map.engine.input.on('click', onClick);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
|
||||
picking?.registerPickOwner?.(
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
parts.router.ownsPick,
|
||||
state.map?.engine,
|
||||
);
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
@@ -83,7 +87,7 @@ export function createSelection({ state, parts }) {
|
||||
state.clickHandler();
|
||||
state.clickHandler = null;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
||||
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID, state.map?.engine);
|
||||
}
|
||||
|
||||
return { install, uninstall, ownsPick: parts.router.ownsPick };
|
||||
|
||||
@@ -183,7 +183,11 @@ export function createLifecycle({
|
||||
);
|
||||
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
parts.selection.installClickHandler(viewer);
|
||||
registerPickOwner('transit', (pickedId) => state._vehicles.has(pickedId));
|
||||
registerPickOwner(
|
||||
'transit',
|
||||
(pickedId) => state._vehicles.has(pickedId),
|
||||
parts.renderer?.engine,
|
||||
);
|
||||
if (!state._cameraChangedAttached) {
|
||||
state._cameraChangedRemove = parts.renderer.onCameraChanged(
|
||||
parts.viewport.onCameraChanged,
|
||||
@@ -226,7 +230,7 @@ export function createLifecycle({
|
||||
state._visibilityTimer =
|
||||
null;
|
||||
parts.selection.removeClickHandler();
|
||||
unregisterPickOwner('transit');
|
||||
unregisterPickOwner('transit', parts.renderer?.engine);
|
||||
state._cameraChangedRemove?.();
|
||||
state._cameraChangedRemove = null;
|
||||
if (state._cameraChangedAttached) returnCameraSensitivity();
|
||||
@@ -274,7 +278,7 @@ export function createLifecycle({
|
||||
parts.selection.clearSelection();
|
||||
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
parts.selection.removeClickHandler();
|
||||
unregisterPickOwner('transit');
|
||||
unregisterPickOwner('transit', parts.renderer?.engine);
|
||||
if (state._cameraChangedAttached) {
|
||||
state._cameraChangedRemove?.();
|
||||
state._cameraChangedRemove = null;
|
||||
|
||||
@@ -640,6 +640,7 @@ export function createTransitDisplay(
|
||||
own(event.addEventListener((...args) => wrap(() => fn(...args))));
|
||||
const scratchCartographic = geo.cartographic.create();
|
||||
return {
|
||||
engine,
|
||||
attach() {
|
||||
attached = true;
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ export function createLifecycle({
|
||||
state.preRenderRemover = null;
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
unregisterPickOwner('ais-live-vessels', state.renderer?.engine);
|
||||
components.tracking.detachSelectedVesselTrail();
|
||||
for (const record of state.records.all)
|
||||
components.rendering.removeRecordPrimitives(record);
|
||||
@@ -244,7 +244,7 @@ export function createLifecycle({
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
unregisterPickOwner('ais-live-vessels', state.renderer?.engine);
|
||||
components.rendering.setVisible(false);
|
||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||
components.selection.clearVesselInspection();
|
||||
@@ -264,7 +264,7 @@ export function createLifecycle({
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
if (state.feed.abort) state.feed.abort.abort();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
unregisterPickOwner('ais-live-vessels', state.renderer?.engine);
|
||||
components.selection.clearVesselInspection();
|
||||
components.tracking.destroySelectedVesselTrail();
|
||||
detachFleet();
|
||||
@@ -289,8 +289,10 @@ export function createLifecycle({
|
||||
function bindVisibleScene(activeViewer, scene) {
|
||||
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
|
||||
// the registry resolver reduces it to the record's mmsi (a string key).
|
||||
registerPickOwner('ais-live-vessels', (pickedId) =>
|
||||
state.records.byMmsi.has(pickedId),
|
||||
registerPickOwner(
|
||||
'ais-live-vessels',
|
||||
(pickedId) => state.records.byMmsi.has(pickedId),
|
||||
state.renderer?.engine,
|
||||
);
|
||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||
// Vessels are asked for by view, so ask again when the view moves away.
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
packImageAtlas,
|
||||
} from '../../shared/render/imageAtlas.js';
|
||||
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||
import { registerPickOwner } from '../../../data/pickRegistry.js';
|
||||
import { allVesselChevronIcons, VESSEL_ICON_PX } from '../icons.js';
|
||||
|
||||
const LAYER_ID = 'ais-live-vessels';
|
||||
@@ -35,13 +34,6 @@ const ATLAS_PAD = 2;
|
||||
/** The id namespace of track-history lines (vessels and aircraft). */
|
||||
export const TRAIL_PICK_PREFIX = 'gev-trail:';
|
||||
|
||||
// A trail hugs its contact, so an unclaimed trail pick would read as empty
|
||||
// space in every layer's click rule and release the very contact selected.
|
||||
// Claiming the namespace makes a trail pick another layer's for everyone.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith(TRAIL_PICK_PREFIX),
|
||||
);
|
||||
|
||||
const iconEntry = (key, image) => ({
|
||||
key,
|
||||
image,
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { findPickOwner } from '../../../data/pickRegistry.js';
|
||||
import { createPickRegistry } from '../../../data/pickRegistry.js';
|
||||
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||
import { vesselTypeCss } from '../../../data/vesselLabels.js';
|
||||
import { WHITE } from '../../shared/color.js';
|
||||
@@ -122,6 +122,7 @@ test('the chevron atlas has one 32 px cell per chevron the layer draws', () => {
|
||||
});
|
||||
|
||||
test('trail picks belong to the trail owner, so no layer reads one as empty space', () => {
|
||||
const { findPickOwner } = createPickRegistry();
|
||||
assert.equal(findPickOwner('gev-trail:ais-live-vessels-1'), 'trails');
|
||||
assert.equal(findPickOwner('gev-trail:flights-1'), 'trails');
|
||||
assert.equal(findPickOwner('gev-trailing-contact'), null);
|
||||
|
||||
@@ -57,7 +57,11 @@ export function createSelection({
|
||||
handler.setInputAction((click) =>
|
||||
handleClick({
|
||||
position: click.position,
|
||||
picked: pickContact(viewer.scene, click.position),
|
||||
picked: pickContact(
|
||||
viewer.scene,
|
||||
click.position,
|
||||
state.renderer?.engine,
|
||||
),
|
||||
}),
|
||||
);
|
||||
// The follow signal of the layer's renderer.
|
||||
@@ -98,12 +102,18 @@ export function createSelection({
|
||||
// A sibling layer already owns this click. Preserve the current vessel
|
||||
// selection and do not compete with its camera command.
|
||||
const siblingOwned =
|
||||
pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
|
||||
pickedId &&
|
||||
isOwnedByOtherLayer('ais-live-vessels', pickedId, state.renderer?.engine);
|
||||
// Cyclone geometry is ambient context behind painted cards. Its handler
|
||||
// yields to the same foreground AIS hit. Keep moving-contact precedence
|
||||
// unchanged: those handlers may issue their own camera command.
|
||||
const cycloneBackground =
|
||||
siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
|
||||
siblingOwned &&
|
||||
!isOwnedByOtherLayer(
|
||||
'weather-cyclones',
|
||||
pickedId,
|
||||
state.renderer?.engine,
|
||||
);
|
||||
if (siblingOwned && !cycloneBackground) return;
|
||||
|
||||
// Cards are painted on a pointer-events:none canvas, so the scene pick is
|
||||
|
||||
@@ -527,6 +527,7 @@ export function createLifecycle({ state, services, parts }) {
|
||||
typeof id === 'string' &&
|
||||
id.startsWith('wildlife:') &&
|
||||
state.rowById.has(id.slice(9)),
|
||||
state.engine,
|
||||
);
|
||||
state.removeCamera =
|
||||
state.engine.camera.moveEnd.addEventListener(onMoveEnd);
|
||||
@@ -537,7 +538,7 @@ export function createLifecycle({ state, services, parts }) {
|
||||
state.removeCamera = null;
|
||||
parts.rendering.deactivate?.();
|
||||
parts.selection.uninstall?.();
|
||||
services.picking?.unregisterPickOwner?.('wildlife-sightings');
|
||||
services.picking?.unregisterPickOwner?.('wildlife-sightings', state.engine);
|
||||
}
|
||||
|
||||
// Requests, timers and debounces in flight end with their scene or lifetime.
|
||||
|
||||
@@ -504,7 +504,14 @@ export function createSelection({ state, services, parts }) {
|
||||
selectAndFocus(id.slice(9))
|
||||
)
|
||||
return;
|
||||
if (id && services.picking?.isOwnedByOtherLayer?.('wildlife-sightings', id))
|
||||
if (
|
||||
id &&
|
||||
services.picking?.isOwnedByOtherLayer?.(
|
||||
'wildlife-sightings',
|
||||
id,
|
||||
state.engine,
|
||||
)
|
||||
)
|
||||
return;
|
||||
clearAndRebuild();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createPointerPicker({
|
||||
} catch {
|
||||
picked = null;
|
||||
}
|
||||
const entity = resolvePickedContext(picked, { dataManager });
|
||||
const entity = resolvePickedContext(picked, { dataManager, engine });
|
||||
let ground = null;
|
||||
try {
|
||||
const hit = pickWorldFromScreen(engine, x / width, y / height);
|
||||
|
||||
Reference in new issue
Block a user