From 4252ed0798673cc1d420f242b02276c718cf8cee Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 16:27:43 +0400 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 11 +- docs/GLOBE-PRIMITIVES.md | 9 + scripts/format-scope.json | 3 + scripts/package-boundaries.json | 12 +- src/app/globeEngine.test.mjs | 2 +- src/app/navaraScene.test.mjs | 17 ++ src/app/scene.js | 4 + src/cameraHandoff.test.mjs | 4 +- src/data/aisLiveVessels.test.mjs | 6 +- src/data/firmsInteraction.test.mjs | 4 +- src/data/pickRegistry.js | 232 +++++++++++++-------- src/data/pickRegistry.test.mjs | 9 +- src/data/pickedContext.js | 8 +- src/data/pickedContext.test.mjs | 25 ++- src/data/sceneServices.js | 32 +++ src/data/sceneServices.test.mjs | 137 ++++++++++++ src/data/selectionDispatcher.js | 34 ++- src/data/selectionDispatcher.test.mjs | 34 +-- src/data/trackingClickGesture.js | 38 +--- src/data/trackingGesture.js | 32 +++ src/layers/aircraft/sceneBinding.js | 2 +- src/layers/alpr/index.js | 18 +- src/layers/bikeshare/render/display.js | 8 +- src/layers/cctv/lifecycle.js | 36 ++-- src/layers/cctv/render/display.js | 1 + src/layers/cyclones/index.js | 16 +- src/layers/cyclones/index.test.mjs | 47 ++++- src/layers/directions/index.js | 5 +- src/layers/droneAirspace/index.js | 9 +- src/layers/firms/lifecycle.js | 12 +- src/layers/firms/render/display.js | 1 + src/layers/firms/selection.js | 6 +- src/layers/flights/lifecycle.js | 6 +- src/layers/ghostMaps/index.js | 11 +- src/layers/installations/render/display.js | 10 +- src/layers/localAdsb/index.js | 12 +- src/layers/localAdsb/index.test.mjs | 12 +- src/layers/military/lifecycle.js | 6 +- src/layers/perimeters/index.js | 9 +- src/layers/radio/interaction.js | 12 +- src/layers/radio/render/display.js | 1 + src/layers/satellites/interaction.js | 6 +- src/layers/satellites/lifecycle.js | 18 +- src/layers/streetLevel/selection.js | 8 +- src/layers/transit/lifecycle.js | 10 +- src/layers/transit/render/display.js | 1 + src/layers/vessels/lifecycle.js | 12 +- src/layers/vessels/render/display.js | 8 - src/layers/vessels/render/display.test.mjs | 3 +- src/layers/vessels/selection.js | 16 +- src/layers/wildlifeSightings/lifecycle.js | 3 +- src/layers/wildlifeSightings/selection.js | 9 +- src/voice/pointerPick.js | 2 +- 53 files changed, 712 insertions(+), 277 deletions(-) create mode 100644 src/data/sceneServices.js create mode 100644 src/data/sceneServices.test.mjs create mode 100644 src/data/trackingGesture.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 433500ed7..c5421eb4b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index c43fa718e..1d6b9bf72 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0021058c4..7ce1f9ecb 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5d3b4960d..57bbaa5e4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.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"] }, diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index 34ef813c9..c07780bce 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -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,? \};/, ); }); diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index bd7986c7a..b1b0d525d 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -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); +}); diff --git a/src/app/scene.js b/src/app/scene.js index b6bff4d94..f777fdca5 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -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, }; } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index b723604f0..b3a31106e 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -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)', ], diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 8606d9a5e..244cc3460 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -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, diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 45ba8e80d..339e42a27 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -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); } }); } diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index 48948a0a4..fdb8584b7 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -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 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 + ); } diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index 01905a2d4..36f6f797c 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -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 diff --git a/src/data/pickedContext.js b/src/data/pickedContext.js index 28c303564..188ea2b6c 100644 --- a/src/data/pickedContext.js +++ b/src/data/pickedContext.js @@ -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); diff --git a/src/data/pickedContext.test.mjs b/src/data/pickedContext.test.mjs index a73984432..fa14fccb5 100644 --- a/src/data/pickedContext.test.mjs +++ b/src/data/pickedContext.test.mjs @@ -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(); + } +}); diff --git a/src/data/sceneServices.js b/src/data/sceneServices.js new file mode 100644 index 000000000..43565fd97 --- /dev/null +++ b/src/data/sceneServices.js @@ -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); +} diff --git a/src/data/sceneServices.test.mjs b/src/data/sceneServices.test.mjs new file mode 100644 index 000000000..774eb713b --- /dev/null +++ b/src/data/sceneServices.test.mjs @@ -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'); + }); +} diff --git a/src/data/selectionDispatcher.js b/src/data/selectionDispatcher.js index 7e3c3f368..beb1051f2 100644 --- a/src/data/selectionDispatcher.js +++ b/src/data/selectionDispatcher.js @@ -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) { diff --git a/src/data/selectionDispatcher.test.mjs b/src/data/selectionDispatcher.test.mjs index 892e35f19..d5c835740 100644 --- a/src/data/selectionDispatcher.test.mjs +++ b/src/data/selectionDispatcher.test.mjs @@ -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); } }); diff --git a/src/data/trackingClickGesture.js b/src/data/trackingClickGesture.js index a1c006f4b..df6008702 100644 --- a/src/data/trackingClickGesture.js +++ b/src/data/trackingClickGesture.js @@ -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. diff --git a/src/data/trackingGesture.js b/src/data/trackingGesture.js new file mode 100644 index 000000000..aa5f16a96 --- /dev/null +++ b/src/data/trackingGesture.js @@ -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 + ); +} diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js index 59339bbfc..bfb4121c0 100644 --- a/src/layers/aircraft/sceneBinding.js +++ b/src/layers/aircraft/sceneBinding.js @@ -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; diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index fd6350c12..d9796f4e6 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -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; diff --git a/src/layers/bikeshare/render/display.js b/src/layers/bikeshare/render/display.js index 17d602947..f49200b19 100644 --- a/src/layers/bikeshare/render/display.js +++ b/src/layers/bikeshare/render/display.js @@ -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(); diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index e76f426ff..f659e8770 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -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--` 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', diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js index 110f68282..b5bbbb753 100644 --- a/src/layers/cctv/render/display.js +++ b/src/layers/cctv/render/display.js @@ -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; diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index d36de6be6..3d9aa21c3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -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; diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 5ac248189..d6b885069 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -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(); }); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 747fa40f7..065f5f669 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -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(); }, diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 262a87125..1c54a12b3 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -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() { diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 0be598a6e..9396a501f 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -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; diff --git a/src/layers/firms/render/display.js b/src/layers/firms/render/display.js index 3a9ccf595..263b80c6d 100644 --- a/src/layers/firms/render/display.js +++ b/src/layers/firms/render/display.js @@ -135,6 +135,7 @@ export function createFirmsDisplay( sprites = null; }; return { + engine, view: layerView.view, dataSource, project: (position, result) => diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 3108b9a42..85c543292 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -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, diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 6671e8451..47e661b85 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -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 = ''; diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index cc47146d2..29ce6800c 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -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() { diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js index d1c48bd0c..c96df265e 100644 --- a/src/layers/installations/render/display.js +++ b/src/layers/installations/render/display.js @@ -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; diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 7201490f9..141ae8e41 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -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); diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 219042ae5..728c8d012 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -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(); }); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 74647f564..71218bc90 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -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 = ''; diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 0d02432dc..4188ec00a 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -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() { diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js index 1dc3c435b..7ccabecdf 100644 --- a/src/layers/radio/interaction.js +++ b/src/layers/radio/interaction.js @@ -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; } diff --git a/src/layers/radio/render/display.js b/src/layers/radio/render/display.js index 362836890..9f48b65d2 100644 --- a/src/layers/radio/render/display.js +++ b/src/layers/radio/render/display.js @@ -172,6 +172,7 @@ export function createRadioDisplay(engine, { state, services, parts }) { }; const renderer = { + engine, get ready() { return initialized; }, diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 17770292d..7cd63e24c 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -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 diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 78e6dfc49..538a0e469 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -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; diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index da203e3d4..ecb2a523f 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -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 }; diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 4651a9f44..4a427f948 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -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; diff --git a/src/layers/transit/render/display.js b/src/layers/transit/render/display.js index 9735d6be3..40dcf0460 100644 --- a/src/layers/transit/render/display.js +++ b/src/layers/transit/render/display.js @@ -640,6 +640,7 @@ export function createTransitDisplay( own(event.addEventListener((...args) => wrap(() => fn(...args)))); const scratchCartographic = geo.cartographic.create(); return { + engine, attach() { attached = true; }, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 152ed94f7..f2c82c3fb 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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. diff --git a/src/layers/vessels/render/display.js b/src/layers/vessels/render/display.js index cfa3547e8..71b71b1f3 100644 --- a/src/layers/vessels/render/display.js +++ b/src/layers/vessels/render/display.js @@ -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, diff --git a/src/layers/vessels/render/display.test.mjs b/src/layers/vessels/render/display.test.mjs index c73032742..0b0cf5f22 100644 --- a/src/layers/vessels/render/display.test.mjs +++ b/src/layers/vessels/render/display.test.mjs @@ -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); diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 87c839c90..a85975d98 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -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 diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 460bef739..5a19e3244 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -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. diff --git a/src/layers/wildlifeSightings/selection.js b/src/layers/wildlifeSightings/selection.js index 1d5f0c8ba..865c6dc89 100644 --- a/src/layers/wildlifeSightings/selection.js +++ b/src/layers/wildlifeSightings/selection.js @@ -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(); } diff --git a/src/voice/pointerPick.js b/src/voice/pointerPick.js index 57b43f9a8..dd3693936 100644 --- a/src/voice/pointerPick.js +++ b/src/voice/pointerPick.js @@ -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);