From 3814221cbc072c55bc48c97fe9a77fe49caaa2be Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 19:27:31 +0400 Subject: [PATCH] Scope world overlays to each scene attachment Move the world overlay into scene.services.overlay. Each attachment owns its sources, collision domains, paint lanes, canvases, observers, timers and accessible action subscribers. Keep both canvases inside the engine container, with detection beside the card root so its screen blend still reaches the scene. Release shared font measurements only after the last host detaches. Resolve retained layer publications against their attached engine, clear the outgoing source before releasing its renderer, and reconnect the shell's action subscription on attachment. Keep destroyed hosts inert and give parked engines fresh services. Bind annotation rectangles and the existing tracked bridge to their engine's overlay. The tracked bridge itself remains page-owned. Preserve Cesium's projection, card placement, arbitration, fades, hit tests, blend order and render demand. Navara keeps the same shared behaviour. No user-visible change is intended. Exercise simultaneous hosts, either teardown, stale handles, reattachment and a retained selected card on the fake and headless Cesium engines. Update QA reads and package ownership for the service. No coupling metric falls or grows: renderer lines remain 0 Cesium and 189 Navara; external Cesium import files 3; viewer lines 891; Navara kind checks 1; engine sniff calls 0; extension reads 3; ground passes 1; SDK loads 3; ScreenSpaceEventHandler files 4; engine-name lines 32. Each host now takes the page's overlay ids from the host it replaces and gives them back to a remaining host when destroyed, and the chrome's static overlay placeholders are gone. The new hosts had left the ids on those empty placeholders, so the voice pointer policy, the id selectors and the QA probes that read #world-overlay-canvas missed the drawn canvas. Checked in the browser, keyless: the overlay baselines on both engines, CCTV v2 (58 checks), military names, and the engine, voice, ground-layer and Ghost Maps switches pass. The transit script fails the same 25 checks as before. --- docs/CURRENT-STATE.md | 10 +- docs/GLOBE-PRIMITIVES.md | 25 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 73 +- scripts/qa-cctv-v2.mjs | 2 +- scripts/qa-installation-polish.mjs | 4 +- scripts/qa-military-names.mjs | 8 +- scripts/qa-transit-browser.mjs | 4 +- scripts/qa-transit.mjs | 4 +- src/annotations/hybridAnnotationRenderer.js | 8 +- src/app/layers/alprCameras.js | 3 +- src/app/layers/droneAirspace.js | 2 + src/app/layers/firms.js | 2 + src/app/layers/ghostMaps.js | 2 + src/app/layers/overlayHost.js | 2 + src/app/layers/perimeters.js | 2 + src/app/layers/wildlifeSightings.js | 2 + src/app/localGeojsonServices.js | 2 + src/data/bhoteKoshiEvent.js | 4 + src/data/bhoteKoshiLocator.js | 4 + src/data/detection.js | 32 +- src/data/detectionHost.test.mjs | 52 +- src/data/infrastructure.test.mjs | 1 + src/data/sceneServices.js | 4 + src/data/sceneServices.test.mjs | 59 + src/data/telegeographySubmarineCables.js | 4 +- src/data/trackedReadout.js | 6 +- .../annotations/screenAnnotationRenderer.js | 3 +- src/layers/airQuality/index.js | 5 + src/layers/alpr/render/display.js | 9 +- src/layers/bikeshare/state.js | 3 +- src/layers/cctv/index.js | 7 +- src/layers/cyclones/index.js | 3 +- src/layers/cyclones/labels.js | 17 +- src/layers/cyclones/render/display.js | 4 +- src/layers/directions/index.js | 5 + src/layers/droneAirspace/index.js | 2 + src/layers/earthquakes/index.js | 5 + src/layers/firms/index.js | 8 + src/layers/ghostMaps/index.js | 2 + src/layers/infrastructure/render/display.js | 3 +- src/layers/installations/render/display.js | 3 +- src/layers/launches/state.js | 3 +- src/layers/osmPlaces/index.js | 5 + src/layers/perimeters/index.js | 2 + src/layers/radio/index.js | 7 +- src/layers/radio/lifecycle.js | 4 +- src/layers/satellites/state.js | 3 +- src/layers/submarineCables/render/display.js | 5 +- src/layers/transit/index.js | 7 +- src/layers/transit/lifecycle.js | 2 +- src/layers/vessels/state.js | 6 +- src/layers/wildlifeSightings/index.js | 2 + src/overlays/bindOverlayHost.js | 12 + src/overlays/worldOverlay.js | 4601 +++++++++-------- src/overlays/worldOverlay.test.mjs | 666 ++- .../worldOverlayAllocation.worker.mjs | 30 +- src/overlays/worldOverlayDraw.js | 13 + src/ui/applicationShell.js | 10 - src/ui/shellScene.js | 15 +- src/ui/styles/foundation.css | 4 + src/ui/views/SceneChromeView.vue | 3 - 62 files changed, 3148 insertions(+), 2655 deletions(-) create mode 100644 src/overlays/bindOverlayHost.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c76d0428..ad35bd366 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -283,7 +283,15 @@ one application, and only the globe-bound parts move to the next engine. Cesium implements its render mode directly. Retained layers identify their attached engine, and scene callbacks capture their governor. Teardown clears only that scene's holds; an old service cannot release a replacement's hold. - The overlay host and tracked readout still have module-owned state. +- **Scene world overlays.** `scene.services.overlay` owns each attachment's + label sources, paint lanes, collision domains, canvases, observers, timers + and accessible actions. Both canvases live inside the engine container; + detection remains outside the card root to preserve its blend. Retained + 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. - **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 ba7e79fe9..8e68773d9 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -565,6 +565,31 @@ attachment-bound callbacks can capture `renderGovernorFor(engine)` directly. Destroying the scene clears its holds and makes the captured service inert. A parked engine gets a fresh service when reattached. +## World overlays + +`scene.services.overlay` owns one attachment's world labels and cards. It +keeps its sources, collision domains, paint lanes, canvases, frame listeners, +observers, timers and accessible action subscribers. `init()` installs its +DOM inside `engine.host.container`; the detection blend surface stays beside +the card root so it can blend against the scene. + +Publish with `setEntries(sourceId, entries, options)`, `setVisible(sourceId, +visible)` and `clearSource(sourceId)`. Read painted rectangles with +`getPaintRect(sourceId, entryId)` and register custom painters with +`registerPaintLane(laneId, painter, options)`. `subscribeActions(listener)` +provides the accessible mirror; `activateAction(owner, key)` rejects an old +host's actions. The shell reconnects these operations on every attachment. + +`worldOverlayFor(engine)` resolves the same service for standalone consumers. +The module helper functions take the engine first. Retained layer models use +`bindOverlayHost` with their attached-engine getter; injected hosts without a +`forEngine` resolver keep their own operations. Publications can buffer before +`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. + ## Render effects The optional `visualStyles`, `bloom` and `contactSonar` capabilities are diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7ce1f9ecb..5a391f5f6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1705,5 +1705,6 @@ "src/app/layers/displayRehearsal.test.mjs", "src/globe/cesium/annotations/worldAnnotationRenderer.js", "src/globe/cesium/annotations/screenAnnotationRenderer.js", - "src/globe/navara/windFlow.js" + "src/globe/navara/windFlow.js", + "src/overlays/bindOverlayHost.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index da5967c6c..cd8565e9b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -33,7 +33,8 @@ "src/layers/infrastructure/index.js", "src/layers/infrastructure/render/display.js", "src/layers/shared/color.js", - "src/sources/requestDeadline.js" + "src/sources/requestDeadline.js", + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -1225,7 +1226,18 @@ "src/ui/wildlifeSightingPlacement.js", "src/overlays/worldOverlayChrome.js", "src/layers/wildlifeSightings/photoText.js", - "src/globe/cesium/doubleClick.js" + "src/globe/cesium/doubleClick.js", + "src/celestialRing.js", + "src/celestialRing/ephemeris.js", + "src/celestialRing/view.js", + "src/data/detectionCohort.js", + "src/data/labelArbiter.js", + "src/globe/cesium/celestial.js", + "src/layers/shared/render/engineView.js", + "src/overlays/worldOverlay.js", + "src/overlays/worldOverlayDraw.js", + "src/overlays/worldOverlayTokens.js", + "src/time/clock.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1604,7 +1616,8 @@ "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/cesium/engineFromViewer.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -1617,7 +1630,8 @@ "src/layers/earthquakes/records.js", "src/geo/", "src/layers/shared/color.js", - "src/layers/earthquakes/render/display.js" + "src/layers/earthquakes/render/display.js", + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -1685,7 +1699,8 @@ "src/i18n/locales/zh-Hant.json", "src/geo/", "src/layers/firms/rectangle.js", - "src/layers/shared/color.js" + "src/layers/shared/color.js", + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -1727,7 +1742,8 @@ "src/ui/unitLabels.js", "src/geo/", "src/time/clock.js", - "src/layers/shared/color.js" + "src/layers/shared/color.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium", "satellite.js"] }, @@ -1772,7 +1788,8 @@ "src/geo/", "src/time/clock.js", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/cesium/engineFromViewer.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -1981,7 +1998,8 @@ "src/layers/shared/color.js", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/cesium/engineFromViewer.js", + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -2038,7 +2056,8 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", - "src/geo/" + "src/geo/", + "src/overlays/bindOverlayHost.js" ], "external": ["hls.js"] }, @@ -2200,7 +2219,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/" + "src/geo/", + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -2230,7 +2250,8 @@ "src/geo/", "src/sources/requestDeadline.js", "src/layers/shared/color.js", - "src/layers/submarineCables/render/display.js" + "src/layers/submarineCables/render/display.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -3612,7 +3633,8 @@ "src/globe/meshes.js", "src/globe/navara/meshes.js", "src/globe/exclusivePointer.js", - "src/globe/cesium/followOffset.js" + "src/globe/cesium/followOffset.js", + "src/overlays/bindOverlayHost.js" ], "external": [ "hls.js", @@ -4376,7 +4398,8 @@ "src/globe/cesium/meshes.js", "src/globe/meshes.js", "src/globe/exclusivePointer.js", - "src/globe/cesium/followOffset.js" + "src/globe/cesium/followOffset.js", + "src/overlays/bindOverlayHost.js" ], "external": [ "hls.js", @@ -4742,7 +4765,8 @@ "src/ui/unitLabels.js", "src/geo/", "src/layers/directions/render/display.js", - "src/layers/directions/render/style.js" + "src/layers/directions/render/style.js", + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -5145,7 +5169,8 @@ "src/globe/cesium/meshes.js", "src/globe/meshes.js", "src/globe/exclusivePointer.js", - "src/data/selectionDispatcher.js" + "src/data/selectionDispatcher.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -5199,7 +5224,8 @@ "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", "src/geo/", - "src/globe/contract.js" + "src/globe/contract.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium", "pbf"] }, @@ -5265,7 +5291,8 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", - "src/layers/perimeters/render/display.js" + "src/layers/perimeters/render/display.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -5378,7 +5405,8 @@ "src/layers/streetLevel/view.js", "src/data/geoImportRecords.js", "src/data/geoImportKml.js", - "src/data/geoImportDisplay.js" + "src/data/geoImportDisplay.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -5426,7 +5454,8 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", - "src/layers/droneAirspace/render/display.js" + "src/layers/droneAirspace/render/display.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -5631,7 +5660,8 @@ "src/ui/statusText.js", "src/ui/layerNames.js", "src/layers/shared/color.js", - "src/layers/airQuality/render/display.js" + "src/layers/airQuality/render/display.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -5710,7 +5740,8 @@ "src/globe/cesium/credits.js", "src/layers/wildlifeSightings/display.js", "src/data/spriteOrder.js", - "src/layers/shared/color.js" + "src/layers/shared/color.js", + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index a687f6fed..b1e79d96c 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -1486,7 +1486,7 @@ async function main() { const canvas = scene.canvas; const rect = canvas.getBoundingClientRect(); const { hitTestWorldOverlay } = - await import('/src/overlays/worldOverlay.js'); + (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const candidates = [ [0.55, 0.72], [0.45, 0.72], diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs index 03b0e1d6a..d626ce8a8 100644 --- a/scripts/qa-installation-polish.mjs +++ b/scripts/qa-installation-polish.mjs @@ -11,7 +11,7 @@ export async function checkInstallationPan(page, dir) { await sleep(3000); await page.evaluate(async () => { const { getOverlayPaintRect } = - await import('/src/overlays/worldOverlay.js'); + (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const g = window.__godsEyeView; const installations = g.dataManager.layers.get( 'military-installations', @@ -188,7 +188,7 @@ export async function checkInstallationSelection( const { getSelectedEntityContext } = await import('/src/data/contextStore.js'); const { getOverlayPaintRect, getWorldOverlayDiagnostics } = - await import('/src/overlays/worldOverlay.js'); + (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); // The selected site's fill is one engine.ground polygon set // (render/display.js SELECTION_FILL_SET_ID), an item per footprint // part; the layer owns no Cesium entities or ground primitives. diff --git a/scripts/qa-military-names.mjs b/scripts/qa-military-names.mjs index d27cac453..04584ca7d 100644 --- a/scripts/qa-military-names.mjs +++ b/scripts/qa-military-names.mjs @@ -153,7 +153,7 @@ try { const before = await stats(); const overlaps = await page.evaluate(async () => { const { getOverlayPaintRect } = - await import('/src/overlays/worldOverlay.js'); + (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const layer = window.__godsEyeView.dataManager.layers.get( 'military-installations', ).module; @@ -214,7 +214,7 @@ try { ); await sleep(300); result.cleanUi = await page.evaluate(async () => { - const host = await import('/src/overlays/worldOverlay.js'); + const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const root = document.getElementById('world-overlay-root'); return { active: document.body.classList.contains('ui-clean-view'), @@ -244,7 +244,7 @@ try { ), ); result.disabled = await page.evaluate(async () => { - const host = await import('/src/overlays/worldOverlay.js'); + const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); return host.getWorldOverlayDiagnostics().entriesBySource[ 'military-installations' ]; @@ -267,7 +267,7 @@ try { await page.evaluate(async () => { const C = await import('/node_modules/cesium/Build/Cesium/index.js'); const { getOverlayPaintRect } = - await import('/src/overlays/worldOverlay.js'); + (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const g = window.__godsEyeView; const layer = g.dataManager.layers.get('military-installations').module; const overlay = document.createElement('div'); diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index 441f26cdd..249165497 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -137,7 +137,9 @@ export async function sampleTransitPixels(page, palette) { .module._transitStateForTest(); // Decode the actual billboard rasters before the paint being judged. const overlay = window.location - ? await import('/src/overlays/worldOverlay.js') + ? (await import('/src/overlays/worldOverlay.js')).worldOverlayFor( + window.__godsEyeView.engine, + ) : null; const rasters = new Map(); await Promise.all( diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 9d7329eb6..a9e28bac3 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -1618,7 +1618,9 @@ try { await wait(600); anchor = await page.evaluate( async (key, durationMs) => { - const overlay = await import('/src/overlays/worldOverlay.js'); + const overlay = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); diff --git a/src/annotations/hybridAnnotationRenderer.js b/src/annotations/hybridAnnotationRenderer.js index 83c154223..3b3acb981 100644 --- a/src/annotations/hybridAnnotationRenderer.js +++ b/src/annotations/hybridAnnotationRenderer.js @@ -28,10 +28,10 @@ export function createHybridAnnotationRenderer( ) { const world = worldRenderer ?? createWorldAnnotationRenderer(viewer, { engine }); - const screen = createScreenAnnotationRenderer( - screenScene ?? viewer, - screenOptions, - ); + const screen = createScreenAnnotationRenderer(screenScene ?? viewer, { + engine, + ...screenOptions, + }); // anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state. const routed = new Map(); diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index f94a80cc9..ccb9198af 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -30,7 +30,8 @@ export function createApplicationAlpr({ picking, groundFloor, overlays: { - registerPaintLane: registerWorldOverlayPaintLane, + registerPaintLane: (lane, painter, options, engine) => + registerWorldOverlayPaintLane(engine, lane, painter, options), keyholeAlpha: keyholeLabelAlphaFromGeometry, refreshReadout: refreshTrackedReadout, subscribeMapStack(callback) { diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js index 32f45d98b..5c3c196cc 100644 --- a/src/app/layers/droneAirspace.js +++ b/src/app/layers/droneAirspace.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js'; import { rendererFor } from './renderers.js'; import * as picking from '../../data/pickRegistry.js'; @@ -12,6 +13,7 @@ import { isPointerFree } from '../../data/inputOwnership.js'; export function createApplicationDroneAirspace(options) { return createDroneAirspaceLayer({ overlayHost: { + forEngine: worldOverlayFor, setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, clearSource: overlays.clearOverlaySource, diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 29c51f924..913bb6f41 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { rendererFor } from './renderers.js'; import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; import * as render from '../../renderGovernor.js'; @@ -30,6 +31,7 @@ export function createApplicationFirms({ surface, ...options }) { rendererFor('local-firms', engine, renderOptions), }, overlayHost: options.overlayHost ?? { + forEngine: worldOverlayFor, setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, clearSource: overlays.clearOverlaySource, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index f50a3d1d7..3cce66e77 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { rendererFor } from './renderers.js'; import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; @@ -12,6 +13,7 @@ import { isPointerFree } from '../../data/inputOwnership.js'; export function createApplicationGhostMaps(options) { return createGhostMapsLayer({ overlayHost: { + forEngine: worldOverlayFor, setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, clearSource: overlays.clearOverlaySource, diff --git a/src/app/layers/overlayHost.js b/src/app/layers/overlayHost.js index fbaa428a0..97c476755 100644 --- a/src/app/layers/overlayHost.js +++ b/src/app/layers/overlayHost.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { clearOverlaySource, getWorldOverlayDiagnostics, @@ -5,6 +6,7 @@ import { setOverlaySourceVisible, } from '../../overlays/worldOverlay.js'; export const overlayHost = Object.freeze({ + forEngine: worldOverlayFor, clearSource: clearOverlaySource, getDiagnostics: getWorldOverlayDiagnostics, setEntries: setOverlayEntries, diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js index 000f7568e..4720bc547 100644 --- a/src/app/layers/perimeters.js +++ b/src/app/layers/perimeters.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { rendererFor } from './renderers.js'; import { createFirePerimetersLayer, @@ -16,6 +17,7 @@ import { isPointerFree } from '../../data/inputOwnership.js'; export function createApplicationFirePerimeters(options) { return createFirePerimetersLayer({ overlayHost: { + forEngine: worldOverlayFor, setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, clearSource: overlays.clearOverlaySource, diff --git a/src/app/layers/wildlifeSightings.js b/src/app/layers/wildlifeSightings.js index 512fa9d11..415edd052 100644 --- a/src/app/layers/wildlifeSightings.js +++ b/src/app/layers/wildlifeSightings.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -10,6 +11,7 @@ import { isPointerFree } from '../../data/inputOwnership.js'; export function createApplicationWildlifeSightings(options) { return createWildlifeSightingsLayer({ overlayHost: { + forEngine: worldOverlayFor, setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, clearSource: overlays.clearOverlaySource, diff --git a/src/app/localGeojsonServices.js b/src/app/localGeojsonServices.js index a5552d02c..08210ff7b 100644 --- a/src/app/localGeojsonServices.js +++ b/src/app/localGeojsonServices.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../overlays/worldOverlay.js'; import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; import { clearSelectedEntityContextForLayer, @@ -14,6 +15,7 @@ import { /** Existing standalone application operations, shared by its local layers. */ export const localGeoJsonServices = Object.freeze({ overlayHost: Object.freeze({ + forEngine: worldOverlayFor, clearSource: clearOverlaySource, setEntries: setOverlayEntries, setVisible: setOverlaySourceVisible, diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 5e32285f0..177416822 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1,3 +1,5 @@ +import { bindOverlayHost } from '../overlays/bindOverlayHost.js'; +import { worldOverlayFor } from '../overlays/worldOverlay.js'; import { waitForSignal } from '../sources/requestDeadline.js'; import * as Cesium from 'cesium'; import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; @@ -106,6 +108,7 @@ const PASSIVE_ENABLE_ORIGINS = new Set([ ]); const DEFAULT_OVERLAY_HOST = Object.freeze({ + forEngine: worldOverlayFor, setEntries: setOverlayEntries, setVisible: setOverlaySourceVisible, clearSource: clearOverlaySource, @@ -1019,6 +1022,7 @@ export function createBhoteKoshiEventLayer({ panelStore = useBhoteKoshiPanelStore(applicationPinia()), } = {}) { let _viewer = null; + overlayHost = bindOverlayHost(overlayHost, () => _engine); let _engine = null; let _native = null; let _nativeFlood = null; diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 8ebb443d3..ed6be558b 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -1,3 +1,5 @@ +import { bindOverlayHost } from '../overlays/bindOverlayHost.js'; +import { worldOverlayFor } from '../overlays/worldOverlay.js'; import * as Cesium from 'cesium'; import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; import { @@ -151,6 +153,7 @@ const INCIDENT_ACCENT = '#ffad55'; const INCIDENT_COLOR = () => Cesium.Color.fromCssColorString(INCIDENT_ACCENT); const DEFAULT_OVERLAY_HOST = Object.freeze({ + forEngine: worldOverlayFor, setEntries: setOverlayEntries, clearSource: clearOverlaySource, }); @@ -287,6 +290,7 @@ export function createBhoteKoshiLocatorLayer({ cancelDelay = (handle) => clearTimeout(handle), } = {}) { let _viewer = null; + overlayHost = bindOverlayHost(overlayHost, () => _engine); let _engine = null; let _native = null; let _pausedApproach = null; diff --git a/src/data/detection.js b/src/data/detection.js index 35b890df8..a6c673fd9 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -315,21 +315,31 @@ export function initDetection(engine, layers, onModeChange) { ); _hostLayoutRevision = -1; _calloutCount = 0; - _hostLane = registerWorldOverlayPaintLane('detection', _paintDetectionLane, { - id: 'detection', - active: _mode !== MODE_OFF && !_suspended, - target: 'detection', - shouldPaint: _shouldPaintDetectionLane, - }); + _hostLane = registerWorldOverlayPaintLane( + engine, + 'detection', + _paintDetectionLane, + { + id: 'detection', + active: _mode !== MODE_OFF && !_suspended, + target: 'detection', + shouldPaint: _shouldPaintDetectionLane, + }, + ); // Registered AFTER the sensor lane so it replays the callouts that lane just // solved. Both sit in the 'detection' lane — the host's bottom slot — so // callouts still paint under every ordinary overlay card and under the // tracked readout, exactly where they sat before. - _calloutLane = registerWorldOverlayPaintLane('detection', _paintCalloutLane, { - id: 'detection-callouts', - active: _mode !== MODE_OFF && !_suspended, - target: 'shared', - }); + _calloutLane = registerWorldOverlayPaintLane( + engine, + 'detection', + _paintCalloutLane, + { + id: 'detection-callouts', + active: _mode !== MODE_OFF && !_suspended, + target: 'shared', + }, + ); _hostSurface = _hostLane.surface; _cockpitModeListener = (event) => { _cockpitActive = event?.detail?.active === true; diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 4a44ce919..a2bf9e841 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -1,3 +1,4 @@ +let overlayEngine; import { readShellSource } from '../testSupport/readShellSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -232,12 +233,6 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } const container = new MockElement('div'); container.id = 'cesiumContainer'; body.appendChild(container); - const root = new MockElement('div'); - root.id = 'world-overlay-root'; - body.appendChild(root); - const canvas = new MockElement('canvas'); - canvas.id = 'world-overlay-canvas'; - root.appendChild(canvas); const postRender = new MockEvent(); const viewer = { @@ -255,6 +250,7 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } // Shell modules take the globe engine; the Cesium engine runs on the mock. const engine = createCesiumEngine(viewer); + overlayEngine = engine; return { viewer, engine, @@ -267,7 +263,7 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } setPerformanceStep(ms) { performanceStep = ms; }, cleanup() { destroyDetection(); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); engine.destroy(); Date.now = originalDateNow; if (originalPath2D === undefined) delete globalThis.Path2D; @@ -363,7 +359,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste const env = installEnvironment({ search: '?detectDebug=1' }); const modes = []; try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [detectableLayer()], (mode) => modes.push(mode)); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.document.getElementById('detection-overlay'), null); @@ -493,7 +489,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste setMode('DENSE'); env.postRender.raise(); assert.equal(env.document.getElementById('detection-overlay'), null); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); assert.equal(env.postRender.listeners.size, 0); assert.equal(env.document.getElementById('world-overlay-canvas'), null); assert.equal(env.document.getElementById('world-overlay-detection-surface'), null); @@ -517,7 +513,7 @@ test('callouts stop painting the moment the last detectable object goes away', ( type: 'AIR', }]; try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [{ id: 'flights', getDetectableObjects: () => objects }], () => {}); setMode('DENSE'); env.advance(250); @@ -553,7 +549,7 @@ test('every style paints its OWN plate token through the production callout path // path once per style and reads the fill style the shared canvas received. const env = installEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [mixedTierLayer()], () => {}); setMode('DENSE'); settleFrame(env); @@ -591,7 +587,7 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig // tiers at once can show it. const env = installEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [mixedTierLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); @@ -619,7 +615,7 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig test('surveillance military tier reaches the dedicated render target as shipped red', () => { const env = installEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [militaryLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); @@ -659,7 +655,7 @@ test('Global Context echo brackets reach the canvas in the detection style colou pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, echo, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [militaryLayer()], () => {}); setMode('DENSE'); setDetectionStyle('normal'); @@ -705,7 +701,7 @@ test('a Global Context echo bracket is drawn outside its contact bracket and dim echo, level: () => 0.5, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [militaryLayer()], () => {}); setMode('DENSE'); setDetectionStyle('normal'); @@ -777,7 +773,7 @@ test('under the Cyber sonar sweep an echo is never brighter than its own bracket echo, level: () => 1, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [contact], () => {}); setMode('DENSE'); setDetectionStyle('normal'); @@ -839,7 +835,7 @@ test('resting Global Context pulses skip echo work for the whole frame, not per return 0; }, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [layer], () => {}); setMode('DENSE'); settleFrame(env); @@ -866,9 +862,9 @@ test('pathological detection paint holds alternate frames without freezing share // Opts into the telemetry banner: the held-frame assertions count banner paints. const env = installEnvironment({ search: '?detectDebug=1' }); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [detectableLayer()], () => {}); - setOverlayEntries('valve-card', [{ + setOverlayEntries(overlayEngine, 'valve-card', [{ id: 'ambient-card', position: new Cesium.Cartesian3(0, 0, 0), variant: 'label', @@ -929,7 +925,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle assert.match(source, /const LABEL_SOLVE_INTERVAL_MS = 125;/); assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/); assert.match(source, /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/); - assert.match(source, /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/); + assert.match(source, /registerWorldOverlayPaintLane\(\s*engine,\s*'detection',\s*_paintDetectionLane/); assert.match(source, /target: 'detection'/); assert.match(source, /shouldPaint: _shouldPaintDetectionLane/); // The three military styles used to carry three copies of these numbers. @@ -973,7 +969,7 @@ test('the mode banner is absent by default and present behind the flag', () => { const painted = (search) => { const env = installEnvironment({ search }); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); @@ -986,7 +982,7 @@ test('the mode banner is absent by default and present behind the flag', () => { return bannerPaints(env); } finally { destroyDetection(); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); env.cleanup(); } }; @@ -1000,7 +996,7 @@ test('detection telemetry stays reachable programmatically with the banner hidde // is the supported way to read them, and is what the QA harnesses use. const env = installEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); @@ -1012,7 +1008,7 @@ test('detection telemetry stays reachable programmatically with the banner hidde assert.equal(typeof diagnostics.densityPct, 'number'); } finally { destroyDetection(); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); env.cleanup(); } }); @@ -1028,7 +1024,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse metric: 'FL120', type: 'AIR', })); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [{ id: layerId, getDetectableObjects: () => objects }], () => {}); setDetectionTuning({ densityPct: 25 }); setMode('SPARSE'); @@ -1046,7 +1042,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse 'Sparse keeps its documented budget instead of silently becoming Dense'); } finally { destroyDetection(); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); env.cleanup(); } } @@ -1086,7 +1082,7 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', ( // the shared canvas actually received for each plate. const env = installEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection(env.engine, [backdropLayer()], () => {}); setDetectionStyle('normal'); setMode('DENSE'); @@ -1144,7 +1140,7 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share }, ]; try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); initDetection( env.engine, [{ id: 'transit', getDetectableObjects: () => objects }], diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index a8ce70fd8..d8ed8c698 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -198,6 +198,7 @@ test('consumer build includes only infrastructure code and engine-free geometry, 'src/layers/infrastructure/index.js', 'src/layers/infrastructure/render/display.js', 'src/layers/shared/color.js', + 'src/overlays/bindOverlayHost.js', 'src/sources/infrastructureData.js', 'src/sources/requestDeadline.js', ]); diff --git a/src/data/sceneServices.js b/src/data/sceneServices.js index 6db7f5e33..2c43f8635 100644 --- a/src/data/sceneServices.js +++ b/src/data/sceneServices.js @@ -1,3 +1,4 @@ +import { createWorldOverlay } from '../overlays/worldOverlay.js'; import { createRenderGovernor } from '../renderGovernor.js'; import { createPickRegistry } from './pickRegistry.js'; import { createSelectionDispatcher } from './selectionDispatcher.js'; @@ -9,6 +10,7 @@ const scenes = new WeakMap(); /** Create the services for one attachment, including reuse of a parked engine. */ export function createSceneServices(engine, options = {}) { scenes.get(engine)?.destroy(); + const overlay = createWorldOverlay(engine); const render = createRenderGovernor(engine); const picking = createPickRegistry(); const selection = createSelectionDispatcher(engine, { ...options, picking }); @@ -17,11 +19,13 @@ export function createSceneServices(engine, options = {}) { picking, selection, render, + overlay, destroy() { if (destroyed) return; destroyed = true; selection.destroy(); picking.destroy(); + overlay.destroy(); render.destroy(); }, }; diff --git a/src/data/sceneServices.test.mjs b/src/data/sceneServices.test.mjs index 774eb713b..9c8fa61d6 100644 --- a/src/data/sceneServices.test.mjs +++ b/src/data/sceneServices.test.mjs @@ -1,3 +1,5 @@ +import { createDroneAirspaceLayer } from '../layers/droneAirspace/index.js'; +import { overlayHost } from '../app/layers/overlayHost.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; @@ -135,3 +137,60 @@ for (const makeHarness of GROUND_HARNESSES) { assert.equal(b.picking.findPickOwner('shared-id'), 'flights'); }); } + +for (const makeHarness of GROUND_HARNESSES) { + test(`${makeHarness.name}: a retained selected airspace card moves between overlay services`, async (t) => { + const first = makeHarness(); + const second = makeHarness(); + const a = createSceneServices(first.engine); + const b = createSceneServices(second.engine); + let requests = 0; + const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], + ]; + const layer = createDroneAirspaceLayer({ + source: { + async getSnapshot() { + requests++; + return [ + { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + polygons: [[ring]], + }, + ]; + }, + }, + overlayHost, + picking, + pointer: { isPointerFree: () => true }, + }); + t.after(() => { + layer.destroy(); + a.destroy(); + b.destroy(); + first.destroy(); + second.destroy(); + }); + layer.init(null, { engine: first.engine }); + layer.enable(); + await layer.update(); + first.click({ id: 'drone-airspace:310:0' }); + assert.equal(a.overlay.getDiagnostics().entryCount, 1); + assert.equal(b.overlay.getDiagnostics().entryCount, 0); + layer.detachRenderer(); + assert.equal(a.overlay.getDiagnostics().entryCount, 0); + layer.attachRenderer(null, { engine: second.engine }); + assert.equal(requests, 1); + assert.equal(b.overlay.getDiagnostics().entryCount, 1); + a.destroy(); + assert.equal(b.overlay.getDiagnostics().entryCount, 1); + layer.disable(); + assert.equal(b.overlay.getDiagnostics().entryCount, 0); + }); +} diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 2c823a127..a895805df 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -1,5 +1,5 @@ import { createApplicationCables } from '../app/layers/submarineCables.js'; -import { overlayHost } from '../app/layers/overlayHost.js'; +import { worldOverlayFor } from '../overlays/worldOverlay.js'; import { createCableOverlayPublisher as createPublisher } from '../layers/submarineCables/index.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; export function createTeleGeographySubmarineCableLayer(options = {}) { @@ -9,7 +9,7 @@ export function createTeleGeographySubmarineCableLayer(options = {}) { }); } export function createCableOverlayPublisher(options = {}) { - return createPublisher({ host: overlayHost, ...options }); + return createPublisher({ host: worldOverlayFor(options.engine), ...options }); } export { CABLE_REFERENCE_LABEL_WINNER_CAP, diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 71bcc7848..46ae123b1 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -28,9 +28,9 @@ const TRACKED_BILLBOARD_SCALE = Object.freeze({ }); const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, + 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(). diff --git a/src/globe/cesium/annotations/screenAnnotationRenderer.js b/src/globe/cesium/annotations/screenAnnotationRenderer.js index b5e95e6d2..8ad5943b6 100644 --- a/src/globe/cesium/annotations/screenAnnotationRenderer.js +++ b/src/globe/cesium/annotations/screenAnnotationRenderer.js @@ -71,7 +71,8 @@ function markScale(h) { export function createScreenAnnotationRenderer( target, { - overlayPaintRect = getOverlayPaintRect, + engine, + overlayPaintRect = (...args) => getOverlayPaintRect(engine, ...args), activeTrackedReadoutId = getActiveTrackedReadoutId, store = useScreenAnnotationsStore(applicationPinia()), } = {}, diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 540337f25..419bed30b 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { fromDegrees } from '../../geo/cartesian3.js'; import { toDegrees } from '../../geo/math.js'; import { createAirQualityDisplay } from './render/display.js'; @@ -33,6 +34,8 @@ export function createAirQualityLayer({ if (typeof source?.getSnapshot !== 'function') throw new TypeError('Air quality requires a snapshot source'); if (!overlayHost) throw new TypeError('Air quality requires an overlay host'); + let sceneEngine = null; + overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); let initialized = false; let _request = null; let renderer = null; @@ -79,6 +82,7 @@ export function createAirQualityLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid air quality scene attachment'); + sceneEngine = engine; renderer = createRenderer({ engine }); renderer.setVisible(_enabled); overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled); @@ -90,6 +94,7 @@ export function createAirQualityLayer({ overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false); renderer?.destroy(); renderer = null; + sceneEngine = null; }, enable() { diff --git a/src/layers/alpr/render/display.js b/src/layers/alpr/render/display.js index feb7a4b36..8784d9a72 100644 --- a/src/layers/alpr/render/display.js +++ b/src/layers/alpr/render/display.js @@ -222,7 +222,14 @@ export function createAlprDisplay(engine, { state, services, source }) { const overlay = createAlprOverlayCore({ state, - services, + services: { + ...services, + overlays: { + ...services.overlays, + registerPaintLane: (...args) => + services.overlays.registerPaintLane?.(...args, engine), + }, + }, onSurfaceChange: refreshPlacements, engine: { marker: (id) => markers.get(id), diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 21232cc27..716d73553 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -1,6 +1,7 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - services.overlays; + bindOverlayHost(services.overlays, () => layerState._engine); const layerState = {}; layerState.DEFAULT_OVERLAY_HOST = Object.freeze({ diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 4c25dcdff..597f0e5b9 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createCalibration } from './calibration.js'; import { createTesting } from './testing.js'; import { createProjection } from './projection.js'; @@ -27,8 +28,12 @@ export function createCctvLayer({ services, source, rendererFactory }) { ) ) throw new TypeError('A camera source is required'); - const state = createState({ services }); const parts = {}; + services = { + ...services, + overlays: bindOverlayHost(services.overlays, () => parts.renderer?.engine), + }; + const state = createState({ services }); const context = { state, services, parts, source, rendererFactory }; parts.calibration = createCalibration(context); parts.testing = createTesting(context); diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 3d9aa21c3..eecc57e3a 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -65,7 +65,7 @@ const number = (value, unit) => */ export function createCyclonesLayer({ feed, - hitTestOverlay = hitTestWorldOverlay, + hitTestOverlay = null, overlayHost, context = null, createRendering = null, @@ -75,6 +75,7 @@ export function createCyclonesLayer({ if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Cyclones require a snapshot source'); let engine = null; + hitTestOverlay ??= (...args) => hitTestWorldOverlay(engine, ...args); let rendering = null, snapshot = null, request = null, diff --git a/src/layers/cyclones/labels.js b/src/layers/cyclones/labels.js index cb418865f..11e055ce5 100644 --- a/src/layers/cyclones/labels.js +++ b/src/layers/cyclones/labels.js @@ -1,8 +1,4 @@ -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../../overlays/worldOverlay.js'; +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; /** * @module cyclones/labels @@ -31,12 +27,6 @@ export const CYCLONE_MARKER_PX = Object.freeze({ const STORM_PRIORITY = 1000; const LEAD_PRIORITY = 500; -const CYCLONE_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); - /** Fire-card anchor clearance: the gap and leader start follow the marker. */ function markerClearance(markerPx) { const gapPx = Math.max(12, markerPx + 8); @@ -139,7 +129,10 @@ export function cycloneStormIdFromEntryId(entryId) { * Publish the renderer's committed anchors and selection to the overlay host. * @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options] */ -export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) { +export function createCycloneLabels({ + engine, + host = worldOverlayFor(engine), +} = {}) { let storms = []; let selectedId = null; let published = false; diff --git a/src/layers/cyclones/render/display.js b/src/layers/cyclones/render/display.js index 17211d042..ad9ef345c 100644 --- a/src/layers/cyclones/render/display.js +++ b/src/layers/cyclones/render/display.js @@ -81,9 +81,7 @@ export function cycloneSphereVisible(sphere, camera) { export function createCyclonesDisplay(engine, { overlayHost } = {}) { if (!engine?.movers || !engine?.ground) throw new TypeError('The cyclone display needs a globe engine'); - const labels = createCycloneLabels( - overlayHost ? { host: overlayHost } : undefined, - ); + const labels = createCycloneLabels({ engine, host: overlayHost }); // Mover set ids stay unique when a set is rebuilt larger. let setSeq = 0; const pointSet = (role, capacity, outlinePx, sizePx, order) => diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 08f314cc4..1f3fc2be3 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; /** * @module layers/directions * @description Keyless A→B directions on the globe: drive, walk or cycle. @@ -871,6 +872,10 @@ export function createDirectionsLayer({ * tests swap it. Resolved on use, not at import, because the services arrive * after this module is evaluated. */ + services = { + ...services, + overlays: bindOverlayHost(services.overlays, () => _engine), + }; const DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: (...args) => services.overlays.setOverlayEntries(...args), setVisible: (...args) => services.overlays.setOverlaySourceVisible(...args), diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 1c54a12b3..211081f68 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; import { msg, t } from '../../i18n/index.js'; import { createDroneAirspaceDisplay } from './render/display.js'; @@ -60,6 +61,7 @@ export function createDroneAirspaceLayer({ /** @type {Map} */ const _rowById = new Map(); + overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); const canSelect = () => overlayHost && picking; function publishSelectedCard() { diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index c90c088e3..77bf0ca64 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; // Source errors stay English until the layer panel presents them. const msg = (text) => text; import * as geo from '../../geo/index.js'; @@ -31,6 +32,8 @@ export function createEarthquakesLayer({ if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); if (typeof renderer?.create !== 'function') throw new TypeError('Earthquakes require a renderer factory'); + let sceneEngine = null; + overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); let initialized = false; let observations = []; let discs = []; @@ -73,6 +76,7 @@ export function createEarthquakesLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || _renderer) throw new Error('Invalid earthquake scene attachment'); + sceneEngine = engine; _renderer = renderer.create({ engine }); if (!_renderer) throw new Error('Earthquake renderer unavailable'); _renderer.setVisible(_enabled); @@ -86,6 +90,7 @@ export function createEarthquakesLayer({ overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); _renderer?.destroy(); _renderer = null; + sceneEngine = null; }, enable(viewer) { diff --git a/src/layers/firms/index.js b/src/layers/firms/index.js index c7a19937d..8874f0d45 100644 --- a/src/layers/firms/index.js +++ b/src/layers/firms/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createModel } from './model.js'; import { createIngestion } from './ingestion.js'; import { createRendering } from './rendering.js'; @@ -20,6 +21,13 @@ export function createFirmsHeatmapLayer({ services, feed, ...config }) { if (typeof config.renderer?.create !== 'function') throw new TypeError('Fires require a renderer factory'); const layerState = createFirmsState({ services, config }); + config = { + ...config, + overlayHost: bindOverlayHost( + config.overlayHost, + () => layerState._renderer?.engine, + ), + }; const components = {}; const context = { layerState, services, config, components, feed }; components.model = createModel(context); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 29ce6800c..754f7768b 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { fromDegrees } from '../../geo/cartesian3.js'; import { decodeXml, @@ -186,6 +187,7 @@ export function createGhostMapsLayer({ const notifyRow = () => _rowListener?.(); const render = () => _display?.requestRender(); + overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); const canSelect = () => overlayHost && picking && createRenderer; function wanted(product) { diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js index e3d3852f6..cd31b7aaa 100644 --- a/src/layers/infrastructure/render/display.js +++ b/src/layers/infrastructure/render/display.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js'; // The datacenters and dams on the globe contract (engine.movers, // engine.lines, engine.ground, engine.surface), the same on every engine. // Each point or polygon feature has a reference: @@ -113,7 +114,7 @@ export function createInfrastructureDisplay( const footprintFill = [css.red, css.green, css.blue, FOOTPRINT_ALPHA]; const publisher = createLocalInfrastructureOverlayPublisher({ sourceId: id, - host: services.overlayHost, + host: bindOverlayHost(services.overlayHost, () => engine), }); const key = (featureId) => `${id}:${featureId}`; const wake = (reason = id) => engine.render.request(reason); diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js index e9b11aac2..11dda60e9 100644 --- a/src/layers/installations/render/display.js +++ b/src/layers/installations/render/display.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js'; // Mapped Installations on the globe contract (engine.movers, engine.ground, // engine.input, engine.camera), the same on every engine: // @@ -164,7 +165,7 @@ export function createInstallationsDisplay( state, services, parts, - overlayHost, + overlayHost: bindOverlayHost(overlayHost, () => engine), }); let initialized = false; diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 276185334..11d2fc306 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -1,8 +1,9 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - services.overlays; + bindOverlayHost(services.overlays, () => state._engine); const state = {}; state.DEFAULT_OVERLAY_HOST = Object.freeze({ diff --git a/src/layers/osmPlaces/index.js b/src/layers/osmPlaces/index.js index 0015b57a3..7a7624fdf 100644 --- a/src/layers/osmPlaces/index.js +++ b/src/layers/osmPlaces/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; import { msg } from '../../i18n/marker.js'; import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from './model.js'; @@ -26,6 +27,8 @@ export function createOsmPlacesLayer({ createRenderer = ({ engine }) => createOsmPlacesDisplay(engine), } = {}) { if (!overlayHost) throw new TypeError('OSM Places requires an overlay host'); + let sceneEngine = null; + overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); let initialized = false; let renderer = null; let enabled = false; @@ -101,6 +104,7 @@ export function createOsmPlacesLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid OSM Places scene attachment'); + sceneEngine = engine; renderer = createRenderer({ viewer, engine }); renderer.setVisible(enabled); if (enabled) overlayHost.setVisible(OVERLAY_SOURCE_ID, true); @@ -112,6 +116,7 @@ export function createOsmPlacesLayer({ overlayHost.setVisible(OVERLAY_SOURCE_ID, false); renderer?.destroy(); renderer = null; + sceneEngine = null; }, enable() { diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 4188ec00a..430e27e1d 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { fromDegrees } from '../../geo/cartesian3.js'; import { createPerimetersDisplay } from './render/display.js'; import { @@ -89,6 +90,7 @@ export function createFirePerimetersLayer({ _linkRequest = null; } + overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); const canSelect = () => overlayHost && picking; /** diff --git a/src/layers/radio/index.js b/src/layers/radio/index.js index f38388775..632203e9b 100644 --- a/src/layers/radio/index.js +++ b/src/layers/radio/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createCatalogModel } from './catalogModel.js'; import { createLabels } from './labels.js'; import { createClustering } from './clustering.js'; @@ -20,8 +21,12 @@ import { createState } from './state.js'; /** Construct one layer with its own scene state and supplied application services. */ export function createRadioLayer({ services, source, rendererFactory }) { - const state = createState({ services }); const parts = {}; + services = { + ...services, + overlays: bindOverlayHost(services.overlays, () => parts.renderer?.engine), + }; + const state = createState({ services }); const context = { state, services, parts, source, rendererFactory }; parts.catalogModel = createCatalogModel(context); parts.labels = createLabels(context); diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 3bb33b34a..a81561a9c 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -50,12 +50,12 @@ export function createLifecycle({ parts.interaction.removeInteraction(); clearTimeout(layerState._overlayPublishTimer); layerState._overlayPublishTimer = null; + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); parts.renderer?.destroy(); parts.renderer = null; layerState._renderById.clear(); layerState._selectedPosition = null; layerState._lastHorizonCameraPosition = null; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); }, /** Show stations. Enabling or preset restoration never starts audio. */ @@ -109,9 +109,9 @@ export function createLifecycle({ if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME; layerState._audio = null; layerState._userVolume = DEFAULT_RADIO_VOLUME; + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); parts.renderer?.destroy(); parts.renderer = null; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); layerState._stations = []; layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; layerState._stationById.clear(); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index beee05008..4e09b9cc1 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,8 +1,9 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - services.overlays; + bindOverlayHost(services.overlays, () => state._engine); const state = {}; state.DEFAULT_OVERLAY_HOST = Object.freeze({ diff --git a/src/layers/submarineCables/render/display.js b/src/layers/submarineCables/render/display.js index 64ca04b83..bc3d93f0a 100644 --- a/src/layers/submarineCables/render/display.js +++ b/src/layers/submarineCables/render/display.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js'; // The submarine cables on the globe contract, the same on every engine: // - cable paths: one engine.ground corridor set (2.5 px, each cable's own // colour at 0.92), draped on the active map stack's surface; @@ -134,7 +135,9 @@ export function createCablesDisplay( ) { if (!engine?.ground || !engine?.movers || !engine?.lines) throw new TypeError('The cables display needs a globe engine'); - const publisher = createCableOverlayPublisher({ host: overlayHost }); + const publisher = createCableOverlayPublisher({ + host: bindOverlayHost(overlayHost, () => engine), + }); const gate = createCableReferenceSweepGate({ now: sweepClock }); // Reference records by id (their pickId), in creation order. const records = new Map(); diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 6c45accac..c7503db60 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createTransitSource } from './source.js'; import { createTrails } from './trails.js'; import { createState } from './state.js'; @@ -39,8 +40,12 @@ export function createTransitLayer({ 'A transit source needs snapshot and history operations', ); } - const state = createState({ services }); const parts = {}; + services = { + ...services, + overlays: bindOverlayHost(services.overlays, () => parts.renderer?.engine), + }; + const state = createState({ services }); const context = { state, services, parts, source, rendererFactory }; parts.trails = createTrails(context); parts.height = createHeight(context); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index ea1c7861d..e569f78a7 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -339,10 +339,10 @@ export function createLifecycle({ */ destroy(viewer) { this.disable(viewer); + state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); parts.renderer?.destroy(); parts.renderer = null; state._markers = state._animatedMarkers = null; - state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); parts.height.clear(); unbindStyleEvents(); state._dataManager = null; diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index c21559d71..aead66a35 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createVesselFeed } from './ingestion.js'; import { VesselRecords } from './records.js'; import * as geo from '../../geo/index.js'; @@ -9,7 +10,10 @@ export function createVesselState({ source, services }) { setOverlaySourceVisible, clearOverlaySource, hitTestWorldOverlay, - } = services.overlay; + } = bindOverlayHost( + services.overlay, + () => vesselState.state.renderer?.engine, + ); const vesselState = {}; vesselState._source = source; diff --git a/src/layers/wildlifeSightings/index.js b/src/layers/wildlifeSightings/index.js index 779d36edd..ff03e5454 100644 --- a/src/layers/wildlifeSightings/index.js +++ b/src/layers/wildlifeSightings/index.js @@ -1,3 +1,4 @@ +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createPhotoSlots } from './photos.js'; import { createRendering } from './rendering.js'; import { createSelection } from './selection.js'; @@ -73,6 +74,7 @@ export function createWildlifeSightingsLayer({ retryAt: 0, failures: 0, }; + overlayHost = bindOverlayHost(overlayHost, () => state.engine); const parts = {}; const services = { source, diff --git a/src/overlays/bindOverlayHost.js b/src/overlays/bindOverlayHost.js new file mode 100644 index 000000000..feea298d1 --- /dev/null +++ b/src/overlays/bindOverlayHost.js @@ -0,0 +1,12 @@ +/** Resolve a retained model's overlay calls against its current attachment. */ +export function bindOverlayHost(host, getEngine) { + if (!host?.forEngine) return host; + return Object.fromEntries( + Object.entries(host) + .filter(([, value]) => typeof value === 'function') + .map(([name]) => [ + name, + (...args) => host.forEngine(getEngine())?.[name]?.(...args), + ]), + ); +} diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 7696519b7..376c6a578 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1,3 +1,4 @@ +import { sceneServicesFor } from '../data/sceneServices.js'; import * as geo from '../geo/index.js'; import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js'; export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js'; @@ -15,7 +16,7 @@ import { BoundedCohort, stableIdentityHash } from '../data/detectionCohort.js'; import { LabelArbiter, LABEL_ARBITER_TIMING } from '../data/labelArbiter.js'; import { altitudeFade, - destroyWorldOverlayDraw, + retainWorldOverlayDraw, distanceFade, distanceScale, measureOverlayEntry, @@ -104,149 +105,6 @@ const PAINT_LANE_INDEX = new Map( * reported only on membership changes. The borrowed Set is read-only. */ -/** @type {import('../globe/contract.js').GlobeEngine|null} */ -let _engine = null; -/** @type {HTMLElement|null} */ -let _root = null; -/** @type {HTMLCanvasElement|null} */ -let _canvas = null; -/** @type {CanvasRenderingContext2D|null} */ -let _ctx = null; -/** - * The focusable mirror of painted actionable cards, as its view renders it: - * `{owner, items, status, announced}`, or null without a host. `announced` - * counts accepted focuses, so a repeated announcement is spoken again. Subscribers (the - * application shell, which publishes it to a store) receive every change; - * the module itself needs no Vue, for the portable transit entry. - * @type {?{owner: object, items: object[], status: string, announced: number}} - */ -let _actions = null; -const _actionsListeners = new Set(); -let _accessibilitySignature = ''; -const _accessibleActivatorByKey = new Map(); -/** @type {HTMLCanvasElement|null} Host-owned detection blend-isolation surface. */ -let _detectionSurface = null; -/** @type {CanvasRenderingContext2D|null} */ -let _detectionCtx = null; -/** @type {Function|null} */ -let _removePostRender = null; -/** @type {Function|null} */ -let _removeMoveEnd = null; -let _resizeObserver = null; -let _mutationObserver = null; -let _observedOccluderElements = new WeakSet(); -let _occluderRefreshTimer = null; -let _sonarRenderTimer = null; -let _cockpitModeHandler = null; -let _windowResizeHandler = null; -let _cockpitActive = false; -/** - * Set by `destroyWorldOverlay` when it actually tore a host down, and cleared - * by `initWorldOverlay`. A torn-down host must not be resurrected by a late - * source callback, so every mutating export short-circuits while this is true. - * Destroying before the first init leaves it false, so pre-init buffering - * keeps working. - */ -let _destroyed = false; -let _resizeDirty = true; -let _occludersDirty = true; -let _solveDirty = true; -let _canvasNeedsClear = true; -let _detectionSurfaceNeedsClear = true; -let _occludersUpdatedAt = Number.NEGATIVE_INFINITY; -let _canvasWidth = 0; -let _canvasHeight = 0; -let _canvasDpr = 1; -/** - * Monotonic per-frame identity. Pooled candidate/rectangle records carry the - * stamp of the frame that last published them, which replaces the per-frame - * `Map.clear()` + refill cycle that reallocated a hash table every frame. - */ -let _frameStamp = 0; - -/** @type {Map} */ -const _sources = new Map(); -/** @type {Array} Append-only iteration order for `_sources`. */ -const _sourceList = []; -/** @type {Map} Source-owned painters hosted in fixed lanes. */ -const _customPaintLanes = new Map(); -/** @type {Array} Stable registration order for custom painters. */ -const _customPaintLaneList = []; -/** @type {Map} */ -const _domains = new Map(); -/** @type {Array} Append-only iteration order for `_domains`. */ -const _domainList = []; -/** @type {Map} */ -const _records = new Map(); -const _scratchViewProjection = geo.matrix4.create(); -const _viewProjectionScalars = {}; -const _viewport = { width: 0, height: 0 }; -/** `{ isPointVisible }` over the engine's horizon test, for painters too. */ -let _occluder = null; -/** The engine's live camera position and the camera height, per frame. */ -let _cameraPosition = null; -let _cameraHeight = Number.NaN; -/** Cached keyhole geometry, versioned by both its box and shared tuning. */ -let _keyhole = null; -let _keyholeWidth = -1; -let _keyholeHeight = -1; -let _keyholeFadeRatio = Number.NaN; -let _keyholeOutsideOpacity = Number.NaN; -let _layoutRevision = 0; - -const _uiOcclusionRects = []; -const _uiOcclusionRectPool = []; -/** Shared, allocation-free frame contract passed to custom paint lanes. */ -const _customPaintFrame = { - canvas: null, - surface: null, - ctx: null, - width: 0, - height: 0, - dpr: 1, - timestamp: 0, - viewProjectionMatrix: _scratchViewProjection, - viewProjection: _viewProjectionScalars, - cameraPosition: null, - occluder: null, - keyhole: null, - uiRects: _uiOcclusionRects, - uiRectCount: 0, - layoutRevision: 0, -}; -const _paintQueue = []; -const _paintItemPool = []; -const _paintRectPool = []; -const _paintRectByKey = new Map(); -const _hitRects = []; -const _paintedBySource = Object.create(null); -const _paintedSourceKeys = []; -// Live lengths of the pooled frame buffers. Pool arrays are never truncated -// per frame, so their backing stores survive instead of being re-grown. -let _paintCount = 0; -let _paintRectCount = 0; -let _hitRectCount = 0; - -const _diagnostics = { - sourceCount: 0, - entryCount: 0, - candidateCount: 0, - projectedCount: 0, - selectedCount: 0, - fadingCount: 0, - paintedCount: 0, - hitRectCount: 0, - projectionMs: 0, - solveMs: 0, - paintMs: 0, - solveRevision: 0, - paintItemPoolSize: 0, - paintRectPoolSize: 0, - candidateIndexSize: 0, - entriesBySource: {}, - paintedBySource: _paintedBySource, -}; - function nowMs() { return globalThis.performance?.now?.() ?? Date.now(); } @@ -747,372 +605,6 @@ export function selectBoundedOverlayCohort( return protectedEntries.concat(bounded.values(cap)); } -function sourceActive(source) { - return ( - source.options.visible && !(_cockpitActive && source.options.hideInCockpit) - ); -} - -function getOrCreateDomain(domainId) { - let domain = _domains.get(domainId); - if (!domain) { - domain = { - id: domainId, - arbiter: new LabelArbiter(), - candidates: [], - candidateCount: 0, - candidateMap: new Map(), - protectedCandidates: [], - protectedCount: 0, - protectedRects: [], - protectedRectCount: 0, - // Pooled accepted-anchor ring for opt-in anchor separation. Parallel - // arrays instead of objects so the pass allocates nothing per frame. - sepX: [], - sepY: [], - sepR: [], - sepSource: [], - sepCount: 0, - renderEntries: [], - demandBySource: new Map(), - limitsBySource: new Map(), - capacity: DEFAULT_COLLISION_CAPACITY, - lastSolveAt: Number.NEGATIVE_INFINITY, - moving: false, - solveIntervalMs: DEFAULT_MOVING_SOLVE_MS, - }; - _domains.set(domainId, domain); - _domainList.push(domain); - } - return domain; -} - -function rebuildSourceCohorts(source) { - const groups = new Map(); - source.demandByDomain.clear(); - for (const entry of source.entries.values()) { - if (!groups.has(entry.collisionGroup)) groups.set(entry.collisionGroup, []); - groups.get(entry.collisionGroup).push(entry); - if (!isProtected(entry)) { - source.demandByDomain.set( - entry.collisionGroup, - (source.demandByDomain.get(entry.collisionGroup) || 0) + 1, - ); - } - } - let index = 0; - for (const [domainId, entries] of groups) { - const incumbentKeys = getOrCreateDomain(domainId).arbiter.selectedKeys; - source.cohortDomainIds[index] = domainId; - source.cohortLists[index] = selectBoundedOverlayCohort( - entries, - source.options.cohortLimit, - incumbentKeys, - ); - index++; - } - source.cohortCount = index; - source.cohortDomainIds.length = index; - source.cohortLists.length = index; -} - -function getOrCreateSource(sourceId, options = {}) { - const id = assertSourceId(sourceId); - let source = _sources.get(id); - if (!source) { - source = { - id, - entries: new Map(), - cohortDomainIds: [], - cohortLists: [], - cohortCount: 0, - demandByDomain: new Map(), - visibleIds: new Set(), - options: normalizeSourceOptions(options), - }; - _sources.set(id, source); - _sourceList.push(source); - } else if (options && Object.keys(options).length > 0) { - source.options = normalizeSourceOptions(options, source.options); - } - return source; -} - -function invalidateHost({ solve = true, layout = false } = {}) { - if (solve) _solveDirty = true; - if (layout) _occludersDirty = true; - _canvasNeedsClear = true; - _paintRectCount = 0; - _hitRectCount = 0; - _paintRectByKey.clear(); - requestRender(); -} - -function requestRender() { - _engine?.render.request('world-overlay'); -} - -function inertPaintLaneHandle() { - return { surface: null, setActive() {}, requestPaint() {}, unregister() {} }; -} - -/** - * Register a source-owned painter behind one deterministic host lane. The - * callback receives the host's already-sized/clipped Canvas2D target plus the - * frame's shared matrix, keyhole, occluder, and UI rectangles. It must not - * clear or resize the target. The detection target is a host-owned sibling - * surface used only to preserve element-level scene blending. - * @param {string} laneId One of `WORLD_OVERLAY_PAINT_LANES`. - * @param {function(object):void} painter Source-owned paint callback. - * @param {{id?:string,active?:boolean,target?:'shared'|'detection',shouldPaint?:function(object):boolean}} [options] - * @returns {{surface:HTMLCanvasElement|null,setActive:function(boolean):void,requestPaint:function():void,unregister:function():void}} - */ -export function registerWorldOverlayPaintLane(laneId, painter, options = {}) { - if (_destroyed) return inertPaintLaneHandle(); - if (!PAINT_LANE_INDEX.has(laneId)) { - throw new TypeError(`Unsupported WorldOverlay paint lane: ${laneId}`); - } - if (typeof painter !== 'function') { - throw new TypeError( - 'WorldOverlay custom paint lane requires a painter callback', - ); - } - const id = assertSourceId(options.id || laneId); - const previous = _customPaintLanes.get(id); - if (previous) { - const index = _customPaintLaneList.indexOf(previous); - if (index >= 0) _customPaintLaneList.splice(index, 1); - } - const record = { - id, - lane: PAINT_LANE_INDEX.get(laneId), - painter, - active: options.active === true, - target: - options.target === PAINT_TARGET_DETECTION - ? PAINT_TARGET_DETECTION - : PAINT_TARGET_SHARED, - shouldPaint: - typeof options.shouldPaint === 'function' ? options.shouldPaint : null, - }; - _customPaintLanes.set(id, record); - _customPaintLaneList.push(record); - invalidateHost({ solve: false }); - - const unregister = () => { - if (_customPaintLanes.get(id) !== record) return; - _customPaintLanes.delete(id); - const index = _customPaintLaneList.indexOf(record); - if (index >= 0) _customPaintLaneList.splice(index, 1); - record.active = false; - invalidateHost({ solve: false }); - }; - return { - get surface() { - return record.target === PAINT_TARGET_DETECTION - ? _detectionSurface - : _canvas; - }, - setActive(active) { - if (_destroyed || _customPaintLanes.get(id) !== record) return; - const next = active === true; - if (record.active === next) return; - record.active = next; - invalidateHost({ solve: false }); - }, - requestPaint() { - if (_destroyed || _customPaintLanes.get(id) !== record) return; - invalidateHost({ solve: false }); - }, - unregister, - }; -} - -// Removed entries cannot paint an exit fade: their source no longer owns -// them. Release the arbiter identity too, so later layout changes cannot -// wake the idle renderer for an invisible minimum-lifetime/fade state. -function forgetOverlayEntry(entry) { - _records.delete(entry._overlayKey); - _domains.get(entry.collisionGroup)?.arbiter.remove(entry._overlayKey); -} - -/** - * Replace all entries owned by a source atomically. - * @param {string} sourceId - * @param {object[]} entries - * @param {WorldOverlaySourceOptions} [options] - */ -export function setOverlayEntries(sourceId, entries, options = {}) { - if (_destroyed) return; - if (!Array.isArray(entries)) - throw new TypeError('WorldOverlay entries must be an array'); - const source = getOrCreateSource(sourceId, options); - const normalized = entries.map((entry) => - normalizeOverlayEntry(source.id, entry), - ); - const next = new Map(); - for (const entry of normalized) next.set(entry.id, entry); - for (const entry of source.entries.values()) { - if (!next.has(entry.id)) forgetOverlayEntry(entry); - const replacement = next.get(entry.id); - if (replacement && isProtected(entry) !== isProtected(replacement)) - getOrCreateDomain(entry.collisionGroup).arbiter.remove(entry._overlayKey); - } - source.entries = next; - rebuildSourceCohorts(source); - updateEntryDiagnostics(); - invalidateHost(); -} - -/** - * Insert or replace one entry without rebuilding another source. - * @param {string} sourceId - * @param {object} entry - */ -export function upsertOverlayEntry(sourceId, entry) { - if (_destroyed) return; - const source = getOrCreateSource(sourceId); - const normalized = normalizeOverlayEntry(source.id, entry); - source.entries.set(normalized.id, normalized); - rebuildSourceCohorts(source); - updateEntryDiagnostics(); - invalidateHost(); -} - -/** - * Remove one entry by stable identity. - * @param {string} sourceId - * @param {string} entryId - * @returns {boolean} - */ -export function removeOverlayEntry(sourceId, entryId) { - if (_destroyed) return false; - const source = _sources.get(assertSourceId(sourceId)); - if (!source) return false; - const id = String(entryId); - const entry = source.entries.get(id); - if (!entry) return false; - source.entries.delete(id); - forgetOverlayEntry(entry); - rebuildSourceCohorts(source); - updateEntryDiagnostics(); - invalidateHost(); - return true; -} - -/** - * Remove all entries while retaining the source's registration/options. - * @param {string} sourceId - * @returns {boolean} - */ -export function clearOverlaySource(sourceId) { - if (_destroyed) return false; - const source = _sources.get(assertSourceId(sourceId)); - if (!source) return false; - for (const entry of source.entries.values()) forgetOverlayEntry(entry); - source.entries.clear(); - source.cohortDomainIds.length = 0; - source.cohortLists.length = 0; - source.cohortCount = 0; - source.demandByDomain.clear(); - updateEntryDiagnostics(); - invalidateHost(); - return true; -} - -/** - * Enable/disable one registered source. - * @param {string} sourceId - * @param {boolean} visible - */ -export function setOverlaySourceVisible(sourceId, visible) { - if (_destroyed) return; - const source = getOrCreateSource(sourceId); - const next = visible !== false; - if (source.options.visible === next) return; - source.options.visible = next; - invalidateHost(); -} - -function updateEntryDiagnostics() { - const entriesBySource = {}; - let entryCount = 0; - for (const [sourceId, source] of _sources) { - entriesBySource[sourceId] = source.entries.size; - entryCount += source.entries.size; - } - _diagnostics.sourceCount = _sources.size; - _diagnostics.entryCount = entryCount; - _diagnostics.entriesBySource = entriesBySource; -} - -/** - * Return the most recently published painted box (valid until next frame). - * @param {string} sourceId - * @param {string} entryId - * @returns {(OverlayRect & {sourceId:string,entryId:string})|null} - */ -export function getOverlayPaintRect(sourceId, entryId) { - if (_destroyed) return null; - const key = entryKey(String(sourceId), String(entryId)); - const rect = _paintRectByKey.get(key); - // Pooled rectangles outlive their key, so identity and the publishing frame - // are both re-checked instead of clearing the index every frame. - return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null; -} - -/** - * Resolve the topmost interactive world-overlay entry at CSS-pixel coords. - * @param {number} x - * @param {number} y - * @param {{sourceId?:string,collisionGroup?:string,filter?:Function}} [options] - * @returns {{sourceId:string,entryId:string,entry:WorldOverlayEntry,rect:OverlayRect}|null} - */ -export function hitTestWorldOverlay(x, y, options = {}) { - if (_destroyed || !Number.isFinite(x) || !Number.isFinite(y)) return null; - for (let i = _hitRectCount - 1; i >= 0; i--) { - const hit = _hitRects[i]; - if (options.sourceId && hit.sourceId !== options.sourceId) continue; - if ( - options.collisionGroup && - hit.entry.collisionGroup !== options.collisionGroup - ) - continue; - if (typeof options.filter === 'function' && !options.filter(hit.entry)) - continue; - if (x < hit.x || x > hit.x + hit.w || y < hit.y || y > hit.y + hit.h) - continue; - return { - sourceId: hit.sourceId, - entryId: hit.entryId, - entry: hit.entry, - rect: hit, - }; - } - return null; -} - -/** @returns {object} Copy of the stable diagnostic facade shape. */ -export function getWorldOverlayDiagnostics() { - const paintedBySource = {}; - for (let i = 0; i < _paintedSourceKeys.length; i++) { - const sourceId = _paintedSourceKeys[i]; - const count = _paintedBySource[sourceId]; - if (count > 0) paintedBySource[sourceId] = count; - } - return { - ..._diagnostics, - entriesBySource: { ..._diagnostics.entriesBySource }, - paintedBySource, - }; -} - -/** - * True when a candidate survives the explicit horizon/viewport policies. - * The horizon test prefers `entry.cullPosition` when the source supplied one - * (see normalizeOverlayEntry) so a sub-ellipsoid render anchor is not - * false-hidden near the limb; everything else still uses the render position. - */ export function isOverlayPointVisible( entry, position, @@ -1147,1741 +639,127 @@ export function isOverlayPointVisible( ); } -/** - * Return true when a rectangle intersects any cached UI exclusion box. - * `count` lets pooled buffers expose a live prefix without truncating. - */ -function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) { - for (let i = 0; i < count; i++) { - if (overlayRectsOverlap(rect, exclusions[i])) return true; - } - return false; -} +/** Own one scene attachment's sources, canvases and frame work. */ +export function createWorldOverlay(engine) { + /** @type {import('../globe/contract.js').GlobeEngine|null} */ + let _engine = engine; + /** @type {HTMLElement|null} */ + let _root = null; + let _releaseDraw = null; + /** @type {HTMLCanvasElement|null} */ + let _canvas = null; + /** @type {CanvasRenderingContext2D|null} */ + let _ctx = null; + /** + * The focusable mirror of painted actionable cards, as its view renders it: + * `{owner, items, status, announced}`, or null without a host. `announced` + * counts accepted focuses, so a repeated announcement is spoken again. Subscribers (the + * application shell, which publishes it to a store) receive every change; + * the module itself needs no Vue, for the portable transit entry. + * @type {?{owner: object, items: object[], status: string, announced: number}} + */ + let _actions = null; + const _actionsListeners = new Set(); + let _accessibilitySignature = ''; + const _accessibleActivatorByKey = new Map(); + /** @type {HTMLCanvasElement|null} Host-owned detection blend-isolation surface. */ + let _detectionSurface = null; + /** @type {CanvasRenderingContext2D|null} */ + let _detectionCtx = null; + /** @type {Function|null} */ + let _removePostRender = null; + /** @type {Function|null} */ + let _removeMoveEnd = null; + let _resizeObserver = null; + let _mutationObserver = null; + let _observedOccluderElements = new WeakSet(); + let _occluderRefreshTimer = null; + let _sonarRenderTimer = null; + let _cockpitModeHandler = null; + let _windowResizeHandler = null; + let _cockpitActive = false; + /** A destroyed attachment stays inert, including before DOM initialization. */ + let _destroyed = false; + let _resizeDirty = true; + let _occludersDirty = true; + let _solveDirty = true; + let _canvasNeedsClear = true; + let _detectionSurfaceNeedsClear = true; + let _occludersUpdatedAt = Number.NEGATIVE_INFINITY; + let _canvasWidth = 0; + let _canvasHeight = 0; + let _canvasDpr = 1; + /** + * Monotonic per-frame identity. Pooled candidate/rectangle records carry the + * stamp of the frame that last published them, which replaces the per-frame + * `Map.clear()` + refill cycle that reallocated a hash table every frame. + */ + let _frameStamp = 0; -function overlayRectsOverlap(a, b) { - return ( - a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y - ); -} + /** @type {Map} */ + const _sources = new Map(); + /** @type {Array} Append-only iteration order for `_sources`. */ + const _sourceList = []; + /** @type {Map} Source-owned painters hosted in fixed lanes. */ + const _customPaintLanes = new Map(); + /** @type {Array} Stable registration order for custom painters. */ + const _customPaintLaneList = []; + /** @type {Map} */ + const _domains = new Map(); + /** @type {Array} Append-only iteration order for `_domains`. */ + const _domainList = []; + /** @type {Map} */ + const _records = new Map(); + const _scratchViewProjection = geo.matrix4.create(); + const _viewProjectionScalars = {}; + const _viewport = { width: 0, height: 0 }; + /** `{ isPointVisible }` over the engine's horizon test, for painters too. */ + let _occluder = null; + /** The engine's live camera position and the camera height, per frame. */ + let _cameraPosition = null; + let _cameraHeight = Number.NaN; + /** Cached keyhole geometry, versioned by both its box and shared tuning. */ + let _keyhole = null; + let _keyholeWidth = -1; + let _keyholeHeight = -1; + let _keyholeFadeRatio = Number.NaN; + let _keyholeOutsideOpacity = Number.NaN; + let _layoutRevision = 0; -/** - * Like `overlayRectIntersectsAny`, but only against exclusions that composite - * BELOW the host — the ones a placement may never overlap at any cost. - */ -function overlayRectIntersectsAnyHard( - rect, - exclusions, - count = exclusions.length, -) { - for (let i = 0; i < count; i++) { - const other = exclusions[i]; - if (other.hard && overlayRectsOverlap(rect, other)) return true; - } - return false; -} - -function leastOverlappingPlacement(placements, exclusions, count) { - let best = null; - let bestArea = Number.POSITIVE_INFINITY; - for (let i = 0; i < placements.length; i++) { - const placement = placements[i]; - const rect = placement?.rect; - if (!rect) continue; - let overlapArea = 0; - for (let j = 0; j < count; j++) { - const other = exclusions[j]; - const overlapW = - Math.min(rect.x + rect.w, other.x + other.w) - - Math.max(rect.x, other.x); - const overlapH = - Math.min(rect.y + rect.h, other.y + other.h) - - Math.max(rect.y, other.y); - if (overlapW > 0 && overlapH > 0) overlapArea += overlapW * overlapH; - } - if (overlapArea < bestArea) { - best = placement; - bestArea = overlapArea; - } - } - return best; -} - -function ensureOverlayDom() { - _root = document.getElementById(ROOT_ID); - if (!_root) { - _root = document.createElement('div'); - _root.id = ROOT_ID; - _root.setAttribute('aria-hidden', 'true'); - const container = _engine.host.container; - const parent = container?.parentElement || document.body; - if (container?.nextSibling) - parent.insertBefore(_root, container.nextSibling); - else parent.appendChild(_root); - } - _canvas = - _root.querySelector?.(`#${CANVAS_ID}`) || - document.getElementById(CANVAS_ID); - if (!_canvas) { - _canvas = document.createElement('canvas'); - _canvas.id = CANVAS_ID; - _root.appendChild(_canvas); - } - // The detection surface is parented to the Cesium container, NOT to - // `#world-overlay-root`. Detection paints with `mix-blend-mode: screen`, - // which only reaches the WebGL scene while no ancestor between the surface - // and the Cesium canvas forms a stacking context (an isolated blending - // group). `#cesiumContainer` is `position:absolute; z-index:auto` and does - // not; `#world-overlay-root` is `z-index:6` and does — parenting here made - // the browser silently discard the blend while the CSS string stayed - // `'screen'`. Paint order is expressed purely by z-index: this surface is - // z5, the shared card canvas inside the root is z6. - const detectionParent = _engine?.host.container || document.body; - _detectionSurface = - detectionParent.querySelector?.(`#${DETECTION_SURFACE_ID}`) || - document.getElementById(DETECTION_SURFACE_ID); - if (!_detectionSurface) { - _detectionSurface = document.createElement('canvas'); - _detectionSurface.id = DETECTION_SURFACE_ID; - } - if (_detectionSurface.parentElement !== detectionParent) { - detectionParent.appendChild(_detectionSurface); - } - _root.setAttribute('aria-hidden', 'true'); - _canvas.setAttribute('aria-hidden', 'true'); - _detectionSurface.setAttribute('aria-hidden', 'true'); - // Each host owns its action list; a click from an earlier host's list is - // ignored. - if (!_actions) - presentActions({ owner: {}, items: [], status: '', announced: 0 }); - _ctx = _canvas.getContext('2d', { alpha: true, desynchronized: true }); - _detectionCtx = _detectionSurface.getContext('2d', { - alpha: true, - desynchronized: true, - }); -} - -function sizeCanvasSurface(canvas, ctx, width, height, dpr) { - if (!canvas) return false; - const backingWidth = Math.round(width * dpr); - const backingHeight = Math.round(height * dpr); - const changed = - canvas.width !== backingWidth || canvas.height !== backingHeight; - if (changed) { - canvas.width = backingWidth; - canvas.height = backingHeight; - } - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - ctx?.setTransform?.(dpr, 0, 0, dpr, 0, 0); - return changed; -} - -/** - * Size the backing store to the live CSS box and DPR. A dormant host never - * calls this, so a zero-source overlay keeps a 0x0 canvas instead of a - * full-viewport buffer; `drawWorldOverlay` sizes lazily on the first frame - * that actually has paint work. - */ -function ensureCanvasSize() { - if (!_canvas || !_engine) return false; - const size = _engine.host.size(); - const width = Math.max(0, Math.round(Number(size.width) || 0)); - const height = Math.max(0, Math.round(Number(size.height) || 0)); - const dpr = Math.max(1, Number(globalThis.window?.devicePixelRatio) || 1); - const changed = - _canvas.width !== Math.round(width * dpr) || - _canvas.height !== Math.round(height * dpr) || - _detectionSurface?.width !== Math.round(width * dpr) || - _detectionSurface?.height !== Math.round(height * dpr) || - _canvasWidth !== width || - _canvasHeight !== height || - _canvasDpr !== dpr; - _resizeDirty = false; - if (!changed) return false; - sizeCanvasSurface(_detectionSurface, _detectionCtx, width, height, dpr); - sizeCanvasSurface(_canvas, _ctx, width, height, dpr); - _canvasWidth = width; - _canvasHeight = height; - _viewport.width = width; - _viewport.height = height; - _canvasDpr = dpr; - _occludersDirty = true; - _solveDirty = true; - _canvasNeedsClear = true; - // Resizing a canvas clears its backing store by definition. - _detectionSurfaceNeedsClear = false; - _layoutRevision++; - return true; -} - -/** - * Only chrome the user can actually see may exclude anything. A collapsed or - * hidden panel must contribute nothing, including when the reason it is - * invisible lives on an ANCESTOR — a child of a `display:none` container keeps - * its own computed `display`, so the local style test alone cannot see it. - * `checkVisibility` resolves the whole chain; the local test is the fallback. - */ -function elementIsVisible(element) { - if (!element || element.hidden) return false; - if (typeof element.checkVisibility === 'function') { - return ( - element.checkVisibility({ - contentVisibilityAuto: true, - // The spec renamed these; pass both spellings so the check keeps its - // opacity/visibility coverage on either engine vintage. Unknown keys are - // ignored, and every option defaults to false, so a missing alias would - // silently weaken the test rather than throw. - opacityProperty: true, - visibilityProperty: true, - checkOpacity: true, - checkVisibilityCSS: true, - }) !== false - ); - } - const style = globalThis.window?.getComputedStyle?.(element); - if (!style) return true; - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - Number(style.opacity) !== 0 - ); -} - -/** - * Highest z-index the host paints at: the card canvas sits in `#world-overlay-root` - * (z6) and the detection surface at z5. Chrome ABOVE this composites over host - * paint; chrome at or below it is painted OVER by the host. - */ -const HOST_TOP_Z_INDEX = 6; - -/** - * Effective stacking level of an element relative to the page root. - * - * Paint order against the host is decided by the OUTERMOST positioned ancestor - * carrying a z-index — that is the stacking context which actually competes with - * `#world-overlay-root` — so the walk keeps overwriting as it climbs and returns - * the last one found. An element with no positioned z-indexed ancestor stacks at - * the root's own level, which is below the host. - */ -function elementStackLevel(element) { - let level = 0; - let node = element; - while (node && node !== document.body) { - const style = globalThis.window?.getComputedStyle?.(node); - if (style && style.position !== 'static' && style.zIndex !== 'auto') { - const numeric = Number(style.zIndex); - if (Number.isFinite(numeric)) level = numeric; - } - node = node.parentElement; - } - return level; -} - -/** - * Does this chrome composite ABOVE the host? - * - * This is the justification for treating exclusion as a soft preference: chrome - * that paints over the host hides an unplaceable card harmlessly. Chrome that - * paints UNDER the host does not — a card kept there renders ON TOP of it, which - * violates the absolute rule that labels never cover the UI. `#intel-hud` is z2, - * i.e. below both host surfaces, so its corners and bars need a HARD veto. - */ -function occluderStacksAboveHost(element) { - return elementStackLevel(element) > HOST_TOP_Z_INDEX; -} - -/** Append one inflated, canvas-relative exclusion rectangle from the pool. */ -function pushUiOcclusionRect(rect, canvasRect, hard = false) { - const index = _uiOcclusionRects.length; - const out = _uiOcclusionRectPool[index] || (_uiOcclusionRectPool[index] = {}); - // `hard` rects veto a placement outright; soft rects are only preferred against. - out.hard = hard; - out.x = rect.left - canvasRect.left - OCCLUDER_PADDING_PX; - out.y = rect.top - canvasRect.top - OCCLUDER_PADDING_PX; - out.w = rect.width + OCCLUDER_PADDING_PX * 2; - out.h = rect.height + OCCLUDER_PADDING_PX * 2; - _uiOcclusionRects.push(out); -} - -function refreshUiOccluders(timestamp, force = false) { - if (!_canvas || !_occludersDirty) return false; - const elapsed = timestamp - _occludersUpdatedAt; - if (!force && elapsed < OCCLUDER_REFRESH_MS) { - if (_occluderRefreshTimer == null && typeof setTimeout === 'function') { - _occluderRefreshTimer = setTimeout( - () => { - _occluderRefreshTimer = null; - requestRender(); - }, - Math.max(0, OCCLUDER_REFRESH_MS - elapsed), - ); - } - return false; - } - _occludersUpdatedAt = timestamp; - _occludersDirty = false; - _uiOcclusionRects.length = 0; - const canvasRect = _canvas.getBoundingClientRect?.() || { left: 0, top: 0 }; - const seen = new Set(); - for (const selector of WORLD_OVERLAY_OCCLUDER_SELECTORS) { - const matches = - document.querySelectorAll?.(selector) || - [document.querySelector?.(selector)].filter(Boolean); - for (const element of matches) { - if (seen.has(element) || !elementIsVisible(element)) continue; - seen.add(element); - if (_resizeObserver && !_observedOccluderElements.has(element)) { - _resizeObserver.observe(element); - _mutationObserver?.observe?.(element, OCCLUDER_ATTRIBUTE_OBSERVATION); - _observedOccluderElements.add(element); - } - const rect = element.getBoundingClientRect?.(); - if (!rect || rect.width <= 0 || rect.height <= 0) continue; - pushUiOcclusionRect(rect, canvasRect, !occluderStacksAboveHost(element)); - } - } - // Each element keeps its OWN rectangle. The inventory used to be reduced to - // transitive bounding UNIONS of overlapping rects, which is only a - // requirement for even-odd canvas holes (overlapping holes toggle pixels back - // on) — and the host no longer punches holes in anything. As placement - // geometry the unions were actively wrong: a chain of pairwise-overlapping - // panels collapsed into one enormous rectangle that swept unrelated world - // space, jumped in size whenever a panel expanded, and in cockpit coalesced - // to 94-98 % of the viewport. Per-rect exclusions hug the real chrome. - _solveDirty = true; - _layoutRevision++; - return true; -} - -function markLayoutDirty() { - _resizeDirty = true; - _occludersDirty = true; - if (!overlayHasPaintWork()) return; - invalidateHost({ solve: true, layout: true }); -} - -function markOccludersDirty() { - _occludersDirty = true; - if (!overlayHasPaintWork()) return; - invalidateHost({ solve: true }); -} - -/** - * Occluder-element attribute observation is deliberately NOT `subtree`: the - * inventory only reads each occluder's own border box and visibility, and a - * descendant cannot change either without the element's ResizeObserver seeing - * it. Subtree scope added nothing — but it delivered every ticking descendant - * (the REC-dot blink, chip internals) as an invalidation, which held a parked - * camera at ~12 requestRender/s for every live overlay source. - */ -const OCCLUDER_ATTRIBUTE_OBSERVATION = Object.freeze({ - attributes: true, - attributeFilter: Object.freeze(['class', 'style', 'hidden']), -}); - -/** - * True when a node either IS inventory chrome or CONTAINS some. Judged against - * live nodes at record-delivery time, so "append, then set id" within one task - * is still seen; removals judge the detached subtree, which selector APIs - * still traverse. - */ -function nodeIsOrContainsOccluderChrome(node) { - if (!node || typeof node.matches !== 'function') return false; // text/comment nodes - for (const selector of WORLD_OVERLAY_OCCLUDER_SELECTORS) { - if (node.matches(selector)) return true; - if ( - typeof node.querySelector === 'function' && - node.querySelector(selector) - ) - return true; - } - return false; -} - -function childListTouchesChrome(nodes) { - if (!nodes) return false; - for (let i = 0; i < nodes.length; i++) { - if (nodeIsOrContainsOccluderChrome(nodes[i])) return true; - } - return false; -} - -/** - * Shared MutationObserver callback for both observation scopes. Body-level - * childList records are DISCOVERY only — they matter solely when the churn - * adds or removes inventory chrome, so a ticking clock's text swaps and other - * non-chrome element churn are dropped here. Attribute records can only - * originate from an occluder element itself (element-scoped observation), so - * they always invalidate. A batch the runtime failed to deliver falls back to - * the conservative pre-filter behavior. - */ -function handleChromeMutations(records) { - if (!overlayHasPaintWork()) { - // Dormant host: keep the pre-filter flag semantics without paying selector - // checks — the first frame with real paint work force-refreshes anyway. - _occludersDirty = true; - return; - } - if (!records || typeof records.length !== 'number') { - markOccludersDirty(); - return; - } - for (let i = 0; i < records.length; i++) { - const record = records[i]; - if (!record) continue; - if ( - record.type !== 'childList' || - childListTouchesChrome(record.addedNodes) || - childListTouchesChrome(record.removedNodes) - ) { - markOccludersDirty(); - return; - } - } -} - -function installUiOccluderObservers() { - _windowResizeHandler = markLayoutDirty; - globalThis.window?.addEventListener?.('resize', _windowResizeHandler); - if (typeof MutationObserver === 'function' && document.body) { - _mutationObserver = new MutationObserver(handleChromeMutations); - // Body observation is childList discovery only, filtered in the callback; - // attribute churn is observed per occluder element, without subtree. - _mutationObserver.observe(document.body, { - subtree: true, - childList: true, - }); - } - if (typeof ResizeObserver === 'function') { - _resizeObserver = new ResizeObserver(markLayoutDirty); - if (_root) { - _resizeObserver.observe(_root); - _observedOccluderElements.add(_root); - } - for (const selector of WORLD_OVERLAY_OCCLUDER_SELECTORS) { - const matches = document.querySelectorAll?.(selector) || []; - for (const element of matches) { - if (_observedOccluderElements.has(element)) continue; - _resizeObserver.observe(element); - _mutationObserver?.observe?.(element, OCCLUDER_ATTRIBUTE_OBSERVATION); - _observedOccluderElements.add(element); - } - } - } -} - -function clearCanvasSurface(canvas, ctx) { - if (!ctx || !canvas || !(canvas.width > 0) || !(canvas.height > 0)) return; - ctx.setTransform?.(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.setTransform?.(_canvasDpr, 0, 0, _canvasDpr, 0, 0); -} - -/** Clear either or both host-owned surfaces through the single frame path. */ -function clearCanvas(clearMain = true, clearDetection = true) { - if (clearMain) { - clearCanvasSurface(_canvas, _ctx); - _canvasNeedsClear = false; - } - if (clearDetection) { - clearCanvasSurface(_detectionSurface, _detectionCtx); - _detectionSurfaceNeedsClear = false; - } -} - -function activeEntryCount() { - let count = 0; - for (let i = 0; i < _sourceList.length; i++) { - const source = _sourceList[i]; - if (sourceActive(source)) count += source.entries.size; - } - return count; -} - -function activeCustomPaintLaneCount(target = null) { - let count = 0; - for (let i = 0; i < _customPaintLaneList.length; i++) { - const record = _customPaintLaneList[i]; - if (record.active && (target === null || record.target === target)) count++; - } - return count; -} - -function activeFadeCount(timestamp) { - let count = 0; - for (let d = 0; d < _domainList.length; d++) { - const arbiter = _domainList[d].arbiter; - const stateCount = arbiter.activeStateCount(); - for (let i = 0; i < stateCount; i++) { - const state = arbiter.activeStateAt(i); - if (state.selected) { - if (timestamp - state.enterStartedAt < LABEL_ARBITER_TIMING.fadeInMs) - count++; - } else if ( - timestamp - state.exitStartedAt < - LABEL_ARBITER_TIMING.fadeOutMs - ) { - count++; - } - } - } - return count; -} - -function activeLeaderAnimationCount(timestamp) { - let count = 0; - for (let i = 0; i < _sourceList.length; i++) { - const source = _sourceList[i]; - if (!sourceActive(source)) continue; - for (const entry of source.entries.values()) { - if ( - entry.leaderAnimationMs > 0 && - timestamp - entry.leaderAnimationStartedAt < entry.leaderAnimationMs - ) - count++; - } - } - return count; -} - -function overlayHasPaintWork(timestamp = nowMs()) { - if (activeCustomPaintLaneCount() > 0) return true; - if (activeEntryCount() > 0) return true; - if (!_solveDirty) return activeFadeCount(timestamp) > 0; - for (let d = 0; d < _domainList.length; d++) { - if (_domainList[d].arbiter.states.size > 0) return true; - } - return false; -} - -function prepareProjectionMatrix() { - const matrix = _engine.projector.viewProjection(_scratchViewProjection); - _viewProjectionScalars.m0 = matrix[0]; - _viewProjectionScalars.m1 = matrix[1]; - _viewProjectionScalars.m3 = matrix[3]; - _viewProjectionScalars.m4 = matrix[4]; - _viewProjectionScalars.m5 = matrix[5]; - _viewProjectionScalars.m7 = matrix[7]; - _viewProjectionScalars.m8 = matrix[8]; - _viewProjectionScalars.m9 = matrix[9]; - _viewProjectionScalars.m11 = matrix[11]; - _viewProjectionScalars.m12 = matrix[12]; - _viewProjectionScalars.m13 = matrix[13]; - _viewProjectionScalars.m15 = matrix[15]; - return _viewProjectionScalars; -} - -function prepareCustomPaintFrame(timestamp, keyhole) { - const viewProjection = prepareProjectionMatrix(); - const camera = _engine.camera.livePosition; - _cameraPosition = camera; - _cameraHeight = _engine.camera.height(); - _customPaintFrame.canvas = _canvas; - _customPaintFrame.surface = _canvas; - _customPaintFrame.ctx = _ctx; - _customPaintFrame.width = _canvasWidth; - _customPaintFrame.height = _canvasHeight; - _customPaintFrame.dpr = _canvasDpr; - _customPaintFrame.timestamp = timestamp; - _customPaintFrame.cameraPosition = camera; - _customPaintFrame.occluder = _occluder; - _customPaintFrame.keyhole = keyhole; - _customPaintFrame.uiRectCount = _uiOcclusionRects.length; - _customPaintFrame.layoutRevision = _layoutRevision; - return viewProjection; -} - -function getProjectionRecord(entry) { - let record = _records.get(entry._overlayKey); - if (!record) { - record = { - key: entry._overlayKey, - entry, - position: geo.cartesian3.create(), - screen: { x: 0, y: 0 }, - layout: entry._overlayLayout, - placements: [], - candidate: null, - distanceAlpha: 1, - paintScale: 1, - leaderProgress: 1, - contentAlpha: 1, - altitudeAlpha: 1, - sourceAlpha: 1, - protectedPlacement: null, - distanceOptions: {}, - altitudeOptions: {}, - placementInput: {}, - scaledPaintPlacement: { rect: {} }, - }; - record.candidate = { - key: record.key, - layerId: entry.source, - sourceId: entry.id, - priority: entry.priority, - centerDistance: 0, - keyholeAlpha: 1, - placements: record.placements, - screenX: 0, - screenY: 0, - frameStamp: 0, - // Declared up front so the arbiter's per-solve scalar caches land in - // stable double fields instead of transitioning the shape every frame. - _anchorX: 0, - _anchorY: 0, - _record: record, - }; - _records.set(record.key, record); - } - record.entry = entry; - record.layout = entry._overlayLayout; - record.candidate.layerId = entry.source; - record.candidate.sourceId = entry.id; - record.candidate.priority = entry.priority; - record.candidate.stateless = entry.stateless; - record.distanceOptions.minDistance = entry.minDistance; - record.distanceOptions.maxDistance = entry.maxDistance; - record.distanceOptions.fadeStartRatio = entry.distanceFadeStartRatio; - record.altitudeOptions.minAltitude = entry.minAltitude; - record.altitudeOptions.fadeStart = entry.altitudeFadeStart; - record.altitudeOptions.fadeEnd = entry.altitudeFadeEnd; - return record; -} - -function resetFrameDomains() { - for (let d = 0; d < _domainList.length; d++) { - const domain = _domainList[d]; - // `candidateMap` keeps its table across frames; membership is expressed by - // `candidate.frameStamp`, and demand tallies are zeroed rather than cleared - // because `Map#clear` reallocates the backing hash table every call. Stale - // keys still have to go somewhere, so the index is pruned here — before the - // frame republishes it — against the previous frame's live cohort. - pruneKeyedIndex(domain.candidateMap, domain.candidateCount); - domain.candidateCount = 0; - domain.protectedCount = 0; - domain.protectedRectCount = 0; - domain.sepCount = 0; - domain.demandBySource.forEach(zeroDemandEntry); - domain.capacity = 0; - domain.moving = false; - domain.solveIntervalMs = Number.POSITIVE_INFINITY; - } -} - -// Evaluate optional scene presentation outside the shared projection hot path. -// Passing records (rather than intermediate doubles) also avoids boxing the -// ordinary moving-source workload at a non-inlined call boundary. -function applyPresentation(entry, source, record) { - try { - const scale = Number( - typeof entry.presentationScale === 'function' - ? entry.presentationScale() - : entry.presentationScale, - ); - record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1; - const alpha = Number( - typeof entry.sourceAlpha === 'function' - ? entry.sourceAlpha() - : entry.sourceAlpha, - ); - record.sourceAlpha = - source.options.alpha * - (Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1); - const leader = Number( - typeof entry.leaderProgress === 'function' - ? entry.leaderProgress() - : entry.leaderProgress, - ); - record.leaderProgress = Number.isFinite(leader) - ? Math.max(0, Math.min(1, leader)) - : 1; - const content = Number( - typeof entry.contentAlpha === 'function' - ? entry.contentAlpha() - : entry.contentAlpha, - ); - record.contentAlpha = Number.isFinite(content) - ? Math.max(0, Math.min(1, content)) - : 1; - return true; - } catch { - return false; - } -} - -function snapshotAndProject(entry, source, viewProjection, keyhole) { - const record = getProjectionRecord(entry); - let position; - try { - position = - typeof entry.position === 'function' ? entry.position() : entry.position; - } catch { - return null; - } - if ( - !position || - !Number.isFinite(position.x) || - !Number.isFinite(position.y) || - !Number.isFinite(position.z) - ) { - return null; - } - record.position.x = position.x; - record.position.y = position.y; - record.position.z = position.z; - - const { x: px, y: py, z: pz } = record.position; - const clipW = - viewProjection.m3 * px + - viewProjection.m7 * py + - viewProjection.m11 * pz + - viewProjection.m15; - if (!(clipW > 0)) return null; - const invW = 1 / clipW; - record.screen.x = - ((viewProjection.m0 * px + - viewProjection.m4 * py + - viewProjection.m8 * pz + - viewProjection.m12) * - invW * - 0.5 + - 0.5) * - _canvasWidth; - record.screen.y = - (0.5 - - (viewProjection.m1 * px + - viewProjection.m5 * py + - viewProjection.m9 * pz + - viewProjection.m13) * - invW * - 0.5) * - _canvasHeight; - if ( - entry.safeTopRatio > 0 && - !(entry.pinned && entry.pinnedBypassesSafeTop) - ) { - const safeTop = Math.min( - entry.safeTopMaxPx, - _canvasHeight * entry.safeTopRatio, - ); - if (record.screen.y < safeTop) return null; - } - if ( - !isOverlayPointVisible( - entry, - record.position, - record.screen, - _viewport, - _occluder, - ) - ) - return null; - - // Doubles crossing a non-inlined call boundary are boxed, so the two hot - // scalar steps stay local: the range is computed inline, and the common - // "no far limit" fade policy resolves without calling out at all. Both are - // exact restatements of `Cartesian3.distance` / `distanceFade`. - const cameraPosition = _cameraPosition; - const rangeX = cameraPosition.x - record.position.x; - const rangeY = cameraPosition.y - record.position.y; - const rangeZ = cameraPosition.z - record.position.z; - const distance = Math.sqrt( - rangeX * rangeX + rangeY * rangeY + rangeZ * rangeZ, - ); - record.distanceAlpha = - entry.maxDistance === Number.POSITIVE_INFINITY - ? distance >= entry.minDistance - ? 1 - : 0 - : distanceFade(distance, record.distanceOptions); - const cameraAltitude = _cameraHeight; - record.paintScale = entry.distanceScale - ? distanceScale(distance, entry.distanceScale) - : 1; - // Keep the source-configurable piecewise curve local to the projection hot - // path. Passing its five doubles through a non-inlined helper boxed them for - // every thumbnail candidate and broke the shared 154 B/candidate gate. - const altitudeCurve = entry.altitudeScale; - if (altitudeCurve && Number.isFinite(cameraAltitude)) { - let altitudeFactor = 1; - if (cameraAltitude > altitudeCurve.fullEnd) { - if (cameraAltitude <= altitudeCurve.midEnd) { - const span = Math.max(1, altitudeCurve.midEnd - altitudeCurve.fullEnd); - let progress = Math.max( - 0, - Math.min(1, (cameraAltitude - altitudeCurve.fullEnd) / span), - ); - if (altitudeCurve.smoothToMid) - progress = progress * progress * (3 - 2 * progress); - altitudeFactor = 1 + (altitudeCurve.midValue - 1) * progress; - } else if (cameraAltitude >= altitudeCurve.end) { - altitudeFactor = altitudeCurve.endValue; - } else { - const progress = - (cameraAltitude - altitudeCurve.midEnd) / - Math.max(1, altitudeCurve.end - altitudeCurve.midEnd); - altitudeFactor = - altitudeCurve.midValue + - (altitudeCurve.endValue - altitudeCurve.midValue) * progress; - } - } - record.paintScale *= altitudeFactor; - } - record.altitudeAlpha = - entry.altitudeFadeEnd === Number.POSITIVE_INFINITY - ? Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude - ? 0 - : 1 - : altitudeFade(cameraAltitude, record.altitudeOptions); - record.leaderProgress = 1; - record.contentAlpha = 1; - if ( - entry.presentationScale !== 1 || - entry.leaderProgress !== 1 || - entry.contentAlpha !== 1 || - typeof entry.sourceAlpha === 'function' - ) { - if (!applyPresentation(entry, source, record)) return null; - } else { - record.sourceAlpha = source.options.alpha * entry.sourceAlpha; - } - if ( - record.distanceAlpha <= 0 || - record.paintScale <= 0 || - record.altitudeAlpha <= 0 || - record.sourceAlpha <= 0 - ) - return null; - - measureOverlayEntry(_ctx, entry, record.layout); - record.placementInput.anchorX = record.screen.x; - record.placementInput.anchorY = record.screen.y; - record.placementInput.width = record.layout.w * record.paintScale; - record.placementInput.height = record.layout.h * record.paintScale; - record.placementInput.viewportWidth = _canvasWidth; - record.placementInput.viewportHeight = _canvasHeight; - if (entry.anchorRadiusPx > 0) { - const anchorScale = entry.anchorRadiusScale - ? distanceScale(distance, entry.anchorRadiusScale) - : 1; - const anchorRadius = entry.anchorRadiusPx * anchorScale; - record.placementInput.gap = - anchorRadius + - Math.max(entry.minAnchorGapPx, anchorRadius + entry.anchorGapPaddingPx); - record.placementInput.leaderOffset = anchorRadius; - } else { - record.placementInput.gap = entry.gapPx; - record.placementInput.leaderOffset = entry.leaderOffsetPx; - } - record.placementInput.preferred = entry.placement; - record.placementInput.verticalOnly = entry.verticalOnly; - record.placementInput.viewportMargin = entry.viewportMargin; - placementVariants(record.placementInput, record.placements); - // UI exclusion is a PREFERENCE for chrome that composites ABOVE the host, and - // a HARD VETO for chrome that composites below it. - // - // The soft half exists because deleting an entry whose every variant collides - // is what made cards hop between corners and then fade out near a panel, and - // what left cockpit with nothing. Keeping the placement is safe only when the - // chrome paints over the card anyway. - // - // That justification fails for chrome UNDER the host — `#intel-hud` is z2, - // below both host surfaces — where a kept placement would render ON TOP of the - // HUD text. Labels never cover the UI, so those rects keep the absolute veto. - let softClear = 0; - for (let i = 0; i < record.placements.length; i++) { - const placement = record.placements[i]; - if (overlayRectIntersectsAny(placement.rect, _uiOcclusionRects)) continue; - record.placements[softClear++] = placement; - } - if (softClear === 0) { - // Nothing is clear of everything: fall back to placements that at least - // clear the below-host chrome, and veto the entry if even that is impossible. - let hardClear = 0; - for (let i = 0; i < record.placements.length; i++) { - const placement = record.placements[i]; - if (overlayRectIntersectsAnyHard(placement.rect, _uiOcclusionRects)) - continue; - record.placements[hardClear++] = placement; - } - record.placements.length = hardClear; - } else { - record.placements.length = softClear; - } - if (record.placements.length === 0) return null; - let centerDistance = Number.POSITIVE_INFINITY; - let closestCenterX = 0; - let closestCenterY = 0; - for (let i = 0; i < record.placements.length; i++) { - const placement = record.placements[i]; - // `Math.hypot` is variadic and allocates per call; screen-space offsets - // never approach the overflow range it exists to guard. - const dx = placement.centerX - keyhole.centerX; - const dy = placement.centerY - keyhole.centerY; - const distanceFromCenter = Math.sqrt(dx * dx + dy * dy); - if (distanceFromCenter < centerDistance) { - centerDistance = distanceFromCenter; - closestCenterX = placement.centerX; - closestCenterY = placement.centerY; - } - } - // Shared keyhole alpha is radial and monotonic, so the closest surviving - // placement is exactly the maximum-alpha placement. Evaluate the shared - // helper once, then evaluate the final chosen rectangle again at paint. - record.candidate.keyholeAlpha = - entry.edgeFade === 'keyhole' - ? centerDistance <= keyhole.radius - ? 1 - : keyholeLabelAlphaFromGeometry(closestCenterX, closestCenterY, keyhole) - : 1; - record.candidate.centerDistance = centerDistance; - // Anchor separation is authored in unscaled CSS px; the shipped pass scaled it - // with the card, so a zoomed-out (smaller) card needs proportionally less room. - record.candidate.minAnchorSeparationPx = - entry.minAnchorSeparationPx > 0 - ? entry.minAnchorSeparationPx * record.paintScale - : 0; - record.candidate.screenX = record.screen.x; - record.candidate.screenY = record.screen.y; - return record; -} - -/** - * Project every active cohort into the pooled per-domain candidate buffers. - * Nothing here allocates per painted entry in steady state: records, candidate - * objects, placement objects, and the domain buffers are all reused, and live - * membership is carried by `candidate.frameStamp` instead of a rebuilt map. - */ -function collectFrameCandidates(keyhole, viewProjection) { - resetFrameDomains(); - let candidateCount = 0; - let projectedCount = 0; - for (let s = 0; s < _sourceList.length; s++) { - const source = _sourceList[s]; - if (!sourceActive(source)) continue; - for (let c = 0; c < source.cohortCount; c++) { - const domainId = source.cohortDomainIds[c]; - const cohort = source.cohortLists[c]; - const domain = getOrCreateDomain(domainId); - domain.capacity = - domainId === 'ambient-card' - ? Math.min( - AMBIENT_CARD_COLLISION_CAPACITY, - domain.capacity + source.options.collisionCapacity, - ) - : Math.max(domain.capacity, source.options.collisionCapacity); - domain.moving ||= source.options.moving; - domain.solveIntervalMs = Math.min( - domain.solveIntervalMs, - source.options.solveIntervalMs, - ); - domain.demandBySource.set( - source.id, - source.demandByDomain.get(domainId) || 0, - ); - domain.limitsBySource.set(source.id, source.options.maxVisible); - candidateCount += cohort.length; - for (let i = 0; i < cohort.length; i++) { - const entry = cohort[i]; - // Source-owned stable slots are dereferenced on every frame, before - // the numeric projection path. Ambient CCTV has no pre-frame chrome; - // the shipped pinned-card exception remains immediate feedback. - if (entry.requireImage && !entry.pinned) { - const imageSlot = entry._overlayImageSlot; - if ( - imageSlot - ? !(imageSlot.stamp > 0) || !imageSlot.frame - : !entry.image - ) - continue; - } - const record = snapshotAndProject( - entry, - source, - viewProjection, - keyhole, - ); - if (!record) continue; - // Anchor separation runs HERE, not inside the arbiter. The shipped pass - // filtered the candidate list and then drew it; rejecting inside the - // arbiter's fill loop instead leaves the source's quota permanently - // unfillable, so it rebuilds its spatial queue every solve and blows the - // per-frame allocation budget. Filtering first keeps the quota honest. - // Cohorts arrive in rank order (nearest/highest priority first), which is - // the order the shipped greedy accept used. - const separation = record.candidate.minAnchorSeparationPx; - if (separation > 0 && !isProtected(record.entry)) { - let clear = true; - for (let k = 0; k < domain.sepCount; k++) { - if (domain.sepSource[k] !== source.id) continue; - const dx = record.candidate.screenX - domain.sepX[k]; - const dy = record.candidate.screenY - domain.sepY[k]; - const required = - separation > domain.sepR[k] ? separation : domain.sepR[k]; - if (dx * dx + dy * dy < required * required) { - clear = false; - break; - } - } - if (!clear) continue; - const slot = domain.sepCount++; - domain.sepX[slot] = record.candidate.screenX; - domain.sepY[slot] = record.candidate.screenY; - domain.sepR[slot] = separation; - domain.sepSource[slot] = source.id; - } - projectedCount++; - if (isProtected(record.entry)) { - domain.protectedCandidates[domain.protectedCount++] = - record.candidate; - } else { - record.candidate.frameStamp = _frameStamp; - domain.candidates[domain.candidateCount++] = record.candidate; - domain.candidateMap.set(record.key, record.candidate); - } - } - } - } - - for (let d = 0; d < _domainList.length; d++) { - const domain = _domainList[d]; - sortPooledRange( - domain.protectedCandidates, - domain.protectedCount, - compareProtectedCandidates, - ); - for (let p = 0; p < domain.protectedCount; p++) { - const candidate = domain.protectedCandidates[p]; - let placement = null; - for (let i = 0; i < candidate.placements.length; i++) { - const candidatePlacement = candidate.placements[i]; - if ( - overlayRectIntersectsAny( - candidatePlacement.rect, - domain.protectedRects, - domain.protectedRectCount, - ) - ) - continue; - placement = candidatePlacement; - break; - } - placement ||= leastOverlappingPlacement( - candidate.placements, - domain.protectedRects, - domain.protectedRectCount, - ); - candidate._record.protectedPlacement = placement; - if (placement) - domain.protectedRects[domain.protectedRectCount++] = placement.rect; - } - for (let a = 0; a < domain.candidateCount; a++) { - const candidate = domain.candidates[a]; - let count = 0; - for (let i = 0; i < candidate.placements.length; i++) { - const placement = candidate.placements[i]; - if ( - overlayRectIntersectsAny( - placement.rect, - domain.protectedRects, - domain.protectedRectCount, - ) - ) - continue; - candidate.placements[count++] = placement; - } - candidate.placements.length = count; - if (count === 0) candidate.frameStamp = 0; - } - let count = 0; - for (let i = 0; i < domain.candidateCount; i++) { - if (domain.candidates[i].frameStamp !== _frameStamp) continue; - domain.candidates[count++] = domain.candidates[i]; - } - domain.candidateCount = count; - domain.candidates.length = count; - domain.protectedCandidates.length = domain.protectedCount; - domain.protectedRects.length = domain.protectedRectCount; - if (!Number.isFinite(domain.solveIntervalMs)) - domain.solveIntervalMs = DEFAULT_MOVING_SOLVE_MS; - } - let candidateIndexSize = 0; - for (let d = 0; d < _domainList.length; d++) - candidateIndexSize += _domainList[d].candidateMap.size; - _diagnostics.candidateIndexSize = candidateIndexSize; - _diagnostics.candidateCount = candidateCount; - _diagnostics.projectedCount = projectedCount; -} - -function addPaintItem(record, placement, temporalAlpha, selected) { - if (!record || !placement) return; - const item = - _paintItemPool[_paintCount] || (_paintItemPool[_paintCount] = {}); - item.record = record; - item.placement = placement; - item.temporalAlpha = temporalAlpha; - item.selected = selected; - item.lane = paintLaneForOverlayEntry(record.entry); - item.zIndex = record.entry.zIndex; - item.key = record.key; - item.collisionHidden = false; - _paintQueue[_paintCount++] = item; -} - -function sourceCanPaint(sourceId) { - const source = _sources.get(sourceId); - return !!source && sourceActive(source); -} - -function solveDomains(timestamp) { - _paintCount = 0; - let solveMs = 0; - let selectedCount = 0; - let fadingCount = 0; - let solveRevision = 0; - for (let d = 0; d < _domainList.length; d++) { - const domain = _domainList[d]; - const movingSolveDue = - domain.moving && timestamp - domain.lastSolveAt >= domain.solveIntervalMs; - if (_solveDirty || movingSolveDue) { - const started = nowMs(); - domain.arbiter.solve(domain.candidates, { - capacity: Math.min(domain.capacity, domain.candidates.length), - demandByLayer: domain.demandBySource, - limitsByLayer: domain.limitsBySource, - now: timestamp, - // The host publishes its own pooled counters below, so duplicating a - // per-layer diagnostic object in the arbiter adds no observable data. - collectDiagnostics: false, - }); - solveMs += nowMs() - started; - domain.lastSolveAt = timestamp; - } - for (let p = 0; p < domain.protectedCount; p++) { - const record = domain.protectedCandidates[p]._record; - addPaintItem( - record, - record.protectedPlacement, - record.entry.temporalAlpha, - true, - ); - selectedCount++; - } - const rendered = domain.arbiter.renderEntries( - domain.candidateMap, - timestamp, - domain.renderEntries, - domain.limitsBySource, - ); - for (let r = 0; r < rendered.length; r++) { - const renderedEntry = rendered[r]; - const candidate = renderedEntry.candidate; - const record = candidate?._record; - if (!record || !sourceCanPaint(record.entry.source)) continue; - if (renderedEntry.selected && candidate.frameStamp !== _frameStamp) - continue; - // No second UI veto here. The placement already went through the - // exclusion preference at projection; re-testing it meant that a panel - // expanding over a settled card evicted the card outright — it popped - // out and then had to fade back in — instead of simply sliding beneath - // the chrome that composites above this host anyway. - addPaintItem( - record, - renderedEntry.placement, - renderedEntry.temporalAlpha * record.entry.temporalAlpha, - renderedEntry.selected, - ); - if (renderedEntry.selected) selectedCount++; - else fadingCount++; - } - solveRevision += domain.arbiter.solveRevision; - } - _paintQueue.length = _paintCount; - _solveDirty = false; - _diagnostics.solveMs = solveMs; - _diagnostics.selectedCount = selectedCount; - _diagnostics.fadingCount = fadingCount; - _diagnostics.solveRevision = solveRevision; -} - -function publishPaintRect(item) { - const { record, placement } = item; - const rect = - _paintRectPool[_paintRectCount] || (_paintRectPool[_paintRectCount] = {}); - _paintRectCount++; - rect.anchorX = placement.anchorX; - rect.anchorY = placement.anchorY; - rect.x = placement.rect.x; - rect.y = placement.rect.y; - rect.w = placement.rect.w; - rect.h = placement.rect.h; - rect.sourceId = record.entry.source; - rect.entryId = record.entry.id; - rect.entry = record.entry; - rect.key = record.key; - rect.stamp = _frameStamp; - _paintRectByKey.set(record.key, rect); - if (record.entry.interactive) _hitRects[_hitRectCount++] = rect; -} - -/** Publish the action list to every subscriber. */ -function presentActions(next) { - _actions = next && Object.freeze(next); - for (const listener of _actionsListeners) listener(_actions); -} - -/** - * Receive the visible map target list now and after every change. - * @param {(view: ?object) => void} listener - * @returns {() => void} Unsubscribe. - */ -export function subscribeWorldOverlayActions(listener) { - _actionsListeners.add(listener); - listener(_actions); - return () => _actionsListeners.delete(listener); -} - -/** - * Focus a listed target, as its button does, announcing an accepted focus. - * @param {object} owner The `owner` of the list the button belongs to. - * @param {string} key The item's key. - */ -export function activateWorldOverlayAction(owner, key) { - if (!_actions || owner !== _actions.owner) return; - const item = _actions.items.find((candidate) => candidate.key === key); - const activate = _accessibleActivatorByKey.get(key); - if (!item || !activate) return; - const accepted = activate(); - if (accepted !== false && _actions?.owner === owner) { - presentActions({ - ..._actions, - status: t('Focusing {label}', { label: item.label }), - announced: _actions.announced + 1, - }); - } -} - -/** Keep a stable, bounded accessible mirror of currently painted actions. */ -function syncAccessibleActions() { - if (!_actions) return; - _accessibleActivatorByKey.clear(); - const items = []; - for (let i = _hitRectCount - 1; i >= 0; i--) { - const rect = _hitRects[i]; - const entry = rect.entry; - if (!entry?.accessibilityLabel || typeof entry.activate !== 'function') - continue; - if (_accessibleActivatorByKey.has(rect.key)) continue; - _accessibleActivatorByKey.set(rect.key, entry.activate); - items.push({ - key: rect.key, - label: entry.accessibilityLabel, - selected: entry.selected === true, - }); - } - const signature = items - .map( - (item) => - `${item.key}\u0000${item.label}\u0000${item.selected ? '1' : '0'}`, - ) - .join('\u0001'); - if (signature === _accessibilitySignature) return; - _accessibilitySignature = signature; - presentActions({ ..._actions, items }); -} - -let _detectionSurfacePrepared = false; - -function paintCustomLane(lane) { - for (let i = 0; i < _customPaintLaneList.length; i++) { - const record = _customPaintLaneList[i]; - if (!record.active || record.lane !== lane) continue; - const detectionTarget = record.target === PAINT_TARGET_DETECTION; - const surface = detectionTarget ? _detectionSurface : _canvas; - const ctx = detectionTarget ? _detectionCtx : _ctx; - if (!surface || !ctx) continue; - _customPaintFrame.surface = surface; - _customPaintFrame.ctx = ctx; - if (record.shouldPaint && record.shouldPaint(_customPaintFrame) === false) - continue; - if (detectionTarget && !_detectionSurfacePrepared) { - clearCanvas(false, true); - _detectionSurfacePrepared = true; - } - ctx.save(); - try { - // No UI clip. Detection paints edge to edge exactly as it shipped: it - // consumes `frame.uiRects` itself to keep its CALLOUT CARDS off solid - // chrome, while brackets, labels, the focus ring, the banner and the - // scanline wash cover the whole field. Clipping this surface punched - // hard-edged rectangular voids through the detection field, and in - // cockpit — where the exclusions coalesced to nearly the full viewport — - // blanked Panoptic entirely while it was still solving and painting. - record.painter(_customPaintFrame); - if (detectionTarget) _detectionSurfaceNeedsClear = true; - } finally { - ctx.restore(); - } - } -} - -function paintEntryItem(item, keyhole, sonar) { - if (item.collisionHidden) return; - const { record, placement } = item; - const entry = record.entry; - let keyholeAlpha = 1; - if (entry.edgeFade === 'keyhole') { - const keyholeX = placement.centerX - keyhole.centerX; - const keyholeY = placement.centerY - keyhole.centerY; - keyholeAlpha = - keyholeX * keyholeX + keyholeY * keyholeY <= - keyhole.radius * keyhole.radius - ? 1 - : keyholeLabelAlphaFromGeometry( - placement.centerX, - placement.centerY, - keyhole, - ); - } - // All five channels are normalized at their source. Keep the multiply on - // the hot paint path so its intermediate doubles remain unboxed; the pure - // `combinedOverlayAlpha` export still specifies/tests the same binding. - const finalAlpha = - record.sourceAlpha * - item.temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha * - keyholeAlpha * - (sonar ? sonar.label(sonar.at(placement.anchorX, placement.anchorY)) : 1); - if (finalAlpha <= 0.001) return; - if (record.paintScale === 1) { - paintOverlayEntry( - _ctx, - entry, - placement, - finalAlpha, - record.leaderProgress, - record.contentAlpha, - ); - } else { - const scaled = localizeScaledPlacement( - placement, - record.paintScale, - record.scaledPaintPlacement, - ); - _ctx.save(); - _ctx.translate(placement.rect.x, placement.rect.y); - _ctx.scale(record.paintScale, record.paintScale); - paintOverlayEntry( - _ctx, - entry, - scaled, - finalAlpha, - record.leaderProgress, - record.contentAlpha, - ); - _ctx.restore(); - } - publishPaintRect(item); - if (_paintedBySource[entry.source] === undefined) { - _paintedBySource[entry.source] = 0; - _paintedSourceKeys.push(entry.source); - } - _paintedBySource[entry.source]++; -} - -function compareYieldingPaintItems(a, b) { - const aYields = !!a.record.entry.yieldToCollisionGroup; - const bYields = !!b.record.entry.yieldToCollisionGroup; - return ( - Number(aYields) - Number(bYields) || - b.record.entry.priority - a.record.entry.priority || - compareStableKeys(a.key, b.key) - ); -} - -/** Recheck yielding content every painted frame, even between camera solves. - * The arbiter's sticky/exit placements are intentionally retained for cards; - * labels can only use the remaining rectangles. All buffers are reused. */ -function declutterYieldingEntries() { - let hasYielding = false; - for (let i = 0; i < _paintCount; i++) - if (_paintQueue[i].record.entry.yieldToCollisionGroup) { - hasYielding = true; - break; - } - if (!hasYielding) return; - sortPooledRange(_paintQueue, _paintCount, compareYieldingPaintItems); - for (let i = 0; i < _paintCount; i++) { - const item = _paintQueue[i]; - const entry = item.record.entry; - if (!entry.yieldToCollisionGroup) continue; - const placements = item.record.placements; - let accepted = null; - // Try the arbiter's chosen corner first, then its other valid placements. - for (let p = -1; p < placements.length; p++) { - const placement = p < 0 ? item.placement : placements[p]; - if (p >= 0 && placement === item.placement) continue; - let blocked = false; - for (let j = 0; j < i; j++) { - const other = _paintQueue[j]; - if (other.collisionHidden) continue; - const group = other.record.entry.collisionGroup; - if ( - (group === entry.collisionGroup || - group === entry.yieldToCollisionGroup) && - overlayRectsOverlap(placement.rect, other.placement.rect) - ) { - blocked = true; - break; - } - } - if (!blocked) { - accepted = placement; - break; - } - } - if (accepted) item.placement = accepted; - else item.collisionHidden = true; - } -} - -function syncPaintVisibility(painted = true) { - for (let s = 0; s < _sourceList.length; s++) { - const source = _sourceList[s]; - const callback = source.options.onVisibilityChange; - if (!callback) continue; - const count = painted ? _paintedBySource[source.id] || 0 : 0; - let changed = count !== source.visibleIds.size; - if (!changed && painted) { - for (const id of source.visibleIds) { - if ( - _paintRectByKey.get(entryKey(source.id, id))?.stamp !== _frameStamp - ) { - changed = true; - break; - } - } - } - if (!changed) continue; - source.visibleIds.clear(); - if (painted) - for (let i = 0; i < _paintRectCount; i++) { - const rect = _paintRectPool[i]; - if (rect.sourceId === source.id) source.visibleIds.add(rect.entryId); - } - callback(source.visibleIds); - } -} - -function paintFrame(keyhole) { - const started = nowMs(); - const sonar = isCyberSonarActive() - ? createCyberSonarSampler(_canvasWidth, _canvasHeight, started) - : null; - clearCanvas(true, false); - _detectionSurfacePrepared = false; - if ( - activeCustomPaintLaneCount(PAINT_TARGET_DETECTION) === 0 && - _detectionSurfaceNeedsClear - ) - clearCanvas(false, true); - _paintRectCount = 0; - _hitRectCount = 0; - declutterYieldingEntries(); - sortPooledRange(_paintQueue, _paintCount, comparePaintItems); - // The shared canvas is not clipped either. Cards already avoid solid chrome - // by placement, and every occluder composites above this host in the DOM, so - // the clip could only ever carve voids out of legitimate world content. - _ctx.save(); - let itemIndex = 0; - for (let lane = 0; lane < WORLD_OVERLAY_PAINT_LANES.length; lane++) { - // Source-owned painters run first inside their lane. Detection therefore - // retains its former z5 position below every ordinary host entry at z6. - paintCustomLane(lane); - while (itemIndex < _paintCount && _paintQueue[itemIndex].lane === lane) { - paintEntryItem(_paintQueue[itemIndex], keyhole, sonar); - itemIndex++; - } - } - _ctx.restore(); - _diagnostics.paintedCount = _paintRectCount; - _diagnostics.hitRectCount = _hitRectCount; - _diagnostics.paintItemPoolSize = _paintItemPool.length; - _diagnostics.paintRectPoolSize = _paintRectPool.length; - _diagnostics.paintMs = nowMs() - started; - syncAccessibleActions(); - syncPaintVisibility(); - _canvasNeedsClear = - _paintRectCount > 0 || activeCustomPaintLaneCount(PAINT_TARGET_SHARED) > 0; -} - -function localizeScaledPlacement(placement, scale, out) { - const x = placement.rect.x; - const y = placement.rect.y; - out.corner = placement.corner; - out.rect.x = 0; - out.rect.y = 0; - out.rect.w = placement.rect.w / scale; - out.rect.h = placement.rect.h / scale; - out.centerX = (placement.centerX - x) / scale; - out.centerY = (placement.centerY - y) / scale; - out.anchorX = (placement.anchorX - x) / scale; - out.anchorY = (placement.anchorY - y) / scale; - out.leadFromX = (placement.leadFromX - x) / scale; - out.leadFromY = (placement.leadFromY - y) / scale; - out.paintScale = scale; - out.leaderOffset = placement.leaderOffset; - if (out.leaderOffset !== 0) out.leaderOffset /= scale; - out.leadToX = (placement.leadToX - x) / scale; - out.leadToY = (placement.leadToY - y) / scale; - return out; -} - -function resetFrameDiagnostics() { - _diagnostics.candidateCount = 0; - _diagnostics.projectedCount = 0; - _diagnostics.selectedCount = 0; - _diagnostics.fadingCount = 0; - _diagnostics.paintedCount = 0; - _diagnostics.hitRectCount = 0; - _diagnostics.projectionMs = 0; - _diagnostics.solveMs = 0; - _diagnostics.paintMs = 0; - for (let i = 0; i < _paintedSourceKeys.length; i++) { - _paintedBySource[_paintedSourceKeys[i]] = 0; - } -} - -function drawWorldOverlay() { - if (_sonarRenderTimer !== null) { - clearTimeout(_sonarRenderTimer); - _sonarRenderTimer = null; - } - if (_destroyed || !_engine || !_canvas || !_ctx) return; - const timestamp = nowMs(); - if (!overlayHasPaintWork(timestamp)) { - resetFrameDiagnostics(); - _solveDirty = false; - if (_canvasNeedsClear || _detectionSurfaceNeedsClear) clearCanvas(); - if (_accessibilitySignature) { - _hitRectCount = 0; - syncAccessibleActions(); - } - syncPaintVisibility(false); - return; - } - if (_resizeDirty) ensureCanvasSize(); - if (_canvasWidth <= 0 || _canvasHeight <= 0) return; - _frameStamp++; - refreshUiOccluders( - timestamp, - _occludersUpdatedAt === Number.NEGATIVE_INFINITY, - ); - resetFrameDiagnostics(); - const projectionStarted = nowMs(); - const fadeTuning = getKeyholeFadeTuning(); - if ( - !_keyhole || - _keyholeWidth !== _canvasWidth || - _keyholeHeight !== _canvasHeight || - _keyholeFadeRatio !== fadeTuning.fadeRatio || - _keyholeOutsideOpacity !== fadeTuning.outsideOpacity - ) { - _keyhole = getKeyholeGeometry(_canvasWidth, _canvasHeight); - _keyholeWidth = _canvasWidth; - _keyholeHeight = _canvasHeight; - _keyholeFadeRatio = fadeTuning.fadeRatio; - _keyholeOutsideOpacity = fadeTuning.outsideOpacity; - } - const keyhole = _keyhole; - const viewProjection = prepareCustomPaintFrame(timestamp, keyhole); - collectFrameCandidates(keyhole, viewProjection); - _diagnostics.projectionMs = nowMs() - projectionStarted; - solveDomains(timestamp); - paintFrame(keyhole); - // Labels-only layers need a bounded sweep refresh even with detection off. - // Empty/hidden hosts and teardown never retain scanner render demand. - if (_paintRectCount > 0 && isCyberSonarActive()) { - _sonarRenderTimer = setTimeout(() => { - _sonarRenderTimer = null; - if (!_destroyed && isCyberSonarActive()) requestRender(); - }, CYBER_SONAR_RENDER_INTERVAL_MS); - } - const animationsRemaining = - activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp); - if (animationsRemaining > 0) requestRender(); -} - -function createDevFacade() { - if (typeof window === 'undefined' || import.meta.env?.DEV !== true) return; - window.__gevWorldOverlay = { - getDiagnostics: getWorldOverlayDiagnostics, - getPaintRect: getOverlayPaintRect, + const _uiOcclusionRects = []; + const _uiOcclusionRectPool = []; + /** Shared, allocation-free frame contract passed to custom paint lanes. */ + const _customPaintFrame = { + canvas: null, + surface: null, + ctx: null, + width: 0, + height: 0, + dpr: 1, + timestamp: 0, + viewProjectionMatrix: _scratchViewProjection, + viewProjection: _viewProjectionScalars, + cameraPosition: null, + occluder: null, + keyhole: null, + uiRects: _uiOcclusionRects, + uiRectCount: 0, + layoutRevision: 0, }; -} + const _paintQueue = []; + const _paintItemPool = []; + const _paintRectPool = []; + const _paintRectByKey = new Map(); + const _hitRects = []; + const _paintedBySource = Object.create(null); + const _paintedSourceKeys = []; + // Live lengths of the pooled frame buffers. Pool arrays are never truncated + // per frame, so their backing stores survive instead of being re-grown. + let _paintCount = 0; + let _paintRectCount = 0; + let _hitRectCount = 0; -/** - * Initialize the singleton host. Repeated calls for the same engine are no-op. - * @param {import('../globe/contract.js').GlobeEngine} engine - */ -export function initWorldOverlay(engine) { - // What the host reads of the engine: a Cesium viewer has none of it. - if (!engine?.projector || !engine.frames?.postRender || !engine.host) - throw new TypeError('initWorldOverlay requires a globe engine'); - if (_engine === engine && _canvas && _removePostRender) return; - if (_engine) destroyWorldOverlay(); - _destroyed = false; - _engine = engine; - ensureOverlayDom(); - const { projector } = engine; - _occluder = { - isPointVisible: (position) => projector.horizonVisible(position), - }; - _cockpitActive = !!document.body?.classList?.contains('cockpit-mode'); - _cockpitModeHandler = (event) => { - _cockpitActive = event?.detail?.active === true; - invalidateHost(); - }; - window.addEventListener('gev:cockpit-mode-changed', _cockpitModeHandler); - _removePostRender = engine.frames.postRender.addEventListener(() => - drawWorldOverlay(), - ); - _removeMoveEnd = engine.camera.moveEnd.addEventListener(() => - invalidateHost(), - ); - installUiOccluderObservers(); - // The backing store and the occluder inventory are both deferred to the - // first frame with real paint work: a dormant host must not hold a - // full-viewport canvas (~19 MB at DPR 2) or scan the UI for exclusions. - _resizeDirty = true; - _occludersDirty = true; - _solveDirty = true; - _canvasNeedsClear = true; - _detectionSurfaceNeedsClear = true; - updateEntryDiagnostics(); - createDevFacade(); -} - -/** Remove all listeners, observers, entries, diagnostics, and overlay DOM. */ -export function destroyWorldOverlay() { - if (_sonarRenderTimer !== null) clearTimeout(_sonarRenderTimer); - _sonarRenderTimer = null; - // Tearing down a host that was never initialized must not arm the - // post-destroy guard: sources are allowed to publish before the first - // `initWorldOverlay`, and that buffering has to survive a stray destroy. - const hadHost = _engine !== null; - _removePostRender?.(); - _removePostRender = null; - _removeMoveEnd?.(); - _removeMoveEnd = null; - if (_cockpitModeHandler && typeof window !== 'undefined') { - window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeHandler); - } - _cockpitModeHandler = null; - if (_windowResizeHandler && typeof window !== 'undefined') { - window.removeEventListener('resize', _windowResizeHandler); - } - _windowResizeHandler = null; - _resizeObserver?.disconnect?.(); - _resizeObserver = null; - _observedOccluderElements = new WeakSet(); - _mutationObserver?.disconnect?.(); - _mutationObserver = null; - destroyWorldOverlayDraw(); - if (_occluderRefreshTimer != null) clearTimeout(_occluderRefreshTimer); - _occluderRefreshTimer = null; - _root?.remove?.(); - // The detection surface is not a child of `_root` (it lives in the Cesium - // container so its `screen` blend reaches the scene), so it has to be torn - // down explicitly rather than by the root's removal. - _detectionSurface?.remove?.(); - if ( - typeof window !== 'undefined' && - window.__gevWorldOverlay?.getDiagnostics === getWorldOverlayDiagnostics - ) { - delete window.__gevWorldOverlay; - } - syncPaintVisibility(false); - _sources.clear(); - _sourceList.length = 0; - for (let i = 0; i < _customPaintLaneList.length; i++) { - _customPaintLaneList[i].active = false; - _customPaintLaneList[i].painter = null; - _customPaintLaneList[i].shouldPaint = null; - } - _customPaintLanes.clear(); - _customPaintLaneList.length = 0; - _domains.clear(); - _domainList.length = 0; - _records.clear(); - for (let i = 0; i < _paintItemPool.length; i++) { - const item = _paintItemPool[i]; - item.record = null; - item.placement = null; - } - for (let i = 0; i < _paintRectPool.length; i++) { - _paintRectPool[i].entry = null; - } - _paintQueue.length = 0; - _paintItemPool.length = 0; - _paintRectPool.length = 0; - _hitRects.length = 0; - _accessibleActivatorByKey.clear(); - _accessibilitySignature = ''; - _paintRectByKey.clear(); - _paintCount = 0; - _paintRectCount = 0; - _hitRectCount = 0; - _uiOcclusionRects.length = 0; - _uiOcclusionRectPool.length = 0; - _destroyed = hadHost || _destroyed; - _engine = null; - _root = null; - _canvas = null; - _ctx = null; - if (_actions) presentActions(null); - _detectionSurface = null; - _detectionCtx = null; - _occluder = null; - _cameraPosition = null; - _cameraHeight = Number.NaN; - _keyhole = null; - _keyholeWidth = -1; - _keyholeHeight = -1; - _keyholeFadeRatio = Number.NaN; - _keyholeOutsideOpacity = Number.NaN; - _cockpitActive = false; - _resizeDirty = true; - _occludersDirty = true; - _solveDirty = true; - _canvasNeedsClear = true; - _detectionSurfaceNeedsClear = true; - _detectionSurfacePrepared = false; - _occludersUpdatedAt = Number.NEGATIVE_INFINITY; - _canvasWidth = 0; - _canvasHeight = 0; - _viewport.width = 0; - _viewport.height = 0; - _canvasDpr = 1; - _layoutRevision = 0; - _customPaintFrame.canvas = null; - _customPaintFrame.surface = null; - _customPaintFrame.ctx = null; - _customPaintFrame.width = 0; - _customPaintFrame.height = 0; - _customPaintFrame.dpr = 1; - _customPaintFrame.timestamp = 0; - _customPaintFrame.cameraPosition = null; - _customPaintFrame.occluder = null; - _customPaintFrame.keyhole = null; - _customPaintFrame.uiRectCount = 0; - _customPaintFrame.layoutRevision = 0; - for (let i = 0; i < _paintedSourceKeys.length; i++) { - delete _paintedBySource[_paintedSourceKeys[i]]; - } - _paintedSourceKeys.length = 0; - Object.assign(_diagnostics, { + const _diagnostics = { sourceCount: 0, entryCount: 0, candidateCount: 0, @@ -2899,5 +777,2246 @@ export function destroyWorldOverlay() { candidateIndexSize: 0, entriesBySource: {}, paintedBySource: _paintedBySource, + }; + + function sourceActive(source) { + return ( + source.options.visible && + !(_cockpitActive && source.options.hideInCockpit) + ); + } + + function getOrCreateDomain(domainId) { + let domain = _domains.get(domainId); + if (!domain) { + domain = { + id: domainId, + arbiter: new LabelArbiter(), + candidates: [], + candidateCount: 0, + candidateMap: new Map(), + protectedCandidates: [], + protectedCount: 0, + protectedRects: [], + protectedRectCount: 0, + // Pooled accepted-anchor ring for opt-in anchor separation. Parallel + // arrays instead of objects so the pass allocates nothing per frame. + sepX: [], + sepY: [], + sepR: [], + sepSource: [], + sepCount: 0, + renderEntries: [], + demandBySource: new Map(), + limitsBySource: new Map(), + capacity: DEFAULT_COLLISION_CAPACITY, + lastSolveAt: Number.NEGATIVE_INFINITY, + moving: false, + solveIntervalMs: DEFAULT_MOVING_SOLVE_MS, + }; + _domains.set(domainId, domain); + _domainList.push(domain); + } + return domain; + } + + function rebuildSourceCohorts(source) { + const groups = new Map(); + source.demandByDomain.clear(); + for (const entry of source.entries.values()) { + if (!groups.has(entry.collisionGroup)) + groups.set(entry.collisionGroup, []); + groups.get(entry.collisionGroup).push(entry); + if (!isProtected(entry)) { + source.demandByDomain.set( + entry.collisionGroup, + (source.demandByDomain.get(entry.collisionGroup) || 0) + 1, + ); + } + } + let index = 0; + for (const [domainId, entries] of groups) { + const incumbentKeys = getOrCreateDomain(domainId).arbiter.selectedKeys; + source.cohortDomainIds[index] = domainId; + source.cohortLists[index] = selectBoundedOverlayCohort( + entries, + source.options.cohortLimit, + incumbentKeys, + ); + index++; + } + source.cohortCount = index; + source.cohortDomainIds.length = index; + source.cohortLists.length = index; + } + + function getOrCreateSource(sourceId, options = {}) { + const id = assertSourceId(sourceId); + let source = _sources.get(id); + if (!source) { + source = { + id, + entries: new Map(), + cohortDomainIds: [], + cohortLists: [], + cohortCount: 0, + demandByDomain: new Map(), + visibleIds: new Set(), + options: normalizeSourceOptions(options), + }; + _sources.set(id, source); + _sourceList.push(source); + } else if (options && Object.keys(options).length > 0) { + source.options = normalizeSourceOptions(options, source.options); + } + return source; + } + + function invalidateHost({ solve = true, layout = false } = {}) { + if (solve) _solveDirty = true; + if (layout) _occludersDirty = true; + _canvasNeedsClear = true; + _paintRectCount = 0; + _hitRectCount = 0; + _paintRectByKey.clear(); + requestRender(); + } + + function requestRender() { + if (_canvas) _engine?.render.request('world-overlay'); + } + + function inertPaintLaneHandle() { + return { + surface: null, + setActive() {}, + requestPaint() {}, + unregister() {}, + }; + } + + /** + * Register a source-owned painter behind one deterministic host lane. The + * callback receives the host's already-sized/clipped Canvas2D target plus the + * frame's shared matrix, keyhole, occluder, and UI rectangles. It must not + * clear or resize the target. The detection target is a host-owned sibling + * surface used only to preserve element-level scene blending. + * @param {string} laneId One of `WORLD_OVERLAY_PAINT_LANES`. + * @param {function(object):void} painter Source-owned paint callback. + * @param {{id?:string,active?:boolean,target?:'shared'|'detection',shouldPaint?:function(object):boolean}} [options] + * @returns {{surface:HTMLCanvasElement|null,setActive:function(boolean):void,requestPaint:function():void,unregister:function():void}} + */ + function registerWorldOverlayPaintLane(laneId, painter, options = {}) { + if (_destroyed) return inertPaintLaneHandle(); + if (!PAINT_LANE_INDEX.has(laneId)) { + throw new TypeError(`Unsupported WorldOverlay paint lane: ${laneId}`); + } + if (typeof painter !== 'function') { + throw new TypeError( + 'WorldOverlay custom paint lane requires a painter callback', + ); + } + const id = assertSourceId(options.id || laneId); + const previous = _customPaintLanes.get(id); + if (previous) { + const index = _customPaintLaneList.indexOf(previous); + if (index >= 0) _customPaintLaneList.splice(index, 1); + } + const record = { + id, + lane: PAINT_LANE_INDEX.get(laneId), + painter, + active: options.active === true, + target: + options.target === PAINT_TARGET_DETECTION + ? PAINT_TARGET_DETECTION + : PAINT_TARGET_SHARED, + shouldPaint: + typeof options.shouldPaint === 'function' ? options.shouldPaint : null, + }; + _customPaintLanes.set(id, record); + _customPaintLaneList.push(record); + invalidateHost({ solve: false }); + + const unregister = () => { + if (_customPaintLanes.get(id) !== record) return; + _customPaintLanes.delete(id); + const index = _customPaintLaneList.indexOf(record); + if (index >= 0) _customPaintLaneList.splice(index, 1); + record.active = false; + invalidateHost({ solve: false }); + }; + return { + get surface() { + return record.target === PAINT_TARGET_DETECTION + ? _detectionSurface + : _canvas; + }, + setActive(active) { + if (_destroyed || _customPaintLanes.get(id) !== record) return; + const next = active === true; + if (record.active === next) return; + record.active = next; + invalidateHost({ solve: false }); + }, + requestPaint() { + if (_destroyed || _customPaintLanes.get(id) !== record) return; + invalidateHost({ solve: false }); + }, + unregister, + }; + } + + // Removed entries cannot paint an exit fade: their source no longer owns + // them. Release the arbiter identity too, so later layout changes cannot + // wake the idle renderer for an invisible minimum-lifetime/fade state. + function forgetOverlayEntry(entry) { + _records.delete(entry._overlayKey); + _domains.get(entry.collisionGroup)?.arbiter.remove(entry._overlayKey); + } + + /** + * Replace all entries owned by a source atomically. + * @param {string} sourceId + * @param {object[]} entries + * @param {WorldOverlaySourceOptions} [options] + */ + function setOverlayEntries(sourceId, entries, options = {}) { + if (_destroyed) return; + if (!Array.isArray(entries)) + throw new TypeError('WorldOverlay entries must be an array'); + const source = getOrCreateSource(sourceId, options); + const normalized = entries.map((entry) => + normalizeOverlayEntry(source.id, entry), + ); + const next = new Map(); + for (const entry of normalized) next.set(entry.id, entry); + for (const entry of source.entries.values()) { + if (!next.has(entry.id)) forgetOverlayEntry(entry); + const replacement = next.get(entry.id); + if (replacement && isProtected(entry) !== isProtected(replacement)) + getOrCreateDomain(entry.collisionGroup).arbiter.remove( + entry._overlayKey, + ); + } + source.entries = next; + rebuildSourceCohorts(source); + updateEntryDiagnostics(); + invalidateHost(); + } + + /** + * Insert or replace one entry without rebuilding another source. + * @param {string} sourceId + * @param {object} entry + */ + function upsertOverlayEntry(sourceId, entry) { + if (_destroyed) return; + const source = getOrCreateSource(sourceId); + const normalized = normalizeOverlayEntry(source.id, entry); + source.entries.set(normalized.id, normalized); + rebuildSourceCohorts(source); + updateEntryDiagnostics(); + invalidateHost(); + } + + /** + * Remove one entry by stable identity. + * @param {string} sourceId + * @param {string} entryId + * @returns {boolean} + */ + function removeOverlayEntry(sourceId, entryId) { + if (_destroyed) return false; + const source = _sources.get(assertSourceId(sourceId)); + if (!source) return false; + const id = String(entryId); + const entry = source.entries.get(id); + if (!entry) return false; + source.entries.delete(id); + forgetOverlayEntry(entry); + rebuildSourceCohorts(source); + updateEntryDiagnostics(); + invalidateHost(); + return true; + } + + /** + * Remove all entries while retaining the source's registration/options. + * @param {string} sourceId + * @returns {boolean} + */ + function clearOverlaySource(sourceId) { + if (_destroyed) return false; + const source = _sources.get(assertSourceId(sourceId)); + if (!source) return false; + for (const entry of source.entries.values()) forgetOverlayEntry(entry); + source.entries.clear(); + source.cohortDomainIds.length = 0; + source.cohortLists.length = 0; + source.cohortCount = 0; + source.demandByDomain.clear(); + updateEntryDiagnostics(); + invalidateHost(); + return true; + } + + /** + * Enable/disable one registered source. + * @param {string} sourceId + * @param {boolean} visible + */ + function setOverlaySourceVisible(sourceId, visible) { + if (_destroyed) return; + const source = getOrCreateSource(sourceId); + const next = visible !== false; + if (source.options.visible === next) return; + source.options.visible = next; + invalidateHost(); + } + + function updateEntryDiagnostics() { + const entriesBySource = {}; + let entryCount = 0; + for (const [sourceId, source] of _sources) { + entriesBySource[sourceId] = source.entries.size; + entryCount += source.entries.size; + } + _diagnostics.sourceCount = _sources.size; + _diagnostics.entryCount = entryCount; + _diagnostics.entriesBySource = entriesBySource; + } + + /** + * Return the most recently published painted box (valid until next frame). + * @param {string} sourceId + * @param {string} entryId + * @returns {(OverlayRect & {sourceId:string,entryId:string})|null} + */ + function getOverlayPaintRect(sourceId, entryId) { + if (_destroyed) return null; + const key = entryKey(String(sourceId), String(entryId)); + const rect = _paintRectByKey.get(key); + // Pooled rectangles outlive their key, so identity and the publishing frame + // are both re-checked instead of clearing the index every frame. + return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null; + } + + /** + * Resolve the topmost interactive world-overlay entry at CSS-pixel coords. + * @param {number} x + * @param {number} y + * @param {{sourceId?:string,collisionGroup?:string,filter?:Function}} [options] + * @returns {{sourceId:string,entryId:string,entry:WorldOverlayEntry,rect:OverlayRect}|null} + */ + function hitTestWorldOverlay(x, y, options = {}) { + if (_destroyed || !Number.isFinite(x) || !Number.isFinite(y)) return null; + for (let i = _hitRectCount - 1; i >= 0; i--) { + const hit = _hitRects[i]; + if (options.sourceId && hit.sourceId !== options.sourceId) continue; + if ( + options.collisionGroup && + hit.entry.collisionGroup !== options.collisionGroup + ) + continue; + if (typeof options.filter === 'function' && !options.filter(hit.entry)) + continue; + if (x < hit.x || x > hit.x + hit.w || y < hit.y || y > hit.y + hit.h) + continue; + return { + sourceId: hit.sourceId, + entryId: hit.entryId, + entry: hit.entry, + rect: hit, + }; + } + return null; + } + + /** @returns {object} Copy of the stable diagnostic facade shape. */ + function getWorldOverlayDiagnostics() { + const paintedBySource = {}; + for (let i = 0; i < _paintedSourceKeys.length; i++) { + const sourceId = _paintedSourceKeys[i]; + const count = _paintedBySource[sourceId]; + if (count > 0) paintedBySource[sourceId] = count; + } + return { + ..._diagnostics, + entriesBySource: { ..._diagnostics.entriesBySource }, + paintedBySource, + }; + } + + /** + * True when a candidate survives the explicit horizon/viewport policies. + * The horizon test prefers `entry.cullPosition` when the source supplied one + * (see normalizeOverlayEntry) so a sub-ellipsoid render anchor is not + * false-hidden near the limb; everything else still uses the render position. + */ + /** + * Return true when a rectangle intersects any cached UI exclusion box. + * `count` lets pooled buffers expose a live prefix without truncating. + */ + function overlayRectIntersectsAny( + rect, + exclusions, + count = exclusions.length, + ) { + for (let i = 0; i < count; i++) { + if (overlayRectsOverlap(rect, exclusions[i])) return true; + } + return false; + } + + function overlayRectsOverlap(a, b) { + return ( + a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + ); + } + + /** + * Like `overlayRectIntersectsAny`, but only against exclusions that composite + * BELOW the host — the ones a placement may never overlap at any cost. + */ + function overlayRectIntersectsAnyHard( + rect, + exclusions, + count = exclusions.length, + ) { + for (let i = 0; i < count; i++) { + const other = exclusions[i]; + if (other.hard && overlayRectsOverlap(rect, other)) return true; + } + return false; + } + + function leastOverlappingPlacement(placements, exclusions, count) { + let best = null; + let bestArea = Number.POSITIVE_INFINITY; + for (let i = 0; i < placements.length; i++) { + const placement = placements[i]; + const rect = placement?.rect; + if (!rect) continue; + let overlapArea = 0; + for (let j = 0; j < count; j++) { + const other = exclusions[j]; + const overlapW = + Math.min(rect.x + rect.w, other.x + other.w) - + Math.max(rect.x, other.x); + const overlapH = + Math.min(rect.y + rect.h, other.y + other.h) - + Math.max(rect.y, other.y); + if (overlapW > 0 && overlapH > 0) overlapArea += overlapW * overlapH; + } + if (overlapArea < bestArea) { + best = placement; + bestArea = overlapArea; + } + } + return best; + } + + function ensureOverlayDom() { + const container = _engine.host.container; + // The page's selectors (#world-overlay-canvas and the rest) name the + // newest host: it takes each id from the host it replaces, and gives it + // back to a remaining host when destroyed (releaseIds). + const make = (tag, name) => { + const node = document.createElement(tag); + node.className = name; + const prior = document.getElementById(name); + if (prior) prior.id = ''; + node.id = name; + return node; + }; + _root = make('div', ROOT_ID); + container.appendChild(_root); + _canvas = make('canvas', CANVAS_ID); + _root.appendChild(_canvas); + // The detection surface is parented to the Cesium container, NOT to + // `#world-overlay-root`. Detection paints with `mix-blend-mode: screen`, + // which only reaches the WebGL scene while no ancestor between the surface + // and the Cesium canvas forms a stacking context (an isolated blending + // group). `#cesiumContainer` is `position:absolute; z-index:auto` and does + // not; `#world-overlay-root` is `z-index:6` and does — parenting here made + // the browser silently discard the blend while the CSS string stayed + // `'screen'`. Paint order is expressed purely by z-index: this surface is + // z5, the shared card canvas inside the root is z6. + const detectionParent = _engine?.host.container || document.body; + _detectionSurface = make('canvas', DETECTION_SURFACE_ID); + detectionParent.appendChild(_detectionSurface); + _root.setAttribute('aria-hidden', 'true'); + _canvas.setAttribute('aria-hidden', 'true'); + _detectionSurface.setAttribute('aria-hidden', 'true'); + // Each host owns its action list; a click from an earlier host's list is + // ignored. + if (!_actions) + presentActions({ owner: {}, items: [], status: '', announced: 0 }); + _ctx = _canvas.getContext('2d', { alpha: true, desynchronized: true }); + _detectionCtx = _detectionSurface.getContext('2d', { + alpha: true, + desynchronized: true, + }); + } + + function sizeCanvasSurface(canvas, ctx, width, height, dpr) { + if (!canvas) return false; + const backingWidth = Math.round(width * dpr); + const backingHeight = Math.round(height * dpr); + const changed = + canvas.width !== backingWidth || canvas.height !== backingHeight; + if (changed) { + canvas.width = backingWidth; + canvas.height = backingHeight; + } + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + ctx?.setTransform?.(dpr, 0, 0, dpr, 0, 0); + return changed; + } + + /** + * Size the backing store to the live CSS box and DPR. A dormant host never + * calls this, so a zero-source overlay keeps a 0x0 canvas instead of a + * full-viewport buffer; `drawWorldOverlay` sizes lazily on the first frame + * that actually has paint work. + */ + function ensureCanvasSize() { + if (!_canvas || !_engine) return false; + const size = _engine.host.size(); + const width = Math.max(0, Math.round(Number(size.width) || 0)); + const height = Math.max(0, Math.round(Number(size.height) || 0)); + const dpr = Math.max(1, Number(globalThis.window?.devicePixelRatio) || 1); + const changed = + _canvas.width !== Math.round(width * dpr) || + _canvas.height !== Math.round(height * dpr) || + _detectionSurface?.width !== Math.round(width * dpr) || + _detectionSurface?.height !== Math.round(height * dpr) || + _canvasWidth !== width || + _canvasHeight !== height || + _canvasDpr !== dpr; + _resizeDirty = false; + if (!changed) return false; + sizeCanvasSurface(_detectionSurface, _detectionCtx, width, height, dpr); + sizeCanvasSurface(_canvas, _ctx, width, height, dpr); + _canvasWidth = width; + _canvasHeight = height; + _viewport.width = width; + _viewport.height = height; + _canvasDpr = dpr; + _occludersDirty = true; + _solveDirty = true; + _canvasNeedsClear = true; + // Resizing a canvas clears its backing store by definition. + _detectionSurfaceNeedsClear = false; + _layoutRevision++; + return true; + } + + /** + * Only chrome the user can actually see may exclude anything. A collapsed or + * hidden panel must contribute nothing, including when the reason it is + * invisible lives on an ANCESTOR — a child of a `display:none` container keeps + * its own computed `display`, so the local style test alone cannot see it. + * `checkVisibility` resolves the whole chain; the local test is the fallback. + */ + function elementIsVisible(element) { + if (!element || element.hidden) return false; + if (typeof element.checkVisibility === 'function') { + return ( + element.checkVisibility({ + contentVisibilityAuto: true, + // The spec renamed these; pass both spellings so the check keeps its + // opacity/visibility coverage on either engine vintage. Unknown keys are + // ignored, and every option defaults to false, so a missing alias would + // silently weaken the test rather than throw. + opacityProperty: true, + visibilityProperty: true, + checkOpacity: true, + checkVisibilityCSS: true, + }) !== false + ); + } + const style = globalThis.window?.getComputedStyle?.(element); + if (!style) return true; + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + Number(style.opacity) !== 0 + ); + } + + /** + * Highest z-index the host paints at: the card canvas sits in `#world-overlay-root` + * (z6) and the detection surface at z5. Chrome ABOVE this composites over host + * paint; chrome at or below it is painted OVER by the host. + */ + const HOST_TOP_Z_INDEX = 6; + + /** + * Effective stacking level of an element relative to the page root. + * + * Paint order against the host is decided by the OUTERMOST positioned ancestor + * carrying a z-index — that is the stacking context which actually competes with + * `#world-overlay-root` — so the walk keeps overwriting as it climbs and returns + * the last one found. An element with no positioned z-indexed ancestor stacks at + * the root's own level, which is below the host. + */ + function elementStackLevel(element) { + let level = 0; + let node = element; + while (node && node !== document.body) { + const style = globalThis.window?.getComputedStyle?.(node); + if (style && style.position !== 'static' && style.zIndex !== 'auto') { + const numeric = Number(style.zIndex); + if (Number.isFinite(numeric)) level = numeric; + } + node = node.parentElement; + } + return level; + } + + /** + * Does this chrome composite ABOVE the host? + * + * This is the justification for treating exclusion as a soft preference: chrome + * that paints over the host hides an unplaceable card harmlessly. Chrome that + * paints UNDER the host does not — a card kept there renders ON TOP of it, which + * violates the absolute rule that labels never cover the UI. `#intel-hud` is z2, + * i.e. below both host surfaces, so its corners and bars need a HARD veto. + */ + function occluderStacksAboveHost(element) { + return elementStackLevel(element) > HOST_TOP_Z_INDEX; + } + + /** Append one inflated, canvas-relative exclusion rectangle from the pool. */ + function pushUiOcclusionRect(rect, canvasRect, hard = false) { + const index = _uiOcclusionRects.length; + const out = + _uiOcclusionRectPool[index] || (_uiOcclusionRectPool[index] = {}); + // `hard` rects veto a placement outright; soft rects are only preferred against. + out.hard = hard; + out.x = rect.left - canvasRect.left - OCCLUDER_PADDING_PX; + out.y = rect.top - canvasRect.top - OCCLUDER_PADDING_PX; + out.w = rect.width + OCCLUDER_PADDING_PX * 2; + out.h = rect.height + OCCLUDER_PADDING_PX * 2; + _uiOcclusionRects.push(out); + } + + function refreshUiOccluders(timestamp, force = false) { + if (!_canvas || !_occludersDirty) return false; + const elapsed = timestamp - _occludersUpdatedAt; + if (!force && elapsed < OCCLUDER_REFRESH_MS) { + if (_occluderRefreshTimer == null && typeof setTimeout === 'function') { + _occluderRefreshTimer = setTimeout( + () => { + _occluderRefreshTimer = null; + requestRender(); + }, + Math.max(0, OCCLUDER_REFRESH_MS - elapsed), + ); + } + return false; + } + _occludersUpdatedAt = timestamp; + _occludersDirty = false; + _uiOcclusionRects.length = 0; + const canvasRect = _canvas.getBoundingClientRect?.() || { left: 0, top: 0 }; + const seen = new Set(); + for (const selector of WORLD_OVERLAY_OCCLUDER_SELECTORS) { + const matches = + document.querySelectorAll?.(selector) || + [document.querySelector?.(selector)].filter(Boolean); + for (const element of matches) { + if (seen.has(element) || !elementIsVisible(element)) continue; + seen.add(element); + if (_resizeObserver && !_observedOccluderElements.has(element)) { + _resizeObserver.observe(element); + _mutationObserver?.observe?.(element, OCCLUDER_ATTRIBUTE_OBSERVATION); + _observedOccluderElements.add(element); + } + const rect = element.getBoundingClientRect?.(); + if (!rect || rect.width <= 0 || rect.height <= 0) continue; + pushUiOcclusionRect( + rect, + canvasRect, + !occluderStacksAboveHost(element), + ); + } + } + // Each element keeps its OWN rectangle. The inventory used to be reduced to + // transitive bounding UNIONS of overlapping rects, which is only a + // requirement for even-odd canvas holes (overlapping holes toggle pixels back + // on) — and the host no longer punches holes in anything. As placement + // geometry the unions were actively wrong: a chain of pairwise-overlapping + // panels collapsed into one enormous rectangle that swept unrelated world + // space, jumped in size whenever a panel expanded, and in cockpit coalesced + // to 94-98 % of the viewport. Per-rect exclusions hug the real chrome. + _solveDirty = true; + _layoutRevision++; + return true; + } + + function markLayoutDirty() { + _resizeDirty = true; + _occludersDirty = true; + if (!overlayHasPaintWork()) return; + invalidateHost({ solve: true, layout: true }); + } + + function markOccludersDirty() { + _occludersDirty = true; + if (!overlayHasPaintWork()) return; + invalidateHost({ solve: true }); + } + + /** + * Occluder-element attribute observation is deliberately NOT `subtree`: the + * inventory only reads each occluder's own border box and visibility, and a + * descendant cannot change either without the element's ResizeObserver seeing + * it. Subtree scope added nothing — but it delivered every ticking descendant + * (the REC-dot blink, chip internals) as an invalidation, which held a parked + * camera at ~12 requestRender/s for every live overlay source. + */ + const OCCLUDER_ATTRIBUTE_OBSERVATION = Object.freeze({ + attributes: true, + attributeFilter: Object.freeze(['class', 'style', 'hidden']), }); + + /** + * True when a node either IS inventory chrome or CONTAINS some. Judged against + * live nodes at record-delivery time, so "append, then set id" within one task + * is still seen; removals judge the detached subtree, which selector APIs + * still traverse. + */ + function nodeIsOrContainsOccluderChrome(node) { + if (!node || typeof node.matches !== 'function') return false; // text/comment nodes + for (const selector of WORLD_OVERLAY_OCCLUDER_SELECTORS) { + if (node.matches(selector)) return true; + if ( + typeof node.querySelector === 'function' && + node.querySelector(selector) + ) + return true; + } + return false; + } + + function childListTouchesChrome(nodes) { + if (!nodes) return false; + for (let i = 0; i < nodes.length; i++) { + if (nodeIsOrContainsOccluderChrome(nodes[i])) return true; + } + return false; + } + + /** + * Shared MutationObserver callback for both observation scopes. Body-level + * childList records are DISCOVERY only — they matter solely when the churn + * adds or removes inventory chrome, so a ticking clock's text swaps and other + * non-chrome element churn are dropped here. Attribute records can only + * originate from an occluder element itself (element-scoped observation), so + * they always invalidate. A batch the runtime failed to deliver falls back to + * the conservative pre-filter behavior. + */ + function handleChromeMutations(records) { + if (!overlayHasPaintWork()) { + // Dormant host: keep the pre-filter flag semantics without paying selector + // checks — the first frame with real paint work force-refreshes anyway. + _occludersDirty = true; + return; + } + if (!records || typeof records.length !== 'number') { + markOccludersDirty(); + return; + } + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (!record) continue; + if ( + record.type !== 'childList' || + childListTouchesChrome(record.addedNodes) || + childListTouchesChrome(record.removedNodes) + ) { + markOccludersDirty(); + return; + } + } + } + + function installUiOccluderObservers() { + _windowResizeHandler = markLayoutDirty; + globalThis.window?.addEventListener?.('resize', _windowResizeHandler); + if (typeof MutationObserver === 'function' && document.body) { + _mutationObserver = new MutationObserver(handleChromeMutations); + // Body observation is childList discovery only, filtered in the callback; + // attribute churn is observed per occluder element, without subtree. + _mutationObserver.observe(document.body, { + subtree: true, + childList: true, + }); + } + if (typeof ResizeObserver === 'function') { + _resizeObserver = new ResizeObserver(markLayoutDirty); + if (_root) { + _resizeObserver.observe(_root); + _observedOccluderElements.add(_root); + } + for (const selector of WORLD_OVERLAY_OCCLUDER_SELECTORS) { + const matches = document.querySelectorAll?.(selector) || []; + for (const element of matches) { + if (_observedOccluderElements.has(element)) continue; + _resizeObserver.observe(element); + _mutationObserver?.observe?.(element, OCCLUDER_ATTRIBUTE_OBSERVATION); + _observedOccluderElements.add(element); + } + } + } + } + + function clearCanvasSurface(canvas, ctx) { + if (!ctx || !canvas || !(canvas.width > 0) || !(canvas.height > 0)) return; + ctx.setTransform?.(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform?.(_canvasDpr, 0, 0, _canvasDpr, 0, 0); + } + + /** Clear either or both host-owned surfaces through the single frame path. */ + function clearCanvas(clearMain = true, clearDetection = true) { + if (clearMain) { + clearCanvasSurface(_canvas, _ctx); + _canvasNeedsClear = false; + } + if (clearDetection) { + clearCanvasSurface(_detectionSurface, _detectionCtx); + _detectionSurfaceNeedsClear = false; + } + } + + function activeEntryCount() { + let count = 0; + for (let i = 0; i < _sourceList.length; i++) { + const source = _sourceList[i]; + if (sourceActive(source)) count += source.entries.size; + } + return count; + } + + function activeCustomPaintLaneCount(target = null) { + let count = 0; + for (let i = 0; i < _customPaintLaneList.length; i++) { + const record = _customPaintLaneList[i]; + if (record.active && (target === null || record.target === target)) + count++; + } + return count; + } + + function activeFadeCount(timestamp) { + let count = 0; + for (let d = 0; d < _domainList.length; d++) { + const arbiter = _domainList[d].arbiter; + const stateCount = arbiter.activeStateCount(); + for (let i = 0; i < stateCount; i++) { + const state = arbiter.activeStateAt(i); + if (state.selected) { + if (timestamp - state.enterStartedAt < LABEL_ARBITER_TIMING.fadeInMs) + count++; + } else if ( + timestamp - state.exitStartedAt < + LABEL_ARBITER_TIMING.fadeOutMs + ) { + count++; + } + } + } + return count; + } + + function activeLeaderAnimationCount(timestamp) { + let count = 0; + for (let i = 0; i < _sourceList.length; i++) { + const source = _sourceList[i]; + if (!sourceActive(source)) continue; + for (const entry of source.entries.values()) { + if ( + entry.leaderAnimationMs > 0 && + timestamp - entry.leaderAnimationStartedAt < entry.leaderAnimationMs + ) + count++; + } + } + return count; + } + + function overlayHasPaintWork(timestamp = nowMs()) { + if (activeCustomPaintLaneCount() > 0) return true; + if (activeEntryCount() > 0) return true; + if (!_solveDirty) return activeFadeCount(timestamp) > 0; + for (let d = 0; d < _domainList.length; d++) { + if (_domainList[d].arbiter.states.size > 0) return true; + } + return false; + } + + function prepareProjectionMatrix() { + const matrix = _engine.projector.viewProjection(_scratchViewProjection); + _viewProjectionScalars.m0 = matrix[0]; + _viewProjectionScalars.m1 = matrix[1]; + _viewProjectionScalars.m3 = matrix[3]; + _viewProjectionScalars.m4 = matrix[4]; + _viewProjectionScalars.m5 = matrix[5]; + _viewProjectionScalars.m7 = matrix[7]; + _viewProjectionScalars.m8 = matrix[8]; + _viewProjectionScalars.m9 = matrix[9]; + _viewProjectionScalars.m11 = matrix[11]; + _viewProjectionScalars.m12 = matrix[12]; + _viewProjectionScalars.m13 = matrix[13]; + _viewProjectionScalars.m15 = matrix[15]; + return _viewProjectionScalars; + } + + function prepareCustomPaintFrame(timestamp, keyhole) { + const viewProjection = prepareProjectionMatrix(); + const camera = _engine.camera.livePosition; + _cameraPosition = camera; + _cameraHeight = _engine.camera.height(); + _customPaintFrame.canvas = _canvas; + _customPaintFrame.surface = _canvas; + _customPaintFrame.ctx = _ctx; + _customPaintFrame.width = _canvasWidth; + _customPaintFrame.height = _canvasHeight; + _customPaintFrame.dpr = _canvasDpr; + _customPaintFrame.timestamp = timestamp; + _customPaintFrame.cameraPosition = camera; + _customPaintFrame.occluder = _occluder; + _customPaintFrame.keyhole = keyhole; + _customPaintFrame.uiRectCount = _uiOcclusionRects.length; + _customPaintFrame.layoutRevision = _layoutRevision; + return viewProjection; + } + + function getProjectionRecord(entry) { + let record = _records.get(entry._overlayKey); + if (!record) { + record = { + key: entry._overlayKey, + entry, + position: geo.cartesian3.create(), + screen: { x: 0, y: 0 }, + layout: entry._overlayLayout, + placements: [], + candidate: null, + distanceAlpha: 1, + paintScale: 1, + leaderProgress: 1, + contentAlpha: 1, + altitudeAlpha: 1, + sourceAlpha: 1, + protectedPlacement: null, + distanceOptions: {}, + altitudeOptions: {}, + placementInput: {}, + scaledPaintPlacement: { rect: {} }, + }; + record.candidate = { + key: record.key, + layerId: entry.source, + sourceId: entry.id, + priority: entry.priority, + centerDistance: 0, + keyholeAlpha: 1, + placements: record.placements, + screenX: 0, + screenY: 0, + frameStamp: 0, + // Declared up front so the arbiter's per-solve scalar caches land in + // stable double fields instead of transitioning the shape every frame. + _anchorX: 0, + _anchorY: 0, + _record: record, + }; + _records.set(record.key, record); + } + record.entry = entry; + record.layout = entry._overlayLayout; + record.candidate.layerId = entry.source; + record.candidate.sourceId = entry.id; + record.candidate.priority = entry.priority; + record.candidate.stateless = entry.stateless; + record.distanceOptions.minDistance = entry.minDistance; + record.distanceOptions.maxDistance = entry.maxDistance; + record.distanceOptions.fadeStartRatio = entry.distanceFadeStartRatio; + record.altitudeOptions.minAltitude = entry.minAltitude; + record.altitudeOptions.fadeStart = entry.altitudeFadeStart; + record.altitudeOptions.fadeEnd = entry.altitudeFadeEnd; + return record; + } + + function resetFrameDomains() { + for (let d = 0; d < _domainList.length; d++) { + const domain = _domainList[d]; + // `candidateMap` keeps its table across frames; membership is expressed by + // `candidate.frameStamp`, and demand tallies are zeroed rather than cleared + // because `Map#clear` reallocates the backing hash table every call. Stale + // keys still have to go somewhere, so the index is pruned here — before the + // frame republishes it — against the previous frame's live cohort. + pruneKeyedIndex(domain.candidateMap, domain.candidateCount); + domain.candidateCount = 0; + domain.protectedCount = 0; + domain.protectedRectCount = 0; + domain.sepCount = 0; + domain.demandBySource.forEach(zeroDemandEntry); + domain.capacity = 0; + domain.moving = false; + domain.solveIntervalMs = Number.POSITIVE_INFINITY; + } + } + + // Evaluate optional scene presentation outside the shared projection hot path. + // Passing records (rather than intermediate doubles) also avoids boxing the + // ordinary moving-source workload at a non-inlined call boundary. + function applyPresentation(entry, source, record) { + try { + const scale = Number( + typeof entry.presentationScale === 'function' + ? entry.presentationScale() + : entry.presentationScale, + ); + record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1; + const alpha = Number( + typeof entry.sourceAlpha === 'function' + ? entry.sourceAlpha() + : entry.sourceAlpha, + ); + record.sourceAlpha = + source.options.alpha * + (Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1); + const leader = Number( + typeof entry.leaderProgress === 'function' + ? entry.leaderProgress() + : entry.leaderProgress, + ); + record.leaderProgress = Number.isFinite(leader) + ? Math.max(0, Math.min(1, leader)) + : 1; + const content = Number( + typeof entry.contentAlpha === 'function' + ? entry.contentAlpha() + : entry.contentAlpha, + ); + record.contentAlpha = Number.isFinite(content) + ? Math.max(0, Math.min(1, content)) + : 1; + return true; + } catch { + return false; + } + } + + function snapshotAndProject(entry, source, viewProjection, keyhole) { + const record = getProjectionRecord(entry); + let position; + try { + position = + typeof entry.position === 'function' + ? entry.position() + : entry.position; + } catch { + return null; + } + if ( + !position || + !Number.isFinite(position.x) || + !Number.isFinite(position.y) || + !Number.isFinite(position.z) + ) { + return null; + } + record.position.x = position.x; + record.position.y = position.y; + record.position.z = position.z; + + const { x: px, y: py, z: pz } = record.position; + const clipW = + viewProjection.m3 * px + + viewProjection.m7 * py + + viewProjection.m11 * pz + + viewProjection.m15; + if (!(clipW > 0)) return null; + const invW = 1 / clipW; + record.screen.x = + ((viewProjection.m0 * px + + viewProjection.m4 * py + + viewProjection.m8 * pz + + viewProjection.m12) * + invW * + 0.5 + + 0.5) * + _canvasWidth; + record.screen.y = + (0.5 - + (viewProjection.m1 * px + + viewProjection.m5 * py + + viewProjection.m9 * pz + + viewProjection.m13) * + invW * + 0.5) * + _canvasHeight; + if ( + entry.safeTopRatio > 0 && + !(entry.pinned && entry.pinnedBypassesSafeTop) + ) { + const safeTop = Math.min( + entry.safeTopMaxPx, + _canvasHeight * entry.safeTopRatio, + ); + if (record.screen.y < safeTop) return null; + } + if ( + !isOverlayPointVisible( + entry, + record.position, + record.screen, + _viewport, + _occluder, + ) + ) + return null; + + // Doubles crossing a non-inlined call boundary are boxed, so the two hot + // scalar steps stay local: the range is computed inline, and the common + // "no far limit" fade policy resolves without calling out at all. Both are + // exact restatements of `Cartesian3.distance` / `distanceFade`. + const cameraPosition = _cameraPosition; + const rangeX = cameraPosition.x - record.position.x; + const rangeY = cameraPosition.y - record.position.y; + const rangeZ = cameraPosition.z - record.position.z; + const distance = Math.sqrt( + rangeX * rangeX + rangeY * rangeY + rangeZ * rangeZ, + ); + record.distanceAlpha = + entry.maxDistance === Number.POSITIVE_INFINITY + ? distance >= entry.minDistance + ? 1 + : 0 + : distanceFade(distance, record.distanceOptions); + const cameraAltitude = _cameraHeight; + record.paintScale = entry.distanceScale + ? distanceScale(distance, entry.distanceScale) + : 1; + // Keep the source-configurable piecewise curve local to the projection hot + // path. Passing its five doubles through a non-inlined helper boxed them for + // every thumbnail candidate and broke the shared 154 B/candidate gate. + const altitudeCurve = entry.altitudeScale; + if (altitudeCurve && Number.isFinite(cameraAltitude)) { + let altitudeFactor = 1; + if (cameraAltitude > altitudeCurve.fullEnd) { + if (cameraAltitude <= altitudeCurve.midEnd) { + const span = Math.max( + 1, + altitudeCurve.midEnd - altitudeCurve.fullEnd, + ); + let progress = Math.max( + 0, + Math.min(1, (cameraAltitude - altitudeCurve.fullEnd) / span), + ); + if (altitudeCurve.smoothToMid) + progress = progress * progress * (3 - 2 * progress); + altitudeFactor = 1 + (altitudeCurve.midValue - 1) * progress; + } else if (cameraAltitude >= altitudeCurve.end) { + altitudeFactor = altitudeCurve.endValue; + } else { + const progress = + (cameraAltitude - altitudeCurve.midEnd) / + Math.max(1, altitudeCurve.end - altitudeCurve.midEnd); + altitudeFactor = + altitudeCurve.midValue + + (altitudeCurve.endValue - altitudeCurve.midValue) * progress; + } + } + record.paintScale *= altitudeFactor; + } + record.altitudeAlpha = + entry.altitudeFadeEnd === Number.POSITIVE_INFINITY + ? Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude + ? 0 + : 1 + : altitudeFade(cameraAltitude, record.altitudeOptions); + record.leaderProgress = 1; + record.contentAlpha = 1; + if ( + entry.presentationScale !== 1 || + entry.leaderProgress !== 1 || + entry.contentAlpha !== 1 || + typeof entry.sourceAlpha === 'function' + ) { + if (!applyPresentation(entry, source, record)) return null; + } else { + record.sourceAlpha = source.options.alpha * entry.sourceAlpha; + } + if ( + record.distanceAlpha <= 0 || + record.paintScale <= 0 || + record.altitudeAlpha <= 0 || + record.sourceAlpha <= 0 + ) + return null; + + measureOverlayEntry(_ctx, entry, record.layout); + record.placementInput.anchorX = record.screen.x; + record.placementInput.anchorY = record.screen.y; + record.placementInput.width = record.layout.w * record.paintScale; + record.placementInput.height = record.layout.h * record.paintScale; + record.placementInput.viewportWidth = _canvasWidth; + record.placementInput.viewportHeight = _canvasHeight; + if (entry.anchorRadiusPx > 0) { + const anchorScale = entry.anchorRadiusScale + ? distanceScale(distance, entry.anchorRadiusScale) + : 1; + const anchorRadius = entry.anchorRadiusPx * anchorScale; + record.placementInput.gap = + anchorRadius + + Math.max(entry.minAnchorGapPx, anchorRadius + entry.anchorGapPaddingPx); + record.placementInput.leaderOffset = anchorRadius; + } else { + record.placementInput.gap = entry.gapPx; + record.placementInput.leaderOffset = entry.leaderOffsetPx; + } + record.placementInput.preferred = entry.placement; + record.placementInput.verticalOnly = entry.verticalOnly; + record.placementInput.viewportMargin = entry.viewportMargin; + placementVariants(record.placementInput, record.placements); + // UI exclusion is a PREFERENCE for chrome that composites ABOVE the host, and + // a HARD VETO for chrome that composites below it. + // + // The soft half exists because deleting an entry whose every variant collides + // is what made cards hop between corners and then fade out near a panel, and + // what left cockpit with nothing. Keeping the placement is safe only when the + // chrome paints over the card anyway. + // + // That justification fails for chrome UNDER the host — `#intel-hud` is z2, + // below both host surfaces — where a kept placement would render ON TOP of the + // HUD text. Labels never cover the UI, so those rects keep the absolute veto. + let softClear = 0; + for (let i = 0; i < record.placements.length; i++) { + const placement = record.placements[i]; + if (overlayRectIntersectsAny(placement.rect, _uiOcclusionRects)) continue; + record.placements[softClear++] = placement; + } + if (softClear === 0) { + // Nothing is clear of everything: fall back to placements that at least + // clear the below-host chrome, and veto the entry if even that is impossible. + let hardClear = 0; + for (let i = 0; i < record.placements.length; i++) { + const placement = record.placements[i]; + if (overlayRectIntersectsAnyHard(placement.rect, _uiOcclusionRects)) + continue; + record.placements[hardClear++] = placement; + } + record.placements.length = hardClear; + } else { + record.placements.length = softClear; + } + if (record.placements.length === 0) return null; + let centerDistance = Number.POSITIVE_INFINITY; + let closestCenterX = 0; + let closestCenterY = 0; + for (let i = 0; i < record.placements.length; i++) { + const placement = record.placements[i]; + // `Math.hypot` is variadic and allocates per call; screen-space offsets + // never approach the overflow range it exists to guard. + const dx = placement.centerX - keyhole.centerX; + const dy = placement.centerY - keyhole.centerY; + const distanceFromCenter = Math.sqrt(dx * dx + dy * dy); + if (distanceFromCenter < centerDistance) { + centerDistance = distanceFromCenter; + closestCenterX = placement.centerX; + closestCenterY = placement.centerY; + } + } + // Shared keyhole alpha is radial and monotonic, so the closest surviving + // placement is exactly the maximum-alpha placement. Evaluate the shared + // helper once, then evaluate the final chosen rectangle again at paint. + record.candidate.keyholeAlpha = + entry.edgeFade === 'keyhole' + ? centerDistance <= keyhole.radius + ? 1 + : keyholeLabelAlphaFromGeometry( + closestCenterX, + closestCenterY, + keyhole, + ) + : 1; + record.candidate.centerDistance = centerDistance; + // Anchor separation is authored in unscaled CSS px; the shipped pass scaled it + // with the card, so a zoomed-out (smaller) card needs proportionally less room. + record.candidate.minAnchorSeparationPx = + entry.minAnchorSeparationPx > 0 + ? entry.minAnchorSeparationPx * record.paintScale + : 0; + record.candidate.screenX = record.screen.x; + record.candidate.screenY = record.screen.y; + return record; + } + + /** + * Project every active cohort into the pooled per-domain candidate buffers. + * Nothing here allocates per painted entry in steady state: records, candidate + * objects, placement objects, and the domain buffers are all reused, and live + * membership is carried by `candidate.frameStamp` instead of a rebuilt map. + */ + function collectFrameCandidates(keyhole, viewProjection) { + resetFrameDomains(); + let candidateCount = 0; + let projectedCount = 0; + for (let s = 0; s < _sourceList.length; s++) { + const source = _sourceList[s]; + if (!sourceActive(source)) continue; + for (let c = 0; c < source.cohortCount; c++) { + const domainId = source.cohortDomainIds[c]; + const cohort = source.cohortLists[c]; + const domain = getOrCreateDomain(domainId); + domain.capacity = + domainId === 'ambient-card' + ? Math.min( + AMBIENT_CARD_COLLISION_CAPACITY, + domain.capacity + source.options.collisionCapacity, + ) + : Math.max(domain.capacity, source.options.collisionCapacity); + domain.moving ||= source.options.moving; + domain.solveIntervalMs = Math.min( + domain.solveIntervalMs, + source.options.solveIntervalMs, + ); + domain.demandBySource.set( + source.id, + source.demandByDomain.get(domainId) || 0, + ); + domain.limitsBySource.set(source.id, source.options.maxVisible); + candidateCount += cohort.length; + for (let i = 0; i < cohort.length; i++) { + const entry = cohort[i]; + // Source-owned stable slots are dereferenced on every frame, before + // the numeric projection path. Ambient CCTV has no pre-frame chrome; + // the shipped pinned-card exception remains immediate feedback. + if (entry.requireImage && !entry.pinned) { + const imageSlot = entry._overlayImageSlot; + if ( + imageSlot + ? !(imageSlot.stamp > 0) || !imageSlot.frame + : !entry.image + ) + continue; + } + const record = snapshotAndProject( + entry, + source, + viewProjection, + keyhole, + ); + if (!record) continue; + // Anchor separation runs HERE, not inside the arbiter. The shipped pass + // filtered the candidate list and then drew it; rejecting inside the + // arbiter's fill loop instead leaves the source's quota permanently + // unfillable, so it rebuilds its spatial queue every solve and blows the + // per-frame allocation budget. Filtering first keeps the quota honest. + // Cohorts arrive in rank order (nearest/highest priority first), which is + // the order the shipped greedy accept used. + const separation = record.candidate.minAnchorSeparationPx; + if (separation > 0 && !isProtected(record.entry)) { + let clear = true; + for (let k = 0; k < domain.sepCount; k++) { + if (domain.sepSource[k] !== source.id) continue; + const dx = record.candidate.screenX - domain.sepX[k]; + const dy = record.candidate.screenY - domain.sepY[k]; + const required = + separation > domain.sepR[k] ? separation : domain.sepR[k]; + if (dx * dx + dy * dy < required * required) { + clear = false; + break; + } + } + if (!clear) continue; + const slot = domain.sepCount++; + domain.sepX[slot] = record.candidate.screenX; + domain.sepY[slot] = record.candidate.screenY; + domain.sepR[slot] = separation; + domain.sepSource[slot] = source.id; + } + projectedCount++; + if (isProtected(record.entry)) { + domain.protectedCandidates[domain.protectedCount++] = + record.candidate; + } else { + record.candidate.frameStamp = _frameStamp; + domain.candidates[domain.candidateCount++] = record.candidate; + domain.candidateMap.set(record.key, record.candidate); + } + } + } + } + + for (let d = 0; d < _domainList.length; d++) { + const domain = _domainList[d]; + sortPooledRange( + domain.protectedCandidates, + domain.protectedCount, + compareProtectedCandidates, + ); + for (let p = 0; p < domain.protectedCount; p++) { + const candidate = domain.protectedCandidates[p]; + let placement = null; + for (let i = 0; i < candidate.placements.length; i++) { + const candidatePlacement = candidate.placements[i]; + if ( + overlayRectIntersectsAny( + candidatePlacement.rect, + domain.protectedRects, + domain.protectedRectCount, + ) + ) + continue; + placement = candidatePlacement; + break; + } + placement ||= leastOverlappingPlacement( + candidate.placements, + domain.protectedRects, + domain.protectedRectCount, + ); + candidate._record.protectedPlacement = placement; + if (placement) + domain.protectedRects[domain.protectedRectCount++] = placement.rect; + } + for (let a = 0; a < domain.candidateCount; a++) { + const candidate = domain.candidates[a]; + let count = 0; + for (let i = 0; i < candidate.placements.length; i++) { + const placement = candidate.placements[i]; + if ( + overlayRectIntersectsAny( + placement.rect, + domain.protectedRects, + domain.protectedRectCount, + ) + ) + continue; + candidate.placements[count++] = placement; + } + candidate.placements.length = count; + if (count === 0) candidate.frameStamp = 0; + } + let count = 0; + for (let i = 0; i < domain.candidateCount; i++) { + if (domain.candidates[i].frameStamp !== _frameStamp) continue; + domain.candidates[count++] = domain.candidates[i]; + } + domain.candidateCount = count; + domain.candidates.length = count; + domain.protectedCandidates.length = domain.protectedCount; + domain.protectedRects.length = domain.protectedRectCount; + if (!Number.isFinite(domain.solveIntervalMs)) + domain.solveIntervalMs = DEFAULT_MOVING_SOLVE_MS; + } + let candidateIndexSize = 0; + for (let d = 0; d < _domainList.length; d++) + candidateIndexSize += _domainList[d].candidateMap.size; + _diagnostics.candidateIndexSize = candidateIndexSize; + _diagnostics.candidateCount = candidateCount; + _diagnostics.projectedCount = projectedCount; + } + + function addPaintItem(record, placement, temporalAlpha, selected) { + if (!record || !placement) return; + const item = + _paintItemPool[_paintCount] || (_paintItemPool[_paintCount] = {}); + item.record = record; + item.placement = placement; + item.temporalAlpha = temporalAlpha; + item.selected = selected; + item.lane = paintLaneForOverlayEntry(record.entry); + item.zIndex = record.entry.zIndex; + item.key = record.key; + item.collisionHidden = false; + _paintQueue[_paintCount++] = item; + } + + function sourceCanPaint(sourceId) { + const source = _sources.get(sourceId); + return !!source && sourceActive(source); + } + + function solveDomains(timestamp) { + _paintCount = 0; + let solveMs = 0; + let selectedCount = 0; + let fadingCount = 0; + let solveRevision = 0; + for (let d = 0; d < _domainList.length; d++) { + const domain = _domainList[d]; + const movingSolveDue = + domain.moving && + timestamp - domain.lastSolveAt >= domain.solveIntervalMs; + if (_solveDirty || movingSolveDue) { + const started = nowMs(); + domain.arbiter.solve(domain.candidates, { + capacity: Math.min(domain.capacity, domain.candidates.length), + demandByLayer: domain.demandBySource, + limitsByLayer: domain.limitsBySource, + now: timestamp, + // The host publishes its own pooled counters below, so duplicating a + // per-layer diagnostic object in the arbiter adds no observable data. + collectDiagnostics: false, + }); + solveMs += nowMs() - started; + domain.lastSolveAt = timestamp; + } + for (let p = 0; p < domain.protectedCount; p++) { + const record = domain.protectedCandidates[p]._record; + addPaintItem( + record, + record.protectedPlacement, + record.entry.temporalAlpha, + true, + ); + selectedCount++; + } + const rendered = domain.arbiter.renderEntries( + domain.candidateMap, + timestamp, + domain.renderEntries, + domain.limitsBySource, + ); + for (let r = 0; r < rendered.length; r++) { + const renderedEntry = rendered[r]; + const candidate = renderedEntry.candidate; + const record = candidate?._record; + if (!record || !sourceCanPaint(record.entry.source)) continue; + if (renderedEntry.selected && candidate.frameStamp !== _frameStamp) + continue; + // No second UI veto here. The placement already went through the + // exclusion preference at projection; re-testing it meant that a panel + // expanding over a settled card evicted the card outright — it popped + // out and then had to fade back in — instead of simply sliding beneath + // the chrome that composites above this host anyway. + addPaintItem( + record, + renderedEntry.placement, + renderedEntry.temporalAlpha * record.entry.temporalAlpha, + renderedEntry.selected, + ); + if (renderedEntry.selected) selectedCount++; + else fadingCount++; + } + solveRevision += domain.arbiter.solveRevision; + } + _paintQueue.length = _paintCount; + _solveDirty = false; + _diagnostics.solveMs = solveMs; + _diagnostics.selectedCount = selectedCount; + _diagnostics.fadingCount = fadingCount; + _diagnostics.solveRevision = solveRevision; + } + + function publishPaintRect(item) { + const { record, placement } = item; + const rect = + _paintRectPool[_paintRectCount] || (_paintRectPool[_paintRectCount] = {}); + _paintRectCount++; + rect.anchorX = placement.anchorX; + rect.anchorY = placement.anchorY; + rect.x = placement.rect.x; + rect.y = placement.rect.y; + rect.w = placement.rect.w; + rect.h = placement.rect.h; + rect.sourceId = record.entry.source; + rect.entryId = record.entry.id; + rect.entry = record.entry; + rect.key = record.key; + rect.stamp = _frameStamp; + _paintRectByKey.set(record.key, rect); + if (record.entry.interactive) _hitRects[_hitRectCount++] = rect; + } + + /** Publish the action list to every subscriber. */ + function presentActions(next) { + _actions = next && Object.freeze(next); + for (const listener of _actionsListeners) listener(_actions); + } + + /** + * Receive the visible map target list now and after every change. + * @param {(view: ?object) => void} listener + * @returns {() => void} Unsubscribe. + */ + function subscribeWorldOverlayActions(listener) { + if (_destroyed) { + listener(null); + return () => {}; + } + _actionsListeners.add(listener); + listener(_actions); + return () => _actionsListeners.delete(listener); + } + + /** + * Focus a listed target, as its button does, announcing an accepted focus. + * @param {object} owner The `owner` of the list the button belongs to. + * @param {string} key The item's key. + */ + function activateWorldOverlayAction(owner, key) { + if (!_actions || owner !== _actions.owner) return; + const item = _actions.items.find((candidate) => candidate.key === key); + const activate = _accessibleActivatorByKey.get(key); + if (!item || !activate) return; + const accepted = activate(); + if (accepted !== false && _actions?.owner === owner) { + presentActions({ + ..._actions, + status: t('Focusing {label}', { label: item.label }), + announced: _actions.announced + 1, + }); + } + } + + /** Keep a stable, bounded accessible mirror of currently painted actions. */ + function syncAccessibleActions() { + if (!_actions) return; + _accessibleActivatorByKey.clear(); + const items = []; + for (let i = _hitRectCount - 1; i >= 0; i--) { + const rect = _hitRects[i]; + const entry = rect.entry; + if (!entry?.accessibilityLabel || typeof entry.activate !== 'function') + continue; + if (_accessibleActivatorByKey.has(rect.key)) continue; + _accessibleActivatorByKey.set(rect.key, entry.activate); + items.push({ + key: rect.key, + label: entry.accessibilityLabel, + selected: entry.selected === true, + }); + } + const signature = items + .map( + (item) => + `${item.key}\u0000${item.label}\u0000${item.selected ? '1' : '0'}`, + ) + .join('\u0001'); + if (signature === _accessibilitySignature) return; + _accessibilitySignature = signature; + presentActions({ ..._actions, items }); + } + + let _detectionSurfacePrepared = false; + + function paintCustomLane(lane) { + for (let i = 0; i < _customPaintLaneList.length; i++) { + const record = _customPaintLaneList[i]; + if (!record.active || record.lane !== lane) continue; + const detectionTarget = record.target === PAINT_TARGET_DETECTION; + const surface = detectionTarget ? _detectionSurface : _canvas; + const ctx = detectionTarget ? _detectionCtx : _ctx; + if (!surface || !ctx) continue; + _customPaintFrame.surface = surface; + _customPaintFrame.ctx = ctx; + if (record.shouldPaint && record.shouldPaint(_customPaintFrame) === false) + continue; + if (detectionTarget && !_detectionSurfacePrepared) { + clearCanvas(false, true); + _detectionSurfacePrepared = true; + } + ctx.save(); + try { + // No UI clip. Detection paints edge to edge exactly as it shipped: it + // consumes `frame.uiRects` itself to keep its CALLOUT CARDS off solid + // chrome, while brackets, labels, the focus ring, the banner and the + // scanline wash cover the whole field. Clipping this surface punched + // hard-edged rectangular voids through the detection field, and in + // cockpit — where the exclusions coalesced to nearly the full viewport — + // blanked Panoptic entirely while it was still solving and painting. + record.painter(_customPaintFrame); + if (detectionTarget) _detectionSurfaceNeedsClear = true; + } finally { + ctx.restore(); + } + } + } + + function paintEntryItem(item, keyhole, sonar) { + if (item.collisionHidden) return; + const { record, placement } = item; + const entry = record.entry; + let keyholeAlpha = 1; + if (entry.edgeFade === 'keyhole') { + const keyholeX = placement.centerX - keyhole.centerX; + const keyholeY = placement.centerY - keyhole.centerY; + keyholeAlpha = + keyholeX * keyholeX + keyholeY * keyholeY <= + keyhole.radius * keyhole.radius + ? 1 + : keyholeLabelAlphaFromGeometry( + placement.centerX, + placement.centerY, + keyhole, + ); + } + // All five channels are normalized at their source. Keep the multiply on + // the hot paint path so its intermediate doubles remain unboxed; the pure + // `combinedOverlayAlpha` export still specifies/tests the same binding. + const finalAlpha = + record.sourceAlpha * + item.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha * + keyholeAlpha * + (sonar ? sonar.label(sonar.at(placement.anchorX, placement.anchorY)) : 1); + if (finalAlpha <= 0.001) return; + if (record.paintScale === 1) { + paintOverlayEntry( + _ctx, + entry, + placement, + finalAlpha, + record.leaderProgress, + record.contentAlpha, + ); + } else { + const scaled = localizeScaledPlacement( + placement, + record.paintScale, + record.scaledPaintPlacement, + ); + _ctx.save(); + _ctx.translate(placement.rect.x, placement.rect.y); + _ctx.scale(record.paintScale, record.paintScale); + paintOverlayEntry( + _ctx, + entry, + scaled, + finalAlpha, + record.leaderProgress, + record.contentAlpha, + ); + _ctx.restore(); + } + publishPaintRect(item); + if (_paintedBySource[entry.source] === undefined) { + _paintedBySource[entry.source] = 0; + _paintedSourceKeys.push(entry.source); + } + _paintedBySource[entry.source]++; + } + + function compareYieldingPaintItems(a, b) { + const aYields = !!a.record.entry.yieldToCollisionGroup; + const bYields = !!b.record.entry.yieldToCollisionGroup; + return ( + Number(aYields) - Number(bYields) || + b.record.entry.priority - a.record.entry.priority || + compareStableKeys(a.key, b.key) + ); + } + + /** Recheck yielding content every painted frame, even between camera solves. + * The arbiter's sticky/exit placements are intentionally retained for cards; + * labels can only use the remaining rectangles. All buffers are reused. */ + function declutterYieldingEntries() { + let hasYielding = false; + for (let i = 0; i < _paintCount; i++) + if (_paintQueue[i].record.entry.yieldToCollisionGroup) { + hasYielding = true; + break; + } + if (!hasYielding) return; + sortPooledRange(_paintQueue, _paintCount, compareYieldingPaintItems); + for (let i = 0; i < _paintCount; i++) { + const item = _paintQueue[i]; + const entry = item.record.entry; + if (!entry.yieldToCollisionGroup) continue; + const placements = item.record.placements; + let accepted = null; + // Try the arbiter's chosen corner first, then its other valid placements. + for (let p = -1; p < placements.length; p++) { + const placement = p < 0 ? item.placement : placements[p]; + if (p >= 0 && placement === item.placement) continue; + let blocked = false; + for (let j = 0; j < i; j++) { + const other = _paintQueue[j]; + if (other.collisionHidden) continue; + const group = other.record.entry.collisionGroup; + if ( + (group === entry.collisionGroup || + group === entry.yieldToCollisionGroup) && + overlayRectsOverlap(placement.rect, other.placement.rect) + ) { + blocked = true; + break; + } + } + if (!blocked) { + accepted = placement; + break; + } + } + if (accepted) item.placement = accepted; + else item.collisionHidden = true; + } + } + + function syncPaintVisibility(painted = true) { + for (let s = 0; s < _sourceList.length; s++) { + const source = _sourceList[s]; + const callback = source.options.onVisibilityChange; + if (!callback) continue; + const count = painted ? _paintedBySource[source.id] || 0 : 0; + let changed = count !== source.visibleIds.size; + if (!changed && painted) { + for (const id of source.visibleIds) { + if ( + _paintRectByKey.get(entryKey(source.id, id))?.stamp !== _frameStamp + ) { + changed = true; + break; + } + } + } + if (!changed) continue; + source.visibleIds.clear(); + if (painted) + for (let i = 0; i < _paintRectCount; i++) { + const rect = _paintRectPool[i]; + if (rect.sourceId === source.id) source.visibleIds.add(rect.entryId); + } + callback(source.visibleIds); + } + } + + function paintFrame(keyhole) { + const started = nowMs(); + const sonar = isCyberSonarActive() + ? createCyberSonarSampler(_canvasWidth, _canvasHeight, started) + : null; + clearCanvas(true, false); + _detectionSurfacePrepared = false; + if ( + activeCustomPaintLaneCount(PAINT_TARGET_DETECTION) === 0 && + _detectionSurfaceNeedsClear + ) + clearCanvas(false, true); + _paintRectCount = 0; + _hitRectCount = 0; + declutterYieldingEntries(); + sortPooledRange(_paintQueue, _paintCount, comparePaintItems); + // The shared canvas is not clipped either. Cards already avoid solid chrome + // by placement, and every occluder composites above this host in the DOM, so + // the clip could only ever carve voids out of legitimate world content. + _ctx.save(); + let itemIndex = 0; + for (let lane = 0; lane < WORLD_OVERLAY_PAINT_LANES.length; lane++) { + // Source-owned painters run first inside their lane. Detection therefore + // retains its former z5 position below every ordinary host entry at z6. + paintCustomLane(lane); + while (itemIndex < _paintCount && _paintQueue[itemIndex].lane === lane) { + paintEntryItem(_paintQueue[itemIndex], keyhole, sonar); + itemIndex++; + } + } + _ctx.restore(); + _diagnostics.paintedCount = _paintRectCount; + _diagnostics.hitRectCount = _hitRectCount; + _diagnostics.paintItemPoolSize = _paintItemPool.length; + _diagnostics.paintRectPoolSize = _paintRectPool.length; + _diagnostics.paintMs = nowMs() - started; + syncAccessibleActions(); + syncPaintVisibility(); + _canvasNeedsClear = + _paintRectCount > 0 || + activeCustomPaintLaneCount(PAINT_TARGET_SHARED) > 0; + } + + function localizeScaledPlacement(placement, scale, out) { + const x = placement.rect.x; + const y = placement.rect.y; + out.corner = placement.corner; + out.rect.x = 0; + out.rect.y = 0; + out.rect.w = placement.rect.w / scale; + out.rect.h = placement.rect.h / scale; + out.centerX = (placement.centerX - x) / scale; + out.centerY = (placement.centerY - y) / scale; + out.anchorX = (placement.anchorX - x) / scale; + out.anchorY = (placement.anchorY - y) / scale; + out.leadFromX = (placement.leadFromX - x) / scale; + out.leadFromY = (placement.leadFromY - y) / scale; + out.paintScale = scale; + out.leaderOffset = placement.leaderOffset; + if (out.leaderOffset !== 0) out.leaderOffset /= scale; + out.leadToX = (placement.leadToX - x) / scale; + out.leadToY = (placement.leadToY - y) / scale; + return out; + } + + function resetFrameDiagnostics() { + _diagnostics.candidateCount = 0; + _diagnostics.projectedCount = 0; + _diagnostics.selectedCount = 0; + _diagnostics.fadingCount = 0; + _diagnostics.paintedCount = 0; + _diagnostics.hitRectCount = 0; + _diagnostics.projectionMs = 0; + _diagnostics.solveMs = 0; + _diagnostics.paintMs = 0; + for (let i = 0; i < _paintedSourceKeys.length; i++) { + _paintedBySource[_paintedSourceKeys[i]] = 0; + } + } + + function drawWorldOverlay() { + if (_sonarRenderTimer !== null) { + clearTimeout(_sonarRenderTimer); + _sonarRenderTimer = null; + } + if (_destroyed || !_engine || !_canvas || !_ctx) return; + const timestamp = nowMs(); + if (!overlayHasPaintWork(timestamp)) { + resetFrameDiagnostics(); + _solveDirty = false; + if (_canvasNeedsClear || _detectionSurfaceNeedsClear) clearCanvas(); + if (_accessibilitySignature) { + _hitRectCount = 0; + syncAccessibleActions(); + } + syncPaintVisibility(false); + return; + } + if (_resizeDirty) ensureCanvasSize(); + if (_canvasWidth <= 0 || _canvasHeight <= 0) return; + _frameStamp++; + refreshUiOccluders( + timestamp, + _occludersUpdatedAt === Number.NEGATIVE_INFINITY, + ); + resetFrameDiagnostics(); + const projectionStarted = nowMs(); + const fadeTuning = getKeyholeFadeTuning(); + if ( + !_keyhole || + _keyholeWidth !== _canvasWidth || + _keyholeHeight !== _canvasHeight || + _keyholeFadeRatio !== fadeTuning.fadeRatio || + _keyholeOutsideOpacity !== fadeTuning.outsideOpacity + ) { + _keyhole = getKeyholeGeometry(_canvasWidth, _canvasHeight); + _keyholeWidth = _canvasWidth; + _keyholeHeight = _canvasHeight; + _keyholeFadeRatio = fadeTuning.fadeRatio; + _keyholeOutsideOpacity = fadeTuning.outsideOpacity; + } + const keyhole = _keyhole; + const viewProjection = prepareCustomPaintFrame(timestamp, keyhole); + collectFrameCandidates(keyhole, viewProjection); + _diagnostics.projectionMs = nowMs() - projectionStarted; + solveDomains(timestamp); + paintFrame(keyhole); + // Labels-only layers need a bounded sweep refresh even with detection off. + // Empty/hidden hosts and teardown never retain scanner render demand. + if (_paintRectCount > 0 && isCyberSonarActive()) { + _sonarRenderTimer = setTimeout(() => { + _sonarRenderTimer = null; + if (!_destroyed && isCyberSonarActive()) requestRender(); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + } + const animationsRemaining = + activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp); + if (animationsRemaining > 0) requestRender(); + } + + function createDevFacade() { + if (typeof window === 'undefined' || import.meta.env?.DEV !== true) return; + window.__gevWorldOverlay = { + getDiagnostics: getWorldOverlayDiagnostics, + getPaintRect: getOverlayPaintRect, + }; + } + + /** + * Initialize this attachment’s host. Repeated calls are no-op. + * @param {import('../globe/contract.js').GlobeEngine} engine + */ + function initWorldOverlay() { + if (_destroyed) return; + const engine = _engine; + // What the host reads of the engine: a Cesium viewer has none of it. + if (!engine?.projector || !engine.frames?.postRender || !engine.host) + throw new TypeError('initWorldOverlay requires a globe engine'); + if (_engine === engine && _canvas && _removePostRender) return; + _releaseDraw = retainWorldOverlayDraw(); + ensureOverlayDom(); + const { projector } = engine; + _occluder = { + isPointVisible: (position) => projector.horizonVisible(position), + }; + _cockpitActive = !!document.body?.classList?.contains('cockpit-mode'); + _cockpitModeHandler = (event) => { + _cockpitActive = event?.detail?.active === true; + invalidateHost(); + }; + window.addEventListener('gev:cockpit-mode-changed', _cockpitModeHandler); + _removePostRender = engine.frames.postRender.addEventListener(() => + drawWorldOverlay(), + ); + _removeMoveEnd = engine.camera.moveEnd.addEventListener(() => + invalidateHost(), + ); + installUiOccluderObservers(); + // The backing store and the occluder inventory are both deferred to the + // first frame with real paint work: a dormant host must not hold a + // full-viewport canvas (~19 MB at DPR 2) or scan the UI for exclusions. + _resizeDirty = true; + _occludersDirty = true; + _solveDirty = true; + _canvasNeedsClear = true; + _detectionSurfaceNeedsClear = true; + updateEntryDiagnostics(); + createDevFacade(); + } + + /** Remove all listeners, observers, entries, diagnostics, and overlay DOM. */ + function destroyWorldOverlay() { + if (_destroyed) return; + _destroyed = true; + if (_sonarRenderTimer !== null) clearTimeout(_sonarRenderTimer); + _sonarRenderTimer = null; + _removePostRender?.(); + _removePostRender = null; + _removeMoveEnd?.(); + _removeMoveEnd = null; + if (_cockpitModeHandler && typeof window !== 'undefined') { + window.removeEventListener( + 'gev:cockpit-mode-changed', + _cockpitModeHandler, + ); + } + _cockpitModeHandler = null; + if (_windowResizeHandler && typeof window !== 'undefined') { + window.removeEventListener('resize', _windowResizeHandler); + } + _windowResizeHandler = null; + _resizeObserver?.disconnect?.(); + _resizeObserver = null; + _observedOccluderElements = new WeakSet(); + _mutationObserver?.disconnect?.(); + _mutationObserver = null; + _releaseDraw?.(); + _releaseDraw = null; + if (_occluderRefreshTimer != null) clearTimeout(_occluderRefreshTimer); + _occluderRefreshTimer = null; + const ownedIds = [_root, _canvas, _detectionSurface] + .filter((node) => node?.id) + .map((node) => node.id); + _root?.remove?.(); + // The detection surface is not a child of `_root` (it lives in the Cesium + // container so its `screen` blend reaches the scene), so it has to be torn + // down explicitly rather than by the root's removal. + _detectionSurface?.remove?.(); + // Another live host takes the ids this one held. + for (const name of ownedIds) { + const heir = globalThis.document?.querySelector?.(`.${name}`); + if (heir && !globalThis.document.getElementById(name)) heir.id = name; + } + if ( + typeof window !== 'undefined' && + window.__gevWorldOverlay?.getDiagnostics === getWorldOverlayDiagnostics + ) { + delete window.__gevWorldOverlay; + } + syncPaintVisibility(false); + _sources.clear(); + _sourceList.length = 0; + for (let i = 0; i < _customPaintLaneList.length; i++) { + _customPaintLaneList[i].active = false; + _customPaintLaneList[i].painter = null; + _customPaintLaneList[i].shouldPaint = null; + } + _customPaintLanes.clear(); + _customPaintLaneList.length = 0; + _domains.clear(); + _domainList.length = 0; + _records.clear(); + for (let i = 0; i < _paintItemPool.length; i++) { + const item = _paintItemPool[i]; + item.record = null; + item.placement = null; + } + for (let i = 0; i < _paintRectPool.length; i++) { + _paintRectPool[i].entry = null; + } + _paintQueue.length = 0; + _paintItemPool.length = 0; + _paintRectPool.length = 0; + _hitRects.length = 0; + _accessibleActivatorByKey.clear(); + _accessibilitySignature = ''; + _paintRectByKey.clear(); + _paintCount = 0; + _paintRectCount = 0; + _hitRectCount = 0; + _uiOcclusionRects.length = 0; + _uiOcclusionRectPool.length = 0; + _engine = null; + _root = null; + _canvas = null; + _ctx = null; + if (_actions) presentActions(null); + _actionsListeners.clear(); + _detectionSurface = null; + _detectionCtx = null; + _occluder = null; + _cameraPosition = null; + _cameraHeight = Number.NaN; + _keyhole = null; + _keyholeWidth = -1; + _keyholeHeight = -1; + _keyholeFadeRatio = Number.NaN; + _keyholeOutsideOpacity = Number.NaN; + _cockpitActive = false; + _resizeDirty = true; + _occludersDirty = true; + _solveDirty = true; + _canvasNeedsClear = true; + _detectionSurfaceNeedsClear = true; + _detectionSurfacePrepared = false; + _occludersUpdatedAt = Number.NEGATIVE_INFINITY; + _canvasWidth = 0; + _canvasHeight = 0; + _viewport.width = 0; + _viewport.height = 0; + _canvasDpr = 1; + _layoutRevision = 0; + _customPaintFrame.canvas = null; + _customPaintFrame.surface = null; + _customPaintFrame.ctx = null; + _customPaintFrame.width = 0; + _customPaintFrame.height = 0; + _customPaintFrame.dpr = 1; + _customPaintFrame.timestamp = 0; + _customPaintFrame.cameraPosition = null; + _customPaintFrame.occluder = null; + _customPaintFrame.keyhole = null; + _customPaintFrame.uiRectCount = 0; + _customPaintFrame.layoutRevision = 0; + for (let i = 0; i < _paintedSourceKeys.length; i++) { + delete _paintedBySource[_paintedSourceKeys[i]]; + } + _paintedSourceKeys.length = 0; + Object.assign(_diagnostics, { + sourceCount: 0, + entryCount: 0, + candidateCount: 0, + projectedCount: 0, + selectedCount: 0, + fadingCount: 0, + paintedCount: 0, + hitRectCount: 0, + projectionMs: 0, + solveMs: 0, + paintMs: 0, + solveRevision: 0, + paintItemPoolSize: 0, + paintRectPoolSize: 0, + candidateIndexSize: 0, + entriesBySource: {}, + paintedBySource: _paintedBySource, + }); + } + + return { + registerWorldOverlayPaintLane, + setOverlayEntries, + upsertOverlayEntry, + removeOverlayEntry, + clearOverlaySource, + setOverlaySourceVisible, + getOverlayPaintRect, + hitTestWorldOverlay, + getWorldOverlayDiagnostics, + subscribeWorldOverlayActions, + activateWorldOverlayAction, + initWorldOverlay, + destroyWorldOverlay, + init: initWorldOverlay, + destroy: destroyWorldOverlay, + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, + getDiagnostics: getWorldOverlayDiagnostics, + getPaintRect: getOverlayPaintRect, + registerPaintLane: registerWorldOverlayPaintLane, + subscribeActions: subscribeWorldOverlayActions, + activateAction: activateWorldOverlayAction, + }; +} + +export function worldOverlayFor(engine) { + return sceneServicesFor(engine)?.overlay; +} + +export const forEngine = worldOverlayFor; + +export function registerWorldOverlayPaintLane(engine, ...args) { + return worldOverlayFor(engine)?.registerWorldOverlayPaintLane(...args); +} + +export function setOverlayEntries(engine, ...args) { + return worldOverlayFor(engine)?.setOverlayEntries(...args); +} + +export function upsertOverlayEntry(engine, ...args) { + return worldOverlayFor(engine)?.upsertOverlayEntry(...args); +} + +export function removeOverlayEntry(engine, ...args) { + return worldOverlayFor(engine)?.removeOverlayEntry(...args); +} + +export function clearOverlaySource(engine, ...args) { + return worldOverlayFor(engine)?.clearOverlaySource(...args); +} + +export function setOverlaySourceVisible(engine, ...args) { + return worldOverlayFor(engine)?.setOverlaySourceVisible(...args); +} + +export function getOverlayPaintRect(engine, ...args) { + return worldOverlayFor(engine)?.getOverlayPaintRect(...args); +} + +export function hitTestWorldOverlay(engine, ...args) { + return worldOverlayFor(engine)?.hitTestWorldOverlay(...args); +} + +export function getWorldOverlayDiagnostics(engine, ...args) { + return worldOverlayFor(engine)?.getWorldOverlayDiagnostics(...args); +} + +export function subscribeWorldOverlayActions(engine, ...args) { + return worldOverlayFor(engine)?.subscribeWorldOverlayActions(...args); +} + +export function activateWorldOverlayAction(engine, ...args) { + return worldOverlayFor(engine)?.activateWorldOverlayAction(...args); +} + +export function initWorldOverlay(engine, ...args) { + if (!engine?.projector || !engine.frames?.postRender || !engine.host) + throw new TypeError('initWorldOverlay requires a globe engine'); + return worldOverlayFor(engine)?.initWorldOverlay(...args); +} + +export function destroyWorldOverlay(engine, ...args) { + return worldOverlayFor(engine)?.destroyWorldOverlay(...args); } diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 250a67888..e03a77697 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,3 +1,8 @@ +import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; +import { bindOverlayHost } from './bindOverlayHost.js'; +import * as overlayServices from './worldOverlay.js'; +import { createSceneServices, sceneServicesFor } from '../data/sceneServices.js'; +let overlayEngine; import { createNamedMarkers } from '../layers/installations/render/namedMarkers.js'; import { rgbaFromCss } from '../layers/shared/color.js'; import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs'; @@ -316,12 +321,6 @@ function installMockEnvironment({ const viewerContainer = new MockElement('div'); viewerContainer.id = 'cesiumContainer'; body.appendChild(viewerContainer); - const root = new MockElement('div'); - root.id = 'world-overlay-root'; - body.appendChild(root); - const canvas = new MockElement('canvas'); - canvas.id = 'world-overlay-canvas'; - root.appendChild(canvas); if (occluderRect) { const title = new MockElement('div'); title.id = 'title-bar'; @@ -394,6 +393,7 @@ function installMockEnvironment({ // Shell modules take the globe engine; the Cesium engine runs on the mock. const engine = createCesiumEngine(viewer); + overlayEngine = engine; return { viewer, engine, @@ -410,7 +410,7 @@ function installMockEnvironment({ selectorQueries, advanceTime(ms) { currentTime += ms; }, cleanup() { - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); engine.destroy(); delete globalThis.document; delete globalThis.window; @@ -446,29 +446,28 @@ function selectedEntry(id, overrides = {}) { // MUST STAY FIRST in this file: it asserts the module's never-initialized // state, which every later test destroys. -test('destroying before the first init leaves pre-init buffering intact', () => { +test('an attachment buffers before init and rejects writes after destruction', () => { const env = installMockEnvironment(); - destroyWorldOverlay(); - setOverlayEntries('pre-init', [selectedEntry('buffered')]); - assert.equal(getWorldOverlayDiagnostics().entryCount, 1); + setOverlayEntries(overlayEngine, 'pre-init', [selectedEntry('buffered')]); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).entryCount, 1); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); // A real teardown does arm the guard, and repeating it must not disarm it. - destroyWorldOverlay(); - destroyWorldOverlay(); - setOverlayEntries('pre-init', [selectedEntry('rejected')]); - assert.equal(getWorldOverlayDiagnostics().entryCount, 0); + destroyWorldOverlay(overlayEngine); + destroyWorldOverlay(overlayEngine); + setOverlayEntries(overlayEngine, 'pre-init', [selectedEntry('rejected')]); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).entryCount, 0); env.cleanup(); }); test('lifecycle is idempotent and teardown removes listeners, observers, and DOM', async () => { const env = installMockEnvironment(); assert.equal(env.document.getElementById('world-overlay-detection-surface'), null); - initWorldOverlay(env.engine); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); + initWorldOverlay(overlayEngine = env.engine); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.moveEnd.listeners.size, 1); assert.equal(env.window.listenerCount('gev:cockpit-mode-changed'), 1); @@ -492,7 +491,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM 'both host surfaces share absolute positioning and pointer passthrough', ); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); assert.equal(env.postRender.listeners.size, 0); assert.equal(env.moveEnd.listeners.size, 0); assert.equal(env.window.listenerCount('gev:cockpit-mode-changed'), 0); @@ -570,7 +569,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene' // changed was containment. This walks the ancestor chain the host actually // builds and evaluates each node against the shipped stylesheet. const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const css = readStylesheet(new URL('../../style.css', import.meta.url)); const surface = env.document.getElementById('world-overlay-detection-surface'); assert.ok(surface, 'the host owns a detection surface'); @@ -602,7 +601,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene' test('canvas backing store tracks CSS size and live DPR', () => { const env = installMockEnvironment({ width: 400, height: 300, dpr: 2 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let canvas = env.document.getElementById('world-overlay-canvas'); let detectionSurface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(canvas.width, 0); @@ -613,7 +612,7 @@ test('canvas backing store tracks CSS size and live DPR', () => { env.viewer.canvas.clientHeight = 200; env.window.devicePixelRatio = 1.5; env.window.dispatch('resize'); - setOverlayEntries('resize-probe', [selectedEntry('probe')]); + setOverlayEntries(overlayEngine, 'resize-probe', [selectedEntry('probe')]); env.postRender.raise(); canvas = env.document.getElementById('world-overlay-canvas'); detectionSurface = env.document.getElementById('world-overlay-detection-surface'); @@ -636,8 +635,8 @@ test('shared fade tuning reaches a host-painted card on the next rendered frame' }); try { setKeyholeFadeTuning({ fadeRatio: 0.16, outsideOpacity: 0.05 }); - initWorldOverlay(env.engine); - setOverlayEntries('fade-host', [selectedEntry('CARD', { + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'fade-host', [selectedEntry('CARD', { position: new Cesium.Cartesian3(0.85, 0, 0), variant: 'card', edgeFade: 'keyhole', @@ -666,7 +665,7 @@ test('inlined host alpha binding matches combinedOverlayAlpha across channel ran get() { return paintedAlphas.at(-1) ?? 1; }, set(value) { paintedAlphas.push(value); }, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const cases = [ { source: 1, entry: 0.9, temporal: 0.8, distance: 0.25, altitude: 0.25 }, { source: 0.8, entry: 0.75, temporal: 0.7, distance: 0.5, altitude: 0.5 }, @@ -676,7 +675,7 @@ test('inlined host alpha binding matches combinedOverlayAlpha across channel ran for (let index = 0; index < cases.length; index++) { const channels = cases[index]; env.viewer.camera.positionCartographic.height = 9500 - channels.altitude * 2000; - setOverlayEntries('alpha-binding', [{ + setOverlayEntries(overlayEngine, 'alpha-binding', [{ id: `alpha-${index}`, position: new Cesium.Cartesian3(0, 0, channels.distance * 10_000_000), variant: 'label', @@ -712,7 +711,7 @@ test('inlined host alpha binding matches combinedOverlayAlpha across channel ran test('a host dormant since init holds no canvas backing store', () => { const env = installMockEnvironment({ width: 1600, height: 900, dpr: 2 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const canvas = env.document.getElementById('world-overlay-canvas'); const detectionSurface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(canvas.width, 0); @@ -730,27 +729,27 @@ test('a host dormant since init holds no canvas backing store', () => { assert.equal(detectionSurface.width, 0); assert.equal(detectionSurface.height, 0); - setOverlayEntries('lazy-canvas', [selectedEntry('first-paint')]); + setOverlayEntries(overlayEngine, 'lazy-canvas', [selectedEntry('first-paint')]); env.advanceTime(120); env.postRender.raise(); assert.equal(canvas.width, 3200); assert.equal(canvas.height, 1800); assert.equal(detectionSurface.width, 3200); assert.equal(detectionSurface.height, 1800); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); // Emptying a source that already painted may keep the sized backing store; // only the dormant-since-init host is required to stay at 0x0. - assert.equal(clearOverlaySource('lazy-canvas'), true); + assert.equal(clearOverlaySource(overlayEngine, 'lazy-canvas'), true); env.advanceTime(600); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 0); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0); env.cleanup(); }); test('empty postRender stays inert through production mutation, resize, and clock noise', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); env.postRender.raise(); const settledCallCount = env.ctx.calls.length; const settledLayoutReads = { ...env.layoutReads }; @@ -763,13 +762,13 @@ test('empty postRender stays inert through production mutation, resize, and cloc } assert.equal(env.ctx.calls.length, settledCallCount); assert.deepEqual(env.layoutReads, settledLayoutReads); - assert.equal(getWorldOverlayDiagnostics().entryCount, 0); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).entryCount, 0); env.cleanup(); }); test('HUD mutations defer occluder selector scans until overlay paint work exists', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const settledQueries = { ...env.selectorQueries }; const hudMutation = { type: 'childList', @@ -781,7 +780,7 @@ test('HUD mutations defer occluder selector scans until overlay paint work exist assert.deepEqual(env.selectorQueries, settledQueries); env.advanceTime(101); - setOverlayEntries('lazy-occluders', [selectedEntry('paint-work')]); + setOverlayEntries(overlayEngine, 'lazy-occluders', [selectedEntry('paint-work')]); env.postRender.raise(); assert.ok(env.selectorQueries.querySelectorAll > settledQueries.querySelectorAll); env.cleanup(); @@ -794,8 +793,8 @@ test('ticking chrome churn (text swaps, non-chrome nodes) never invalidates a li { id: 'traffic-sync-chip', rect: { left: 200, top: 0, width: 90, height: 24 } }, ], }); - initWorldOverlay(env.engine); - setOverlayEntries('ticker-noise', [selectedEntry('live-entry')]); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'ticker-noise', [selectedEntry('live-entry')]); env.postRender.raise(); // A HUD clock tick: `textContent = ...` swaps TEXT nodes deep inside chrome. @@ -836,8 +835,8 @@ test('genuine chrome changes still invalidate: add, remove, and own-attribute fl { id: 'pp-toggles', rect: { left: 300, top: 40, width: 60, height: 200 } }, ], }); - initWorldOverlay(env.engine); - setOverlayEntries('chrome-changes', [selectedEntry('live-entry')]); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'chrome-changes', [selectedEntry('live-entry')]); env.postRender.raise(); const observer = env.mutationObservers[0]; @@ -876,8 +875,8 @@ test('chrome observers scope: body is childList discovery; occluder attributes a { selector: '.hud-top-left', rect: { left: 0, top: 0, width: 140, height: 44 } }, ], }); - initWorldOverlay(env.engine); - setOverlayEntries('observer-scope', [selectedEntry('live-entry')]); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'observer-scope', [selectedEntry('live-entry')]); env.postRender.raise(); // Chrome discovered LATE goes through the refresh-time observe site too. @@ -926,7 +925,7 @@ test('projection records never retain keys for entries that are no longer live', const env = installMockEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); for (let batch = 0; batch < 4; batch++) { const entries = Array.from({ length: 320 }, (_, index) => ( selectedEntry(`batch-${batch}-${index}`, { @@ -941,15 +940,15 @@ test('projection records never retain keys for entries that are no longer live', assert.ok(liveKeys.has(key), `stale projection record: ${key}`); } }; - setOverlayEntries('churn', entries, { cohortLimit: 8, collisionCapacity: 4 }); + setOverlayEntries(overlayEngine, 'churn', entries, { cohortLimit: 8, collisionCapacity: 4 }); env.postRender.raise(); assert.ok(recordMap); - assert.ok(recordMap.size < getWorldOverlayDiagnostics().entryCount); + assert.ok(recordMap.size < getWorldOverlayDiagnostics(overlayEngine).entryCount); assertNoStaleKeys(); - assert.equal(removeOverlayEntry('churn', entries[0].id), true); + assert.equal(removeOverlayEntry(overlayEngine, 'churn', entries[0].id), true); liveKeys.delete(`churn\u0000${entries[0].id}`); assertNoStaleKeys(); - assert.equal(clearOverlaySource('churn'), true); + assert.equal(clearOverlaySource(overlayEngine, 'churn'), true); liveKeys.clear(); assertNoStaleKeys(); } @@ -961,7 +960,7 @@ test('projection records never retain keys for entries that are no longer live', test('the per-domain candidate index stays bounded while identities churn', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const COHORT = 40; const BATCHES = 60; const batchEntries = (batch) => Array.from({ length: COHORT }, (_, index) => ( @@ -974,13 +973,13 @@ test('the per-domain candidate index stays bounded while identities churn', () = let peakIndexSize = 0; for (let batch = 0; batch < BATCHES; batch++) { - setOverlayEntries('churn', batchEntries(batch), { + setOverlayEntries(overlayEngine, 'churn', batchEntries(batch), { cohortLimit: COHORT, collisionCapacity: COHORT, }); env.advanceTime(16); env.postRender.raise(); - peakIndexSize = Math.max(peakIndexSize, getWorldOverlayDiagnostics().candidateIndexSize); + peakIndexSize = Math.max(peakIndexSize, getWorldOverlayDiagnostics(overlayEngine).candidateIndexSize); } // `resetFrameDomains` prunes at `live * 4 + 64` and the frame then republishes @@ -1020,15 +1019,15 @@ test('entry normalization validates required fields and source lifecycle is stab assert.throws(() => normalizeOverlayEntry('fires', { id: 'a', position: position(), variant: 'table' }), /variant/); const env = installMockEnvironment(); - initWorldOverlay(env.engine); - setOverlayEntries('fires', [normalized]); - upsertOverlayEntry('fires', { id: 'b', position: position(), variant: 'card' }); - assert.deepEqual(getWorldOverlayDiagnostics().entriesBySource, { fires: 2 }); - assert.equal(removeOverlayEntry('fires', 'a'), true); - assert.equal(removeOverlayEntry('fires', 'missing'), false); - setOverlaySourceVisible('fires', false); - assert.equal(clearOverlaySource('fires'), true); - assert.deepEqual(getWorldOverlayDiagnostics().entriesBySource, { fires: 0 }); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'fires', [normalized]); + upsertOverlayEntry(overlayEngine, 'fires', { id: 'b', position: position(), variant: 'card' }); + assert.deepEqual(getWorldOverlayDiagnostics(overlayEngine).entriesBySource, { fires: 2 }); + assert.equal(removeOverlayEntry(overlayEngine, 'fires', 'a'), true); + assert.equal(removeOverlayEntry(overlayEngine, 'fires', 'missing'), false); + setOverlaySourceVisible(overlayEngine, 'fires', false); + assert.equal(clearOverlaySource(overlayEngine, 'fires'), true); + assert.deepEqual(getWorldOverlayDiagnostics(overlayEngine).entriesBySource, { fires: 0 }); env.cleanup(); }); @@ -1037,14 +1036,14 @@ test('accessible actions announce only accepted focus and expose selected state' let acceptedActivations = 0; let staleActivations = 0; let actions = null; - const unsubscribe = subscribeWorldOverlayActions((view) => { + const unsubscribe = subscribeWorldOverlayActions(overlayEngine, (view) => { actions = view; }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); assert.deepEqual(actions.items, []); assert.equal(actions.status, ''); assert.equal(actions.announced, 0); - setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', { + setOverlayEntries(overlayEngine, 'accessible-actions', [selectedEntry('vessel:123', { interactive: true, accessibilityLabel: 'Focus vessel TEST, MMSI 123', activate: () => { @@ -1059,16 +1058,16 @@ test('accessible actions announce only accepted focus and expose selected state' assert.equal(item.label, 'Focus vessel TEST, MMSI 123'); assert.equal(item.selected, true); const owner = actions.owner; - activateWorldOverlayAction({}, item.key); + activateWorldOverlayAction(overlayEngine, {}, item.key); assert.equal(acceptedActivations, 0, 'a list from another host cannot activate'); - activateWorldOverlayAction(owner, item.key); + activateWorldOverlayAction(overlayEngine, owner, item.key); assert.equal(acceptedActivations, 1); assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123'); assert.equal(actions.announced, 1); - activateWorldOverlayAction(owner, item.key); + activateWorldOverlayAction(overlayEngine, owner, item.key); assert.equal(actions.announced, 2, 'a repeated focus is a new announcement'); - setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', { + setOverlayEntries(overlayEngine, 'accessible-actions', [selectedEntry('vessel:123', { selected: false, interactive: true, accessibilityLabel: 'Focus vessel TEST, MMSI 123', @@ -1081,7 +1080,7 @@ test('accessible actions announce only accepted focus and expose selected state' assert.equal(actions.items.length, 1); assert.equal(actions.items[0].selected, false); assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123', 'the list keeps its last announcement'); - activateWorldOverlayAction(owner, actions.items[0].key); + activateWorldOverlayAction(overlayEngine, owner, actions.items[0].key); assert.equal(staleActivations, 1); assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123', 'a refused focus announces nothing'); assert.equal(actions.announced, 2); @@ -1125,7 +1124,7 @@ test('viewport and horizon culling obey explicit policy inputs', () => { test('paint lanes are deterministic across all seven binding lanes', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); assert.deepEqual(WORLD_OVERLAY_PAINT_LANES, [ 'detection', 'ambient-label', @@ -1144,7 +1143,7 @@ test('paint lanes are deterministic across all seven binding lanes', () => { selectedEntry('SELECTED', { variant: 'selected', paintLane: 'selected' }), selectedEntry('TRACKED', { variant: 'tracked', paintLane: 'tracked' }), ].reverse(); - setOverlayEntries('lanes', laneEntries); + setOverlayEntries(overlayEngine, 'lanes', laneEntries); env.postRender.raise(); const textOrder = env.ctx.calls .filter(([name]) => name === 'fillText') @@ -1169,15 +1168,15 @@ test('custom detection lane receives the shared host frame and paints below ordi dpr: 2, occluderRect: { left: 10, top: 20, width: 80, height: 40 }, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let capturedFrame = null; let paintCount = 0; - const lane = registerWorldOverlayPaintLane('detection', (frame) => { + const lane = registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { capturedFrame = frame; paintCount++; frame.ctx.fillText('CUSTOM-DETECTION', 20, 20); }, { id: 'detection', active: true, target: 'detection' }); - setOverlayEntries('cards', [selectedEntry('HOST-CARD', { + setOverlayEntries(overlayEngine, 'cards', [selectedEntry('HOST-CARD', { variant: 'label', paintLane: 'ambient-label', })]); @@ -1219,7 +1218,7 @@ test('custom detection lane receives the shared host frame and paints below ordi lane.setActive(false); env.postRender.raise(); assert.equal(paintCount, 1); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); lane.setActive(true); lane.requestPaint(); assert.equal(env.document.getElementById('world-overlay-canvas'), null); @@ -1233,8 +1232,8 @@ test('custom-lane painter state is bracketed by host save/restore', () => { // host wraps every custom-lane painter in save/try/finally-restore. Pin the // bracketing structurally (the mock ctx records call order, not state). const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); - initWorldOverlay(env.engine); - const lane = registerWorldOverlayPaintLane('detection', (frame) => { + initWorldOverlay(overlayEngine = env.engine); + const lane = registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { frame.ctx.beginPath(); frame.ctx.rect(0, 0, 1, 1); frame.ctx.clip(); @@ -1260,9 +1259,9 @@ test('custom-lane painter state is bracketed by host save/restore', () => { test('custom lanes without the detection target paint on the shared canvas', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let target = null; - registerWorldOverlayPaintLane('ambient-label', (frame) => { + registerWorldOverlayPaintLane(overlayEngine, 'ambient-label', (frame) => { target = { surface: frame.surface, ctx: frame.ctx }; }, { id: 'shared-custom', active: true }); env.postRender.raise(); @@ -1312,15 +1311,15 @@ test('UI exclusions stay per-rect: overlapping chrome never merges into a boundi { id: 'command-dock', rect: chrome.bottom }, ], }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let uiRects = null; - registerWorldOverlayPaintLane('detection', (frame) => { + registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { uiRects = frame.uiRects.slice(0, frame.uiRectCount) .map(({ x, y, w, h }) => ({ x, y, w, h })); }, { id: 'detection', active: true, target: 'detection' }); // Anchored just clear of the left panel, close enough that its default // above-the-anchor placement would collide with it. - setOverlayEntries('ambient', [selectedEntry('CLEAR-OF-BOTH', { + setOverlayEntries(overlayEngine, 'ambient', [selectedEntry('CLEAR-OF-BOTH', { position: positionAtScreen(80, 100), })]); env.postRender.raise(); @@ -1331,8 +1330,8 @@ test('UI exclusions stay per-rect: overlapping chrome never merges into a boundi { x: 34, y: 194, w: 212, h: 72 }, ], 'each occluder keeps its own rectangle'); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); - const painted = getOverlayPaintRect('ambient', 'CLEAR-OF-BOTH'); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + const painted = getOverlayPaintRect(overlayEngine, 'ambient', 'CLEAR-OF-BOTH'); assert.ok(Number.isFinite(painted.anchorX)); assert.ok(Number.isFinite(painted.anchorY)); assert.ok(painted, 'the entry published a paint rectangle'); @@ -1369,19 +1368,19 @@ test('UI exclusions ignore chrome the user cannot see', () => { { id: 'pp-toggles', rect: { left: 0, top: 0, width: 0, height: 0 } }, ], }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let uiRectCount = -1; - registerWorldOverlayPaintLane('detection', (frame) => { uiRectCount = frame.uiRectCount; }, { + registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { uiRectCount = frame.uiRectCount; }, { id: 'detection', active: true, target: 'detection', }); - setOverlayEntries('ambient', [selectedEntry('VISIBLE-WORLD')]); + setOverlayEntries(overlayEngine, 'ambient', [selectedEntry('VISIBLE-WORLD')]); env.postRender.raise(); assert.equal(uiRectCount, 0, 'hidden, display:none, visibility:hidden and collapsed chrome contribute nothing', ); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); env.cleanup(); }); @@ -1396,16 +1395,16 @@ test('chrome that fills the viewport suppresses neither cards nor the detection dpr: 1, occluderRect: { left: 0, top: 0, width: 400, height: 300 }, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let detectionPaints = 0; - registerWorldOverlayPaintLane('detection', (frame) => { + registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { detectionPaints++; frame.ctx.fillText('DETECTION-ALIVE', 10, 10); }, { id: 'detection', active: true, target: 'detection' }); - setOverlayEntries('blocked', [selectedEntry('behind-ui')]); + setOverlayEntries(overlayEngine, 'blocked', [selectedEntry('behind-ui')]); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1, + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1, 'the card survives a viewport-filling exclusion instead of being deleted'); assert.equal(detectionPaints, 1); assert.ok( @@ -1435,10 +1434,10 @@ test('a placement is only ever kept under chrome that composites ABOVE the host' const paintedUnder = (occluders) => { const env = installMockEnvironment({ width: 400, height: 300, dpr: 1, occluders }); - initWorldOverlay(env.engine); - setOverlayEntries('ambient', [selectedEntry('CONTACT')]); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'ambient', [selectedEntry('CONTACT')]); env.postRender.raise(); - const { paintedCount } = getWorldOverlayDiagnostics(); + const { paintedCount } = getWorldOverlayDiagnostics(overlayEngine); env.cleanup(); return paintedCount; }; @@ -1479,10 +1478,10 @@ test('cockpit keeps its cards and its detection lane, and hides only the tracked { selector: '.cockpit-roll-arc', inInventory: false, rect: { left: 88, top: 2, width: 224, height: 32 } }, ], }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); let uiRectCount = -1; let detectionPaints = 0; - registerWorldOverlayPaintLane('detection', (frame) => { + registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { detectionPaints++; uiRectCount = frame.uiRectCount; }, { @@ -1490,20 +1489,20 @@ test('cockpit keeps its cards and its detection lane, and hides only the tracked active: true, target: 'detection', }); - setOverlayEntries('vessels', [ + setOverlayEntries(overlayEngine, 'vessels', [ selectedEntry('VESSEL-A', { position: positionAtScreen(200, 90) }), selectedEntry('VESSEL-B', { position: positionAtScreen(120, 130) }), ]); - setOverlayEntries('trackedReadout', [selectedEntry('TRACKED')], { hideInCockpit: true }); + setOverlayEntries(overlayEngine, 'trackedReadout', [selectedEntry('TRACKED')], { hideInCockpit: true }); env.window.dispatch('gev:cockpit-mode-changed', { detail: { active: true } }); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 2, + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 2, 'both ambient cards survive cockpit entry'); - assert.ok(getOverlayPaintRect('vessels', 'VESSEL-A')); - assert.ok(getOverlayPaintRect('vessels', 'VESSEL-B')); - assert.equal(getOverlayPaintRect('trackedReadout', 'TRACKED'), null, + assert.ok(getOverlayPaintRect(overlayEngine, 'vessels', 'VESSEL-A')); + assert.ok(getOverlayPaintRect(overlayEngine, 'vessels', 'VESSEL-B')); + assert.equal(getOverlayPaintRect(overlayEngine, 'trackedReadout', 'TRACKED'), null, 'the source-level hideInCockpit rule still hides the tracked readout'); assert.equal(detectionPaints, 1, 'Panoptic keeps painting in cockpit'); assert.equal(uiRectCount, 2, @@ -1525,8 +1524,8 @@ test('opt-in anchor separation thins a co-located cohort before the arbiter sees const anchors = [500, 540, 580, 620, 660, 700]; const project = (separation) => { const env = installMockEnvironment({ width: 400, height: 800, dpr: 1 }); - initWorldOverlay(env.engine); - setOverlayEntries('cctv', anchors.map((y, index) => selectedEntry(`cam-${index}`, { + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'cctv', anchors.map((y, index) => selectedEntry(`cam-${index}`, { position: positionAtScreen(200, y, 400, 800), protected: false, selected: false, @@ -1535,7 +1534,7 @@ test('opt-in anchor separation thins a co-located cohort before the arbiter sees minAnchorSeparationPx: separation, })), { collisionCapacity: 16, cohortLimit: 16 }); env.postRender.raise(); - const { projectedCount } = getWorldOverlayDiagnostics(); + const { projectedCount } = getWorldOverlayDiagnostics(overlayEngine); env.cleanup(); return projectedCount; }; @@ -1565,21 +1564,21 @@ test('the occluder inventory carries no cockpit line-art chrome', () => { test('position getters snapshot once per frame and cockpit-gated sources disappear', () => { const env = installMockEnvironment(); let getterCalls = 0; - initWorldOverlay(env.engine); - setOverlayEntries('tracked', [selectedEntry('flight', { + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'tracked', [selectedEntry('flight', { position: () => { getterCalls++; return position(); }, interactive: true, })], { hideInCockpit: true }); env.postRender.raise(); assert.equal(getterCalls, 1); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); env.postRender.raise(); assert.equal(getterCalls, 2); env.window.dispatch('gev:cockpit-mode-changed', { detail: { active: true } }); env.postRender.raise(); assert.equal(getterCalls, 2); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 0); - assert.equal(hitTestWorldOverlay(200, 120), null); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0); + assert.equal(hitTestWorldOverlay(overlayEngine, 200, 120), null); env.cleanup(); }); @@ -1601,10 +1600,10 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec assert.equal(selectBoundedOverlayCohort([...normalized, protectedEntry], 3).length, 4); const env = installMockEnvironment(); - initWorldOverlay(env.engine); - setOverlayEntries('bulk', normalized, { cohortLimit: 3, collisionCapacity: 2 }); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'bulk', normalized, { cohortLimit: 3, collisionCapacity: 2 }); env.postRender.raise(); - const diagnostics = getWorldOverlayDiagnostics(); + const diagnostics = getWorldOverlayDiagnostics(overlayEngine); assert.equal(diagnostics.entryCount, 10); assert.equal(diagnostics.candidateCount, 3); assert.ok(diagnostics.selectedCount <= 2); @@ -1614,7 +1613,7 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec test('wildlife common names use the host ambient collision domain and never paint overlapping labels', (t) => { const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 }); t.after(() => env.cleanup()); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const entries = [ ['hawk', 'Red-tailed Hawk', position()], ['maple', 'Red Maple', position()], @@ -1625,11 +1624,11 @@ test('wildlife common names use the host ambient collision domain and never pain return entry && { ...entry, position: anchor, horizonCull: false, edgeFade: 'none' }; }).filter(Boolean); assert.equal(entries.length, 3, 'unidentified icons never enter the overlay host'); - setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS); env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - const rects = entries.map((entry) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, entry.id)).filter(Boolean); + const rects = entries.map((entry) => getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, entry.id)).filter(Boolean); assert.ok(rects.length >= 2 && rects.length <= 3, 'the host places separated names and relocates or drops coincident names'); for (let i = 0; i < rects.length; i++) { assert.equal(rects[i].entry.collisionGroup, 'ambient-label'); @@ -1667,7 +1666,7 @@ function cityCard(row, x, y, width = 1000, height = 600, image = { width: 75, he }; } function assertDisjointPaint(entries) { - const rects = entries.map(([source, entry]) => getOverlayPaintRect(source, entry.id)).filter(Boolean); + const rects = entries.map(([source, entry]) => getOverlayPaintRect(overlayEngine, source, entry.id)).filter(Boolean); for (let i = 0; i < rects.length; i++) for (let j = i + 1; j < rects.length; j++) { const a = rects[i], b = rects[j]; @@ -1681,21 +1680,21 @@ test('close-range dense city labels and photo cards never overlap, including fra const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 }); t.after(() => env.cleanup()); env.viewer.camera.positionCartographic.height = 9000; - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const labels = Array.from({ length: 150 }, (_, i) => cityLabel(citySighting(`city:${i}`, { favesCount: 150 - i }), 65 + (i % 30) * 28, 150 + Math.floor(i / 30) * 62)); const cards = Array.from({ length: 8 }, (_, i) => cityCard(citySighting(`photo:${i}`), 140 + (i % 4) * 210, 180 + Math.floor(i / 4) * 240)); const all = [...labels.map((e) => [WILDLIFE_LABEL_SOURCE_ID, e]), ...cards.map((e) => [WILDLIFE_OVERLAY_SOURCE_ID, e])]; - setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS); - setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, cards, WILDLIFE_OVERLAY_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, cards, WILDLIFE_OVERLAY_SOURCE_OPTIONS); for (const ms of [0, 75, 125]) { env.advanceTime(ms); env.postRender.raise(); assertDisjointPaint(all); } - assert.equal(cards.filter((e) => getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8); - const placedLabels = labels.filter((e) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, e.id)); + assert.equal(cards.filter((e) => getOverlayPaintRect(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8); + const placedLabels = labels.filter((e) => getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, e.id)); assert.ok(placedLabels.length > 5 && placedLabels.length < labels.length, 'label count is capped by the remaining space'); // A pan/zoom reprojects sticky placements on every frame before moveEnd. env.viewer.camera.frustum.projectionMatrix[0] = 0.7; @@ -1712,41 +1711,41 @@ test('close-range dense city labels and photo cards never overlap, including fra test('labels yield immediately to photo cards, then compete by ambientScore without disturbing the card', (t) => { const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 }); t.after(() => env.cleanup()); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const low = cityLabel(citySighting('low'), 200, 210, 400, 350); const high = cityLabel(citySighting('high', { favesCount: 1000 }), 200, 210, 400, 350); const card = cityCard(citySighting('card', { quality: 'needs_id' }), 200, 210, 400, 350); const all = [[WILDLIFE_LABEL_SOURCE_ID, low], [WILDLIFE_LABEL_SOURCE_ID, high], [WILDLIFE_OVERLAY_SOURCE_ID, card]]; - setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [low, high], WILDLIFE_LABEL_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, [low, high], WILDLIFE_LABEL_SOURCE_OPTIONS); env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); assert.equal(assertDisjointPaint(all).length, 2, 'both label slots are initially free'); - setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [card], WILDLIFE_OVERLAY_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [card], WILDLIFE_OVERLAY_SOURCE_OPTIONS); env.postRender.raise(); for (const ms of [1, 74, 125]) { env.advanceTime(ms); env.postRender.raise(); assertDisjointPaint(all); - assert.ok(getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id)); - assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id)); - assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id), null); + assert.ok(getOverlayPaintRect(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, card.id)); + assert.ok(getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, high.id)); + assert.equal(getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, low.id), null); } - const before = { ...getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id) }; + const before = { ...getOverlayPaintRect(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, card.id) }; const promoted = cityLabel(citySighting('low', { favesCount: 1_000_000 }), 200, 210, 400, 350); - setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS); env.postRender.raise(); assertDisjointPaint(all); - assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id)); - assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id), null); - const after = getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id); + assert.ok(getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, low.id)); + assert.equal(getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, high.id), null); + const after = getOverlayPaintRect(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, card.id); assert.deepEqual([after.x, after.y, after.w, after.h], [before.x, before.y, before.w, before.h], 'the card retains its sticky placement'); const selected = { ...buildWildlifeSelectedLabel(citySighting('selected')), position: card.position, horizonCull: false, edgeFade: 'none' }; - setOverlayEntries('wildlife-sightings-selected', [selected]); + setOverlayEntries(overlayEngine, 'wildlife-sightings-selected', [selected]); env.postRender.raise(); assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]); // Exit fades still occupy space; labels must wait until the card is gone. - setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS); + setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS); for (const ms of [1, 149, 151]) { env.advanceTime(ms); env.postRender.raise(); @@ -1757,28 +1756,28 @@ test('labels yield immediately to photo cards, then compete by ambientScore with test('photo visibility reports only painted membership changes, ignoring loading cards and idle frames', (t) => { const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 }); t.after(() => env.cleanup()); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const changes = []; const options = { ...WILDLIFE_OVERLAY_SOURCE_OPTIONS, onVisibilityChange: (ids) => changes.push([...ids]) }; const r = citySighting('anchor'); - setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options); + setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options); env.postRender.raise(); assert.deepEqual(changes, []); - setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350)], options); + setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350)], options); env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); assert.deepEqual(changes, [['sighting:anchor']]); for (let i = 0; i < 10; i++) env.postRender.raise(); assert.equal(changes.length, 1, 'unchanged visibility emits nothing'); - setOverlaySourceVisible(WILDLIFE_OVERLAY_SOURCE_ID, false); + setOverlaySourceVisible(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, false); env.postRender.raise(); assert.deepEqual(changes, [['sighting:anchor'], []]); }); test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregate budget', () => { const env = installMockEnvironment({ width: 1600, height: 900, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const makeEntries = (sourceOffset, count = 200) => Array.from({ length: count }, (_, index) => { const slot = index % 160; const column = slot % 16; @@ -1799,30 +1798,30 @@ test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregat collisionGroup: 'ambient-card', }; }); - setOverlayEntries('local-datacenters', makeEntries('dc'), { + setOverlayEntries(overlayEngine, 'local-datacenters', makeEntries('dc'), { cohortLimit: 160, collisionCapacity: 96, }); - setOverlayEntries('local-dams', makeEntries('dam'), { + setOverlayEntries(overlayEngine, 'local-dams', makeEntries('dam'), { cohortLimit: 160, collisionCapacity: 96, }); - setOverlayEntries('firms', makeEntries('fire').slice(0, 18), { + setOverlayEntries(overlayEngine, 'firms', makeEntries('fire').slice(0, 18), { cohortLimit: 18, collisionCapacity: 18, }); - setOverlayEntries('ais-live-vessels', makeEntries('vessel', 900), { + setOverlayEntries(overlayEngine, 'ais-live-vessels', makeEntries('vessel', 900), { cohortLimit: 900, collisionCapacity: 900, }); - setOverlayEntries('cctv', makeEntries('cctv', 40), { + setOverlayEntries(overlayEngine, 'cctv', makeEntries('cctv', 40), { cohortLimit: 40, collisionCapacity: 40, }); env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - const diagnostics = getWorldOverlayDiagnostics(); + const diagnostics = getWorldOverlayDiagnostics(overlayEngine); assert.equal(diagnostics.candidateCount, 1278, 'the complete configured cohorts join one bounded domain'); assert.ok(diagnostics.selectedCount > 96, 'the second source must enlarge the shared lane budget'); assert.equal(AMBIENT_CARD_COLLISION_CAPACITY, 1150); @@ -1835,14 +1834,14 @@ test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregat test('paint rectangles publish from a pool and topmost interactive lookup wins', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); - setOverlayEntries('hits', [ + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'hits', [ selectedEntry('under', { interactive: true, zIndex: 1, collisionGroup: 'under' }), selectedEntry('over', { interactive: true, zIndex: 2, collisionGroup: 'over' }), ]); env.postRender.raise(); - const under = getOverlayPaintRect('hits', 'under'); - const over = getOverlayPaintRect('hits', 'over'); + const under = getOverlayPaintRect(overlayEngine, 'hits', 'under'); + const over = getOverlayPaintRect(overlayEngine, 'hits', 'over'); assert.ok(under && over); assert.ok(under.x < over.x + over.w && under.x + under.w > over.x); assert.ok(under.y < over.y + over.h && under.y + under.h > over.y); @@ -1851,16 +1850,16 @@ test('paint rectangles publish from a pool and topmost interactive lookup wins', ['under', 'over'], 'higher-z entry is the last painted card', ); - const hit = hitTestWorldOverlay(200, 120); + const hit = hitTestWorldOverlay(overlayEngine, 200, 120); assert.equal(hit.entryId, 'over', 'hit test walks painted entries from last to first'); assert.equal(hit.sourceId, 'hits'); - assert.equal(getWorldOverlayDiagnostics().hitRectCount, 2); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).hitRectCount, 2); env.cleanup(); }); test('CCTV stable frame slots gate ambient chrome and wire the exact host hit rectangle', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const slot = createFrameSlot(); const entry = createCctvThumbnailOverlayEntry({ id: 'cam-a', @@ -1870,15 +1869,15 @@ test('CCTV stable frame slots gate ambient chrome and wire the exact host hit re }); entry.horizonCull = false; entry.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries('cctv', [entry], { + setOverlayEntries(overlayEngine, 'cctv', [entry], { cohortLimit: 40, collisionCapacity: 40, moving: true, solveIntervalMs: 125, }); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 0, 'no ambient placeholder before frame one'); - assert.equal(hitTestWorldOverlay(250, 150, { sourceId: 'cctv' }), null); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0, 'no ambient placeholder before frame one'); + assert.equal(hitTestWorldOverlay(overlayEngine, 250, 150, { sourceId: 'cctv' }), null); const firstFrame = { name: 'frame-one' }; slot.frame = firstFrame; @@ -1887,10 +1886,10 @@ test('CCTV stable frame slots gate ambient chrome and wire the exact host hit re env.postRender.raise(); env.advanceTime(16); env.postRender.raise(); - const rect = getOverlayPaintRect('cctv', 'cam-a'); + const rect = getOverlayPaintRect(overlayEngine, 'cctv', 'cam-a'); assert.ok(rect); assert.deepEqual({ w: rect.w, h: rect.h }, { w: 104, h: 77 }); - const hit = hitTestWorldOverlay(rect.x + 1, rect.y + 1, { sourceId: 'cctv' }); + const hit = hitTestWorldOverlay(overlayEngine, rect.x + 1, rect.y + 1, { sourceId: 'cctv' }); assert.equal(hit?.entryId, 'cam-a'); assert.equal(hit?.entry.interactive, true); assert.ok(env.ctx.calls.some((call) => call[0] === 'drawImage' && call[1] === firstFrame)); @@ -1909,7 +1908,7 @@ test('CCTV stable frame slots gate ambient chrome and wire the exact host hit re test('pinned CCTV chrome may paint before frame one while ordinary safe-top policy still yields', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const ambient = createCctvThumbnailOverlayEntry({ id: 'ambient-top', position: new Cesium.Cartesian3(0, 0.9, 0), @@ -1927,10 +1926,10 @@ test('pinned CCTV chrome may paint before frame one while ordinary safe-top poli }); pinned.horizonCull = false; pinned.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries('cctv', [ambient, pinned], { cohortLimit: 40, collisionCapacity: 40 }); + setOverlayEntries(overlayEngine, 'cctv', [ambient, pinned], { cohortLimit: 40, collisionCapacity: 40 }); env.postRender.raise(); - assert.equal(getOverlayPaintRect('cctv', 'ambient-top'), null, 'ambient anchor yields to top HUD band'); - assert.ok(getOverlayPaintRect('cctv', 'pinned-top'), 'explicit hover pin paints chrome immediately'); + assert.equal(getOverlayPaintRect(overlayEngine, 'cctv', 'ambient-top'), null, 'ambient anchor yields to top HUD band'); + assert.ok(getOverlayPaintRect(overlayEngine, 'cctv', 'pinned-top'), 'explicit hover pin paints chrome immediately'); env.cleanup(); }); @@ -1939,7 +1938,7 @@ test('safe-top yield culls an uncontested ambient entry at projection, with a be // pinned neighbor to arbiter-collide with, so only the projection gate can // null it; the below-band twin proves the paint path itself is live. const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const above = createCctvThumbnailOverlayEntry({ id: 'yield-above', position: new Cesium.Cartesian3(-0.5, 0.9, 0), @@ -1956,16 +1955,16 @@ test('safe-top yield culls an uncontested ambient entry at projection, with a be }); below.horizonCull = false; below.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries('cctv', [above, below], { cohortLimit: 40, collisionCapacity: 40 }); + setOverlayEntries(overlayEngine, 'cctv', [above, below], { cohortLimit: 40, collisionCapacity: 40 }); env.postRender.raise(); env.advanceTime(130); env.postRender.raise(); env.advanceTime(16); env.postRender.raise(); - const diagnostics = getWorldOverlayDiagnostics(); + const diagnostics = getWorldOverlayDiagnostics(overlayEngine); assert.equal(diagnostics.projectedCount, 1, 'the above-band anchor is culled at projection, not by collision'); - assert.equal(getOverlayPaintRect('cctv', 'yield-above'), null, 'anchor above the HUD safe band must yield'); - assert.ok(getOverlayPaintRect('cctv', 'yield-below'), 'below-band control paints (guard is non-vacuous)'); + assert.equal(getOverlayPaintRect(overlayEngine, 'cctv', 'yield-above'), null, 'anchor above the HUD safe band must yield'); + assert.ok(getOverlayPaintRect(overlayEngine, 'cctv', 'yield-below'), 'below-band control paints (guard is non-vacuous)'); env.cleanup(); }); @@ -1977,7 +1976,7 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve' get() { return paintedAlphas.at(-1) ?? 1; }, set(value) { paintedAlphas.push(value); }, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const entry = createCctvThumbnailOverlayEntry({ id: 'altitude-curve', position: position(), @@ -1996,7 +1995,7 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve' endValue: 0.35, smoothToMid: true, }); - setOverlayEntries('cctv-altitude', [entry], { cohortLimit: 1, collisionCapacity: 0 }); + setOverlayEntries(overlayEngine, 'cctv-altitude', [entry], { cohortLimit: 1, collisionCapacity: 0 }); const cases = [ { altitude: 2850, scale: 0.9140625, alpha: 1 }, @@ -2028,7 +2027,7 @@ test('scene presentation samples once per frame and recovers after a callback fa get() { return alphas.at(-1) ?? 1; }, set(value) { alphas.push(value); }, }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const entry = createCctvThumbnailOverlayEntry({ id: 'scene-presentation', position: position(), title: 'SCENE', frameSlot: { frame: { ready: true }, stamp: 1 }, active: true, @@ -2051,7 +2050,7 @@ test('scene presentation samples once per frame and recovers after a callback fa }; entry.leaderProgress = () => { sampled.leader++; return leader; }; entry.contentAlpha = () => { sampled.content++; return content; }; - const publish = () => setOverlayEntries('scene-presentation', [entry], { + const publish = () => setOverlayEntries(overlayEngine, 'scene-presentation', [entry], { cohortLimit: 1, collisionCapacity: 0, }); const frame = () => { @@ -2102,7 +2101,7 @@ test('scene presentation samples once per frame and recovers after a callback fa test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); env.viewer.camera.positionCartographic.height = 9500; - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const entry = createCctvThumbnailOverlayEntry({ id: 'scaled-leader', position: position(), @@ -2115,7 +2114,7 @@ test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => entry.edgeFade = 'none'; entry.altitudeFadeStart = Number.POSITIVE_INFINITY; entry.altitudeFadeEnd = Number.POSITIVE_INFINITY; - setOverlayEntries('cctv-scaled-leader', [entry], { cohortLimit: 1, collisionCapacity: 0 }); + setOverlayEntries(overlayEngine, 'cctv-scaled-leader', [entry], { cohortLimit: 1, collisionCapacity: 0 }); env.postRender.raise(); const scale = env.ctx.calls.find(([name]) => name === 'scale')?.[1]; @@ -2128,7 +2127,7 @@ test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => test('active CCTV thumbnail is protected outside the ambient quota and excludes ambient footprint', () => { const env = installMockEnvironment({ width: 800, height: 600, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const readySlot = () => ({ frame: { ready: true }, stamp: 1 }); const active = createCctvThumbnailOverlayEntry({ id: 'active', @@ -2147,11 +2146,11 @@ test('active CCTV thumbnail is protected outside the ambient quota and excludes }); ambient.horizonCull = false; ambient.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries('cctv-active', [active], { cohortLimit: 1, collisionCapacity: 0 }); - setOverlayEntries('cctv-ambient', [ambient], { cohortLimit: 1, collisionCapacity: 1 }); + setOverlayEntries(overlayEngine, 'cctv-active', [active], { cohortLimit: 1, collisionCapacity: 0 }); + setOverlayEntries(overlayEngine, 'cctv-ambient', [ambient], { cohortLimit: 1, collisionCapacity: 1 }); env.postRender.raise(); - const activeRect = getOverlayPaintRect('cctv-active', 'active'); - const ambientRect = getOverlayPaintRect('cctv-ambient', 'ambient'); + const activeRect = getOverlayPaintRect(overlayEngine, 'cctv-active', 'active'); + const ambientRect = getOverlayPaintRect(overlayEngine, 'cctv-ambient', 'ambient'); assert.ok(activeRect, 'active card bypasses a zero ambient quota'); if (ambientRect) { const intersects = ambientRect.x < activeRect.x + activeRect.w @@ -2165,9 +2164,9 @@ test('active CCTV thumbnail is protected outside the ambient quota and excludes test('protected tracked entry bypasses ambient quota and excludes its paint footprint', () => { const env = installMockEnvironment({ width: 800, height: 600, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const position = new Cesium.Cartesian3(0, 0, 0); - setOverlayEntries('ambient-source', [{ + setOverlayEntries(overlayEngine, 'ambient-source', [{ id: 'ambient', position, variant: 'card', @@ -2178,7 +2177,7 @@ test('protected tracked entry bypasses ambient quota and excludes its paint foot horizonCull: false, edgeFade: 'none', }], { cohortLimit: 1, collisionCapacity: 1 }); - setOverlayEntries('tracked', [{ + setOverlayEntries(overlayEngine, 'tracked', [{ id: 'flights:test', position, variant: 'tracked', @@ -2193,8 +2192,8 @@ test('protected tracked entry bypasses ambient quota and excludes its paint foot edgeFade: 'none', }], { cohortLimit: 1, collisionCapacity: 0 }); env.postRender.raise(); - const trackedRect = getOverlayPaintRect('tracked', 'flights:test'); - const ambientRect = getOverlayPaintRect('ambient-source', 'ambient'); + const trackedRect = getOverlayPaintRect(overlayEngine, 'tracked', 'flights:test'); + const ambientRect = getOverlayPaintRect(overlayEngine, 'ambient-source', 'ambient'); assert.ok(trackedRect, 'protected tracked card paints with a zero ambient quota'); if (ambientRect) { const intersects = ambientRect.x < trackedRect.x + trackedRect.w @@ -2208,9 +2207,9 @@ test('protected tracked entry bypasses ambient quota and excludes its paint foot test('three clustered protected cards fall back to the least-overlapping placement', () => { const env = installMockEnvironment({ width: 800, height: 600, dpr: 1 }); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const anchor = new Cesium.Cartesian3(0, 0, 0); - setOverlayEntries('protected-cluster', [ + setOverlayEntries(overlayEngine, 'protected-cluster', [ selectedEntry('wide-first', { position: anchor, title: 'WIDE FIRST PROTECTED CARD', @@ -2235,9 +2234,9 @@ test('three clustered protected cards fall back to the least-overlapping placeme ], { cohortLimit: 3, collisionCapacity: 0 }); env.postRender.raise(); - const first = getOverlayPaintRect('protected-cluster', 'wide-first'); - const second = getOverlayPaintRect('protected-cluster', 'short-second'); - const third = getOverlayPaintRect('protected-cluster', 'medium-third'); + const first = getOverlayPaintRect(overlayEngine, 'protected-cluster', 'wide-first'); + const second = getOverlayPaintRect(overlayEngine, 'protected-cluster', 'short-second'); + const third = getOverlayPaintRect(overlayEngine, 'protected-cluster', 'medium-third'); const overlapArea = (a, b) => Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)); assert.ok(first && second && third); @@ -2249,8 +2248,8 @@ test('three clustered protected cards fall back to the least-overlapping placeme test('steady frames reuse paint-item and paint-rectangle pool identities', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); - setOverlayEntries('pool-reuse', [selectedEntry('ambient', { + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'pool-reuse', [selectedEntry('ambient', { selected: false, protected: false, collisionGroup: 'pool-reuse', @@ -2258,14 +2257,14 @@ test('steady frames reuse paint-item and paint-rectangle pool identities', () => env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - const firstRect = getOverlayPaintRect('pool-reuse', 'ambient'); - const settled = getWorldOverlayDiagnostics(); + const firstRect = getOverlayPaintRect(overlayEngine, 'pool-reuse', 'ambient'); + const settled = getWorldOverlayDiagnostics(overlayEngine); for (let frame = 0; frame < 12; frame++) { env.advanceTime(16); env.postRender.raise(); } - const secondRect = getOverlayPaintRect('pool-reuse', 'ambient'); - const steady = getWorldOverlayDiagnostics(); + const secondRect = getOverlayPaintRect(overlayEngine, 'pool-reuse', 'ambient'); + const steady = getWorldOverlayDiagnostics(overlayEngine); assert.ok(firstRect && secondRect); assert.equal(secondRect, firstRect); assert.equal(steady.paintedCount, settled.paintedCount); @@ -2276,27 +2275,28 @@ test('steady frames reuse paint-item and paint-rectangle pool identities', () => test('destroy empties paint pools and releases pooled record and entry payloads', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); - setOverlayEntries('pool-release', [selectedEntry('payload', { + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'pool-release', [selectedEntry('payload', { metadata: { retainedProbe: new Uint8Array(1024) }, })]); env.postRender.raise(); - const firstRect = getOverlayPaintRect('pool-release', 'payload'); - const painting = getWorldOverlayDiagnostics(); + const firstRect = getOverlayPaintRect(overlayEngine, 'pool-release', 'payload'); + const painting = getWorldOverlayDiagnostics(overlayEngine); assert.ok(firstRect?.entry); assert.ok(painting.paintItemPoolSize >= 1); assert.ok(painting.paintRectPoolSize >= 1); - destroyWorldOverlay(); - const released = getWorldOverlayDiagnostics(); + destroyWorldOverlay(overlayEngine); + const released = getWorldOverlayDiagnostics(overlayEngine); assert.equal(firstRect.entry, null); assert.equal(released.paintItemPoolSize, 0); assert.equal(released.paintRectPoolSize, 0); - initWorldOverlay(env.engine); - setOverlayEntries('pool-release', [selectedEntry('next-payload')]); + createSceneServices(env.engine); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'pool-release', [selectedEntry('next-payload')]); env.postRender.raise(); - assert.notEqual(getOverlayPaintRect('pool-release', 'next-payload'), firstRect); + assert.notEqual(getOverlayPaintRect(overlayEngine, 'pool-release', 'next-payload'), firstRect); env.cleanup(); }); @@ -2307,9 +2307,9 @@ function togglablePosition(box) { test('a candidate that is not projected this frame stops painting immediately', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const box = { hidden: false }; - setOverlayEntries('stamp', [selectedEntry('ambient', { + setOverlayEntries(overlayEngine, 'stamp', [selectedEntry('ambient', { selected: false, protected: false, collisionGroup: 'stamp', @@ -2318,13 +2318,13 @@ test('a candidate that is not projected this frame stops painting immediately', env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); - const solveRevision = getWorldOverlayDiagnostics().solveRevision; + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + const solveRevision = getWorldOverlayDiagnostics(overlayEngine).solveRevision; box.hidden = true; env.advanceTime(16); env.postRender.raise(); - const after = getWorldOverlayDiagnostics(); + const after = getWorldOverlayDiagnostics(overlayEngine); // The arbiter has NOT re-solved, so its state still reports this identity as // selected. Only the per-frame stamp can suppress the stale placement. assert.equal(after.solveRevision, solveRevision); @@ -2334,16 +2334,16 @@ test('a candidate that is not projected this frame stops painting immediately', test('published paint rectangles are scoped to the publishing key and frame', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const first = { hidden: false }; const second = { hidden: false }; - setOverlayEntries('rects', [ + setOverlayEntries(overlayEngine, 'rects', [ selectedEntry('aaa', { interactive: true, position: togglablePosition(first) }), selectedEntry('bbb', { interactive: true, position: togglablePosition(second) }), ]); env.postRender.raise(); - const rectA = getOverlayPaintRect('rects', 'aaa'); - const rectB = getOverlayPaintRect('rects', 'bbb'); + const rectA = getOverlayPaintRect(overlayEngine, 'rects', 'aaa'); + const rectB = getOverlayPaintRect(overlayEngine, 'rects', 'bbb'); assert.ok(rectA && rectB); assert.notEqual(rectA, rectB); @@ -2352,42 +2352,42 @@ test('published paint rectangles are scoped to the publishing key and frame', () first.hidden = true; env.advanceTime(16); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); - assert.equal(getOverlayPaintRect('rects', 'bbb'), rectA); - assert.equal(getOverlayPaintRect('rects', 'aaa'), null); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'bbb'), rectA); + assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'aaa'), null); // Nothing paints: the slot still carries 'bbb' as its key, so only the frame // stamp keeps last frame's box from being handed back. second.hidden = true; env.advanceTime(16); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 0); - assert.equal(getOverlayPaintRect('rects', 'bbb'), null); - assert.equal(getOverlayPaintRect('rects', 'aaa'), null); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0); + assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'bbb'), null); + assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'aaa'), null); env.cleanup(); }); test('every exported entry point is inert after destroy', () => { const env = installMockEnvironment(); - initWorldOverlay(env.engine); - setOverlayEntries('post-destroy', [selectedEntry('live', { interactive: true })]); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'post-destroy', [selectedEntry('live', { interactive: true })]); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); assert.equal(env.document.getElementById('world-overlay-root'), null); - assert.doesNotThrow(() => setOverlayEntries('post-destroy', [selectedEntry('zombie')])); - assert.doesNotThrow(() => setOverlayEntries('post-destroy', 'not-an-array')); - assert.doesNotThrow(() => upsertOverlayEntry('post-destroy', { id: 'zombie-2', position: position() })); - assert.equal(removeOverlayEntry('post-destroy', 'live'), false); - assert.equal(clearOverlaySource('post-destroy'), false); - assert.doesNotThrow(() => setOverlaySourceVisible('post-destroy', false)); - assert.equal(hitTestWorldOverlay(200, 120), null); - assert.equal(getOverlayPaintRect('post-destroy', 'live'), null); + assert.doesNotThrow(() => setOverlayEntries(overlayEngine, 'post-destroy', [selectedEntry('zombie')])); + assert.doesNotThrow(() => setOverlayEntries(overlayEngine, 'post-destroy', 'not-an-array')); + assert.doesNotThrow(() => upsertOverlayEntry(overlayEngine, 'post-destroy', { id: 'zombie-2', position: position() })); + assert.equal(removeOverlayEntry(overlayEngine, 'post-destroy', 'live'), false); + assert.equal(clearOverlaySource(overlayEngine, 'post-destroy'), false); + assert.doesNotThrow(() => setOverlaySourceVisible(overlayEngine, 'post-destroy', false)); + assert.equal(hitTestWorldOverlay(overlayEngine, 200, 120), null); + assert.equal(getOverlayPaintRect(overlayEngine, 'post-destroy', 'live'), null); assert.doesNotThrow(() => env.postRender.raise()); - const diagnostics = getWorldOverlayDiagnostics(); + const diagnostics = getWorldOverlayDiagnostics(overlayEngine); assert.equal(diagnostics.sourceCount, 0); assert.equal(diagnostics.entryCount, 0); assert.equal(diagnostics.paintedCount, 0); @@ -2403,17 +2403,18 @@ test('every exported entry point is inert after destroy', () => { assert.equal(env.window.listenerCount('gev:cockpit-mode-changed'), 0); assert.equal(env.window.listenerCount('resize'), 0); - // A later init still produces a fully working host. - initWorldOverlay(env.engine); - setOverlayEntries('post-destroy', [selectedEntry('revived', { interactive: true })]); + // A new attachment on the parked engine produces a fully working host. + createSceneServices(env.engine); + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, 'post-destroy', [selectedEntry('revived', { interactive: true })]); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); - assert.equal(hitTestWorldOverlay(200, 120).entryId, 'revived'); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + assert.equal(hitTestWorldOverlay(overlayEngine, 200, 120).entryId, 'revived'); env.cleanup(); }); test('diagnostics facade preserves the complete binding shape', () => { - const diagnostics = getWorldOverlayDiagnostics(); + const diagnostics = getWorldOverlayDiagnostics(overlayEngine); const fields = [ 'sourceCount', 'entryCount', 'candidateCount', 'projectedCount', 'selectedCount', 'fadingCount', 'paintedCount', 'hitRectCount', 'projectionMs', 'solveMs', @@ -2438,10 +2439,9 @@ test('installation titles share persistent card arbitration and retain a bounded const markers = createNamedMarkers(engine, { state, services: { render: { governorRequestRender() {} } }, parts: { model: { colorFor: () => rgbaFromCss('#ff8000'), installationSurfaceHeightM: () => 0 } }, - overlayHost: { setEntries: setOverlayEntries, clearSource: clearOverlaySource, - setVisible: setOverlaySourceVisible, getDiagnostics: getWorldOverlayDiagnostics }, + overlayHost: sceneServicesFor(engine).overlay, }); - initWorldOverlay(engine); + initWorldOverlay(overlayEngine = engine); markers.enable(); const records = Array.from({ length: 40 }, (_, i) => ({ id: `site:${i}`, name: `Base ${i}`, namedArea: true, pointOnly: i % 2 === 0, areaM2: 1000 - i, @@ -2456,39 +2456,39 @@ test('installation titles share persistent card arbitration and retain a bounded const source = 'military-installations'; env.advanceTime(250); engine.fake.frame(); assert.equal(markers.stats().labelsOnScreen, 24); - assert.ok(getOverlayPaintRect(source, 'site:0')); - assert.equal(getOverlayPaintRect(source, 'small'), null); + assert.ok(getOverlayPaintRect(overlayEngine, source, 'site:0')); + assert.equal(getOverlayPaintRect(overlayEngine, source, 'small'), null); for (let i = 0; i < 30; i++) { longitude -= 0.003; if (i > 15) height = 4_000_000; look(); env.advanceTime(16); engine.fake.frame(); assert.ok(markers.stats().labelsOnScreen > 0 && markers.stats().labelsOnScreen <= 24); // Incumbents persist through collisions during a pan, as for Data Centers. - if (i < 10) assert.ok(getOverlayPaintRect(source, 'site:0')); + if (i < 10) assert.ok(getOverlayPaintRect(overlayEngine, source, 'site:0')); } state.enabled = false; markers.hide(); engine.fake.frame(); - assert.equal(getWorldOverlayDiagnostics().entriesBySource[source], 0); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).entriesBySource[source], 0); assert.equal(markers.stats().labelsOnScreen, 0); state.enabled = true; markers.enable(); engine.fake.frame(); env.advanceTime(250); engine.fake.frame(); assert.ok(markers.stats().labelsOnScreen > 0); markers.destroy(); engine.fake.frame(); - assert.equal(getWorldOverlayDiagnostics().entriesBySource[source], 0); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).entriesBySource[source], 0); }); test('promoting an ambient entry to selected paints it once with no fading duplicate', () => { const env = installMockEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); const entry = { id: 'site', position: position(), title: 'One site', variant: 'card', collisionGroup: 'ambient-card', horizonCull: false, edgeFade: false }; const paint = () => { env.ctx.calls.length = 0; env.postRender.raise(); return env.ctx.calls.filter(([op, text]) => op === 'fillText' && text === 'One site').length; }; - setOverlayEntries('military-installations', [entry]); paint(); env.advanceTime(200); + setOverlayEntries(overlayEngine, 'military-installations', [entry]); paint(); env.advanceTime(200); assert.equal(paint(), 1); - setOverlayEntries('military-installations', [{ ...entry, variant: 'selected', selected: true }]); + setOverlayEntries(overlayEngine, 'military-installations', [{ ...entry, variant: 'selected', selected: true }]); assert.equal(paint(), 1, 'no ambient exit tail in the selected paint lane'); env.advanceTime(100); assert.equal(paint(), 1); - setOverlayEntries('military-installations', [entry]); paint(); env.advanceTime(200); + setOverlayEntries(overlayEngine, 'military-installations', [entry]); paint(); env.advanceTime(200); assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown'); } finally { env.cleanup(); } }); @@ -2508,20 +2508,20 @@ async function fakeEngineHost() { test('the host runs on a globe engine: frames, camera events and canvas size', async () => { const { env, engine } = await fakeEngineHost(); - initWorldOverlay(engine); - initWorldOverlay(engine); + initWorldOverlay(overlayEngine = engine); + initWorldOverlay(overlayEngine = engine); assert.equal(engine.frames.postRender.numberOfListeners, 1); assert.equal(engine.camera.moveEnd.numberOfListeners, 1); const surface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(surface.parentElement, env.viewer.container, 'the detection surface hangs off the engine host container'); - setOverlayEntries('engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]); + setOverlayEntries(overlayEngine, 'engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]); engine.fake.frame(); const canvas = env.document.getElementById('world-overlay-canvas'); assert.equal(canvas.width, 400, 'sized from engine.host.size()'); assert.equal(canvas.height, 300); - assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); - destroyWorldOverlay(); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + destroyWorldOverlay(overlayEngine); assert.equal(engine.frames.postRender.numberOfListeners, 0); assert.equal(engine.camera.moveEnd.numberOfListeners, 0); env.cleanup(); @@ -2529,16 +2529,16 @@ test('the host runs on a globe engine: frames, camera events and canvas size', a test('on a globe engine the host projects with viewProjection and culls with horizonVisible', async () => { const { env, engine } = await fakeEngineHost(); - initWorldOverlay(engine); - setOverlayEntries('engine', [ + initWorldOverlay(overlayEngine = engine); + setOverlayEntries(overlayEngine, 'engine', [ selectedEntry('NEAR', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0), horizonCull: true }), selectedEntry('FAR', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0), horizonCull: true, cullPosition: Cesium.Cartesian3.fromDegrees(180, 0, 0) }), ]); engine.fake.frame(); - assert.equal(getOverlayPaintRect('engine', 'NEAR') !== null, true); - assert.equal(getOverlayPaintRect('engine', 'FAR'), null, 'hidden behind the globe'); - const rect = getOverlayPaintRect('engine', 'NEAR'); + assert.equal(getOverlayPaintRect(overlayEngine, 'engine', 'NEAR') !== null, true); + assert.equal(getOverlayPaintRect(overlayEngine, 'engine', 'FAR'), null, 'hidden behind the globe'); + const rect = getOverlayPaintRect(overlayEngine, 'engine', 'NEAR'); const centre = engine.projector.worldToWindow(Cesium.Cartesian3.fromDegrees(0, 0, 0)); assert.ok(Math.abs(rect.anchorX - centre.x) < 1e-6 && Math.abs(rect.anchorY - centre.y) < 1e-6, 'anchored at the projected point'); @@ -2547,11 +2547,11 @@ test('on a globe engine the host projects with viewProjection and culls with hor test('on a globe engine the host hands painters the live camera position and requests renders by reason', async () => { const { env, engine } = await fakeEngineHost(); - initWorldOverlay(engine); + initWorldOverlay(overlayEngine = engine); let frame = null; - registerWorldOverlayPaintLane('detection', (value) => { frame = value; }, + registerWorldOverlayPaintLane(overlayEngine, 'detection', (value) => { frame = value; }, { id: 'detection', active: true, target: 'detection' }); - setOverlayEntries('engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]); + setOverlayEntries(overlayEngine, 'engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]); assert.ok(engine.fake.callsOf('render.request').some(({ args }) => args[0] === 'world-overlay')); engine.fake.frame(); assert.equal(frame.cameraPosition, engine.camera.livePosition); @@ -2562,9 +2562,9 @@ test('on a globe engine the host hands painters the live camera position and req test('the host takes a globe engine and refuses a bare viewer', async () => { const { env, engine } = await fakeEngineHost(); - assert.throws(() => initWorldOverlay(env.viewer), /requires a globe engine/); - assert.throws(() => initWorldOverlay(null), /requires a globe engine/); - initWorldOverlay(engine); + assert.throws(() => initWorldOverlay(overlayEngine = env.viewer), /requires a globe engine/); + assert.throws(() => initWorldOverlay(overlayEngine = null), /requires a globe engine/); + initWorldOverlay(overlayEngine = engine); assert.equal(engine.frames.postRender.numberOfListeners, 1); env.cleanup(); }); @@ -2572,15 +2572,15 @@ test('the host takes a globe engine and refuses a bare viewer', async () => { // Source deletion is final: invisible candidates must not retain minimum-life // or fade state which wakes a dormant scene on a later UI/layout invalidation. for (const [name, remove] of [ - ["clear", () => clearOverlaySource("deleted-static")], - ["remove", () => removeOverlayEntry("deleted-static", "event")], - ["replace-empty", () => setOverlayEntries("deleted-static", [])], + ["clear", () => clearOverlaySource(overlayEngine, "deleted-static")], + ["remove", () => removeOverlayEntry(overlayEngine, "deleted-static", "event")], + ["replace-empty", () => setOverlayEntries(overlayEngine, "deleted-static", [])], ]) { test(`removed static labels stay dormant after ${name}`, () => { const env = installMockEnvironment(); try { - initWorldOverlay(env.engine); - setOverlayEntries( + initWorldOverlay(overlayEngine = env.engine); + setOverlayEntries(overlayEngine, "deleted-static", [ selectedEntry("event", { @@ -2598,7 +2598,7 @@ for (const [name, remove] of [ env.postRender.raise(); env.advanceTime(600); env.postRender.raise(); - assert.ok(getWorldOverlayDiagnostics().selectedCount > 0); + assert.ok(getWorldOverlayDiagnostics(overlayEngine).selectedCount > 0); remove(); env.postRender.raise(); env.viewer.scene.requestRenderCount = 0; @@ -2607,7 +2607,7 @@ for (const [name, remove] of [ for (const observer of env.resizeObservers) observer.callback(); env.postRender.raise(); } - assert.equal(getWorldOverlayDiagnostics().paintedCount, 0); + assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0); assert.equal( env.viewer.scene.requestRenderCount, 0, @@ -2622,12 +2622,12 @@ for (const [name, remove] of [ test("clearing one static source preserves another source in the same label domain", () => { const env = installMockEnvironment(); try { - initWorldOverlay(env.engine); + initWorldOverlay(overlayEngine = env.engine); for (const [source, x] of [ ["left", -0.5], ["right", 0.5], ]) - setOverlayEntries( + setOverlayEntries(overlayEngine, source, [ selectedEntry("event", { @@ -2644,16 +2644,130 @@ test("clearing one static source preserves another source in the same label doma env.postRender.raise(); env.advanceTime(600); env.postRender.raise(); - assert.deepEqual(getWorldOverlayDiagnostics().paintedBySource, { + assert.deepEqual(getWorldOverlayDiagnostics(overlayEngine).paintedBySource, { left: 1, right: 1, }); - clearOverlaySource("left"); + clearOverlaySource(overlayEngine, "left"); env.postRender.raise(); - assert.deepEqual(getWorldOverlayDiagnostics().paintedBySource, { + assert.deepEqual(getWorldOverlayDiagnostics(overlayEngine).paintedBySource, { right: 1, }); } finally { env.cleanup(); } }); + +for (const makeHarness of GROUND_HARNESSES) { + for (const retired of [0, 1]) { + test(`${makeHarness.name}: retiring overlay ${retired} leaves its sibling live`, () => { + const env = installMockEnvironment({ dpr: 1 }); + const harnesses = [makeHarness(), makeHarness()]; + const containers = harnesses.map(({ engine }) => { + const container = env.document.createElement('div'); + env.document.body.appendChild(container); + engine.host.container = container; + return container; + }); + const services = harnesses.map(({ engine }) => createSceneServices(engine)); + const hosts = services.map((service) => service.overlay); + const views = [[], []]; + const paints = [0, 0]; + const activations = [0, 0]; + const lanes = []; + const removers = []; + const frame = (i) => harnesses[i].engine.frames.postRender.raiseEvent(); + let next; + try { + hosts.forEach((host, i) => { + host.init(); + removers[i] = host.subscribeActions((view) => views[i].push(view)); + host.setEntries('shared-source', [selectedEntry('same-id', { + title: `scene ${i}`, horizonCull: false, + accessibilityLabel: `Focus scene ${i}`, interactive: true, + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + activate: () => { activations[i]++; }, + })]); + lanes[i] = host.registerPaintLane('detection', () => { paints[i]++; }, { id: 'same-lane', active: true }); + frame(i); + assert.equal(host.getDiagnostics().paintedCount, 1); + assert.equal(host.getPaintRect('shared-source', 'same-id').entry.title, `scene ${i}`); + assert.equal(containers[i].children.length, 2); + assert.equal(containers[i].children[0].className, 'world-overlay-root'); + assert.equal(containers[i].children[1].className, 'world-overlay-detection-surface'); + }); + assert.notEqual(containers[0].children[0], containers[1].children[0]); + // The page's ids name the newest host. + assert.equal(env.document.getElementById('world-overlay-root'), containers[1].children[0]); + assert.equal(containers[0].children[0].id, ''); + assert.deepEqual(paints, [1, 1]); + const oldView = views[retired].at(-1); + const live = 1 - retired; + const siblingView = views[live].at(-1); + services[retired].destroy(); + services[retired].destroy(); + hosts[retired].init(); + hosts[retired].setEntries('late', [selectedEntry('late')]); + hosts[retired].activateAction(oldView.owner, oldView.items[0].key); + lanes[retired].requestPaint(); + frame(retired); + frame(live); + assert.equal(containers[retired].children.length, 0); + assert.equal(containers[live].children.length, 2); + assert.equal(env.resizeObservers[retired].disconnected, true); + assert.equal(env.resizeObservers[live].disconnected, false); + assert.equal(env.mutationObservers[retired].disconnected, true); + assert.equal(env.mutationObservers[live].disconnected, false); + assert.equal(hosts[retired].getDiagnostics().entryCount, 0); + assert.equal(hosts[live].getDiagnostics().paintedCount, 1); + assert.equal(views[retired].at(-1), null); + assert.equal(views[live].at(-1).owner, siblingView.owner); + assert.equal(paints[retired], 1); + assert.equal(paints[live], 2); + assert.equal(harnesses[retired].engine.frames.postRender.numberOfListeners, 0); + assert.equal(harnesses[live].engine.frames.postRender.numberOfListeners, 1); + hosts[live].activateAction(siblingView.owner, siblingView.items[0].key); + assert.equal(activations[live], 1); + assert.equal(activations[retired], 0); + + next = createSceneServices(harnesses[retired].engine); + next.overlay.init(); + next.overlay.registerPaintLane('detection', () => { paints[retired]++; }, { id: 'same-lane', active: true }); + lanes[retired].unregister(); + services[retired].destroy(); + removers[retired](); + frame(retired); + assert.equal(paints[retired], 2); + assert.equal(hosts[live].getDiagnostics().paintedCount, 1); + } finally { + next?.destroy(); + services.forEach((service) => service.destroy()); + harnesses.forEach((harness) => harness.destroy()); + env.cleanup(); + } + }); + } + + test(`${makeHarness.name}: retained overlay publishers rebind to their scene`, () => { + const first = makeHarness(); + const second = makeHarness(); + const a = createSceneServices(first.engine); + const b = createSceneServices(second.engine); + let engine = first.engine; + const host = bindOverlayHost(overlayServices, () => engine); + try { + host.setOverlayEntries('retained', [selectedEntry('first')]); + assert.equal(a.overlay.getDiagnostics().entryCount, 1); + assert.equal(b.overlay.getDiagnostics().entryCount, 0); + host.clearOverlaySource('retained'); + engine = second.engine; + host.setOverlayEntries('retained', [selectedEntry('second')]); + a.destroy(); + host.setOverlaySourceVisible('retained', true); + assert.equal(a.overlay.getDiagnostics().entryCount, 0); + assert.equal(b.overlay.getDiagnostics().entryCount, 1); + } finally { + a.destroy(); b.destroy(); first.destroy(); second.destroy(); + } + }); +} diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index 2707480d0..98622dcbe 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -1,3 +1,4 @@ +let overlayEngine; import * as Cesium from 'cesium'; import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import * as geo from '../geo/index.js'; @@ -1321,7 +1322,7 @@ function main() { // The Navara projector profile swaps the engine's projector before the // host reads it. if (PROFILE === 'navara-projector') env.engine.projector = navaraProjector(); - initWorldOverlay(env.engine); + initWorldOverlay((overlayEngine = env.engine)); const workload = PROFILE === 'phase6-detection' ? buildPhase6DetectionWorkload(ENTRY_COUNT) @@ -1376,16 +1377,21 @@ function main() { setDetectionMode('DENSE'); } else if (workload.registrations) { for (const registration of workload.registrations) { - setOverlayEntries(registration.sourceId, registration.entries, { - ...registration.options, - // Exercise the stricter moving-source solve cadence while retaining - // the production source ids, entries, caps, and bounded cohorts. - moving: true, - solveIntervalMs, - }); + setOverlayEntries( + overlayEngine, + registration.sourceId, + registration.entries, + { + ...registration.options, + // Exercise the stricter moving-source solve cadence while retaining + // the production source ids, entries, caps, and bounded cohorts. + moving: true, + solveIntervalMs, + }, + ); } } else { - setOverlayEntries('alloc-probe', entries, { + setOverlayEntries(overlayEngine, 'alloc-probe', entries, { collisionCapacity: 96, cohortLimit: 256, moving: true, @@ -1411,7 +1417,7 @@ function main() { globalThis.gc(); for (let i = 0; i < CHUNK_FRAMES; i++) tick(); } - const warm = getWorldOverlayDiagnostics(); + const warm = getWorldOverlayDiagnostics(overlayEngine); const detectionWarm = detectionActive ? getDetectionDiagnostics() : null; const painted = detectionActive ? detectionWarm.visibleCount @@ -1434,7 +1440,7 @@ function main() { } const solveRevisionAfter = detectionActive ? getDetectionDiagnostics().solveRevision - : getWorldOverlayDiagnostics().solveRevision; + : getWorldOverlayDiagnostics(overlayEngine).solveRevision; const sorted = chunkRates.slice().sort((a, b) => a - b); const maxBytesPerFrame = sorted[sorted.length - 1]; const medianBytesPerFrame = sorted[Math.floor(sorted.length / 2)]; @@ -1470,7 +1476,7 @@ function main() { ); if (detectionActive) destroyDetection(); - destroyWorldOverlay(); + destroyWorldOverlay(overlayEngine); env.engine.destroy(); } diff --git a/src/overlays/worldOverlayDraw.js b/src/overlays/worldOverlayDraw.js index e5db5d5b7..379d0bd32 100644 --- a/src/overlays/worldOverlayDraw.js +++ b/src/overlays/worldOverlayDraw.js @@ -70,6 +70,19 @@ export function installWorldOverlayFontInvalidation() { ); } +let _drawHosts = 0; + +/** Keep shared font measurements live while any scene host is attached. */ +export function retainWorldOverlayDraw() { + _drawHosts++; + let released = false; + return () => { + if (released) return; + released = true; + if (--_drawHosts === 0) destroyWorldOverlayDraw(); + }; +} + /** Remove font hooks and cached measurements when the overlay host is destroyed. */ export function destroyWorldOverlayDraw() { _fontInvalidationGeneration++; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b3c97c6b9..f3e266bdc 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -109,8 +109,6 @@ export class StyleManager extends ShellScene { CelestialRing, initTrackedReadout, initWorldOverlay, - subscribeWorldOverlayActions, - activateWorldOverlayAction, initDetection, setDetectionStyle, trafficLayer, @@ -313,14 +311,6 @@ export class StyleManager extends ShellScene { }); this._initSceneOverlays(); - // Its focusable mirror of the painted cards, which the scene chrome - // renders. - const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia()); - overlayActionsStore.connect({ activate: activateWorldOverlayAction }); - this._overlayActionsUnsubscribe = subscribeWorldOverlayActions((view) => - overlayActionsStore.publish(view), - ); - this._displayBindings = new DisplayBindings({ viewer, services: { diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index 684e93097..92cef948d 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -1,3 +1,4 @@ +import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js'; import { renderGovernorFor } from '../renderGovernor.js'; import { retainsLayerSession } from '../app/layerSession.js'; import { resolveImageryHost } from '../layers/weather/imageryHost.js'; @@ -253,6 +254,16 @@ export class ShellScene extends ShellFacade { // detection and tracked-readout initialize. It stays transparent until a // production source explicitly registers entries. initWorldOverlay(engine); + const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia()); + this._overlayActionsUnsubscribe?.(); + overlayActionsStore.connect({ + activate: (...args) => + this.services.activateWorldOverlayAction(engine, ...args), + }); + this._overlayActionsUnsubscribe = + this.services.subscribeWorldOverlayActions?.(engine, (view) => + overlayActionsStore.publish(view), + ); // Initialize detection overlay BEFORE style stages so the composite // stage is first in the post-process pipeline. Both run on the engine, // Navara's too. @@ -415,7 +426,9 @@ export class ShellScene extends ShellFacade { this._layerBindings.disconnect(); destroyTrackedReadout(); destroyDetection(); - destroyWorldOverlay(); + destroyWorldOverlay(this._scene.engine); + this._overlayActionsUnsubscribe?.(); + this._overlayActionsUnsubscribe = null; this.celestialRing?.destroy(); this.celestialRing = null; this._visualSettings.destroy(); diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index 6786ac669..4b2417f06 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -139,6 +139,7 @@ body { beneath the shared text/card canvas; both share one frame path. The root holds ONLY the card canvas — it is a stacking context (z-index below), and a canvas that blends against the scene cannot live inside one. */ +.world-overlay-root, #world-overlay-root { position: absolute; inset: 0; @@ -147,6 +148,8 @@ body { pointer-events: none; } +.world-overlay-detection-surface, +.world-overlay-canvas, #world-overlay-detection-surface, #world-overlay-canvas { position: absolute; @@ -179,6 +182,7 @@ body { `mix-blend-mode: screen` composites against the WebGL scene. z5 keeps it under the z6 root that carries the shared card canvas; do not move this surface inside #world-overlay-root, which would isolate the blend. */ +.world-overlay-detection-surface, #world-overlay-detection-surface { z-index: 5; } diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..50b869d08 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -60,9 +60,6 @@ const vToastText = {