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.
This commit is contained in:
German Khokhlov committed 2026-11-07 19:27:31 +04:00
1 parent 26597ad25c
commit 3814221cbc
62 files changed
+3148 -2655

No files matched your search

+9 -1
View File
@@ -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,
+25
View File
@@ -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
+2 -1
View File
@@ -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"
]
+52 -21
View File
@@ -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"]
},
+1 -1
View File
@@ -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],
+2 -2
View File
@@ -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.
+4 -4
View File
@@ -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');
+3 -1
View File
@@ -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(
+3 -1
View File
@@ -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();
+4 -4
View File
@@ -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();
+2 -1
View File
@@ -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) {
+2
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+4
View File
@@ -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;
+4
View File
@@ -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;
+21 -11
View File
@@ -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;
+24 -28
View File
@@ -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 }],
+1
View File
@@ -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',
]);
+4
View File
@@ -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();
},
};
+59
View File
@@ -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);
});
}
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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().
@@ -71,7 +71,8 @@ function markScale(h) {
export function createScreenAnnotationRenderer(
target,
{
overlayPaintRect = getOverlayPaintRect,
engine,
overlayPaintRect = (...args) => getOverlayPaintRect(engine, ...args),
activeTrackedReadoutId = getActiveTrackedReadoutId,
store = useScreenAnnotationsStore(applicationPinia()),
} = {},
+5
View File
@@ -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() {
+8 -1
View File
@@ -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),
+2 -1
View File
@@ -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({
+6 -1
View File
@@ -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);
+2 -1
View File
@@ -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,
+5 -12
View File
@@ -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;
+1 -3
View File
@@ -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) =>
+5
View File
@@ -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),
+2
View File
@@ -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<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
overlayHost = bindOverlayHost(overlayHost, () => sceneEngine);
const canSelect = () => overlayHost && picking;
function publishSelectedCard() {
+5
View File
@@ -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) {
+8
View File
@@ -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);
+2
View File
@@ -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) {
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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({
+5
View File
@@ -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() {
+2
View File
@@ -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;
/**
+6 -1
View File
@@ -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);
+2 -2
View File
@@ -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();
+2 -1
View File
@@ -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({
+4 -1
View File
@@ -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();
+6 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -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;
+2
View File
@@ -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,
+12
View File
@@ -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),
]),
);
}
+2360 -2241
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -12
View File
@@ -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();
}
+13
View File
@@ -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++;
-10
View File
@@ -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: {
+14 -1
View File
@@ -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();
+4
View File
@@ -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;
}
-3
View File
@@ -60,9 +60,6 @@ const vToastText = {
<template>
<div id="cesiumContainer"></div>
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" :aria-label="t('Visible map targets')">
<div id="world-overlay-action-list"><UiButton v-for="item in targets?.items ?? []" :key="item.key" :aria-label="item.label" :pressed="Boolean(item.selected)" :data-overlay-action-key="item.key" :onClick="onOverlayAction(targets.owner, item.key)">{{ item.label }}</UiButton></div>
<div id="world-overlay-status" v-overlay-status="targets" role="status" aria-live="polite" aria-atomic="true"></div>