diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ad35bd366..9a0a48580 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -290,8 +290,19 @@ one application, and only the globe-bound parts move to the next engine. models resolve their attached engine, and the shell reconnects its action subscription after switching. Destroyed services and old lane handles stay inert; a parked engine receives a new host. Shared font measurements live - until the last host detaches. The tracked readout remains page-owned and - publishes into the attached overlay; its event isolation is separate work. + until the last host detaches. +- **Scene tracked readouts.** `scene.services.trackedReadout` owns the follow + subscription, selected card, active entry ID and follow-label cache for one + attachment. It publishes only into that scene's overlay. Selection records + and notifications belong to `scene.services.context`; publishers resolve + their attached engine. The existing page context slot and selection events + remain available to page consumers with the same payloads. Destroyed readouts + and context services stay inert; parked reattachment creates fresh services. + Engine handoff carries the retained page selection into the incoming context; + rebuilding its marker updates the readout without another page selection event. + Readout initialization leaves the selection intact while its outgoing display + is hidden and its incoming layer waits for camera restoration. + Label text, live visual anchors and follow-camera anti-jitter are unchanged. - **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 8e68773d9..6c27f1c93 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -587,8 +587,30 @@ The module helper functions take the engine first. Retained layer models use `init()`. Destruction is terminal, including before initialization; a parked engine needs fresh scene services. An old paint-lane handle cannot mutate its replacement. Font measurements are shared until the last live host releases -them. The tracked-readout bridge remains page-owned and publishes into its -attached engine's overlay. +them. + +`scene.services.trackedReadout` bridges the follow handle's label and selected +ALPR or Local ADS-B card into this overlay. `init()` subscribes to that engine's +follow changes and that attachment's context events; `refresh(entity)` republishes +changed label text, and `activeId()` resolves the card for annotation placement. +Positions stay live cached visual anchors. `trackedReadoutFor(engine)` resolves +the service; destruction removes its listeners and clears only its own source. + +`scene.services.context` owns selection records and notifications. Layer publishers +use `bindContextStore` with their current engine, or a context service captured by +their display. It mirrors records to the page context slot and preserves the +`gev:entity-selected`, `gev:entity-selection-cleared` and aircraft awareness event +payloads for page consumers. A clear reaches the page only when that attachment +owns the page's selected record. Readouts listen only to their scene's event target. +Identical IDs in different scenes cannot share a card or clear one another's +selection. Retired context services cannot publish into a replacement attachment. +An engine handoff carries the page-selected record through the layer session. +The incoming context adopts it only while the page still selects that record; +a clear between attachments wins. Rebuilt markers replace the retained carrier +in the incoming readout without announcing another page selection. +Readout initialization reads the retained record without testing the outgoing +carrier's visibility; layers rebuild their carriers after camera restoration. +The page chrome and detection controller still have page lifetimes. ## Render effects diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5a391f5f6..b221331b0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1706,5 +1706,8 @@ "src/globe/cesium/annotations/worldAnnotationRenderer.js", "src/globe/cesium/annotations/screenAnnotationRenderer.js", "src/globe/navara/windFlow.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js", + "src/data/trackedReadoutScenes.test.mjs", + "src/data/trackedAircraftScenes.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cd8565e9b..8f1e54823 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1237,7 +1237,9 @@ "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayTokens.js", - "src/time/clock.js" + "src/time/clock.js", + "src/data/contextStore.js", + "src/data/trackedReadout.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1464,7 +1466,8 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", "src/layers/shared/color.js", - "src/layers/aircraft/qaDrawn.js" + "src/layers/aircraft/qaDrawn.js", + "src/data/bindContextStore.js" ], "external": ["cesium"] }, @@ -1532,7 +1535,8 @@ "src/time/clock.js", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", - "src/layers/aircraft/qaDrawn.js" + "src/layers/aircraft/qaDrawn.js", + "src/data/bindContextStore.js" ], "external": ["cesium"] }, @@ -1617,7 +1621,8 @@ "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js" ], "external": ["cesium"] }, @@ -1700,7 +1705,8 @@ "src/geo/", "src/layers/firms/rectangle.js", "src/layers/shared/color.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js" ], "external": [] }, @@ -1743,7 +1749,8 @@ "src/geo/", "src/time/clock.js", "src/layers/shared/color.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js" ], "external": ["cesium", "satellite.js"] }, @@ -3634,7 +3641,8 @@ "src/globe/navara/meshes.js", "src/globe/exclusivePointer.js", "src/globe/cesium/followOffset.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js" ], "external": [ "hls.js", @@ -4399,7 +4407,8 @@ "src/globe/meshes.js", "src/globe/exclusivePointer.js", "src/globe/cesium/followOffset.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js" ], "external": [ "hls.js", @@ -5170,7 +5179,9 @@ "src/globe/meshes.js", "src/globe/exclusivePointer.js", "src/data/selectionDispatcher.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/contextStore.js", + "src/data/trackedReadout.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -5741,7 +5752,8 @@ "src/layers/wildlifeSightings/display.js", "src/data/spriteOrder.js", "src/layers/shared/color.js", - "src/overlays/bindOverlayHost.js" + "src/overlays/bindOverlayHost.js", + "src/data/bindContextStore.js" ], "external": ["cesium"] }, diff --git a/src/app/layerSession.js b/src/app/layerSession.js index ead392243..18cb616ed 100644 --- a/src/app/layerSession.js +++ b/src/app/layerSession.js @@ -18,6 +18,7 @@ * @property {Map | null} handoffLayers Switch coordinator: outgoing layer intentions. * @property {boolean} restoringView Switch coordinator: incoming address restoration. * @property {() => boolean} switching Switch coordinator: voice waits while busy. + * @property {object | null} selectionContext Scene factory: selected record carried to the next attachment. * @property {object | null} contextScene Shell: retained Context state. */ @@ -40,6 +41,7 @@ export function createLayerSession() { restoringView: false, switching: () => false, contextScene: null, + selectionContext: null, }; } diff --git a/src/app/localGeojsonServices.js b/src/app/localGeojsonServices.js index 08210ff7b..9aab1fe63 100644 --- a/src/app/localGeojsonServices.js +++ b/src/app/localGeojsonServices.js @@ -1,3 +1,4 @@ +import * as context from '../data/contextStore.js'; import { worldOverlayFor } from '../overlays/worldOverlay.js'; import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; import { @@ -14,6 +15,7 @@ import { } from '../overlays/worldOverlay.js'; /** Existing standalone application operations, shared by its local layers. */ export const localGeoJsonServices = Object.freeze({ + context, overlayHost: Object.freeze({ forEngine: worldOverlayFor, clearSource: clearOverlaySource, diff --git a/src/app/scene.js b/src/app/scene.js index ec2d5f737..f91e6a08a 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -1,3 +1,4 @@ +import { retainsLayerSession } from './layerSession.js'; import { createSceneServices } from '../data/sceneServices.js'; import { releaseCameraVerbs } from '../cameraVerbs.js'; import { createApplicationOperations } from './operations.js'; @@ -76,8 +77,16 @@ 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); + const services = createSceneServices(engine, { + retainedSelection: layerSession?.selectionContext, + }); + if (layerSession) layerSession.selectionContext = null; + defer(() => { + const retained = services.destroy({ + retainSelection: retainsLayerSession(layerSession), + }); + if (layerSession) layerSession.selectionContext = retained; + }); 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. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 244cc3460..2c37f4fe0 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1,3 +1,4 @@ +import { forEngine as contextForEngine } from './contextStore.js'; // src/data/aisLiveVessels.test.mjs // Focused tests for the AIS feed-status derivation helper (Batch 10, finding H3/AIS) // and the vessel vertical-datum seam (2026-07-27 datum pass — see @@ -985,6 +986,7 @@ function installWireHarness(picked, stateOverrides = {}) { windowTarget, reinstalledHandlers, interactionHandlerFactory, + engine: wireEngine, renderer, cleanup() { stopReadout(); @@ -1100,12 +1102,12 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 }); const cleared = []; try { - registerEntityContext(harness.record, { + contextForEngine(harness.engine).registerEntityContext(harness.record, { id: `ais-${harness.record.mmsi}`, layerId: 'ais-live-vessels', label: harness.record.name, }); - selectEntityContext(harness.record); + contextForEngine(harness.engine).selectEntityContext(harness.record); harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => { cleared.push(event.detail); }); @@ -1127,12 +1129,12 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti const harness = installWireHarness(undefined); const cleared = []; try { - registerEntityContext(harness.record, { + contextForEngine(harness.engine).registerEntityContext(harness.record, { id: `ais-${harness.record.mmsi}`, layerId: 'ais-live-vessels', label: harness.record.name, }); - selectEntityContext(harness.record); + contextForEngine(harness.engine).selectEntityContext(harness.record); harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => { cleared.push(event.detail); }); diff --git a/src/data/bindContextStore.js b/src/data/bindContextStore.js new file mode 100644 index 000000000..a85914d3f --- /dev/null +++ b/src/data/bindContextStore.js @@ -0,0 +1,12 @@ +/** Resolve a retained layer's context calls against its current attachment. */ +export function bindContextStore(context, getEngine) { + if (!context?.forEngine) return context; + return Object.fromEntries( + Object.entries(context) + .filter(([, value]) => typeof value === 'function') + .map(([name]) => [ + name, + (...args) => context.forEngine(getEngine())?.[name]?.(...args), + ]), + ); +} diff --git a/src/data/contextStore.js b/src/data/contextStore.js index e0b14b2ba..029c4086d 100644 --- a/src/data/contextStore.js +++ b/src/data/contextStore.js @@ -1,4 +1,7 @@ +import { sceneServicesFor } from './sceneServices.js'; + const STORE_KEY = '__gevContextStore'; +const contextOwners = new WeakMap(); function createStore() { return { @@ -28,166 +31,357 @@ function hasContextHost() { return typeof window !== 'undefined' && Boolean(window); } -export function registerEntityContext(entity, metadata) { - if (!entity || !metadata?.id) return null; - const store = getContextStore(); - const record = { - ...metadata, - entity, - updatedAt: Date.now(), +/** Selection records and notifications for one attachment, mirrored to page consumers. */ +export function createSceneContext(retainedSelection = null) { + return createContext(true, retainedSelection); +} + +function createContext(scoped = false, retainedSelection = null) { + const local = scoped ? createStore() : null; + const events = new EventTarget(); + let destroyed = false; + const storeForContext = () => local ?? getContextStore(); + const available = () => !destroyed && (scoped || hasContextHost()); + + function dispatch(event, publishPage = true) { + if (destroyed) return; + if (scoped) + events.dispatchEvent( + new CustomEvent(event.type, { detail: event.detail }), + ); + if (publishPage && hasContextHost()) window.dispatchEvent(event); + } + + function ownsPageSelection(store) { + if (!scoped) return true; + if (!hasContextHost()) return false; + const page = getContextStore(); + const record = store.entities.get(store.selectedEntityId); + return Boolean( + record && page.entities.get(page.selectedEntityId) === record, + ); + } + + function clearSelection(store) { + if (scoped && hasContextHost()) { + const page = getContextStore(); + const record = store.entities.get(store.selectedEntityId); + if (record && page.entities.get(page.selectedEntityId) === record) { + page.selectedEntityId = null; + page.selectedAt = null; + } + } + store.selectedEntityId = null; + store.selectedAt = null; + } + + function deleteRecord(store, id) { + if (scoped && hasContextHost()) { + const page = getContextStore(); + if (page.entities.get(id) === store.entities.get(id)) { + page.entities.delete(id); + if (page.selectedEntityId === id) { + page.selectedEntityId = null; + page.selectedAt = null; + } + } + } + store.entities.delete(id); + } + + function registerEntityContext(entity, metadata) { + if (destroyed || !entity || !metadata?.id) return null; + const store = storeForContext(); + const record = { + ...metadata, + entity, + updatedAt: Date.now(), + }; + entity.__gevContextId = metadata.id; + if (scoped) contextOwners.set(entity, context); + const previous = store.entities.get(metadata.id); + store.entities.set(metadata.id, record); + if (scoped && hasContextHost()) { + const page = getContextStore(); + if ( + page.selectedEntityId !== metadata.id || + page.entities.get(metadata.id) === previous + ) + page.entities.set(metadata.id, record); + } + if ( + scoped && + store.selectedEntityId === metadata.id && + previous?.entity !== entity + ) + dispatch( + new CustomEvent('gev:entity-selected', { detail: record }), + false, + ); + return record; + } + + function selectEntityContext(entity) { + if (destroyed) return; + const store = storeForContext(); + const contextId = entity?.__gevContextId; + if (!contextId || !store.entities.has(contextId)) return null; + store.selectedEntityId = contextId; + store.selectedAt = Date.now(); + if (scoped && hasContextHost()) { + const page = getContextStore(); + page.entities.set( + store.selectedEntityId, + store.entities.get(store.selectedEntityId), + ); + page.selectedEntityId = store.selectedEntityId; + page.selectedAt = store.selectedAt; + } + const record = store.entities.get(contextId); + dispatch(new CustomEvent('gev:entity-selected', { detail: record })); + return record; + } + + /** + * Publish the live subject of a TRACKING layer (aircraft) into the shared + * selection slot. + * + * Selecting a contact is a click-selection like any other — voice's + * `scope:'selected'` and the Cockpit's selected-target lookup both read this + * one slot, so a tracking layer that stays out of it is invisible to them + * even while its readout card is on screen. + * + * Deliberately does NOT dispatch `gev:entity-selected`: tracking layers own a + * separate publication lane (`gev:awareness-subject-selected`) that the + * readout and Contacts panel already consume, and a second event for the same + * click would make those two surfaces fight over one subject. + * + * A tracking layer has at most one subject, so any earlier record it left + * behind is dropped — its feed refreshes continuously and a frozen snapshot + * must never reach the visible-entity scan. + * + * @param {object} metadata Record fields; `id` and `layerId` required. + * @returns {object|null} The stored record, or null when identity is missing. + */ + function selectTrackedSubjectContext(metadata) { + if (!available() || !metadata?.id || !metadata?.layerId) return null; + const store = storeForContext(); + const id = String(metadata.id); + for (const [key, record] of store.entities) { + if (record?.layerId === metadata.layerId && key !== id) + deleteRecord(store, key); + } + // Reuse the existing carrier so a per-poll refresh does not churn identity. + const carrier = store.entities.get(id)?.entity || { __gevContextId: id }; + const record = registerEntityContext(carrier, { ...metadata, id }); + if (!record) return null; + store.selectedEntityId = id; + store.selectedAt = Date.now(); + if (scoped && hasContextHost()) { + const page = getContextStore(); + page.entities.set( + store.selectedEntityId, + store.entities.get(store.selectedEntityId), + ); + page.selectedEntityId = store.selectedEntityId; + page.selectedAt = store.selectedAt; + } + return record; + } + + /** + * Refresh a tracking layer's subject in place WITHOUT claiming the selection. + * + * The per-poll position/identity refresh must not resurrect a subject the + * operator has since replaced by clicking something else. + * @param {object} metadata Record fields; `id` and `layerId` required. + * @returns {object|null} The stored record, or null when it is not registered. + */ + function refreshTrackedSubjectContext(metadata) { + if (!available() || !metadata?.id || !metadata?.layerId) return null; + const store = storeForContext(); + const id = String(metadata.id); + const existing = store.entities.get(id); + if (!existing || existing.layerId !== metadata.layerId) return null; + return registerEntityContext(existing.entity, { ...metadata, id }); + } + + /** + * Drop a tracking layer's subject when the operator deselects it. + * + * Pairs with {@link selectTrackedSubjectContext} and stays event-free for the + * same reason: `gev:awareness-subject-cleared` is the tracking layers' lane. + * @param {string} layerId Owning layer. + * @returns {void} + */ + function clearTrackedSubjectContext(layerId) { + if (!available() || !layerId) return; + const store = storeForContext(); + for (const [key, record] of store.entities) { + if (record?.layerId === layerId) deleteRecord(store, key); + } + if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) { + clearSelection(store); + } + } + + function getSelectedEntityContext({ dataManager = null } = {}) { + const store = storeForContext(); + if (!store.selectedEntityId) return null; + const record = store.entities.get(store.selectedEntityId); + if (!record || !isContextRecordActive(record, dataManager)) { + clearSelection(store); + return null; + } + return record; + } + + /** + * Drop the selected context record owned by a layer. + * @param {string} layerId Owning layer. + * @param {object} [options] Clear origin. + * @param {boolean} [options.evicted=false] The record aged out of its feed + * rather than being deselected. Readouts that stay on screen hold their + * last-known values for an eviction and only tear down on a deliberate clear. + */ + function clearSelectedEntityContextForLayer( + layerId, + { evicted = false } = {}, + ) { + if (destroyed) return; + const store = storeForContext(); + if (!store.selectedEntityId) return; + const record = store.entities.get(store.selectedEntityId); + if (record?.layerId === layerId) { + const publishPage = ownsPageSelection(store); + clearSelection(store); + dispatch( + new CustomEvent('gev:entity-selection-cleared', { + detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' }, + }), + publishPage, + ); + } + } + + /** + * Remove obsolete context records when a viewport-scoped layer refreshes. + * Optional retainIds keeps surviving records and their current selection intact. + */ + function removeEntityContextsForLayer(layerId, { retainIds } = {}) { + if (destroyed) return; + const store = storeForContext(); + const publishPage = ownsPageSelection(store); + for (const [id, record] of store.entities) { + if (record?.layerId === layerId && !retainIds?.has(id)) + deleteRecord(store, id); + } + if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) { + clearSelection(store); + // A viewport refresh dropped the record out from under the selection — + // the user did not deselect anything. + dispatch( + new CustomEvent('gev:entity-selection-cleared', { + detail: { layerId, reason: 'evicted' }, + }), + publishPage, + ); + } + } + + const context = { + getContextStore: storeForContext, + events, + publishAwareness(type, detail) { + dispatch(new CustomEvent(type, { detail })); + }, + registerEntityContext, + removeEntityContext(id) { + if (!destroyed) deleteRecord(storeForContext(), id); + }, + selectEntityContext, + selectTrackedSubjectContext, + refreshTrackedSubjectContext, + clearTrackedSubjectContext, + getSelectedEntityContext, + clearSelectedEntityContextForLayer, + removeEntityContextsForLayer, + destroy({ retainSelection = false } = {}) { + if (destroyed) return null; + const retained = + retainSelection && local && ownsPageSelection(local) + ? local.entities.get(local.selectedEntityId) + : null; + destroyed = true; + if (local) { + for (const id of local.entities.keys()) { + const entity = local.entities.get(id).entity; + if (contextOwners.get(entity) === context) + contextOwners.delete(entity); + if (id !== retained?.id) deleteRecord(local, id); + } + local.entities.clear(); + clearSelection(local); + } + return retained; + }, }; - entity.__gevContextId = metadata.id; - store.entities.set(metadata.id, record); - return record; + // Only an explicit handoff can adopt a page selection; a background scene + // never takes it merely because it registers the same id. + if (retainedSelection && hasContextHost()) { + const page = getContextStore(); + if (page.entities.get(page.selectedEntityId) === retainedSelection) { + local.entities.set(retainedSelection.id, retainedSelection); + local.selectedEntityId = retainedSelection.id; + local.selectedAt = page.selectedAt; + contextOwners.set(retainedSelection.entity, context); + } + } + return context; } -export function selectEntityContext(entity) { - const store = getContextStore(); - const contextId = entity?.__gevContextId; - if (!contextId || !store.entities.has(contextId)) return null; - store.selectedEntityId = contextId; - store.selectedAt = Date.now(); - const record = store.entities.get(contextId); - window.dispatchEvent( - new CustomEvent('gev:entity-selected', { detail: record }), +const pageContext = createContext(); +export const registerEntityContext = (entity, metadata) => + (contextOwners.get(entity) ?? pageContext).registerEntityContext( + entity, + metadata, ); - return record; + +// Page actions use the owner of their target, or of the page's current selection. +export function selectEntityContext(entity) { + const page = getContextStore(); + const owner = + contextOwners.get(entity) ?? + contextOwners.get(page.entities.get(page.selectedEntityId)?.entity); + if (!owner) return pageContext.selectEntityContext(entity); + const record = page.entities.get(entity?.__gevContextId); + if (record && !contextOwners.has(entity)) + owner.registerEntityContext(entity, record); + return owner.selectEntityContext(entity); } -/** - * Publish the live subject of a TRACKING layer (aircraft) into the shared - * selection slot. - * - * Selecting a contact is a click-selection like any other — voice's - * `scope:'selected'` and the Cockpit's selected-target lookup both read this - * one slot, so a tracking layer that stays out of it is invisible to them - * even while its readout card is on screen. - * - * Deliberately does NOT dispatch `gev:entity-selected`: tracking layers own a - * separate publication lane (`gev:awareness-subject-selected`) that the - * readout and Contacts panel already consume, and a second event for the same - * click would make those two surfaces fight over one subject. - * - * A tracking layer has at most one subject, so any earlier record it left - * behind is dropped — its feed refreshes continuously and a frozen snapshot - * must never reach the visible-entity scan. - * - * @param {object} metadata Record fields; `id` and `layerId` required. - * @returns {object|null} The stored record, or null when identity is missing. - */ -export function selectTrackedSubjectContext(metadata) { - if (!hasContextHost() || !metadata?.id || !metadata?.layerId) return null; - const store = getContextStore(); - const id = String(metadata.id); - for (const [key, record] of store.entities) { - if (record?.layerId === metadata.layerId && key !== id) - store.entities.delete(key); - } - // Reuse the existing carrier so a per-poll refresh does not churn identity. - const carrier = store.entities.get(id)?.entity || { __gevContextId: id }; - const record = registerEntityContext(carrier, { ...metadata, id }); - if (!record) return null; - store.selectedEntityId = id; - store.selectedAt = Date.now(); - return record; +export const removeEntityContext = (...args) => + pageContext.removeEntityContext(...args); +export const selectTrackedSubjectContext = (...args) => + pageContext.selectTrackedSubjectContext(...args); +export const refreshTrackedSubjectContext = (...args) => + pageContext.refreshTrackedSubjectContext(...args); +export const clearTrackedSubjectContext = (...args) => + pageContext.clearTrackedSubjectContext(...args); +export const getSelectedEntityContext = (...args) => + pageContext.getSelectedEntityContext(...args); +export function clearSelectedEntityContextForLayer(...args) { + const page = getContextStore(); + const owner = contextOwners.get( + page.entities.get(page.selectedEntityId)?.entity, + ); + return (owner ?? pageContext).clearSelectedEntityContextForLayer(...args); } +export const removeEntityContextsForLayer = (...args) => + pageContext.removeEntityContextsForLayer(...args); -/** - * Refresh a tracking layer's subject in place WITHOUT claiming the selection. - * - * The per-poll position/identity refresh must not resurrect a subject the - * operator has since replaced by clicking something else. - * @param {object} metadata Record fields; `id` and `layerId` required. - * @returns {object|null} The stored record, or null when it is not registered. - */ -export function refreshTrackedSubjectContext(metadata) { - if (!hasContextHost() || !metadata?.id || !metadata?.layerId) return null; - const store = getContextStore(); - const id = String(metadata.id); - const existing = store.entities.get(id); - if (!existing || existing.layerId !== metadata.layerId) return null; - return registerEntityContext(existing.entity, { ...metadata, id }); -} - -/** - * Drop a tracking layer's subject when the operator deselects it. - * - * Pairs with {@link selectTrackedSubjectContext} and stays event-free for the - * same reason: `gev:awareness-subject-cleared` is the tracking layers' lane. - * @param {string} layerId Owning layer. - * @returns {void} - */ -export function clearTrackedSubjectContext(layerId) { - if (!hasContextHost() || !layerId) return; - const store = getContextStore(); - for (const [key, record] of store.entities) { - if (record?.layerId === layerId) store.entities.delete(key); - } - if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) { - store.selectedEntityId = null; - store.selectedAt = null; - } -} - -export function getSelectedEntityContext({ dataManager = null } = {}) { - const store = getContextStore(); - if (!store.selectedEntityId) return null; - const record = store.entities.get(store.selectedEntityId); - if (!record || !isContextRecordActive(record, dataManager)) { - store.selectedEntityId = null; - store.selectedAt = null; - return null; - } - return record; -} - -/** - * Drop the selected context record owned by a layer. - * @param {string} layerId Owning layer. - * @param {object} [options] Clear origin. - * @param {boolean} [options.evicted=false] The record aged out of its feed - * rather than being deselected. Readouts that stay on screen hold their - * last-known values for an eviction and only tear down on a deliberate clear. - */ -export function clearSelectedEntityContextForLayer( - layerId, - { evicted = false } = {}, -) { - const store = getContextStore(); - if (!store.selectedEntityId) return; - const record = store.entities.get(store.selectedEntityId); - if (record?.layerId === layerId) { - store.selectedEntityId = null; - store.selectedAt = null; - window.dispatchEvent( - new CustomEvent('gev:entity-selection-cleared', { - detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' }, - }), - ); - } -} - -/** - * Remove obsolete context records when a viewport-scoped layer refreshes. - * Optional retainIds keeps surviving records and their current selection intact. - */ -export function removeEntityContextsForLayer(layerId, { retainIds } = {}) { - const store = getContextStore(); - for (const [id, record] of store.entities) { - if (record?.layerId === layerId && !retainIds?.has(id)) - store.entities.delete(id); - } - if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) { - store.selectedEntityId = null; - store.selectedAt = null; - // A viewport refresh dropped the record out from under the selection — - // the user did not deselect anything. - window.dispatchEvent( - new CustomEvent('gev:entity-selection-cleared', { - detail: { layerId, reason: 'evicted' }, - }), - ); - } +export function forEngine(engine) { + return sceneServicesFor(engine)?.context ?? null; } export function isContextRecordActive(record, dataManager = null) { diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index ec4da6eac..860ffcf4a 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -1,3 +1,4 @@ +import { createSceneServices } from './sceneServices.js'; // src/data/flights.test.mjs // Focused tests for the pure analyst-record mapper (analyst query engine seam). // Pure function — no viewer/DOM needed; imported directly. @@ -24,7 +25,6 @@ import { _clearMeshFloorCellsForTest, } from './groundFloor.js'; import { - _setTrackedOverlayHostForTest, destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; @@ -478,11 +478,12 @@ test('real civil track path creates no native label and publishes every cached h }, ); globalThis.fetch = async () => ({ ok: false }); - _setTrackedOverlayHostForTest(host); + const engine = trackingEngine(viewer); + const readoutServices = createSceneServices(engine, { trackedOverlay: host }); try { - initTrackedReadout(trackingEngine(viewer)); + initTrackedReadout(engine); _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: (options) => aircraftViewerRenderer({ ...options, engine }), icao24, entity: null, billboard, @@ -710,8 +711,8 @@ test('real civil track path creates no native label and publishes every cached h } finally { flightsLayer.stopTracking(); viewer.scene.preUpdate.raiseEvent(); - destroyTrackedReadout(); - _setTrackedOverlayHostForTest(); + destroyTrackedReadout(engine); + readoutServices.destroy(); globalThis.fetch = realFetch; globalThis.window = realWindow; } diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 604cd1091..87c9031a7 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -1,3 +1,4 @@ +import { createSceneServices } from './sceneServices.js'; // src/data/militaryFlights.test.mjs // Focused tests for the pure analyst-record mapper (analyst query engine seam). // Pure function — no viewer/DOM needed; imported directly. @@ -13,7 +14,6 @@ import militaryFlightsLayer, { mapAnalystRecord, } from './militaryFlights.js'; import { - _setTrackedOverlayHostForTest, destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; @@ -406,11 +406,12 @@ test('real military track path creates no native label and publishes every cache }, ); globalThis.fetch = async () => ({ ok: false }); - _setTrackedOverlayHostForTest(host); + const engine = trackingEngine(viewer); + const readoutServices = createSceneServices(engine, { trackedOverlay: host }); try { - initTrackedReadout(trackingEngine(viewer)); + initTrackedReadout(engine); _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: (options) => aircraftViewerRenderer({ ...options, engine }), icao24, entity: null, billboard, @@ -649,8 +650,8 @@ test('real military track path creates no native label and publishes every cache } finally { militaryFlightsLayer.stopTracking(); viewer.scene.preUpdate.raiseEvent(); - destroyTrackedReadout(); - _setTrackedOverlayHostForTest(); + destroyTrackedReadout(engine); + readoutServices.destroy(); globalThis.fetch = realFetch; globalThis.window = realWindow; } diff --git a/src/data/sceneServices.js b/src/data/sceneServices.js index 2c43f8635..84d1d1098 100644 --- a/src/data/sceneServices.js +++ b/src/data/sceneServices.js @@ -1,3 +1,5 @@ +import { createSceneContext } from './contextStore.js'; +import { createTrackedReadout } from './trackedReadout.js'; import { createWorldOverlay } from '../overlays/worldOverlay.js'; import { createRenderGovernor } from '../renderGovernor.js'; import { createPickRegistry } from './pickRegistry.js'; @@ -11,6 +13,17 @@ const scenes = new WeakMap(); export function createSceneServices(engine, options = {}) { scenes.get(engine)?.destroy(); const overlay = createWorldOverlay(engine); + const context = createSceneContext(options.retainedSelection); + const trackedReadout = createTrackedReadout(engine, { + overlay: options.trackedOverlay ?? overlay, + events: context.events, + selectedContext: () => { + // Retained layers replace hidden outgoing carriers after camera restore. + // Initializing a readout must not clear their selection in the meantime. + const store = context.getContextStore(); + return store.entities.get(store.selectedEntityId) ?? null; + }, + }); const render = createRenderGovernor(engine); const picking = createPickRegistry(); const selection = createSelectionDispatcher(engine, { ...options, picking }); @@ -18,15 +31,20 @@ export function createSceneServices(engine, options = {}) { const services = { picking, selection, + context, + trackedReadout, render, overlay, - destroy() { + destroy(options) { if (destroyed) return; destroyed = true; selection.destroy(); picking.destroy(); + trackedReadout.destroy(); + const retained = context.destroy(options); overlay.destroy(); render.destroy(); + return retained; }, }; scenes.set(engine, services); diff --git a/src/data/trackedAircraftScenes.test.mjs b/src/data/trackedAircraftScenes.test.mjs new file mode 100644 index 000000000..fe71c2096 --- /dev/null +++ b/src/data/trackedAircraftScenes.test.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as geo from '../geo/index.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs'; +import { createApplicationFlights } from '../app/layers/flights.js'; +import { createApplicationMilitary } from '../app/layers/militaryFlights.js'; +import { + createAircraftDisplay, + aircraftAtlasLayout, +} from '../layers/aircraft/render/display.js'; +import { createSceneServices } from './sceneServices.js'; +import { defaultSurface } from './surfaceServices.js'; +import * as militaryRegistry from './militaryRegistry.js'; +import { getSelectedEntityContext } from './contextStore.js'; + +const layout = aircraftAtlasLayout(); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const height = (position) => geo.cartographic.fromCartesian(position).height; + +function fakeHarness() { + const engine = createFakeEngine(); + return { engine, frame: () => engine.fake.frame() }; +} + +for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) { + for (const [layerId, createLayer] of [ + ['flights', createApplicationFlights], + ['military', createApplicationMilitary], + ]) { + test(`${makeHarness.name}: ${layerId} grounded models, follow readout and display floor share their frame`, async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: Date.now() }); + const previous = { + window: globalThis.window, + document: globalThis.document, + fetch: globalThis.fetch, + }; + globalThis.window = new EventTarget(); + globalThis.document = { + body: { classList: { contains: () => false } }, + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.fetch = async () => ({ ok: false }); + const h = makeHarness(); + const { engine } = h; + engine.surface.sampleHeightDetailed = () => ({ + height: 204.5, + source: 'tiles', + }); + engine.surface.tilesLoaded = () => true; + const publications = []; + const services = createSceneServices(engine, { + trackedOverlay: { + setEntries(source, entries) { + publications.push(entries[0]); + }, + setVisible() {}, + clearSource() {}, + }, + }); + services.trackedReadout.init(); + let display; + let callsign = 'GROUND1'; + const id = layerId === 'flights' ? 'aaa097' : 'bbb197'; + let floor; + const surface = { + ...defaultSurface, + groundFloor: { + ...defaultSurface.groundFloor, + cachedGroundFloor: () => floor, + resolveGroundFloorCellsBounded: async () => {}, + }, + meshFloor: { ...defaultSurface.meshFloor, warmMeshFloorCells() {} }, + }; + const layer = createLayer({ + surface, + militaryRegistry, + source: { + label: 'Fixture aircraft', + getSnapshot: async () => ({ + source: 'Fixture aircraft', + complete: true, + observedAtMs: Date.now(), + records: [ + { + id, + callsign, + latitude: 30.3, + longitude: -97.6, + onGround: true, + baroAltitudeM: 0, + courseDeg: 90, + speedMps: 0, + positionTimeMs: Date.now(), + contactTimeMs: Date.now(), + }, + ], + }), + }, + renderer: { + create: ({ engine }) => + (display = createAircraftDisplay(engine, { + layerId, + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + })), + }, + }); + t.after(() => { + layer.disable(); + layer.destroy(); + services.destroy(); + engine.destroy(); + Object.assign(globalThis, previous); + }); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97.6, 30.3, 8000), + pitch: -Math.PI / 2, + }); + layer.init(null, { engine }); + layer.enable(); + layer.setParams({ models3d: true }); + await display.whenReady(); + await layer.update(); + floor = 204.5; + async function frames(n = 6) { + for (let i = 0; i < n; i++) { + if (h.viewer) + h.viewer.scene.frameState.frameNumber = + (h.viewer.scene.frameState.frameNumber || 0) + 1; + for (const model of h.loads ?? []) model.ready = true; + t.mock.timers.tick(250); + h.frame(); + await settle(); + } + } + await frames(); + assert.equal(layer.getAllPositions().length, 1); + const fleet = layer.qaDrawn().find((row) => row.id === id); + assert.ok( + fleet.model?.ready && fleet.model.show, + 'a grounded fleet contact gets a ready, shown model', + ); + assert.ok( + height(fleet.model.position) >= 204.5 && + height(fleet.model.position) < 217, + 'the fleet model stands on the sampled ground', + ); + assert.equal(layer.trackById(id, { origin: 'user' }), true); + await frames(); + const follow = engine.camera.following(); + const native = follow.native; + assert.equal(services.trackedReadout.activeId(), `${layerId}:${id}`); + assert.equal(getSelectedEntityContext().id, id); + const trackedModel = layer + .qaDrawn() + .find((row) => row.trackedModel)?.model; + assert.ok( + trackedModel?.ready && trackedModel.show, + 'the tracked model owns the visual', + ); + const cameraPosition = native.gevDisplayPosition(); + assert.ok(cameraPosition); + if (layerId === 'flights') + assert.ok( + height(native.gevDisplayPosition()) < 100, + 'a ready tracked model releases the billboard floor', + ); + assert.deepEqual( + publications.at(-1).position(), + follow.label.position(), + 'the card uses the follow label cache', + ); + const cached = { ...native.gevDisplayPosition() }; + publications.at(-1).position(); + assert.deepEqual( + { ...native.gevDisplayPosition() }, + cached, + 'painting never advances the follow sample', + ); + follow.retarget({ viewFrom: { x: 0, y: 0, z: 178000 } }); + await frames(); + const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model; + assert.ok(!zoomedOut?.show, 'zooming out releases the tracked model'); + if (layerId === 'flights') + assert.ok( + height(native.gevDisplayPosition()) >= 205.5, + 'the billboard regains its floor', + ); + assert.equal(services.trackedReadout.activeId(), `${layerId}:${id}`); + assert.equal(engine.camera.following(), follow); + callsign = 'GROUND2'; + await layer.update(); + await frames(); + assert.equal(publications.at(-1).title, callsign); + assert.equal(engine.camera.following(), follow); + assert.ok( + Math.abs(layer.getTrackedInfo().altitudeM) < 50, + 'sensor truth is independent of the display floor', + ); + }); + } +} diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 46ae123b1..629d8f56f 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -1,8 +1,4 @@ -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; +import { sceneServicesFor } from './sceneServices.js'; import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js'; /** @@ -27,22 +23,6 @@ const TRACKED_BILLBOARD_SCALE = Object.freeze({ farValue: 0.5, }); -const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({ - setEntries: (...args) => setOverlayEntries(_engine, ...args), - setVisible: (...args) => setOverlaySourceVisible(_engine, ...args), - clearSource: (...args) => clearOverlaySource(_engine, ...args), -}); - -// The globe engine: the followed target is engine.camera.following(). -let _engine = null; -let _trackedEntityChangedRemove = null; -let _selectedContext = null; -let _contextSelectedHandler = null; -let _contextClearedHandler = null; -let _aircraftSelectedHandler = null; -let _activeEntryId = null; -let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST; - /** * Convert the former newline/inline label text into the explicit presentation * model used by the host. @@ -123,36 +103,6 @@ function entryIdFor(entity) { return fallback ? `entity:${fallback}` : null; } -// A follow's plain-data label as the subject the readout reads (the shape a -// Cesium layer's tracked Entity has): one subject per follow handle. -const _labelSubjects = new WeakMap(); -function followSubject(handle) { - if (!handle) return null; - const { label } = handle; - if (!label) return handle.native ?? null; - let subject = _labelSubjects.get(handle); - if (!subject) { - subject = { - gevTrackedId: label.id, - get gevLabelModel() { - const { model } = handle.label ?? label; - return typeof model === 'function' ? model() : model; - }, - gevDisplayPosition: () => (handle.label ?? label).position(), - }; - _labelSubjects.set(handle, subject); - } - return subject; -} - -function trackedSubject() { - return _engine ? followSubject(_engine.camera.following()) : null; -} - -function activeEntity() { - return trackedSubject() || _selectedContext?.entity || null; -} - /** * Build the host entry from a layer-owned tracked presentation model. * @param {Object|null} entity Tracked or selected presentation entity. @@ -206,138 +156,213 @@ export function createTrackedOverlayEntry(entity) { }; } -function publishEntity(entity) { - const entry = createTrackedOverlayEntry(entity); - if (!entry) { - clearTrackedSource(); - return; +/** + * The tracked card bridge for one scene attachment. + * @param {import('../globe/contract.js').GlobeEngine} engine + * @param {{ overlay: object, events: EventTarget }} services + */ +export function createTrackedReadout( + engine, + { overlay, events, selectedContext }, +) { + const _engine = engine; + const _overlayHost = overlay; + let destroyed = false; + let initialized = false; + let _trackedEntityChangedRemove = null; + let _selectedContext = null; + let _contextSelectedHandler = null; + let _contextClearedHandler = null; + let _aircraftSelectedHandler = null; + let _activeEntryId = null; + // A follow's plain-data label as the subject the readout reads (the shape a + // Cesium layer's tracked Entity has): one subject per follow handle. + const _labelSubjects = new WeakMap(); + function followSubject(handle) { + if (!handle) return null; + const { label } = handle; + if (!label) return handle.native ?? null; + let subject = _labelSubjects.get(handle); + if (!subject) { + subject = { + gevTrackedId: label.id, + get gevLabelModel() { + const { model } = handle.label ?? label; + return typeof model === 'function' ? model() : model; + }, + gevDisplayPosition: () => (handle.label ?? label).position(), + }; + _labelSubjects.set(handle, subject); + } + return subject; } - _activeEntryId = entry.id; - _overlayHost.setEntries( - TRACKED_OVERLAY_SOURCE_ID, - [entry], - TRACKED_OVERLAY_SOURCE_OPTIONS, - ); - _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); -} -function clearTrackedSource() { - _activeEntryId = null; - _overlayHost.clearSource(TRACKED_OVERLAY_SOURCE_ID); -} + function trackedSubject() { + return _engine ? followSubject(_engine.camera.following()) : null; + } -function syncActiveEntity() { - publishEntity(activeEntity()); -} + function activeEntity() { + return trackedSubject() || _selectedContext?.entity || null; + } -/** - * Republish a layer's newly assigned `gevLabelModel` when that entity owns the - * active tracked readout. Layers call this only when presentation text changes; - * position remains the live frame-cache getter already registered with host. - * @param {Object} entity Entity whose model changed. - */ -export function refreshTrackedReadout(entity) { - // A follow handle stands for its label subject; so does a layer's tracked - // visual that names its follow handle (gevFollowHandle, an engine without - // tracked entities: the Navara aircraft renderer). - const subject = - _labelSubjects.get(entity) ?? - _labelSubjects.get(entity?.gevFollowHandle) ?? - entity; - if (subject && subject === activeEntity()) publishEntity(subject); -} - -/** Current tracked host entry id, used to resolve its actual painted rectangle. */ -export function getActiveTrackedReadoutId() { - return _activeEntryId; -} - -/** Static-context layers whose click selection publishes the readout card. */ -const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']); - -/** - * Initialize the model bridge and selection listeners. No render listener is - * installed; the already-initialized world-overlay host owns the frame lane. - * @param {import('../globe/contract.js').GlobeEngine} engine The readout - * follows engine.camera.following(), its label (plain data) or its Cesium - * entity. - */ -export function initTrackedReadout(engine) { - if (!engine || _engine === engine) return; - if (_engine) destroyTrackedReadout(); - _engine = engine; - _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); - _trackedEntityChangedRemove = - engine.camera.followChanged?.addEventListener?.(() => { - if (trackedSubject()) _selectedContext = null; - syncActiveEntity(); - }) || null; - _contextSelectedHandler = (event) => { - const record = event.detail; - if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) { - _selectedContext = record; - publishEntity(record.entity); + function publishEntity(entity) { + if (destroyed || !initialized) return; + const entry = createTrackedOverlayEntry(entity); + if (!entry) { + clearTrackedSource(); return; } - if (_selectedContext) { - _selectedContext = null; - syncActiveEntity(); - } - }; - _contextClearedHandler = (event) => { - if ( - !_selectedContext || - event.detail?.layerId === _selectedContext.layerId - ) { - _selectedContext = null; - syncActiveEntity(); - } - }; - _aircraftSelectedHandler = () => { - _selectedContext = null; - if (!trackedSubject()) clearTrackedSource(); - }; - window.addEventListener('gev:entity-selected', _contextSelectedHandler); - window.addEventListener( - 'gev:entity-selection-cleared', - _contextClearedHandler, - ); - window.addEventListener( - 'gev:awareness-subject-selected', - _aircraftSelectedHandler, - ); - syncActiveEntity(); - // Boot-verification contract: the track regression harness asserts this - // exact line at init (dropped by the host migration; restored). - console.log('[TrackedReadout] Initialized'); -} + _activeEntryId = entry.id; + _overlayHost.setEntries( + TRACKED_OVERLAY_SOURCE_ID, + [entry], + TRACKED_OVERLAY_SOURCE_OPTIONS, + ); + _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); + } -/** Tear down selection listeners and clear the host source. */ -export function destroyTrackedReadout() { - _trackedEntityChangedRemove?.(); - _trackedEntityChangedRemove = null; - if (_contextSelectedHandler) - window.removeEventListener('gev:entity-selected', _contextSelectedHandler); - if (_contextClearedHandler) - window.removeEventListener( + function clearTrackedSource() { + _activeEntryId = null; + _overlayHost.clearSource(TRACKED_OVERLAY_SOURCE_ID); + } + + function syncActiveEntity() { + if (destroyed) return; + publishEntity(activeEntity()); + } + + /** + * Republish a layer's newly assigned `gevLabelModel` when that entity owns the + * active tracked readout. Layers call this only when presentation text changes; + * position remains the live frame-cache getter already registered with host. + * @param {Object} entity Entity whose model changed. + */ + function refresh(entity) { + if (destroyed) return; + // A follow handle stands for its label subject; so does a layer's tracked + // visual that names its follow handle (gevFollowHandle, an engine without + // tracked entities: the Navara aircraft renderer). + const subject = + _labelSubjects.get(entity) ?? + _labelSubjects.get(entity?.gevFollowHandle) ?? + entity; + if (subject && subject === activeEntity()) publishEntity(subject); + } + + /** Current tracked host entry id, used to resolve its actual painted rectangle. */ + function activeId() { + return _activeEntryId; + } + + /** Static-context layers whose click selection publishes the readout card. */ + const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']); + + /** + * Initialize the model bridge and selection listeners. No render listener is + * installed; the already-initialized world-overlay host owns the frame lane. + */ + function init() { + if (destroyed || initialized) return; + initialized = true; + _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); + _trackedEntityChangedRemove = + engine.camera.followChanged?.addEventListener?.(() => { + if (trackedSubject()) _selectedContext = null; + syncActiveEntity(); + }) || null; + _contextSelectedHandler = (event) => { + if (destroyed) return; + const record = event.detail; + if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) { + _selectedContext = record; + publishEntity(record.entity); + return; + } + if (_selectedContext) { + _selectedContext = null; + syncActiveEntity(); + } + }; + _contextClearedHandler = (event) => { + if (destroyed) return; + if ( + !_selectedContext || + event.detail?.layerId === _selectedContext.layerId + ) { + _selectedContext = null; + syncActiveEntity(); + } + }; + _aircraftSelectedHandler = () => { + if (destroyed) return; + _selectedContext = null; + if (!trackedSubject()) clearTrackedSource(); + }; + events.addEventListener('gev:entity-selected', _contextSelectedHandler); + events.addEventListener( 'gev:entity-selection-cleared', _contextClearedHandler, ); - if (_aircraftSelectedHandler) - window.removeEventListener( + events.addEventListener( 'gev:awareness-subject-selected', _aircraftSelectedHandler, ); - _contextSelectedHandler = null; - _contextClearedHandler = null; - _aircraftSelectedHandler = null; - _selectedContext = null; - clearTrackedSource(); - _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false); - _engine = null; + const selected = selectedContext?.(); + if (READOUT_CONTEXT_LAYERS.has(selected?.layerId)) + _selectedContext = selected; + syncActiveEntity(); + // The track regression harness checks this initialization marker. + console.log('[TrackedReadout] Initialized'); + } + + /** Tear down selection listeners and clear the host source. */ + function destroy() { + if (destroyed) return; + destroyed = true; + _trackedEntityChangedRemove?.(); + _trackedEntityChangedRemove = null; + if (_contextSelectedHandler) + events.removeEventListener( + 'gev:entity-selected', + _contextSelectedHandler, + ); + if (_contextClearedHandler) + events.removeEventListener( + 'gev:entity-selection-cleared', + _contextClearedHandler, + ); + if (_aircraftSelectedHandler) + events.removeEventListener( + 'gev:awareness-subject-selected', + _aircraftSelectedHandler, + ); + _contextSelectedHandler = null; + _contextClearedHandler = null; + _aircraftSelectedHandler = null; + _selectedContext = null; + clearTrackedSource(); + _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false); + } + + return { init, refresh, activeId, destroy }; } -/** Inject a host recorder for focused lifecycle tests; null restores production. */ -export function _setTrackedOverlayHostForTest(host = null) { - _overlayHost = host || DEFAULT_TRACKED_OVERLAY_HOST; +export function trackedReadoutFor(engine) { + return sceneServicesFor(engine)?.trackedReadout ?? null; +} + +export function initTrackedReadout(engine) { + trackedReadoutFor(engine)?.init(); +} + +export function refreshTrackedReadout(entity, engine) { + trackedReadoutFor(engine)?.refresh(entity); +} + +export function getActiveTrackedReadoutId(engine) { + return trackedReadoutFor(engine)?.activeId() ?? null; +} + +export function destroyTrackedReadout(engine) { + trackedReadoutFor(engine)?.destroy(); } diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 2c8790651..36cd45280 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -1,10 +1,10 @@ +import { createSceneServices } from './sceneServices.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { trackingEngine } from '../testSupport/viewerEngine.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { - _setTrackedOverlayHostForTest, cachedTrackedDisplayPosition, cachedTrackedVisualPosition, createTrackedOverlayEntry, @@ -156,9 +156,10 @@ test('tracked entity publishes a protected host entry backed by the frame cache' const viewer = { trackedEntity: entity, trackedEntityChanged: changed }; const recorder = makeHostRecorder(); globalThis.window = fakeWindow; - _setTrackedOverlayHostForTest(recorder.host); + const engine = trackingEngine(viewer); + const services = createSceneServices(engine, { trackedOverlay: recorder.host }); try { - initTrackedReadout(trackingEngine(viewer)); + initTrackedReadout(engine); const publication = recorder.calls.find(({ op }) => op === 'set'); assert.ok(publication); assert.equal(publication.sourceId, 'tracked'); @@ -173,21 +174,21 @@ test('tracked entity publishes a protected host entry backed by the frame cache' assert.equal(entry.position(), display); assert.equal(publication.options.collisionCapacity, 0); assert.equal(publication.options.hideInCockpit, true); - assert.equal(getActiveTrackedReadoutId(), 'flights:abc123'); + assert.equal(getActiveTrackedReadoutId(engine), 'flights:abc123'); entity.gevLabelModel = { ...entity.gevLabelModel, details: ['FL360 · 455 kts'] }; - refreshTrackedReadout(entity); + refreshTrackedReadout(entity, engine); assert.deepEqual(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details, [ 'FL360 · 455 kts', ]); viewer.trackedEntity = null; changed.raise(); - assert.equal(getActiveTrackedReadoutId(), null); + assert.equal(getActiveTrackedReadoutId(engine), null); assert.equal(recorder.calls.at(-1).op, 'clear'); } finally { - destroyTrackedReadout(); - _setTrackedOverlayHostForTest(); + destroyTrackedReadout(engine); + services.destroy(); globalThis.window = originalWindow; } }); @@ -204,19 +205,20 @@ test('selection lifecycle leaves installations to their own entry and retains AL gevLabelModel: { title: 'FORT TEST', details: ['AIRFIELD'], accent: '#5aa9ff' }, }; globalThis.window = fakeWindow; - _setTrackedOverlayHostForTest(recorder.host); + const engine = trackingEngine(viewer); + const services = createSceneServices(engine, { trackedOverlay: recorder.host }); try { - initTrackedReadout(trackingEngine(viewer)); + initTrackedReadout(engine); const setsBefore = recorder.calls.filter(({ op }) => op === 'set').length; - fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { + services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'ais-live-vessels', entity: installation }, })); assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore); - fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { + services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'military-installations', entity: installation }, })); - assert.equal(getActiveTrackedReadoutId(), null); + assert.equal(getActiveTrackedReadoutId(engine), null); assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore, 'installation selection cannot create a second overlay'); // ALPR cameras are static context too: a click publishes the same card. @@ -225,35 +227,35 @@ test('selection lifecycle leaves installations to their own entry and retains AL gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }), gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' }, }; - fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { + services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'alpr-cameras', entity: camera }, })); - assert.equal(getActiveTrackedReadoutId(), 'alpr:42'); + assert.equal(getActiveTrackedReadoutId(engine), 'alpr:42'); assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FLOCK SAFETY ALPR'); - fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { + services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'ais-live-vessels', entity: installation }, })); - assert.equal(getActiveTrackedReadoutId(), null, 'sibling selection clears an installation card'); + assert.equal(getActiveTrackedReadoutId(engine), null, 'sibling selection clears an installation card'); - fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { + services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'military-installations', entity: installation }, })); - fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', { + services.context.events.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', { detail: { layerId: 'military-installations' }, })); - assert.equal(getActiveTrackedReadoutId(), null); + assert.equal(getActiveTrackedReadoutId(engine), null); assert.ok(recorder.calls.some(({ op }) => op === 'clear')); const clearsBeforeDestroy = recorder.calls.filter(({ op }) => op === 'clear').length; - destroyTrackedReadout(); + destroyTrackedReadout(engine); assert.ok(recorder.calls.filter(({ op }) => op === 'clear').length > clearsBeforeDestroy); assert.equal(changed.size, 0, 'trackedEntityChanged listener was removed'); assert.ok(recorder.calls.some(({ op, visible }) => op === 'visible' && visible === false)); } finally { - destroyTrackedReadout(); - _setTrackedOverlayHostForTest(); + destroyTrackedReadout(engine); + services.destroy(); globalThis.window = originalWindow; } }); @@ -316,41 +318,41 @@ test("on a globe engine the readout follows engine.camera.follow's plain-data la const originalWindow = globalThis.window; globalThis.window = new EventTarget(); const recorder = makeHostRecorder(); - _setTrackedOverlayHostForTest(recorder.host); const engine = createFakeEngine(); + const services = createSceneServices(engine, { trackedOverlay: recorder.host }); const anchor = { x: 6_378_137, y: 0, z: 1000 }; let model = { title: 'ISS (ZARYA)', details: ['408 km'], accent: '#ffd166' }; try { initTrackedReadout(engine); - assert.equal(getActiveTrackedReadoutId(), null); + assert.equal(getActiveTrackedReadoutId(engine), null); const handle = engine.camera.follow({ position: () => anchor, viewFrom: { x: 0, y: -5000, z: 2000 }, label: { id: 'satellites:25544', model: () => model, position: () => anchor }, }); - assert.equal(getActiveTrackedReadoutId(), 'satellites:25544'); + assert.equal(getActiveTrackedReadoutId(engine), 'satellites:25544'); const entry = recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0]; assert.equal(entry.title, 'ISS (ZARYA)'); assert.equal(entry.position(), anchor); // A layer republishes its model through the follow handle. model = { ...model, details: ['409 km'] }; - refreshTrackedReadout(handle); + refreshTrackedReadout(handle, engine); assert.deepEqual( recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details, ['409 km'], ); // So does a layer's tracked visual that names its follow handle. model = { ...model, details: ['410 km'] }; - refreshTrackedReadout({ gevFollowHandle: handle }); + refreshTrackedReadout({ gevFollowHandle: handle }, engine); assert.deepEqual( recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details, ['410 km'], ); handle.stop(); - assert.equal(getActiveTrackedReadoutId(), null); + assert.equal(getActiveTrackedReadoutId(engine), null); } finally { - destroyTrackedReadout(); - _setTrackedOverlayHostForTest(); + destroyTrackedReadout(engine); + services.destroy(); engine.destroy(); globalThis.window = originalWindow; } diff --git a/src/data/trackedReadoutScenes.test.mjs b/src/data/trackedReadoutScenes.test.mjs new file mode 100644 index 000000000..a1f633b5b --- /dev/null +++ b/src/data/trackedReadoutScenes.test.mjs @@ -0,0 +1,769 @@ +import * as geo from '../geo/index.js'; +import { createApplicationScene } from '../app/scene.js'; +import { createLayerSession } from '../app/layerSession.js'; +import * as context from './contextStore.js'; +import * as picking from './pickRegistry.js'; +import * as selection from './selectionDispatcher.js'; +import { createLocalAdsbLayer } from '../layers/localAdsb/index.js'; +import { createLocalAdsbDisplay } from '../layers/localAdsb/render/display.js'; +import { aircraftAtlasLayout } from '../layers/aircraft/render/display.js'; +import { createAlprDisplay } from '../layers/alpr/render/display.js'; +import { createFirmsHeatmapLayer } from '../layers/firms/index.js'; +import { createFirmsDisplay } from '../layers/firms/render/display.js'; +import { CONTEXT_TOP_N } from '../layers/firms/policy.js'; +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 { getSelectedEntityContext } from './contextStore.js'; +import { trackedReadoutFor, refreshTrackedReadout } from './trackedReadout.js'; + +function recorder() { + const calls = []; + return { + calls, + setEntries(source, entries) { + calls.push({ source, entry: entries[0] }); + }, + setVisible() {}, + clearSource(source) { + calls.push({ source, entry: null }); + }, + last: () => calls.at(-1)?.entry, + }; +} + +function subject(title = 'Camera') { + return { + gevTrackedId: 'shared-id', + gevLabelModel: { title, details: ['Original'] }, + gevDisplayPosition: () => ({ x: 6378137, y: 0, z: 100 }), + }; +} + +function select(context, entity, layerId = 'alpr-cameras') { + const record = context.registerEntityContext(entity, { + id: 'shared-id', + layerId, + }); + context.selectEntityContext(entity); + return record; +} + +for (const makeHarness of GROUND_HARNESSES) { + function setup(t) { + const original = globalThis.window; + const page = new EventTarget(); + globalThis.window = page; + const harnesses = [makeHarness(), makeHarness()]; + const hosts = [recorder(), recorder()]; + const services = harnesses.map(({ engine }, i) => + createSceneServices(engine, { trackedOverlay: hosts[i] }), + ); + services.forEach((s) => s.trackedReadout.init()); + t.after(() => { + for (const { engine } of harnesses) sceneServicesFor(engine).destroy(); + harnesses.forEach((h) => h.destroy()); + globalThis.window = original; + }); + return { harnesses, hosts, services, page }; + } + + test(`${makeHarness.name}: simultaneous follows refresh only their own card and cached anchor`, (t) => { + const { harnesses, services, hosts } = setup(t); + const models = [ + { title: 'First', details: ['100 km'] }, + { title: 'Second', details: ['200 km'] }, + ]; + const anchors = [ + { x: 6378137, y: 0, z: 100 }, + { x: 6378137, y: 100, z: 200 }, + ]; + const handles = harnesses.map(({ engine }, i) => + engine.camera.follow({ + position: () => anchors[i], + viewFrom: { x: 0, y: -5000, z: 2000 }, + label: { + id: 'same-follow-id', + model: () => models[i], + position: () => anchors[i], + }, + }), + ); + assert.equal( + trackedReadoutFor(harnesses[0].engine), + services[0].trackedReadout, + ); + assert.deepEqual( + hosts.map((h) => h.last().title), + ['First', 'Second'], + ); + assert.equal(hosts[0].last().position(), anchors[0]); + assert.equal(hosts[1].last().position(), anchors[1]); + models[0] = { title: 'First refreshed', details: ['101 km'] }; + const before = hosts[1].calls.length; + refreshTrackedReadout(handles[0], harnesses[0].engine); + assert.equal(hosts[0].last().title, 'First refreshed'); + assert.equal(hosts[1].calls.length, before); + models[1] = { title: 'Second refreshed', details: ['201 km'] }; + refreshTrackedReadout({ gevFollowHandle: handles[1] }, harnesses[1].engine); + assert.equal(hosts[1].last().title, 'Second refreshed'); + refreshTrackedReadout(handles[1], harnesses[0].engine); + assert.equal(hosts[0].last().title, 'First refreshed'); + handles[0].stop(); + assert.equal(hosts[0].last(), null); + assert.equal(services[1].trackedReadout.activeId(), 'same-follow-id'); + services[1].destroy(); + const callsAfterDestroy = hosts[1].calls.length; + handles[1].stop(); + refreshTrackedReadout(handles[1], harnesses[1].engine); + assert.equal(hosts[1].last(), null); + assert.equal( + hosts[1].calls.length, + callsAfterDestroy, + 'follow changes after teardown cannot republish', + ); + }); + + test(`${makeHarness.name}: scoped selections, clears and aircraft events preserve page notifications`, (t) => { + const { + services: [a, b], + hosts, + page, + } = setup(t); + const notifications = []; + const clears = []; + for (const scene of [a, b]) + scene.context.events.addEventListener( + 'gev:entity-selection-cleared', + ({ detail }) => clears.push(detail), + ); + for (const type of [ + 'gev:entity-selected', + 'gev:entity-selection-cleared', + 'gev:awareness-subject-selected', + ]) + page.addEventListener(type, ({ detail }) => + notifications.push({ type, detail }), + ); + const first = subject('First camera'); + const second = subject('Second camera'); + const firstRecord = select(a.context, first); + assert.equal(notifications[0].detail, firstRecord); + assert.equal(getSelectedEntityContext(), firstRecord); + // Registering an identical id in another scene does not replace the page selection. + b.context.registerEntityContext(second, { + id: 'shared-id', + layerId: 'alpr-cameras', + }); + assert.equal(getSelectedEntityContext(), firstRecord); + const secondRecord = b.context.selectEntityContext(second); + assert.equal(getSelectedEntityContext(), secondRecord); + assert.deepEqual( + hosts.map((h) => h.last().title), + ['First camera', 'Second camera'], + ); + first.gevLabelModel = { title: 'First refreshed' }; + a.trackedReadout.refresh(first); + assert.equal(hosts[0].last().title, 'First refreshed'); + assert.equal(hosts[1].last().title, 'Second camera'); + a.context.clearSelectedEntityContextForLayer('local-adsb'); + assert.equal(hosts[0].last().title, 'First refreshed'); + const beforeBackgroundClear = notifications.length; + a.context.clearSelectedEntityContextForLayer('alpr-cameras'); + assert.equal(notifications.length, beforeBackgroundClear); + assert.equal(hosts[0].last(), null); + assert.equal(hosts[1].last().title, 'Second camera'); + assert.equal(getSelectedEntityContext(), secondRecord); + assert.deepEqual(clears.at(-1), { + layerId: 'alpr-cameras', + reason: 'deliberate', + }); + select(a.context, first, 'local-adsb'); + const beforeBackgroundEviction = notifications.length; + b.context.removeEntityContextsForLayer('alpr-cameras'); + assert.equal(notifications.length, beforeBackgroundEviction); + assert.equal(hosts[1].last(), null); + assert.deepEqual(clears.at(-1), { + layerId: 'alpr-cameras', + reason: 'evicted', + }); + context.clearSelectedEntityContextForLayer('local-adsb'); + assert.deepEqual(notifications.at(-1).detail, { + layerId: 'local-adsb', + reason: 'deliberate', + }); + select(b.context, second); + a.context.publishAwareness('gev:awareness-subject-selected', { + layerId: 'flights', + id: 'plane', + }); + assert.equal(hosts[0].last(), null); + assert.equal(hosts[1].last().title, 'Second camera'); + assert.deepEqual(notifications.at(-1).detail, { + layerId: 'flights', + id: 'plane', + }); + select(a.context, first, 'local-adsb'); + select(a.context, subject('Other layer'), 'local-datacenters'); + assert.equal( + hosts[0].last(), + null, + 'another layer selection clears only its scene readout', + ); + assert.equal(hosts[1].last().title, 'Second camera'); + page.dispatchEvent( + new CustomEvent('gev:entity-selection-cleared', { + detail: { layerId: 'alpr-cameras' }, + }), + ); + assert.equal( + hosts[1].last().title, + 'Second camera', + 'unscoped page notifications cannot mutate scene readouts', + ); + }); + + test(`${makeHarness.name}: a handoff carries only the page selection and respects a clear between scenes`, (t) => { + const { + harnesses, + services: [a, b], + page, + } = setup(t); + const notifications = []; + for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared']) + page.addEventListener(type, (event) => notifications.push(event.detail)); + const entity = subject('Retained camera'); + const record = select(a.context, entity); + const selectedAt = page.__gevContextStore.selectedAt; + const retained = a.destroy({ retainSelection: true }); + assert.equal(retained, record); + assert.equal(getSelectedEntityContext(), record); + assert.equal(page.__gevContextStore.selectedAt, selectedAt); + const host = recorder(); + const next = createSceneServices(harnesses[0].engine, { + trackedOverlay: host, + retainedSelection: retained, + }); + next.trackedReadout.init(); + assert.equal(next.context.getSelectedEntityContext(), record); + assert.equal(host.last().title, 'Retained camera'); + assert.equal(b.context.getSelectedEntityContext(), null); + assert.equal(notifications.length, 1, 'handoff is not another selection'); + const cleared = next.destroy({ retainSelection: true }); + context.clearSelectedEntityContextForLayer('alpr-cameras'); + const replacement = createSceneServices(harnesses[0].engine, { + retainedSelection: cleared, + }); + assert.equal(replacement.context.getSelectedEntityContext(), null); + assert.equal(getSelectedEntityContext(), null); + assert.equal(notifications.at(-1).reason, 'deliberate'); + }); + + test(`${makeHarness.name}: application scene release and creation carry the session selection`, async (t) => { + const original = globalThis.window; + globalThis.window = new EventTarget(); + const first = makeHarness(); + const second = makeHarness(); + const session = createLayerSession(); + session.chrome.sceneChrome = true; + session.operations = { + surface: { + groundFloor: { + clearMeshFloorCells() {}, + setMeshFloorPreferred() {}, + }, + }, + }; + session.handoffLayers = new Map(); + const cleanups = []; + const open = (engine) => + createApplicationScene({ + layerSession: session, + defer: (cleanup) => cleanups.push(cleanup), + rendererCache: { + acquire: async () => ({ + engine, + mapStackController: { getActiveId: () => 'osm' }, + }), + }, + }); + t.after(() => { + while (cleanups.length) cleanups.pop()(); + first.destroy(); + second.destroy(); + globalThis.window = original; + }); + const outgoing = await open(first.engine); + const record = select(outgoing.services.context, subject()); + cleanups.pop()(); + assert.equal(session.selectionContext, record); + assert.equal(getSelectedEntityContext(), record); + const incoming = await open(second.engine); + assert.equal(session.selectionContext, null); + assert.equal(incoming.services.context.getSelectedEntityContext(), record); + assert.equal(getSelectedEntityContext(), record); + session.lifetime.abort(); + cleanups.pop()(); + assert.equal( + getSelectedEntityContext(), + null, + 'page teardown does not retain selection', + ); + }); + + test(`${makeHarness.name}: terminal teardown rejects stale notifications and parked reattachment gets fresh state`, (t) => { + const { + harnesses, + services: [a, b], + hosts, + page, + } = setup(t); + const first = subject('Old camera'); + const second = subject('Sibling camera'); + select(a.context, first); + const siblingRecord = select(b.context, second); + let pageEvents = 0; + page.addEventListener('gev:entity-selected', () => pageEvents++); + a.destroy(); + a.destroy(); + const calls = hosts[0].calls.length; + a.trackedReadout.init(); + a.trackedReadout.refresh(first); + select(a.context, first); + a.context.events.dispatchEvent( + new CustomEvent('gev:entity-selected', { + detail: { entity: first, layerId: 'alpr-cameras' }, + }), + ); + a.context.publishAwareness('gev:awareness-subject-selected', { + layerId: 'flights', + }); + assert.equal(hosts[0].calls.length, calls); + assert.equal(pageEvents, 0); + assert.equal(getSelectedEntityContext(), siblingRecord); + assert.equal(b.trackedReadout.activeId(), 'shared-id'); + const nextHost = recorder(); + const next = createSceneServices(harnesses[0].engine, { + trackedOverlay: nextHost, + }); + next.trackedReadout.init(); + assert.notEqual(next.trackedReadout, a.trackedReadout); + assert.equal(next.trackedReadout.activeId(), null); + select(next.context, subject('New camera')); + a.context.clearSelectedEntityContextForLayer('alpr-cameras'); + a.context.removeEntityContextsForLayer('alpr-cameras'); + a.destroy(); + assert.equal(nextHost.last().title, 'New camera'); + assert.equal(hosts[1].last().title, 'Sibling camera'); + b.destroy(); + assert.equal(nextHost.last().title, 'New camera'); + }); +} + +for (const makeHarness of GROUND_HARNESSES) { + test(`${makeHarness.name}: Local ADS-B publishes and refreshes through its attached readout`, async (t) => { + const original = globalThis.window; + globalThis.window = new EventTarget(); + const first = makeHarness(); + const second = makeHarness(); + const a = createSceneServices(first.engine); + const b = createSceneServices(second.engine); + a.trackedReadout.init(); + b.trackedReadout.init(); + const hosts = [recorder(), recorder()]; + const scenes = [a, b]; + // Observe publications while keeping the production overlay as the destination. + scenes.forEach((scene, i) => { + const setEntries = scene.overlay.setEntries; + scene.overlay.setEntries = (...args) => { + hosts[i].setEntries(...args); + return setEntries(...args); + }; + }); + const layout = aircraftAtlasLayout(); + let changed; + const aircraft = { + icao: 'abc123', + callsign: 'LOCAL1', + lat: 0, + lon: 0, + altitudeFt: 10000, + groundSpeedKt: 180, + trackDeg: 0, + verticalRateFpm: 0, + lastPositionAt: 100000, + lastMessageAt: 100000, + messageCount: 12, + band: '1090', + source: 'webusb', + }; + const snapshot = () => ({ + connected: true, + mode: 'adsb', + status: 'ready', + aircraft: [aircraft], + }); + const layer = createLocalAdsbLayer({ + receiver: { + getState: snapshot, + subscribe(fn) { + changed = fn; + return () => {}; + }, + async setMode() {}, + }, + now: () => 100000, + services: { + context, + picking, + selection, + render: { governorRequestRender() {} }, + overlays: { refreshReadout: refreshTrackedReadout }, + renderer: { + create: ({ engine }) => + createLocalAdsbDisplay(engine, { + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + }), + }, + }, + }); + t.after(() => { + layer.destroy(); + a.destroy(); + b.destroy(); + first.destroy(); + second.destroy(); + globalThis.window = original; + }); + layer.init(null, { engine: first.engine }); + await layer.enable(); + assert.equal(layer.selectById('abc123'), true); + assert.equal(a.trackedReadout.activeId(), 'local-adsb:abc123'); + assert.equal(b.trackedReadout.activeId(), null); + assert.equal(a.overlay.getDiagnostics().entryCount, 1); + aircraft.callsign = 'LOCAL2'; + changed(snapshot()); + await layer.update(); + assert.equal(hosts[0].last().title, 'LOCAL2'); + layer.detachRenderer(); + assert.equal(a.trackedReadout.activeId(), null); + await layer.attachRenderer(null, { engine: second.engine }); + assert.equal(layer.selectById('abc123'), true); + assert.equal(b.trackedReadout.activeId(), 'local-adsb:abc123'); + assert.equal(a.trackedReadout.activeId(), null); + aircraft.callsign = 'LOCAL3'; + changed(snapshot()); + await layer.update(); + assert.equal(hosts[1].last().title, 'LOCAL3'); + a.destroy(); + assert.equal(b.overlay.getDiagnostics().entryCount, 1); + layer.disable(); + assert.equal(b.trackedReadout.activeId(), null); + }); + + test(`${makeHarness.name}: a selected fire survives scene handoff before viewport restoration`, async (t) => { + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = new EventTarget(); + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'fire-glow', + }), + }; + const first = makeHarness(); + const second = makeHarness(); + const session = createLayerSession(); + session.chrome.sceneChrome = true; + session.operations = { + surface: { + groundFloor: { + clearMeshFloorCells() {}, + setMeshFloorPreferred() {}, + }, + }, + }; + session.handoffLayers = new Map(); + const cleanups = []; + const open = (engine) => + createApplicationScene({ + layerSession: session, + defer: (cleanup) => cleanups.push(cleanup), + rendererCache: { + acquire: async () => ({ + engine, + mapStackController: { getActiveId: () => 'osm' }, + }), + }, + }); + const fire = { + index: 0, + lat: 37.77, + lon: -122.42, + acqMs: 1792137600000, + satellite: 'N20', + sensor: 'VIIRS', + frp: 999, + confidence: 0.9, + }; + const id = 'firms:37.7700:-122.4200:1792137600000:N20'; + let snapshot = null; + const notifications = []; + for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared']) + window.addEventListener(type, (event) => notifications.push(event.type)); + const layer = createFirmsHeatmapLayer({ + id: 'local-firms', + name: 'Fires', + feed: { + getSnapshot: async () => { + assert.ok(snapshot, 'a retained fire needs no refetch'); + return snapshot; + }, + }, + services: { + context, + picking, + render: { governorRequestRender() {} }, + focus: { requestWorldFocus() {} }, + anchors: { + fireAnchorHeight: () => 0, + warmFireAnchorFloors: async () => false, + }, + }, + overlayHost: recorder(), + renderer: { + create: ({ engine, id }) => + createFirmsDisplay(engine, { + id, + loadImage: async () => ({ width: 16, height: 16 }), + createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }), + }), + }, + }); + t.after(() => { + layer.destroy(); + session.lifetime.abort(); + while (cleanups.length) cleanups.pop()(); + first.destroy(); + second.destroy(); + globalThis.window = originalWindow; + globalThis.document = originalDocument; + }); + const outgoing = await open(first.engine); + outgoing.services.trackedReadout.init(); + layer._bindInteractionForTest(first.engine, [fire]); + first.click({ id: 'firms-0' }); + assert.equal(window.__gevContextStore.selectedEntityId, id); + const selectedAt = window.__gevContextStore.selectedAt; + layer.detachRenderer(); + cleanups.pop()(); + assert.equal(session.selectionContext.id, id); + const incoming = await open(second.engine); + layer.attachRenderer(null, { + engine: second.engine, + waitForCameraRestore: true, + }); + // The shell initializes readouts before restoring the camera and resuming layers. + incoming.services.trackedReadout.init(); + assert.equal(window.__gevContextStore.selectedEntityId, id); + assert.equal(window.__gevContextStore.selectedAt, selectedAt); + second.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(fire.lon, fire.lat, 100_000), + }); + layer.resumeViewport(); + assert.equal(layer.getSelectedInfo().id, id); + assert.equal(incoming.services.context.getSelectedEntityContext().id, id); + assert.equal(getSelectedEntityContext().id, id); + assert.equal(window.__gevContextStore.selectedAt, selectedAt); + // Refreshing top-N context records must also keep a selected weaker fire. + const row = { + ...fire, + acqDate: '2026-10-16', + acqTime: '0800', + instrument: 'VIIRS', + confidence: 'h', + }; + snapshot = { + fires: [ + row, + ...Array.from({ length: CONTEXT_TOP_N }, (_, index) => ({ + ...row, + lat: fire.lat + (index + 1) * 0.001, + frp: fire.frp + index + 1, + })), + ], + }; + await layer.update(); + assert.equal(layer.getStats().error, null); + assert.equal(window.__gevContextStore.selectedEntityId, id); + assert.equal(getSelectedEntityContext().id, id); + assert.equal(window.__gevContextStore.selectedAt, selectedAt); + assert.deepEqual(notifications, ['gev:entity-selected']); + layer.disable(); + assert.equal(window.__gevContextStore.selectedEntityId, null); + }); + + test(`${makeHarness.name}: a selected ALPR camera survives a switch and refreshes the incoming card`, (t) => { + const original = globalThis.window; + globalThis.window = new EventTarget(); + const first = makeHarness(); + const second = makeHarness(); + const hosts = [recorder(), recorder()]; + const a = createSceneServices(first.engine, { trackedOverlay: hosts[0] }); + a.trackedReadout.init(); + const record = { + id: 'alpr:9000079', + latitude: 30, + longitude: -97, + osmId: 9000079, + operator: 'Retained operator', + }; + const state = { + enabled: true, + selectedId: null, + lastAnchorSampleAt: 0, + renderRevision: 0, + records: [record], + recordById: new Map([[record.id, record]]), + }; + function displayFor(engine) { + engine.surface.terrainHeight = () => 150; + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97, 30, 2000), + }); + const display = createAlprDisplay(engine, { + state, + source: { label: 'Fixture' }, + services: { + context, + render: { governorRequestRender() {} }, + groundFloor: { cachedGroundFloor: () => 150 }, + overlays: { refreshReadout: refreshTrackedReadout }, + }, + }); + display.init(null, { onMove() {} }); + display.setVisible(true); + display.renderRecords(); + return display; + } + const outgoing = displayFor(first.engine); + let incoming, b; + t.after(() => { + outgoing.destroy(); + incoming?.destroy(); + a.destroy(); + b?.destroy(); + first.destroy(); + second.destroy(); + globalThis.window = original; + }); + outgoing.selectRecord(record.id); + const oldEntity = getSelectedEntityContext().entity; + outgoing.destroy({ retainContext: true }); + const retainedSelection = a.destroy({ retainSelection: true }); + assert.equal(window.__gevContextStore.selectedEntityId, record.id); + b = createSceneServices(second.engine, { + retainedSelection, + trackedOverlay: hosts[1], + }); + incoming = displayFor(second.engine); + // The shell initializes its readout after the layer's scene attachment. + b.trackedReadout.init(); + assert.equal(state.selectedId, record.id); + assert.equal(window.__gevContextStore.selectedEntityId, record.id); + const selected = b.context.getSelectedEntityContext(); + assert.equal(selected.id, record.id); + assert.equal(getSelectedEntityContext(), selected); + assert.notEqual(selected.entity, oldEntity); + assert.equal(hosts[1].last().id, record.id); + assert.equal(a.trackedReadout.activeId(), null); + const moved = { x: 6378000, y: 0, z: 100 }; + selected.entity.displayPosition = moved; + assert.equal(hosts[1].last().position(), moved); + context.clearSelectedEntityContextForLayer('alpr-cameras'); + assert.equal(b.context.getSelectedEntityContext(), null); + assert.equal(hosts[1].last(), null); + assert.equal(window.__gevContextStore.selectedEntityId, null); + }); + + test(`${makeHarness.name}: ALPR selection and refresh keep their attachment after replacement`, (t) => { + const original = globalThis.window; + globalThis.window = new EventTarget(); + const h = makeHarness(); + h.engine.surface.terrainHeight = () => 150; + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97, 30, 2000), + }); + const host = recorder(); + const a = createSceneServices(h.engine, { trackedOverlay: host }); + a.trackedReadout.init(); + const records = [ + { + id: 'alpr:0', + latitude: 30, + longitude: -97, + osmId: 0, + operator: 'First operator', + }, + ]; + const state = { + enabled: true, + selectedId: null, + lastAnchorSampleAt: 0, + renderRevision: 0, + records, + recordById: new Map(records.map((r) => [r.id, r])), + }; + const display = createAlprDisplay(h.engine, { + state, + source: { label: 'Fixture' }, + services: { + context, + render: { governorRequestRender() {} }, + groundFloor: { cachedGroundFloor: () => 150 }, + overlays: { refreshReadout: refreshTrackedReadout }, + }, + }); + let next; + t.after(() => { + display.destroy(); + a.destroy(); + next?.destroy(); + h.destroy(); + globalThis.window = original; + }); + display.init(null, { onMove() {} }); + display.setVisible(true); + display.renderRecords(); + display.selectRecord('alpr:0'); + assert.equal(a.trackedReadout.activeId(), 'alpr:0'); + const before = host.calls.length; + records[0] = { ...records[0], operator: 'Changed operator' }; + state.recordById.set(records[0].id, records[0]); + display.renderRecords(); + assert.ok( + host.calls.length > before, + 'the active card receives its refreshed model', + ); + assert.ok( + host.last().details.some((line) => line.includes('CHANGED OPERATOR')), + ); + next = createSceneServices(h.engine); + next.trackedReadout.init(); + select(next.context, subject('Replacement')); + display.clearSelection(); + display.selectRecord('alpr:0'); + assert.equal( + next.trackedReadout.activeId(), + 'shared-id', + 'the old display cannot publish into the replacement context', + ); + }); +} diff --git a/src/globe/cesium/annotations/screenAnnotationRenderer.js b/src/globe/cesium/annotations/screenAnnotationRenderer.js index 8ad5943b6..80fe3caef 100644 --- a/src/globe/cesium/annotations/screenAnnotationRenderer.js +++ b/src/globe/cesium/annotations/screenAnnotationRenderer.js @@ -73,7 +73,7 @@ export function createScreenAnnotationRenderer( { engine, overlayPaintRect = (...args) => getOverlayPaintRect(engine, ...args), - activeTrackedReadoutId = getActiveTrackedReadoutId, + activeTrackedReadoutId = () => getActiveTrackedReadoutId(engine), store = useScreenAnnotationsStore(applicationPinia()), } = {}, ) { diff --git a/src/layers/aircraft/tracking.js b/src/layers/aircraft/tracking.js index 3cca5f8cd..550fe5c1a 100644 --- a/src/layers/aircraft/tracking.js +++ b/src/layers/aircraft/tracking.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import * as geo from '../../geo/index.js'; import { nearFarScale } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; @@ -81,7 +82,7 @@ export function createAircraftTracking({ selectTrackedSubjectContext, clearTrackedSubjectContext, refreshTrackedSubjectContext, - } = services.context; + } = bindContextStore(services.context, () => flightState._renderer?.engine); const { isTr3b, tr3bConvertedIds } = services.aircraftPresentation; const { ensureGeoidReady, geoidHeight } = services.geoid; const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = @@ -91,6 +92,12 @@ export function createAircraftTracking({ const { selectionDispatcherFor } = services.selection; function _emitAwarenessEvent(type, detail) { + if (services.context.forEngine && flightState._renderer?.engine) { + services.context + .forEngine(flightState._renderer?.engine) + ?.publishAwareness(type, detail); + return; + } if ( typeof window === 'undefined' || !window.dispatchEvent || @@ -251,7 +258,10 @@ export function createAircraftTracking({ trackedLabelText(icao24), trackedStyle.labelColor, ); - refreshTrackedReadout(flightState._trackedEntity); + refreshTrackedReadout( + flightState._trackedEntity, + flightState._renderer?.engine, + ); // The readout and the context slot describe the same contact — refresh them // together so voice never narrates a fix the card has already replaced. refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); @@ -998,7 +1008,10 @@ export function createAircraftTracking({ // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. flightState._trackedEntity.gevVisualPosition = parts.motion._trackedVisualCached; - refreshTrackedReadout(flightState._trackedEntity); + refreshTrackedReadout( + flightState._trackedEntity, + flightState._renderer?.engine, + ); // Camera follows the tracked entity (the renderer cancels any flight // first and applies the one exact tracked frame). // A resumed binding or a share restore re-follows the session's own diff --git a/src/layers/alpr/render/display.js b/src/layers/alpr/render/display.js index 8784d9a72..f49fffb21 100644 --- a/src/layers/alpr/render/display.js +++ b/src/layers/alpr/render/display.js @@ -67,7 +67,7 @@ export function createAlprDisplay(engine, { state, services, source }) { registerEntityContext, removeEntityContextsForLayer, selectEntityContext, - } = services.context; + } = services.context.forEngine?.(engine) ?? services.context; const markers = new Map(); const floors = createAlprFloorResolver(services.groundFloor); let normal = null, @@ -525,7 +525,7 @@ export function createAlprDisplay(engine, { state, services, source }) { if (!chosen) state.selectedId = null; updateSelectedAnchor(); overlay.sync(shown); - if (chosen) services.overlays?.refreshReadout?.(chosen); + if (chosen) services.overlays?.refreshReadout?.(chosen, engine); // Keep the on-map credit tied to displayed data, not loading or zoom prompts. if (shown.length) presentOnMapCredit(); else hideOnMapCredit(); diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index eecc57e3a..ee4032025 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; import { isPointerFree } from '../../data/inputOwnership.js'; @@ -75,6 +76,7 @@ export function createCyclonesLayer({ if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Cyclones require a snapshot source'); let engine = null; + context = bindContextStore(context, () => engine); hitTestOverlay ??= (...args) => hitTestWorldOverlay(engine, ...args); let rendering = null, snapshot = null, diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index cb9dbd72f..6aab8c303 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; import { fireDetectionKey } from '../../data/firmsLabels.js'; import { filterFires } from '../../data/firmsIntensity.js'; @@ -10,7 +11,10 @@ export function createIngestion({ config, feed, }) { - const { clearSelectedEntityContextForLayer } = services.context; + const { clearSelectedEntityContextForLayer } = bindContextStore( + services.context, + () => layerState._renderer?.engine, + ); const { id } = config; /** Replace a validated snapshot while preserving source freshness and selection identity. */ @@ -65,6 +69,7 @@ export function createIngestion({ // last-known values for this instead of tearing down. clearSelectedEntityContextForLayer(id, { evicted: true }); } + layerState._selectedFire = reselected; components.rendering.renderCurrentLod(true); if (reselected) components.selection.selectFire(reselected, false); layerState._rowControlsListener?.(); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 9396a501f..2ecf24c28 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; export function createLifecycle({ @@ -8,7 +9,10 @@ export function createLifecycle({ feed, }) { const { registerPickOwner, unregisterPickOwner } = services.picking; - const { clearSelectedEntityContextForLayer } = services.context; + const { clearSelectedEntityContextForLayer } = bindContextStore( + services.context, + () => layerState._renderer?.engine, + ); const { id, overlayHost } = config; // The heatmap is draped on the renderer's globe; it goes with the renderer @@ -135,12 +139,12 @@ export function createLifecycle({ components.selection.removeClickHandler(); unregisterPickOwner(id, layerState._renderer?.engine); components.rendering.removeDetectionCollections(); + components.selection.clearContextRegistrations(); + clearSelectedEntityContextForLayer(id); layerState._renderer?.destroy(); layerState._renderer = null; layerState._viewer = null; layerState._dataSource = null; - components.selection.clearContextRegistrations(); - clearSelectedEntityContextForLayer(id); layerState._allFires = []; layerState._allFiresByFrp = []; layerState._fires = []; diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 85c543292..32d26ca9c 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import { FIRMS_OVERLAY_SOURCE_ID, fireDetectionKey, @@ -19,8 +20,9 @@ export function createSelection({ selectEntityContext, clearSelectedEntityContextForLayer, getContextStore, + removeEntityContext = (id) => getContextStore()?.entities.delete(id), registerEntityContext, - } = services.context; + } = bindContextStore(services.context, () => layerState._renderer?.engine); const { id, name, overlayHost } = config; /** @@ -143,9 +145,8 @@ export function createSelection({ */ function refreshContextRegistrations(fires) { - let store = null; try { - store = getContextStore(); + if (!getContextStore()) return; } catch { return; } @@ -161,7 +162,7 @@ export function createSelection({ } for (const staleId of layerState._contextIds) { - if (!nextIds.has(staleId)) store.entities.delete(staleId); + if (!nextIds.has(staleId)) removeEntityContext(staleId); } layerState._contextIds = nextIds; } @@ -211,9 +212,8 @@ export function createSelection({ function clearContextRegistrations() { if (!layerState._contextIds.size) return; try { - const store = getContextStore(); for (const recordId of layerState._contextIds) - store.entities.delete(recordId); + removeEntityContext(recordId); } catch { // store unavailable — nothing to clean } diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js index cd31b7aaa..67bdd281d 100644 --- a/src/layers/infrastructure/render/display.js +++ b/src/layers/infrastructure/render/display.js @@ -97,6 +97,10 @@ export function createInfrastructureDisplay( engine, { config, services = {} } = {}, ) { + services = { + ...services, + ...(services.context?.forEngine?.(engine) ?? services.context), + }; if (!engine?.movers || !engine?.lines || !engine?.ground) throw new TypeError('The infrastructure display needs a globe engine'); const { diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js index 11dda60e9..de2723542 100644 --- a/src/layers/installations/render/display.js +++ b/src/layers/installations/render/display.js @@ -160,7 +160,7 @@ export function createInstallationsDisplay( registerEntityContext, removeEntityContextsForLayer, selectEntityContext, - } = services.context; + } = services.context.forEngine?.(engine) ?? services.context; const namedMarkers = createNamedMarkers(engine, { state, services, diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 3631a539d..2c595c45b 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import * as geo from '../../geo/index.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; @@ -108,7 +109,7 @@ export function createLocalAdsbLayer({ clearSelectedEntityContextForLayer, getSelectedEntityContext, removeEntityContextsForLayer, - } = services.context; + } = bindContextStore(services.context, () => renderer?.engine); const { registerPickOwner, unregisterPickOwner } = services.picking; /** The engine's Local ADS-B display for this lifecycle (render/display.js). */ @@ -434,7 +435,7 @@ export function createLocalAdsbLayer({ const marker = selectedId ? markers.get(selectedId) : null; if (!marker?.entity) return; marker.entity.gevLabelModel = cardModel(marker, now()); - services.overlays?.refreshReadout?.(marker.entity); + services.overlays?.refreshReadout?.(marker.entity, renderer?.engine); } function updateRenderHold() { @@ -484,7 +485,8 @@ export function createLocalAdsbLayer({ if (getSelectedEntityContext()?.id !== selectedId) { selectedId = null; releaseTrail(); - } else services.overlays?.refreshReadout?.(selected.entity); + } else + services.overlays?.refreshReadout?.(selected.entity, renderer?.engine); } requestEnrichment(); updateRenderHold(); @@ -672,13 +674,13 @@ export function createLocalAdsbLayer({ renderHeld = false; renderer.detach(); renderer.destroy(); + removeEntityContextsForLayer(LAYER_ID); renderer = null; for (const marker of markers.values()) { marker.entity = null; marker.iconKind = marker.iconScale = null; marker.modelOwnsVisual = false; } - removeEntityContextsForLayer(LAYER_ID); updateRenderHold(); }, diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index e43955d2e..c541b79d1 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import * as geo from '../../geo/index.js'; import { constantRgba } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; @@ -28,7 +29,7 @@ export function createTracking({ getContextStore, refreshTrackedSubjectContext, selectTrackedSubjectContext, - } = services.context; + } = bindContextStore(services.context, () => getRenderer()?.engine); const { refreshTrackedReadout } = services.readout; function _normalizeTrackedNorad(candidate) { @@ -39,6 +40,12 @@ export function createTracking({ } function _emitAwarenessEvent(type, detail) { + if (services.context.forEngine && getRenderer()?.engine) { + services.context + .forEngine(getRenderer()?.engine) + ?.publishAwareness(type, detail); + return; + } if ( typeof window === 'undefined' || !window.dispatchEvent || @@ -407,7 +414,7 @@ export function createTracking({ details, accent: '#ffd84d', }; - refreshTrackedReadout(layerState._trackedEntity); + refreshTrackedReadout(layerState._trackedEntity, getRenderer()?.engine); } function _trackSatellite( diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index a85975d98..c4b96b750 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import { isPointerFree } from '../../data/inputOwnership.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; @@ -15,7 +16,10 @@ export function createSelection({ selectEntityContext, registerEntityContext, clearSelectedEntityContextForLayer, - } = services.context; + } = bindContextStore( + services.context, + () => vesselState.state.renderer?.engine, + ); const aisLiveVesselsLayer = layer; /** diff --git a/src/layers/wildlifeSightings/index.js b/src/layers/wildlifeSightings/index.js index ff03e5454..9e2b46209 100644 --- a/src/layers/wildlifeSightings/index.js +++ b/src/layers/wildlifeSightings/index.js @@ -1,3 +1,4 @@ +import { bindContextStore } from '../../data/bindContextStore.js'; import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createPhotoSlots } from './photos.js'; import { createRendering } from './rendering.js'; @@ -74,6 +75,7 @@ export function createWildlifeSightingsLayer({ retryAt: 0, failures: 0, }; + context = bindContextStore(context, () => state.engine); overlayHost = bindOverlayHost(overlayHost, () => state.engine); const parts = {}; const services = { diff --git a/src/layers/wildlifeSightings/selection.js b/src/layers/wildlifeSightings/selection.js index 865c6dc89..5aad305a8 100644 --- a/src/layers/wildlifeSightings/selection.js +++ b/src/layers/wildlifeSightings/selection.js @@ -372,8 +372,11 @@ export function createSelection({ state, services, parts }) { const store = services.context.getContextStore?.(); for (const id of state.contextIds) { if (!next.has(id)) { - if (store?.entities?.get(id)?.layerId === 'wildlife-sightings') - store.entities.delete(id); + if (store?.entities?.get(id)?.layerId === 'wildlife-sightings') { + if (services.context.removeEntityContext) + services.context.removeEntityContext(id); + else store.entities.delete(id); + } state.carriers.delete(id); } } diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index 92cef948d..472cd479e 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -424,7 +424,7 @@ export class ShellScene extends ShellFacade { this._visualSettings.releaseIrBoost(); this._cockpitCoordinator.destroy(); this._layerBindings.disconnect(); - destroyTrackedReadout(); + destroyTrackedReadout(this._scene.engine); destroyDetection(); destroyWorldOverlay(this._scene.engine); this._overlayActionsUnsubscribe?.();