diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bf7a5eac6..381baec58 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -348,14 +348,7 @@ one application, and only the globe-bound parts move to the next engine. kind: wind streamlines), a layer that draws itself through the contract (Wildlife Sightings), or, without a display, its drawing per engine kind. Weather, wind imagery and Bhote Koshi - use shared displays; Sea Marks and Ski Pistes drape themselves on both. A layer is available on an engine when that engine has the - primitives it needs, or, without a display, when its entry lists that - kind; today every layer shows on both. The manifest's Navara backends - and renderers load through `import()` (`loadLayerRenderers(kind)`, awaited - by `src/standalone/application.js` before the engine's catalog), so - Cesium mode fetches none of them; `rendererFor(id, engine, options)` - answers synchronously after that. The layer list reads - `createLayerAvailability(kind)`. + use shared displays; Sea Marks and Ski Pistes drape themselves on both. Every layer shows on both engines. The displays and wind backend load through `loadLayerRenderers(kind)`, awaited before the engine's catalog. `rendererFor(id, engine, options)` answers synchronously after that. - **One tools tree.** `src/app/tools.js` attaches the same tools to every engine (`attachEngineTools`): the annotation board, Draw, Import, the Recent Imagery box and panel, the render governor, the scope mask, @@ -6316,7 +6309,7 @@ pins and share links already supply complete boxes. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. - **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied. -- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes. +- **Rendering.** `render/display.js` owns at most two GIBS layers on the surface reported by `engine.imagery.surface()`: the globe, a shown tileset, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes. - **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. - **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once. - **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 5120d8374..41e97d242 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -331,7 +331,7 @@ On the engine: - on the globe's imagery layers while the globe shows, else on the shown 3D tileset's own imagery layers (Cesium drapes imagery on tiles there), as - `src/maps/imageryHost.js` resolves it for the shell; with neither, the + `engine.imagery.surface()` reports it to layers; with neither, the layers wait; - the engine checks the surface on every frame while it holds a layer, and builds each layer again on the new stack after a switch; @@ -474,8 +474,7 @@ against the native ellipsoidal occluder, including points near the limb. `engine.input.suspendCameraInputs()` disables camera gestures and returns an idempotent resume function. Suspensions are counted: only the last resume restores the state captured before the first suspension. This preserves an -already-disabled camera. A `setCameraInputs()` call during suspension updates -the state restored on resume. `cameraInputsEnabled()` reads the current state. +already-disabled camera. `cameraInputsEnabled()` reads the current state. Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use these methods on every engine. Pointer claims remain separate from camera input suspension. @@ -539,7 +538,7 @@ Positions stay live cached visual anchors. `trackedReadoutFor(engine)` resolves the service; destruction removes its listeners and clears only its own source. `scene.services.context` owns selection records and notifications. Layer publishers -use `bindContextStore` with their current engine, or a context service captured by +use `bindOverlayHost` with their current engine, or a context service captured by their display. It mirrors records to the page context slot and preserves the `gev:entity-selected`, `gev:entity-selection-cleared` and aircraft awareness event payloads for page consumers. A clear reaches the page only when that attachment diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 3dcd7f63b..300643503 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -270,9 +270,9 @@ a feature supplying only its accent. the tracked-entity fade is measured once the frame is in the document; the styles it injected now live in `src/ui/styles/annotations.css`), the visible map target list (the scene chrome renders the buttons and status - the world overlay presents through `subscribeWorldOverlayActions()`, which + the world overlay presents through `subscribeActions()`, which the application shell publishes to the `gev-world-overlay-actions` store - and answers with `activateWorldOverlayAction()`; the module needs no Vue + and answers with `activateAction()`; the module needs no Vue for the portable transit entry, and its card and detection canvases stay painting surfaces it draws on, like the Cesium canvas), the Global Context compass and contact bearings (`AwarenessDirection` teleports into diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index a0009d771..f65eb7ba0 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -3,14 +3,14 @@ "cesiumLayerRendererLines": 0, "navaraLayerRendererLines": 0, "cesiumImportFiles": 3, - "viewerLines": 849, + "viewerLines": 750, "navaraKindChecks": 1, "engineSniffCalls": 0, "engineExtensionReads": 3, "groundEffectPasses": 1, "cesiumSdkLoads": 3, "screenSpaceHandlerFiles": 4, - "engineNameLines": 32 + "engineNameLines": 27 }, - "measuredAt": "Delete dead render code" + "measuredAt": "Drop leftover viewer paths" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d841d4d46..d01a9d884 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -910,7 +910,6 @@ "scripts/qa-weather-journey.mjs", "scripts/qa-weather-swap.mjs", "scripts/qa-weather-teardown.mjs", - "src/layers/weather/imageryHost.test.mjs", "src/layers/weather/display.test.mjs", "src/sdr/adsbDecoder.test.mjs", "src/sdr/controller.test.mjs", @@ -935,7 +934,6 @@ "src/data/modelEligibility.test.mjs", "src/layers/recentImagery/model.test.mjs", "src/layers/recentImagery/catalog.test.mjs", - "src/maps/imageryHost.test.mjs", "src/layers/recentImagery/thumbnails.test.mjs", "src/layers/recentImagery/index.test.mjs", "src/ui/imageryBoxTool.test.mjs", @@ -1695,7 +1693,10 @@ "src/globe/cesium/annotations/screenAnnotationRenderer.js", "src/globe/navara/windFlow.js", "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js", "src/data/trackedReadoutScenes.test.mjs", - "src/data/trackedAircraftScenes.test.mjs" + "src/data/trackedAircraftScenes.test.mjs", + "src/ui/releaseTrackers.js", + "src/testSupport/domClasses.mjs", + "src/layers/shared/imageryHost.js", + "src/layers/shared/imageryHost.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b6dfe5a38..4b7f3acc0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1135,7 +1135,7 @@ "src/data/feedState.js", "src/data/layerSnapshot.js", "src/ui/aircraftDisplay.js", - "src/layers/weather/imageryHost.js", + "src/layers/shared/imageryHost.js", "src/ui/layerBindings.js", "src/ui/displayBindings.js", "src/ui/pendingVisualSettings.js", @@ -1240,7 +1240,8 @@ "src/overlays/worldOverlayTokens.js", "src/time/clock.js", "src/data/contextStore.js", - "src/data/trackedReadout.js" + "src/data/trackedReadout.js", + "src/ui/releaseTrackers.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1466,7 +1467,7 @@ "src/globe/contract.js", "src/layers/shared/color.js", "src/layers/aircraft/qaDrawn.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -1534,7 +1535,7 @@ "src/time/clock.js", "src/globe/contract.js", "src/layers/aircraft/qaDrawn.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -1617,8 +1618,7 @@ "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", - "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, @@ -1645,7 +1645,7 @@ "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", + "src/layers/shared/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/ar.json", @@ -1701,8 +1701,7 @@ "src/geo/", "src/layers/firms/rectangle.js", "src/layers/shared/color.js", - "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": [] }, @@ -1745,8 +1744,7 @@ "src/geo/", "src/time/clock.js", "src/layers/shared/color.js", - "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": ["cesium", "satellite.js"] }, @@ -2302,7 +2300,7 @@ "src/maps/terrainRetry.js", "src/keySetupCore.mjs", "src/localRequestGate.mjs", - "src/maps/imageryHost.js", + "src/layers/shared/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/ar.json", @@ -3215,7 +3213,7 @@ "src/scenes/sharing.js", "src/ui/sceneSharing.js", "src/ui/stores/sceneSharing.js", - "src/layers/weather/imageryHost.js", + "src/layers/shared/imageryHost.js", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", @@ -3243,7 +3241,6 @@ "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", "src/ui/recentImagery.js", "src/ui/imageryBoxTool.js", "src/voice/pointerContext.js", @@ -3449,7 +3446,6 @@ "src/globe/navara/sonar.js", "src/globe/navara/surface.js", "src/globe/navara/viewOptions.js", - "src/app/engineChoice.js", "src/app/navaraMode.js", "src/annotations/navaraAnnotationRenderer.js", "src/annotations/drawSurface.js", @@ -3629,7 +3625,7 @@ "src/globe/exclusivePointer.js", "src/globe/cesium/followOffset.js", "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js" + "src/ui/releaseTrackers.js" ], "external": [ "hls.js", @@ -4117,7 +4113,7 @@ "src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredImage.js", "src/layers/weather/source.js", - "src/layers/weather/imageryHost.js", + "src/layers/shared/imageryHost.js", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", @@ -4147,7 +4143,6 @@ "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", "src/layers/streetLevel/index.js", "src/layers/streetLevel/uiState.js", "src/layers/streetLevel/pickRouter.js", @@ -4391,8 +4386,7 @@ "src/globe/meshes.js", "src/globe/exclusivePointer.js", "src/globe/cesium/followOffset.js", - "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": [ "hls.js", @@ -4529,7 +4523,8 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/i18n/index.js", - "src/globe/contract.js" + "src/globe/contract.js", + "src/ui/releaseTrackers.js" ], "external": ["cesium"] }, @@ -5731,8 +5726,7 @@ "src/layers/wildlifeSightings/display.js", "src/data/spriteOrder.js", "src/layers/shared/color.js", - "src/overlays/bindOverlayHost.js", - "src/data/bindContextStore.js" + "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] }, diff --git a/scripts/qa-focus-evidence.mjs b/scripts/qa-focus-evidence.mjs index 707c81f2e..aca33ca34 100644 --- a/scripts/qa-focus-evidence.mjs +++ b/scripts/qa-focus-evidence.mjs @@ -245,10 +245,9 @@ async function takeFrameClock(page) { async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) { return page.evaluate((stepMs) => { const gev = window.__godsEyeView; - const viewer = gev.viewer; const seam = gev.dataManager.layers.get('flights').module.__focusEvidence; const nowMs = seam.advanceFrameClock(stepMs); - if (!viewer) return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000); const remove = gev.engine.frames.postRender.addEventListener(() => { remove(); clearTimeout(timer); @@ -256,13 +255,7 @@ async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) { }); gev.engine.render.request('focus-evidence'); }); - const JulianDate = viewer.clock.currentTime.constructor; - JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime); - viewer.dataSourceDisplay?.update(viewer.clock.currentTime); - viewer.scene.render(viewer.clock.currentTime); - // Cross a task boundary so Chrome can commit the explicitly rendered - // surface before capture. Cesium's drained default loop stays disabled. - return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0)); + }, deltaMs); } diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index 01aee280e..84d313092 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -95,7 +95,6 @@ export function activeContactsWindow(dataManager) { */ export function getWorldPacket(options = {}) { const { - viewer, engine, styleManager, dataManager, @@ -105,13 +104,12 @@ export function getWorldPacket(options = {}) { // A default parameter only covers undefined; null or NaN would age every // feed against the epoch and report stale data as "just now". const now = Number.isFinite(requestedNow) ? requestedNow : Date.now(); - if (!engine && !viewer?.camera?.positionWC) - throw new TypeError('World Packet requires a viewer camera'); + if (!engine) throw new TypeError('World Packet requires a globe engine'); if (!dataManager || typeof dataManager.getAll !== 'function') throw new TypeError('World Packet requires a data manager'); const cartographic = geo.cartographic.fromCartesian( - engine?.camera.livePosition ?? viewer.camera.positionWC, + engine.camera.livePosition, ); // Read the roster once. Voice must not rewalk disabled layers: preserving the // current enabled-only view keeps the model's context budget bounded. diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs index 063e3652c..58696a279 100644 --- a/src/agent/worldPacket.test.mjs +++ b/src/agent/worldPacket.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -60,13 +61,15 @@ function harness() { getCockpitState: () => null, getControlState: () => ({ hud: { visible: 'auto' } }), }; - return { viewer, dataManager, styleManager }; + const engine = createFakeEngine(); + engine.camera.setView({ destination: position }); + return { viewer, engine, dataManager, styleManager }; } test('World Packet preserves get_current_view_state parity', async () => { - const { viewer, dataManager, styleManager } = harness(); - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const { viewer, engine, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ engine, dataManager, styleManager }); + const runner = createGevActionRunner({ engine, dataManager, styleManager }); const voice = await runner('get_current_view_state'); assert.deepEqual(withoutVoicePresentation(voice), packet); @@ -77,7 +80,7 @@ test('World Packet preserves get_current_view_state parity', async () => { }); test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { - const { viewer, dataManager, styleManager } = harness(); + const { viewer, engine, dataManager, styleManager } = harness(); dataManager.getAll = () => [ { id: 'traffic', @@ -92,7 +95,7 @@ test('World Packet keeps stale/unavailable feed semantics distinct from empty', }, ]; - const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const packet = getWorldPacket({ engine, dataManager, styleManager }); assert.equal(packet.layers[0].count, 0); assert.notEqual(packet.layers[0].feedState, 'nominal'); assert.notEqual(packet.feedProvenance.overall, 'nominal'); @@ -101,7 +104,7 @@ test('World Packet keeps stale/unavailable feed semantics distinct from empty', // The 2026-10 main voice contract omits disabled layers from the model payload. test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { - const { viewer, dataManager, styleManager } = harness(); + const { viewer, engine, dataManager, styleManager } = harness(); const rows = dataManager.getAll(); rows.push({ id: 'earthquakes', @@ -114,7 +117,7 @@ test('World Packet and voice preserve enabled-only payload and count disabled la reads += 1; return rows; }; - const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const packet = getWorldPacket({ engine, dataManager, styleManager }); assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); assert.equal(packet.disabledLayerCount, 1); assert.deepEqual( @@ -123,7 +126,7 @@ test('World Packet and voice preserve enabled-only payload and count disabled la ); assert.equal(packet.feedProvenance.overall, 'nominal'); - const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ engine, dataManager, styleManager }); const voice = await runner('get_current_view_state'); assert.deepEqual(withoutVoicePresentation(voice), packet); }); @@ -134,7 +137,7 @@ const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0); // cockpit, controls, scene playback, tracked families (one not ready), // enabled and disabled layers, and an aged and an unavailable feed. function pinnedHarness() { - const { viewer } = harness(); + const { viewer, engine } = harness(); const rows = [ { id: 'flights', @@ -209,7 +212,7 @@ function pinnedHarness() { const sceneDirector = { getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }), }; - return { viewer, dataManager, styleManager, sceneDirector }; + return { viewer, engine, dataManager, styleManager, sceneDirector }; } // Captured from main's get_current_view_state before the extraction @@ -331,5 +334,5 @@ test('World Packet ages feeds against the clock when now is not a finite number' }); test('World Packet rejects null options with the camera requirement', () => { - assert.throws(() => getWorldPacket(null), /requires a viewer camera/); + assert.throws(() => getWorldPacket(null), /requires a globe engine/); }); diff --git a/src/annotations/hybridAnnotationRenderer.test.mjs b/src/annotations/hybridAnnotationRenderer.test.mjs index 4f7d13186..1e071ada7 100644 --- a/src/annotations/hybridAnnotationRenderer.test.mjs +++ b/src/annotations/hybridAnnotationRenderer.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../testSupport/domClasses.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -75,24 +76,14 @@ function cameraLookingAt(lon, lat) { function installBrowserGlobals(t) { const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalWindow = globalThis.window; - const originalBrowserConstructors = new Map([ - ['HTMLCanvasElement', globalThis.HTMLCanvasElement], - ['HTMLImageElement', globalThis.HTMLImageElement], - ['ImageBitmap', globalThis.ImageBitmap], - ['OffscreenCanvas', globalThis.OffscreenCanvas], - ]); + installDomClasses(t); globalThis.requestAnimationFrame = (callback) => { callback(); return 1; }; globalThis.window = { setTimeout: (fn) => { fn(); return 1; } }; - for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {}; t.after(() => { if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame; else globalThis.requestAnimationFrame = originalRequestAnimationFrame; if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; - for (const [name, original] of originalBrowserConstructors) { - if (original === undefined) delete globalThis[name]; - else globalThis[name] = original; - } }); } @@ -182,23 +173,13 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry' const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalWindow = globalThis.window; globalThis.window = { setTimeout: (fn) => { fn(); return 1; } }; - const originalBrowserConstructors = new Map([ - ['HTMLCanvasElement', globalThis.HTMLCanvasElement], - ['HTMLImageElement', globalThis.HTMLImageElement], - ['ImageBitmap', globalThis.ImageBitmap], - ['OffscreenCanvas', globalThis.OffscreenCanvas], - ]); + installDomClasses(t); globalThis.requestAnimationFrame = (callback) => { callback(); return 1; }; - for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {}; t.after(() => { if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame; else globalThis.requestAnimationFrame = originalRequestAnimationFrame; if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; - for (const [name, original] of originalBrowserConstructors) { - if (original === undefined) delete globalThis[name]; - else globalThis[name] = original; - } }); const camera = cameraLookingAt(-99, 31); diff --git a/src/annotations/worldAnnotationRenderer.test.mjs b/src/annotations/worldAnnotationRenderer.test.mjs index 0e15ec95b..16c7a0779 100644 --- a/src/annotations/worldAnnotationRenderer.test.mjs +++ b/src/annotations/worldAnnotationRenderer.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../testSupport/domClasses.mjs'; // A mark hidden from Draw's list leaves the globe whole: every entity it put // there is taken out on sync and comes back when shown, and the line's // flowing stroke, which keeps its own colour, follows the mark's alpha. @@ -11,11 +12,7 @@ import { const noop = () => {}; function viewer(t) { - for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) { - const original = globalThis[name]; - globalThis[name] = class {}; - t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; }); - } + installDomClasses(t); const before = globalThis.document; globalThis.document = { addEventListener: noop, removeEventListener: noop }; t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; }); diff --git a/src/app/catalog.js b/src/app/catalog.js index 57907ada4..becc25eef 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -47,24 +47,14 @@ export function createLayerCatalog(layers, metadata) { }); } -/** - * Bind the current control surface to the exact instances registered by the - * app. A layer the catalog leaves out on purpose (another engine's: in - * `catalog.unavailableMetadata`) gets an empty stand-in, so the shell's - * optional calls (`layer.method?.()`) do nothing; any other missing layer is - * a wiring error. - */ +/** Bind controls to the exact instances registered by the app. */ export function catalogControlServices(catalog) { if (!catalog?.get) throw new TypeError('An application layer catalog is required'); - const omitted = new Set( - (catalog.unavailableMetadata ?? []).map(({ id }) => id), - ); return Object.fromEntries( Object.entries(CONTROL_LAYER_IDS).map(([role, id]) => { const layer = catalog.get(id); if (layer) return [role, layer]; - if (omitted.has(id)) return [role, {}]; throw new TypeError(`Control layer missing from catalog: ${id}`); }), ); diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index a7696b45c..c511131c1 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -184,44 +184,6 @@ test('data setup without a viewer registers an empty catalog and no sonar scene' for (const release of releases.reverse()) await release(); }); -test('a catalog for an engine gives stand-ins only for the layers it leaves out', () => { - const flights = { id: 'flights' }; - const catalog = { - ...createLayerCatalog( - [flights], - [{ id: 'flights', disposition: 'enabled-only' }], - ), - unavailableMetadata: Object.keys({ - traffic: 1, - military: 1, - satellites: 1, - cctv: 1, - 'street-level': 1, - radio: 1, - bikeshare: 1, - transit: 1, - 'ais-live-vessels': 1, - 'military-awareness': 1, - 'military-installations': 1, - 'rocket-launches': 1, - 'local-adsb': 1, - 'wildlife-sightings': 1, - }).map((id) => ({ id })), - }; - const services = catalogControlServices(catalog); - assert.equal(services.flightsLayer, flights); - assert.deepEqual(services.trafficLayer, {}); - assert.notEqual(services.trafficLayer, services.militaryFlightsLayer); - const missing = { - ...catalog, - unavailableMetadata: catalog.unavailableMetadata.slice(1), - }; - assert.throws( - () => catalogControlServices(missing), - /Control layer missing from catalog: traffic/, - ); -}); - test('a teardown during startup ends the wait for a stalled layer attachment', async (t) => { const previous = globalThis.document; globalThis.document = { getElementById: () => null }; diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 557cb0baa..7f305a6d5 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -4,7 +4,7 @@ import { createWeatherLayer } from '../layers/weather/index.js'; import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; -import { createLayerAvailability, rendererFor } from './layers/renderers.js'; +import { rendererFor } from './layers/renderers.js'; import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; @@ -59,10 +59,6 @@ export const APPLICATION_LAYER_METADATA = Object.freeze([ ]); /** Construct the current catalog without choosing any source provider. - * `engineKind` ('cesium' | 'navara'): only the layers that engine can show - * (src/app/layers/renderers.js) are constructed, in the same order; the - * catalog keeps the others' metadata (`unavailableMetadata`) and carries - * the engine's `availability`. * Scene engines remain page-owned; layers and classification have this app's lifetime. * The manager owns layer destruction, while abort releases classification even if startup fails. */ @@ -77,7 +73,6 @@ export function createApplicationCatalog({ nepalBoundaryResolver, streetLevelProviders, engineKind = 'cesium', - availability = createLayerAvailability(engineKind), }) { if (!signal?.addEventListener) throw new TypeError('An application lifetime signal is required'); @@ -462,36 +457,13 @@ export function createApplicationCatalog({ ), ], ]; - const available = (id) => availability.isAvailable(id); - const layers = []; - for (const [ids, create] of entries) { - const wanted = [ids].flat().filter(available); - if (!wanted.length) continue; - for (const layer of [create()].flat()) - if (wanted.includes(layer.id)) layers.push(layer); - } - const catalog = createLayerCatalog( - layers, - metadata.filter(({ id }) => available(id)), - ); + const layers = entries.flatMap(([, create]) => create()); + const catalog = createLayerCatalog(layers, metadata); return Object.freeze({ ...catalog, engineKind, - // Layers this engine cannot show: their metadata, so links keep their - // state (data.js), and the stable availability API (renderers.js). - unavailableMetadata: Object.freeze( - metadata.filter(({ id }) => !available(id)), - ), - availability, getSourceAvailability: sourceComposition.createAvailabilityLookup( catalog.layers, - { - omitted: new Set( - APPLICATION_LAYER_DIRECTORY.map(({ id }) => id).filter( - (id) => !available(id), - ), - ), - }, ), militaryRegistry, surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 2d1e43b0d..00bed2220 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -200,10 +200,8 @@ test('Mapillary can be omitted or replaced without changing catalog membership', ); }); -test('the catalog per engine: the same layers on both, a subset when the manifest says so', async (t) => { +test('the catalog has the same layers on both engines', async (t) => { const { APPLICATION_LAYER_DIRECTORY } = await import('./layers/directory.js'); - const { createLayerAvailability, LAYER_MANIFEST } = - await import('./layers/renderers.js'); const controller = new AbortController(); t.after(() => controller.abort()); const build = (options) => @@ -220,35 +218,9 @@ test('the catalog per engine: the same layers on both, a subset when the manifes APPLICATION_LAYER_DIRECTORY.map(({ id, name }) => ({ id, name })), ); assert.equal(cesium.engineKind, 'cesium'); - assert.deepEqual(cesium.unavailableMetadata, []); - assert.equal(cesium.availability.unavailable().length, 0); const navara = build({ engineKind: 'navara' }); assert.equal(navara.layers.length, cesium.layers.length); - assert.deepEqual(navara.unavailableMetadata, []); - assert.equal(navara.availability.engine, 'navara'); - - // When the engine can show only two layers, only those are constructed, - // in catalog order, with their metadata. - const subset = build({ - engineKind: 'navara', - availability: createLayerAvailability('navara', { - manifest: { - satellites: LAYER_MANIFEST.satellites, - flights: LAYER_MANIFEST.flights, - }, - }), - }); - assert.deepEqual( - subset.layers.map(({ id }) => id), - ['flights', 'satellites'], - ); - assert.deepEqual( - subset.metadata.map(({ id }) => id), - ['flights', 'satellites'], - ); - assert.ok(subset.unavailableMetadata.some(({ id }) => id === 'military')); - assert.equal(subset.get('military'), undefined); }); test('both engines attach to the same 38 session models', async (t) => { diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs index c4db926f1..57552c96b 100644 --- a/src/app/controls.test.mjs +++ b/src/app/controls.test.mjs @@ -10,7 +10,7 @@ const { createApplicationControls } = await import('./controls.js'); // controls.js and the shell import browser-only UI modules, so these read the // source: the scene engine is threaded to the shell, which hands it (not the -// viewer) to the world overlay. initWorldOverlay itself is tested on the fake +// viewer) to the world overlay. The overlay itself is tested on the fake // engine without a viewer (src/overlays/worldOverlay.test.mjs). const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8'); @@ -32,8 +32,8 @@ test('the controls pass the scene engine to the shell', () => { test('the shell initializes the world overlay on its engine', () => { const shell = read('../ui/applicationShell.js') + read('../ui/shellScene.js'); assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/); - assert.match(shell, /initWorldOverlay\(engine\);/); - assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/); + assert.match(shell, /worldOverlayFor\(engine\)\.init\(\);/); + assert.doesNotMatch(shell, /worldOverlayFor\(viewer\)/); }); let uninstallDom; diff --git a/src/app/controlsSceneFailure.test.mjs b/src/app/controlsSceneFailure.test.mjs index 9e992fb72..1a519bf6c 100644 --- a/src/app/controlsSceneFailure.test.mjs +++ b/src/app/controlsSceneFailure.test.mjs @@ -25,7 +25,7 @@ class FakeShell { test('a candidate failing in a later part leaves the shell ready for the working engine', async () => { const sceneHost = createSceneHost(); - const catalog = { get: (id) => ({ id }), unavailableMetadata: [] }; + const catalog = { get: (id) => ({ id }) }; const cesium = { kind: 'cesium', viewer: null, diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js index d23e373b1..badb08ec0 100644 --- a/src/app/layers/airQuality.js +++ b/src/app/layers/airQuality.js @@ -9,7 +9,7 @@ import { overlayHost } from './overlayHost.js'; export function createApplicationAirQuality(options) { return createAirQualityLayer({ overlayHost, - createRenderer: ({ engine }) => rendererFor('air-quality', engine), + rendererFactory: ({ engine }) => rendererFor('air-quality', engine), ...options, }); } diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index 9ec29901b..1b25e446c 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -2,7 +2,7 @@ import { createVesselLayer } from '../../layers/vessels/index.js'; import * as context from '../../data/contextStore.js'; import * as labels from '../../data/detectionDraw.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlay from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlay } from '../../overlays/worldOverlay.js'; import * as geoid from '../../data/geoid.js'; import * as sprites from '../../data/spriteOrder.js'; import * as focus from '../../data/focusDeemphasis.js'; diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index ccb9198af..1465f665b 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -1,10 +1,11 @@ +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; import { rendererFor } from './renderers.js'; import * as credits from '../../data/dataCredits.js'; import { createAlprCamerasLayer } from '../../layers/alpr/index.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; -import { registerWorldOverlayPaintLane } from '../../overlays/worldOverlay.js'; + import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js'; import { refreshTrackedReadout } from '../../data/trackedReadout.js'; @@ -31,7 +32,7 @@ export function createApplicationAlpr({ groundFloor, overlays: { registerPaintLane: (lane, painter, options, engine) => - registerWorldOverlayPaintLane(engine, lane, painter, options), + worldOverlayFor(engine)?.registerPaintLane(lane, painter, options), keyholeAlpha: keyholeLabelAlphaFromGeometry, refreshReadout: refreshTrackedReadout, subscribeMapStack(callback) { diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js index 5a520d8d9..ba899c487 100644 --- a/src/app/layers/bikeshare.js +++ b/src/app/layers/bikeshare.js @@ -2,7 +2,7 @@ import { createBikeshareLayer } from '../../layers/bikeshare/index.js'; import { rendererFor } from './renderers.js'; import * as render from '../../renderGovernor.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; /** * Construct one layer using the application scene owners and a supplied diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 13fd536a7..584f7bfa2 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -3,7 +3,7 @@ import { createCctvLayer } from '../../layers/cctv/index.js'; import { rendererFor } from './renderers.js'; import * as credits from '../../data/dataCredits.js'; import * as activation from '../../cctvFocusRequest.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import { CITY_POIS } from '../../cityPois.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js index a22d9f7d0..140369ad9 100644 --- a/src/app/layers/directions.js +++ b/src/app/layers/directions.js @@ -11,6 +11,6 @@ export function createApplicationDirections({ source, surface }) { return createApplicationDirectionsLayer({ source, groundFloor: surface?.groundFloor, - createRenderer: (engine) => rendererFor('directions', engine), + rendererFactory: (engine) => rendererFor('directions', engine), }); } diff --git a/src/app/layers/displayRehearsal.test.mjs b/src/app/layers/displayRehearsal.test.mjs index 131abf9e1..a9eb4fcd4 100644 --- a/src/app/layers/displayRehearsal.test.mjs +++ b/src/app/layers/displayRehearsal.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { LAYER_MANIFEST, CONTRACT_PRIMITIVES } from './renderers.js'; +import { LAYER_MANIFEST } from './renderers.js'; +const CONTRACT_PRIMITIVES = ['movers', 'lines', 'ground', 'imagery', 'meshes']; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import * as geo from '../../geo/index.js'; import { rgbaFromCss } from '../../layers/shared/color.js'; @@ -166,7 +167,7 @@ const cases = { }, }); engine.camera.setView({ destination: at(-97, 30, 2000) }); - d.init(null, { onMove: noop }); + d.init({ onMove: noop }); d.setVisible(true); d.renderRecords(); return { hide: () => d.setVisible(false) }; @@ -308,8 +309,8 @@ const cases = { }, }); layer.init(null, { engine }); - display.lifecycle.methods.show(); - display.rendering.ensureCityPoints( + display.show(); + display.ensureCityPoints( 'austin-capmetro', new Map([ [ @@ -326,7 +327,7 @@ const cases = { ); return { hide: () => layer.disable(), destroy: () => layer.destroy() }; }, - directions: (make) => { + directions: (make, engine) => { const d = make(); d.setMarker('A', { lat: 0, lon: 0 }); d.setRoute({ @@ -336,7 +337,7 @@ const cases = { [1, 1], ], }); - const off = d.onClick(noop); + const off = engine.input.on('click', noop, { pick: true }); d.show(true); return { hide: () => d.show(false), detach: off }; }, @@ -388,9 +389,9 @@ const cases = { sources: [], }, ]); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); return { - hide: () => h.parts.lifecycle.methods.disable(), + hide: () => h.parts.display.disable(), destroy: () => h.destroy(), }; }, @@ -587,9 +588,9 @@ function servicesFixture() { ground: { cachedGroundFloor: () => 20, warmGroundFloor: noop }, globe: { getKeyholeGeometry: () => ({ radius: 500 }) }, overlays: { - setOverlayEntries: overlayHost.setEntries, - clearOverlaySource: overlayHost.clearSource, - setOverlaySourceVisible: noop, + setEntries: overlayHost.setEntries, + clearSource: overlayHost.clearSource, + setVisible: noop, }, picking: { registerPickOwner: (id, fn) => owners.set(id, fn), diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js index 5c3c196cc..7a937139d 100644 --- a/src/app/layers/droneAirspace.js +++ b/src/app/layers/droneAirspace.js @@ -1,8 +1,7 @@ -import { worldOverlayFor } from '../../overlays/worldOverlay.js'; +import { overlayHost } from './overlayHost.js'; import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js'; import { rendererFor } from './renderers.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; /** @@ -12,14 +11,8 @@ import { isPointerFree } from '../../data/inputOwnership.js'; */ export function createApplicationDroneAirspace(options) { return createDroneAirspaceLayer({ - overlayHost: { - forEngine: worldOverlayFor, - setEntries: overlays.setOverlayEntries, - setVisible: overlays.setOverlaySourceVisible, - clearSource: overlays.clearOverlaySource, - hitTest: overlays.hitTestWorldOverlay, - }, - createRenderer: ({ engine }) => rendererFor('drone-airspace', engine), + overlayHost: overlayHost, + rendererFactory: ({ engine }) => rendererFor('drone-airspace', engine), picking, pointer: { isPointerFree }, ...options, diff --git a/src/app/layers/earthquakes.js b/src/app/layers/earthquakes.js index 2c0b57129..110eb4ef4 100644 --- a/src/app/layers/earthquakes.js +++ b/src/app/layers/earthquakes.js @@ -7,10 +7,8 @@ import { overlayHost } from './overlayHost.js'; * engine). */ export function createApplicationEarthquakes({ - renderer = { - create: ({ engine }) => rendererFor('earthquakes', engine), - }, + rendererFactory = ({ engine }) => rendererFor('earthquakes', engine), ...options } = {}) { - return createEarthquakesLayer({ overlayHost, renderer, ...options }); + return createEarthquakesLayer({ overlayHost, rendererFactory, ...options }); } diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 913bb6f41..fda6afc22 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -1,10 +1,10 @@ -import { worldOverlayFor } from '../../overlays/worldOverlay.js'; +import { overlayHost } from './overlayHost.js'; import { rendererFor } from './renderers.js'; import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import * as focus from '../../worldFocus.js'; export const firmsServices = { render, @@ -30,12 +30,6 @@ export function createApplicationFirms({ surface, ...options }) { create: ({ engine, ...renderOptions }) => rendererFor('local-firms', engine, renderOptions), }, - overlayHost: options.overlayHost ?? { - forEngine: worldOverlayFor, - setEntries: overlays.setOverlayEntries, - setVisible: overlays.setOverlaySourceVisible, - clearSource: overlays.clearOverlaySource, - hitTest: overlays.hitTestWorldOverlay, - }, + overlayHost: options.overlayHost ?? overlayHost, }); } diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index 3cce66e77..c5290c980 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,8 +1,7 @@ -import { worldOverlayFor } from '../../overlays/worldOverlay.js'; +import { overlayHost } from './overlayHost.js'; import { rendererFor } from './renderers.js'; import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; /** @@ -12,13 +11,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, - hitTest: overlays.hitTestWorldOverlay, - }, + overlayHost: overlayHost, openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), picking, pointer: { isPointerFree }, diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 15eaa94ad..b8ccf7909 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -8,8 +8,7 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; */ export function createApplicationGnssIntegrity(options) { return createGnssIntegrityLayer({ - createRenderer: ({ engine, viewAnchor }) => - rendererFor('gnss-integrity', engine, { viewAnchor }), + rendererFactory: ({ engine }) => rendererFor('gnss-integrity', engine), ...options, }); } diff --git a/src/app/layers/overlayHost.js b/src/app/layers/overlayHost.js index 97c476755..3b5360a09 100644 --- a/src/app/layers/overlayHost.js +++ b/src/app/layers/overlayHost.js @@ -1,14 +1 @@ -import { worldOverlayFor } from '../../overlays/worldOverlay.js'; -import { - clearOverlaySource, - getWorldOverlayDiagnostics, - setOverlayEntries, - setOverlaySourceVisible, -} from '../../overlays/worldOverlay.js'; -export const overlayHost = Object.freeze({ - forEngine: worldOverlayFor, - clearSource: clearOverlaySource, - getDiagnostics: getWorldOverlayDiagnostics, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, -}); +export { worldOverlayFor as overlayHost } from '../../overlays/worldOverlay.js'; diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js index 4720bc547..54c81cf9f 100644 --- a/src/app/layers/perimeters.js +++ b/src/app/layers/perimeters.js @@ -1,4 +1,4 @@ -import { worldOverlayFor } from '../../overlays/worldOverlay.js'; +import { overlayHost } from './overlayHost.js'; import { rendererFor } from './renderers.js'; import { createFirePerimetersLayer, @@ -6,7 +6,6 @@ import { createInciwebPublicationSource, } from '../../layers/perimeters/index.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; /** @@ -16,13 +15,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, - hitTest: overlays.hitTestWorldOverlay, - }, + overlayHost: overlayHost, inciwebSource: createInciwebIndexSource(), inciwebPublications: createInciwebPublicationSource(), openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js index 2cb79547f..de1c6e7b7 100644 --- a/src/app/layers/radio.js +++ b/src/app/layers/radio.js @@ -1,7 +1,7 @@ import { createRadioLayer } from '../../layers/radio/index.js'; import { rendererFor } from './renderers.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import * as globe from '../../celestialRing.js'; import * as render from '../../renderGovernor.js'; diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 0690c00a8..3c7647447 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -1,124 +1,11 @@ -// How each application layer draws on each engine: the layer manifest, and -// the layer availability API built on it. -// -// ## The manifest -// -// One entry per layer of the directory (src/app/layers/directory.js), in -// its order, of one of three kinds: -// -// - `display(name, needs, backends)`: the layer's render/display.js, the -// same factory `(engine, options) => renderer` on every engine, drawn -// through the contract primitives in `needs` (docs/GLOBE-PRIMITIVES.md). -// The factory is the export `name` of ./displays.js, one chunk loaded -// through import() on every engine kind, outside the Cesium-mode main -// chunk (but for the displays a layer package also imports as its -// default); the entry's `display` is its loader. `backends` is the -// display's engine-specific option, keyed by engine kind: an import() -// loader of `{ option: value }` merged into the display's options on that -// engine (wind streamlines). -// - `drawsItself(needs)`: no renderer; the layer draws itself through the -// contract primitives in `needs` (Wildlife Sightings). -// - `perEngine({ [kind]: loader | DRAWS_ITSELF })`: a layer with no display -// yet. On each engine kind listed it either draws itself (DRAWS_ITSELF, -// the open map overlays) or has an engine-specific renderer, an import() -// loader of its factory. It shows only on the kinds listed. -// -// Every import() here is resolved by `loadLayerRenderers(kind)`, which the -// page awaits before it builds an engine's catalog (src/standalone/ -// application.js), so a Cesium page fetches its displays and none of the -// Navara renderers. -// `rendererFor(id, engine, options)` then answers synchronously: the -// layer's renderer on that engine, or null when it draws itself or the -// engine cannot show it. -// -// ## Layer availability (stable API, used by the layer list) -// -// import { createLayerAvailability } from './app/layers/renderers.js'; -// const availability = createLayerAvailability(engine.kind); -// availability.engine // 'cesium' | 'navara' -// availability.isAvailable(id) // can this engine show layer `id`? -// availability.available() // [{ id, name }] in catalog order -// availability.unavailable() // [{ id, name }] the catalog leaves out -// -// A layer is available on an engine kind when that engine has every -// primitive its display (or its own drawing) needs, or, without one, when -// its entry lists that kind. The object is frozen; available() and -// unavailable() return fresh arrays. `name` is the layer's display name -// (src/app/layers/directory.js). The layer list takes it as its -// `availability` provider (`{ engine, unavailable() }` is the part it reads). - -import { ENGINE_KINDS } from '../../globe/contract.js'; -import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; - -/** - * The contract's drawing primitives (docs/GLOBE-PRIMITIVES.md). They are - * required members of every engine (src/globe/contract.js), so every - * engine kind has them all. - */ -export const CONTRACT_PRIMITIVES = Object.freeze([ - 'movers', - 'lines', - 'ground', - 'imagery', - 'meshes', -]); - -/** The primitives an engine kind draws with: all of them, on every kind. */ -const primitivesOf = (engineKind) => - ENGINE_KINDS.includes(engineKind) ? CONTRACT_PRIMITIVES : []; - -/** In a `perEngine` entry: the layer draws itself on that engine kind. */ -export const DRAWS_ITSELF = Object.freeze({ drawsItself: true }); - -const checkNeeds = (needs) => { - if ( - !Array.isArray(needs) || - !needs.length || - !needs.every((name) => CONTRACT_PRIMITIVES.includes(name)) - ) - throw new TypeError(`Not contract primitives: ${needs}`); - return Object.freeze([...needs]); -}; - -const checkLoaders = (loaders, allowDrawsItself) => { - for (const [kind, loader] of Object.entries(loaders)) { - if (!ENGINE_KINDS.includes(kind)) - throw new TypeError(`Unknown engine kind for layers: ${kind}`); - if ( - typeof loader !== 'function' && - !(allowDrawsItself && loader === DRAWS_ITSELF) - ) - throw new TypeError(`Not a loader for ${kind}: ${loader}`); - } - return Object.freeze({ ...loaders }); -}; - -/** A layer drawn by its display on every engine (see the module comment). */ -function display(name, needs, backends = {}) { - if (typeof name !== 'string') - throw new TypeError('A layer display is named by its export'); +// Display factories by layer id, with engine-specific wind flow backends. +function display(name, backends = {}) { return Object.freeze({ - display: async () => { - const factory = (await displays())[name]; - if (typeof factory !== 'function') - throw new TypeError(`No layer display ${name} in ./displays.js`); - return factory; - }, - needs: checkNeeds(needs), - backends: checkLoaders(backends, false), + display: async () => (await displays())[name], + backends, }); } -/** A layer that draws itself through the contract (no renderer). */ -const drawsItself = (needs) => Object.freeze({ needs: checkNeeds(needs) }); - -/** A layer without a display: its drawing per engine kind. */ -const perEngine = (engines) => - Object.freeze({ engines: checkLoaders(engines, true) }); - -/** Navara's own view and classes, for the renderers that still use them. */ -const navaraNative = (engine) => engine.extensions.navara; - // Wind streamlines retain their engine backends. const cesiumWindFlow = async () => { const { createWindCesiumFlow } = @@ -132,257 +19,101 @@ const navaraWindFlow = async () => { ]); return { flow: (engine) => - createWindNavaraFlow(engine, navaraNative(engine), createWindFlowMesh), + createWindNavaraFlow( + engine, + engine.extensions.navara, + createWindFlowMesh, + ), }; }; // The layers' displays (./displays.js), one chunk. const displays = () => import('./displays.js'); -/** How every application layer draws, by id, in directory order. */ export const LAYER_MANIFEST = Object.freeze({ - 'bhote-koshi-2026': display('createBhoteKoshiDisplay', [ - 'imagery', - 'ground', - 'movers', - ]), - 'bhote-koshi-locator': display('createBhoteKoshiLocatorDisplay', [ - 'ground', - 'movers', - ]), + 'bhote-koshi-2026': display('createBhoteKoshiDisplay'), + 'bhote-koshi-locator': display('createBhoteKoshiLocatorDisplay'), // The aircraft family. - flights: display('createFlightsDisplay', ['movers', 'lines']), - military: display('createMilitaryDisplay', ['movers', 'lines']), - 'local-adsb': display('createLocalAdsbDisplay', ['movers', 'lines']), - earthquakes: display('createEarthquakesDisplay', ['ground']), - 'air-quality': display('createAirQualityDisplay', ['ground']), - 'fire-perimeters': display('createPerimetersDisplay', ['ground']), - 'gnss-integrity': display('createGnssDisplay', ['ground']), - 's2-ghostmaps': display('createGhostMapsDisplay', ['movers', 'ground']), - 'drone-airspace': display('createDroneAirspaceDisplay', ['ground']), + flights: display('createFlightsDisplay'), + military: display('createMilitaryDisplay'), + 'local-adsb': display('createLocalAdsbDisplay'), + earthquakes: display('createEarthquakesDisplay'), + 'air-quality': display('createAirQualityDisplay'), + 'fire-perimeters': display('createPerimetersDisplay'), + 'gnss-integrity': display('createGnssDisplay'), + 's2-ghostmaps': display('createGhostMapsDisplay'), + 'drone-airspace': display('createDroneAirspaceDisplay'), // Draws itself through the globe contract (engine.movers, engine.lines). - 'wildlife-sightings': drawsItself(['movers', 'lines']), - 'alpr-cameras': display('createAlprDisplay', ['movers']), - 'street-level': display('createStreetLevelDisplay', ['movers', 'ground']), - satellites: display('createSatellitesDisplay', ['movers', 'lines']), - 'rocket-launches': display('createLaunchesDisplay', [ - 'movers', - 'lines', - 'ground', - ]), - traffic: display('createTrafficDisplay', ['movers', 'ground']), - cctv: display('createCctvDisplay', ['movers', 'lines', 'meshes']), - radio: display('createRadioDisplay', ['movers']), - transit: display('createTransitDisplay', ['movers', 'ground']), - bikeshare: display('createBikeshareDisplay', ['movers']), - directions: display('createDirectionsDisplay', ['movers', 'ground']), - 'recent-imagery': display('createRecentImageryDisplay', [ - 'ground', - 'imagery', - ]), - 'osm-places': display('createOsmPlacesDisplay', ['movers']), - 'ais-live-vessels': display('createVesselsDisplay', ['movers', 'lines']), - 'military-installations': display('createInstallationsDisplay', [ - 'movers', - 'ground', - ]), - 'military-awareness': display('createAwarenessDisplay', ['lines', 'ground']), + 'wildlife-sightings': Object.freeze({}), + 'alpr-cameras': display('createAlprDisplay'), + 'street-level': display('createStreetLevelDisplay'), + satellites: display('createSatellitesDisplay'), + 'rocket-launches': display('createLaunchesDisplay'), + traffic: display('createTrafficDisplay'), + cctv: display('createCctvDisplay'), + radio: display('createRadioDisplay'), + transit: display('createTransitDisplay'), + bikeshare: display('createBikeshareDisplay'), + directions: display('createDirectionsDisplay'), + 'recent-imagery': display('createRecentImageryDisplay'), + 'osm-places': display('createOsmPlacesDisplay'), + 'ais-live-vessels': display('createVesselsDisplay'), + 'military-installations': display('createInstallationsDisplay'), + 'military-awareness': display('createAwarenessDisplay'), // The wind field and the weather rasters on engine.imagery; the wind's // streamlines are each engine's backend (Cesium's wind renderer, Navara's // flow mesh). - wind: display('createWindDisplay', ['imagery'], { + wind: display('createWindDisplay', { cesium: cesiumWindFlow, navara: navaraWindFlow, }), - 'weather-radar': display('createWeatherDisplay', ['imagery']), - 'weather-satellite': display('createWeatherDisplay', ['imagery']), - 'weather-lightning': display('createWeatherDisplay', ['imagery']), - 'weather-cyclones': display('createCyclonesDisplay', ['movers', 'ground']), + 'weather-radar': display('createWeatherDisplay'), + 'weather-satellite': display('createWeatherDisplay'), + 'weather-lightning': display('createWeatherDisplay'), + 'weather-cyclones': display('createCyclonesDisplay'), // The open map overlays draw through imagery, or the optional native // maps.addOverlay path (src/data/osmOverlays.js). - 'sea-marks': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }), - 'ski-pistes': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }), - 'local-datacenters': display('createInfrastructureDisplay', [ - 'movers', - 'lines', - 'ground', - ]), - 'local-dams': display('createInfrastructureDisplay', [ - 'movers', - 'lines', - 'ground', - ]), - 'telegeography-submarine-cables': display('createCablesDisplay', [ - 'movers', - 'lines', - 'ground', - ]), - 'local-firms': display('createFirmsDisplay', ['movers', 'ground', 'imagery']), + 'sea-marks': Object.freeze({}), + 'ski-pistes': Object.freeze({}), + 'local-datacenters': display('createInfrastructureDisplay'), + 'local-dams': display('createInfrastructureDisplay'), + 'telegeography-submarine-cables': display('createCablesDisplay'), + 'local-firms': display('createFirmsDisplay'), }); -/** The manifest entry of a layer, or null. */ -const entryOf = (manifest, layerId) => - Object.hasOwn(manifest, layerId) ? manifest[layerId] : null; - -/** Whether `entry` shows on `engineKind` (see the module comment). */ -const entryAvailableOn = (entry, engineKind) => { - if (!entry) return false; - if (entry.needs) { - const primitives = primitivesOf(engineKind); - return entry.needs.every((name) => primitives.includes(name)); - } - return Object.hasOwn(entry.engines, engineKind); -}; - -/** The engine-specific import() loader of a layer on an engine kind, or null. */ -const loaderOf = (entry, engineKind) => { - const loader = entry.backends?.[engineKind] ?? entry.engines?.[engineKind]; - return typeof loader === 'function' ? loader : null; -}; - -/** Whether a layer has an import() to load on an engine kind. */ -const loadsOn = (entry, engineKind) => - entryAvailableOn(entry, engineKind) && - Boolean(entry.display || loaderOf(entry, engineKind)); - -/** - * What a layer's import()s resolve to on an engine kind: its display with - * the backend's options (`{ display, options }`), or the engine-specific - * factory. - */ -const loadEntry = async (entry, engineKind) => { - const loader = loaderOf(entry, engineKind); - if (!entry.display) return loader(); - const [factory, options] = await Promise.all([ - entry.display(), - loader ? loader() : {}, - ]); - return { display: factory, options }; -}; - -// What loadLayerRenderers resolved: engine kind → layer id → value -// (loadEntry's). const loaded = new Map(); const loading = new Map(); -/** - * Loads every import() the manifest has for `engineKind` (the displays and - * that engine's renderers and backends), once per page: concurrent calls - * share the load, and after a failure the next call loads what is still - * missing. - * @param {string} engineKind One of ENGINE_KINDS. - * @returns {Promise} - */ +/** Load the displays and wind backend once per engine kind. */ export function loadLayerRenderers(engineKind) { - if (!ENGINE_KINDS.includes(engineKind)) - throw new TypeError(`Unknown engine kind for layers: ${engineKind}`); if (loading.has(engineKind)) return loading.get(engineKind); if (!loaded.has(engineKind)) loaded.set(engineKind, new Map()); const done = loaded.get(engineKind); const load = Promise.all( Object.entries(LAYER_MANIFEST).map(async ([id, entry]) => { - if (loadsOn(entry, engineKind) && !done.has(id)) - done.set(id, await loadEntry(entry, engineKind)); + if (!entry.display || done.has(id)) return; + const [factory, options] = await Promise.all([ + entry.display(), + entry.backends[engineKind]?.() ?? {}, + ]); + done.set(id, { factory, options }); }), - ).then( - () => { - loading.delete(engineKind); - }, - (error) => { - loading.delete(engineKind); - throw error; - }, - ); + ).finally(() => loading.delete(engineKind)); loading.set(engineKind, load); return load; } -// Renderers of the engine kinds a test brings (a stand-in engine of its -// own kind): layer id → factory. The application's kinds use the manifest. -const standIns = new Map(); - -/** - * Test seam: renderer factories `{ [layerId]: (engine, options) => renderer }` - * for an engine kind outside the application's (ENGINE_KINDS). - */ -export function registerLayerRenderers(engineKind, factories) { - if (typeof engineKind !== 'string' || !engineKind) - throw new TypeError('registerLayerRenderers needs an engine kind'); - if (ENGINE_KINDS.includes(engineKind)) - throw new Error(`The layer manifest draws ${engineKind}`); - const map = standIns.get(engineKind) ?? new Map(); - standIns.set(engineKind, map); - for (const [layerId, factory] of Object.entries(factories ?? {})) { - if (typeof factory !== 'function') - throw new TypeError( - `Layer renderer for ${layerId} on ${engineKind} is not a factory`, - ); - if (map.has(layerId)) - throw new Error( - `Layer ${layerId} already has a renderer on ${engineKind}`, - ); - map.set(layerId, factory); - } -} - -/** - * The renderer of a layer on `engine` (its display or engine-specific - * renderer, built now), or null when the layer draws itself there or the - * engine cannot show it. Throws when that engine's import()s are not - * loaded yet (loadLayerRenderers). - */ +/** Build a loaded display, or return null for a layer that draws itself. */ export function rendererFor(layerId, engine, options = {}) { - const engineKind = engine?.kind; - const standIn = standIns.get(engineKind)?.get(layerId); - if (standIn) return standIn(engine, options); - const entry = entryOf(LAYER_MANIFEST, layerId); - if (!loadsOn(entry, engineKind)) return null; + const entry = + Object.hasOwn(LAYER_MANIFEST, layerId) && LAYER_MANIFEST[layerId]; + if (!entry?.display) return null; + if (!engine) return null; + const engineKind = engine.kind; const value = loaded.get(engineKind)?.get(layerId); - if (value === undefined) + if (!value) throw new Error( `Layer ${layerId} on ${engineKind}: its renderer is not loaded (loadLayerRenderers)`, ); - if (entry.display) - return value.display(engine, { ...options, ...value.options }); - return value(engine, options); -} - -/** Whether the application can show a layer on an engine kind. */ -export const layerAvailableOn = (layerId, engineKind) => - entryAvailableOn(entryOf(LAYER_MANIFEST, layerId), engineKind); - -/** - * @typedef {object} LayerAvailability - * @property {'cesium' | 'navara'} engine - * @property {(layerId: string) => boolean} isAvailable - * @property {() => { id: string, name: string }[]} available - * @property {() => { id: string, name: string }[]} unavailable - */ - -/** - * The layers an engine kind can show (stable API; see the module comment). - * @param {'cesium' | 'navara'} engineKind - * @param {{ manifest?: object, directory?: { id: string, name: string }[] }} [options] - * Test seams: the manifest (some of LAYER_MANIFEST's entries) and the - * layer directory. - * @returns {LayerAvailability} - */ -export function createLayerAvailability( - engineKind, - { manifest = LAYER_MANIFEST, directory = APPLICATION_LAYER_DIRECTORY } = {}, -) { - if (!ENGINE_KINDS.includes(engineKind)) - throw new TypeError(`Unknown engine kind for layers: ${engineKind}`); - const isAvailable = (layerId) => - entryAvailableOn(entryOf(manifest, layerId), engineKind); - const rows = (wanted) => - directory - .filter(({ id }) => isAvailable(id) === wanted) - .map(({ id, name }) => ({ id, name })); - return Object.freeze({ - engine: engineKind, - isAvailable, - available: () => rows(true), - unavailable: () => rows(false), - }); + return value.factory(engine, { ...options, ...value.options }); } diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 7c5379992..1e18a3143 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -1,17 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - CONTRACT_PRIMITIVES, - DRAWS_ITSELF, LAYER_MANIFEST, - createLayerAvailability, - layerAvailableOn, loadLayerRenderers, - registerLayerRenderers, rendererFor, } from './renderers.js'; import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; -import { ENGINE_KINDS, assertGlobeEngine } from '../../globe/contract.js'; +import { ENGINE_KINDS } from '../../globe/contract.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; test('the manifest lists every directory layer, in its order', () => { @@ -24,25 +19,6 @@ test('the manifest lists every directory layer, in its order', () => { assert.ok(Object.isFrozen(entry)); }); -test('the primitives a display needs are the contract primitives every engine has', () => { - const engine = createFakeEngine(); - assertGlobeEngine(engine); - for (const name of CONTRACT_PRIMITIVES) assert.ok(engine[name], name); - for (const [id, entry] of Object.entries(LAYER_MANIFEST)) - for (const name of entry.needs ?? []) - assert.ok(CONTRACT_PRIMITIVES.includes(name), `${id} needs ${name}`); -}); - -test('every layer shows on both engines, as before', () => { - for (const { id } of APPLICATION_LAYER_DIRECTORY) - for (const kind of ENGINE_KINDS) - assert.equal(layerAvailableOn(id, kind), true, `${id} on ${kind}`); - assert.equal(layerAvailableOn('flights', 'vulkan'), false); - assert.equal(layerAvailableOn('unknown', 'cesium'), false); - assert.equal(layerAvailableOn('toString', 'cesium'), false); - assert.equal(rendererFor('toString', createFakeEngine()), null); -}); - test('the layers drawn through one display, and the ones that draw without one', () => { const kinds = (filter) => Object.entries(LAYER_MANIFEST) @@ -94,29 +70,10 @@ test('the layers drawn through one display, and the ones that draw without one', ['wind'], ); assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), []); - assert.ok(LAYER_MANIFEST.cctv.needs.includes('meshes')); - // The layer that draws itself through the contract. assert.deepEqual( - kinds((entry) => !entry.display && entry.needs), - ['wildlife-sightings'], + kinds((entry) => !entry.display), + ['wildlife-sightings', 'sea-marks', 'ski-pistes'], ); - // Without a display: drawn by the layer itself or by an engine-specific - // renderer. - const engines = Object.fromEntries( - kinds((entry) => entry.engines).map((id) => [ - id, - Object.fromEntries( - Object.entries(LAYER_MANIFEST[id].engines).map(([kind, drawing]) => [ - kind, - drawing === DRAWS_ITSELF ? 'itself' : 'renderer', - ]), - ), - ]), - ); - assert.deepEqual(engines, { - 'sea-marks': { cesium: 'itself', navara: 'itself' }, - 'ski-pistes': { cesium: 'itself', navara: 'itself' }, - }); }); test('a display or an engine-specific renderer answers only after its engine loaded them', () => { @@ -207,82 +164,5 @@ test('a layer that draws itself has no renderer; an unknown engine none either', const engine = createFakeEngine(); for (const id of ['wildlife-sightings', 'sea-marks', 'ski-pistes']) assert.equal(rendererFor(id, engine), null, id); - assert.equal(rendererFor('flights', null), null); - assert.equal(rendererFor('flights', { kind: 'vulkan' }), null); engine.destroy(); }); - -test('a test engine of its own kind brings its renderers', () => { - const made = []; - registerLayerRenderers('renderers-test', { - flights: (engine, options) => { - made.push([engine.kind, options]); - return { destroy() {} }; - }, - }); - const engine = { kind: 'renderers-test' }; - assert.ok(rendererFor('flights', engine, { tint: 1 })); - assert.deepEqual(made, [['renderers-test', { tint: 1 }]]); - assert.equal(rendererFor('satellites', engine), null); - assert.throws( - () => registerLayerRenderers('renderers-test', { flights: () => ({}) }), - /already/, - 'one renderer per layer and engine', - ); - assert.throws( - () => registerLayerRenderers('renderers-test', { x: 42 }), - TypeError, - ); - for (const kind of ENGINE_KINDS) - assert.throws( - () => registerLayerRenderers(kind, { flights: () => ({}) }), - /manifest/, - ); -}); - -test('the layer availability API: engine, isAvailable, available, unavailable', () => { - const directory = [ - { id: 'flights', name: 'Live Flights' }, - { id: 'satellites', name: 'Satellites' }, - ]; - const availability = createLayerAvailability('navara', { - manifest: { flights: LAYER_MANIFEST.flights }, - directory, - }); - assert.equal(availability.engine, 'navara'); - assert.equal(availability.isAvailable('flights'), true); - assert.equal(availability.isAvailable('satellites'), false); - assert.equal(availability.isAvailable('unknown'), false); - assert.equal(availability.isAvailable('toString'), false); - assert.deepEqual(availability.available(), [ - { id: 'flights', name: 'Live Flights' }, - ]); - assert.deepEqual(availability.unavailable(), [ - { id: 'satellites', name: 'Satellites' }, - ]); - // Fresh copies: a caller cannot change the answer. - availability.unavailable().push({ id: 'x', name: 'x' }); - assert.equal(availability.unavailable().length, 1); - assert.ok(Object.isFrozen(availability)); - // A layer without a display shows only on the engine kinds it lists. - const cesiumOnly = { wind: { engines: { cesium: DRAWS_ITSELF } } }; - const wind = [{ id: 'wind', name: 'Wind' }]; - for (const [kind, shown] of [ - ['cesium', true], - ['navara', false], - ]) - assert.equal( - createLayerAvailability(kind, { - manifest: cesiumOnly, - directory: wind, - }).isAvailable('wind'), - shown, - kind, - ); - for (const kind of ENGINE_KINDS) { - const all = createLayerAvailability(kind); - assert.equal(all.unavailable().length, 0, kind); - assert.equal(all.available().length, APPLICATION_LAYER_DIRECTORY.length); - } - assert.throws(() => createLayerAvailability('vulkan'), TypeError); -}); diff --git a/src/app/layers/rocketLaunches.js b/src/app/layers/rocketLaunches.js index 2e12f6549..0bf7e371f 100644 --- a/src/app/layers/rocketLaunches.js +++ b/src/app/layers/rocketLaunches.js @@ -1,7 +1,7 @@ import { rendererFor } from './renderers.js'; import { createRocketLaunchesLayer } from '../../layers/launches/index.js'; import * as geometry from '../../celestialRing.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import * as render from '../../renderGovernor.js'; /** Construct one layer using the application scene owners and a supplied source. */ diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js index d5ca5e4db..e613a9c2f 100644 --- a/src/app/layers/satellites.js +++ b/src/app/layers/satellites.js @@ -3,7 +3,7 @@ import { createSatellitesLayer } from '../../layers/satellites/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as readout from '../../data/trackedReadout.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import * as context from '../../data/contextStore.js'; import * as render from '../../renderGovernor.js'; import * as layerState from '../../data/layerState.js'; diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 253fac3c1..5765fb2d5 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -2,7 +2,7 @@ import { createTransitLayer } from '../../layers/transit/index.js'; import { rendererFor } from './renderers.js'; import * as render from '../../renderGovernor.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import { registerDynamicCredit, transitFeedCredit, diff --git a/src/app/layers/wildlifeSightings.js b/src/app/layers/wildlifeSightings.js index 415edd052..0a03100e0 100644 --- a/src/app/layers/wildlifeSightings.js +++ b/src/app/layers/wildlifeSightings.js @@ -1,7 +1,6 @@ -import { worldOverlayFor } from '../../overlays/worldOverlay.js'; +import { overlayHost } from './overlayHost.js'; import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js'; import * as picking from '../../data/pickRegistry.js'; -import * as overlays from '../../overlays/worldOverlay.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as focus from '../../worldFocus.js'; @@ -10,13 +9,7 @@ import { isPointerFree } from '../../data/inputOwnership.js'; /** Bind the wildlife layer to the application's selection and overlay owners. */ export function createApplicationWildlifeSightings(options) { return createWildlifeSightingsLayer({ - overlayHost: { - forEngine: worldOverlayFor, - setEntries: overlays.setOverlayEntries, - setVisible: overlays.setOverlaySourceVisible, - clearSource: overlays.clearOverlaySource, - hitTest: overlays.hitTestWorldOverlay, - }, + overlayHost: overlayHost, picking, pointer: { isPointerFree }, render, diff --git a/src/app/localGeojsonServices.js b/src/app/localGeojsonServices.js index 9aab1fe63..1f6c68ae9 100644 --- a/src/app/localGeojsonServices.js +++ b/src/app/localGeojsonServices.js @@ -8,20 +8,11 @@ import { selectEntityContext, getSelectedEntityContext, } from '../data/contextStore.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; + /** Existing standalone application operations, shared by its local layers. */ export const localGeoJsonServices = Object.freeze({ context, - overlayHost: Object.freeze({ - forEngine: worldOverlayFor, - clearSource: clearOverlaySource, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - }), + overlayHost: worldOverlayFor, clearSelectedEntityContextForLayer, registerEntityContext, removeEntityContextsForLayer, diff --git a/src/app/mapLifecycle.test.mjs b/src/app/mapLifecycle.test.mjs index 2a2e6df6f..406f48454 100644 --- a/src/app/mapLifecycle.test.mjs +++ b/src/app/mapLifecycle.test.mjs @@ -8,10 +8,10 @@ test('destroy invalidates a pending imagery provider before it can touch the vie let resolveProvider; let removed = 0; const changes = []; - const controller = new MapStackController( - {}, - { onChange: (state) => changes.push(state.status) }, - ); + const controller = new MapStackController({ + engine: createFakeEngine({ kind: 'cesium' }), + onChange: (state) => changes.push(state.status), + }); controller._getImageryProvider = () => new Promise((resolve) => { resolveProvider = resolve; diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 41b95e00a..b68959cce 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -3,12 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - createLayerAvailability, - loadLayerRenderers, - rendererFor, -} from './layers/renderers.js'; -import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; +import { loadLayerRenderers, rendererFor } from './layers/renderers.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; test('Navara loads its renderers once and shows every layer', async () => { @@ -18,12 +13,6 @@ test('Navara loads its renderers once and shows every layer', async () => { assert.equal(loadLayerRenderers('navara'), first, 'one load at a time'); await Promise.all([first, loadLayerRenderers('navara')]); await loadLayerRenderers('navara'); - assert.deepEqual( - createLayerAvailability('navara') - .available() - .map(({ id }) => id), - APPLICATION_LAYER_DIRECTORY.map(({ id }) => id), - ); // Navara's own renderers answer now, with its view: a weather renderer // draws nothing until it has a frame. const viewed = { ...engine, extensions: { navara: {} } }; @@ -32,7 +21,6 @@ test('Navara loads its renderers once and shows every layer', async () => { // The layers that draw themselves still have none. for (const id of ['wildlife-sightings', 'sea-marks', 'ski-pistes']) assert.equal(rendererFor(id, engine), null, id); - assert.throws(() => loadLayerRenderers('vulkan'), TypeError); // Cesium loads the same displays: wind draws through one there too. await loadLayerRenderers('cesium'); assert.equal(typeof rendererFor('wind', createFakeEngine()), 'object'); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index cdff8062d..074d0bb50 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -6,7 +6,8 @@ import { t } from '../i18n/index.js'; // none of it. import { registerDataCredits } from '../data/dataCredits.js'; -import { createLayerAvailability } from './layers/renderers.js'; +import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; +import { LAYER_MANIFEST } from './layers/renderers.js'; import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; /** The Navara globe module, loaded on demand. */ @@ -17,10 +18,11 @@ const loadNavaraGlobeModule = async () => * The shell's notice in the experimental Navara mode (label and detail): the * data layers this engine shows (its catalog, from the layer manifest) and * what it does not have. - * @param {import('./layers/renderers.js').LayerAvailability} availability */ -export function navaraModeNotice(availability) { - const names = availability.available().map(({ name }) => name); +export function navaraModeNotice(manifest = LAYER_MANIFEST) { + const names = APPLICATION_LAYER_DIRECTORY.filter(({ id }) => + Object.hasOwn(manifest, id), + ).map(({ name }) => name); return Object.freeze({ label: t('Experimental Navara engine'), detail: names.length @@ -61,7 +63,7 @@ export async function createNavaraScene({ const { engine } = globe; signal.throwIfAborted(); loaderStatus.textContent = t('Initializing systems...'); - const mapStackController = new MapController(null, { + const mapStackController = new MapController({ requestRender: (reason) => engine.render.request(reason), engine, ...mapOptions, @@ -116,13 +118,11 @@ export function navaraToolBackend({ engine }) { // The session's tool on the engine's input and preview. draw: () => ({ engine }), recentImagery: () => ({ engine }), - attach({ data, styleManager }, { signal, defer }) { + attach({ styleManager }, { signal, defer }) { void styleManager.initialRestorePromise?.then(() => { if (!signal.aborted) views.alignDaylightToRestoredView(); }); - const notice = navaraModeNotice( - data.catalog?.availability ?? createLayerAvailability(engine.kind), - ); + const notice = navaraModeNotice(); styleManager._showGlobalStatusNotice?.(notice.label, { state: 'retry', detail: notice.detail, diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index 18a57fc14..fae0981bf 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -229,7 +229,6 @@ test('parking or tearing down the Navara scene releases a running camera verb', moveCamera({ motion: 'pan', direction: 'left', mode: 'continuous' }).ok, true, ); - assert.equal(getActiveCameraMotion()?.rig, 'engine'); // An engine switch parks the renderer (its scene's suspend); a page // teardown disposes it. if (end === 'park') cache.retain('navara'); diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js index 7eb044963..5d025ba92 100644 --- a/src/app/qaProbe.js +++ b/src/app/qaProbe.js @@ -24,10 +24,9 @@ const plain = (position) => * @param {{ * engine: import('../globe/contract.js').GlobeEngine, * dataManager: object, - * availability: import('./layers/renderers.js').LayerAvailability, * }} options */ -export function createQaProbe({ engine, dataManager, availability }) { +export function createQaProbe({ engine, dataManager }) { let frames = 0; const removeFrames = engine.frames.postRender.addEventListener(() => { frames++; @@ -53,10 +52,10 @@ export function createQaProbe({ engine, dataManager, availability }) { id, name, enabled: Boolean(enabled), - available: availability.isAvailable(id), + available: true, })), - unavailable: () => availability.unavailable(), - available: (layerId) => availability.isAvailable(layerId), + unavailable: () => [], + available: (layerId) => dataManager.layers.has(layerId), enable: (layerId) => dataManager.setEnabled(layerId, true, { origin: 'user' }), disable: (layerId) => diff --git a/src/app/qaProbe.test.mjs b/src/app/qaProbe.test.mjs index d875546bb..90ff4cdbe 100644 --- a/src/app/qaProbe.test.mjs +++ b/src/app/qaProbe.test.mjs @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import * as geo from '../geo/index.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createQaProbe } from './qaProbe.js'; -import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js'; function fakeManager(modules) { const enabled = new Set(); @@ -33,9 +32,6 @@ test('the probe lists, toggles and reads layers through the engine', async () => const probe = createQaProbe({ engine, dataManager: manager, - availability: createLayerAvailability('navara', { - manifest: { flights: LAYER_MANIFEST.flights }, - }), }); assert.deepEqual(probe.list(), [ { id: 'flights', name: 'Live Flights', enabled: false, available: true }, @@ -47,7 +43,7 @@ test('the probe lists, toggles and reads layers through the engine', async () => .unavailable() .map(({ id }) => id) .includes('satellites'), - true, + false, ); assert.equal(await probe.enable('flights'), true); assert.equal(manager.isEnabled('flights'), true); diff --git a/src/app/scene.js b/src/app/scene.js index f91e6a08a..da8b89f69 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -219,7 +219,7 @@ async function createRenderer({ loaderStatus.textContent = t('Initializing systems...'); - const mapStackController = new MapController(viewer, { + const mapStackController = new MapController({ requestRender: (reason) => engine.render.request(reason), // Map sources reach the scene through the globe engine's maps. engine, diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 15ba0a5a9..f07d3deaf 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createApplicationTools } from './tools.js'; import { navaraModeNotice } from './navaraMode.js'; -import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js'; +import { LAYER_MANIFEST } from './layers/renderers.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { renderGovernorFor, @@ -138,7 +138,7 @@ test('without a viewer the tools run the governor on the engine, show the notice assert.equal(chrome.length, 1); assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome'); assert.equal(chrome[0].styleManager, styleManager); - const notice = navaraModeNotice(createLayerAvailability('navara')); + const notice = navaraModeNotice(); assert.match(notice.detail, /^Data layers: Bhote Koshi Flood, /); assert.deepEqual(notices, [ { @@ -150,9 +150,7 @@ test('without a viewer the tools run the governor on the engine, show the notice }, }, ]); - const none = navaraModeNotice( - createLayerAvailability('navara', { manifest: {} }), - ); + const none = navaraModeNotice({}); assert.match( `${none.label}: ${none.detail}`, /^Experimental Navara engine: no registered data layers$/i, @@ -163,7 +161,7 @@ test('without a viewer the tools run the governor on the engine, show the notice flights: LAYER_MANIFEST.flights, }; assert.equal( - navaraModeNotice(createLayerAvailability('navara', { manifest })).detail, + navaraModeNotice(manifest).detail, 'Data layers: Live Flights, Satellites', ); // The governor drives the engine's continuous rendering. diff --git a/src/cameraGroundGuard.test.mjs b/src/cameraGroundGuard.test.mjs index e2be77f87..84b3182fc 100644 --- a/src/cameraGroundGuard.test.mjs +++ b/src/cameraGroundGuard.test.mjs @@ -73,6 +73,7 @@ function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) { flyTo: (options) => flights.push(options), }, }; + viewer.clock = { onTick: new Cesium.Event() }; viewer.engine = createCesiumEngine(viewer); return viewer; } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 831be442c..e1e37f2f2 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -249,27 +249,19 @@ test('accepted navigation releases through PR15-aware ownership before flight', /_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'follow release', ); - ordered( - release, - [ - 'origin: trackingOrigin', - 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', - 'rocketLaunchesLayer.releaseCameraOwnership?.()', - 'this.engine?.camera.following()?.stop();', - "interruptCameraMotion('explicit-navigation')", - 'camera?.cancelFlight();', - 'camera?.release();', - ], - 'follow release', - ); - assert.match( - release, - /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/, - ); - assert.match( - release, - /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/, - ); + ordered(release, [ + 'releaseTrackers(this.tracking, {', 'origin: trackingOrigin', + 'this.engine?.camera.following()?.stop();', + "interruptCameraMotion('explicit-navigation')", 'camera?.cancelFlight();', 'camera?.release();', + ], 'follow release'); + const trackers = fs.readFileSync(new URL('./ui/releaseTrackers.js', import.meta.url), 'utf8'); + ordered(trackers, ['militaryAwarenessLayer.releaseCameraOwnership?.(', + 'flightsLayer.stopTracking?.({ origin: options.origin })', + 'militaryFlightsLayer.stopTracking?.({ origin: options.origin })', + 'aisLiveVesselsLayer.clearSelection?.()', + 'satellitesLayer.stopTracking?.({ origin: options.origin })', + 'rocketLaunchesLayer.releaseCameraOwnership?.()', + ], 'tracker release'); }); test('validated voice camera destinations share the UI navigation authority facade', () => { diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index f3a883806..eea4185af 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1093,7 +1093,6 @@ function createEngineRig(engine) { const place = (position, direction, up) => camera.setView({ destination: position, orientation: { direction, up } }); return { - kind: 'engine', owner: engine, engine, position: () => pose().position, @@ -1255,42 +1254,9 @@ function engineRigFor(engine) { return rig; } -/** The members an engine rig reads, as paths on the engine. */ -const ENGINE_RIG_MEMBERS = Object.freeze([ - 'camera.pose', - 'camera.height', - 'camera.setView', - 'camera.lookAt', - 'camera.release', - 'camera.transform', - 'camera.flightActive', - 'camera.following', - 'camera.cancelFlight', - 'frames.preRender.addEventListener', - 'camera.followChanged.addEventListener', - 'host.size', - 'projector.worldToWindow', - 'render.request', -]); - -/** - * Whether the camera verbs (move_camera, and fly_route over a route list) can - * drive this target: a globe engine with every contract member the rig - * reads. The voice gate asks this instead of "is there a viewer". - * @param {object|null|undefined} target A globe engine. - * @returns {boolean} - */ -export function cameraVerbsSupported(target) { - if (!target || target.isDestroyed?.()) return false; - return ENGINE_RIG_MEMBERS.every( - (path) => - typeof path - .split('.') - .reduce( - (value, key) => (value == null ? value : value[key]), - target, - ) === 'function', - ); +/** Whether the engine can accept camera motions. */ +export function cameraVerbsSupported(engine) { + return Boolean(engine) && !engine.isDestroyed(); } /* ── Motion state ───────────────────────────────────────────────────────── */ @@ -1705,7 +1671,6 @@ export function getActiveCameraMotion() { mode: _active.mode, speed: _active.speed, motionId: _active.motionId || 0, - rig: _active.rig?.kind ?? null, }; if (_active.kind === 'route') { info.progress = _active.u; diff --git a/src/cameraVerbsEngine.test.mjs b/src/cameraVerbsEngine.test.mjs index 49f69f0e9..7c7f4a420 100644 --- a/src/cameraVerbsEngine.test.mjs +++ b/src/cameraVerbsEngine.test.mjs @@ -354,7 +354,6 @@ test('a new verb supersedes the running one and stop ends it', (t) => { moveCamera({ motion: 'pan', direction: 'left', mode: 'continuous' }); assert.notEqual(activeCameraMotionId(), orbitId); assert.equal(getActiveCameraMotion().kind, 'pan'); - assert.equal(getActiveCameraMotion().rig, 'engine'); assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); engine.fake.resetCalls(); run(0.3); @@ -506,7 +505,6 @@ test('fly_route flies the installed engine camera and lands level', (t) => { () => {}, ); assert.equal(result.ok, true); - assert.equal(getActiveCameraMotion().rig, 'engine'); run(3); const mid = getActiveCameraMotion(); assert.ok(mid.traveledM > 0, 'departed'); @@ -557,18 +555,6 @@ test('cameraVerbsSupported names the targets the verbs can drive', () => { const engine = createFakeEngine(); assert.equal(cameraVerbsSupported(engine), true); assert.equal(cameraVerbsSupported(null), false); - assert.equal( - cameraVerbsSupported({ - camera: {}, - scene: {}, - clock: { onTick: new Cesium.Event() }, - }), - false, - 'a Cesium viewer is driven through its engine', - ); - const partial = createFakeEngine(); - partial.camera = { ...partial.camera, following: undefined }; - assert.equal(cameraVerbsSupported(partial), false); engine.destroy(); assert.equal(cameraVerbsSupported(engine), false); }); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 032169eb6..d5b0f8a72 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -796,11 +796,11 @@ test('Cockpit hides the complete top-center globe action group', () => { test('Reset releases Contact camera ownership through its selection-preserving route', () => { const resetStart = ui.indexOf('resetToGlobeView()'); const contextRelease = ui.indexOf( - "militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' })", + "releaseTrackers(this.services, { origin: 'tool' })", resetStart, ); const satelliteRelease = ui.indexOf( - "satellitesLayer.stopTracking?.({ origin: 'tool' })", + 'this.engine.camera.following()?.stop()', resetStart, ); @@ -841,10 +841,9 @@ test('Location navigation releases immediate routes before flight and deferred r const voiceStart = voiceActions.indexOf('beginDeferredLocationNavigation'); assert.ok(releaseStart >= 0, 'shared Location camera handoff is missing'); - assert.match( - ui.slice(releaseStart, releaseStart + 1800), - /militaryAwarenessLayer\.releaseCameraOwnership\?\.\(\{[\s\S]*?origin: trackingOrigin[\s\S]*?\}\)[\s\S]*?satellitesLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)[\s\S]*?rocketLaunchesLayer\.releaseCameraOwnership\?\.\(\)/, - ); + assert.match(ui.slice(releaseStart, releaseStart + 1800), + /releaseTrackers\(this.tracking, \{[\s\S]*?origin: trackingOrigin/); + assert.match( ui.slice(locationFlight, locationFlight + 900), /this\._runExplicitNavigation\('location',[\s\S]*?flyAction\(/, diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 177416822..5748358fa 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -4,12 +4,7 @@ import { waitForSignal } from '../sources/requestDeadline.js'; import * as Cesium from 'cesium'; import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; import { isPointerFree } from './inputOwnership.js'; -import { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; + import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js'; import { governorRequestRender, @@ -107,13 +102,7 @@ const PASSIVE_ENABLE_ORIGINS = new Set([ 'dependency-restore', ]); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - forEngine: worldOverlayFor, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, -}); +const DEFAULT_OVERLAY_HOST = worldOverlayFor; const DEFAULT_RENDER_HOST = Object.freeze({ request: governorRequestRender, hold: holdContinuousRender, @@ -125,8 +114,6 @@ const DEFAULT_EVENT_LOADER = async () => { throw new Error(`Bhote Koshi event pack unavailable (${response.status})`); return response.json(); }; -const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) => - Cesium.SingleTileImageryProvider.fromUrl(url, options); const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => { if ( !viewer?.terrainProvider || @@ -1010,7 +997,6 @@ export function createBhoteKoshiEventLayer({ overlayHost = DEFAULT_OVERLAY_HOST, renderHost = DEFAULT_RENDER_HOST, eventLoader = DEFAULT_EVENT_LOADER, - imageryProviderFactory = DEFAULT_IMAGERY_PROVIDER_FACTORY, terrainSampler = DEFAULT_TERRAIN_SAMPLER, mediaLoader = DEFAULT_MEDIA_LOADER, imageLoader = DEFAULT_MEDIA_LOADER, @@ -1026,7 +1012,6 @@ export function createBhoteKoshiEventLayer({ let _engine = null; let _native = null; let _nativeFlood = null; - let _providers = null; // The before and after images, decoded once for every engine the event // is drawn on (as Cesium's providers were kept across switches). let _imageryImages = null; @@ -1046,9 +1031,6 @@ export function createBhoteKoshiEventLayer({ let _panelFixed = null; let _offPanelLocale = null; let _splitControl = null; - let _beforeLayer = null; - let _afterLayer = null; - let _imageryLayerCollection = null; let _floodDataSource = null; let _floodSurfaceMode = null; let _floodHalo = null; @@ -1189,53 +1171,25 @@ export function createBhoteKoshiEventLayer({ } async function createImagery(event, current = () => true) { - if (_native) { - // The engine's display draws the images through engine.imagery. - _imageryImages ??= Promise.all( - [event.imagery.before.path, event.imagery.after.path].map((path) => - imageLoader(eventAsset(path)), - ), - ); - let images; - try { - images = await _imageryImages; - } catch { - _imageryImages = null; - throw new Error('Bhote Koshi imagery unavailable'); - } - await _native.loadImages( - event.imagery.rectangle, - images, - 'Vantor WorldView Open Data · CC BY-NC 4.0', - ); - if (_enabled && current()) setSplit(_split); - return; - } - const rectangle = Cesium.Rectangle.fromDegrees( - event.imagery.rectangle.west, - event.imagery.rectangle.south, - event.imagery.rectangle.east, - event.imagery.rectangle.north, + // The engine's display draws the images through engine.imagery. + _imageryImages ??= Promise.all( + [event.imagery.before.path, event.imagery.after.path].map((path) => + imageLoader(eventAsset(path)), + ), ); - const [beforeProvider, afterProvider] = await (_providers ??= Promise.all([ - imageryProviderFactory(eventAsset(event.imagery.before.path), { - rectangle, - credit: 'Vantor WorldView Open Data · CC BY-NC 4.0', - }), - imageryProviderFactory(eventAsset(event.imagery.after.path), { - rectangle, - credit: 'Vantor WorldView Open Data · CC BY-NC 4.0', - }), - ])); - if (!_enabled || !current()) return; - _imageryLayerCollection = _viewer.imageryLayers; - _beforeLayer = _imageryLayerCollection.addImageryProvider(beforeProvider); - _afterLayer = _imageryLayerCollection.addImageryProvider(afterProvider); - _beforeLayer.splitDirection = Cesium.SplitDirection.LEFT; - _afterLayer.splitDirection = Cesium.SplitDirection.RIGHT; - _beforeLayer.alpha = 0.96; - _afterLayer.alpha = 0.96; - setSplit(_split); + let images; + try { + images = await _imageryImages; + } catch { + _imageryImages = null; + throw new Error('Bhote Koshi imagery unavailable'); + } + await _native.loadImages( + event.imagery.rectangle, + images, + 'Vantor WorldView Open Data · CC BY-NC 4.0', + ); + if (_enabled && current()) setSplit(_split); } async function resolveEvidenceAnchors(event, signal) { @@ -2654,9 +2608,8 @@ export function createBhoteKoshiEventLayer({ disableDepthTestDistance: tileSafe ? Number.POSITIVE_INFINITY : 0, }, }); - // The engine's story backend, or Cesium's data sources. + // The engine's story display. _nativeFlood = _native?.draw(_floodDataSource) ?? null; - if (!_nativeFlood) _viewer.dataSources.add(_floodDataSource); } function syncFloodSurfaceMode() { @@ -2669,7 +2622,6 @@ export function createBhoteKoshiEventLayer({ return; _nativeFlood?.destroy(); _nativeFlood = null; - _viewer?.dataSources.remove(_floodDataSource, true); _floodDataSource = null; _floodHalo = null; _floodCore = null; @@ -3355,8 +3307,6 @@ export function createBhoteKoshiEventLayer({ _panelRefs.progressInput.hidden = !scrubberAvailable; _splitControl?.setVisible(imageryAvailable); _native?.setVisible(imageryAvailable); - if (_beforeLayer) _beforeLayer.show = imageryAvailable; - if (_afterLayer) _afterLayer.show = imageryAvailable; if (_panelRefs?.caveat) { _panelRefs.caveat.textContent = imageryAvailable ? bhoteEventText(_event?.reconstruction?.caveat || '') @@ -3538,14 +3488,6 @@ export function createBhoteKoshiEventLayer({ focusView(_viewer, _event.viewpoints.corridor, _engine); } - function removeImagery() { - if (_beforeLayer) _imageryLayerCollection?.remove(_beforeLayer, true); - if (_afterLayer) _imageryLayerCollection?.remove(_afterLayer, true); - _beforeLayer = null; - _afterLayer = null; - _imageryLayerCollection = null; - } - // Each attachment and release advances this. An attachment that resumes // after a release, or after its scene signal aborted, stops right there; // the release that follows it owns whatever it had already created. @@ -3567,7 +3509,7 @@ export function createBhoteKoshiEventLayer({ _viewer = viewer; _engine = engine; // The engine's display (src/layers/bhoteKoshi/render/display.js). - if (_engine) _native = createNativeRenderer?.(engine) ?? null; + _native = createNativeRenderer(engine); _embeddedMedia?.attachScene?.(viewer, engine); if (!_enabled) return; // Its enable has loaded the SDK; a restored attachment waits for it too. @@ -3594,7 +3536,6 @@ export function createBhoteKoshiEventLayer({ if (!current()) return; if (_floodDataSource) { _nativeFlood = _native?.draw(_floodDataSource) ?? null; - if (!_nativeFlood) await _viewer.dataSources.add(_floodDataSource); if (!current()) return; } registerDynamicCredit(engine, BHOTE_KOSHI_CREDIT); @@ -3630,11 +3571,9 @@ export function createBhoteKoshiEventLayer({ _splitControl?.destroy(); _splitControl = null; _embeddedMedia?.attachScene?.(null, null); - removeImagery(); + _native?.clear(); _nativeFlood?.destroy(); _nativeFlood = null; - if (_floodDataSource && _viewer) - _viewer.dataSources.remove(_floodDataSource, false); _native?.destroy(); _native = null; _engine = _viewer = null; @@ -3789,7 +3728,7 @@ export function createBhoteKoshiEventLayer({ _witnessEmbedActive = false; overlayHost.clearSource(BHOTE_KOSHI_OVERLAY_SOURCE_ID); overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, false); - removeImagery(); + _native?.clear(); _terrainPositions = []; _sourceShotPaths.clear(); _scenePathPositionCache.clear(); @@ -3809,10 +3748,7 @@ export function createBhoteKoshiEventLayer({ _nativeFlood?.destroy(); _nativeFlood = null; _native?.clear(); - _providers = null; _imageryImages = null; - if (_floodDataSource && _viewer) - _viewer.dataSources.remove(_floodDataSource, true); _floodDataSource = null; _floodSurfaceMode = null; _floodHalo = null; diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 9d22ff9ae..19e6aff9b 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -14,7 +14,7 @@ import { buildCinematicKeyframes, buildEvidenceTimeline, clampUnit, - createBhoteKoshiEventLayer, + createBhoteKoshiEventLayer as buildEventLayer, evidenceCardLayout, createEvidenceFrame, elapsedLabel, @@ -231,6 +231,42 @@ function installEventDom() { return { body, removedProperties }; } +function createBhoteKoshiEventLayer(options) { + let viewer; + const layer = buildEventLayer({ + ...options, + createNativeRenderer() { + let images = []; + const clear = () => { + for (const image of images) viewer._test?.removedImagery.push(image); + images = []; + }; + return { + async loadImages(_box, providers) { + clear(); + images = providers.map((provider, index) => ({ provider, splitDirection: index ? 'right' : 'left' })); + viewer._test?.imagery.push(...images); + }, + draw(source) { + viewer._test?.dataSources.push(source); + let removed = false; + return { destroy() { + if (!removed) viewer._test?.removedDataSources.push(source); + removed = true; + } }; + }, + setVisible(show) { for (const image of images) image.show = show; }, + setSplit() {}, clear, destroy: clear, + }; + }, + }); + for (const name of ['init', 'attachRenderer', 'enable']) { + const method = layer[name]; + layer[name] = (next, options) => { viewer = next; if (name === 'enable') layer.attachRenderer(next); return method.call(layer, next, options); }; + } + return layer; +} + function eventViewer(splitPosition = 0.37) { const imagery = []; const removedImagery = []; @@ -293,7 +329,7 @@ test('public lifecycle restores Nepal scene ownership without forwarding origin const mapCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), mediaLoader: async () => { const error = new Error('No bundled clip'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -355,7 +391,7 @@ test('trimmed scene media holds through provider startup, playback and fade, the const event = JSON.parse(await readFile(eventUrl, 'utf8')); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), mediaLoader: async () => { const error = new Error('No bundled clip'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -403,7 +439,7 @@ test('Pinokio source-card fallback selects authored Director dwell, not an impos const event = JSON.parse(await readFile(eventUrl, 'utf8')); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async url => ({ url }), + imageLoader: async url => ({ url }), terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -448,7 +484,7 @@ test('saved autoplay controls cannot authorize media; live shot ownership is can const event = JSON.parse(await readFile(eventUrl, 'utf8')); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async url => ({ url }), + imageLoader: async url => ({ url }), terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -843,7 +879,7 @@ test('Bhote Koshi failed direct enable restores the prior map and shared split', }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async () => { throw new Error('fixture image failure'); }, + imageLoader: async () => { throw new Error('fixture image failure'); }, overlayHost: { setEntries() {}, setVisible(id, visible) { overlayCalls.push(['visible', id, visible]); }, @@ -866,7 +902,7 @@ test('Bhote Koshi failed direct enable restores the prior map and shared split', try { await assert.rejects( layer.enable(viewer, { origin: 'scene' }), - /fixture image failure/, + /Bhote Koshi imagery unavailable/, ); assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); assert.equal(viewer.scene.splitPosition, 0.37); @@ -946,7 +982,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever const renderCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), overlayHost: { setEntries() {}, setVisible(id, visible) { overlayCalls.push(['visible', id, visible]); }, @@ -1013,7 +1049,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, @@ -1071,7 +1107,7 @@ test('Bhote Koshi passive restores do not start off-screen playback', async () = const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), embeddedMediaFactory: () => ({ show() {}, hide() {}, pause() {}, destroy() {} }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, @@ -1120,7 +1156,7 @@ test('Bhote Koshi standalone Scene keeps its Esri cinematic contract', async () const mapCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -1195,7 +1231,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership const overlayVisibility = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -1506,7 +1542,7 @@ test('Nepal hybrid comparison preserves native split and terrain-clamped flood t const comparisonCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -1540,8 +1576,8 @@ test('Nepal hybrid comparison preserves native split and terrain-clamped flood t assert.equal(viewer._test.imagery.length, 2); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true)); assert.equal(viewer.scene.splitPosition, 0.5); - assert.equal(viewer._test.imagery[0].splitDirection, Cesium.SplitDirection.LEFT); - assert.equal(viewer._test.imagery[1].splitDirection, Cesium.SplitDirection.RIGHT); + assert.equal(viewer._test.imagery[0].splitDirection, 'left'); + assert.equal(viewer._test.imagery[1].splitDirection, 'right'); layer.setParams({ split: 0.25 }); assert.equal(viewer.scene.splitPosition, 0.25); const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); @@ -1650,7 +1686,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and const publications = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -1747,7 +1783,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -1848,7 +1884,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -1924,7 +1960,7 @@ test('Debris-Dammed Lake card opens its source and is armed for approved local v let activeCard = null; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -2015,7 +2051,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from const releases = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -2186,7 +2222,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one const publications = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -2306,7 +2342,7 @@ test('Bhote Koshi repeated event frames republish no panel and churn no camera', const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -2389,7 +2425,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -2470,7 +2506,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), @@ -2552,7 +2588,7 @@ test('Bhote Koshi closes a poster that resolves after disable', async () => { const poster = { closed: false, close() { this.closed = true; } }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async () => [{ height: 2800 }], mediaLoader: async (url) => { assert.equal(url, '/events/bhote-koshi-2026/evidence-09.jpg'); @@ -2598,7 +2634,7 @@ test('every sourced shot grows its tail with its head, keeps its own card, and c let entries = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async () => null, mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, overlayHost: { setEntries(_id, value) { entries = value; }, setVisible() {}, clearSource() {} }, @@ -2700,7 +2736,7 @@ test('media-led upper-valley shots retain only their upstream prefix during came const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async url => ({ url }), + imageLoader: async url => ({ url }), terrainSampler: async () => null, mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -2798,7 +2834,7 @@ test('camera-led Nepal flood motion preserves its prefix and stays ahead of shot const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), terrainSampler: async () => null, mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -3004,7 +3040,7 @@ for (const stop of ['detach', 'scene-abort']) { const second = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), embeddedMediaFactory: () => ({ show() {}, hide() {}, pause() {}, destroy() {} }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, @@ -3062,7 +3098,7 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea const second = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, - imageryProviderFactory: async (url) => ({ url }), + imageLoader: async (url) => ({ url }), embeddedMediaFactory: () => ({ show() {}, hide() {}, pause() {}, destroy() {} }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index ed6be558b..5f6562473 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -2,10 +2,7 @@ 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 { - clearOverlaySource, - setOverlayEntries, -} from '../overlays/worldOverlay.js'; + import { governorRequestRender } from '../renderGovernor.js'; import { BHOTE_KOSHI_LOCATOR_CREDIT, @@ -152,11 +149,7 @@ const BORDER_ACCENT = '#20e7f2'; const INCIDENT_ACCENT = '#ffad55'; const INCIDENT_COLOR = () => Cesium.Color.fromCssColorString(INCIDENT_ACCENT); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - forEngine: worldOverlayFor, - setEntries: setOverlayEntries, - clearSource: clearOverlaySource, -}); +const DEFAULT_OVERLAY_HOST = worldOverlayFor; // Applications supply their boundary resolver; the layer has no server-route dependency. const DEFAULT_BOUNDARY_RESOLVER = async () => null; @@ -362,8 +355,6 @@ export function createBhoteKoshiLocatorLayer({ overlayHost.clearSource(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); _native?.destroy(); _native = null; - if (_dataSource && _viewer?.dataSources) - _viewer.dataSources.remove(_dataSource, true); _dataSource = null; } @@ -1047,9 +1038,7 @@ export function createBhoteKoshiLocatorLayer({ function connectDisplay() { if (!_dataSource) return; - if (_engine && createNativeRenderer) - _native = createNativeRenderer(_engine, _dataSource); - else if (_viewer) _viewer.dataSources.add(_dataSource); + _native = createNativeRenderer(_engine, _dataSource); registerDynamicCredit(_engine, BHOTE_KOSHI_LOCATOR_CREDIT); showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); } @@ -1080,7 +1069,6 @@ export function createBhoteKoshiLocatorLayer({ } _native?.destroy(); _native = null; - if (_viewer && _dataSource) _viewer.dataSources.remove(_dataSource, false); hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); _engine = _viewer = null; } diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 605f8eaab..f314b5b2c 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -21,12 +21,27 @@ import { BHOTE_KOSHI_REGIONAL_PRESENTATION, BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, closeRegionRing, - createBhoteKoshiLocatorLayer, + createBhoteKoshiLocatorLayer as buildLocatorLayer, largestGeoJsonRing, NEPAL_CONTEXT_PRESENTATION, revealPolylinePositions, } from './bhoteKoshiLocator.js'; +function createBhoteKoshiLocatorLayer(options) { + let viewer; + const layer = buildLocatorLayer({ ...options, + createNativeRenderer(_engine, source) { + viewer._test.added.push(source); + return { destroy() { viewer._test.removed.push(source); } }; + }, + }); + for (const name of ['init', 'attachRenderer', 'enable']) { + const method = layer[name]; + layer[name] = (next, options) => { viewer = next; return method.call(layer, next, options); }; + } + return layer; +} + function viewerFixture() { const added = []; const removed = []; diff --git a/src/data/bindContextStore.js b/src/data/bindContextStore.js deleted file mode 100644 index a85914d3f..000000000 --- a/src/data/bindContextStore.js +++ /dev/null @@ -1,12 +0,0 @@ -/** Resolve a retained layer's context calls against its current attachment. */ -export function bindContextStore(context, getEngine) { - if (!context?.forEngine) return context; - return Object.fromEntries( - Object.entries(context) - .filter(([, value]) => typeof value === 'function') - .map(([name]) => [ - name, - (...args) => context.forEngine(getEngine())?.[name]?.(...args), - ]), - ); -} diff --git a/src/data/boundaryLabels.test.mjs b/src/data/boundaryLabels.test.mjs index 852b249c4..98c987387 100644 --- a/src/data/boundaryLabels.test.mjs +++ b/src/data/boundaryLabels.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../testSupport/domClasses.mjs'; // Outline names are drawn by the DOM annotation overlay, never as Cesium // text labels: the boundary data modules, the outline resolver and the // annotation renderers must not import or construct Cesium label primitives, @@ -95,16 +96,7 @@ test('the static check catches a Cesium label however it is written', () => { test('drawing bundled place and neighborhood outlines creates no Cesium label', async (t) => { // Cesium's material setup checks for these browser image types. - for (const name of [ - 'HTMLCanvasElement', - 'HTMLImageElement', - 'ImageBitmap', - 'OffscreenCanvas', - ]) { - if (name in globalThis) continue; - globalThis[name] = class {}; - t.after(() => delete globalThis[name]); - } + installDomClasses(t); const featureSource = {}; for (const method of FEATURE_SOURCE_METHODS) featureSource[method] = async () => null; diff --git a/src/data/contextStore.js b/src/data/contextStore.js index 029c4086d..4aae9c03e 100644 --- a/src/data/contextStore.js +++ b/src/data/contextStore.js @@ -387,6 +387,7 @@ export function forEngine(engine) { export function isContextRecordActive(record, dataManager = null) { if (!record) return false; if (record.entity?.show === false) return false; + if (record.isVisible?.() === false) return false; if (record.dataSource && record.dataSource.show === false) return false; if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false; diff --git a/src/data/detection.js b/src/data/detection.js index a6c673fd9..5d4855bf8 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../overlays/worldOverlay.js'; import { paintTransitBracket } from './detectionDraw.js'; import * as geo from '../geo/index.js'; import { governorRequestRender } from '../renderGovernor.js'; @@ -21,7 +22,7 @@ import { getKeyholeFadeTuning, keyholeLabelAlphaFromGeometry, } from '../celestialRing.js'; -import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js'; + import { DETECTION_STYLE, DETECTION_THEME_MAP, @@ -315,8 +316,7 @@ export function initDetection(engine, layers, onModeChange) { ); _hostLayoutRevision = -1; _calloutCount = 0; - _hostLane = registerWorldOverlayPaintLane( - engine, + _hostLane = worldOverlayFor(engine)?.registerPaintLane( 'detection', _paintDetectionLane, { @@ -330,8 +330,7 @@ export function initDetection(engine, layers, onModeChange) { // 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( - engine, + _calloutLane = worldOverlayFor(engine)?.registerPaintLane( 'detection', _paintCalloutLane, { diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index a2bf9e841..853cf92cb 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../overlays/worldOverlay.js'; let overlayEngine; import { readShellSource } from '../testSupport/readShellSource.mjs'; import test from 'node:test'; @@ -20,11 +21,7 @@ import { setMode, suspendDetection, } from './detection.js'; -import { - destroyWorldOverlay, - initWorldOverlay, - setOverlayEntries, -} from '../overlays/worldOverlay.js'; + import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { clearAwarenessEchoField, @@ -249,6 +246,7 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } }; // Shell modules take the globe engine; the Cesium engine runs on the mock. + viewer.clock = { onTick: new Cesium.Event() }; const engine = createCesiumEngine(viewer); overlayEngine = engine; return { @@ -263,7 +261,7 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } setPerformanceStep(ms) { performanceStep = ms; }, cleanup() { destroyDetection(); - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); engine.destroy(); Date.now = originalDateNow; if (originalPath2D === undefined) delete globalThis.Path2D; @@ -359,7 +357,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste const env = installEnvironment({ search: '?detectDebug=1' }); const modes = []; try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [detectableLayer()], (mode) => modes.push(mode)); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.document.getElementById('detection-overlay'), null); @@ -489,7 +487,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(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); 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); @@ -513,7 +511,7 @@ test('callouts stop painting the moment the last detectable object goes away', ( type: 'AIR', }]; try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [{ id: 'flights', getDetectableObjects: () => objects }], () => {}); setMode('DENSE'); env.advance(250); @@ -549,7 +547,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [mixedTierLayer()], () => {}); setMode('DENSE'); settleFrame(env); @@ -587,7 +585,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [mixedTierLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); @@ -615,7 +613,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [militaryLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); @@ -655,7 +653,7 @@ test('Global Context echo brackets reach the canvas in the detection style colou pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, echo, }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [militaryLayer()], () => {}); setMode('DENSE'); setDetectionStyle('normal'); @@ -701,7 +699,7 @@ test('a Global Context echo bracket is drawn outside its contact bracket and dim echo, level: () => 0.5, }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [militaryLayer()], () => {}); setMode('DENSE'); setDetectionStyle('normal'); @@ -773,7 +771,7 @@ test('under the Cyber sonar sweep an echo is never brighter than its own bracket echo, level: () => 1, }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [contact], () => {}); setMode('DENSE'); setDetectionStyle('normal'); @@ -835,7 +833,7 @@ test('resting Global Context pulses skip echo work for the whole frame, not per return 0; }, }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [layer], () => {}); setMode('DENSE'); settleFrame(env); @@ -862,9 +860,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [detectableLayer()], () => {}); - setOverlayEntries(overlayEngine, 'valve-card', [{ + worldOverlayFor(overlayEngine)?.setEntries('valve-card', [{ id: 'ambient-card', position: new Cesium.Cartesian3(0, 0, 0), variant: 'label', @@ -925,7 +923,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\(\s*engine,\s*'detection',\s*_paintDetectionLane/); + assert.match(source, /worldOverlayFor\(engine\)\?\.registerPaintLane\(\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. @@ -969,7 +967,7 @@ test('the mode banner is absent by default and present behind the flag', () => { const painted = (search) => { const env = installEnvironment({ search }); try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); @@ -982,7 +980,7 @@ test('the mode banner is absent by default and present behind the flag', () => { return bannerPaints(env); } finally { destroyDetection(); - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); env.cleanup(); } }; @@ -996,7 +994,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); @@ -1008,7 +1006,7 @@ test('detection telemetry stays reachable programmatically with the banner hidde assert.equal(typeof diagnostics.densityPct, 'number'); } finally { destroyDetection(); - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); env.cleanup(); } }); @@ -1024,7 +1022,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse metric: 'FL120', type: 'AIR', })); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [{ id: layerId, getDetectableObjects: () => objects }], () => {}); setDetectionTuning({ densityPct: 25 }); setMode('SPARSE'); @@ -1042,7 +1040,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(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); env.cleanup(); } } @@ -1082,7 +1080,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection(env.engine, [backdropLayer()], () => {}); setDetectionStyle('normal'); setMode('DENSE'); @@ -1140,7 +1138,7 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share }, ]; try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); initDetection( env.engine, [{ id: 'transit', getDetectableObjects: () => objects }], diff --git a/src/data/directions.js b/src/data/directions.js index 805aee483..5d4ab38c4 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -15,7 +15,7 @@ import * as credits from './dataCredits.js'; import * as render from '../renderGovernor.js'; import * as picking from './pickRegistry.js'; import * as scenePick from './scenePick.js'; -import * as overlays from '../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../overlays/worldOverlay.js'; import * as camera from '../cameraVerbs.js'; import * as ground from './groundFloor.js'; import * as input from './inputOwnership.js'; diff --git a/src/data/geoImportDisplay.js b/src/data/geoImportDisplay.js index 41aeb5fc5..e8823d633 100644 --- a/src/data/geoImportDisplay.js +++ b/src/data/geoImportDisplay.js @@ -103,8 +103,6 @@ export async function drawImportedFile( pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)), } = {}, ) { - if (!engine?.ground || !engine?.movers || !engine?.lines) - throw new TypeError('The imported file display needs a globe engine'); const prefix = `geo-import-${++serial}`; const request = () => engine.render.request('geo-import'); let shown = visible; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 8fc5531af..3c8eb9d2c 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -3,24 +3,10 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import militaryAwarenessLayer, { - _getAwarenessNavigationStateForTest, - _getAwarenessPanelRendersForTest, - awarenessClearMatchesSubject, - awarenessNeedsContinuousRender, - awarenessRefreshDecision, - awarenessRefreshIntervalMs, - awarenessRefreshRequired, - awarenessResultsAreLive, - buildAwarenessContextSnapshot, - canNavigateAwarenessNext, - contactsWindowFromSnapshot, - contextTargetFlyToAllowed, - summarizeInstallationViewport, - findCompatibleHistoryIndex, - AWARENESS_QUERY_LIMIT, - historySubjectSnapshot, -} from './militaryAwareness.js'; +import { createAwarenessLayer, AWARENESS_QUERY_LIMIT } from '../layers/awareness/index.js'; +import * as navigation from '../navigationPolicy.js'; +import * as geometry from '../celestialRing.js'; +import * as render from '../renderGovernor.js'; import flightsLayer, { _setTrackedFlightRefreshStateForTest, } from './flights.js'; @@ -46,14 +32,8 @@ import { } from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; -import { registerLayerRenderers } from '../app/layers/renderers.js'; -// The layer's view of a stub viewer (render/display.js's interface), drawn -// by nothing: these tests drive the layer's logic, not the ring. It reaches -// the layer through the renderer registry, under an engine kind of its own. -const VIEWER_ENGINE = 'awareness-viewer-test'; -registerLayerRenderers(VIEWER_ENGINE, { - 'military-awareness': (engine) => { +const createTestDisplay = (engine) => { const { viewer } = engine; return { engine, @@ -73,12 +53,32 @@ registerLayerRenderers(VIEWER_ENGINE, { createRim: () => ({ move() {}, setAlpha() {}, destroy() {} }), createPulses: () => null, }; - }, -}); +}; +const militaryAwarenessLayer = createAwarenessLayer({ services: { + flights: flightsLayer, military: militaryFlightsLayer, vessels: aisLiveVesselsLayer, + installations: militaryInstallationsLayer, navigation, geometry, render, renderer: createTestDisplay, +} }); +const { + _getAwarenessNavigationStateForTest, + _getAwarenessPanelRendersForTest, + awarenessClearMatchesSubject, + awarenessNeedsContinuousRender, + awarenessRefreshDecision, + awarenessRefreshIntervalMs, + awarenessRefreshRequired, + awarenessResultsAreLive, + buildAwarenessContextSnapshot, + canNavigateAwarenessNext, + contactsWindowFromSnapshot, + contextTargetFlyToAllowed, + summarizeInstallationViewport, + findCompatibleHistoryIndex, + historySubjectSnapshot, +} = militaryAwarenessLayer; const viewerEngines = new WeakMap(); const viewerEngine = (viewer) => { if (!viewerEngines.has(viewer)) viewerEngines.set(viewer, { - kind: VIEWER_ENGINE, viewer, + kind: 'cesium', viewer, render: { request: () => viewer.scene.requestRender?.(), setContinuous: (enabled) => { viewer.scene.requestRenderMode = !enabled; }, diff --git a/src/data/militaryNames.test.mjs b/src/data/militaryNames.test.mjs index 7e52b2c60..f5f069586 100644 --- a/src/data/militaryNames.test.mjs +++ b/src/data/militaryNames.test.mjs @@ -38,14 +38,14 @@ test('wide-view counts report visible points rather than retained off-screen can services: {}, source: {}, parts: { - namedMarkers: { + display: { namedMarkers: { stats: () => ({ namedMarkers: 8, pointsOnScreen: 3, labelsOnScreen: 2, labelCap: 24, }), - }, + } }, }, }); const stats = methods.getStats(); diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js index 10fb9811f..9d245bf93 100644 --- a/src/data/osmOverlays.js +++ b/src/data/osmOverlays.js @@ -1,4 +1,4 @@ -import { NO_IMAGERY_HOST } from '../maps/imageryHost.js'; +import { NO_IMAGERY_HOST } from '../layers/shared/imageryHost.js'; /** * @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index 3ae3d705b..68b6a50c1 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -6,7 +6,7 @@ import { createOsmOverlayLayers, osmOverlayHiddenReason, } from './osmOverlays.js'; -import { NO_IMAGERY_HOST } from '../maps/imageryHost.js'; +import { NO_IMAGERY_HOST } from '../layers/shared/imageryHost.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; /** A layer on the fake engine, whose imagery drapes where `surface()` says. */ diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 43d4bcafc..53579932f 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -1637,16 +1637,17 @@ test('Radio recovery preserves closer altitude and caps extreme full-globe zoom' }); test('Radio camera navigation yields to every live tracked-entity owner', () => { - const viewer = { camera: {} }; - assert.equal(radioCameraNavigationAllowed(viewer), true); + let following = null; + const engine = { camera: { following: () => following } }; + assert.equal(radioCameraNavigationAllowed(engine), true); for (const gevTrackedId of ['flights:abc123', 'military:def456']) { const trackedEntity = { gevTrackedId }; - viewer.trackedEntity = trackedEntity; - assert.equal(radioCameraNavigationAllowed(viewer), false, gevTrackedId); - assert.equal(viewer.trackedEntity, trackedEntity); + following = trackedEntity; + assert.equal(radioCameraNavigationAllowed(engine), false, gevTrackedId); + assert.equal(engine.camera.following(), trackedEntity); } - viewer.trackedEntity = undefined; - assert.equal(radioCameraNavigationAllowed(viewer), true); + following = null; + assert.equal(radioCameraNavigationAllowed(engine), true); assert.equal(radioCameraNavigationAllowed({ trackedEntity: {} }), false); assert.equal(radioCameraNavigationAllowed(null), false); }); diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 30905ba7b..243bf857d 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../testSupport/domClasses.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as C from 'cesium'; @@ -18,11 +19,7 @@ const noop = () => {}; const overlayHost = { setEntries: noop, setVisible: noop, clearSource: noop }; function viewer(t) { t.mock.timers.enable({ apis: ['setTimeout'] }); - for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) { - const original = globalThis[name]; - globalThis[name] = class {}; - t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; }); - } + installDomClasses(t); const before = globalThis.document; globalThis.document = { addEventListener: noop, removeEventListener: noop }; t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; }); @@ -60,7 +57,7 @@ for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce th test('Nepal locator introduces the shared OSM credit', async (t) => { const v = viewer(t); - const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop }); + const layer = createBhoteKoshiLocatorLayer({ createNativeRenderer: () => ({ destroy: noop }), boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop }); await layer.init(v, { engine: cesiumCredits(v) }); await layer.enable(v, { origin: 'scene' }); assertCredit(v); await layer.destroy(v); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); @@ -78,7 +75,7 @@ test('Directions introduces the shared OSM credit when the requested route draws const engine = createFakeEngine(); const layer = createDirectionsLayer({ source: createDirectionsSource(), services: { credits: { showOsmCredit, hideOsmCredit }, input, scenePick: {}, camera: {}, - overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, + overlays: { setEntries: noop, setVisible: noop, clearSource: noop }, render: { governorRequestRender: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, ground: { warmGroundFloor: noop }, diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 4a31dabd5..7521043b1 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -431,7 +431,7 @@ test('cables create no native labels, no per-frame callbacks and no engine impor ); assert.match( source, - /setOverlayEntries/, + /setEntries/, 'labels must flow through the shared world-overlay host', ); }); diff --git a/src/data/trackedReadoutScenes.test.mjs b/src/data/trackedReadoutScenes.test.mjs index a1f633b5b..4f3e323da 100644 --- a/src/data/trackedReadoutScenes.test.mjs +++ b/src/data/trackedReadoutScenes.test.mjs @@ -648,7 +648,7 @@ for (const makeHarness of GROUND_HARNESSES) { overlays: { refreshReadout: refreshTrackedReadout }, }, }); - display.init(null, { onMove() {} }); + display.init({ onMove() {} }); display.setVisible(true); display.renderRecords(); return display; @@ -739,7 +739,7 @@ for (const makeHarness of GROUND_HARNESSES) { h.destroy(); globalThis.window = original; }); - display.init(null, { onMove() {} }); + display.init({ onMove() {} }); display.setVisible(true); display.renderRecords(); display.selectRecord('alpr:0'); diff --git a/src/data/transit.js b/src/data/transit.js index af0c26143..b767cd6ea 100644 --- a/src/data/transit.js +++ b/src/data/transit.js @@ -2,7 +2,7 @@ import { createTransitLayer as createLayer } from '../layers/transit/index.js'; import { createTransitDisplay } from '../layers/transit/render/display.js'; import * as render from '../renderGovernor.js'; import * as picking from './pickRegistry.js'; -import * as overlays from '../overlays/worldOverlay.js'; +import { worldOverlayFor as overlays } from '../overlays/worldOverlay.js'; import { registerDynamicCredit, transitFeedCredit } from './dataCredits.js'; import { GROUND_FLOOR_LIFT_M, diff --git a/src/globe/cesium/annotations/screenAnnotationRenderer.js b/src/globe/cesium/annotations/screenAnnotationRenderer.js index 80fe3caef..3fc5472c8 100644 --- a/src/globe/cesium/annotations/screenAnnotationRenderer.js +++ b/src/globe/cesium/annotations/screenAnnotationRenderer.js @@ -1,6 +1,7 @@ +import { worldOverlayFor } from '../../../overlays/worldOverlay.js'; import * as Cesium from 'cesium'; import * as geo from '../../../geo/index.js'; -import { getOverlayPaintRect } from '../../../overlays/worldOverlay.js'; + import { getActiveTrackedReadoutId, TRACKED_OVERLAY_SOURCE_ID, @@ -72,7 +73,8 @@ export function createScreenAnnotationRenderer( target, { engine, - overlayPaintRect = (...args) => getOverlayPaintRect(engine, ...args), + overlayPaintRect = (...args) => + worldOverlayFor(engine)?.getPaintRect(...args), activeTrackedReadoutId = () => getActiveTrackedReadoutId(engine), store = useScreenAnnotationsStore(applicationPinia()), } = {}, diff --git a/src/globe/cesium/clock.js b/src/globe/cesium/clock.js index 8758af166..7819da117 100644 --- a/src/globe/cesium/clock.js +++ b/src/globe/cesium/clock.js @@ -10,9 +10,7 @@ // The latch is attached when the engine is created (the app creates it with // the scene), so it runs before any onTick listener added later; one added // before the engine existed would read the previous frame's time. destroy -// removes it. A viewer without a clock (tests, a bare scene) latches on the -// scene's preUpdate instead, attached on first use so creating the engine -// adds no scene listener. +// removes it. import { appClock as pageClock } from '../../time/clock.js'; @@ -23,32 +21,18 @@ import { appClock as pageClock } from '../../time/clock.js'; * @returns {{ clock: import('../contract.js').GlobeClock, destroy: () => void }} */ export function createCesiumClock(viewer, { appClock = pageClock } = {}) { - // A viewer-shaped object without a clock (tests, a bare scene): the - // scene's preUpdate opens the frame instead. - const tickSource = viewer.clock?.onTick ?? viewer.scene.preUpdate; - let removeTick = null; - let destroyed = false; - const onTick = () => { - appClock.beginFrame(); - }; - const attach = () => { - if (!removeTick && !destroyed) - removeTick = tickSource.addEventListener(onTick); - }; - if (viewer.clock?.onTick) attach(); + const removeTick = viewer.clock.onTick.addEventListener(() => + appClock.beginFrame(), + ); return { clock: { now: () => appClock.now(), frameTime() { - attach(); return appClock.frameTime(); }, }, destroy() { - destroyed = true; - const remove = removeTick; - removeTick = null; - remove?.(); + removeTick(); }, }; } diff --git a/src/globe/cesium/clock.test.mjs b/src/globe/cesium/clock.test.mjs index dd8531b5f..684c584fb 100644 --- a/src/globe/cesium/clock.test.mjs +++ b/src/globe/cesium/clock.test.mjs @@ -91,24 +91,6 @@ test('frameTime reads the latch; destroy removes it', () => { assert.equal(viewer.clock.onTick.numberOfListeners, 0); }); -test('a viewer without a clock latches on preUpdate', () => { - const viewer = createFakeViewer(); - delete viewer.clock; - const wall = manualAppClock(); - const { clock, destroy } = createCesiumClock(viewer, { - appClock: wall.appClock, - }); - // Attached on first use: creating the clock adds no scene listener. - assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); - const before = clock.frameTime(); - assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); - wall.advance(7); - viewer.scene.preUpdate.raiseEvent(viewer.scene); - wall.advance(2); - assert.equal(clock.frameTime(), before + 7); - destroy(); - assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); -}); test('the viewer clock itself is left alone', () => { const viewer = createFakeViewer(); diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 1cc3c4fcd..8034b4b69 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../../testSupport/domClasses.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -118,6 +119,7 @@ test('the engine reads the Viewer aliases and builds on a minimal viewer', () => projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), }, }, + clock: { onTick: new Cesium.Event() }, scene: { postRender, requestRender: () => renders++ }, }; const engine = createCesiumEngine(viewer); @@ -247,14 +249,7 @@ test('movers and lines draw on the scene, answer picks with their pickId and go test('the contract suite movers and lines checks pass on the Cesium engine (no pixels or pointer)', async (t) => { // Cesium's Material sorts uniform values with instanceof checks against // these DOM classes; Node has none of them. - for (const name of [ - 'HTMLCanvasElement', - 'HTMLImageElement', - 'HTMLVideoElement', - 'ImageBitmap', - 'OffscreenCanvas', - ]) - globalThis[name] ??= class {}; + installDomClasses(); const handlers = createFakeEventHandlers(); const { scene, engine, frame } = createCameraViewer({ engineOptions: { diff --git a/src/globe/cesium/credits.js b/src/globe/cesium/credits.js index 651568676..9c25428a0 100644 --- a/src/globe/cesium/credits.js +++ b/src/globe/cesium/credits.js @@ -9,17 +9,15 @@ import * as Cesium from 'cesium'; export function createCesiumCredits(viewer) { const added = new Set(); // Viewer.creditDisplay is the scene frame state's display. - const display = () => - viewer.creditDisplay ?? viewer.scene?.frameState?.creditDisplay; const remove = (credit) => { if (!added.delete(credit)) return; - display()?.removeStaticCredit(credit); + viewer.creditDisplay.removeStaticCredit(credit); }; return { credits: { addStatic(html, { onScreen = false } = {}) { const credit = new Cesium.Credit(html, Boolean(onScreen)); - display().addStaticCredit(credit); + viewer.creditDisplay.addStaticCredit(credit); added.add(credit); return () => remove(credit); }, diff --git a/src/globe/cesium/credits.test.mjs b/src/globe/cesium/credits.test.mjs index f67aaa5d4..0a2ddab94 100644 --- a/src/globe/cesium/credits.test.mjs +++ b/src/globe/cesium/credits.test.mjs @@ -32,14 +32,14 @@ test('destroy removes the credits the engine added', () => { assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 2); }); -test('the display is Viewer.creditDisplay, else the scene frame state one it aliases', () => { +test('the display is Viewer.creditDisplay', () => { const added = []; const display = { addStaticCredit: (credit) => added.push(credit), removeStaticCredit: (credit) => added.splice(added.indexOf(credit), 1), }; const { credits } = createCesiumCredits({ - scene: { frameState: { creditDisplay: display } }, + creditDisplay: display, }); const remove = credits.addStatic('frame state'); assert.equal(added[0].html, 'frame state'); diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index 53a5a058c..6c705dab6 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -129,7 +129,7 @@ export function applyTrackedCameraFrame( // front/back oscillation). The controller then enforces the minimum range // during the same update pass. remove = viewer.scene.preUpdate.addEventListener(() => { - if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + if (viewer.isDestroyed() || viewer.trackedEntity !== entity) { stop(); return; } @@ -356,7 +356,7 @@ export function createCesiumFollow(viewer, { commanded }) { viewer.trackedEntity = undefined; release(state); }); - viewer.scene.requestRender?.(); + viewer.scene.requestRender(); } function follow(input) { diff --git a/src/globe/cesium/followOffset.js b/src/globe/cesium/followOffset.js index f95e31b74..6ff126339 100644 --- a/src/globe/cesium/followOffset.js +++ b/src/globe/cesium/followOffset.js @@ -11,7 +11,6 @@ const MAX_FRAMES = 120; * through the entity and puts it back. Returns a disposer. */ export function swingFollowOffset(viewer, entity, local, position) { - if (!viewer?.scene?.preUpdate || !entity || !local) return () => {}; let frames = 0; let remove = null; const stop = () => { @@ -19,7 +18,7 @@ export function swingFollowOffset(viewer, entity, local, position) { remove = null; }; remove = viewer.scene.preUpdate.addEventListener(() => { - if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + if (viewer.isDestroyed() || viewer.trackedEntity !== entity) { stop(); return; } @@ -62,9 +61,9 @@ export function swingFollowOffset(viewer, entity, local, position) { ? goal : Cesium.Cartesian3.clone(turnFollowOffset(from, goal)); camera.lookAtTransform(camera.transform, next); - viewer.scene.requestRender?.(); + viewer.scene.requestRender(); if (done) stop(); }); - viewer.scene.requestRender?.(); + viewer.scene.requestRender(); return stop; } diff --git a/src/globe/cesium/ground.test.mjs b/src/globe/cesium/ground.test.mjs index f91e81638..309962aa7 100644 --- a/src/globe/cesium/ground.test.mjs +++ b/src/globe/cesium/ground.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../../testSupport/domClasses.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -7,14 +8,7 @@ import { createFakeViewer } from './testing/fakeViewer.mjs'; // Cesium's Material types its uniforms against the browser's image and // canvas classes; Node has none of them. -for (const name of [ - 'HTMLCanvasElement', - 'HTMLImageElement', - 'HTMLVideoElement', - 'ImageBitmap', - 'OffscreenCanvas', -]) - globalThis[name] ??= class {}; +installDomClasses(); const items = (kind, list) => list.map((item, index) => normalizeGroundItem(kind, item, index)); diff --git a/src/globe/cesium/imagery.js b/src/globe/cesium/imagery.js index 4d295f33f..02a6bee48 100644 --- a/src/globe/cesium/imagery.js +++ b/src/globe/cesium/imagery.js @@ -1,7 +1,7 @@ // engine.imagery on Cesium: one ImageryLayer per layer, on the imagery // stack of the surface the map shows — the globe's imagery layers, or the // shown 3D tileset's own (Cesium drapes imagery on tiles there), as -// src/maps/imageryHost.js resolves it for the shell. Layers sit above the +// The engine resolves the active surface. Layers sit above the // base imagery, in their order among the engine's layers (higher on top, // then creation); imagery added by others stays where it was put. A tiles // source is a UrlTemplateImageryProvider in Web Mercator or geographic @@ -187,7 +187,7 @@ export function createCesiumImagery( const now = () => globalThis.performance?.now() ?? Date.now(); function host() { - if (scene.globe?.show === true && viewer.imageryLayers) + if (scene.globe.show === true) return { collection: viewer.imageryLayers, surface: 'globe' }; const tileset = tilesets().find( (candidate) => @@ -351,8 +351,7 @@ export function createCesiumImagery( if (!layer) return; layer.alpha = state.alpha; layer.show = state.shown; - layer.splitDirection = - Cesium.SplitDirection?.[SPLIT[state.split]] ?? layer.splitDirection; + layer.splitDirection = Cesium.SplitDirection[SPLIT[state.split]]; guardBase(); } @@ -557,9 +556,7 @@ export function createCesiumImagery( requestRender('imagery-stage'); }); job.layer = make.layer(provider, { alpha: 0, show: true }); - job.layer.splitDirection = - Cesium.SplitDirection?.[SPLIT[entry.state.split]] ?? - job.layer.splitDirection; + job.layer.splitDirection = Cesium.SplitDirection[SPLIT[entry.state.split]]; if (entry.layer && entry.collection === collection) collection.add(job.layer, collection.indexOf(entry.layer) + 1); else place(entry, job.layer, collection); @@ -834,8 +831,7 @@ export function createCesiumImagery( applyLook(entry); if (entry.staging?.layer) entry.staging.layer.splitDirection = - Cesium.SplitDirection?.[SPLIT[state.split]] ?? - entry.staging.layer.splitDirection; + Cesium.SplitDirection[SPLIT[state.split]]; // A hidden layer stages nothing: the staged source replaces the // drawn one at once. if (!state.shown && entry.staging?.layer) diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js index ba262c17a..28fa8adcf 100644 --- a/src/globe/cesium/input.js +++ b/src/globe/cesium/input.js @@ -51,7 +51,7 @@ export function createCesiumInput( const groups = new Map(); // Camera-input suspensions held, and the enableInputs the last resume // puts back: the controller's own from before the first, or what - // setCameraInputs asked for since. + // the controller had before the first suspension. let suspended = 0; let resumeTo = true; const pointer = createExclusivePointer(() => { @@ -115,10 +115,6 @@ export function createCesiumInput( handler.removeInputAction(TYPES[type], KEYS[modifier]); }; }, - setCameraInputs(enabled) { - if (suspended) resumeTo = Boolean(enabled); - else scene.screenSpaceCameraController.enableInputs = Boolean(enabled); - }, // The controller's switch: whatever else turned it off counts too. cameraInputsEnabled: () => scene.screenSpaceCameraController?.enableInputs !== false, diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs index 2a78ccfe2..67f42b7dc 100644 --- a/src/globe/cesium/input.test.mjs +++ b/src/globe/cesium/input.test.mjs @@ -97,14 +97,6 @@ test('listeners share an action, removers are idempotent and the action goes wit assert.ok(!handler.actions.has(key)); }); -test('setCameraInputs toggles the camera controller', () => { - const { viewer, input } = setup(); - input.setCameraInputs(false); - assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, false); - input.setCameraInputs(true); - assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, true); -}); - test('suspendCameraInputs counts and the last resume restores enableInputs', () => { const { viewer, input } = setup(); const controller = viewer.scene.screenSpaceCameraController; @@ -123,12 +115,6 @@ test('suspendCameraInputs counts and the last resume restores enableInputs', () controller.enableInputs = false; input.suspendCameraInputs()(); assert.equal(controller.enableInputs, false); - // A choice made under a suspension applies at its resume. - const resume = input.suspendCameraInputs(); - input.setCameraInputs(true); - assert.equal(controller.enableInputs, false); - resume(); - assert.equal(controller.enableInputs, true); }); test('destroy destroys the handler', () => { diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs index 7cd2cd6d1..c5ac04eb8 100644 --- a/src/globe/cesium/lines.test.mjs +++ b/src/globe/cesium/lines.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../../testSupport/domClasses.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -6,14 +7,7 @@ import { createFakeViewer } from './testing/fakeViewer.mjs'; // Cesium's Material sorts uniform values with instanceof checks against these // DOM classes; Node has none of them. -for (const name of [ - 'HTMLCanvasElement', - 'HTMLImageElement', - 'HTMLVideoElement', - 'ImageBitmap', - 'OffscreenCanvas', -]) - globalThis[name] ??= class {}; +installDomClasses(); // Cesium's own Primitive, PolylineGeometry and materials (built, never drawn). function setup() { diff --git a/src/globe/cesium/mapScene.js b/src/globe/cesium/mapScene.js index 1e11a841f..ea7a63c40 100644 --- a/src/globe/cesium/mapScene.js +++ b/src/globe/cesium/mapScene.js @@ -1,5 +1,4 @@ import * as Cesium from 'cesium'; -import { createCesiumCredits } from './credits.js'; import { createMapCredits } from '../../maps/credits.js'; /** @@ -10,15 +9,14 @@ import { createMapCredits } from '../../maps/credits.js'; * @param {Cesium.Viewer} viewer * @param {{ * createImageryLayer?: (provider: object) => object, - * credits?: import('../contract.js').GlobeEngine['credits'], - * }} [options] `credits`: the globe engine's credits (default: the viewer's - * credit display through the Cesium engine's credits). + * credits: import('../contract.js').GlobeEngine['credits'], + * }} options `credits`: the globe engine's credits. */ export function createMapScene( viewer, { createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), - credits: engineCredits = createCesiumCredits(viewer).credits, + credits: engineCredits, } = {}, ) { const credits = createMapCredits(engineCredits); diff --git a/src/globe/cesium/mapScene.test.mjs b/src/globe/cesium/mapScene.test.mjs index 2e1b2c38b..a49a0c206 100644 --- a/src/globe/cesium/mapScene.test.mjs +++ b/src/globe/cesium/mapScene.test.mjs @@ -1,3 +1,4 @@ +import { createCesiumCredits } from './credits.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createMapScene } from './mapScene.js'; @@ -25,7 +26,9 @@ function fixture() { }, }, }; + viewer.creditDisplay = viewer.scene.frameState.creditDisplay; const scene = createMapScene(viewer, { + credits: createCesiumCredits(viewer).credits, createImageryLayer: (provider) => ({ provider }), }); return { viewer, scene, layers, removed, credits }; diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs index b12ecd124..c15ecf6ba 100644 --- a/src/globe/cesium/maps.test.mjs +++ b/src/globe/cesium/maps.test.mjs @@ -4,6 +4,7 @@ import * as Cesium from 'cesium'; import { MAP_STACKS } from '../../maps/catalog.js'; import { createDefaultMapSources } from '../../maps/defaultSources.js'; import { ESRI_ATTRIBUTION_HTML } from './mapImagery.js'; +import { createCesiumCredits } from './credits.js'; import { createCesiumMaps } from './maps.js'; import { createFakeViewer } from './testing/fakeViewer.mjs'; @@ -13,6 +14,7 @@ function setup(options = {}) { const { maps, destroy } = createCesiumMaps(viewer, { requestRender: (reason) => renders.push(reason), createImageryLayer: (provider) => ({ provider }), + credits: createCesiumCredits(viewer).credits, ...options, }); return { viewer, maps, destroy, renders }; diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js index 8c2102cd5..62ee70048 100644 --- a/src/globe/cesium/render.js +++ b/src/globe/cesium/render.js @@ -37,7 +37,7 @@ export function createCesiumRender( let removeFrameWatch = null; const watchFrames = () => { const { scene } = viewer; - if (removeFrameWatch || !scene.preUpdate?.addEventListener) return; + if (removeFrameWatch) return; const removers = [ scene.preUpdate.addEventListener(() => { drawing = true; diff --git a/src/globe/cesium/sonar.js b/src/globe/cesium/sonar.js index e2b6f7532..61ba1ee7e 100644 --- a/src/globe/cesium/sonar.js +++ b/src/globe/cesium/sonar.js @@ -70,11 +70,6 @@ export function createCesiumSonar( }); function attach() { - if ( - !scene?.postUpdate?.addEventListener || - !scene?.preRender?.addEventListener - ) - return null; const tracked = new Map(); const matrix = new Cesium.Matrix4(); const position = new Cesium.Cartesian3(); diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs index 4fd134af0..8a3fc4548 100644 --- a/src/globe/cesium/testing/fakeViewer.mjs +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -136,6 +136,7 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { canvas: { clientWidth: width, clientHeight: height }, globe, preUpdate: new Cesium.Event(), + postUpdate: new Cesium.Event(), preRender: new Cesium.Event(), postRender: new Cesium.Event(), requestRenderMode: true, diff --git a/src/globe/contract.js b/src/globe/contract.js index e77b0821a..89b6646eb 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -218,7 +218,7 @@ * The position moved onto the rendered scene along the geodetic normal. * @property {boolean} clampToHeightSupported * @property {() => boolean} tilesLoaded - * @property {(ray: { origin: Cartesian3, direction: Cartesian3, far: number, exclude?: any[] }) => ({ position: Cartesian3, distance: number, object: any } | undefined)} [raycast] + * @property {(ray: { origin: Cartesian3, direction: Cartesian3, far: number, exclude?: any[] }) => ({ position: Cartesian3, distance: number, object: any } | undefined)} raycast * Nearest visible scene object within finite positive `far` metres (Navara: * loaded tile triangles). Direction * is normalized by the adapter. Excluded roots/meshes and hidden objects do @@ -319,15 +319,12 @@ * @property {(type: InputType, listener: (event: InputEvent) => void, options?: InputOptions) => () => void} on * As in Cesium, a listener with a modifier fires only while that key is * held and one without fires only while no modifier is held. - * @property {(enabled: boolean) => void} setCameraInputs Whether the user's - * drag, wheel and touch move the camera (Cesium's enableInputs). Under a - * suspension the choice is kept and applies at the last resume. * @property {() => () => void} suspendCameraInputs Camera inputs off until * the returned resume (idempotent): for an owner that holds the camera for * a while (the cockpit, a gizmo or box drag, a flight a gesture would * cancel). Suspensions count: inputs stay off until the last one resumes, * which brings back the state from before the first (Cesium: the - * controller's enableInputs as it was), or what setCameraInputs set since. + * controller's enableInputs as it was). * @property {() => boolean} cameraInputsEnabled Whether the user's gestures * move the camera now: on, and under no suspension. * @@ -1016,10 +1013,10 @@ const SHAPE = { clampToHeight: isFunction, clampToHeightSupported: isBoolean, tilesLoaded: isFunction, + raycast: isFunction, }, input: { on: isFunction, - setCameraInputs: isFunction, acquirePointer: isFunction, suspendCameraInputs: isFunction, cameraInputsEnabled: isFunction, diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index ad54dd870..cfa481c2c 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -618,13 +618,13 @@ test('the floor does not fight flights or a lookAt target', () => { test('the floor stands aside while the camera inputs are off (the cockpit drives it)', () => { const { engine, view } = setup(); view.sampleTerrainHeight = () => 100; - engine.input.setCameraInputs(false); + const resumeInputs = engine.input.suspendCameraInputs(); engine.camera.setView({ destination: cartesian3.fromDegrees(2.35, 48.85, 112), }); view.emit('preRender', 1); near(heightOf(engine), 112, 1e-6, 'the cockpit eye 12 m up is kept'); - engine.input.setCameraInputs(true); + resumeInputs(); view.emit('preRender', 2); near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back'); engine.destroy(); diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index 285828699..51ad3de93 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -44,7 +44,6 @@ export function createNavaraInput( const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()])); let attached = false; let destroyed = false; - let cameraInputs = true; let cameraOptions = {}; let downAt = null; let last = null; @@ -123,7 +122,7 @@ export function createNavaraInput( last = position; }; const onCameraEvent = (event) => { - if (cameraInputs) onCameraInput(event.type); + onCameraInput(event.type); }; for (const type of CAMERA_EVENTS) canvas.addEventListener(type, onCameraEvent, { @@ -133,7 +132,7 @@ export function createNavaraInput( let suspended = 0; const sendCameraInputs = () => { - const enabled = cameraInputs && suspended === 0; + const enabled = suspended === 0; input.setCameraOptions({ enableSpin: enabled, enableZoom: enabled, @@ -166,17 +165,12 @@ export function createNavaraInput( listeners.get(type).delete(entry); }; }, - setCameraInputs(enabled) { - cameraInputs = Boolean(enabled); - sendCameraInputs(); - }, - /** Off under any suspension, else the setCameraInputs choice. */ - cameraInputsEnabled: () => cameraInputs && suspended === 0, + /** Off under any suspension. */ + cameraInputsEnabled: () => suspended === 0, /** * Navara's spin, zoom and tilt off until the returned release (the * follow camera takes drag and wheel itself, as do the app's camera - * owners); the app's setCameraInputs choice comes back after the last - * release. + * owners). The last release enables the controls. */ suspendCameraInputs() { suspended++; @@ -201,8 +195,6 @@ export function createNavaraInput( }; return { input, - /** Whether the app leaves the camera to the user (setCameraInputs). */ - cameraInputs: () => cameraInputs, destroy() { if (destroyed) return; pointer.destroy(); diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs index 71851d624..b95719564 100644 --- a/src/globe/navara/input.test.mjs +++ b/src/globe/navara/input.test.mjs @@ -119,22 +119,22 @@ test('on validates its arguments and returns an idempotent remover', () => { assert.equal(count, 0); }); -test('setCameraInputs toggles Navara camera control and user input cancels flights only while enabled', () => { +test('suspending Navara controls preserves user input notifications', () => { const { canvas, input, cameraOptions, cameraInputs } = setup(); canvas.dispatchEvent(pointer('pointerdown', 1, 1)); canvas.dispatchEvent(new Event('wheel')); assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']); - input.setCameraInputs(false); + const resume = input.suspendCameraInputs(); canvas.dispatchEvent(pointer('pointerdown', 1, 1)); - assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']); - input.setCameraInputs(true); + assert.deepEqual(cameraInputs, ['pointerdown', 'wheel', 'pointerdown']); + resume(); assert.deepEqual(cameraOptions, [ { enableSpin: false, enableZoom: false, enableTilt: false }, { enableSpin: true, enableZoom: true, enableTilt: true }, ]); }); -test('suspendCameraInputs counts and keeps the setCameraInputs choice for the last resume', () => { +test('suspendCameraInputs counts until the last resume', () => { const { input, cameraOptions } = setup(); const on = { enableSpin: true, enableZoom: true, enableTilt: true }; const off = { enableSpin: false, enableZoom: false, enableTilt: false }; @@ -145,11 +145,7 @@ test('suspendCameraInputs counts and keeps the setCameraInputs choice for the la inner(); inner(); assert.deepEqual(cameraOptions.at(-1), off); - input.setCameraInputs(false); outer(); - assert.deepEqual(cameraOptions.at(-1), off); - assert.equal(input.cameraInputsEnabled(), false); - input.setCameraInputs(true); assert.deepEqual(cameraOptions.at(-1), on); assert.equal(input.cameraInputsEnabled(), true); }); @@ -165,10 +161,10 @@ test('destroy removes every DOM listener and later subscriptions are inert', () assert.equal(canvas.listenerCount, 0); }); -test('setCameraInputs merges into the camera options set through the engine', () => { +test('suspension merges into the camera options set through the engine', () => { const { input, cameraOptions } = setup(); input.setCameraOptions({ minimumZoomDistance: 50, zoomSpeed: 2 }); - input.setCameraInputs(false); + const resume = input.suspendCameraInputs(); assert.deepEqual(cameraOptions.at(-1), { minimumZoomDistance: 50, zoomSpeed: 2, diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 8d3da3bc7..75cf4d94e 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -1883,8 +1883,6 @@ export function createContractSuite( off(); } } - engine.input.setCameraInputs(false); - engine.input.setCameraInputs(true); }, ], [ @@ -1896,14 +1894,9 @@ export function createContractSuite( typeof before === 'boolean', `cameraInputsEnabled() returned ${before}`, ); + const outer = input.suspendCameraInputs(); + const inner = input.suspendCameraInputs(); try { - input.setCameraInputs(true); - ensure( - input.cameraInputsEnabled(), - 'setCameraInputs(true) left them off', - ); - const outer = input.suspendCameraInputs(); - const inner = input.suspendCameraInputs(); ensure( typeof outer === 'function' && typeof inner === 'function', 'suspendCameraInputs returned no resume', @@ -1913,29 +1906,16 @@ export function createContractSuite( inner(); ensure( !input.cameraInputsEnabled(), - 'an inner resume (twice) let them on under the outer one', + 'an inner resume lifted the outer suspension', ); outer(); - ensure(input.cameraInputsEnabled(), 'off after the last resume'); - input.setCameraInputs(false); - input.suspendCameraInputs()(); ensure( - !input.cameraInputsEnabled(), - 'a resume turned on inputs that were off before the suspension', - ); - const resume = input.suspendCameraInputs(); - input.setCameraInputs(true); - ensure( - !input.cameraInputsEnabled(), - 'setCameraInputs(true) lifted a suspension', - ); - resume(); - ensure( - input.cameraInputsEnabled(), - 'the choice made under a suspension did not apply at its resume', + input.cameraInputsEnabled() === before, + 'last resume did not restore inputs', ); } finally { - input.setCameraInputs(before); + inner(); + outer(); } }, ], diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 17719a7d9..f4c698251 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -333,8 +333,6 @@ export function createFakeEngine(options = {}) { let moving = false; let lastFramePose = null; let lastChangedPose = null; - // The setCameraInputs choice and the suspensions held over it. - let cameraInputChoice = true; let cameraSuspensions = 0; const fake = { @@ -793,6 +791,10 @@ export function createFakeEngine(options = {}) { ...fakeContactSonar(options.contactSonar, record), }, surface: { + raycast(ray) { + record('surface.raycast', [ray]); + return scripted('raycast', [ray], () => undefined); + }, sampleHeight(position, exclude) { record('surface.sampleHeight', [position, exclude]); return scripted('sampleHeight', [position, exclude], () => undefined); @@ -848,11 +850,6 @@ export function createFakeEngine(options = {}) { listeners.add(entry); return () => listeners.delete(entry); }, - setCameraInputs(enabled) { - record('input.setCameraInputs', [enabled]); - cameraInputChoice = Boolean(enabled); - fake.cameraInputsEnabled = cameraInputChoice && !cameraSuspensions; - }, suspendCameraInputs() { record('input.suspendCameraInputs', []); cameraSuspensions++; @@ -863,7 +860,7 @@ export function createFakeEngine(options = {}) { resumed = true; record('input.resumeCameraInputs', []); cameraSuspensions--; - fake.cameraInputsEnabled = cameraInputChoice && !cameraSuspensions; + fake.cameraInputsEnabled = !cameraSuspensions; }; }, cameraInputsEnabled: () => fake.cameraInputsEnabled, diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index d1316e71e..0b0d7f19b 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -127,9 +127,6 @@ test('fake input dispatches to subscribers and off removes them', () => { engine.fake.input('click', { position: geo.cartesian2.create(6, 7) }); assert.deepEqual(seen, [geo.cartesian2.create(4, 5)]); assert.throws(() => engine.input.on('wheel', () => {}), /Unknown input type/); - engine.input.setCameraInputs(false); - assert.equal(engine.fake.cameraInputsEnabled, false); - engine.input.setCameraInputs(true); const resume = engine.input.suspendCameraInputs(); const inner = engine.input.suspendCameraInputs(); assert.equal(engine.input.cameraInputsEnabled(), false); diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 419bed30b..e4f36cd83 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -29,7 +29,7 @@ const READOUT_LIMIT = 5; export function createAirQualityLayer({ source, overlayHost, - createRenderer = ({ engine }) => createAirQualityDisplay(engine), + rendererFactory = ({ engine }) => createAirQualityDisplay(engine), } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Air quality requires a snapshot source'); @@ -83,7 +83,7 @@ export function createAirQualityLayer({ if (!initialized || renderer) throw new Error('Invalid air quality scene attachment'); sceneEngine = engine; - renderer = createRenderer({ engine }); + renderer = rendererFactory({ engine }); renderer.setVisible(_enabled); overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled); if (_readings.length) this._render(); diff --git a/src/layers/airQuality/render/display.js b/src/layers/airQuality/render/display.js index 70a93ff47..057eeba0d 100644 --- a/src/layers/airQuality/render/display.js +++ b/src/layers/airQuality/render/display.js @@ -19,8 +19,6 @@ export const airQualityPickId = (reading) => `air-quality:${reading.id}`; * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createAirQualityDisplay(engine) { - if (!engine?.ground) - throw new TypeError('The air-quality display needs a globe engine'); const set = engine.ground.createSet({ id: SET_ID, kind: 'disc', diff --git a/src/layers/airQuality/render/display.test.mjs b/src/layers/airQuality/render/display.test.mjs index 55b4d7e33..93527e9f6 100644 --- a/src/layers/airQuality/render/display.test.mjs +++ b/src/layers/airQuality/render/display.test.mjs @@ -61,10 +61,6 @@ const near = (actual, expected, label, tolerance = 1e-9) => `${label}: ${actual} vs ${expected}`, ); -test('the display needs a globe engine', () => { - assert.throws(() => createAirQualityDisplay(null), TypeError); -}); - for (const makeHarness of GROUND_HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js index 9d1661aab..4505f9236 100644 --- a/src/layers/aircraft/render/contract.js +++ b/src/layers/aircraft/render/contract.js @@ -50,14 +50,7 @@ * @property {(position: object, result?: object) => ({x: number, y: number} | undefined)} worldToWindow * @property {() => void} requestRender * @property {(event: 'preRender'|'preUpdate'|'moveEnd', listener: Function) => () => void} on - * @property {object} surfaceOwner What groundSnap/meshFloor sample through - * (the engine). - * @property {object} surfaceScene What sampleMeshFloorCells takes. - * @property {() => void} holdFrames Evidence capture: stop the engine's own - * frame loop (QA only; the contract has no frame hold, so it holds none). - * @property {() => void} closeFrameLatch Evidence capture, one task later: - * let manual renders start without waiting for the loop's next frame. - * @property {() => void} releaseFrames + * @property {object} engine The globe engine used for surface sampling. */ /** diff --git a/src/layers/aircraft/render/display.js b/src/layers/aircraft/render/display.js index 550949d47..38312657b 100644 --- a/src/layers/aircraft/render/display.js +++ b/src/layers/aircraft/render/display.js @@ -109,8 +109,6 @@ export function createAircraftDisplay( drawAtlas = drawAircraftIconAtlas, }, ) { - if (!engine?.movers || !engine?.lines) - throw new TypeError('The aircraft display needs a globe engine'); let destroyed = false; let attached = false; const removers = []; diff --git a/src/layers/aircraft/render/display.test.mjs b/src/layers/aircraft/render/display.test.mjs index df91671c6..8849b317c 100644 --- a/src/layers/aircraft/render/display.test.mjs +++ b/src/layers/aircraft/render/display.test.mjs @@ -109,10 +109,6 @@ const trackedSpec = (state) => ({ rotation: () => 0.25, }); -test('the display needs a globe engine', () => { - assert.throws(() => createAircraftDisplay(null, { layerId: 'x' }), TypeError); -}); - test('the atlas has every glyph at the fleet and tracked sizes', () => { const cells = aircraftIconCells(); for (const key of cells.values()) diff --git a/src/layers/aircraft/tracking.js b/src/layers/aircraft/tracking.js index 637faacbb..b1d525621 100644 --- a/src/layers/aircraft/tracking.js +++ b/src/layers/aircraft/tracking.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; import { nearFarScale } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; @@ -82,7 +82,7 @@ export function createAircraftTracking({ selectTrackedSubjectContext, clearTrackedSubjectContext, refreshTrackedSubjectContext, - } = bindContextStore(services.context, () => flightState._renderer?.engine); + } = bindOverlayHost(services.context, () => flightState._renderer?.engine); const { isTr3b, tr3bConvertedIds } = services.aircraftPresentation; const { ensureGeoidReady, geoidHeight } = services.geoid; const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 5f58ff0c4..e4ed9cac6 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -288,7 +288,7 @@ export function createAlprCamerasLayer({ source, engine: options.engine, }); - renderer.init(viewer, { onMove: scheduleLoad }); + renderer.init({ onMove: scheduleLoad }); if (state.enabled) { registerPickOwner( LAYER_ID, diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js index 1c690a607..8f1164133 100644 --- a/src/layers/alpr/overlay.js +++ b/src/layers/alpr/overlay.js @@ -27,13 +27,17 @@ import { * @param {object} options.state Layer state. * @param {object} options.services Layer services. * @param {function(): void} [options.onSurfaceChange] Map stack changed. - * @param {object} options.engine Engine adapter. + * @param {object} options.engine Globe engine. */ export function createAlprOverlayCore({ state, services, onSurfaceChange = () => {}, engine, + markers, + syncMarker, + sampleHeight, + terrainHeight, }) { let lane, removeMapListener, @@ -48,17 +52,29 @@ export function createAlprOverlayCore({ if (!destroyed && state.enabled) lane?.requestPaint(); }; + function canvasOwned(marker, owned) { + if (marker.canvasOwned === owned) return; + marker.canvasOwned = owned; + syncMarker(marker); + } + + function setShown(marker, shown) { + if (marker.shown === shown) return; + marker.shown = shown; + syncMarker(marker); + } + function nativeVisible(marker, visible) { if (!marker) return; - engine.canvasOwned(marker, !visible); - engine.setShown(marker, visible); + canvasOwned(marker, !visible); + setShown(marker, visible); } function resetAnchors() { - for (const marker of engine.markers()) { + for (const marker of markers.values()) { marker.gevAlprCanvasPosition = null; marker.gevAlprWedge = null; - engine.setDisplayPosition(marker, null); + marker.displayPosition = null; nativeVisible(marker, true); } state.lastAnchorSampleAt = 0; @@ -77,16 +93,16 @@ export function createAlprOverlayCore({ record.longitude, ); if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M; - else if (engine.surfaceSupported()) { + else if (engine.surface.supported) { if (budget.samples <= 0) return null; budget.samples -= 1; try { - height = engine.sampleHeight(record, marker); + height = sampleHeight(record, marker); } catch { /* streaming tiles */ } } - if (!validAlprGroundHeight(height)) height = engine.terrainHeight(record); + if (!validAlprGroundHeight(height)) height = terrainHeight(record); if (!validAlprGroundHeight(height)) return null; setAnchor(record, marker, height); return marker.gevAlprCanvasPosition; @@ -98,7 +114,13 @@ export function createAlprOverlayCore({ } function setAnchor(record, marker, height) { - engine.placeAnchor(marker, record, height); + marker.gevAlprCanvasPosition = geo.cartesian3.fromDegrees( + record.longitude, + record.latitude, + height, + ); + marker.resolved = true; + syncMarker(marker); marker.gevAlprWedge = directionWedgePositions( record, height, @@ -115,7 +137,7 @@ export function createAlprOverlayCore({ * rejected. Returns whether anchors remain to check. */ function refineAnchors(budget) { - const camera = engine.cameraPose(); + const camera = engine.camera.pose(); const still = lastCamera && camera.position && @@ -136,20 +158,20 @@ export function createAlprOverlayCore({ position: geo.cartesian3.clone(camera.position), direction: geo.cartesian3.clone(camera.direction), }; - if (!engine.surfaceSupported()) return false; + if (!engine.surface.supported) return false; let pending = false; for (const record of records) { - const marker = engine.marker(record.id); + const marker = markers.get(record.id); const anchor = marker?.gevAlprCanvasPosition; if (!anchor || marker.gevAlprSurfaceCheckedFor === anchor) continue; - if (!still || !engine.surfaceSettled() || budget.samples <= 0) { + if (!still || !engine.surface.tilesLoaded() || budget.samples <= 0) { pending = true; continue; } budget.samples -= 1; let sample; try { - sample = engine.sampleHeight(record, marker); + sample = sampleHeight(record, marker); } catch { /* streaming tiles */ } @@ -167,7 +189,7 @@ export function createAlprOverlayCore({ setAnchor(record, marker, sample + MARKER_FLOOR_LIFT_M); marker.gevAlprSurfaceCheckedFor = marker.gevAlprCanvasPosition; if (marker.id === state.selectedId) - engine.setDisplayPosition(marker, marker.gevAlprCanvasPosition); + marker.displayPosition = marker.gevAlprCanvasPosition; } return pending; } @@ -176,12 +198,29 @@ export function createAlprOverlayCore({ const { ctx, width, height, keyhole } = frame; hits = []; if (!state.enabled || destroyed) return; - const { horizonVisible, project } = engine.projector(frame); + const horizonVisible = (point) => engine.projector.horizonVisible(point); + const matrix = frame.viewProjectionMatrix; + function project({ x, y, z }) { + const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; + if (!(w > 0)) return null; + return { + x: + (((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / w + + 1) * + width) / + 2, + y: + ((1 - + (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / w) * + height) / + 2, + }; + } let unresolved = false; const painted = []; const budget = { samples: ANCHOR_SAMPLES_PER_PAINT }; for (const record of records) { - const marker = engine.marker(record.id); + const marker = markers.get(record.id); if (!marker) continue; const selected = record.id === state.selectedId; const image = images[selected ? 1 : 0]; @@ -199,20 +238,20 @@ export function createAlprOverlayCore({ unresolved = true; continue; } - engine.canvasOwned(marker, true); + canvasOwned(marker, true); if (!horizonVisible(anchor)) { - engine.setShown(marker, false); + setShown(marker, false); continue; } const origin = project(anchor); if (!origin) { - engine.setShown(marker, false); + setShown(marker, false); continue; } const alpha = services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1; if (!(alpha > 0)) { - engine.setShown(marker, false); + setShown(marker, false); continue; } const wedge = marker.gevAlprWedge; @@ -222,11 +261,10 @@ export function createAlprOverlayCore({ if ( left && right && - (!engine.cullOffscreenWedges || - (Math.max(origin.x, left.x, right.x) >= -3 && - Math.min(origin.x, left.x, right.x) <= width + 3 && - Math.max(origin.y, left.y, right.y) >= -3 && - Math.min(origin.y, left.y, right.y) <= height + 3)) + Math.max(origin.x, left.x, right.x) >= -3 && + Math.min(origin.x, left.x, right.x) <= width + 3 && + Math.max(origin.y, left.y, right.y) >= -3 && + Math.min(origin.y, left.y, right.y) <= height + 3 ) { ctx.save(); ctx.globalAlpha *= alpha; @@ -240,10 +278,10 @@ export function createAlprOverlayCore({ origin.y < -60 || origin.y > height + 60 ) { - engine.setShown(marker, false); + setShown(marker, false); continue; } - engine.setShown(marker, true); + setShown(marker, true); painted.push({ record, origin, @@ -251,10 +289,10 @@ export function createAlprOverlayCore({ image, alpha, scale: markerScale( - geo.cartesian3.distance(engine.cameraPosition(), anchor), + geo.cartesian3.distance(engine.camera.livePosition, anchor), ), }); - if (selected) engine.setDisplayPosition(marker, anchor); + if (selected) marker.displayPosition = anchor; } // Paint all glyphs after the wedges so one camera's cone cannot wash out another. for (const { record, origin, selected, image, alpha, scale } of painted) { @@ -303,7 +341,7 @@ export function createAlprOverlayCore({ if (!services.overlays?.registerPaintLane || typeof Image === 'undefined') return; lane = services.overlays.registerPaintLane('selected', paint, { - id: `alpr-${engine.createId()}`, + id: `alpr-${globalThis.crypto.randomUUID()}`, active: false, }); images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => { @@ -316,9 +354,8 @@ export function createAlprOverlayCore({ }, sync(visible) { if (!lane) return; - for (const record of records) - nativeVisible(engine.marker(record.id), true); - const position = engine.cameraPosition(); + for (const record of records) nativeVisible(markers.get(record.id), true); + const position = engine.camera.livePosition; records = visible .filter( (record) => @@ -363,8 +400,7 @@ export function createAlprOverlayCore({ clear() { clearTimeout(retryTimer); retryTimer = null; - for (const record of records) - nativeVisible(engine.marker(record.id), true); + for (const record of records) nativeVisible(markers.get(record.id), true); records = []; hits = []; lane?.setActive(false); diff --git a/src/layers/alpr/render/display.js b/src/layers/alpr/render/display.js index f49fffb21..1ec743674 100644 --- a/src/layers/alpr/render/display.js +++ b/src/layers/alpr/render/display.js @@ -50,8 +50,8 @@ const SELECTED_ORDER = -0.5; // What a canvas-owned marker keeps of its badge: enough for a pick. const PICK_TARGET_ALPHA = 0.01; // The label accents, in Cesium's CSS form. -const ACCENT = rgbaFromCss(ALPR_COLOR).toCssColorString(); -const SELECTED_ACCENT = rgbaFromCss(ALPR_SELECTED_COLOR).toCssColorString(); +const ACCENT = ALPR_COLOR; +const SELECTED_ACCENT = ALPR_SELECTED_COLOR; /** * @param {import('../../../globe/contract.js').GlobeEngine} engine @@ -59,7 +59,6 @@ const SELECTED_ACCENT = rgbaFromCss(ALPR_SELECTED_COLOR).toCssColorString(); * The layer's state, services and camera source. */ export function createAlprDisplay(engine, { state, services, source }) { - if (!engine) throw new TypeError('The ALPR display needs a globe engine'); const { governorRequestRender } = services.render; const { clearSelectedEntityContextForLayer, @@ -231,63 +230,11 @@ export function createAlprDisplay(engine, { state, services, source }) { }, }, onSurfaceChange: refreshPlacements, - engine: { - marker: (id) => markers.get(id), - markers: () => markers.values(), - canvasOwned(marker, owned) { - if (marker.canvasOwned === owned) return; - marker.canvasOwned = owned; - syncMarker(marker); - }, - setShown(marker, shown) { - if (marker.shown === shown) return; - marker.shown = shown; - syncMarker(marker); - }, - setDisplayPosition(marker, position) { - marker.displayPosition = position; - }, - placeAnchor(marker, record, height) { - marker.gevAlprCanvasPosition = geo.cartesian3.fromDegrees( - record.longitude, - record.latitude, - height, - ); - marker.resolved = true; - syncMarker(marker); - }, - surfaceSupported: () => engine.surface.supported, - sampleHeight: (record) => sampleHeight(record), - terrainHeight, - surfaceSettled: () => engine.surface.tilesLoaded(), - cameraPose: () => engine.camera.pose(), - cameraPosition: () => engine.camera.livePosition, - projector: ({ width, height, viewProjectionMatrix: matrix }) => ({ - horizonVisible: (point) => engine.projector.horizonVisible(point), - // The host captured the camera and CSS viewport for this frame; - // every wedge vertex reuses it instead of rereading DOM geometry. - project({ x, y, z }) { - const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; - if (!(w > 0)) return null; - return { - x: - (((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / - w + - 1) * - width) / - 2, - y: - ((1 - - (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / - w) * - height) / - 2, - }; - }, - }), - cullOffscreenWedges: true, - createId: () => globalThis.crypto.randomUUID(), - }, + engine, + markers, + syncMarker, + sampleHeight, + terrainHeight, }); /** Places every marker again (a new map stack moved the surface). */ @@ -713,7 +660,7 @@ export function createAlprDisplay(engine, { state, services, source }) { focusNearest, /** The marker handles displayed now. */ markers: () => [...markers.values()], - init(_viewer, { onMove }) { + init({ onMove }) { if (initialized) throw new Error('ALPR renderer is already initialized'); // A source credit that is not a public HTTPS link fails here. creditMarkup = alprCreditMarkup(source.attribution); diff --git a/src/layers/alpr/render/display.test.mjs b/src/layers/alpr/render/display.test.mjs index 307b4f2ff..c4886c4de 100644 --- a/src/layers/alpr/render/display.test.mjs +++ b/src/layers/alpr/render/display.test.mjs @@ -205,7 +205,7 @@ function setup( recordById: new Map(), }; const display = createAlprDisplay(h.engine, { state, services: s, source }); - display.init(null, { onMove() {} }); + display.init({ onMove() {} }); display.setVisible(true); const load = (list) => { state.records = list; @@ -238,10 +238,6 @@ function setup( }; } -test('the display needs a globe engine', () => { - assert.throws(() => createAlprDisplay(null, {}), TypeError); -}); - for (const makeHarness of HARNESSES) { const { name } = makeHarness(); @@ -315,14 +311,14 @@ for (const makeHarness of HARNESSES) { assert.equal(chosen.id, 'alpr:0'); assert.equal( h.marker('alpr:0').gevLabelModel.accent, - 'rgb(255,100,116)', - 'the selected accent, in Cesium’s CSS form', + '#ff6474', + 'the selected accent', ); h.clickCamera('alpr:1'); h.frame(); assert.equal(h.services.selected.id, 'alpr:1'); assert.equal(h.badge('alpr-cameras', 0).shown, true); - assert.equal(h.marker('alpr:0').gevLabelModel.accent, 'rgb(82,212,255)'); + assert.equal(h.marker('alpr:0').gevLabelModel.accent, '#52d4ff'); h.click({ id: 'alpr:1', owner: 'alpr-selected', instance: 0 }); h.frame(); assert.equal( diff --git a/src/layers/awareness/render/display.js b/src/layers/awareness/render/display.js index 63b9a4671..9070f4238 100644 --- a/src/layers/awareness/render/display.js +++ b/src/layers/awareness/render/display.js @@ -1,3 +1,4 @@ +import { rgbaFromCss } from '../../shared/color.js'; // The Global Context visual on the globe contract (engine.lines, // engine.camera, engine.frames, engine.host), the same on every engine: // the layer's view of the globe (its canvas, camera, frames, the followed @@ -25,13 +26,8 @@ import { awarenessRangeRingPositions } from '../rendering.js'; export const RING_ID = 'gev-awareness-rim'; export const RING_HALO_ID = 'gev-awareness-rim-halo'; -/** `rgba(r, g, b, a)` as an rgba array in 0..1, as Cesium reads it. */ -function rgbaArray(css) { - const [r, g, b, a = 1] = css.match(/[\d.]+/g).map(Number); - return [r / 255, g / 255, b / 255, a]; -} -const RING_CORE = rgbaArray(AWARENESS_RANGE_RING_STYLE.color); -const RING_HALO = rgbaArray(AWARENESS_RANGE_RING_STYLE.outlineColor); +const RING_CORE = rgbaFromCss(AWARENESS_RANGE_RING_STYLE.color).rgba(); +const RING_HALO = rgbaFromCss(AWARENESS_RANGE_RING_STYLE.outlineColor).rgba(); const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha]; /** @@ -39,8 +35,6 @@ const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha]; * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createAwarenessDisplay(engine) { - if (!engine?.lines) - throw new TypeError('The awareness display needs a globe engine'); const binding = createEngineLayerView(engine, { layerId: 'military-awareness', }); diff --git a/src/layers/awareness/render/display.test.mjs b/src/layers/awareness/render/display.test.mjs index 6ead1ebc0..5880eb161 100644 --- a/src/layers/awareness/render/display.test.mjs +++ b/src/layers/awareness/render/display.test.mjs @@ -42,7 +42,7 @@ for (const makeHarness of HARNESSES) { assert.equal(display.cameraInputsEnabled(), false); resume(); assert.equal(display.cameraInputsEnabled(), true); - h.engine.input.setCameraInputs(false); + h.engine.input.suspendCameraInputs(); assert.equal(display.cameraInputsEnabled(), false); }); diff --git a/src/layers/bhoteKoshi/render/display.js b/src/layers/bhoteKoshi/render/display.js index 2172bb1db..264c9d82a 100644 --- a/src/layers/bhoteKoshi/render/display.js +++ b/src/layers/bhoteKoshi/render/display.js @@ -18,8 +18,6 @@ let events = 0; * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createBhoteKoshiDisplay(engine) { - if (!engine?.imagery) - throw new TypeError('The Bhote Koshi display needs a globe engine'); const id = `bhote-koshi-imagery-${++events}`; let layers = []; const stories = new Set(); diff --git a/src/layers/bikeshare/edgeCases.test.mjs b/src/layers/bikeshare/edgeCases.test.mjs index 8019311c0..015f9b997 100644 --- a/src/layers/bikeshare/edgeCases.test.mjs +++ b/src/layers/bikeshare/edgeCases.test.mjs @@ -63,9 +63,9 @@ function fixture() { services: { render: { governorRequestRender() {} }, overlays: { - setOverlayEntries: (id, value) => entries.set(id, value), - setOverlaySourceVisible() {}, - clearOverlaySource: (id) => entries.delete(id), + setEntries: (id, value) => entries.set(id, value), + setVisible() {}, + clearSource: (id) => entries.delete(id), }, }, rendererFactory: (context) => createBikeshareDisplay(engine, context), diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs index 86f44d310..d035feeee 100644 --- a/src/layers/bikeshare/failureReporting.test.mjs +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -19,9 +19,9 @@ import { createControls } from './controls.js'; function makeLayer() { const services = { overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, + setEntries() {}, + setVisible() {}, + clearSource() {}, }, }; const state = createState({ services }); @@ -164,9 +164,9 @@ function makeLiveLayer() { const services = { render: { governorRequestRender() {} }, overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, + setEntries() {}, + setVisible() {}, + clearSource() {}, }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, }; @@ -181,7 +181,7 @@ function makeLiveLayer() { // The view centre is the point under the camera. engine.fake.script('viewRectangle', undefined); const display = createBikeshareDisplay(engine, context); - Object.assign(parts, display); + parts.display = display; parts.queries = { collectDetectableStations: () => [] }; const { methods: controls } = createControls(context); @@ -190,7 +190,7 @@ function makeLiveLayer() { destination: geo.cartesian3.fromDegrees(where.lon, where.lat, height), }); place(BETWEEN, LOW_M); - parts.lifecycle.methods.init(); + parts.display.init(); return { state, @@ -198,13 +198,13 @@ function makeLiveLayer() { stats: () => controls.getStats(), moveTo(where, height = LOW_M) { place(where, height); - return parts.viewport.runProximityCheck(); + return parts.display.runProximityCheck(); }, async enable() { - parts.lifecycle.methods.enable(); + parts.display.enable(); await settle(); }, - disable: () => parts.lifecycle.methods.disable(), + disable: () => parts.display.disable(), poll: () => parts.ingestion.methods.update(), }; } diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js index 4fd7c6232..ddcabbc59 100644 --- a/src/layers/bikeshare/index.js +++ b/src/layers/bikeshare/index.js @@ -31,27 +31,25 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { throw new Error('Bikeshare layer is already initialized'); renderer ??= rendererFactory({ ...context, - viewer, engine: options.engine ?? null, }); - if (!renderer?.lifecycle?.methods) - throw new Error('Bikeshare renderer is unavailable'); - Object.assign(parts, renderer); + if (!renderer) throw new Error('Bikeshare renderer is unavailable'); + parts.display = renderer; initialized = true; try { state._restoringViewport = Boolean(options.waitForCameraRestore); - renderer.lifecycle.methods.init(viewer, options); + renderer.init(); if (state._enabled) { const selected = state._selectedKey; for (const city of state._activeCityIds) { const stations = state._stationInfoCache.get(city); if (!stations) continue; - parts.rendering.ensureCityPoints(city, stations); + parts.display.ensureCityPoints(city, stations); const status = state._statusCache.get(city)?.statusMap; - if (status) parts.rendering.applyStatusToPoints(city, status); + if (status) parts.display.applyStatusToPoints(city, status); } - if (selected) parts.selection._selectStation(selected); - renderer.lifecycle.methods.show(viewer); + if (selected) parts.display._selectStation(selected); + renderer.show(); } } catch (error) { initialized = false; @@ -61,37 +59,35 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { resumeViewport() { if (!state._restoringViewport) return; state._restoringViewport = false; - if (state._enabled) parts.viewport?.onCameraChanged(); + if (state._enabled) parts.display?.onCameraChanged(); }, detachRenderer() { if (!renderer) return; state._restoringViewport = true; clearTimeout(state._cameraDebounceTimer); const selected = state._selectedKey; - renderer.lifecycle.methods.detach(); + renderer.detach(); state._selectedKey = selected; state._cityRuntime.clear(); state._stationRenderMap.clear(); state._engine = null; renderer = null; initialized = false; - for (const key of ['rendering', 'selection', 'viewport', 'lifecycle']) - delete parts[key]; + parts.display = null; }, enable(...args) { - return renderer?.lifecycle.methods.enable(...args); + return renderer?.enable(...args); }, disable(...args) { - return renderer?.lifecycle.methods.disable(...args); + return renderer?.disable(...args); }, destroy(...args) { try { - return renderer?.lifecycle.methods.destroy(...args); + return renderer?.destroy(...args); } finally { renderer = null; initialized = false; - for (const key of ['rendering', 'selection', 'viewport', 'lifecycle']) - delete parts[key]; + parts.display = null; } }, }; @@ -105,10 +101,9 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { _setBikeshareSelectionStateForTest: (options) => { renderer ??= rendererFactory({ ...context, - viewer: null, engine: options.engine, }); - Object.assign(parts, renderer); + parts.display = renderer; parts.testing._setBikeshareSelectionStateForTest(options); }, _selectBikeshareStationForTest: diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index 16e0a5d7b..9250ab34f 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -187,7 +187,7 @@ export function createIngestion({ try { const statusMap = await loadCityStationStatus(cityId, generation); if (!isCurrent(cityId)) return; - parts.rendering?.applyStatusToPoints(cityId, statusMap); + parts.display?.applyStatusToPoints(cityId, statusMap); layerState._failedCityIds.delete(cityId); refreshed = true; } catch (error) { diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index d4cb45d67..ea1de6297 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -457,8 +457,8 @@ test('a station without a published capacity re-derives it from every status pol _cityRuntime: new Map(), _stationRenderMap: new Map(), }; - const parts = { model, selection: { refreshSelectedCard() {} } }; - const { rendering } = createBikeshareDisplay(createFakeEngine(), { + const parts = { model, display: { refreshSelectedCard() {} } }; + const rendering = createBikeshareDisplay(createFakeEngine(), { state, services: { render: { governorRequestRender() {} } }, parts, diff --git a/src/layers/bikeshare/render/display.js b/src/layers/bikeshare/render/display.js index f49200b19..8ad67d24f 100644 --- a/src/layers/bikeshare/render/display.js +++ b/src/layers/bikeshare/render/display.js @@ -51,8 +51,6 @@ const SELECTED_ORDER = STATION_ORDER + 0.5; * @returns {{ rendering: object, selection: object, viewport: object, lifecycle: { methods: object } }} */ export function createBikeshareDisplay(engine, context) { - if (!engine) - throw new TypeError('The bikeshare display needs a globe engine'); const { state, services, parts } = context; const { registerPickOwner, unregisterPickOwner } = services.picking ?? {}; let stations = null; @@ -292,7 +290,7 @@ export function createBikeshareDisplay(engine, context) { function attachCamera() { if (state._cameraChangedAttached) return; removeCamera = engine.camera.changed.addEventListener( - parts.viewport.onCameraChanged, + parts.display.onCameraChanged, ); claimCameraSensitivity(engine, LAYER_ID, 0.05); state._cameraChangedAttached = true; @@ -305,7 +303,7 @@ export function createBikeshareDisplay(engine, context) { state._cameraChangedAttached = false; } function releaseInput() { - parts.selection._uninstallClickHandler(); + parts.display._uninstallClickHandler(); unregisterPickOwner?.(LAYER_ID, engine); } @@ -334,7 +332,7 @@ export function createBikeshareDisplay(engine, context) { show() { setVisible(true); state._overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true); - parts.selection._installClickHandler(); + parts.display._installClickHandler(); // Pick ownership: station point ids are render-map keys. registerPickOwner?.( LAYER_ID, @@ -342,7 +340,7 @@ export function createBikeshareDisplay(engine, context) { engine, ); attachCamera(); - void parts.viewport.runProximityCheck(); + void parts.display.runProximityCheck(); }, /** Hides everything, stops every request and drops the cities. */ disable() { @@ -352,7 +350,7 @@ export function createBikeshareDisplay(engine, context) { state._altitudeGateEnabled = false; clearTimeout(state._cameraDebounceTimer); state._cameraDebounceTimer = null; - parts.selection._clearSelection(); + parts.display._clearSelection(); state._overlayHost.setVisible( BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false, @@ -360,7 +358,7 @@ export function createBikeshareDisplay(engine, context) { releaseInput(); detachCamera(); parts.ingestion.abortAllInFlight(); - parts.viewport.deactivateAllCities(); + parts.display.deactivateAllCities(); state._cityRuntime.clear(); setVisible(false); state._count = 0; @@ -369,7 +367,7 @@ export function createBikeshareDisplay(engine, context) { }, /** Leaves this globe; the caches stay for the next one. */ detach() { - parts.selection._clearSelection(); + parts.display._clearSelection(); releaseInput(); detachCamera(); destroySets(); @@ -382,7 +380,7 @@ export function createBikeshareDisplay(engine, context) { state._cameraDebounceTimer = null; if (state._enabled) methods.disable(); else { - parts.selection._clearSelection(); + parts.display._clearSelection(); state._overlayHost.setVisible( BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false, @@ -399,5 +397,5 @@ export function createBikeshareDisplay(engine, context) { }, }; - return { rendering, selection, viewport, lifecycle: { methods } }; + return { ...rendering, ...selection, ...viewport, ...methods }; } diff --git a/src/layers/bikeshare/render/display.test.mjs b/src/layers/bikeshare/render/display.test.mjs index 7e0b1bd6d..ab257eae2 100644 --- a/src/layers/bikeshare/render/display.test.mjs +++ b/src/layers/bikeshare/render/display.test.mjs @@ -151,9 +151,9 @@ function fixture(makeHarness, getStations) { services: { render: { governorRequestRender() {} }, overlays: { - setOverlayEntries: (id, value) => entries.set(id, value), - setOverlaySourceVisible() {}, - clearOverlaySource: (id) => entries.delete(id), + setEntries: (id, value) => entries.set(id, value), + setVisible() {}, + clearSource: (id) => entries.delete(id), }, picking: { registerPickOwner: (id, owns) => owners.set(id, owns), @@ -186,10 +186,6 @@ function fixture(makeHarness, getStations) { }; } -test('the display needs a globe engine', () => { - assert.throws(() => createBikeshareDisplay(null, {}), TypeError); -}); - for (const makeHarness of HARNESSES) { const { name } = makeHarness(); @@ -344,7 +340,7 @@ for (const makeHarness of HARNESSES) { f.engine.camera.changed.raiseEvent(0.1); assert.equal(f.context.state._cameraDebounceTimer, null); } - const gate = f.display.viewport.shouldActivateAtAltitude; + const gate = f.display.shouldActivateAtAltitude; assert.equal(gate(49000), false); assert.equal(gate(48000), true); assert.equal(gate(51000), true); @@ -437,7 +433,7 @@ test('a station without a published capacity re-derives it from every status pol try { f.layer.enable(); await settle(); - const { rendering } = f.display; + const rendering = f.display; const { model } = f.context.parts; const info = model.parseStationInformation({ data: { diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 716d73553..7e81ac64e 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -1,13 +1,15 @@ import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; export function createState({ services }) { - const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - bindOverlayHost(services.overlays, () => layerState._engine); + const { setEntries, setVisible, clearSource } = bindOverlayHost( + services.overlays, + () => layerState._engine, + ); const layerState = {}; layerState.DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, + setEntries: setEntries, + setVisible: setVisible, + clearSource: clearSource, }); layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; diff --git a/src/layers/bikeshare/stationPoints.js b/src/layers/bikeshare/stationPoints.js index 8f1cb936a..9c6b4632a 100644 --- a/src/layers/bikeshare/stationPoints.js +++ b/src/layers/bikeshare/stationPoints.js @@ -98,7 +98,7 @@ export function createStationPoints({ state, services, parts }, points) { // Clear selection if it belongs to the city being removed if (state._selectedKey && runtime.stationKeys.has(state._selectedKey)) - parts.selection._clearSelection(); + parts.display._clearSelection(); for (const key of runtime.stationKeys) { const record = state._stationRenderMap.get(key); @@ -159,7 +159,7 @@ export function createStationPoints({ state, services, parts }, points) { points.update?.(record); } points.afterUpdate?.(); - parts.selection.refreshSelectedCard(); + parts.display.refreshSelectedCard(); } return { ensureCityPoints, removeCityPoints, applyStatusToPoints }; diff --git a/src/layers/bikeshare/testing.js b/src/layers/bikeshare/testing.js index df95eb08d..89facfb3b 100644 --- a/src/layers/bikeshare/testing.js +++ b/src/layers/bikeshare/testing.js @@ -11,13 +11,13 @@ export function createTesting({ state: layerState, services, parts, source }) { /** Exercise the production selection path in focused runtime tests. */ function _selectBikeshareStationForTest(key) { - parts.selection._selectStation(key); + parts.display._selectStation(key); } /** Exercise the production clear path and restore the production host seam. */ function _clearBikeshareSelectionForTest() { - parts.selection._clearSelection(); + parts.display._clearSelection(); layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST; } return { diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 8d41924cc..cbbdd8c3b 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -69,7 +69,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { governorRequestRender('bikeshare-deactivate', layerState._engine); parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId); parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId); - parts.rendering?.removeCityPoints(cityId); + parts.display?.removeCityPoints(cityId); } /** Deactivate all currently active cities and clear the active set. */ @@ -109,12 +109,12 @@ export function createViewport({ state: layerState, services, parts, camera }) { ) return; - parts.rendering?.ensureCityPoints(cityId, stationMap); + parts.display?.ensureCityPoints(cityId, stationMap); // Apply any cached status immediately for snappier initial rendering const cachedStatus = layerState._statusCache.get(cityId)?.statusMap; if (cachedStatus) { - parts.rendering?.applyStatusToPoints(cityId, cachedStatus); + parts.display?.applyStatusToPoints(cityId, cachedStatus); showingLastKnown = layerState._cityRuntime.has(cityId); } @@ -128,7 +128,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { generation !== layerState._proximityGeneration ) return; - parts.rendering?.applyStatusToPoints(cityId, statusMap); + parts.display?.applyStatusToPoints(cityId, statusMap); layerState._lastUpdate = Date.now(); layerState._failedCityIds.delete(cityId); } catch (error) { diff --git a/src/layers/cctv/lifecycle.test.mjs b/src/layers/cctv/lifecycle.test.mjs index 64eac7c8e..76fba8fb9 100644 --- a/src/layers/cctv/lifecycle.test.mjs +++ b/src/layers/cctv/lifecycle.test.mjs @@ -7,10 +7,10 @@ function setup(t, { failMarkers = false } = {}) { const noop = () => {}; const services = { overlays: { - clearOverlaySource: noop, - hitTestWorldOverlay: noop, - setOverlayEntries: noop, - setOverlaySourceVisible: noop, + clearSource: noop, + hitTest: noop, + setEntries: noop, + setVisible: noop, }, activation: {}, picking: { unregisterPickOwner: noop }, diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js index fb4604750..070de946f 100644 --- a/src/layers/cctv/render/display.js +++ b/src/layers/cctv/render/display.js @@ -87,7 +87,6 @@ export function planeOutline(shape, positions, orientation) { * @param {{ state: object, services: object }} options */ export function createCctvDisplay(engine, { state, services = {} } = {}) { - if (!engine) throw new TypeError('The CCTV display needs a globe engine'); const meshes = createCctvMeshes(engine); const markers = []; const markerHandles = new WeakSet(); @@ -673,14 +672,12 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) { /** The world (tiles) along the activation probe, within `far` metres. */ obstructionRay(origin, direction, exclude, far) { syncMarkers(); - if (typeof engine.surface.raycast === 'function') - return engine.surface.raycast({ - origin, - direction, - far, - exclude: [markerSet, ...engineObjects(exclude)].filter(Boolean), - }); - return undefined; + return engine.surface.raycast({ + origin, + direction, + far, + exclude: [markerSet, ...engineObjects(exclude)].filter(Boolean), + }); }, createPlaneMaterial: (image) => meshes.createMaterial(image), createTestPlaneMaterial: () => meshes.createMaterial(null), diff --git a/src/layers/cctv/render/display.test.mjs b/src/layers/cctv/render/display.test.mjs index b3766c361..35d443d6d 100644 --- a/src/layers/cctv/render/display.test.mjs +++ b/src/layers/cctv/render/display.test.mjs @@ -63,10 +63,6 @@ const recordFor = (state, id, extra = {}) => { return record; }; -test('the display needs a globe engine', () => { - assert.throws(() => createCctvDisplay(null, { state: {} }), TypeError); -}); - for (const makeHarness of CCTV_HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index 030288549..42ee528e1 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -107,10 +107,10 @@ test('camera construction is inert and destruction cancels a pending catalog and const noop = () => {}; const services = { overlays: { - clearOverlaySource: noop, - hitTestWorldOverlay: noop, - setOverlayEntries: noop, - setOverlaySourceVisible: noop, + clearSource: noop, + hitTest: noop, + setEntries: noop, + setVisible: noop, }, activation: {}, locations: {}, diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index 0aaca1a7c..8da556b94 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -1,12 +1,7 @@ import * as geo from '../../geo/index.js'; export function createState({ services }) { - const { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, - } = services.overlays; + const { clearSource, hitTest, setEntries, setVisible } = services.overlays; const layerState = {}; // --------------------------------------------------------------------------- @@ -222,10 +217,10 @@ export function createState({ services }) { layerState._cardFetchMode = 'steady'; layerState.DEFAULT_CCTV_OVERLAY_HOST = Object.freeze({ - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, + clearSource: clearSource, + hitTest: hitTest, + setEntries: setEntries, + setVisible: setVisible, }); layerState._cctvOverlayHost = layerState.DEFAULT_CCTV_OVERLAY_HOST; diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index ee4032025..e1c7a2c14 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -1,5 +1,6 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; -import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js'; +import { worldOverlayFor } from '../../overlays/worldOverlay.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; + import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; import { isPointerFree } from '../../data/inputOwnership.js'; import { @@ -76,8 +77,8 @@ export function createCyclonesLayer({ if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Cyclones require a snapshot source'); let engine = null; - context = bindContextStore(context, () => engine); - hitTestOverlay ??= (...args) => hitTestWorldOverlay(engine, ...args); + context = bindOverlayHost(context, () => engine); + hitTestOverlay ??= (...args) => worldOverlayFor(engine)?.hitTest(...args); let rendering = null, snapshot = null, request = null, diff --git a/src/layers/cyclones/render/display.js b/src/layers/cyclones/render/display.js index ad9ef345c..d360a8d78 100644 --- a/src/layers/cyclones/render/display.js +++ b/src/layers/cyclones/render/display.js @@ -79,8 +79,6 @@ export function cycloneSphereVisible(sphere, camera) { * cards and lead-hour labels publish to (labels.js has the default). */ export function createCyclonesDisplay(engine, { overlayHost } = {}) { - if (!engine?.movers || !engine?.ground) - throw new TypeError('The cyclone display needs a globe engine'); const labels = createCycloneLabels({ engine, host: overlayHost }); // Mover set ids stay unique when a set is rebuilt larger. let setSeq = 0; diff --git a/src/layers/directions/edgeCases.test.mjs b/src/layers/directions/edgeCases.test.mjs index 04c6a8a1f..048b232b5 100644 --- a/src/layers/directions/edgeCases.test.mjs +++ b/src/layers/directions/edgeCases.test.mjs @@ -56,7 +56,7 @@ function fixture(makeHarness = createFakeGroundHarness) { cachedGroundFloor: () => 0, warmGroundFloor() {}, }, - createRenderer: createDirectionsDisplay, + rendererFactory: createDirectionsDisplay, }); layer._setOverlayHostForTest({ setEntries() {}, diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 1f3fc2be3..c35d64670 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -855,15 +855,15 @@ export function normalizeRoutePayload(payload, mode) { * shared services are injected, which is what keeps this module's own imports * down to the globe contract display and the pure step formatter. * @param {{services: object, source: {getRoute: Function}, - * createRenderer?: (engine: object) => object|null}} options - * `createRenderer` builds the display on an engine (render/display.js by + * rendererFactory?: (engine: object) => object|null}} options + * `rendererFactory` builds the display on an engine (render/display.js by * default). * @returns {object} The layer module the data manager registers. */ export function createDirectionsLayer({ services, source, - createRenderer = createDirectionsDisplay, + rendererFactory = createDirectionsDisplay, }) { if (typeof source?.getRoute !== 'function') throw new TypeError('Directions require a route source'); @@ -877,9 +877,9 @@ export function createDirectionsLayer({ overlays: bindOverlayHost(services.overlays, () => _engine), }; const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: (...args) => services.overlays.setOverlayEntries(...args), - setVisible: (...args) => services.overlays.setOverlaySourceVisible(...args), - clearSource: (...args) => services.overlays.clearOverlaySource(...args), + setEntries: (...args) => services.overlays.setEntries(...args), + setVisible: (...args) => services.overlays.setVisible(...args), + clearSource: (...args) => services.overlays.clearSource(...args), }); let _overlayHost = DEFAULT_OVERLAY_HOST; @@ -1486,7 +1486,7 @@ export function createDirectionsLayer({ function _installClickHandler() { if (_listening) return; _listening = true; - _removeClick = _display?.onClick(_onClick) ?? null; + _removeClick = _engine?.input.on('click', _onClick, { pick: true }) ?? null; if (typeof document !== 'undefined') document.addEventListener('keydown', _onKeyDown); } @@ -1521,7 +1521,7 @@ export function createDirectionsLayer({ }, attachRenderer(_scene, { engine = null } = {}) { _engine = engine; - _display = engine ? createRenderer(engine) : null; + _display = engine ? rendererFactory(engine) : null; _display?.show(_enabled); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled); if (!_enabled) return; diff --git a/src/layers/directions/render/display.js b/src/layers/directions/render/display.js index a6c7b449d..27f593578 100644 --- a/src/layers/directions/render/display.js +++ b/src/layers/directions/render/display.js @@ -83,8 +83,6 @@ function letterAtlas() { * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createDirectionsDisplay(engine) { - if (!engine?.ground || !engine?.movers || !engine?.input) - throw new TypeError('The Directions display needs a globe engine'); const markers = engine.movers.createSet({ id: DIRECTIONS_SET_IDS.markers, kind: 'point', @@ -274,10 +272,6 @@ export function createDirectionsDisplay(engine) { placeMarkers(); writeSteps(); }, - /** Listens for clicks with the engine's pick; returns the remover. */ - onClick(listener) { - return engine.input.on('click', listener, { pick: true }); - }, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs index c6bed8f46..bf6c3217f 100644 --- a/src/layers/directions/render/display.test.mjs +++ b/src/layers/directions/render/display.test.mjs @@ -162,10 +162,6 @@ function fixture(t, makeHarness, { document = true } = {}) { }; } -test('the display needs a globe engine', () => { - assert.throws(() => createDirectionsDisplay(null), TypeError); -}); - for (const makeHarness of GROUND_HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 211081f68..597942332 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -39,7 +39,7 @@ export function createDroneAirspaceLayer({ overlayHost = null, picking = null, pointer = null, - createRenderer = ({ engine }) => createDroneAirspaceDisplay(engine), + rendererFactory = ({ engine }) => createDroneAirspaceDisplay(engine), } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Drone airspace requires a snapshot source'); @@ -113,7 +113,7 @@ export function createDroneAirspaceLayer({ { sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID }, ); if (cardHit && cardHit.entryId === _selectedCardId) return; - const picked = renderer.pick(click); + const picked = click?.picked ?? null; const zoneId = picked ? pickedZoneId(picked) : null; if (zoneId) { _selectedId = zoneId; @@ -176,7 +176,7 @@ export function createDroneAirspaceLayer({ if (!initialized || renderer) throw new Error('Invalid drone airspace scene attachment'); sceneEngine = engine; - renderer = createRenderer({ engine }); + renderer = rendererFactory({ engine }); renderer.setVisible(_enabled); if (_rows.length) renderer.replace(_rows); if (_enabled) { diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index bc6c6951f..cd3247aeb 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -242,7 +242,7 @@ test('an engine switch keeps the zones and the selected card for the next globe' isOwnedByOtherLayer: () => false, }, pointer: { isPointerFree: () => true }, - createRenderer({ engine }) { + rendererFactory({ engine }) { const renderer = { engine, rows: [], diff --git a/src/layers/droneAirspace/render/display.js b/src/layers/droneAirspace/render/display.js index ebe95f6b4..3c59dda39 100644 --- a/src/layers/droneAirspace/render/display.js +++ b/src/layers/droneAirspace/render/display.js @@ -21,13 +21,10 @@ export const droneAirspacePickId = (row, index) => /** * The zones: `{ setVisible, replace(rows), onClick(listener) → remove, - * pick(click), destroy }`. A click listener gets the engine's click with its - * pick; pick(click) is that pick (null for none). + * destroy }`. A click listener receives the engine's pick. * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createDroneAirspaceDisplay(engine) { - if (!engine?.ground) - throw new TypeError('The drone airspace display needs a globe engine'); const set = engine.ground.createSet({ id: SET_ID, kind: 'polygon', @@ -73,7 +70,6 @@ export function createDroneAirspaceDisplay(engine) { removers.add(off); return off; }, - pick: (click) => click?.picked ?? null, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/layers/droneAirspace/render/display.test.mjs b/src/layers/droneAirspace/render/display.test.mjs index 31c21b618..8e12b9dce 100644 --- a/src/layers/droneAirspace/render/display.test.mjs +++ b/src/layers/droneAirspace/render/display.test.mjs @@ -90,10 +90,6 @@ const near = (actual, expected, label, tolerance = 1e-9) => `${label}: ${actual} vs ${expected}`, ); -test('the display needs a globe engine', () => { - assert.throws(() => createDroneAirspaceDisplay(null), TypeError); -}); - for (const makeHarness of GROUND_HARNESSES) { const { name } = makeHarness(); @@ -266,7 +262,5 @@ test('the display ignores calls after destroy and destroys once', () => { display.replace([row]); assert.equal(h.listeners(), 0); display.onClick(() => {})(); - assert.equal(display.pick({}), null); - assert.equal(display.pick({ picked: { id: 'x' } }).id, 'x'); h.destroy(); }); diff --git a/src/layers/earthquakes/edgeCases.test.mjs b/src/layers/earthquakes/edgeCases.test.mjs index bd71e52d1..7a9e2ef92 100644 --- a/src/layers/earthquakes/edgeCases.test.mjs +++ b/src/layers/earthquakes/edgeCases.test.mjs @@ -58,15 +58,13 @@ function layerWith(payloads) { setVisible() {}, clearSource() {}, }, - renderer: { - create: () => ({ + rendererFactory: () => ({ setVisible() {}, replace(discs) { replaced.push(discs.map((disc) => disc.stableId)); }, destroy() {}, }), - }, }); return { layer, replaced, labelSets }; } diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index 77bf0ca64..e9d28f7b2 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -17,7 +17,7 @@ export { createUsgsEarthquakeSource } from './source.js'; /** * Own one earthquake display and its refresh lifecycle. - * renderer.create({ engine }) owns drawing (render/display.js by default) + * rendererFactory({ engine }) owns drawing (render/display.js by default) * and returns: setVisible(boolean), replace(discs), destroy(). * Discs are degrees, metre radii and Rgba colours; renderers keep source * order. The layer retains observations for queries while detached. @@ -25,12 +25,12 @@ export { createUsgsEarthquakeSource } from './source.js'; export function createEarthquakesLayer({ source, overlayHost, - renderer = { create: ({ engine }) => createEarthquakesDisplay(engine) }, + rendererFactory = ({ engine }) => createEarthquakesDisplay(engine), } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Earthquakes require a snapshot source'); if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); - if (typeof renderer?.create !== 'function') + if (typeof rendererFactory !== 'function') throw new TypeError('Earthquakes require a renderer factory'); let sceneEngine = null; overlayHost = bindOverlayHost(overlayHost, () => sceneEngine); @@ -77,7 +77,7 @@ export function createEarthquakesLayer({ if (!initialized || _renderer) throw new Error('Invalid earthquake scene attachment'); sceneEngine = engine; - _renderer = renderer.create({ engine }); + _renderer = rendererFactory({ engine }); if (!_renderer) throw new Error('Earthquake renderer unavailable'); _renderer.setVisible(_enabled); if (discs.length) _renderer.replace(discs); @@ -93,7 +93,7 @@ export function createEarthquakesLayer({ sceneEngine = null; }, - enable(viewer) { + enable() { _enabled = true; // No continuous-render hold: the discs are static geometry now, so the // layer has no per-frame animator to keep the render loop alive for. @@ -104,7 +104,7 @@ export function createEarthquakesLayer({ } }, - disable(viewer) { + disable() { _request?.abort(); _request = null; _enabled = false; @@ -113,7 +113,7 @@ export function createEarthquakesLayer({ overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); }, - async update(viewer) { + async update() { if (!_enabled) return false; _request?.abort(); const request = new AbortController(); diff --git a/src/layers/earthquakes/render/display.js b/src/layers/earthquakes/render/display.js index e1be4db43..4996c107c 100644 --- a/src/layers/earthquakes/render/display.js +++ b/src/layers/earthquakes/render/display.js @@ -16,8 +16,6 @@ export const earthquakePickId = (disc) => `earthquake:${disc.stableId}`; * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createEarthquakesDisplay(engine) { - if (!engine?.ground) - throw new TypeError('The earthquakes display needs a globe engine'); const set = engine.ground.createSet({ id: SET_ID, kind: 'disc', diff --git a/src/layers/earthquakes/render/display.test.mjs b/src/layers/earthquakes/render/display.test.mjs index 8e5c9bccf..e03068a0b 100644 --- a/src/layers/earthquakes/render/display.test.mjs +++ b/src/layers/earthquakes/render/display.test.mjs @@ -83,10 +83,6 @@ const near = (actual, expected, label, tolerance = 1e-9) => `${label}: ${actual} vs ${expected}`, ); -test('the display needs a globe engine', () => { - assert.throws(() => createEarthquakesDisplay(null), TypeError); -}); - for (const makeHarness of GROUND_HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js index 50faa9fc8..62583428d 100644 --- a/src/layers/firms/cards.js +++ b/src/layers/firms/cards.js @@ -29,9 +29,9 @@ export function createCards({ function rebuildAmbientLabels() { if (!layerState._renderer) return; - const scene = layerState._viewer.scene; + const scene = layerState._view.scene; const project = (position, result) => - layerState._renderer.project(position, result); + layerState._renderer.view.worldToWindow(position, result); const now = Date.now(); const entries = []; /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ diff --git a/src/layers/firms/heatVisibility.test.mjs b/src/layers/firms/heatVisibility.test.mjs index e392431a8..0231c0496 100644 --- a/src/layers/firms/heatVisibility.test.mjs +++ b/src/layers/firms/heatVisibility.test.mjs @@ -7,8 +7,8 @@ test('reenabling an empty filtered layer never reveals the retained pre-filter h const noop = () => {}; const layerState = { _enabled: false, - _viewer: {}, - _dataSource: {}, + _view: {}, + _renderer: {}, _fires: [], _loading: true, _pickIndexById: new Map(), @@ -34,13 +34,13 @@ test('reenabling an empty filtered layer never reveals the retained pre-filter h selection: { installClickHandler: noop }, }, }); - await lifecycle.methods.enable(layerState._viewer); + await lifecycle.methods.enable(layerState._view); assert.equal( shown, false, 'empty current set must keep the old raster hidden while refresh is pending', ); layerState._fires = [{ frp: null, confidence: null }]; - await lifecycle.methods.enable(layerState._viewer); + await lifecycle.methods.enable(layerState._view); assert.equal(shown, true, 'nonempty sets still resume their raster'); }); diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 6aab8c303..6bc53bced 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; import { fireDetectionKey } from '../../data/firmsLabels.js'; import { filterFires } from '../../data/firmsIntensity.js'; @@ -11,7 +11,7 @@ export function createIngestion({ config, feed, }) { - const { clearSelectedEntityContextForLayer } = bindContextStore( + const { clearSelectedEntityContextForLayer } = bindOverlayHost( services.context, () => layerState._renderer?.engine, ); @@ -20,7 +20,7 @@ export function createIngestion({ /** Replace a validated snapshot while preserving source freshness and selection identity. */ async function loadHeatmap() { - if (!layerState._dataSource || !layerState._enabled) return; + if (!layerState._renderer || !layerState._enabled) return; layerState.request?.abort(); const request = new AbortController(); layerState.request = request; diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 2ecf24c28..1cc37487c 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; export function createLifecycle({ @@ -9,7 +9,7 @@ export function createLifecycle({ feed, }) { const { registerPickOwner, unregisterPickOwner } = services.picking; - const { clearSelectedEntityContextForLayer } = bindContextStore( + const { clearSelectedEntityContextForLayer } = bindOverlayHost( services.context, () => layerState._renderer?.engine, ); @@ -39,8 +39,7 @@ export function createLifecycle({ if (layerState._renderer || !engine) return; layerState._renderer = config.renderer.create({ engine, id, services }); if (!layerState._renderer) return; - layerState._viewer = layerState._renderer.view; - layerState._dataSource = layerState._renderer.dataSource; + layerState._view = layerState._renderer.view; layerState._renderer.setVisible(false); layerState._restoringViewport = waitForCameraRestore; if (layerState._enabled && !waitForCameraRestore) this.showRenderer(); @@ -67,7 +66,7 @@ export function createLifecycle({ components.rendering.removeDetectionCollections(); releaseHeat(); layerState._renderer?.destroy(); - layerState._renderer = layerState._viewer = layerState._dataSource = null; + layerState._renderer = layerState._view = null; layerState._pickIndexById.clear(); layerState._labelCandidates = []; layerState._camSnapValid = false; @@ -143,8 +142,8 @@ export function createLifecycle({ clearSelectedEntityContextForLayer(id); layerState._renderer?.destroy(); layerState._renderer = null; - layerState._viewer = null; - layerState._dataSource = null; + layerState._view = null; + layerState._renderer = null; layerState._allFires = []; layerState._allFiresByFrp = []; layerState._fires = []; diff --git a/src/layers/firms/render/display.js b/src/layers/firms/render/display.js index 263b80c6d..3312e0990 100644 --- a/src/layers/firms/render/display.js +++ b/src/layers/firms/render/display.js @@ -99,16 +99,12 @@ export function createFirmsDisplay( engine, { id = 'local-firms', loadImage, createCanvas } = {}, ) { - if (!engine?.movers || !engine?.ground || !engine?.imagery) - throw new TypeError('The FIRMS display needs a globe engine'); let destroyed = false, visible = false, cellItems = [], cellsQueued = false, sprites = null, heat = null; - // The layer's visibility as the context store reads a data source's. - const dataSource = { show: false }; const clickRemovers = new Set(); const cells = engine.ground.createSet({ id: `${id}:cells`, kind: 'cell' }); cells.show(false); @@ -116,9 +112,6 @@ export function createFirmsDisplay( layerId: id, isDestroyed: () => destroyed, }); - // The camera and scene events the layer's watchers listen to. - layerView.view.camera.moveEnd = engine.camera.moveEnd; - layerView.view.scene.preRender = engine.frames.preRender; // Cells arrive one addCell at a time: one commit per batch. const scheduleCells = () => { if (cellsQueued || destroyed) return; @@ -137,16 +130,11 @@ export function createFirmsDisplay( return { engine, view: layerView.view, - dataSource, - project: (position, result) => - engine.projector.worldToWindow(position, result), + visible: () => visible, viewRectangle(result) { const rect = engine.camera.viewRectangle(); return rect ? Object.assign(result ?? {}, rect) : null; }, - horizon: () => ({ - isPointVisible: (position) => engine.projector.horizonVisible(position), - }), /** * Calls `handler({ position, picked })` for every click while the layer * is shown, with the pick under it; returns the remover. @@ -171,7 +159,6 @@ export function createFirmsDisplay( const next = show !== false; if (visible === next) return; visible = next; - dataSource.show = next; sprites?.setVisible(next); cells.show(next); }, @@ -230,7 +217,6 @@ export function createFirmsDisplay( if (destroyed) return; destroyed = true; visible = false; - dataSource.show = false; cellItems = []; for (const release of [...clickRemovers]) release(); removeSprites(); diff --git a/src/layers/firms/render/display.test.mjs b/src/layers/firms/render/display.test.mjs index 3396c3e73..6eaf96fbf 100644 --- a/src/layers/firms/render/display.test.mjs +++ b/src/layers/firms/render/display.test.mjs @@ -230,7 +230,7 @@ for (const make of HARNESSES) { } r.setVisible(false); assert.equal(h.ground.listed(id).shown, false); - assert.equal(r.dataSource.show, false); + assert.equal(r.visible(), false); r.clearCells(); await settle(); assert.equal(r.cellCount(), 0); @@ -422,19 +422,19 @@ for (const make of HARNESSES) { 1e-6, ), ); - assert.equal(r.view.camera.moveEnd, h.engine.camera.moveEnd); - assert.equal(r.view.scene.preRender, h.engine.frames.preRender); + assert.equal(r.engine.camera.moveEnd, h.engine.camera.moveEnd); + assert.equal(r.engine.frames.preRender, h.engine.frames.preRender); const near = at(FIRE.lon, FIRE.lat); assert.deepEqual( - r.project(near, {}), + r.view.worldToWindow(near, {}), h.engine.projector.worldToWindow(near, {}), ); assert.deepEqual(r.viewRectangle({}), { ...h.engine.camera.viewRectangle(), }); const far = at(FIRE.lon + 180, -FIRE.lat); - assert.equal(r.horizon().isPointVisible(near), true); - assert.equal(r.horizon().isPointVisible(far), false); + assert.equal(r.view.horizon().isPointVisible(near), true); + assert.equal(r.view.horizon().isPointVisible(far), false); r.destroy(); } finally { h.destroy(); diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 383f66bb2..ab5738453 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -38,9 +38,9 @@ export function createRendering({ function renderCurrentLod(force = false) { if ( - !layerState._dataSource || + !layerState._renderer || !layerState._fires.length || - !layerState._viewer + !layerState._view ) { // A filter that leaves nothing shown must not leave the old heat behind. if (!layerState._fires.length) layerState._heatImagery?.setShow(false); @@ -295,7 +295,7 @@ export function createRendering({ // reports false, so this chain terminates instead of looping against a // down proxy (the next camera-driven rebuild retries). warmFireAnchorFloors(candidates).then((warmed) => { - if (!warmed || !layerState._enabled || !layerState._viewer) return; + if (!warmed || !layerState._enabled || !layerState._view) return; const currentLod = LOD_LEVELS[layerState._currentLodIndex]; if (!currentLod || currentLod.mode !== 'detections') return; renderCurrentLod(true); @@ -389,7 +389,7 @@ export function createRendering({ */ function fireHorizonOccluder() { - return layerState._renderer?.horizon() ?? null; + return layerState._renderer?.view.horizon() ?? null; } /** diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 32d26ca9c..da6d72c43 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { FIRMS_OVERLAY_SOURCE_ID, fireDetectionKey, @@ -22,7 +22,7 @@ export function createSelection({ getContextStore, removeEntityContext = (id) => getContextStore()?.entities.delete(id), registerEntityContext, - } = bindContextStore(services.context, () => layerState._renderer?.engine); + } = bindOverlayHost(services.context, () => layerState._renderer?.engine); const { id, name, overlayHost } = config; /** @@ -191,7 +191,7 @@ export function createSelection({ layerId: id, layerName: name, source: 'NASA FIRMS', - dataSource: layerState._dataSource, + isVisible: () => layerState._renderer?.visible() ?? false, // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 1dcdc66d0..703e8daa0 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -16,9 +16,9 @@ export function createFirmsState({ services, config }) { layerState.scratchWindowCoord = geo.cartesian2.create(); - layerState._viewer = null; + layerState._view = null; - layerState._dataSource = null; + layerState._renderer = null; layerState._renderer = null; diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 343f2ed8a..60b6985c3 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -31,9 +31,9 @@ export function createViewport({ */ function installMoveEndWatcher() { - if (layerState._moveEndRemover || !layerState._viewer) return; + if (layerState._moveEndRemover || !layerState._view) return; layerState._moveEndRemover = - layerState._viewer.camera.moveEnd.addEventListener(() => { + layerState._renderer.engine.camera.moveEnd.addEventListener(() => { if (!layerState._enabled) return; // Settled-camera exactness for the horizon pass (the throttled preRender // watcher can last have run up to LOD_CHECK_MS before the camera @@ -54,7 +54,7 @@ export function createViewport({ function cameraHeight() { return ( - layerState._viewer?.camera?.positionCartographic?.height ?? + layerState._view?.camera?.positionCartographic?.height ?? Number.POSITIVE_INFINITY ); } @@ -138,13 +138,13 @@ export function createViewport({ } function installLodWatcher() { - if (layerState._preRenderRemover || !layerState._viewer) return; + if (layerState._preRenderRemover || !layerState._view) return; // The LOD sweep is throttle-gated inside preRender. When the camera // settles just inside the throttle window, the final rebuild would wait // for a frame that idle mode never produces — schedule one. (perf wave 2) if (!layerState._moveEndSettleRemover) { layerState._moveEndSettleRemover = - layerState._viewer.camera.moveEnd.addEventListener(() => { + layerState._renderer.engine.camera.moveEnd.addEventListener(() => { if (!layerState._enabled) return; clearSettleTimer(); settleTimer = setTimeout(() => { @@ -158,7 +158,7 @@ export function createViewport({ }); } layerState._preRenderRemover = - layerState._viewer.scene.preRender.addEventListener(() => { + layerState._renderer.engine.frames.preRender.addEventListener(() => { // NOTE: `_loading`/empty-data are deliberately NOT part of this guard. // A refresh has no timeout, and sprites from the previous payload stay // on screen throughout it — so the horizon pass below must keep running @@ -172,7 +172,7 @@ export function createViewport({ layerState._lastLodCheck = now; // Idle camera: two scratch compares and out — no view-rect math, no // aggregation, no allocation while the user is parked. - const camera = layerState._viewer.camera; + const camera = layerState._view.camera; if ( layerState._camSnapValid && geo.cartesian3.equalsEpsilon( diff --git a/src/layers/firms/viewport.test.mjs b/src/layers/firms/viewport.test.mjs index 921917f31..152e3cf20 100644 --- a/src/layers/firms/viewport.test.mjs +++ b/src/layers/firms/viewport.test.mjs @@ -25,7 +25,8 @@ function harness() { const layerState = { _enabled: true, _destroyed: false, - _viewer: { camera: { moveEnd }, scene: { preRender } }, + _view: { camera: {} }, + _renderer: { engine: { camera: { moveEnd }, frames: { preRender } } }, }; const viewport = createViewport({ layerState, diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 08a6ba597..a1dbf5d8a 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -932,25 +932,15 @@ export function createQueries({ const view = flightState._renderer?.view; if (!view || !Number.isFinite(startMs)) return { ok: false, nowMs: null }; - view.holdFrames(); setFocusEvidenceNowMs(startMs); flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS; - // Cross a browser task boundary, then close the renderer's pending-loop - // latch. Any already-queued callback still observes the false gate, - // while manual evidence renders can begin without waiting on VSYNC. - return new Promise((resolve) => - setTimeout(() => { - flightState._renderer?.view.closeFrameLatch(); - resolve({ ok: true, nowMs: startMs }); - }, 0), - ); + return { ok: true, nowMs: startMs }; }, advanceFrameClock(deltaMs = FLEET_DR_INTERVAL_MS) { return advanceFocusEvidenceNowMs(deltaMs); }, releaseFrameClock() { setFocusEvidenceNowMs(null); - flightState._renderer?.view.releaseFrames(); }, }), } diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 7aa52ea0e..9e2872778 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -279,7 +279,7 @@ export function createRendering({ const meta = flightState.records.data.get(icao24); if (!meta || !meta.onGround) return pos; const h = flightState._groundSnap.heightFor( - flightState._renderer?.view.surfaceOwner, + flightState._renderer?.view.engine, icao24, pos, _groundSampleExclusions, diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index f7bffaf3f..e5b011f54 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -328,22 +328,15 @@ export function createFlightSnapshotRenderer({ // cell, budget-capped, viewer-proximate, google-3d regime only). Own // billboards/models are excluded so a vertical probe can't land on an // aircraft instead of the pavement. - sampleMeshFloorCells( - flightState._renderer?.view.surfaceScene, - floorWarmPoints, - { - excludeObjects: - flightState._renderer?.surfaceExclusions({ - sprites: flightState._billboards.values(), - models: [ - ...flightState._models.values(), - flightState._trackedModel, - ], - }) ?? [], - viewerLat: viewerLatDeg, - viewerLon: viewerLonDeg, - }, - ); + sampleMeshFloorCells(flightState._renderer?.view.engine, floorWarmPoints, { + excludeObjects: + flightState._renderer?.surfaceExclusions({ + sprites: flightState._billboards.values(), + models: [...flightState._models.values(), flightState._trackedModel], + }) ?? [], + viewerLat: viewerLatDeg, + viewerLon: viewerLonDeg, + }); // Round 5: the grounded exact-key warm that used to live here is gone — // see the note where _warmGroundedAircraftSurfaceCache was removed. The diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 754f7768b..1aa98f9a7 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -543,7 +543,7 @@ export function createGhostMapsLayer({ typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), sceneEngine, ); - _clickHandler = _display.onClick((click, pick) => { + _clickHandler = _display.onClick((click) => { if (pointer && !pointer.isPointerFree()) return; const cardHit = overlayHost.hitTest?.( click.position?.x, @@ -554,7 +554,7 @@ export function createGhostMapsLayer({ if (_selectedLink && openExternal) openExternal(_selectedLink); return; } - const picked = pick(); + const picked = click.picked; const key = picked ? pickedKey(picked) : null; if (key) { _selectedKey = key; diff --git a/src/layers/ghostMaps/render/display.js b/src/layers/ghostMaps/render/display.js index 4d06e522b..ef93c924e 100644 --- a/src/layers/ghostMaps/render/display.js +++ b/src/layers/ghostMaps/render/display.js @@ -195,8 +195,6 @@ export function createGhostMapsDisplay( createCanvas = () => document.createElement('canvas'), } = {}, ) { - if (!engine?.ground || !engine?.movers) - throw new TypeError('The GhostMaps display needs a globe engine'); const paints = new Set(); let sequence = 0; let destroyed = false; @@ -296,7 +294,7 @@ export function createGhostMapsDisplay( return { requestRender: request, onClick: (run) => - engine.input.on('click', (event) => run(event, () => event.picked), { + engine.input.on('click', run, { pick: true, }), draw(productId, features, color, markerImage) { diff --git a/src/layers/ghostMaps/render/display.test.mjs b/src/layers/ghostMaps/render/display.test.mjs index e1ce42d0d..29915b005 100644 --- a/src/layers/ghostMaps/render/display.test.mjs +++ b/src/layers/ghostMaps/render/display.test.mjs @@ -351,8 +351,8 @@ for (const makeHarness of HARNESSES) { const harness = makeHarness(); const { display, polygons, pins } = await drawn(harness); const seen = []; - const remove = display.onClick((click, pick) => - seen.push({ position: click.position, picked: pick() }), + const remove = display.onClick((click) => + seen.push({ position: click.position, picked: click.picked }), ); harness.click({ ground: true, diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index cb0f90944..66507a9f7 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -29,9 +29,7 @@ const LEVEL_LABELS = Object.freeze({ */ export function createGnssIntegrityLayer({ source, - viewAnchor, - createRenderer = ({ engine, viewAnchor: anchor }) => - createGnssDisplay(engine, { viewAnchor: anchor }), + rendererFactory = ({ engine }) => createGnssDisplay(engine), now = () => Date.now(), monotonicNow = () => performance.now(), windowMs: requestedWindowMs, @@ -171,7 +169,7 @@ export function createGnssIntegrityLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid GNSS scene attachment'); - renderer = createRenderer({ engine, viewAnchor }); + renderer = rendererFactory({ engine }); _removeCameraEnd = renderer.camera?.moveEnd?.addEventListener( scheduleCameraRefresh, ); diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index a03412f5f..551edd507 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -1,3 +1,4 @@ +import { createGnssDisplay } from './render/display.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import * as geo from '../../geo/index.js'; @@ -39,10 +40,13 @@ function harness( return { ageMs: 0, ...snapshot }; }, }, - viewAnchor: () => { - anchors.push(anchor); - return anchor; - }, + rendererFactory: ({ engine }) => + createGnssDisplay(engine, { + viewAnchor: () => { + anchors.push(anchor); + return anchor; + }, + }), now: () => clock, monotonicNow: () => clock, }); @@ -650,7 +654,8 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async ( return { rows: [], ageMs: 0 }; }, }, - viewAnchor: () => anchor, + rendererFactory: ({ engine }) => + createGnssDisplay(engine, { viewAnchor: () => anchor }), now: () => wall, monotonicNow: () => mono, }); diff --git a/src/layers/gnss/render/display.js b/src/layers/gnss/render/display.js index 5fc1e1040..9f1246ee7 100644 --- a/src/layers/gnss/render/display.js +++ b/src/layers/gnss/render/display.js @@ -54,8 +54,6 @@ export function createGnssDisplay( engine, { viewAnchor = defaultGnssViewAnchor } = {}, ) { - if (!engine?.ground) - throw new TypeError('The GNSS display needs a globe engine'); const set = engine.ground.createSet({ id: SET_ID, kind: 'cell' }); set.show(false); let destroyed = false; diff --git a/src/layers/gnss/render/display.test.mjs b/src/layers/gnss/render/display.test.mjs index 3fb29dad9..7772194b5 100644 --- a/src/layers/gnss/render/display.test.mjs +++ b/src/layers/gnss/render/display.test.mjs @@ -47,10 +47,6 @@ const near = (actual, expected, label, tolerance = 1e-9) => `${label}: ${actual} vs ${expected}`, ); -test('the display needs a globe engine', () => { - assert.throws(() => createGnssDisplay(null), TypeError); -}); - for (const makeHarness of GROUND_HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/gnss/sourceErrorLocale.test.mjs b/src/layers/gnss/sourceErrorLocale.test.mjs index 03a715e3d..a5186b8dd 100644 --- a/src/layers/gnss/sourceErrorLocale.test.mjs +++ b/src/layers/gnss/sourceErrorLocale.test.mjs @@ -1,3 +1,4 @@ +import { createGnssDisplay } from './render/display.js'; // The GNSS source's own failures are English data (source.js); the // card's info line shows them in the UI language, the HTTP form included. import assert from 'node:assert/strict'; @@ -16,7 +17,7 @@ async function failedLayer(message) { }); }, }, - viewAnchor: () => ({ latitude: 50, longitude: 30 }), + rendererFactory: ({ engine }) => createGnssDisplay(engine, { viewAnchor: () => ({ latitude: 50, longitude: 30 }) }), now: () => 1_000, monotonicNow: () => 1_000, }); diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js index bef0d9563..a8a623895 100644 --- a/src/layers/infrastructure/render/display.js +++ b/src/layers/infrastructure/render/display.js @@ -101,8 +101,6 @@ export function createInfrastructureDisplay( ...services, ...(services.context?.forEngine?.(engine) ?? services.context), }; - if (!engine?.movers || !engine?.lines || !engine?.ground) - throw new TypeError('The infrastructure display needs a globe engine'); const { id, name, diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index 76590957a..9a48db619 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -49,7 +49,7 @@ export function createControls({ state: layerState, services, parts, source }) { statsRefreshInterval: 1000, visitNamedMarkers(visitor) { - parts.namedMarkers?.visit(visitor); + parts.display?.namedMarkers?.visit(visitor); }, /** Request a one-shot Google Maps Places search around the current map view. */ @@ -179,7 +179,7 @@ export function createControls({ state: layerState, services, parts, source }) { if (!record || (!flyToSphere && !flyToBoundingSphere)) return false; // No camera flight without a real selection: a flight plus a stale subject // reads as success to Context navigation and strands NEXT on this item. - if (!parts.selection.selectRecord(record.id)) return false; + if (!parts.display.selectRecord(record.id)) return false; const sphere = { center: geo.cartesian3.fromDegrees( record.longitude, @@ -226,7 +226,7 @@ export function createControls({ state: layerState, services, parts, source }) { }, getStats() { - const namedMarkers = parts.namedMarkers?.stats(); + const namedMarkers = parts.display?.namedMarkers?.stats(); const subjectWindow = layerState.coverage?.kind === 'subject' && layerState.contextPosition && diff --git a/src/layers/installations/controls.test.mjs b/src/layers/installations/controls.test.mjs index 443c1b87b..246470e8a 100644 --- a/src/layers/installations/controls.test.mjs +++ b/src/layers/installations/controls.test.mjs @@ -20,7 +20,7 @@ test('focus frames a selected installation through the globe engine camera', () state, services: {}, parts: { - selection: { selectRecord: (id) => id === record.id }, + display: { selectRecord: (id) => id === record.id }, model: { installationSurfaceHeightM: () => 126 }, }, source: {}, diff --git a/src/layers/installations/index.js b/src/layers/installations/index.js index a21e3111b..236c5e017 100644 --- a/src/layers/installations/index.js +++ b/src/layers/installations/index.js @@ -35,14 +35,13 @@ export function createInstallationsLayer({ if (renderer) throw new Error('Installations layer is already initialized'); const engine = options.engine ?? null; - renderer = rendererFactory({ ...context, viewer, engine }); - if (!renderer?.lifecycle?.methods) - throw new Error('Installation renderer is unavailable'); - Object.assign(parts, renderer); - const ready = renderer.lifecycle.methods.init(viewer, options); + renderer = rendererFactory({ ...context, engine }); + if (!renderer) throw new Error('Installation renderer is unavailable'); + parts.display = renderer; + const ready = renderer.init(); if (state.enabled) { - renderer.lifecycle.methods.enable(); - renderer.rendering.renderRecords({ + renderer.enable(); + renderer.renderRecords({ claimSelection: Boolean(state.selectedId), }); } @@ -50,10 +49,10 @@ export function createInstallationsLayer({ }, enable(...args) { state.enabled = true; - return renderer?.lifecycle?.methods.enable(...args); + return renderer.enable(...args); }, disable(...args) { - if (renderer) return renderer.lifecycle.methods.disable(...args); + if (renderer) return renderer.disable(...args); state.enabled = false; state.abort?.abort(); state.abort = null; @@ -71,14 +70,13 @@ export function createInstallationsLayer({ ); parts.ingestion.setInstallationStatus('idle'); }, - detachRenderer(viewer) { - renderer?.lifecycle.methods.detach(viewer); + detachRenderer() { + renderer.detach(); renderer = null; - for (const key of ['rendering', 'namedMarkers', 'selection', 'lifecycle']) - delete parts[key]; + parts.display = null; }, destroy(...args) { - if (renderer) renderer.lifecycle.methods.destroy(...args); + if (renderer) renderer.destroy(...args); else { lifecycle.disable(); source.destroy?.(); diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 3232df359..653346784 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -260,8 +260,8 @@ export function createIngestion({ ? msg('Too many mapped sites in view to list them all') : placesError, ); - parts.rendering?.renderRecords(); - parts.rendering?.warmInstallationFloors( + parts.display?.renderRecords(); + parts.display?.warmInstallationFloors( layerState.records.filter((r) => !r.pointOnly), ); }; diff --git a/src/layers/installations/lifecycle.test.mjs b/src/layers/installations/lifecycle.test.mjs index 0045bc354..ac60e8e20 100644 --- a/src/layers/installations/lifecycle.test.mjs +++ b/src/layers/installations/lifecycle.test.mjs @@ -23,7 +23,7 @@ for (const source of [ : [], ); h.state.recordById = new Map(); - const lifecycle = h.parts.lifecycle.methods; + const lifecycle = h.parts.display; lifecycle.init(); const outlinesShown = () => [0, 1].every((n) => h.set(`${OUTLINE_SET_ID}-${n}`).shown); diff --git a/src/layers/installations/namedMarkers.test.mjs b/src/layers/installations/namedMarkers.test.mjs index 9d9e4ddc7..4c99e89f0 100644 --- a/src/layers/installations/namedMarkers.test.mjs +++ b/src/layers/installations/namedMarkers.test.mjs @@ -35,7 +35,7 @@ function setup(t) { return { state: h.state, engine, base, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, - projections, records, markers: h.parts.namedMarkers, lifecycle: h.parts.lifecycle.methods, + projections, records, markers: h.parts.display?.namedMarkers, lifecycle: h.parts.display, setX: (value) => { x = value; }, /** A pose change too small for a camera event. */ nudge() { height += 1; engine.fake.setPose({ height }); }, diff --git a/src/layers/installations/namesLoading.test.mjs b/src/layers/installations/namesLoading.test.mjs index 66a5b9148..abac4ebb6 100644 --- a/src/layers/installations/namesLoading.test.mjs +++ b/src/layers/installations/namesLoading.test.mjs @@ -83,7 +83,7 @@ for (const stale of [false, true]) scheduleUnavailableRetry() {}, }, model: { installationWithinViewport: () => true }, - rendering: { + display: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {}, }, diff --git a/src/layers/installations/navigation.test.mjs b/src/layers/installations/navigation.test.mjs index b903e86f5..0603c8f90 100644 --- a/src/layers/installations/navigation.test.mjs +++ b/src/layers/installations/navigation.test.mjs @@ -36,7 +36,7 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and services: h.services, source: {}, }); - const life = parts.lifecycle.methods; + const life = parts.display; life.init(); life.enable(); assert.equal( diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js index de2723542..ce2750008 100644 --- a/src/layers/installations/render/display.js +++ b/src/layers/installations/render/display.js @@ -151,8 +151,6 @@ export function createInstallationsDisplay( engine, { state, services, parts, source, overlayHost }, ) { - if (!engine?.ground) - throw new TypeError('The installations display needs a globe engine'); const { governorRequestRender } = services.render; const { clearSelectedEntityContextForLayer, @@ -693,21 +691,17 @@ export function createInstallationsDisplay( }; return { - lifecycle: { methods }, + ...methods, namedMarkers, - rendering: { - installationSurfaceHeightM: parts.model.installationSurfaceHeightM, - clearSelectionFill, - syncSelectionFill, - clearRendered, - renderableRecords, - renderRecords, - warmInstallationFloors, - }, - selection: { - selectRecord, - installInteraction, - destroy: destroyInteraction, - }, + installationSurfaceHeightM: parts.model.installationSurfaceHeightM, + clearSelectionFill, + syncSelectionFill, + clearRendered, + renderableRecords, + renderRecords, + warmInstallationFloors, + selectRecord, + installInteraction, + destroyInteraction, }; } diff --git a/src/layers/installations/render/display.test.mjs b/src/layers/installations/render/display.test.mjs index 57cac750c..5d90f0af6 100644 --- a/src/layers/installations/render/display.test.mjs +++ b/src/layers/installations/render/display.test.mjs @@ -95,7 +95,7 @@ for (const makeHarness of GROUND_HARNESSES) { test(`sites are 9 px discs in their class colour, 13 px and white when selected, always on top (${name})`, (t) => { const h = setup(t, makeHarness); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); const [disc] = h.markers(MARKER_SET_ID); assert.equal(h.markers(MARKER_SET_ID).length, 1); assert.equal(disc.pickId, 'osm:way:1', 'a pick names the site'); @@ -129,7 +129,7 @@ for (const makeHarness of GROUND_HARNESSES) { assert.ok(selected.order > options.order, 'over the other discs'); } - assert.equal(h.parts.selection.selectRecord('osm:way:1'), true); + assert.equal(h.parts.display.selectRecord('osm:way:1'), true); assert.equal(h.markers(MARKER_SET_ID).length, 0); const [chosen] = h.markers(SELECTED_MARKER_SET_ID); assert.equal(chosen.pickId, 'osm:way:1'); @@ -140,7 +140,7 @@ for (const makeHarness of GROUND_HARNESSES) { test(`a named area draws no disc but its outlines are drawn, as on Cesium (${name})`, (t) => { const named = namedRange(); const h = setup(t, makeHarness, [airfield(), named]); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); assert.deepEqual( h.markers(MARKER_SET_ID).map((disc) => disc.pickId), ['osm:way:1'], @@ -160,7 +160,7 @@ for (const makeHarness of GROUND_HARNESSES) { ); assert.equal(namedDiscs[0].show, true); // Selected, it still draws no site disc, and its outlines stay. - h.parts.selection.selectRecord(named.id); + h.parts.display.selectRecord(named.id); assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0); assert.equal( drawnOutlines(h).items.filter((item) => item.pickId === named.id).length, @@ -170,7 +170,7 @@ for (const makeHarness of GROUND_HARNESSES) { test(`outlines are 2 px ground lines at 0.85 on both surfaces, rebuilt only for a changed site (${name})`, (t) => { const h = setup(t, makeHarness); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); const set = drawnOutlines(h); assert.equal(set.options.kind, 'corridor'); assert.equal(set.options.drape, 'both'); @@ -195,7 +195,7 @@ for (const makeHarness of GROUND_HARNESSES) { }; if (h.cesium) built(1); const commits = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits); - for (let i = 0; i < 5; i++) h.parts.rendering.renderRecords(); + for (let i = 0; i < 5; i++) h.parts.display.renderRecords(); assert.deepEqual( [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits), commits, @@ -208,7 +208,7 @@ for (const makeHarness of GROUND_HARNESSES) { airfield(), { ...airfield(), id: 'osm:way:3', latitude: 30.2 }, ]); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); const counts = () => [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).items.length); if (h.cesium) { @@ -226,13 +226,13 @@ for (const makeHarness of GROUND_HARNESSES) { const named = namedRange(); const h = setup(t, makeHarness, [named]); h.setPhotoreal(true); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); assert.equal( h.set(SELECTION_FILL_SET_ID), undefined, 'no fill before a selection', ); - h.parts.selection.selectRecord(named.id); + h.parts.display.selectRecord(named.id); const fill = h.set(SELECTION_FILL_SET_ID); assert.equal(fill.options.drape, 'tiles'); assert.equal(fill.options.kind, 'polygon'); @@ -253,7 +253,7 @@ for (const makeHarness of GROUND_HARNESSES) { ); } const commits = fill.commits; - for (let i = 0; i < 10; i++) h.parts.rendering.renderRecords(); + for (let i = 0; i < 10; i++) h.parts.display.renderRecords(); assert.equal( h.set(SELECTION_FILL_SET_ID).commits, commits, @@ -266,15 +266,15 @@ for (const makeHarness of GROUND_HARNESSES) { assert.equal(h.set(SELECTION_FILL_SET_ID).options.drape, 'both'); assert.equal(h.set(SELECTION_FILL_SET_ID).items.length, 1); - h.parts.lifecycle.methods.disable(); + h.parts.display.disable(); assert.equal(h.set(SELECTION_FILL_SET_ID).items.length, 0); assert.equal( h.mapStackRemoved(), 1, 'disable drops the map-stack listener', ); - h.parts.lifecycle.methods.enable(); - h.parts.rendering.syncSelectionFill(); + h.parts.display.enable(); + h.parts.display.syncSelectionFill(); assert.equal( h.set(SELECTION_FILL_SET_ID).items.length, 0, @@ -284,7 +284,7 @@ for (const makeHarness of GROUND_HARNESSES) { test(`a click on a disc, an outline or the fill selects the site; the next click releases it (${name})`, (t) => { const h = setup(t, makeHarness); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); h.click({ id: 'osm:way:1' }); assert.equal(h.state.selectedId, 'osm:way:1'); assert.equal(context.getSelectedEntityContext()?.id, 'osm:way:1'); @@ -304,7 +304,7 @@ for (const makeHarness of GROUND_HARNESSES) { ); h.click({ id: 'aircraft:1' }); assert.equal(h.state.selectedId, null, 'another contact releases it'); - h.parts.selection.selectRecord('osm:way:1'); + h.parts.display.selectRecord('osm:way:1'); h.click({ set: SELECTION_FILL_SET_ID, instance: 0 }); assert.equal( h.state.selectedId, @@ -323,7 +323,7 @@ for (const makeHarness of GROUND_HARNESSES) { } finally { releasePointer(lease); } - h.parts.lifecycle.methods.disable(); + h.parts.display.disable(); h.click({ id: 'osm:way:1' }); assert.equal(h.state.selectedId, null, 'a disabled layer ignores clicks'); }); @@ -334,7 +334,7 @@ for (const makeHarness of GROUND_HARNESSES) { h.floors.set(`${record.latitude},${record.longitude}`, 125); try { setLocale('ru'); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); const handle = context.getContextStore().entities.get(record.id).entity; assert.equal(handle.gevTrackedId, `installations:${record.id}`); assert.deepEqual(handle.gevLabelModel, { @@ -355,14 +355,14 @@ for (const makeHarness of GROUND_HARNESSES) { assert.equal(entry.layerName, 'Mapped Military Installations'); assert.equal(entry.source, 'OpenStreetMap'); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); assert.equal( context.getContextStore().entities.get(record.id).entity, handle, 'a repaint with the same geometry keeps the handle', ); h.setRecords([{ ...record, latitude: record.latitude + 0.001 }]); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); assert.notEqual( context.getContextStore().entities.get(record.id).entity, handle, @@ -378,7 +378,7 @@ for (const makeHarness of GROUND_HARNESSES) { warmFloors: () => new Promise((resolve) => (warm = resolve)), }); const record = airfield(); - h.parts.rendering.renderRecords(); + h.parts.display.renderRecords(); const heightOf = () => geo.cartographic.fromCartesian(h.markers(MARKER_SET_ID)[0].position) .height; @@ -386,7 +386,7 @@ for (const makeHarness of GROUND_HARNESSES) { Math.abs(heightOf()) < 0.01, 'a cold floor sits on the ellipsoid', ); - h.parts.rendering.warmInstallationFloors([record]); + h.parts.display.warmInstallationFloors([record]); h.floors.set(`${record.latitude},${record.longitude}`, 2400); warm(true); await new Promise((resolve) => setImmediate(resolve)); @@ -399,10 +399,10 @@ for (const makeHarness of GROUND_HARNESSES) { test(`teardown removes every set and listener (${name})`, (t) => { const h = setup(t, makeHarness, [airfield(), namedRange()]); const changed = h.engine.camera.changed.numberOfListeners; - h.parts.rendering.renderRecords(); - h.parts.selection.selectRecord('osm:way:1'); + h.parts.display.renderRecords(); + h.parts.display.selectRecord('osm:way:1'); assert.ok(h.listeners() > 0); - h.parts.lifecycle.methods.detach(); + h.parts.display.detach(); assert.equal(h.listeners(), 0, 'no click listener'); assert.equal( h.engine.camera.changed.numberOfListeners, diff --git a/src/layers/installations/render/displayHarness.mjs b/src/layers/installations/render/displayHarness.mjs index dd6de90db..c13c396bb 100644 --- a/src/layers/installations/render/displayHarness.mjs +++ b/src/layers/installations/render/displayHarness.mjs @@ -95,8 +95,8 @@ export function createDisplayHarness( clearUnavailableRetry() {}, }; parts.ingestion = { setInstallationStatus() {} }; - Object.assign(parts, displayFactory(h.engine, ctx)); - const methods = parts.lifecycle.methods; + parts.display = displayFactory(h.engine, ctx); + const methods = parts.display; return { ...h, state, diff --git a/src/layers/installations/selection.test.mjs b/src/layers/installations/selection.test.mjs index 8f016e298..96c64238e 100644 --- a/src/layers/installations/selection.test.mjs +++ b/src/layers/installations/selection.test.mjs @@ -41,7 +41,7 @@ function harness(t, makeHarness = GROUND_HARNESSES[0]) { h.setRecords([record]); h.setPhotoreal(true); h.start(); - const selection = h.parts.selection; + const selection = h.parts.display; t.after(() => { h.destroy(); if (previousWindow === undefined) delete globalThis.window; @@ -93,7 +93,7 @@ test('entity selection and clear still release the installation card and fill', test('destroy removes all installation context listeners', (t) => { const h = harness(t); - h.selection.destroy(); + h.selection.destroyInteraction(); context.selectTrackedSubjectContext({ id: 'other', layerId: 'flights' }); for (const type of [ 'gev:entity-selected', diff --git a/src/layers/installations/selectionFill.test.mjs b/src/layers/installations/selectionFill.test.mjs index 7058f617f..a5e417d32 100644 --- a/src/layers/installations/selectionFill.test.mjs +++ b/src/layers/installations/selectionFill.test.mjs @@ -28,7 +28,7 @@ function harness(t, makeHarness) { h.state.selectedId = 'base'; // Photoreal tiles shown: the globe is hidden. h.setPhotoreal(true); - h.parts.lifecycle.methods.init(); + h.parts.display.init(); t.after(() => { h.destroy(); if (previousWindow === undefined) delete globalThis.window; @@ -38,8 +38,8 @@ function harness(t, makeHarness) { return { h, record, - render: h.parts.rendering, - lifecycle: h.parts.lifecycle.methods, + render: h.parts.display, + lifecycle: h.parts.display, fill, }; } diff --git a/src/layers/launches/edgeCases.test.mjs b/src/layers/launches/edgeCases.test.mjs index 20ecb64b2..8c4e9e995 100644 --- a/src/layers/launches/edgeCases.test.mjs +++ b/src/layers/launches/edgeCases.test.mjs @@ -38,9 +38,9 @@ function rig({ tle = async () => '1 25544U' } = {}) { const state = createState({ services: { overlays: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, }, }); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 189228369..707aa8ef0 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -85,9 +85,9 @@ async function fixture(t) { const services = { overlays: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, render: { holdContinuousRender: noop, releaseContinuousRender: noop }, renderer: { create: ({ engine }) => createLaunchesDisplay(engine) }, diff --git a/src/layers/launches/ingestion.test.mjs b/src/layers/launches/ingestion.test.mjs index 87e8ef43a..bd8e3cf38 100644 --- a/src/layers/launches/ingestion.test.mjs +++ b/src/layers/launches/ingestion.test.mjs @@ -60,9 +60,9 @@ async function fixture({ legacySource = false } = {}) { : launchSource; const services = { overlays: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, render: { holdContinuousRender: noop, releaseContinuousRender: noop }, }; diff --git a/src/layers/launches/render/display.js b/src/layers/launches/render/display.js index 3bb236d8f..a91ae4c31 100644 --- a/src/layers/launches/render/display.js +++ b/src/layers/launches/render/display.js @@ -148,8 +148,6 @@ export function createLaunchHover({ * @param {{ layerId?: string }} [options] Names the mover sets and lines. */ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) { - if (!engine?.movers || !engine?.lines) - throw new TypeError('The launches display needs a globe engine'); let destroyed = false; let visible = false; let lineSeq = 0; diff --git a/src/layers/launches/render/display.test.mjs b/src/layers/launches/render/display.test.mjs index 130339f4b..3ce5394ef 100644 --- a/src/layers/launches/render/display.test.mjs +++ b/src/layers/launches/render/display.test.mjs @@ -79,10 +79,6 @@ const ORBIT = [ geo.cartesian3.fromDegrees(-60, 31, 400_000), ]; -test('the display needs a globe engine', () => { - assert.throws(() => createLaunchesDisplay(null), TypeError); -}); - for (const makeHarness of HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/launches/replay.test.mjs b/src/layers/launches/replay.test.mjs index b0700d0f3..ded0a6a0f 100644 --- a/src/layers/launches/replay.test.mjs +++ b/src/layers/launches/replay.test.mjs @@ -10,9 +10,9 @@ function replayRig() { const state = createState({ services: { overlays: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, }, }); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 11d2fc306..58b146428 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -2,14 +2,16 @@ import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; export function createState({ services }) { - const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - bindOverlayHost(services.overlays, () => state._engine); + const { setEntries, setVisible, clearSource } = bindOverlayHost( + services.overlays, + () => state._engine, + ); const state = {}; state.DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, + setEntries: setEntries, + setVisible: setVisible, + clearSource: clearSource, }); // The layer's records of what it draws, by pick id (rendering.js). diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 2c595c45b..df7a20617 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; @@ -109,7 +109,7 @@ export function createLocalAdsbLayer({ clearSelectedEntityContextForLayer, getSelectedEntityContext, removeEntityContextsForLayer, - } = bindContextStore(services.context, () => renderer?.engine); + } = bindOverlayHost(services.context, () => renderer?.engine); const { registerPickOwner, unregisterPickOwner } = services.picking; /** The engine's Local ADS-B display for this lifecycle (render/display.js). */ diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 24234fe56..c41dc6b0c 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -156,7 +156,7 @@ export function createLocalAdsbModels({ // The vertical sample must hit the tile skin, not this aircraft's own // billboard or model. const height = groundSnap?.heightFor?.( - view.surfaceOwner, + view.engine, id, marker.position, () => [marker.entity, model].filter(Boolean), diff --git a/src/layers/localAdsb/qaDrawn.js b/src/layers/localAdsb/qaDrawn.js index 2f2feb7ee..d3806adec 100644 --- a/src/layers/localAdsb/qaDrawn.js +++ b/src/layers/localAdsb/qaDrawn.js @@ -1,5 +1,6 @@ -// On-demand QA snapshots of the cached local renderer state. Never read -// an entity CallbackProperty: that would evaluate rendering during a probe. +import { LOCAL_ADSB_DISPLAY_DISTANCE_M } from './render/display.js'; + +// On-demand QA snapshots of the cached local renderer state. export function localAdsbQaDrawn( markers, models, @@ -20,7 +21,7 @@ export function localAdsbQaDrawn( enabled && !marker.modelOwnsVisual && isVisible(marker) && - distance <= 2_000_000, + distance <= LOCAL_ADSB_DISPLAY_DISTANCE_M, ); const model = models?.get(marker.id); const matrix = model?.modelMatrix; diff --git a/src/layers/localAdsb/render/display.js b/src/layers/localAdsb/render/display.js index 63356b84f..0c271990a 100644 --- a/src/layers/localAdsb/render/display.js +++ b/src/layers/localAdsb/render/display.js @@ -7,7 +7,7 @@ // // The display: // view camera, scene, horizon(), frustumTester(), -// surfaceOwner, requestRender(), on('preRender', fn) +// engine, requestRender(), on('preRender', fn) // attach(), setVisible(bool), detach() // markers.add({ id, image, position(), rotation(), show() }) → handle // (the context and readout annotations go on it), remove(handle), diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 91288e6aa..8c937dce9 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -295,7 +295,7 @@ export function createRendering({ const meta = flightState.records.data.get(icao24); if (!meta || !meta.onGround) return pos; const h = flightState._groundSnap.heightFor( - flightState._renderer?.view.surfaceOwner, + flightState._renderer?.view.engine, icao24, pos, _groundSampleExclusions, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 56f6cb119..738374c45 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -258,7 +258,7 @@ export function createMilitarySnapshotRenderer({ const viewerCarto = flightState._renderer?.view.camera?.positionCartographic || null; sampleMeshFloorCells( - flightState._renderer?.view.surfaceScene, + flightState._renderer?.view.engine, _floorWarmPoints, { excludeObjects: diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 430e27e1d..20c568447 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -221,7 +221,7 @@ export function createFirePerimetersLayer({ if (_selectedLink && openExternal) openExternal(_selectedLink); return; } - const picked = renderer.pick(click); + const picked = click?.picked ?? null; const incidentId = picked ? pickedIncidentId(picked) : null; if (incidentId) { abortLinkVerification(); diff --git a/src/layers/perimeters/render/display.js b/src/layers/perimeters/render/display.js index 8eb57e028..e5f4deec3 100644 --- a/src/layers/perimeters/render/display.js +++ b/src/layers/perimeters/render/display.js @@ -20,13 +20,10 @@ export const perimeterPickId = (row, index) => /** * The perimeters: `{ setVisible, replace(rows), onClick(listener) → remove, - * pick(click), destroy }`. A click listener gets the engine's click with its - * pick; pick(click) is that pick (null for none). + * destroy }`. A click listener receives the engine's pick. * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createPerimetersDisplay(engine) { - if (!engine?.ground) - throw new TypeError('The fire perimeters display needs a globe engine'); const set = engine.ground.createSet({ id: SET_ID, kind: 'polygon', @@ -72,7 +69,6 @@ export function createPerimetersDisplay(engine) { removers.add(off); return off; }, - pick: (click) => click?.picked ?? null, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/layers/perimeters/render/display.test.mjs b/src/layers/perimeters/render/display.test.mjs index 4b28d3954..9fab7f4a3 100644 --- a/src/layers/perimeters/render/display.test.mjs +++ b/src/layers/perimeters/render/display.test.mjs @@ -87,10 +87,6 @@ const near = (actual, expected, label, tolerance = 1e-9) => `${label}: ${actual} vs ${expected}`, ); -test('the display needs a globe engine', () => { - assert.throws(() => createPerimetersDisplay(null), TypeError); -}); - for (const makeHarness of GROUND_HARNESSES) { const { name } = makeHarness(); @@ -263,7 +259,5 @@ test('the display ignores calls after destroy and destroys once', () => { display.replace([row]); assert.equal(h.listeners(), 0); display.onClick(() => {})(); - assert.equal(display.pick({}), null); - assert.equal(display.pick({ picked: { id: 'x' } }).id, 'x'); h.destroy(); }); diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js index 7ccabecdf..a2c9d6115 100644 --- a/src/layers/radio/interaction.js +++ b/src/layers/radio/interaction.js @@ -13,7 +13,7 @@ export function createInteraction({ source, }) { const { resolvePickId, isOwnedByOtherLayer } = services.picking; - const { setOverlaySourceVisible } = services.overlays; + const { setVisible } = services.overlays; /** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */ @@ -99,7 +99,7 @@ export function createInteraction({ function syncRadioLifecyclePresentation() { const visible = radioPresentationAllowed(); parts.renderer?.setVisible(visible); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible); + setVisible(RADIO_OVERLAY_SOURCE_ID, visible); if (visible) { installInteraction(); parts.rendering.updateSelectionEntity(); diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index a81561a9c..e4a89bc15 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -12,7 +12,7 @@ export function createLifecycle({ source, rendererFactory, }) { - const { clearOverlaySource, setOverlaySourceVisible } = services.overlays; + const { clearSource, setVisible } = services.overlays; const methods = { /** Initialize scene resources and the single audio element. */ @@ -21,8 +21,8 @@ export function createLifecycle({ layerState._sessionGeneration += 1; parts.clustering.resetRadioClusterOverlayIdentities(); parts.playback.installAudio(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + clearSource(RADIO_OVERLAY_SOURCE_ID); + setVisible(RADIO_OVERLAY_SOURCE_ID, false); }, attachRenderer(viewer, options = {}) { @@ -39,7 +39,7 @@ export function createLifecycle({ }); if (!parts.renderer) throw new TypeError('Radio has no renderer on this engine'); - parts.renderer.init(viewer, options); + parts.renderer.init(); if (layerState._enabled) { parts.rendering.drawStations(layerState._stations); parts.interaction.syncRadioLifecyclePresentation(); @@ -50,7 +50,7 @@ export function createLifecycle({ parts.interaction.removeInteraction(); clearTimeout(layerState._overlayPublishTimer); layerState._overlayPublishTimer = null; - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + clearSource(RADIO_OVERLAY_SOURCE_ID); parts.renderer?.destroy(); parts.renderer = null; layerState._renderById.clear(); @@ -91,8 +91,8 @@ export function createLifecycle({ parts.clustering.resetRadioClusterOverlayIdentities(); layerState._overlayDiagnostics = parts.clustering.emptyRadioOverlayDiagnostics(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + clearSource(RADIO_OVERLAY_SOURCE_ID); + setVisible(RADIO_OVERLAY_SOURCE_ID, false); parts.presentation.emitState(); }, @@ -109,7 +109,7 @@ 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); + clearSource(RADIO_OVERLAY_SOURCE_ID); parts.renderer?.destroy(); parts.renderer = null; layerState._stations = []; diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js index 6bdcb9685..349139fdb 100644 --- a/src/layers/radio/navigation.js +++ b/src/layers/radio/navigation.js @@ -147,10 +147,10 @@ export function createNavigation({ /** Radio may move the globe only while no tracked entity owns the follow camera. */ - function radioCameraNavigationAllowed(viewer) { - if (viewer === undefined) + function radioCameraNavigationAllowed(engine) { + if (engine === undefined) return parts.renderer?.navigationAllowed() ?? false; - return Boolean(viewer?.camera) && !viewer.trackedEntity; + return Boolean(engine?.camera) && !engine.camera.following(); } function radioGlobeRecenterPlan() { diff --git a/src/layers/radio/render/display.js b/src/layers/radio/render/display.js index 9f48b65d2..046d35457 100644 --- a/src/layers/radio/render/display.js +++ b/src/layers/radio/render/display.js @@ -51,7 +51,6 @@ const rgba = (css, alpha = 1) => rgbaFromCss(css).withAlpha(alpha).rgba(); * @param {{ state: object, services: object, parts: object }} context */ export function createRadioDisplay(engine, { state, services, parts }) { - if (!engine) throw new TypeError('The radio display needs a globe engine'); let initialized = false; let visible = false; let sets = []; diff --git a/src/layers/radio/render/display.test.mjs b/src/layers/radio/render/display.test.mjs index b0c48cef9..5307e811a 100644 --- a/src/layers/radio/render/display.test.mjs +++ b/src/layers/radio/render/display.test.mjs @@ -180,9 +180,9 @@ function fixture(t, makeHarness) { }, render: { governorRequestRender: noop }, overlays: { - clearOverlaySource: (id) => entries.delete(id), - setOverlaySourceVisible: noop, - setOverlayEntries: (id, value) => entries.set(id, value), + clearSource: (id) => entries.delete(id), + setVisible: noop, + setEntries: (id, value) => entries.set(id, value), }, }, rendererFactory(value) { @@ -216,10 +216,6 @@ function fixture(t, makeHarness) { }; } -test('the display needs a globe engine', () => { - assert.throws(() => createRadioDisplay(null, {}), TypeError); -}); - for (const makeHarness of HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 3f4aa60ea..3f59ca9c9 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -14,8 +14,7 @@ export function createRendering({ parts, source, }) { - const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } = - services.overlays; + const { clearSource, setVisible, setEntries } = services.overlays; const { governorRequestRender } = services.render; function markerPosition(station, liftM = MARKER_LIFT_M) { @@ -52,8 +51,8 @@ export function createRendering({ parts.clustering.resetRadioClusterOverlayIdentities(); layerState._overlayDiagnostics = parts.clustering.emptyRadioOverlayDiagnostics(); - clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + clearSource(RADIO_OVERLAY_SOURCE_ID); + setVisible(RADIO_OVERLAY_SOURCE_ID, false); return; } @@ -152,12 +151,8 @@ export function createRendering({ if (singletonEntry) entries.push(singletonEntry); } - setOverlayEntries( - RADIO_OVERLAY_SOURCE_ID, - entries, - RADIO_OVERLAY_SOURCE_OPTIONS, - ); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true); + setEntries(RADIO_OVERLAY_SOURCE_ID, entries, RADIO_OVERLAY_SOURCE_OPTIONS); + setVisible(RADIO_OVERLAY_SOURCE_ID, true); layerState._overlayDiagnostics = { entryCount: entries.length, selectedCount: entries.filter((entry) => entry.selected).length, diff --git a/src/layers/radio/source.test.mjs b/src/layers/radio/source.test.mjs index 8d28e1485..303e92c59 100644 --- a/src/layers/radio/source.test.mjs +++ b/src/layers/radio/source.test.mjs @@ -86,9 +86,9 @@ test('radio factories keep settings and subscriptions independent without fetchi globe: {}, render: { governorRequestRender() {} }, overlays: { - clearOverlaySource() {}, - setOverlaySourceVisible() {}, - setOverlayEntries() {}, + clearSource() {}, + setVisible() {}, + setEntries() {}, }, }; const first = createRadioLayer({ source, services }); diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 896ab6118..5646a661a 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -14,7 +14,7 @@ * map is never retried in a loop. */ import { searchHls } from './catalog.js'; -import { NO_IMAGERY_HOST, resolveImageryHost } from '../../maps/imageryHost.js'; +import { NO_IMAGERY_HOST } from '../shared/imageryHost.js'; import { CATALOG_DAYS, PRODUCTS, @@ -23,7 +23,6 @@ import { boxFromRectangle, boxSideKm, dequantizeBox, - fitViewHeightM, formatCandidateReadout, parseCandidateKey, quantizeBox, @@ -53,7 +52,6 @@ const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe'); const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map'); const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D'); /** ZOOM IN's flight time, in seconds. */ -const ZOOM_FIT_SECONDS = 1.2; const DEFAULT_SPLIT = 0.5; const SLOT_IDS = ['a', 'b']; const BOX_EDGES = ['west', 'south', 'east', 'north']; @@ -67,32 +65,6 @@ const boxSignature = (box) => box ? BOX_EDGES.map((edge) => Number(box[edge]).toFixed(6)).join(',') : ''; const sourceOf = (product) => PRODUCTS[product]?.source || null; const toDegrees = (radians) => (radians * 180) / Math.PI; -const toRadians = (degrees) => (degrees * Math.PI) / 180; -const ellipsoidOf = (viewer) => - viewer?.scene?.ellipsoid || viewer?.scene?.globe?.ellipsoid || null; - -/** - * The ground under the canvas centre, in degrees; null when the centre is - * sky or the viewer cannot pick. - * @param {object} viewer - * @returns {{ lon: number, lat: number } | null} - */ -function viewCentre(viewer) { - const ellipsoid = ellipsoidOf(viewer); - const canvas = viewer?.scene?.canvas; - const width = Number(canvas?.clientWidth); - const height = Number(canvas?.clientHeight); - if (!ellipsoid?.cartesianToCartographic || !(width > 0) || !(height > 0)) - return null; - const hit = viewer.camera?.pickEllipsoid?.( - { x: width / 2, y: height / 2 }, - ellipsoid, - ); - const ground = hit ? ellipsoid.cartesianToCartographic(hit) : null; - return ground - ? { lon: toDegrees(ground.longitude), lat: toDegrees(ground.latitude) } - : null; -} const modeFrom = (value) => MODES.includes(value) ? value @@ -102,22 +74,19 @@ const modeFrom = (value) => /** * Create the layer. - * @param {{ catalog?: { searchHls: Function }, renderer: object, thumbnails: object, host?: Function, now?: () => Date, days?: number, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} options + * @param {{ catalog?: { searchHls: Function }, renderer: object, thumbnails: object, now?: () => Date, days?: number, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} options */ export function createRecentImageryLayer({ catalog = { searchHls }, renderer, rendererFactory, thumbnails, - host = resolveImageryHost, now = () => new Date(), days = CATALOG_DAYS, setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeoutImpl = (id) => globalThis.clearTimeout(id), } = {}) { - let _viewer = null; let _engine = null; - let _tileset = null; let _mapStackController = null; let _dataManager = null; let _enabled = false; @@ -374,11 +343,7 @@ export function createRecentImageryLayer({ } function syncHost() { - const next = renderer?.resolveHost?.() || - host({ viewer: _viewer, tileset: _tileset }) || { - collection: null, - kind: 'none', - }; + const next = renderer.resolveHost(); const changed = (next.identity || next.collection) !== (_hostInfo.identity || _hostInfo.collection) || @@ -808,15 +773,13 @@ export function createRecentImageryLayer({ source: 'NASA GIBS · HLS + VIIRS', updateInterval: 0, - init(viewer, options) { - return this.attachRenderer(viewer, options); + init(_viewer, options) { + return this.attachRenderer(null, options); }, - attachRenderer(viewer, { engine } = {}) { - _viewer = viewer || null; + attachRenderer(_viewer, { engine } = {}) { _engine = engine || null; - if (rendererFactory && !renderer) - renderer = rendererFactory({ viewer, engine }); + if (rendererFactory && !renderer) renderer = rendererFactory({ engine }); if (!renderer) throw new TypeError('Recent imagery requires a renderer'); if (_enabled) syncRender(); return true; @@ -831,14 +794,13 @@ export function createRecentImageryLayer({ renderer?.destroy(); renderer = null; _hostInfo = { collection: null, kind: 'none' }; - _viewer = _engine = _tileset = null; + _engine = null; _keptGeneration = null; await Promise.all([lease?.release(), _releasing]); }, - enable(viewer) { + enable() { if (_destroyed) return false; - if (viewer) _viewer = viewer; _enabled = true; _comparisonError = null; _unsubscribeThumbnails?.(); @@ -907,9 +869,7 @@ export function createRecentImageryLayer({ _listeners.clear(); _rowListener = null; _toolHandler = null; - _viewer = null; _engine = null; - _tileset = null; _mapStackController = null; _dataManager = null; _destroyed = true; @@ -936,8 +896,7 @@ export function createRecentImageryLayer({ }, /** The photoreal tileset, for draping while the globe is hidden. */ - attachTileset(tileset) { - _tileset = tileset || null; + attachTileset() { if (_enabled && syncHost()) { syncRender(); notify(); @@ -981,13 +940,10 @@ export function createRecentImageryLayer({ /** * Use the camera's view rectangle as the box. A view wider than the cap * is refused with its size, never silently shrunk. - * @param {object} [viewer] * @returns {boolean} */ - useCurrentView(viewer = _viewer) { - const rectangle = - _engine?.camera.viewRectangle() || - viewer?.camera?.computeViewRectangle?.(); + useCurrentView() { + const rectangle = _engine.camera.viewRectangle(); if (!rectangle) return refuseBox('Point the camera at the ground to use the view'); const box = boxFromRectangle(rectangle); @@ -995,7 +951,7 @@ export function createRecentImageryLayer({ const { width, height } = boxSideKm(box); return refuseBox( viewTooLargeMessage(Math.max(width, height)), - renderer?.viewCentre?.() || viewCentre(viewer) || boxCentre(box), + renderer.viewCentre() || boxCentre(box), ); } return chooseBox(box); @@ -1009,27 +965,7 @@ export function createRecentImageryLayer({ */ zoomToFit() { const target = _boxError ? _refusalTarget : null; - if (target && renderer?.zoomToFit) return renderer.zoomToFit(target); - const camera = _viewer?.camera; - const ellipsoid = ellipsoidOf(_viewer); - if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian) - return null; - const canvas = _viewer.scene?.canvas; - const height = fitViewHeightM({ - width: canvas?.clientWidth, - height: canvas?.clientHeight, - fovy: camera.frustum?.fovy, - }); - camera.flyTo({ - destination: ellipsoid.cartographicToCartesian({ - longitude: toRadians(target.lon), - latitude: toRadians(target.lat), - height, - }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - duration: ZOOM_FIT_SECONDS, - }); - return height; + return target ? renderer.zoomToFit(target) : null; }, /** The box tool's transient ground preview, owned by the renderer. */ diff --git a/src/layers/recentImagery/index.test.mjs b/src/layers/recentImagery/index.test.mjs index bf0d7761e..c58b691c0 100644 --- a/src/layers/recentImagery/index.test.mjs +++ b/src/layers/recentImagery/index.test.mjs @@ -1,6 +1,11 @@ +import { createCesiumMaps } from '../../globe/cesium/maps.js'; +import { createCesiumCredits } from '../../globe/cesium/credits.js'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createRecentImageryDisplay } from './render/display.js'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { NO_IMAGERY_HOST } from '../../maps/imageryHost.js'; +import { NO_IMAGERY_HOST } from '../shared/imageryHost.js'; import { acquireImageryComparison } from '../../maps/imageryComparison.js'; import { MapSourceController } from '../../maps/controller.js'; import { createDefaultMapSources } from '../../maps/defaultSources.js'; @@ -114,19 +119,24 @@ function fixture({ viirs = true, } = {}) { const renderer = fakeRenderer(); + const engine = createFakeEngine(); + engine.camera.viewRectangle = () => undefined; + const display = createRecentImageryDisplay(engine); + renderer.viewCentre = display.viewCentre; + renderer.zoomToFit = display.zoomToFit; const thumbnails = fakeThumbnails(); const catalog = fakeCatalog(); const timers = manualTimers(); const hostState = { kind: hostKind }; const collections = { none: null, globe: {}, tileset: {} }; + renderer.resolveHost = () => ({ + collection: collections[hostState.kind], + kind: hostState.kind, + }); const layer = createRecentImageryLayer({ catalog, renderer, thumbnails, - host: () => ({ - collection: collections[hostState.kind], - kind: hostState.kind, - }), now: () => NOW, setTimeoutImpl: timers.setTimeoutImpl, clearTimeoutImpl: timers.clearTimeoutImpl, @@ -135,7 +145,7 @@ function fixture({ const viewer = { camera: {} }; const reasons = []; layer.subscribe((snapshot, reason) => reasons.push(reason)); - layer.init(viewer); + layer.init(null, { engine }); if (controller) layer.attachMapStackController(controller); const dataManager = fakeDataManager({ layer }); layer.attachDataManager(dataManager); @@ -166,6 +176,7 @@ function fixture({ dataManager, hostState, viewer, + engine, reasons, timers, toolCalls, @@ -709,14 +720,22 @@ function failingMapController() { removeStaticCredit() {}, }, }, - primitives: { add() {}, remove() {} }, + primitives: { contains: () => false, add() {}, remove() {} }, }, imageryLayers: { add() {}, remove() {} }, }; - const controller = new MapSourceController(viewer, { + viewer.creditDisplay = viewer.scene.frameState.creditDisplay; + const controller = new MapSourceController({ registry, initialStack: 'photoreal', - createImageryLayer: (provider) => ({ provider }), + engine: { + kind: 'cesium', + render: { request() {} }, + maps: createCesiumMaps(viewer, { + credits: createCesiumCredits(viewer).credits, + createImageryLayer: (provider) => ({ provider }), + }).maps, + }, onError: () => {}, }); const calls = []; @@ -953,10 +972,10 @@ test('USE VIEW takes the camera rectangle and refuses a view over the cap instea east: rad(east), north: rad(north), }); - f.viewer.camera.computeViewRectangle = view(-97.8, 30.2, -97.7, 30.3); + f.engine.camera.viewRectangle = view(-97.8, 30.2, -97.7, 30.3); assert.equal(f.layer.useCurrentView(), true); assert.ok(Math.abs(f.snap().box.west + 97.8) < 1e-9); - f.viewer.camera.computeViewRectangle = view(-108, 20, -88, 40); + f.engine.camera.viewRectangle = view(-108, 20, -88, 40); assert.equal(f.layer.useCurrentView(), false); assert.match(f.snap().boxError, /^View is 2,2\d\d km wide · limit 1,000 km$/); assert.equal(f.catalog.searches.length, 1, 'nothing searched'); @@ -972,23 +991,21 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2); const expected = 400_000 / (2 * Math.tan(Math.PI / 6)); let centreHit = null; - Object.assign(f.viewer, { - scene: { - canvas: { clientWidth: 1000, clientHeight: 500 }, - ellipsoid: { - cartographicToCartesian: (c) => ({ ...c, cartesian: true }), - cartesianToCartographic: (c) => c && { ...c }, - }, - }, - }); - Object.assign(f.viewer.camera, { - frustum: { fovy }, - flyTo: (options) => flights.push(options), - pickEllipsoid: (position) => { - assert.deepEqual(position, { x: 500, y: 250 }, 'the canvas centre'); - return centreHit; - }, - }); + f.engine.host.size = () => ({ width: 1000, height: 500 }); + const pose = f.engine.camera.pose(); + f.engine.camera.pose = () => ({ ...pose, fovy }); + f.engine.camera.flyTo = (options) => + flights.push({ + ...options, + destination: geo.cartographic.fromCartesian(options.destination), + }); + f.engine.camera.pickEllipsoid = (position) => { + assert.deepEqual(position, { x: 500, y: 250 }, 'the canvas centre'); + return ( + centreHit && + geo.cartesian3.fromRadians(centreHit.longitude, centreHit.latitude, 0) + ); + }; f.layer.enable(); assert.equal(f.snap().zoomToFit, false); assert.equal(f.layer.zoomToFit(), null, 'nothing refused, nothing to fit'); @@ -1001,7 +1018,7 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig const [flight] = flights; assert.ok(Math.abs(deg(flight.destination.longitude) - 10) < 1e-9); assert.ok(Math.abs(deg(flight.destination.latitude) - 10) < 1e-9); - assert.equal(flight.destination.height, height); + assert.ok(Math.abs(flight.destination.height - height) < 1e-6); assert.deepEqual(flight.orientation, { heading: 0, pitch: -Math.PI / 2, @@ -1009,7 +1026,7 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig }); assert.equal(flight.duration, 1.2); // A view over the cap: the ground under the canvas centre. - f.viewer.camera.computeViewRectangle = () => ({ + f.engine.camera.viewRectangle = () => ({ west: rad(-108), south: rad(20), east: rad(-88), diff --git a/src/layers/recentImagery/render/display.js b/src/layers/recentImagery/render/display.js index 66f542a7f..609df4b03 100644 --- a/src/layers/recentImagery/render/display.js +++ b/src/layers/recentImagery/render/display.js @@ -15,7 +15,7 @@ const PREVIEW = Object.freeze({ fill: [0.545, 0.914, 1, 0.12], outline: { color: [0.545, 0.914, 1, 0.95], widthPx: 2 }, }); -// What the layer calls a surface (src/maps/imageryHost.js kinds). +// The host kinds exposed by engine imagery. const HOST_KIND = { globe: 'globe', tiles: 'tileset', none: 'none' }; const boxKey = (box) => @@ -48,8 +48,6 @@ export function createRecentImageryDisplay( maxTileRequests = 6, } = {}, ) { - if (!engine?.imagery || !engine?.ground) - throw new TypeError('The Recent Imagery display needs a globe engine'); /** @type {Record<'a'|'b', null | { layer: object, candidate: object, box: object, alpha: number, split: string }>} */ const owned = { a: null, b: null }; let host = { identity: null, kind: 'none' }; diff --git a/src/layers/recentImagery/testDoubles.mjs b/src/layers/recentImagery/testDoubles.mjs index df7060af3..7aa4c36b6 100644 --- a/src/layers/recentImagery/testDoubles.mjs +++ b/src/layers/recentImagery/testDoubles.mjs @@ -98,9 +98,7 @@ function domEvent(fields) { /** * Globe engine and document fakes for `initImageryBoxTool`. The canvas is * 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky. - * The engine input counts camera-input suspensions over the app's - * setCameraInputs choice: `camera.inputs` is on while that choice is on - * and nothing suspends them. + * The engine input counts camera-input suspensions. */ export function boxToolFakes() { const camera = { @@ -118,9 +116,6 @@ export function boxToolFakes() { releasePointerCapture: (id) => captured.delete(id), }); const input = { - setCameraInputs(enabled) { - camera.choice = Boolean(enabled); - }, cameraInputsEnabled: () => camera.inputs, suspendCameraInputs() { camera.suspended += 1; @@ -227,6 +222,9 @@ export function fakeRenderer() { let peak = 0; return { calls, + resolveHost: () => ({ collection: {}, kind: 'globe' }), + viewCentre: () => null, + zoomToFit: () => false, rebind(next) { calls.push(['rebind', next.kind]); host = next; diff --git a/src/layers/satellites/render/display.js b/src/layers/satellites/render/display.js index 3f0680e6c..fa4289116 100644 --- a/src/layers/satellites/render/display.js +++ b/src/layers/satellites/render/display.js @@ -56,8 +56,6 @@ const depthTestOf = (distance) => * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createSatellitesDisplay(engine) { - if (!engine?.movers || !engine?.lines) - throw new TypeError('The satellites display needs a globe engine'); const banks = new Map(); const points = new Map(); const tracked = new Set(); diff --git a/src/layers/satellites/render/display.test.mjs b/src/layers/satellites/render/display.test.mjs index 9194cf976..942b6548e 100644 --- a/src/layers/satellites/render/display.test.mjs +++ b/src/layers/satellites/render/display.test.mjs @@ -113,10 +113,6 @@ function drawn(h, setId, index) { }; } -test('the display needs a globe engine', () => { - assert.throws(() => createSatellitesDisplay(null), TypeError); -}); - for (const makeHarness of HARNESSES) { const { name } = makeHarness(); diff --git a/src/layers/satellites/stale.test.mjs b/src/layers/satellites/stale.test.mjs index d0a62041d..02885f2f9 100644 --- a/src/layers/satellites/stale.test.mjs +++ b/src/layers/satellites/stale.test.mjs @@ -74,9 +74,9 @@ async function fixture(t) { create: ({ engine }) => createSatellitesDisplay(engine), }, overlays: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, layerState: { isExplicitLayerStateOrigin: () => false }, focus: { diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 4e09b9cc1..32ed84740 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -2,14 +2,16 @@ import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; export function createState({ services }) { - const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - bindOverlayHost(services.overlays, () => state._engine); + const { setEntries, setVisible, clearSource } = bindOverlayHost( + services.overlays, + () => state._engine, + ); const state = {}; state.DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, + setEntries: setEntries, + setVisible: setVisible, + clearSource: clearSource, }); state._overlayHost = state.DEFAULT_OVERLAY_HOST; diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index c541b79d1..88b4e79dd 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import * as geo from '../../geo/index.js'; import { constantRgba } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; @@ -29,7 +29,7 @@ export function createTracking({ getContextStore, refreshTrackedSubjectContext, selectTrackedSubjectContext, - } = bindContextStore(services.context, () => getRenderer()?.engine); + } = bindOverlayHost(services.context, () => getRenderer()?.engine); const { refreshTrackedReadout } = services.readout; function _normalizeTrackedNorad(candidate) { diff --git a/src/layers/shared/color.js b/src/layers/shared/color.js index 7c8208f7b..0508b41bc 100644 --- a/src/layers/shared/color.js +++ b/src/layers/shared/color.js @@ -66,6 +66,17 @@ const HEX = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i; * @returns {Rgba} */ export function rgbaFromCss(css) { + const functional = + /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec( + String(css).trim(), + ); + if (functional) + return new Rgba( + Number(functional[1]) / 255, + Number(functional[2]) / 255, + Number(functional[3]) / 255, + Number(functional[4] ?? 1), + ); const match = HEX.exec(String(css).trim()); if (!match) throw new TypeError(`Unsupported colour: ${css}`); return new Rgba( diff --git a/src/layers/weather/imageryHost.js b/src/layers/shared/imageryHost.js similarity index 61% rename from src/layers/weather/imageryHost.js rename to src/layers/shared/imageryHost.js index 951dee9ec..257381874 100644 --- a/src/layers/weather/imageryHost.js +++ b/src/layers/shared/imageryHost.js @@ -1,13 +1,4 @@ -import { msg } from '../../i18n/index.js'; - -/** Resolve the visible surface that can own weather imagery without scene mutation. */ -export function resolveImageryHost({ viewer, tileset }) { - if (viewer?.scene?.globe?.show === true) - return { collection: viewer.imageryLayers, kind: 'globe' }; - if (tileset?.imageryLayers) - return { collection: tileset.imageryLayers, kind: 'tileset' }; - return { collection: null, kind: 'none' }; -} +import { msg } from '../../i18n/marker.js'; // What the layer calls a surface: engine.imagery's surfaces as host kinds. const HOST_KIND = { globe: 'globe', tiles: 'tileset', none: 'none' }; diff --git a/src/layers/shared/imageryHost.test.mjs b/src/layers/shared/imageryHost.test.mjs new file mode 100644 index 000000000..6fe2988ea --- /dev/null +++ b/src/layers/shared/imageryHost.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { imageryHostStatus, NO_IMAGERY_HOST } from './imageryHost.js'; + +test('only a missing host suspends weather; 3D Tiles has no height gate', () => { + assert.equal(imageryHostStatus({ kind: 'tileset' }), null); + assert.equal(imageryHostStatus({ kind: 'globe' }), null); + assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST); +}); + +test('missing imagery host offers a globe map', () => { + assert.match(NO_IMAGERY_HOST, /globe map/); +}); diff --git a/src/layers/shared/render/engineView.js b/src/layers/shared/render/engineView.js index c675921bc..99891d919 100644 --- a/src/layers/shared/render/engineView.js +++ b/src/layers/shared/render/engineView.js @@ -108,9 +108,9 @@ export function createEngineLayerView( return cameraView; }, scene, - surfaceOwner: engine, - surfaceScene: engine, + engine, frameNumber: () => frameNumber, + canvasSize: () => engine.host.size(), viewportHeightPx: () => engine.host.size().height, frustumTester() { const cam = freshCamera(); @@ -145,9 +145,6 @@ export function createEngineLayerView( ); }, isDestroyed: () => isDestroyed() || engine.isDestroyed?.() === true, - holdFrames() {}, - closeFrameLatch() {}, - releaseFrames() {}, }; return { diff --git a/src/layers/streetLevel/render/display.js b/src/layers/streetLevel/render/display.js index 16a6b7d4b..f1345cfb7 100644 --- a/src/layers/streetLevel/render/display.js +++ b/src/layers/streetLevel/render/display.js @@ -85,8 +85,6 @@ export function screenRotation(position, bearingDeg, pose, previous = 0) { * @param {import('../../../globe/contract.js').GlobeEngine} engine */ export function createStreetLevelDisplay(engine) { - if (!engine?.ground || !engine?.movers) - throw new TypeError('The Street Level display needs a globe engine'); /** Live sprite groups and line handles, for destroy(). */ const live = new Set(); let serial = 0; diff --git a/src/layers/streetLevel/render/display.test.mjs b/src/layers/streetLevel/render/display.test.mjs index f45993383..abc83138d 100644 --- a/src/layers/streetLevel/render/display.test.mjs +++ b/src/layers/streetLevel/render/display.test.mjs @@ -25,11 +25,6 @@ const STYLE = Object.freeze({ order: 0, }); -test('the display needs a globe engine', () => { - assert.throws(() => createStreetLevelDisplay(null), /globe engine/); - assert.throws(() => createStreetLevelDisplay({}), /globe engine/); -}); - test('a line colour is the CSS colour, its alpha replaced when asked', () => { assert.deepEqual(lineColor('#00d4ff'), [0, 0xd4 / 255, 1, 1]); assert.deepEqual(lineColor('#05cb63', 0.92), [ diff --git a/src/layers/submarineCables/render/display.js b/src/layers/submarineCables/render/display.js index cc671bcef..b3dbc3486 100644 --- a/src/layers/submarineCables/render/display.js +++ b/src/layers/submarineCables/render/display.js @@ -133,8 +133,6 @@ export function createCablesDisplay( sweepClock = defaultSweepClock, } = {}, ) { - if (!engine?.ground || !engine?.movers || !engine?.lines) - throw new TypeError('The cables display needs a globe engine'); const publisher = createCableOverlayPublisher({ host: bindOverlayHost(overlayHost, () => engine), }); diff --git a/src/layers/traffic/render/display.js b/src/layers/traffic/render/display.js index 78f84395a..4ece80163 100644 --- a/src/layers/traffic/render/display.js +++ b/src/layers/traffic/render/display.js @@ -54,8 +54,6 @@ export function nearFarValue(scalar, distance) { * clearHeat: () => void, excluded: () => object[], destroy: () => void }} */ export function createTrafficDisplay(engine) { - if (!engine?.movers || !engine?.ground) - throw new TypeError('The traffic display needs a globe engine'); const roles = ['outline', 'fill']; const sets = roles.map((role, order) => engine.movers.createSet({ diff --git a/src/layers/transit/edgeCases.test.mjs b/src/layers/transit/edgeCases.test.mjs index 04a0834ac..80a3720f4 100644 --- a/src/layers/transit/edgeCases.test.mjs +++ b/src/layers/transit/edgeCases.test.mjs @@ -22,9 +22,9 @@ function harness(t) { t.mock.method(console, 'warn', () => {}); const services = { overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, + setEntries() {}, + setVisible() {}, + clearSource() {}, }, render: { governorRequestRender() {} }, sprites: {}, diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index e569f78a7..3156a79a4 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -112,7 +112,6 @@ export function createLifecycle({ /** * Create the marker sets. Called once at bootstrap. - * @param {Cesium.Viewer} viewer */ init(viewer, options) { this.attachRenderer(viewer, options); @@ -132,15 +131,13 @@ export function createLifecycle({ }); if (!renderer) throw new TypeError('Transit has no renderer on this engine'); - state._viewer = viewer; parts.renderer = renderer; try { - renderer.attach(viewer); + renderer.attach(); [state._markers, state._animatedMarkers] = renderer.createMarkers(); } catch (error) { renderer.destroy(); parts.renderer = null; - state._viewer = null; throw error; } state._overlayHost.setVisible( @@ -153,7 +150,7 @@ export function createLifecycle({ entry.detectContact = null; entry.pathRevision = -1; } - this.bindScene(viewer); + this.bindScene(); if (!state._restoringViewport) this.repaint(); } bindStyleEvents(); @@ -164,16 +161,15 @@ export function createLifecycle({ /** * Show vehicles, watch the camera, and poll every feed in range. - * @param {Cesium.Viewer} viewer */ - enable(viewer) { + enable() { state._enabled = true; state._generation += 1; state._error = null; - this.bindScene(viewer); + this.bindScene(); }, - bindScene(viewer) { + bindScene() { bindVisibility(); state._markers.show = true; state._animatedMarkers.show = true; @@ -182,7 +178,7 @@ export function createLifecycle({ 250, ); state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true); - parts.selection.installClickHandler(viewer); + parts.selection.installClickHandler(); registerPickOwner( 'transit', (pickedId) => state._vehicles.has(pickedId), @@ -192,7 +188,7 @@ export function createLifecycle({ state._cameraChangedRemove = parts.renderer.onCameraChanged( parts.viewport.onCameraChanged, ); - borrowCameraSensitivity(viewer); + borrowCameraSensitivity(); state._cameraChangedAttached = true; } if (!state._preRenderRemove) { @@ -250,18 +246,13 @@ export function createLifecycle({ releaseContinuousRender('transit', parts.renderer?.engine); state._renderHeld = false; parts.renderer.destroy(); - parts.renderer = - state._markers = - state._animatedMarkers = - state._viewer = - null; + parts.renderer = state._markers = state._animatedMarkers = null; }, /** * Hide everything, stop polling, drop all vehicles. - * @param {Cesium.Viewer} viewer */ - disable(viewer) { + disable() { state._enabled = false; unbindVisibility(); parts.testing._stopTransitProbeForTest(); @@ -283,7 +274,7 @@ export function createLifecycle({ if (state._cameraChangedAttached) { state._cameraChangedRemove?.(); state._cameraChangedRemove = null; - returnCameraSensitivity(viewer); + returnCameraSensitivity(); state._cameraChangedAttached = false; } if (state._preRenderRemove) { @@ -335,10 +326,9 @@ export function createLifecycle({ /** * Tear down the collection entirely and release every shared registration. - * @param {Cesium.Viewer} viewer */ - destroy(viewer) { - this.disable(viewer); + destroy() { + this.disable(); state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); parts.renderer?.destroy(); parts.renderer = null; @@ -346,7 +336,6 @@ export function createLifecycle({ parts.height.clear(); unbindStyleEvents(); state._dataManager = null; - state._viewer = null; }, }; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index c89c89d25..a510a1851 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from '../../testSupport/domClasses.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -267,9 +268,9 @@ function harness( unregisterPickOwner: (id) => pickOwners.delete(id), }, overlays: { - setOverlayEntries: (id, entries) => overlaySources.set(id, entries), - setOverlaySourceVisible() {}, - clearOverlaySource: (id) => overlaySources.delete(id), + setEntries: (id, entries) => overlaySources.set(id, entries), + setVisible() {}, + clearSource: (id) => overlaySources.delete(id), }, credits: { registerDynamicCredit: (_engine, credit) => credits.push(credit?.key), @@ -712,19 +713,7 @@ test('disable aborts in-flight work and a response that lands late is ignored', const pending = new Promise((resolve) => { release = resolve; }); - for (const name of [ - 'HTMLCanvasElement', - 'HTMLImageElement', - 'ImageBitmap', - 'OffscreenCanvas', - ]) { - const prior = globalThis[name]; - globalThis[name] = class {}; - t.after(() => { - if (prior === undefined) delete globalThis[name]; - else globalThis[name] = prior; - }); - } + installDomClasses(t); t.mock.method(performance, 'now', () => Date.now() - 1_789_000_000_000); const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => { @@ -972,7 +961,6 @@ test('destroy releases every shared registration this layer took', async (t) => ); assert.deepEqual(app.engine.lines.list(), []); assert.equal(app.holds().includes('transit'), false); - assert.equal(app.state()._viewer, null); assert.equal(app.state()._dataManager, null); }); @@ -990,9 +978,9 @@ test('two layers built from the factory share no state', async (t) => { }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, + setEntries() {}, + setVisible() {}, + clearSource() {}, }, credits: { registerDynamicCredit() {}, @@ -3629,12 +3617,10 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve }, source: {}, parts: { - selection: { + display: { _installClickHandler: noop, _uninstallClickHandler: noop, _clearSelection: noop, - }, - viewport: { onCameraChanged: noop, runProximityCheck: noop, deactivateAllCities: noop, @@ -3642,7 +3628,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve ingestion: { abortAllInFlight: noop }, }, }, - ).lifecycle.methods; + ); const layers = { traffic, bikeshare: bike, transit: app.layer }; const orders = [ ['traffic', 'bikeshare', 'transit'], diff --git a/src/layers/transit/render/display.js b/src/layers/transit/render/display.js index d70ba2430..c4cad79ee 100644 --- a/src/layers/transit/render/display.js +++ b/src/layers/transit/render/display.js @@ -629,7 +629,6 @@ export function createTransitDisplay( engine, { state, services, drawAtlas = drawTransitAtlas } = {}, ) { - if (!engine) throw new TypeError('The transit display needs a globe engine'); let destroyed = false, attached = false, markers = null; diff --git a/src/layers/transit/render/display.test.mjs b/src/layers/transit/render/display.test.mjs index c68b67990..8330bea0e 100644 --- a/src/layers/transit/render/display.test.mjs +++ b/src/layers/transit/render/display.test.mjs @@ -233,10 +233,6 @@ const spec = (id, overrides = {}) => ({ ...overrides, }); -test('the display needs a globe engine', () => { - assert.throws(() => createTransitDisplay(null, {}), TypeError); -}); - test('the atlas holds every glyph the markers draw, once each', () => { const icons = transitAtlasIcons(); assert.equal(new Set(icons).size, icons.length); diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index e7ef481f1..6bf461b0d 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -113,7 +113,7 @@ export function createSelection({ state, services, parts }) { if (event.key === 'Escape' && state._selectedKey) clearSelection(); } - function installClickHandler(viewer) { + function installClickHandler() { if (state._clickHandler) return; state._clickHandler = parts.renderer.bindClick((picked) => { state._lastPickForTest = picked; diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 05b946879..4dbe9bd59 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -9,21 +9,19 @@ import { createHistoryBudget } from '../../data/contactPlayback.js'; * @returns {object} */ export function createState({ services }) { - const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = - services.overlays; + const { setEntries, setVisible, clearSource } = services.overlays; const state = {}; state._historyBudget = createHistoryBudget(); state.DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, + setEntries: setEntries, + setVisible: setVisible, + clearSource: clearSource, }); state._overlayHost = state.DEFAULT_OVERLAY_HOST; /** The viewer the layer was initialized with (its renderer draws). */ - state._viewer = null; /** The renderer's marker collections: the fleet and the vehicles mid-glide. */ state._markers = null; state._animatedMarkers = null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 80b52bec7..4d5598dee 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -26,9 +26,9 @@ function services() { labels: {}, picking: { unregisterPickOwner: noop }, overlay: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, geoid: {}, sprites: {}, diff --git a/src/layers/vessels/render/display.js b/src/layers/vessels/render/display.js index 6d66a4ce0..6707e6581 100644 --- a/src/layers/vessels/render/display.js +++ b/src/layers/vessels/render/display.js @@ -75,8 +75,6 @@ export function createVesselsDisplay( engine, { drawAtlas = drawVesselAtlas } = {}, ) { - if (!engine?.movers || !engine?.lines) - throw new TypeError('The vessels display needs a globe engine'); let destroyed = false; // --- commits: once per frame listener, or in a microtask ----------------- @@ -113,22 +111,7 @@ export function createVesselsDisplay( wrap: inFrameDo, isDestroyed: () => destroyed, }); - const base = layerView.view; - const view = { - get camera() { - return base.camera; - }, - scene: base.scene, - horizon: () => base.horizon(), - worldToWindow: (position, result) => base.worldToWindow(position, result), - canvasSize: () => engine.host.size(), - requestRender: () => base.requestRender(), - on(event, listener) { - return event === 'preRender' || event === 'moveEnd' - ? base.on(event, listener) - : null; - }, - }; + const view = layerView.view; // --- the chevrons ------------------------------------------------------------ let atlas = null; diff --git a/src/layers/vessels/render/display.test.mjs b/src/layers/vessels/render/display.test.mjs index 0b0cf5f22..4bed56858 100644 --- a/src/layers/vessels/render/display.test.mjs +++ b/src/layers/vessels/render/display.test.mjs @@ -109,10 +109,6 @@ function drawn(h, setId, index) { }; } -test('the display needs a globe engine', () => { - assert.throws(() => createVesselsDisplay(null), TypeError); -}); - test('the chevron atlas has one 32 px cell per chevron the layer draws', () => { for (const icon of [CARGO, SELECTED]) { const [u0, v0, u1, v1] = layout.cells[icon]; diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs index 933fc76f3..4ca3a38f4 100644 --- a/src/layers/vessels/renderGovernor.test.mjs +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -25,9 +25,9 @@ function setup() { labels: {}, picking: { unregisterPickOwner: noop }, overlay: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, + setEntries: noop, + setVisible: noop, + clearSource: noop, }, geoid: {}, sprites: {}, diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index c4b96b750..c1636ab63 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,4 +1,4 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; +import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { isPointerFree } from '../../data/inputOwnership.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; @@ -16,7 +16,7 @@ export function createSelection({ selectEntityContext, registerEntityContext, clearSelectedEntityContextForLayer, - } = bindContextStore( + } = bindOverlayHost( services.context, () => vesselState.state.renderer?.engine, ); diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index aead66a35..982708690 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -5,12 +5,7 @@ import * as geo from '../../geo/index.js'; import { DEFAULT_AIS_RUNTIME } from './policy.js'; export function createVesselState({ source, services }) { - const { - setOverlayEntries, - setOverlaySourceVisible, - clearOverlaySource, - hitTestWorldOverlay, - } = bindOverlayHost( + const { setEntries, setVisible, clearSource, hitTest } = bindOverlayHost( services.overlay, () => vesselState.state.renderer?.engine, ); @@ -25,10 +20,10 @@ export function createVesselState({ source, services }) { vesselState._scratchFocusScreen = geo.cartesian2.create(); vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, + setEntries: setEntries, + setVisible: setVisible, + clearSource: clearSource, + hitTest: hitTest, }); vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST; diff --git a/src/layers/vessels/testFixtures.mjs b/src/layers/vessels/testFixtures.mjs index e8c7a91ac..99d33dca3 100644 --- a/src/layers/vessels/testFixtures.mjs +++ b/src/layers/vessels/testFixtures.mjs @@ -105,10 +105,10 @@ export function services(renderer) { labels: {}, picking, overlay: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, - hitTestWorldOverlay: () => null, + setEntries: noop, + setVisible: noop, + clearSource: noop, + hitTest: () => null, }, geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 }, sprites: { diff --git a/src/layers/weather/imageryHost.test.mjs b/src/layers/weather/imageryHost.test.mjs deleted file mode 100644 index d533538d9..000000000 --- a/src/layers/weather/imageryHost.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - resolveImageryHost, - imageryHostStatus, - NO_IMAGERY_HOST, -} from './imageryHost.js'; - -test('only a missing host suspends weather; 3D Tiles has no height gate', () => { - assert.equal(imageryHostStatus({ kind: 'tileset' }), null); - assert.equal(imageryHostStatus({ kind: 'globe' }), null); - assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST); -}); - -test('visible globe takes precedence over a tileset', () => { - const viewer = { imageryLayers: {}, scene: { globe: { show: true } } }; - assert.deepEqual( - resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }), - { collection: viewer.imageryLayers, kind: 'globe' }, - ); -}); -test('hidden globe uses the supplied tileset imagery collection', () => { - const tileset = { imageryLayers: {} }; - const viewer = { scene: { globe: { show: false } } }; - assert.deepEqual(resolveImageryHost({ viewer, tileset }), { - collection: tileset.imageryLayers, - kind: 'tileset', - }); -}); -test('hidden globe without an imagery-capable tileset has no host', () => { - for (const tileset of [null, {}]) - assert.deepEqual( - resolveImageryHost({ - viewer: { scene: { globe: { show: false } } }, - tileset, - }), - { collection: null, kind: 'none' }, - ); -}); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 3a2fe997e..d91a4f9a1 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -1,6 +1,6 @@ import * as geo from '../../geo/index.js'; import { createWeatherDisplay } from './render/display.js'; -import { engineImageryHost, imageryHostStatus } from './imageryHost.js'; +import { engineImageryHost, imageryHostStatus } from '../shared/imageryHost.js'; import { weatherDisplay, insideExtent } from './display.js'; import { RADAR_MAX_GAP_MS, diff --git a/src/layers/weather/render/display.js b/src/layers/weather/render/display.js index 3ec6dbdb6..c26a12fce 100644 --- a/src/layers/weather/render/display.js +++ b/src/layers/weather/render/display.js @@ -12,7 +12,10 @@ // frame on the other. Lower products draw first: clouds, radar, lightning. import { weatherTileUrl } from '../source.js'; -import { engineImageryHost, imageryHostStatus } from '../imageryHost.js'; +import { + engineImageryHost, + imageryHostStatus, +} from '../../shared/imageryHost.js'; import { createWeatherShell, WEATHER_SHELL_HEIGHTS, @@ -535,8 +538,6 @@ export function createWeatherDisplay( createCanvas = () => document.createElement('canvas'), } = {}, ) { - if (!engine?.imagery) - throw new TypeError('The weather display needs a globe engine'); const shared = { onChange, timeoutMs, diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 65abda303..8e31ced97 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -1,5 +1,5 @@ import { acquireWeatherImage } from './infraredImage.js'; -import { imageryHostStatus } from './imageryHost.js'; +import { imageryHostStatus } from '../shared/imageryHost.js'; import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js'; import { msg } from '../../i18n/index.js'; import * as geo from '../../geo/index.js'; diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index 2a95bc1c8..1503f9d7f 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -13,7 +13,7 @@ import { WEATHER_SHELL_HEIGHTS, } from './shellRendering.js'; import { createRaisedWeatherSurface } from './render/display.js'; -import { NO_IMAGERY_HOST } from './imageryHost.js'; +import { NO_IMAGERY_HOST } from '../shared/imageryHost.js'; const times = [ '2026-09-15T20:00:00.000Z', diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index ed5ef91b9..245bc3b12 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import { createWeatherDisplay } from './render/display.js'; -import { NO_IMAGERY_HOST } from './imageryHost.js'; +import { NO_IMAGERY_HOST } from '../shared/imageryHost.js'; import { imageryDisplayTests } from '../../testSupport/imageryDisplayEngines.mjs'; const { test, createEngine } = imageryDisplayTests(); diff --git a/src/layers/wildlifeSightings/index.js b/src/layers/wildlifeSightings/index.js index 9e2b46209..a61eff002 100644 --- a/src/layers/wildlifeSightings/index.js +++ b/src/layers/wildlifeSightings/index.js @@ -1,4 +1,3 @@ -import { bindContextStore } from '../../data/bindContextStore.js'; import { bindOverlayHost } from '../../overlays/bindOverlayHost.js'; import { createPhotoSlots } from './photos.js'; import { createRendering } from './rendering.js'; @@ -75,7 +74,7 @@ export function createWildlifeSightingsLayer({ retryAt: 0, failures: 0, }; - context = bindContextStore(context, () => state.engine); + context = bindOverlayHost(context, () => state.engine); overlayHost = bindOverlayHost(overlayHost, () => state.engine); const parts = {}; const services = { diff --git a/src/layers/wind/render/display.js b/src/layers/wind/render/display.js index 1fe5ddbd8..a1928b18f 100644 --- a/src/layers/wind/render/display.js +++ b/src/layers/wind/render/display.js @@ -9,7 +9,7 @@ // alone. import { createFieldRaster, fieldUnavailableText } from '../fields.js'; -import { NO_IMAGERY_HOST } from '../../weather/imageryHost.js'; +import { NO_IMAGERY_HOST } from '../../shared/imageryHost.js'; import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js'; import { WEATHER_IMAGERY_ORDER } from '../../weather/render/display.js'; @@ -63,8 +63,6 @@ export function createWindDisplay( createCanvas = rasterCanvas, } = {}, ) { - if (!engine?.imagery) - throw new TypeError('The wind display needs a globe engine'); const flow = makeFlow?.(engine, { onStatusChange }) ?? null; const id = `wind-field-${++fields}`; let snapshot = null; diff --git a/src/mapStackController.js b/src/mapStackController.js index 6a85ef41d..e82b61455 100644 --- a/src/mapStackController.js +++ b/src/mapStackController.js @@ -6,17 +6,16 @@ export { photorealUnavailableReason } from './maps/availability.js'; /** Preserve the standalone entry point; applications can compose the source controller directly. */ export class MapStackController extends MapSourceController { - constructor(viewer, options = {}) { + constructor(options = {}) { const googleApiKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : ''; - // Sources for the page's engine: Navara builds its own from the - // descriptions (src/maps/defaultSources.js). Without an engine, Cesium's. + // Navara builds sources from the map descriptions. const registry = createDefaultMapSources({ ...options, googleApiKey, - engineKind: options.engine?.kind ?? 'cesium', + engineKind: options.engine.kind, }); - super(viewer, { + super({ registry, initialStack: options.googleTileset ? options.initialStack || 'photoreal' diff --git a/src/mapStackController.test.mjs b/src/mapStackController.test.mjs index 1578ec815..134f074ca 100644 --- a/src/mapStackController.test.mjs +++ b/src/mapStackController.test.mjs @@ -6,7 +6,7 @@ import { createFakeEngine } from './globe/testing/fakeEngine.js'; test('the map stack controller applies its stacks through a given globe engine', async () => { const engine = createFakeEngine(); const tileset = { show: true }; - const controller = new MapStackController(null, { + const controller = new MapStackController({ engine, googleTileset: tileset, }); @@ -31,7 +31,7 @@ test('on a Navara engine the map stack controller opens on Google 3D from the br else globalThis.window = previous; }); const engine = createFakeEngine({ kind: 'navara' }); - const controller = new MapStackController(null, { engine }); + const controller = new MapStackController({ engine }); assert.equal(controller.getActiveId(), 'photoreal'); assert.equal(controller.isStackAvailable('bing-aerial'), false); await controller.setStack('photoreal'); @@ -40,7 +40,7 @@ test('on a Navara engine the map stack controller opens on Google 3D from the br assert.equal('tileset' in photoreal, false); controller.destroy(); window.__GOOGLE_MAPS_API_KEY__ = ''; - const keyless = new MapStackController(null, { + const keyless = new MapStackController({ engine: createFakeEngine({ kind: 'navara' }), }); assert.equal(keyless.getActiveId(), 'esri-imagery'); diff --git a/src/maps/controller.js b/src/maps/controller.js index 85f4b8f20..8b2c232d1 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -1,6 +1,4 @@ import { indexMapSources } from './registry.js'; -import { createMapScene } from '../globe/cesium/mapScene.js'; -import { applyTerrain } from '../globe/cesium/terrain.js'; import { acquireImageryComparison } from './imageryComparison.js'; /** @@ -36,26 +34,17 @@ const resolved = (provider, effectiveStackId) => ({ * its own sources from the descriptions; the controller constructs nothing * for it, falls back by description when a setter rejects, and has no tile * failure watch. On destroy the engine keeps what it shows; owned tilesets - * are released and disposed. Without an engine the controller applies - * sources to the viewer itself (standalone use and tests). + * are released and disposed. */ export class MapSourceController { - constructor( - viewer, - { - registry, - initialStack, - onChange = null, - onError = null, - engine = null, - requestRender = engine - ? () => engine.render.request('map-stack') - : () => viewer?.scene?.requestRender?.(), - // A test seam: Cesium's ImageryLayer by default (mapScene.js). - createImageryLayer, - }, - ) { - this.viewer = viewer; + constructor({ + registry, + initialStack, + onChange = null, + onError = null, + engine, + requestRender = () => engine.render.request('map-stack'), + }) { this._registry = registry; this._sources = indexMapSources(registry.sources); this._activeId = this.isStackAvailable(initialStack) @@ -66,13 +55,7 @@ export class MapSourceController { this._requestRender = requestRender; this._engine = engine; /** Whether the engine takes the registry's prebuilt Cesium objects. */ - this._prebuilt = engine?.kind === 'cesium'; - this._scene = engine - ? null - : createMapScene( - viewer, - createImageryLayer ? { createImageryLayer } : {}, - ); + this._prebuilt = engine.kind === 'cesium'; /** Engine mode: the provider last handed to engine.maps as base imagery. */ this._baseProvider = null; this._abort = new AbortController(); @@ -284,7 +267,7 @@ export class MapSourceController { } async _activateTileset(source, gen) { - if (this._engine && !this._prebuilt) { + if (!this._prebuilt) { this._removeImageryLayer(); await this._engine.maps.setPhotorealTiles(this._describeTiles(source)); return; @@ -298,56 +281,18 @@ export class MapSourceController { ); } if (gen !== this._switchGen) return; - if (this._engine) { - // The engine adds an owned tileset to the scene when it shows it. - if (!source.tileset) this._ownedTilesets.add(tileset); - this._removeImageryLayer(); - await this._engine.maps.setPhotorealTiles( - this._describeTiles(source, tileset), - ); - return; - } - if (!source.tileset && !this._ownedTilesets.has(tileset)) { - tileset.show = false; - this.viewer.scene.primitives.add(tileset); - this._ownedTilesets.add(tileset); - } + if (!source.tileset) this._ownedTilesets.add(tileset); this._removeImageryLayer(); - this._scene.showCredit(source.credit); - this._showTileset(tileset); - this._scene.showGlobe(false); - // Terrain is intentionally untouched while the globe is hidden. - } - - _showTileset(active) { - const tilesets = []; - for (const source of this._sources.values()) - if (source.tileset) tilesets.push(source.tileset); - this._scene.showTileset(active, [...tilesets, ...this._ownedTilesets]); + await this._engine.maps.setPhotorealTiles( + this._describeTiles(source, tileset), + ); } async _activateGlobeStack(stack, gen) { - if (this._engine && !this._prebuilt) - return this._activateDescribedGlobeStack(stack, gen); + if (!this._prebuilt) return this._activateDescribedGlobeStack(stack, gen); const resolution = await this._getImageryProvider(stack); if (gen !== this._switchGen) return; - if (this._engine) return this._activateEngineGlobeStack(resolution, gen); - // Scene shots reapply their map stack at every handoff; the map scene - // keeps the live layer when the resolved provider is unchanged. - if (this._scene.baseProvider !== resolution.provider) { - this._removeImageryLayer(); - this._scene.showBaseImagery(resolution.provider); - } - const source = this._sources.get(resolution.effectiveStackId); - this._scene.showCredit(source?.credit); - // A repeated request still owns a new switch generation. Rebind its - // failure listener so fallback remains live without accumulating listeners. - this._removeImageryErrorListener?.(); - this._removeImageryErrorListener = null; - this._watchProvider(resolution, gen); - this._showTileset(null); - this._scene.showGlobe(true); - return this._finishGlobeStack(resolution, source, gen); + return this._activateEngineGlobeStack(resolution, gen); } async _finishGlobeStack(resolution, source, gen) { @@ -372,7 +317,7 @@ export class MapSourceController { return resolution; } - // engine.maps in the order the viewer path applies them: base imagery and + // engine.maps in order: base imagery and // its credit, no photoreal tiles (the globe shows), then terrain. async _activateEngineGlobeStack(resolution, gen) { const { maps } = this._engine; @@ -466,16 +411,12 @@ export class MapSourceController { /** The live base imagery provider, or null. */ _liveBaseProvider() { - return this._engine ? this._baseProvider : this._scene.baseProvider; + return this._baseProvider; } async _applyTerrain(terrain, result, gen) { - if (this._engine) { - await this._engine.maps.setTerrain( - this._describeTerrain(terrain, result), - ); - if (gen !== this._switchGen) return; - } else applyTerrain(this.viewer, result); + await this._engine.maps.setTerrain(this._describeTerrain(terrain, result)); + if (gen !== this._switchGen) return; this._terrainFallback = result.fallback ? result : null; if (this._replacedTerrain && this._replacedTerrain !== result) { this._dispose(this._replacedTerrain.provider); @@ -708,8 +649,7 @@ export class MapSourceController { this._removeImageryErrorListener?.(); this._removeImageryErrorListener = null; // Engine mode: engine.maps drops the base layer when tiles replace it. - if (this._engine) this._baseProvider = null; - else this._scene.removeBaseImagery(); + this._baseProvider = null; this._activeResolution = null; } _dispose(value) { @@ -732,7 +672,6 @@ export class MapSourceController { this._replacedTerrain = null; this._isSwitching = false; this._removeImageryLayer(); - this._scene?.destroy(); for (const promise of this._imageryProviders.values()) void Promise.resolve(promise).then( (value) => this._dispose(value.provider), @@ -744,8 +683,7 @@ export class MapSourceController { () => {}, ); for (const tileset of this._ownedTilesets) { - if (this._engine) this._engine.maps.releasePhotorealTiles({ tileset }); - else this.viewer.scene.primitives.remove(tileset); + this._engine.maps.releasePhotorealTiles({ tileset }); this._dispose(tileset); } for (const promise of this._tilesets.values()) diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 859068b75..366f72c99 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -1,3 +1,5 @@ +import { createCesiumMaps } from '../globe/cesium/maps.js'; +import { createCesiumCredits } from '../globe/cesium/credits.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { MapSourceController } from './controller.js'; @@ -39,6 +41,7 @@ function fixture(registry, options = {}) { }, }, primitives: { + contains: (value) => primitives.includes(value), add(value) { primitives.push(value); }, @@ -58,16 +61,30 @@ function fixture(registry, options = {}) { }, }, }; - const controller = new MapSourceController(viewer, { - registry, + viewer.creditDisplay = viewer.scene.frameState.creditDisplay; + const mapsOwner = createCesiumMaps(viewer, { + credits: createCesiumCredits(viewer).credits, createImageryLayer: (provider) => ({ provider }), + tilesetRef: { + current: + registry.sources.find((source) => source.tileset)?.tileset ?? null, + }, + }); + const controller = new MapSourceController({ + registry, + engine: { kind: 'cesium', render: { request() {} }, maps: mapsOwner.maps }, onChange: (state) => changes.push(state), ...options, }); + const destroyController = controller.destroy.bind(controller); + controller.destroy = () => { + destroyController(); + mapsOwner.destroy(); + }; return { viewer, controller, imagery, credits, primitives, removed, changes }; } const settle = async () => { - for (let i = 0; i < 20; i++) await Promise.resolve(); + for (let i = 0; i < 60; i++) await Promise.resolve(); }; const descriptor = (id) => ({ id, label: id, kind: 'imagery' }); function publicFixture(options) { @@ -386,20 +403,20 @@ for (const reactivate of [false, true]) { await env.controller.setStack('supplied'); resolve(tileset); await loading; - assert.equal(env.primitives.length, 0); + assert.deepEqual(env.primitives, [supplied]); assert.equal(supplied.show, true); assert.equal(env.controller.getActiveId(), 'supplied'); if (reactivate) { await env.controller.setStack('slow'); await env.controller.setStack('slow'); - assert.deepEqual(env.primitives, [tileset]); + assert.deepEqual(env.primitives, [supplied, tileset]); assert.equal(reads, 1); assert.equal(tileset.show, true); assert.equal(supplied.show, false); } env.controller.destroy(); await settle(); - assert.equal(env.primitives.length, 0); + assert.deepEqual(env.primitives, [supplied]); assert.equal(destroyed, 1); }); } @@ -858,7 +875,7 @@ async function engineFixture({ googleTileset = { show: true }, engine } = {}) { }; } const changes = []; - const controller = new MapSourceController(null, { + const controller = new MapSourceController({ registry, engine, onChange: (state) => changes.push(state), @@ -929,7 +946,7 @@ test('on an engine owned tilesets are released through engine.maps and disposed' const engine = createFakeEngine(); let destroyed = 0; const owned = { show: false, destroy: () => destroyed++ }; - const controller = new MapSourceController(null, { + const controller = new MapSourceController({ engine, registry: { defaultId: 'owned', @@ -960,7 +977,7 @@ test('controller and Cesium engine: photoreal to a globe stack restores imagery viewer.primitiveList.push(google); viewer.scene.globe.show = false; const engine = createCesiumEngine(viewer, { - tilesetRef: () => google, + tilesetRef: { current: google }, requestRender: () => {}, createImageryLayer: (provider) => ({ provider }), }); @@ -1015,7 +1032,7 @@ function navaraFixture() { create: () => built.push(`terrain:${source.terrain.id}`), }; } - const controller = new MapSourceController(null, { registry, engine }); + const controller = new MapSourceController({ registry, engine }); return { engine, controller, built }; }, ); diff --git a/src/maps/imageryHost.js b/src/maps/imageryHost.js deleted file mode 100644 index e3086819f..000000000 --- a/src/maps/imageryHost.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Where draped imagery can go on the current map stack. A globe stack drapes - * on `viewer.imageryLayers`; a 3D-tiles stack hides the globe, and Cesium - * 1.138 drapes imagery onto the tileset's own `imageryLayers` instead. When - * neither exists there is nowhere to drape and the caller says so. - */ - -import { msg } from '../i18n/marker.js'; - -/** Guidance shown when the active map stack cannot host draped imagery. */ -export const NO_IMAGERY_HOST = msg( - 'Hidden by this map source · choose a globe map', -); - -/** - * Resolve the imagery layer collection for the active map stack. - * @param {{ viewer?: object, tileset?: object }} scene - * @returns {{ collection: object | null, kind: 'globe' | 'tileset' | 'none' }} - */ -export function resolveImageryHost({ viewer, tileset } = {}) { - if (viewer?.scene?.globe?.show === true && viewer.imageryLayers) { - return { collection: viewer.imageryLayers, kind: 'globe' }; - } - if (tileset?.imageryLayers && !tileset.isDestroyed?.()) { - return { collection: tileset.imageryLayers, kind: 'tileset' }; - } - return { collection: null, kind: 'none' }; -} diff --git a/src/maps/imageryHost.test.mjs b/src/maps/imageryHost.test.mjs deleted file mode 100644 index edb2c3d0d..000000000 --- a/src/maps/imageryHost.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { NO_IMAGERY_HOST, resolveImageryHost } from './imageryHost.js'; - -test('a shown globe hosts imagery on the viewer collection', () => { - const imageryLayers = { id: 'globe' }; - const host = resolveImageryHost({ - viewer: { scene: { globe: { show: true } }, imageryLayers }, - tileset: { imageryLayers: { id: 'tiles' } }, - }); - assert.deepEqual(host, { collection: imageryLayers, kind: 'globe' }); -}); - -test('a hidden globe hands imagery to the tileset collection', () => { - const imageryLayers = { id: 'tiles' }; - const host = resolveImageryHost({ - viewer: { scene: { globe: { show: false } }, imageryLayers: {} }, - tileset: { imageryLayers, isDestroyed: () => false }, - }); - assert.deepEqual(host, { collection: imageryLayers, kind: 'tileset' }); -}); - -test('no globe and no tileset means nowhere to drape', () => { - assert.deepEqual( - resolveImageryHost({ - viewer: { scene: { globe: { show: false } }, imageryLayers: {} }, - tileset: null, - }), - { collection: null, kind: 'none' }, - ); - assert.deepEqual(resolveImageryHost({}), { collection: null, kind: 'none' }); - assert.deepEqual( - resolveImageryHost({ - viewer: { scene: { globe: { show: false } } }, - tileset: { imageryLayers: {}, isDestroyed: () => true }, - }), - { collection: null, kind: 'none' }, - ); - assert.match(NO_IMAGERY_HOST, /globe map/); -}); diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs index cf0708c09..277392fbc 100644 --- a/src/maps/map-subscription-ownership.test.mjs +++ b/src/maps/map-subscription-ownership.test.mjs @@ -1,19 +1,9 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { MapSourceController } from './controller.js'; function fixture() { return new MapSourceController( - { - scene: { - globe: { show: true }, - requestRender() {}, - frameState: { - creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} }, - }, - primitives: { add() {}, remove() {} }, - }, - imageryLayers: { add() {}, remove() {} }, - }, { registry: { defaultId: 'fixture', @@ -24,7 +14,7 @@ function fixture() { }, ], }, - createImageryLayer: (provider) => ({ provider }), + engine: createFakeEngine(), }, ); } diff --git a/src/overlays/bindOverlayHost.js b/src/overlays/bindOverlayHost.js index feea298d1..3bdf91a78 100644 --- a/src/overlays/bindOverlayHost.js +++ b/src/overlays/bindOverlayHost.js @@ -1,5 +1,16 @@ /** Resolve a retained model's overlay calls against its current attachment. */ export function bindOverlayHost(host, getEngine) { + if (typeof host === 'function') { + return new Proxy( + {}, + { + get: + (_target, name) => + (...args) => + host(getEngine())?.[name]?.(...args), + }, + ); + } if (!host?.forEngine) return host; return Object.fromEntries( Object.entries(host) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 376c6a578..49ab7e9d0 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -2934,19 +2934,8 @@ export function createWorldOverlay(engine) { } return { - registerWorldOverlayPaintLane, - setOverlayEntries, - upsertOverlayEntry, - removeOverlayEntry, - clearOverlaySource, - setOverlaySourceVisible, - getOverlayPaintRect, - hitTestWorldOverlay, - getWorldOverlayDiagnostics, - subscribeWorldOverlayActions, - activateWorldOverlayAction, - initWorldOverlay, - destroyWorldOverlay, + upsertEntry: upsertOverlayEntry, + removeEntry: removeOverlayEntry, init: initWorldOverlay, destroy: destroyWorldOverlay, setEntries: setOverlayEntries, @@ -2964,59 +2953,3 @@ export function createWorldOverlay(engine) { 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 e03a77697..a264ba5a5 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,6 +1,6 @@ +import { worldOverlayFor } from './worldOverlay.js'; 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'; @@ -28,28 +28,7 @@ import { WILDLIFE_LABEL_SOURCE_ID, WILDLIFE_LABEL_SOURCE_OPTIONS, } from '../layers/wildlifeSightings/cards.js'; -import { - AMBIENT_CARD_COLLISION_CAPACITY, - WORLD_OVERLAY_OCCLUDER_SELECTORS, - WORLD_OVERLAY_PAINT_LANES, - clearOverlaySource, - destroyWorldOverlay, - getOverlayPaintRect, - getWorldOverlayDiagnostics, - hitTestWorldOverlay, - activateWorldOverlayAction, - initWorldOverlay, - subscribeWorldOverlayActions, - isOverlayPointVisible, - normalizeOverlayEntry, - paintLaneForOverlayEntry, - registerWorldOverlayPaintLane, - removeOverlayEntry, - selectBoundedOverlayCohort, - setOverlayEntries, - setOverlaySourceVisible, - upsertOverlayEntry, -} from './worldOverlay.js'; +import { AMBIENT_CARD_COLLISION_CAPACITY, WORLD_OVERLAY_OCCLUDER_SELECTORS, WORLD_OVERLAY_PAINT_LANES, isOverlayPointVisible, normalizeOverlayEntry, paintLaneForOverlayEntry, selectBoundedOverlayCohort } from './worldOverlay.js'; class MockEvent { constructor() { @@ -392,6 +371,7 @@ function installMockEnvironment({ }; // Shell modules take the globe engine; the Cesium engine runs on the mock. + viewer.clock = { onTick: new Cesium.Event() }; const engine = createCesiumEngine(viewer); overlayEngine = engine; return { @@ -410,7 +390,7 @@ function installMockEnvironment({ selectorQueries, advanceTime(ms) { currentTime += ms; }, cleanup() { - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); engine.destroy(); delete globalThis.document; delete globalThis.window; @@ -448,26 +428,26 @@ function selectedEntry(id, overrides = {}) { // state, which every later test destroys. test('an attachment buffers before init and rejects writes after destruction', () => { const env = installMockEnvironment(); - setOverlayEntries(overlayEngine, 'pre-init', [selectedEntry('buffered')]); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).entryCount, 1); + worldOverlayFor(overlayEngine)?.setEntries('pre-init', [selectedEntry('buffered')]); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().entryCount, 1); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); // A real teardown does arm the guard, and repeating it must not disarm it. - destroyWorldOverlay(overlayEngine); - destroyWorldOverlay(overlayEngine); - setOverlayEntries(overlayEngine, 'pre-init', [selectedEntry('rejected')]); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).entryCount, 0); + worldOverlayFor(overlayEngine)?.destroy(); + worldOverlayFor(overlayEngine)?.destroy(); + worldOverlayFor(overlayEngine)?.setEntries('pre-init', [selectedEntry('rejected')]); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().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(overlayEngine = env.engine); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine = env.engine)?.init(); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.moveEnd.listeners.size, 1); assert.equal(env.window.listenerCount('gev:cockpit-mode-changed'), 1); @@ -491,7 +471,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM 'both host surfaces share absolute positioning and pointer passthrough', ); - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); assert.equal(env.postRender.listeners.size, 0); assert.equal(env.moveEnd.listeners.size, 0); assert.equal(env.window.listenerCount('gev:cockpit-mode-changed'), 0); @@ -569,7 +549,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); 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'); @@ -601,7 +581,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let canvas = env.document.getElementById('world-overlay-canvas'); let detectionSurface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(canvas.width, 0); @@ -612,7 +592,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(overlayEngine, 'resize-probe', [selectedEntry('probe')]); + worldOverlayFor(overlayEngine)?.setEntries('resize-probe', [selectedEntry('probe')]); env.postRender.raise(); canvas = env.document.getElementById('world-overlay-canvas'); detectionSurface = env.document.getElementById('world-overlay-detection-surface'); @@ -635,8 +615,8 @@ test('shared fade tuning reaches a host-painted card on the next rendered frame' }); try { setKeyholeFadeTuning({ fadeRatio: 0.16, outsideOpacity: 0.05 }); - initWorldOverlay(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'fade-host', [selectedEntry('CARD', { + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('fade-host', [selectedEntry('CARD', { position: new Cesium.Cartesian3(0.85, 0, 0), variant: 'card', edgeFade: 'keyhole', @@ -665,7 +645,7 @@ test('inlined host alpha binding matches combinedOverlayAlpha across channel ran get() { return paintedAlphas.at(-1) ?? 1; }, set(value) { paintedAlphas.push(value); }, }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); 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 }, @@ -675,7 +655,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(overlayEngine, 'alpha-binding', [{ + worldOverlayFor(overlayEngine)?.setEntries('alpha-binding', [{ id: `alpha-${index}`, position: new Cesium.Cartesian3(0, 0, channels.distance * 10_000_000), variant: 'label', @@ -711,7 +691,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const canvas = env.document.getElementById('world-overlay-canvas'); const detectionSurface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(canvas.width, 0); @@ -729,27 +709,27 @@ test('a host dormant since init holds no canvas backing store', () => { assert.equal(detectionSurface.width, 0); assert.equal(detectionSurface.height, 0); - setOverlayEntries(overlayEngine, 'lazy-canvas', [selectedEntry('first-paint')]); + worldOverlayFor(overlayEngine)?.setEntries('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(overlayEngine).paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().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(overlayEngine, 'lazy-canvas'), true); + assert.equal(worldOverlayFor(overlayEngine)?.clearSource('lazy-canvas'), true); env.advanceTime(600); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 0); env.cleanup(); }); test('empty postRender stays inert through production mutation, resize, and clock noise', () => { const env = installMockEnvironment(); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); env.postRender.raise(); const settledCallCount = env.ctx.calls.length; const settledLayoutReads = { ...env.layoutReads }; @@ -762,13 +742,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(overlayEngine).entryCount, 0); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().entryCount, 0); env.cleanup(); }); test('HUD mutations defer occluder selector scans until overlay paint work exists', () => { const env = installMockEnvironment(); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const settledQueries = { ...env.selectorQueries }; const hudMutation = { type: 'childList', @@ -780,7 +760,7 @@ test('HUD mutations defer occluder selector scans until overlay paint work exist assert.deepEqual(env.selectorQueries, settledQueries); env.advanceTime(101); - setOverlayEntries(overlayEngine, 'lazy-occluders', [selectedEntry('paint-work')]); + worldOverlayFor(overlayEngine)?.setEntries('lazy-occluders', [selectedEntry('paint-work')]); env.postRender.raise(); assert.ok(env.selectorQueries.querySelectorAll > settledQueries.querySelectorAll); env.cleanup(); @@ -793,8 +773,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'ticker-noise', [selectedEntry('live-entry')]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('ticker-noise', [selectedEntry('live-entry')]); env.postRender.raise(); // A HUD clock tick: `textContent = ...` swaps TEXT nodes deep inside chrome. @@ -835,8 +815,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'chrome-changes', [selectedEntry('live-entry')]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('chrome-changes', [selectedEntry('live-entry')]); env.postRender.raise(); const observer = env.mutationObservers[0]; @@ -875,8 +855,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'observer-scope', [selectedEntry('live-entry')]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('observer-scope', [selectedEntry('live-entry')]); env.postRender.raise(); // Chrome discovered LATE goes through the refresh-time observe site too. @@ -925,7 +905,7 @@ test('projection records never retain keys for entries that are no longer live', const env = installMockEnvironment(); try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); for (let batch = 0; batch < 4; batch++) { const entries = Array.from({ length: 320 }, (_, index) => ( selectedEntry(`batch-${batch}-${index}`, { @@ -940,15 +920,15 @@ test('projection records never retain keys for entries that are no longer live', assert.ok(liveKeys.has(key), `stale projection record: ${key}`); } }; - setOverlayEntries(overlayEngine, 'churn', entries, { cohortLimit: 8, collisionCapacity: 4 }); + worldOverlayFor(overlayEngine)?.setEntries('churn', entries, { cohortLimit: 8, collisionCapacity: 4 }); env.postRender.raise(); assert.ok(recordMap); - assert.ok(recordMap.size < getWorldOverlayDiagnostics(overlayEngine).entryCount); + assert.ok(recordMap.size < worldOverlayFor(overlayEngine)?.getDiagnostics().entryCount); assertNoStaleKeys(); - assert.equal(removeOverlayEntry(overlayEngine, 'churn', entries[0].id), true); + assert.equal(worldOverlayFor(overlayEngine)?.removeEntry('churn', entries[0].id), true); liveKeys.delete(`churn\u0000${entries[0].id}`); assertNoStaleKeys(); - assert.equal(clearOverlaySource(overlayEngine, 'churn'), true); + assert.equal(worldOverlayFor(overlayEngine)?.clearSource('churn'), true); liveKeys.clear(); assertNoStaleKeys(); } @@ -960,7 +940,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const COHORT = 40; const BATCHES = 60; const batchEntries = (batch) => Array.from({ length: COHORT }, (_, index) => ( @@ -973,13 +953,13 @@ test('the per-domain candidate index stays bounded while identities churn', () = let peakIndexSize = 0; for (let batch = 0; batch < BATCHES; batch++) { - setOverlayEntries(overlayEngine, 'churn', batchEntries(batch), { + worldOverlayFor(overlayEngine)?.setEntries('churn', batchEntries(batch), { cohortLimit: COHORT, collisionCapacity: COHORT, }); env.advanceTime(16); env.postRender.raise(); - peakIndexSize = Math.max(peakIndexSize, getWorldOverlayDiagnostics(overlayEngine).candidateIndexSize); + peakIndexSize = Math.max(peakIndexSize, worldOverlayFor(overlayEngine)?.getDiagnostics().candidateIndexSize); } // `resetFrameDomains` prunes at `live * 4 + 64` and the frame then republishes @@ -1019,15 +999,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(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 }); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('fires', [normalized]); + worldOverlayFor(overlayEngine)?.upsertEntry('fires', { id: 'b', position: position(), variant: 'card' }); + assert.deepEqual(worldOverlayFor(overlayEngine)?.getDiagnostics().entriesBySource, { fires: 2 }); + assert.equal(worldOverlayFor(overlayEngine)?.removeEntry('fires', 'a'), true); + assert.equal(worldOverlayFor(overlayEngine)?.removeEntry('fires', 'missing'), false); + worldOverlayFor(overlayEngine)?.setVisible('fires', false); + assert.equal(worldOverlayFor(overlayEngine)?.clearSource('fires'), true); + assert.deepEqual(worldOverlayFor(overlayEngine)?.getDiagnostics().entriesBySource, { fires: 0 }); env.cleanup(); }); @@ -1036,14 +1016,14 @@ test('accessible actions announce only accepted focus and expose selected state' let acceptedActivations = 0; let staleActivations = 0; let actions = null; - const unsubscribe = subscribeWorldOverlayActions(overlayEngine, (view) => { + const unsubscribe = worldOverlayFor(overlayEngine)?.subscribeActions((view) => { actions = view; }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); assert.deepEqual(actions.items, []); assert.equal(actions.status, ''); assert.equal(actions.announced, 0); - setOverlayEntries(overlayEngine, 'accessible-actions', [selectedEntry('vessel:123', { + worldOverlayFor(overlayEngine)?.setEntries('accessible-actions', [selectedEntry('vessel:123', { interactive: true, accessibilityLabel: 'Focus vessel TEST, MMSI 123', activate: () => { @@ -1058,16 +1038,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(overlayEngine, {}, item.key); + worldOverlayFor(overlayEngine)?.activateAction({}, item.key); assert.equal(acceptedActivations, 0, 'a list from another host cannot activate'); - activateWorldOverlayAction(overlayEngine, owner, item.key); + worldOverlayFor(overlayEngine)?.activateAction(owner, item.key); assert.equal(acceptedActivations, 1); assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123'); assert.equal(actions.announced, 1); - activateWorldOverlayAction(overlayEngine, owner, item.key); + worldOverlayFor(overlayEngine)?.activateAction(owner, item.key); assert.equal(actions.announced, 2, 'a repeated focus is a new announcement'); - setOverlayEntries(overlayEngine, 'accessible-actions', [selectedEntry('vessel:123', { + worldOverlayFor(overlayEngine)?.setEntries('accessible-actions', [selectedEntry('vessel:123', { selected: false, interactive: true, accessibilityLabel: 'Focus vessel TEST, MMSI 123', @@ -1080,7 +1060,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(overlayEngine, owner, actions.items[0].key); + worldOverlayFor(overlayEngine)?.activateAction(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); @@ -1124,7 +1104,7 @@ test('viewport and horizon culling obey explicit policy inputs', () => { test('paint lanes are deterministic across all seven binding lanes', () => { const env = installMockEnvironment(); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); assert.deepEqual(WORLD_OVERLAY_PAINT_LANES, [ 'detection', 'ambient-label', @@ -1143,7 +1123,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(overlayEngine, 'lanes', laneEntries); + worldOverlayFor(overlayEngine)?.setEntries('lanes', laneEntries); env.postRender.raise(); const textOrder = env.ctx.calls .filter(([name]) => name === 'fillText') @@ -1168,15 +1148,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let capturedFrame = null; let paintCount = 0; - const lane = registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { + const lane = worldOverlayFor(overlayEngine)?.registerPaintLane('detection', (frame) => { capturedFrame = frame; paintCount++; frame.ctx.fillText('CUSTOM-DETECTION', 20, 20); }, { id: 'detection', active: true, target: 'detection' }); - setOverlayEntries(overlayEngine, 'cards', [selectedEntry('HOST-CARD', { + worldOverlayFor(overlayEngine)?.setEntries('cards', [selectedEntry('HOST-CARD', { variant: 'label', paintLane: 'ambient-label', })]); @@ -1218,7 +1198,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(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); lane.setActive(true); lane.requestPaint(); assert.equal(env.document.getElementById('world-overlay-canvas'), null); @@ -1232,8 +1212,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(overlayEngine = env.engine); - const lane = registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { + worldOverlayFor(overlayEngine = env.engine)?.init(); + const lane = worldOverlayFor(overlayEngine)?.registerPaintLane('detection', (frame) => { frame.ctx.beginPath(); frame.ctx.rect(0, 0, 1, 1); frame.ctx.clip(); @@ -1259,9 +1239,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let target = null; - registerWorldOverlayPaintLane(overlayEngine, 'ambient-label', (frame) => { + worldOverlayFor(overlayEngine)?.registerPaintLane('ambient-label', (frame) => { target = { surface: frame.surface, ctx: frame.ctx }; }, { id: 'shared-custom', active: true }); env.postRender.raise(); @@ -1311,15 +1291,15 @@ test('UI exclusions stay per-rect: overlapping chrome never merges into a boundi { id: 'command-dock', rect: chrome.bottom }, ], }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let uiRects = null; - registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { + worldOverlayFor(overlayEngine)?.registerPaintLane('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(overlayEngine, 'ambient', [selectedEntry('CLEAR-OF-BOTH', { + worldOverlayFor(overlayEngine)?.setEntries('ambient', [selectedEntry('CLEAR-OF-BOTH', { position: positionAtScreen(80, 100), })]); env.postRender.raise(); @@ -1330,8 +1310,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(overlayEngine).paintedCount, 1); - const painted = getOverlayPaintRect(overlayEngine, 'ambient', 'CLEAR-OF-BOTH'); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); + const painted = worldOverlayFor(overlayEngine)?.getPaintRect('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'); @@ -1368,19 +1348,19 @@ test('UI exclusions ignore chrome the user cannot see', () => { { id: 'pp-toggles', rect: { left: 0, top: 0, width: 0, height: 0 } }, ], }); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let uiRectCount = -1; - registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { uiRectCount = frame.uiRectCount; }, { + worldOverlayFor(overlayEngine)?.registerPaintLane('detection', (frame) => { uiRectCount = frame.uiRectCount; }, { id: 'detection', active: true, target: 'detection', }); - setOverlayEntries(overlayEngine, 'ambient', [selectedEntry('VISIBLE-WORLD')]); + worldOverlayFor(overlayEngine)?.setEntries('ambient', [selectedEntry('VISIBLE-WORLD')]); env.postRender.raise(); assert.equal(uiRectCount, 0, 'hidden, display:none, visibility:hidden and collapsed chrome contribute nothing', ); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); env.cleanup(); }); @@ -1395,16 +1375,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let detectionPaints = 0; - registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { + worldOverlayFor(overlayEngine)?.registerPaintLane('detection', (frame) => { detectionPaints++; frame.ctx.fillText('DETECTION-ALIVE', 10, 10); }, { id: 'detection', active: true, target: 'detection' }); - setOverlayEntries(overlayEngine, 'blocked', [selectedEntry('behind-ui')]); + worldOverlayFor(overlayEngine)?.setEntries('blocked', [selectedEntry('behind-ui')]); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1, + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1, 'the card survives a viewport-filling exclusion instead of being deleted'); assert.equal(detectionPaints, 1); assert.ok( @@ -1434,10 +1414,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'ambient', [selectedEntry('CONTACT')]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('ambient', [selectedEntry('CONTACT')]); env.postRender.raise(); - const { paintedCount } = getWorldOverlayDiagnostics(overlayEngine); + const { paintedCount } = worldOverlayFor(overlayEngine)?.getDiagnostics(); env.cleanup(); return paintedCount; }; @@ -1478,10 +1458,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); let uiRectCount = -1; let detectionPaints = 0; - registerWorldOverlayPaintLane(overlayEngine, 'detection', (frame) => { + worldOverlayFor(overlayEngine)?.registerPaintLane('detection', (frame) => { detectionPaints++; uiRectCount = frame.uiRectCount; }, { @@ -1489,20 +1469,20 @@ test('cockpit keeps its cards and its detection lane, and hides only the tracked active: true, target: 'detection', }); - setOverlayEntries(overlayEngine, 'vessels', [ + worldOverlayFor(overlayEngine)?.setEntries('vessels', [ selectedEntry('VESSEL-A', { position: positionAtScreen(200, 90) }), selectedEntry('VESSEL-B', { position: positionAtScreen(120, 130) }), ]); - setOverlayEntries(overlayEngine, 'trackedReadout', [selectedEntry('TRACKED')], { hideInCockpit: true }); + worldOverlayFor(overlayEngine)?.setEntries('trackedReadout', [selectedEntry('TRACKED')], { hideInCockpit: true }); env.window.dispatch('gev:cockpit-mode-changed', { detail: { active: true } }); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 2, + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 2, 'both ambient cards survive cockpit entry'); - assert.ok(getOverlayPaintRect(overlayEngine, 'vessels', 'VESSEL-A')); - assert.ok(getOverlayPaintRect(overlayEngine, 'vessels', 'VESSEL-B')); - assert.equal(getOverlayPaintRect(overlayEngine, 'trackedReadout', 'TRACKED'), null, + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect('vessels', 'VESSEL-A')); + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect('vessels', 'VESSEL-B')); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('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, @@ -1524,8 +1504,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'cctv', anchors.map((y, index) => selectedEntry(`cam-${index}`, { + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('cctv', anchors.map((y, index) => selectedEntry(`cam-${index}`, { position: positionAtScreen(200, y, 400, 800), protected: false, selected: false, @@ -1534,7 +1514,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(overlayEngine); + const { projectedCount } = worldOverlayFor(overlayEngine)?.getDiagnostics(); env.cleanup(); return projectedCount; }; @@ -1564,21 +1544,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'tracked', [selectedEntry('flight', { + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('tracked', [selectedEntry('flight', { position: () => { getterCalls++; return position(); }, interactive: true, })], { hideInCockpit: true }); env.postRender.raise(); assert.equal(getterCalls, 1); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().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(overlayEngine).paintedCount, 0); - assert.equal(hitTestWorldOverlay(overlayEngine, 200, 120), null); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 0); + assert.equal(worldOverlayFor(overlayEngine)?.hitTest(200, 120), null); env.cleanup(); }); @@ -1600,10 +1580,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'bulk', normalized, { cohortLimit: 3, collisionCapacity: 2 }); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('bulk', normalized, { cohortLimit: 3, collisionCapacity: 2 }); env.postRender.raise(); - const diagnostics = getWorldOverlayDiagnostics(overlayEngine); + const diagnostics = worldOverlayFor(overlayEngine)?.getDiagnostics(); assert.equal(diagnostics.entryCount, 10); assert.equal(diagnostics.candidateCount, 3); assert.ok(diagnostics.selectedCount <= 2); @@ -1613,7 +1593,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const entries = [ ['hawk', 'Red-tailed Hawk', position()], ['maple', 'Red Maple', position()], @@ -1624,11 +1604,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(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS); env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - const rects = entries.map((entry) => getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, entry.id)).filter(Boolean); + const rects = entries.map((entry) => worldOverlayFor(overlayEngine)?.getPaintRect(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'); @@ -1666,7 +1646,7 @@ function cityCard(row, x, y, width = 1000, height = 600, image = { width: 75, he }; } function assertDisjointPaint(entries) { - const rects = entries.map(([source, entry]) => getOverlayPaintRect(overlayEngine, source, entry.id)).filter(Boolean); + const rects = entries.map(([source, entry]) => worldOverlayFor(overlayEngine)?.getPaintRect(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]; @@ -1680,21 +1660,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); 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(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS); - setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, cards, WILDLIFE_OVERLAY_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(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(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8); - const placedLabels = labels.filter((e) => getOverlayPaintRect(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, e.id)); + assert.equal(cards.filter((e) => worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8); + const placedLabels = labels.filter((e) => worldOverlayFor(overlayEngine)?.getPaintRect(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; @@ -1711,41 +1691,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); 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(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, [low, high], WILDLIFE_LABEL_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(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(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [card], WILDLIFE_OVERLAY_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(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(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); + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id)); + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id)); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id), null); } - const before = { ...getOverlayPaintRect(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, card.id) }; + const before = { ...worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id) }; const promoted = cityLabel(citySighting('low', { favesCount: 1_000_000 }), 200, 210, 400, 350); - setOverlayEntries(overlayEngine, WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS); env.postRender.raise(); assertDisjointPaint(all); - 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.ok(worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id)); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id), null); + const after = worldOverlayFor(overlayEngine)?.getPaintRect(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(overlayEngine, 'wildlife-sightings-selected', [selected]); + worldOverlayFor(overlayEngine)?.setEntries('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(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS); + worldOverlayFor(overlayEngine)?.setEntries(WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS); for (const ms of [1, 149, 151]) { env.advanceTime(ms); env.postRender.raise(); @@ -1756,28 +1736,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const changes = []; const options = { ...WILDLIFE_OVERLAY_SOURCE_OPTIONS, onVisibilityChange: (ids) => changes.push([...ids]) }; const r = citySighting('anchor'); - setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options); + worldOverlayFor(overlayEngine)?.setEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options); env.postRender.raise(); assert.deepEqual(changes, []); - setOverlayEntries(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350)], options); + worldOverlayFor(overlayEngine)?.setEntries(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(overlayEngine, WILDLIFE_OVERLAY_SOURCE_ID, false); + worldOverlayFor(overlayEngine)?.setVisible(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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const makeEntries = (sourceOffset, count = 200) => Array.from({ length: count }, (_, index) => { const slot = index % 160; const column = slot % 16; @@ -1798,30 +1778,30 @@ test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregat collisionGroup: 'ambient-card', }; }); - setOverlayEntries(overlayEngine, 'local-datacenters', makeEntries('dc'), { + worldOverlayFor(overlayEngine)?.setEntries('local-datacenters', makeEntries('dc'), { cohortLimit: 160, collisionCapacity: 96, }); - setOverlayEntries(overlayEngine, 'local-dams', makeEntries('dam'), { + worldOverlayFor(overlayEngine)?.setEntries('local-dams', makeEntries('dam'), { cohortLimit: 160, collisionCapacity: 96, }); - setOverlayEntries(overlayEngine, 'firms', makeEntries('fire').slice(0, 18), { + worldOverlayFor(overlayEngine)?.setEntries('firms', makeEntries('fire').slice(0, 18), { cohortLimit: 18, collisionCapacity: 18, }); - setOverlayEntries(overlayEngine, 'ais-live-vessels', makeEntries('vessel', 900), { + worldOverlayFor(overlayEngine)?.setEntries('ais-live-vessels', makeEntries('vessel', 900), { cohortLimit: 900, collisionCapacity: 900, }); - setOverlayEntries(overlayEngine, 'cctv', makeEntries('cctv', 40), { + worldOverlayFor(overlayEngine)?.setEntries('cctv', makeEntries('cctv', 40), { cohortLimit: 40, collisionCapacity: 40, }); env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - const diagnostics = getWorldOverlayDiagnostics(overlayEngine); + const diagnostics = worldOverlayFor(overlayEngine)?.getDiagnostics(); 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); @@ -1834,14 +1814,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'hits', [ + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('hits', [ selectedEntry('under', { interactive: true, zIndex: 1, collisionGroup: 'under' }), selectedEntry('over', { interactive: true, zIndex: 2, collisionGroup: 'over' }), ]); env.postRender.raise(); - const under = getOverlayPaintRect(overlayEngine, 'hits', 'under'); - const over = getOverlayPaintRect(overlayEngine, 'hits', 'over'); + const under = worldOverlayFor(overlayEngine)?.getPaintRect('hits', 'under'); + const over = worldOverlayFor(overlayEngine)?.getPaintRect('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); @@ -1850,16 +1830,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(overlayEngine, 200, 120); + const hit = worldOverlayFor(overlayEngine)?.hitTest(200, 120); assert.equal(hit.entryId, 'over', 'hit test walks painted entries from last to first'); assert.equal(hit.sourceId, 'hits'); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).hitRectCount, 2); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const slot = createFrameSlot(); const entry = createCctvThumbnailOverlayEntry({ id: 'cam-a', @@ -1869,15 +1849,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(overlayEngine, 'cctv', [entry], { + worldOverlayFor(overlayEngine)?.setEntries('cctv', [entry], { cohortLimit: 40, collisionCapacity: 40, moving: true, solveIntervalMs: 125, }); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0, 'no ambient placeholder before frame one'); - assert.equal(hitTestWorldOverlay(overlayEngine, 250, 150, { sourceId: 'cctv' }), null); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 0, 'no ambient placeholder before frame one'); + assert.equal(worldOverlayFor(overlayEngine)?.hitTest(250, 150, { sourceId: 'cctv' }), null); const firstFrame = { name: 'frame-one' }; slot.frame = firstFrame; @@ -1886,10 +1866,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(overlayEngine, 'cctv', 'cam-a'); + const rect = worldOverlayFor(overlayEngine)?.getPaintRect('cctv', 'cam-a'); assert.ok(rect); assert.deepEqual({ w: rect.w, h: rect.h }, { w: 104, h: 77 }); - const hit = hitTestWorldOverlay(overlayEngine, rect.x + 1, rect.y + 1, { sourceId: 'cctv' }); + const hit = worldOverlayFor(overlayEngine)?.hitTest(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)); @@ -1908,7 +1888,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const ambient = createCctvThumbnailOverlayEntry({ id: 'ambient-top', position: new Cesium.Cartesian3(0, 0.9, 0), @@ -1926,10 +1906,10 @@ test('pinned CCTV chrome may paint before frame one while ordinary safe-top poli }); pinned.horizonCull = false; pinned.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries(overlayEngine, 'cctv', [ambient, pinned], { cohortLimit: 40, collisionCapacity: 40 }); + worldOverlayFor(overlayEngine)?.setEntries('cctv', [ambient, pinned], { cohortLimit: 40, collisionCapacity: 40 }); env.postRender.raise(); - 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'); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('cctv', 'ambient-top'), null, 'ambient anchor yields to top HUD band'); + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect('cctv', 'pinned-top'), 'explicit hover pin paints chrome immediately'); env.cleanup(); }); @@ -1938,7 +1918,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const above = createCctvThumbnailOverlayEntry({ id: 'yield-above', position: new Cesium.Cartesian3(-0.5, 0.9, 0), @@ -1955,16 +1935,16 @@ test('safe-top yield culls an uncontested ambient entry at projection, with a be }); below.horizonCull = false; below.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries(overlayEngine, 'cctv', [above, below], { cohortLimit: 40, collisionCapacity: 40 }); + worldOverlayFor(overlayEngine)?.setEntries('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(overlayEngine); + const diagnostics = worldOverlayFor(overlayEngine)?.getDiagnostics(); assert.equal(diagnostics.projectedCount, 1, 'the above-band anchor is culled at projection, not by collision'); - 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)'); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('cctv', 'yield-above'), null, 'anchor above the HUD safe band must yield'); + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect('cctv', 'yield-below'), 'below-band control paints (guard is non-vacuous)'); env.cleanup(); }); @@ -1976,7 +1956,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const entry = createCctvThumbnailOverlayEntry({ id: 'altitude-curve', position: position(), @@ -1995,7 +1975,7 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve' endValue: 0.35, smoothToMid: true, }); - setOverlayEntries(overlayEngine, 'cctv-altitude', [entry], { cohortLimit: 1, collisionCapacity: 0 }); + worldOverlayFor(overlayEngine)?.setEntries('cctv-altitude', [entry], { cohortLimit: 1, collisionCapacity: 0 }); const cases = [ { altitude: 2850, scale: 0.9140625, alpha: 1 }, @@ -2027,7 +2007,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const entry = createCctvThumbnailOverlayEntry({ id: 'scene-presentation', position: position(), title: 'SCENE', frameSlot: { frame: { ready: true }, stamp: 1 }, active: true, @@ -2050,7 +2030,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(overlayEngine, 'scene-presentation', [entry], { + const publish = () => worldOverlayFor(overlayEngine)?.setEntries('scene-presentation', [entry], { cohortLimit: 1, collisionCapacity: 0, }); const frame = () => { @@ -2101,7 +2081,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const entry = createCctvThumbnailOverlayEntry({ id: 'scaled-leader', position: position(), @@ -2114,7 +2094,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(overlayEngine, 'cctv-scaled-leader', [entry], { cohortLimit: 1, collisionCapacity: 0 }); + worldOverlayFor(overlayEngine)?.setEntries('cctv-scaled-leader', [entry], { cohortLimit: 1, collisionCapacity: 0 }); env.postRender.raise(); const scale = env.ctx.calls.find(([name]) => name === 'scale')?.[1]; @@ -2127,7 +2107,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const readySlot = () => ({ frame: { ready: true }, stamp: 1 }); const active = createCctvThumbnailOverlayEntry({ id: 'active', @@ -2146,11 +2126,11 @@ test('active CCTV thumbnail is protected outside the ambient quota and excludes }); ambient.horizonCull = false; ambient.maxDistance = Number.POSITIVE_INFINITY; - setOverlayEntries(overlayEngine, 'cctv-active', [active], { cohortLimit: 1, collisionCapacity: 0 }); - setOverlayEntries(overlayEngine, 'cctv-ambient', [ambient], { cohortLimit: 1, collisionCapacity: 1 }); + worldOverlayFor(overlayEngine)?.setEntries('cctv-active', [active], { cohortLimit: 1, collisionCapacity: 0 }); + worldOverlayFor(overlayEngine)?.setEntries('cctv-ambient', [ambient], { cohortLimit: 1, collisionCapacity: 1 }); env.postRender.raise(); - const activeRect = getOverlayPaintRect(overlayEngine, 'cctv-active', 'active'); - const ambientRect = getOverlayPaintRect(overlayEngine, 'cctv-ambient', 'ambient'); + const activeRect = worldOverlayFor(overlayEngine)?.getPaintRect('cctv-active', 'active'); + const ambientRect = worldOverlayFor(overlayEngine)?.getPaintRect('cctv-ambient', 'ambient'); assert.ok(activeRect, 'active card bypasses a zero ambient quota'); if (ambientRect) { const intersects = ambientRect.x < activeRect.x + activeRect.w @@ -2164,9 +2144,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const position = new Cesium.Cartesian3(0, 0, 0); - setOverlayEntries(overlayEngine, 'ambient-source', [{ + worldOverlayFor(overlayEngine)?.setEntries('ambient-source', [{ id: 'ambient', position, variant: 'card', @@ -2177,7 +2157,7 @@ test('protected tracked entry bypasses ambient quota and excludes its paint foot horizonCull: false, edgeFade: 'none', }], { cohortLimit: 1, collisionCapacity: 1 }); - setOverlayEntries(overlayEngine, 'tracked', [{ + worldOverlayFor(overlayEngine)?.setEntries('tracked', [{ id: 'flights:test', position, variant: 'tracked', @@ -2192,8 +2172,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(overlayEngine, 'tracked', 'flights:test'); - const ambientRect = getOverlayPaintRect(overlayEngine, 'ambient-source', 'ambient'); + const trackedRect = worldOverlayFor(overlayEngine)?.getPaintRect('tracked', 'flights:test'); + const ambientRect = worldOverlayFor(overlayEngine)?.getPaintRect('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 @@ -2207,9 +2187,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const anchor = new Cesium.Cartesian3(0, 0, 0); - setOverlayEntries(overlayEngine, 'protected-cluster', [ + worldOverlayFor(overlayEngine)?.setEntries('protected-cluster', [ selectedEntry('wide-first', { position: anchor, title: 'WIDE FIRST PROTECTED CARD', @@ -2234,9 +2214,9 @@ test('three clustered protected cards fall back to the least-overlapping placeme ], { cohortLimit: 3, collisionCapacity: 0 }); env.postRender.raise(); - 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 first = worldOverlayFor(overlayEngine)?.getPaintRect('protected-cluster', 'wide-first'); + const second = worldOverlayFor(overlayEngine)?.getPaintRect('protected-cluster', 'short-second'); + const third = worldOverlayFor(overlayEngine)?.getPaintRect('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); @@ -2248,8 +2228,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'pool-reuse', [selectedEntry('ambient', { + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('pool-reuse', [selectedEntry('ambient', { selected: false, protected: false, collisionGroup: 'pool-reuse', @@ -2257,14 +2237,14 @@ test('steady frames reuse paint-item and paint-rectangle pool identities', () => env.postRender.raise(); env.advanceTime(200); env.postRender.raise(); - const firstRect = getOverlayPaintRect(overlayEngine, 'pool-reuse', 'ambient'); - const settled = getWorldOverlayDiagnostics(overlayEngine); + const firstRect = worldOverlayFor(overlayEngine)?.getPaintRect('pool-reuse', 'ambient'); + const settled = worldOverlayFor(overlayEngine)?.getDiagnostics(); for (let frame = 0; frame < 12; frame++) { env.advanceTime(16); env.postRender.raise(); } - const secondRect = getOverlayPaintRect(overlayEngine, 'pool-reuse', 'ambient'); - const steady = getWorldOverlayDiagnostics(overlayEngine); + const secondRect = worldOverlayFor(overlayEngine)?.getPaintRect('pool-reuse', 'ambient'); + const steady = worldOverlayFor(overlayEngine)?.getDiagnostics(); assert.ok(firstRect && secondRect); assert.equal(secondRect, firstRect); assert.equal(steady.paintedCount, settled.paintedCount); @@ -2275,28 +2255,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(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'pool-release', [selectedEntry('payload', { + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('pool-release', [selectedEntry('payload', { metadata: { retainedProbe: new Uint8Array(1024) }, })]); env.postRender.raise(); - const firstRect = getOverlayPaintRect(overlayEngine, 'pool-release', 'payload'); - const painting = getWorldOverlayDiagnostics(overlayEngine); + const firstRect = worldOverlayFor(overlayEngine)?.getPaintRect('pool-release', 'payload'); + const painting = worldOverlayFor(overlayEngine)?.getDiagnostics(); assert.ok(firstRect?.entry); assert.ok(painting.paintItemPoolSize >= 1); assert.ok(painting.paintRectPoolSize >= 1); - destroyWorldOverlay(overlayEngine); - const released = getWorldOverlayDiagnostics(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); + const released = worldOverlayFor(overlayEngine)?.getDiagnostics(); assert.equal(firstRect.entry, null); assert.equal(released.paintItemPoolSize, 0); assert.equal(released.paintRectPoolSize, 0); createSceneServices(env.engine); - initWorldOverlay(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'pool-release', [selectedEntry('next-payload')]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('pool-release', [selectedEntry('next-payload')]); env.postRender.raise(); - assert.notEqual(getOverlayPaintRect(overlayEngine, 'pool-release', 'next-payload'), firstRect); + assert.notEqual(worldOverlayFor(overlayEngine)?.getPaintRect('pool-release', 'next-payload'), firstRect); env.cleanup(); }); @@ -2307,9 +2287,9 @@ function togglablePosition(box) { test('a candidate that is not projected this frame stops painting immediately', () => { const env = installMockEnvironment(); - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const box = { hidden: false }; - setOverlayEntries(overlayEngine, 'stamp', [selectedEntry('ambient', { + worldOverlayFor(overlayEngine)?.setEntries('stamp', [selectedEntry('ambient', { selected: false, protected: false, collisionGroup: 'stamp', @@ -2318,13 +2298,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(overlayEngine).paintedCount, 1); - const solveRevision = getWorldOverlayDiagnostics(overlayEngine).solveRevision; + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); + const solveRevision = worldOverlayFor(overlayEngine)?.getDiagnostics().solveRevision; box.hidden = true; env.advanceTime(16); env.postRender.raise(); - const after = getWorldOverlayDiagnostics(overlayEngine); + const after = worldOverlayFor(overlayEngine)?.getDiagnostics(); // 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 +2314,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(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); const first = { hidden: false }; const second = { hidden: false }; - setOverlayEntries(overlayEngine, 'rects', [ + worldOverlayFor(overlayEngine)?.setEntries('rects', [ selectedEntry('aaa', { interactive: true, position: togglablePosition(first) }), selectedEntry('bbb', { interactive: true, position: togglablePosition(second) }), ]); env.postRender.raise(); - const rectA = getOverlayPaintRect(overlayEngine, 'rects', 'aaa'); - const rectB = getOverlayPaintRect(overlayEngine, 'rects', 'bbb'); + const rectA = worldOverlayFor(overlayEngine)?.getPaintRect('rects', 'aaa'); + const rectB = worldOverlayFor(overlayEngine)?.getPaintRect('rects', 'bbb'); assert.ok(rectA && rectB); assert.notEqual(rectA, rectB); @@ -2352,42 +2332,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(overlayEngine).paintedCount, 1); - assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'bbb'), rectA); - assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'aaa'), null); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('rects', 'bbb'), rectA); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('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(overlayEngine).paintedCount, 0); - assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'bbb'), null); - assert.equal(getOverlayPaintRect(overlayEngine, 'rects', 'aaa'), null); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 0); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('rects', 'bbb'), null); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('rects', 'aaa'), null); env.cleanup(); }); test('every exported entry point is inert after destroy', () => { const env = installMockEnvironment(); - initWorldOverlay(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, 'post-destroy', [selectedEntry('live', { interactive: true })]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('post-destroy', [selectedEntry('live', { interactive: true })]); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); assert.equal(env.document.getElementById('world-overlay-root'), 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(() => worldOverlayFor(overlayEngine)?.setEntries('post-destroy', [selectedEntry('zombie')])); + assert.doesNotThrow(() => worldOverlayFor(overlayEngine)?.setEntries('post-destroy', 'not-an-array')); + assert.doesNotThrow(() => worldOverlayFor(overlayEngine)?.upsertEntry('post-destroy', { id: 'zombie-2', position: position() })); + assert.equal(worldOverlayFor(overlayEngine)?.removeEntry('post-destroy', 'live'), false); + assert.equal(worldOverlayFor(overlayEngine)?.clearSource('post-destroy'), false); + assert.doesNotThrow(() => worldOverlayFor(overlayEngine)?.setVisible('post-destroy', false)); + assert.equal(worldOverlayFor(overlayEngine)?.hitTest(200, 120), null); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('post-destroy', 'live'), null); assert.doesNotThrow(() => env.postRender.raise()); - const diagnostics = getWorldOverlayDiagnostics(overlayEngine); + const diagnostics = worldOverlayFor(overlayEngine)?.getDiagnostics(); assert.equal(diagnostics.sourceCount, 0); assert.equal(diagnostics.entryCount, 0); assert.equal(diagnostics.paintedCount, 0); @@ -2405,16 +2385,16 @@ test('every exported entry point is inert after destroy', () => { // 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 })]); + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('post-destroy', [selectedEntry('revived', { interactive: true })]); env.postRender.raise(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 1); - assert.equal(hitTestWorldOverlay(overlayEngine, 200, 120).entryId, 'revived'); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); + assert.equal(worldOverlayFor(overlayEngine)?.hitTest(200, 120).entryId, 'revived'); env.cleanup(); }); test('diagnostics facade preserves the complete binding shape', () => { - const diagnostics = getWorldOverlayDiagnostics(overlayEngine); + const diagnostics = worldOverlayFor(overlayEngine)?.getDiagnostics(); const fields = [ 'sourceCount', 'entryCount', 'candidateCount', 'projectedCount', 'selectedCount', 'fadingCount', 'paintedCount', 'hitRectCount', 'projectionMs', 'solveMs', @@ -2441,7 +2421,7 @@ test('installation titles share persistent card arbitration and retain a bounded parts: { model: { colorFor: () => rgbaFromCss('#ff8000'), installationSurfaceHeightM: () => 0 } }, overlayHost: sceneServicesFor(engine).overlay, }); - initWorldOverlay(overlayEngine = engine); + worldOverlayFor(overlayEngine = engine)?.init(); 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 +2436,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(overlayEngine, source, 'site:0')); - assert.equal(getOverlayPaintRect(overlayEngine, source, 'small'), null); + assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect(source, 'site:0')); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect(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(overlayEngine, source, 'site:0')); + if (i < 10) assert.ok(worldOverlayFor(overlayEngine)?.getPaintRect(source, 'site:0')); } state.enabled = false; markers.hide(); engine.fake.frame(); - assert.equal(getWorldOverlayDiagnostics(overlayEngine).entriesBySource[source], 0); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().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(overlayEngine).entriesBySource[source], 0); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().entriesBySource[source], 0); }); test('promoting an ambient entry to selected paints it once with no fading duplicate', () => { const env = installMockEnvironment(); try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); 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(overlayEngine, 'military-installations', [entry]); paint(); env.advanceTime(200); + worldOverlayFor(overlayEngine)?.setEntries('military-installations', [entry]); paint(); env.advanceTime(200); assert.equal(paint(), 1); - setOverlayEntries(overlayEngine, 'military-installations', [{ ...entry, variant: 'selected', selected: true }]); + worldOverlayFor(overlayEngine)?.setEntries('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(overlayEngine, 'military-installations', [entry]); paint(); env.advanceTime(200); + worldOverlayFor(overlayEngine)?.setEntries('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 +2488,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(overlayEngine = engine); - initWorldOverlay(overlayEngine = engine); + worldOverlayFor(overlayEngine = engine)?.init(); + worldOverlayFor(overlayEngine = engine)?.init(); 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(overlayEngine, 'engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]); + worldOverlayFor(overlayEngine)?.setEntries('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(overlayEngine).paintedCount, 1); - destroyWorldOverlay(overlayEngine); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 1); + worldOverlayFor(overlayEngine)?.destroy(); assert.equal(engine.frames.postRender.numberOfListeners, 0); assert.equal(engine.camera.moveEnd.numberOfListeners, 0); env.cleanup(); @@ -2529,16 +2509,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(overlayEngine = engine); - setOverlayEntries(overlayEngine, 'engine', [ + worldOverlayFor(overlayEngine = engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries('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(overlayEngine, 'engine', 'NEAR') !== null, true); - assert.equal(getOverlayPaintRect(overlayEngine, 'engine', 'FAR'), null, 'hidden behind the globe'); - const rect = getOverlayPaintRect(overlayEngine, 'engine', 'NEAR'); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('engine', 'NEAR') !== null, true); + assert.equal(worldOverlayFor(overlayEngine)?.getPaintRect('engine', 'FAR'), null, 'hidden behind the globe'); + const rect = worldOverlayFor(overlayEngine)?.getPaintRect('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 +2527,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(overlayEngine = engine); + worldOverlayFor(overlayEngine = engine)?.init(); let frame = null; - registerWorldOverlayPaintLane(overlayEngine, 'detection', (value) => { frame = value; }, + worldOverlayFor(overlayEngine)?.registerPaintLane('detection', (value) => { frame = value; }, { id: 'detection', active: true, target: 'detection' }); - setOverlayEntries(overlayEngine, 'engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]); + worldOverlayFor(overlayEngine)?.setEntries('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 +2542,8 @@ 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(overlayEngine = env.viewer), /requires a globe engine/); - assert.throws(() => initWorldOverlay(overlayEngine = null), /requires a globe engine/); - initWorldOverlay(overlayEngine = engine); + assert.throws(() => worldOverlayFor(overlayEngine = env.viewer)?.init(), /requires a globe engine/); + worldOverlayFor(overlayEngine = engine)?.init(); assert.equal(engine.frames.postRender.numberOfListeners, 1); env.cleanup(); }); @@ -2572,16 +2551,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(overlayEngine, "deleted-static")], - ["remove", () => removeOverlayEntry(overlayEngine, "deleted-static", "event")], - ["replace-empty", () => setOverlayEntries(overlayEngine, "deleted-static", [])], + ["clear", () => worldOverlayFor(overlayEngine)?.clearSource("deleted-static")], + ["remove", () => worldOverlayFor(overlayEngine)?.removeEntry("deleted-static", "event")], + ["replace-empty", () => worldOverlayFor(overlayEngine)?.setEntries("deleted-static", [])], ]) { test(`removed static labels stay dormant after ${name}`, () => { const env = installMockEnvironment(); try { - initWorldOverlay(overlayEngine = env.engine); - setOverlayEntries(overlayEngine, - "deleted-static", + worldOverlayFor(overlayEngine = env.engine)?.init(); + worldOverlayFor(overlayEngine)?.setEntries("deleted-static", [ selectedEntry("event", { selected: false, @@ -2598,7 +2576,7 @@ for (const [name, remove] of [ env.postRender.raise(); env.advanceTime(600); env.postRender.raise(); - assert.ok(getWorldOverlayDiagnostics(overlayEngine).selectedCount > 0); + assert.ok(worldOverlayFor(overlayEngine)?.getDiagnostics().selectedCount > 0); remove(); env.postRender.raise(); env.viewer.scene.requestRenderCount = 0; @@ -2607,7 +2585,7 @@ for (const [name, remove] of [ for (const observer of env.resizeObservers) observer.callback(); env.postRender.raise(); } - assert.equal(getWorldOverlayDiagnostics(overlayEngine).paintedCount, 0); + assert.equal(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedCount, 0); assert.equal( env.viewer.scene.requestRenderCount, 0, @@ -2622,13 +2600,12 @@ for (const [name, remove] of [ test("clearing one static source preserves another source in the same label domain", () => { const env = installMockEnvironment(); try { - initWorldOverlay(overlayEngine = env.engine); + worldOverlayFor(overlayEngine = env.engine)?.init(); for (const [source, x] of [ ["left", -0.5], ["right", 0.5], ]) - setOverlayEntries(overlayEngine, - source, + worldOverlayFor(overlayEngine)?.setEntries(source, [ selectedEntry("event", { position: new Cesium.Cartesian3(x, 0, 0), @@ -2644,13 +2621,13 @@ 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(overlayEngine).paintedBySource, { + assert.deepEqual(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedBySource, { left: 1, right: 1, }); - clearOverlaySource(overlayEngine, "left"); + worldOverlayFor(overlayEngine)?.clearSource("left"); env.postRender.raise(); - assert.deepEqual(getWorldOverlayDiagnostics(overlayEngine).paintedBySource, { + assert.deepEqual(worldOverlayFor(overlayEngine)?.getDiagnostics().paintedBySource, { right: 1, }); } finally { @@ -2754,16 +2731,16 @@ for (const makeHarness of GROUND_HARNESSES) { const a = createSceneServices(first.engine); const b = createSceneServices(second.engine); let engine = first.engine; - const host = bindOverlayHost(overlayServices, () => engine); + const host = bindOverlayHost(worldOverlayFor, () => engine); try { - host.setOverlayEntries('retained', [selectedEntry('first')]); + host.setEntries('retained', [selectedEntry('first')]); assert.equal(a.overlay.getDiagnostics().entryCount, 1); assert.equal(b.overlay.getDiagnostics().entryCount, 0); - host.clearOverlaySource('retained'); + host.clearSource('retained'); engine = second.engine; - host.setOverlayEntries('retained', [selectedEntry('second')]); + host.setEntries('retained', [selectedEntry('second')]); a.destroy(); - host.setOverlaySourceVisible('retained', true); + host.setVisible('retained', true); assert.equal(a.overlay.getDiagnostics().entryCount, 0); assert.equal(b.overlay.getDiagnostics().entryCount, 1); } finally { diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index 98622dcbe..ff5b76b4e 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -1,3 +1,4 @@ +import { worldOverlayFor } from './worldOverlay.js'; let overlayEngine; import * as Cesium from 'cesium'; import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; @@ -7,13 +8,7 @@ import { projectToWindow as navaraProjectToWindow, viewProjection as navaraViewProjection, } from '../globe/cameraMath.js'; -import { - AMBIENT_CARD_COLLISION_CAPACITY, - destroyWorldOverlay, - getWorldOverlayDiagnostics, - initWorldOverlay, - setOverlayEntries, -} from './worldOverlay.js'; +import { AMBIENT_CARD_COLLISION_CAPACITY } from './worldOverlay.js'; import { LOCAL_OVERLAY_COHORT_LIMIT, createLocalInfrastructureOverlayEntry, @@ -1322,7 +1317,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((overlayEngine = env.engine)); + worldOverlayFor((overlayEngine = env.engine))?.init(); const workload = PROFILE === 'phase6-detection' ? buildPhase6DetectionWorkload(ENTRY_COUNT) @@ -1377,8 +1372,7 @@ function main() { setDetectionMode('DENSE'); } else if (workload.registrations) { for (const registration of workload.registrations) { - setOverlayEntries( - overlayEngine, + worldOverlayFor(overlayEngine)?.setEntries( registration.sourceId, registration.entries, { @@ -1391,7 +1385,7 @@ function main() { ); } } else { - setOverlayEntries(overlayEngine, 'alloc-probe', entries, { + worldOverlayFor(overlayEngine)?.setEntries('alloc-probe', entries, { collisionCapacity: 96, cohortLimit: 256, moving: true, @@ -1417,7 +1411,7 @@ function main() { globalThis.gc(); for (let i = 0; i < CHUNK_FRAMES; i++) tick(); } - const warm = getWorldOverlayDiagnostics(overlayEngine); + const warm = worldOverlayFor(overlayEngine)?.getDiagnostics(); const detectionWarm = detectionActive ? getDetectionDiagnostics() : null; const painted = detectionActive ? detectionWarm.visibleCount @@ -1440,7 +1434,7 @@ function main() { } const solveRevisionAfter = detectionActive ? getDetectionDiagnostics().solveRevision - : getWorldOverlayDiagnostics(overlayEngine).solveRevision; + : worldOverlayFor(overlayEngine)?.getDiagnostics().solveRevision; const sorted = chunkRates.slice().sort((a, b) => a - b); const maxBytesPerFrame = sorted[sorted.length - 1]; const medianBytesPerFrame = sorted[Math.floor(sorted.length / 2)]; @@ -1476,7 +1470,7 @@ function main() { ); if (detectionActive) destroyDetection(); - destroyWorldOverlay(overlayEngine); + worldOverlayFor(overlayEngine)?.destroy(); env.engine.destroy(); } diff --git a/src/sources/militaryTileGeometry.test.mjs b/src/sources/militaryTileGeometry.test.mjs index 014350427..03934f28e 100644 --- a/src/sources/militaryTileGeometry.test.mjs +++ b/src/sources/militaryTileGeometry.test.mjs @@ -222,10 +222,10 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec clearSelectedEntityContextForLayer() {}, }, }); - h.parts.lifecycle.methods.init(); + h.parts.display.init(); const { state } = h; state.selectedId = selected.id; - const rendering = h.parts.rendering; + const rendering = h.parts.display; for (const members of [[a, b], [b], [a, b]]) { state.records = mergeMilitaryFragments(members, aliases); state.recordById = new Map(state.records.map((r) => [r.id, r])); @@ -298,9 +298,9 @@ test('cold names reconcile selection through enrichment, identical groups at ano clearSelectedEntityContextForLayer() {}, }, }); - h.parts.lifecycle.methods.init(); + h.parts.display.init(); const { state } = h; - const rendering = h.parts.rendering; + const rendering = h.parts.display; const publish = (records, claimSelection = false) => { state.records = records; state.recordById = new Map(records.map((r) => [r.id, r])); diff --git a/src/standalone/application.js b/src/standalone/application.js index 99cfa57fc..3ae576273 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -311,7 +311,6 @@ export function createStandaloneApplication({ const probe = createQaProbe({ engine: next.engine, dataManager: context.data.dataManager, - availability: next.catalog.availability, }); debug.layers = probe; defer(() => { diff --git a/src/testSupport/aircraftRecordingRenderer.mjs b/src/testSupport/aircraftRecordingRenderer.mjs index 7ee7e7a8b..bb5a2dda6 100644 --- a/src/testSupport/aircraftRecordingRenderer.mjs +++ b/src/testSupport/aircraftRecordingRenderer.mjs @@ -34,8 +34,7 @@ export function recordingAircraftRenderer(engine, ports = {}) { view: { camera, scene, - surfaceOwner: engine, - surfaceScene: engine, + engine, frameNumber: () => 1, viewportHeightPx: () => 768, frustumTester: () => () => true, @@ -43,9 +42,6 @@ export function recordingAircraftRenderer(engine, ports = {}) { worldToWindow: () => ({ x: 0, y: 0 }), requestRender: () => calls.push(['requestRender']), on: () => () => {}, - holdFrames() {}, - closeFrameLatch() {}, - releaseFrames() {}, }, fleet: { shown: false, diff --git a/src/testSupport/cesiumMoversEngine.mjs b/src/testSupport/cesiumMoversEngine.mjs index af601d555..2f26a408d 100644 --- a/src/testSupport/cesiumMoversEngine.mjs +++ b/src/testSupport/cesiumMoversEngine.mjs @@ -1,3 +1,4 @@ +import { installDomClasses } from './domClasses.mjs'; // The Cesium engine over a real Cesium camera on a stub scene // (globe/cesium/testing/cameraViewer.mjs), set up for layers that draw // through engine.movers, engine.lines and the follow: Cesium's own @@ -11,14 +12,7 @@ import { createCameraViewer } from '../globe/cesium/testing/cameraViewer.mjs'; // Cesium's Material sorts uniform values with instanceof checks against these // DOM classes; Node has none of them. -for (const name of [ - 'HTMLCanvasElement', - 'HTMLImageElement', - 'HTMLVideoElement', - 'ImageBitmap', - 'OffscreenCanvas', -]) - globalThis[name] ??= class {}; +installDomClasses(); const items = (collection) => Array.from({ length: collection.length }, (_, index) => diff --git a/src/testSupport/domClasses.mjs b/src/testSupport/domClasses.mjs new file mode 100644 index 000000000..873e623a2 --- /dev/null +++ b/src/testSupport/domClasses.mjs @@ -0,0 +1,14 @@ +/** Browser classes used by rendering-library instanceof checks in Node tests. */ +export function installDomClasses(t) { + for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'HTMLVideoElement', + 'ImageBitmap', + 'OffscreenCanvas', + ]) { + if (name in globalThis) continue; + globalThis[name] = class {}; + t?.after(() => delete globalThis[name]); + } +} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 801f30550..78b718cd6 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -110,7 +110,6 @@ export class StyleManager extends ShellScene { ShareLinkManager, CelestialRing, initTrackedReadout, - initWorldOverlay, initDetection, setDetectionStyle, trafficLayer, diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 785f75ac3..fb2d87709 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -5,8 +5,7 @@ import { t } from '../i18n/index.js'; // The camera is the globe engine's, and so is the follow: the followed // entity is engine.camera.following().native (on Cesium also an entity the // app put on viewer.trackedEntity). The viewer, when callers pass one, is -// read only for its clock, and for its tracked entity when the engine -// reports no follow. +// read only for its clock. // Cesium's EasingFunction.CUBIC_IN_OUT (tween.js Cubic.InOut). function cubicInOut(amount) { @@ -14,9 +13,9 @@ function cubicInOut(amount) { return 0.5 * ((amount -= 2) * amount * amount + 2); } -function followedEntity(engine, viewer) { +function followedEntity(engine) { const handle = engine?.camera.following?.(); - return handle ? (handle.native ?? null) : (viewer?.trackedEntity ?? null); + return handle?.native ?? null; } // An engine's own follow (no tracked entity: Navara): its handle, which @@ -24,7 +23,7 @@ function followedEntity(engine, viewer) { function engineFollow(engine, viewer) { const handle = engine?.camera.following?.(); return handle && - !followedEntity(engine, viewer) && + !followedEntity(engine) && typeof handle.target === 'function' && typeof handle.retarget === 'function' ? handle @@ -90,7 +89,7 @@ export function pickViewTarget(engine) { */ export function readCameraTargetFrame(engine, viewer = null) { if (!engine) return null; - const entity = followedEntity(engine, viewer); + const entity = followedEntity(engine); const follow = engineFollow(engine, viewer); const center = entity ? trackedTarget(entity, viewer) @@ -151,9 +150,7 @@ function setCameraTargetFrame(engine, frame, viewer = null) { return true; } try { - const trackedTransform = followedEntity(engine, viewer) - ? camera.transform() - : null; + const trackedTransform = followedEntity(engine) ? camera.transform() : null; camera.lookAt(frame.target, { heading: normalizedHeading(frame.heading), pitch: frame.pitch, @@ -241,14 +238,14 @@ export function createCameraOrientationAnimator( if (!engine) return false; if (duration <= 0) return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer); - const entity = followedEntity(engine, viewer); + const entity = followedEntity(engine); pending = destination; const start = now(); const headingDelta = geo.math.negativePiToPi( destination.heading - frame.heading, ); remove = engine.frames.preUpdate.addEventListener(() => { - if (engine.isDestroyed() || followedEntity(engine, viewer) !== entity) { + if (engine.isDestroyed() || followedEntity(engine) !== entity) { cancel(); return; } @@ -395,7 +392,6 @@ export function bindCameraOrientationControls({ // moveEnd fires when the camera stops, which is where the pick is affordable. subscribe(engine?.camera.moveEnd, refreshTilt); subscribe(engine?.camera.followChanged, animator.cancel); - subscribe(viewer?.trackedEntityChanged, animator.cancel); for (const type of ['pointerdown', 'wheel']) { const canvas = engine?.host.canvas; canvas?.addEventListener?.(type, animator.cancel, { passive: true }); diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index a3a0b93f6..3c1735a57 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -21,6 +21,7 @@ import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; // is. A Viewer's canvas is its scene's. function withEngine(viewer) { viewer.canvas = viewer.scene.canvas; + viewer.clock ??= { onTick: new Cesium.Event() }; viewer.engine = createCesiumEngine(viewer); return viewer; } diff --git a/src/ui/composition.js b/src/ui/composition.js index 5fe4593b0..d88560b99 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -1,3 +1,4 @@ +import { worldOverlayFor } from '../overlays/worldOverlay.js'; /** Compose UI controls with the application's existing engines and layer instances. */ import { StyleManager as ApplicationShell } from './applicationShell.js'; import { LocationSearch } from './location.js'; @@ -26,12 +27,7 @@ import { destroyTrackedReadout, initTrackedReadout, } from '../data/trackedReadout.js'; -import { - activateWorldOverlayAction, - destroyWorldOverlay, - initWorldOverlay, - subscribeWorldOverlayActions, -} from '../overlays/worldOverlay.js'; + import { destroyDetection, initDetection, @@ -83,10 +79,7 @@ export class StyleManager extends ApplicationShell { setKeyholeFadeTuning, destroyTrackedReadout, initTrackedReadout, - destroyWorldOverlay, - initWorldOverlay, - subscribeWorldOverlayActions, - activateWorldOverlayAction, + worldOverlayFor, destroyDetection, initDetection, cycleDetectionMode, diff --git a/src/ui/cyberSonarControls.test.mjs b/src/ui/cyberSonarControls.test.mjs index e389084b1..31487eede 100644 --- a/src/ui/cyberSonarControls.test.mjs +++ b/src/ui/cyberSonarControls.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -59,7 +60,7 @@ test('real runner and facade use visual settings, preserve visibility and return scene: { canvas: new EventTarget() }, }; try { - const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} }); + const runner = createGevActionRunner({ engine: createFakeEngine({ visualStyles: { thermal: [], anime: [] }, contactSonar: true }), styleManager: owner, viewer, dataManager: {} }); const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 }); assert.equal(result.ok, true); assert.equal(result.sonar.intensityPct, 73); @@ -76,7 +77,7 @@ test('real runner and facade use visual settings, preserve visibility and return const zero = await runner('set_cyber_sonar', { intensityPct: 0 }); assert.equal(zero.sonar.intensityPct, 0); assert.equal(zero.sonar.enabled, false); - const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} }); + const unavailable = createGevActionRunner({ engine: createFakeEngine({ visualStyles: { thermal: [], anime: [] }, contactSonar: true }), styleManager: {}, viewer, dataManager: {} }); assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false); } finally { globalThis.document = previousDocument; diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 6f31afcfd..b06de93d8 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -256,7 +257,7 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author clock: { onTick: { addEventListener: () => () => {} } }, scene: { canvas: eventSource }, }; - const runVoiceAction = createGevActionRunner({ + const runVoiceAction = createGevActionRunner({ engine: createFakeEngine({ visualStyles: { thermal: [], anime: [] }, contactSonar: true }), viewer, styleManager: voice.owner, dataManager: {}, diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 467a87474..0aaa11808 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -143,16 +143,9 @@ export class LayerBindings { layer.attachShellServices({ runNavigation: (navigate) => this.runImmediateNavigation(noun, navigate), - imageryHost: this.services.imageryHost, }); this._weatherShellModules.push(layer); } - // Active Fires drapes its far-zoom heatmap on the same imagery host. - const fires = this._dataManager?.layers?.get('local-firms')?.module; - if (typeof fires?.attachShellServices === 'function') { - fires.attachShellServices({ imageryHost: this.services.imageryHost }); - this._weatherShellModules.push(fires); - } } _persistAwarenessSelection(event, cleared = false) { diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 012dcfff4..80e62147a 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -1,3 +1,4 @@ +import { releaseTrackers } from './releaseTrackers.js'; import * as geo from '../geo/index.js'; import { t } from '../i18n/index.js'; import { createStateChannel } from '../app/stateChannel.js'; @@ -327,17 +328,7 @@ export class LocationNavigation { } resetToGlobeView() { - const { - GLOBE_VIEW, - flyToGlobeView, - interruptCameraMotion, - flightsLayer, - militaryFlightsLayer, - satellitesLayer, - aisLiveVesselsLayer, - militaryAwarenessLayer, - rocketLaunchesLayer, - } = this.services; + const { GLOBE_VIEW, flyToGlobeView, interruptCameraMotion } = this.services; // Between two globe engines there is no camera to reset: nothing moves // and no navigation is stamped. if (this._disposed || !this.engine) @@ -351,39 +342,7 @@ export class LocationNavigation { interruptCameraMotion('reset-globe'); this._stopOrbit(); this.cockpitView?.exit({ restoreTracking: false }); - try { - // If awareness controls are unavailable, release the trackers directly. - if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function') - throw new Error('no military awareness controls'); - militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' }); - } catch { - // Keep reset available if Context has not initialized completely. - try { - flightsLayer.stopTracking?.({ origin: 'tool' }); - } catch { - /* best-effort release */ - } - try { - militaryFlightsLayer.stopTracking?.({ origin: 'tool' }); - } catch { - /* best-effort release */ - } - try { - aisLiveVesselsLayer.clearSelection?.(); - } catch { - /* best-effort release */ - } - } - try { - satellitesLayer.stopTracking?.({ origin: 'tool' }); - } catch { - /* best-effort release */ - } - try { - rocketLaunchesLayer.releaseCameraOwnership?.(); - } catch { - /* best-effort release */ - } + releaseTrackers(this.services, { origin: 'tool' }); this.engine.camera.following()?.stop(); this.engine.camera.cancelFlight(); this.engine.camera.release(); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index f1650127a..4acf9ee33 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -1,3 +1,4 @@ +import { releaseTrackers } from './releaseTrackers.js'; import { cancelCameraArrival } from '../data/cameraArrival.js'; import { createStateChannel } from '../app/stateChannel.js'; import { @@ -136,54 +137,10 @@ export class NavigationController { preserveCameraFlight = false, trackingOrigin = 'tool', } = {}) { - const { - flightsLayer, - militaryFlightsLayer, - satellitesLayer, - aisLiveVesselsLayer, - militaryAwarenessLayer, - rocketLaunchesLayer, - } = this.tracking; - let contactSelected = false; - try { - // If awareness controls are unavailable, release the trackers directly. - if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function') - throw new Error('no military awareness controls'); - contactSelected = Boolean( - militaryAwarenessLayer.releaseCameraOwnership?.({ - preserveVesselSelection, - origin: trackingOrigin, - }), - ); - } catch { - try { - flightsLayer.stopTracking?.({ origin: trackingOrigin }); - } catch { - /* best-effort release */ - } - try { - militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin }); - } catch { - /* best-effort release */ - } - if (!preserveVesselSelection) { - try { - aisLiveVesselsLayer.clearSelection?.(); - } catch { - /* best-effort release */ - } - } - } - try { - satellitesLayer.stopTracking?.({ origin: trackingOrigin }); - } catch { - /* best-effort release */ - } - try { - rocketLaunchesLayer.releaseCameraOwnership?.(); - } catch { - /* best-effort release */ - } + const contactSelected = releaseTrackers(this.tracking, { + preserveVesselSelection, + origin: trackingOrigin, + }); this.engine?.camera.following()?.stop(); this.interruptCameraMotion('explicit-navigation'); this.stopOrbit(); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index b95a58303..ab93e2771 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -296,7 +296,7 @@ export function createRecentImageryPanel({ title: t('Use the current view as the box'), onClick: () => { exportError = null; - layer.useCurrentView(viewer); + layer.useCurrentView(); }, }, { diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index 5ed0b6bba..917e206b0 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -1,3 +1,6 @@ +import * as geo from '../geo/index.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createRecentImageryDisplay } from '../layers/recentImagery/render/display.js'; import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -110,7 +113,14 @@ function fakeDocument() { // ---- the real layer, with fakes for everything it injects ------------------ function fixture({ fetchImpl, controller = fakeController() } = {}) { const dom = fakeDocument(); - const renderer = fakeRenderer(); + const engine = createFakeEngine({ width: 1000, height: 500 }); + engine.fake.script('viewRectangle', () => undefined); + const display = createRecentImageryDisplay(engine); + const renderer = { + ...fakeRenderer(), + viewCentre: display.viewCentre, + zoomToFit: display.zoomToFit, + }; const thumbnails = fakeThumbnails(); const catalog = fakeCatalog(); const timers = manualTimers(); @@ -118,7 +128,6 @@ function fixture({ fetchImpl, controller = fakeController() } = {}) { catalog, renderer, thumbnails, - host: () => ({ collection: {}, kind: 'globe' }), now: () => new Date('2026-09-21T15:00:00Z'), setTimeoutImpl: timers.setTimeoutImpl, clearTimeoutImpl: timers.clearTimeoutImpl, @@ -128,7 +137,7 @@ function fixture({ fetchImpl, controller = fakeController() } = {}) { camera: {}, scene: { canvas: { clientWidth: 1000 }, requestRender() {} }, }; - layer.init(viewer); + layer.init(null, { engine }); layer.attachMapStackController(controller); let active = false; const tool = { @@ -208,6 +217,7 @@ function fixture({ fetchImpl, controller = fakeController() } = {}) { }; return { ...dom, + engine, layer, viewer, renderer, @@ -800,14 +810,11 @@ test('the notice line shows the refusal, then errors, then CLEAR, then the Esri test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice line; it asks the layer to fit', async () => { const f = fixture(); const flights = []; - f.viewer.scene.canvas.clientHeight = 500; - f.viewer.scene.ellipsoid = { - cartographicToCartesian: (c) => ({ ...c }), - }; - Object.assign(f.viewer.camera, { - frustum: { fovy: 2 * Math.atan(Math.tan(Math.PI / 6) / 2) }, - flyTo: (options) => flights.push(options), - }); + f.engine.camera.flyTo = (options) => + flights.push({ + ...options, + destination: geo.cartographic.fromCartesian(options.destination), + }); const notice = f.byId('ri-notice'); const zoom = f.byId('ri-zoom-in'); assert.equal(zoom.tagName, 'BUTTON'); diff --git a/src/ui/releaseTrackers.js b/src/ui/releaseTrackers.js new file mode 100644 index 000000000..5a2e8afca --- /dev/null +++ b/src/ui/releaseTrackers.js @@ -0,0 +1,51 @@ +/** Release layer camera owners in their handoff order. */ +export function releaseTrackers(tracking, options) { + const { + flightsLayer, + militaryFlightsLayer, + satellitesLayer, + aisLiveVesselsLayer, + militaryAwarenessLayer, + rocketLaunchesLayer, + } = tracking; + let contactSelected = false; + try { + // If awareness controls are unavailable, release the trackers directly. + if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function') + throw new Error('no military awareness controls'); + contactSelected = Boolean( + militaryAwarenessLayer.releaseCameraOwnership?.({ + ...options, + }), + ); + } catch { + try { + flightsLayer.stopTracking?.({ origin: options.origin }); + } catch { + /* best-effort release */ + } + try { + militaryFlightsLayer.stopTracking?.({ origin: options.origin }); + } catch { + /* best-effort release */ + } + if (!options.preserveVesselSelection) { + try { + aisLiveVesselsLayer.clearSelection?.(); + } catch { + /* best-effort release */ + } + } + } + try { + satellitesLayer.stopTracking?.({ origin: options.origin }); + } catch { + /* best-effort release */ + } + try { + rocketLaunchesLayer.releaseCameraOwnership?.(); + } catch { + /* best-effort release */ + } + return contactSelected; +} diff --git a/src/ui/shellLifetime.test.mjs b/src/ui/shellLifetime.test.mjs index 7f4afc24d..a89637470 100644 --- a/src/ui/shellLifetime.test.mjs +++ b/src/ui/shellLifetime.test.mjs @@ -142,10 +142,8 @@ test('camera-entry listeners are removed before any asynchronous layer cleanup', }); -test('weather shell supplies live imagery hosts to wind and observations and releases them', (t) => { +test('weather shell supplies navigation to wind and observations and releases it', (t) => { const { owner } = bindings(t); - let host = { collection: {}, kind: 'tileset' }; - owner.services.imageryHost = () => host; const attached = new Map(); const ids = ['wind', 'weather-radar', 'weather-satellite', 'weather-lightning']; owner.attachDataManager({ @@ -154,9 +152,7 @@ test('weather shell supplies live imagery hosts to wind and observations and rel } }])), subscribe: () => () => {}, }); - for (const id of ids) assert.equal(attached.get(id).imageryHost(), host); - host = { collection: null, kind: 'none' }; - for (const id of ids) assert.equal(attached.get(id).imageryHost(), host); + for (const id of ids) assert.equal(typeof attached.get(id).runNavigation, 'function'); owner.disconnect(); for (const id of ids) assert.equal(attached.get(id), null); }); diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index 5e853672e..57912eb5b 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -2,7 +2,6 @@ import { createSceneHost } from '../app/sceneHost.js'; import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js'; import { renderGovernorFor } from '../renderGovernor.js'; import { retainsLayerSession } from '../app/layerSession.js'; -import { resolveImageryHost } from '../layers/weather/imageryHost.js'; import { LayerBindings } from './layerBindings.js'; import { VisualSettings } from './visualSettings.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -108,11 +107,6 @@ export class ShellScene extends ShellFacade { viewer, engine, services: { - imageryHost: () => - resolveImageryHost({ - viewer, - tileset: mapStackController?.getImageryHostTileset?.(), - }), cachedGroundFloor: services.cachedGroundFloor, warmGroundFloor: services.warmGroundFloor, cctvLayer: services.cctvLayer, @@ -237,7 +231,7 @@ export class ShellScene extends ShellFacade { _initSceneOverlays() { const { engine } = this._scene; const { - initWorldOverlay, + worldOverlayFor, initDetection, initTrackedReadout, setDetectionStyle, @@ -254,17 +248,16 @@ export class ShellScene extends ShellFacade { // The shared world-overlay host must own its one postRender lane before // detection and tracked-readout initialize. It stays transparent until a // production source explicitly registers entries. - initWorldOverlay(engine); + worldOverlayFor(engine).init(); const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia()); this._overlayActionsUnsubscribe?.(); overlayActionsStore.connect({ activate: (...args) => - this.services.activateWorldOverlayAction(engine, ...args), + this.services.worldOverlayFor(engine).activateAction(...args), }); - this._overlayActionsUnsubscribe = - this.services.subscribeWorldOverlayActions?.(engine, (view) => - overlayActionsStore.publish(view), - ); + this._overlayActionsUnsubscribe = this.services + .worldOverlayFor(engine) + .subscribeActions((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. @@ -401,7 +394,7 @@ export class ShellScene extends ShellFacade { handoff = retainsLayerSession(this._layerSession), } = {}) { if (!this._scene.engine && !this._scene.viewer) return; - const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } = + const { destroyTrackedReadout, worldOverlayFor, destroyDetection } = this.services; this._shareRestoration.destroy(); this._navigation.setScene({}, { retainScene: handoff }); @@ -423,7 +416,7 @@ export class ShellScene extends ShellFacade { this._layerBindings.disconnect(); destroyTrackedReadout(this._scene.engine); destroyDetection(); - destroyWorldOverlay(this._scene.engine); + worldOverlayFor(this._scene.engine).destroy(); this._overlayActionsUnsubscribe?.(); this._overlayActionsUnsubscribe = null; this.celestialRing?.destroy(); diff --git a/src/ui/shellSceneSwap.test.mjs b/src/ui/shellSceneSwap.test.mjs index 93a7ed464..b6ea24d8d 100644 --- a/src/ui/shellSceneSwap.test.mjs +++ b/src/ui/shellSceneSwap.test.mjs @@ -42,7 +42,7 @@ function fakeShell() { _layerSession: null, services: { destroyTrackedReadout: record('destroyTrackedReadout'), - destroyWorldOverlay: record('destroyWorldOverlay'), + worldOverlayFor: () => ({ destroy: record('destroyWorldOverlay') }), destroyDetection: record('destroyDetection'), cctvLayer: 'cctv', }, @@ -314,7 +314,7 @@ test('a hosted shell uses the enclosing transition while direct calls wait outsi shell._sceneHost = host; const first = { engine: shell.engine }; await host.attach(first); - const catalog = { get: () => ({}), unavailableMetadata: [] }; + const catalog = { get: () => ({}) }; await host.register(shellScenePart(shell, { builtOn: first, catalog })); let release; let entered; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index c54f1f6ab..56b068f58 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -407,12 +407,12 @@ export function createGevActionRunner({ typeof annotations?.[method] === 'function'; const renderSupported = (capability, method) => typeof styleManager?.[method] === 'function' && - Boolean(viewer || engineCapabilities(engine)[capability]); + engineCapabilities(engine)[capability]; const postEffectSupported = (effect) => effect === 'bloom' ? renderSupported('bloom', 'setBloom') : typeof styleManager?.setSharpen === 'function' && - Boolean(viewer || typeof engine?.render?.setSharpen === 'function'); + typeof engine?.render?.setSharpen === 'function'; function actionSupported(name) { switch (name) { case 'annotate_map': @@ -582,17 +582,6 @@ export function createGevActionRunner({ throw new Error(`Unknown data layer: ${args.layerId || 'missing'}`); } if (!dataManager.layers.has(layerId)) { - // A layer this engine cannot show (the catalog left it out): a - // refusal that names it, not an unknown-layer error. - if (!viewer && voiceLayer(layerId)) - return { - ...unavailableOnEngine( - 'set_layer_visibility', - engine, - `The ${layerId} layer`, - ), - layerId, - }; if (layerId === 'radio') { return { ok: false, @@ -906,11 +895,7 @@ export function createGevActionRunner({ const style = normalizeStyle(args.style); if (!style) throw new Error(`Unknown visual style: ${args.style || 'missing'}`); - if ( - !viewer && - style !== 'normal' && - !engine.render.visualStyles.includes(style) - ) + if (style !== 'normal' && !engine.render.visualStyles.includes(style)) return unavailableOnEngine(name, engine, `The ${style} visual style`); styleManager.setStyle(style, { userInitiated: true }); return { ok: true, action: 'set_visual_style', style }; @@ -1472,29 +1457,6 @@ export function createGevActionRunner({ } if (name === 'analyst_query') { - // Layers this engine's catalog left out cannot be asked: say so rather - // than answer zero. - const missing = !viewer - ? (Array.isArray(args.layers) ? args.layers : []) - .map(normalizeLayerId) - .filter( - (layerId) => - layerId && - voiceLayer(layerId) && - !dataManager.layers.has(layerId), - ) - : []; - if (missing.length) - return { - ...unavailableOnEngine( - 'analyst_query', - engine, - missing.length === 1 - ? `The ${missing[0]} layer` - : `The ${missing.join(', ')} layers`, - ), - layerIds: missing, - }; return runAnalystQuery( ensureAnalystEngine(), dataManager, @@ -1624,7 +1586,6 @@ export function createGevActionRunner({ if (name === 'get_current_view_state') { return getWorldPacket({ - viewer, engine, styleManager, dataManager, @@ -1732,7 +1693,7 @@ export function createGevActionRunner({ } if (name === 'control_radio') { - return controlRadio(viewer, dataManager, args, { + return controlRadio(dataManager, args, { ...runOptions, placeSearch, }); @@ -2412,12 +2373,7 @@ async function resolveRadioLocation( } /** Voice Radio controls over the Radio layer's public player surface. */ -export async function controlRadio( - viewer, - dataManager, - args = {}, - options = {}, -) { +export async function controlRadio(dataManager, args = {}, options = {}) { const requestedAction = String(args.action || '') .trim() .toLowerCase(); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3ace6c406..4b807be73 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3445,7 +3445,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause, }, }; - let result = await controlRadio({}, dataManager, { + let result = await controlRadio(dataManager, { action: 'play', category: 'news', locationId: 'austin', @@ -3465,7 +3465,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause, assert.deepEqual(calls[0], ['enabled', 'radio', true, { origin: 'voice' }]); state.selected = null; - result = await controlRadio({}, dataManager, { action: 'play' }); + result = await controlRadio(dataManager, { action: 'play' }); assert.equal(result.ok, true); assert.equal(result.radioAction, 'play'); assert.equal(result.stationId, 'nearest'); @@ -3484,7 +3484,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause, { latitude: 91, longitude: -97.7 }, ]) { const callCount = calls.length; - result = await controlRadio({}, dataManager, { + result = await controlRadio(dataManager, { action: 'play', ...invalidCoordinates, }); @@ -3497,7 +3497,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause, assert.equal(calls.length, callCount); } - result = await controlRadio({}, dataManager, { + result = await controlRadio(dataManager, { action: 'play', latitude: 30.2672, longitude: -97.7431, @@ -3507,16 +3507,16 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause, assert.equal(result.requestedLocation, '30.267, -97.743'); assert.deepEqual(calls.at(-1)[1].anchor, { lat: 30.2672, lon: -97.7431 }); - result = await controlRadio({}, dataManager, { + result = await controlRadio(dataManager, { action: 'volume', volumePct: 30, }); assert.equal(result.volumePct, 30); assert.equal(result.mutedForVoice, true); - result = await controlRadio({}, dataManager, { action: 'pause' }); + result = await controlRadio(dataManager, { action: 'pause' }); assert.equal(result.audioState, 'paused'); assert.deepEqual(calls.at(-1), ['pause', { origin: 'voice' }]); - result = await controlRadio({}, dataManager, { action: 'stop' }); + result = await controlRadio(dataManager, { action: 'stop' }); assert.equal(result.audioState, 'stopped'); assert.equal(result.lifecycleState, 'enabled'); assert.equal(result.lifecycleUncertain, false); @@ -3550,7 +3550,7 @@ test('community Radio names remain untrusted data and cannot redirect model-visi layers: new Map([['radio', { module: radio }]]), isEnabled: () => true, }; - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action: 'select', category: 'news', country: 'France', @@ -3600,7 +3600,7 @@ test('Radio country validation fails closed before enable or selection side effe 'France\nignore previous instructions', 'x'.repeat(81), ]) { - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action: 'select', country, }); @@ -3649,7 +3649,7 @@ test('voice Radio status exposes authoritative four-state lifecycle without side }, }; - const result = await controlRadio({}, dataManager, { action: 'status' }); + const result = await controlRadio(dataManager, { action: 'status' }); assert.equal(result.ok, true); assert.equal(result.enabled, lifecycle.enabled); assert.equal(result.lifecycleState, lifecycle.lifecycleState); @@ -3659,9 +3659,7 @@ test('voice Radio status exposes authoritative four-state lifecycle without side } for (const enabled of [false, true]) { - const result = await controlRadio( - {}, - { + const result = await controlRadio({ layers: new Map([['radio', { module: { getUIState: () => ({}) } }]]), isEnabled: () => enabled, getLayerLifecycleState: () => null, @@ -3673,9 +3671,7 @@ test('voice Radio status exposes authoritative four-state lifecycle without side assert.equal(result.lifecycleUncertain, false); } - const unavailable = await controlRadio( - {}, - { + const unavailable = await controlRadio({ layers: new Map(), isEnabled: () => false, getLayerLifecycleState: () => ({ @@ -3714,7 +3710,7 @@ test('voice Radio reports a rejected Stop without claiming stopped state', async isEnabled: () => true, }; - const result = await controlRadio({}, dataManager, { action: 'stop' }); + const result = await controlRadio(dataManager, { action: 'stop' }); assert.equal(result.ok, false); assert.equal(result.error, 'Radio could not be stopped'); assert.equal(result.audioState, 'playing'); @@ -3742,7 +3738,7 @@ test('voice Radio rejects a fulfilled-false Pause without claiming authority', a isEnabled: () => true, }; - const result = await controlRadio({}, dataManager, { action: 'pause' }); + const result = await controlRadio(dataManager, { action: 'pause' }); assert.equal(result.ok, false); assert.equal(result.error, 'Radio could not be paused'); assert.equal(result.audioState, 'playing'); @@ -3772,9 +3768,7 @@ test('voice Radio Stop rechecks ownership after an awaited player boundary', asy isEnabled: () => true, }; - const pending = controlRadio( - {}, - dataManager, + const pending = controlRadio(dataManager, { action: 'stop' }, { isCurrent: () => current, @@ -3828,9 +3822,7 @@ test('interrupting delayed Radio geocoding causes no enable or selection side ef }; try { - const pending = controlRadio( - {}, - dataManager, + const pending = controlRadio(dataManager, { action: 'select', locationQuery: 'Delayed place', @@ -3872,9 +3864,7 @@ test('interrupting delayed Radio disable forwards cancellation and retains enabl }, }; const controller = new AbortController(); - const pending = controlRadio( - {}, - dataManager, + const pending = controlRadio(dataManager, { action: 'disable' }, { signal: controller.signal, @@ -3911,20 +3901,20 @@ test('voice Radio propagates fulfilled-false enable, disable, and auto-enable fa setEnabled: async () => false, }; - let result = await controlRadio({}, dataManager, { action: 'disable' }); + let result = await controlRadio(dataManager, { action: 'disable' }); assert.equal(result.ok, false); assert.equal(result.enabled, true); assert.equal(result.lifecycleState, 'enabled'); assert.equal(result.lifecycleUncertain, false); enabled = false; - result = await controlRadio({}, dataManager, { action: 'enable' }); + result = await controlRadio(dataManager, { action: 'enable' }); assert.equal(result.ok, false); assert.equal(result.enabled, false); assert.equal(result.lifecycleState, 'disabled'); assert.equal(result.lifecycleUncertain, false); - result = await controlRadio({}, dataManager, { action: 'play' }); + result = await controlRadio(dataManager, { action: 'play' }); assert.equal(result.ok, false); assert.equal(result.radioPlaybackRequested, undefined); assert.equal(result.enabled, false); @@ -3969,7 +3959,7 @@ test('voice Radio reconciles uncertain lifecycle authority before direct selecti }, }; - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action: 'select', stationQuery: 'Station 1', }); @@ -4030,7 +4020,7 @@ test('voice Radio Volume waits for settled certain lifecycle authority', async ( }, }; - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action, ...(action === 'volume' ? { volumePct: 35 } : {}), }); @@ -4084,7 +4074,7 @@ test('voice Radio settled OFF blocks Volume without enabling', async () => { }, }; - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action, ...(action === 'volume' ? { volumePct: 35 } : {}), }); @@ -4148,7 +4138,7 @@ test('voice Radio reconciliation failures leave gated player state unchanged', a }, }; - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action, ...(action === 'volume' ? { volumePct: 35 } : {}), ...(action === 'select' ? { stationQuery: 'Station 1' } : {}), @@ -4194,9 +4184,7 @@ test('voice Radio cancellation during lifecycle reconciliation prevents Volume m }, }; - const pending = controlRadio( - {}, - dataManager, + const pending = controlRadio(dataManager, { action: 'volume', volumePct: 35 }, { isCurrent: () => current, @@ -4237,7 +4225,7 @@ test('voice Radio Pause while disabled is a truthful playback-only no-op', async }, }; - const result = await controlRadio({}, dataManager, { action: 'pause' }); + const result = await controlRadio(dataManager, { action: 'pause' }); assert.equal(result.ok, true); assert.equal(result.changed, false); assert.equal(result.enabled, false); @@ -5207,7 +5195,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl }; }); - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action: 'select', locationQuery: 'Hạ Long Bay', }); @@ -5245,7 +5233,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou }), })); - const result = await controlRadio({}, dataManager, { + const result = await controlRadio(dataManager, { action: 'select', locationQuery: 'Kraków', }); @@ -5306,8 +5294,8 @@ function testEngineFor(viewer) { testEngines.set(viewer, engine); return engine; } -function controlRadio(viewer, manager, args, options) { - return runControlRadio(viewer, manager, args, { +function controlRadio(manager, args, options) { + return runControlRadio(manager, args, { placeSearch: testPlaceSearch(), ...options, }); diff --git a/src/voice/gevActionsCapabilities.test.mjs b/src/voice/gevActionsCapabilities.test.mjs index 23bda3e2d..b78e9399b 100644 --- a/src/voice/gevActionsCapabilities.test.mjs +++ b/src/voice/gevActionsCapabilities.test.mjs @@ -119,19 +119,12 @@ for (const kind of ['cesium', 'navara']) { for (const [action, args, call] of cases) { test(`${kind}: ${action} runs with its capability and reports the engine`, async (t) => { const f = fixture(t, kind); - // Cesium must keep working without the optional renderer ports. - if (f.viewer) { - delete f.engine.render.setStyle; - delete f.engine.render.setBloom; - delete f.engine.render.setContactSonar; - } assert.ok(GEV_REALTIME_TOOLS.some((tool) => tool.name === action)); const result = await f.build()(action, args); assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(result.engine, kind); // The camera verbs drive the engine on both. if (call) assert.equal(f.calls[0][0], call); - else assert.equal(getActiveCameraMotion().rig, 'engine'); if (action === 'set_post_processing') { assert.deepEqual( f.calls.map(([name]) => name), @@ -151,7 +144,7 @@ for (const kind of ['cesium', 'navara']) { if (action === 'annotate_map') delete f.annotations.annotate; if (action === 'clear_annotations') delete f.annotations.clear; if (action === 'fly_route') delete f.annotations.list; - if (action === 'move_camera') delete f.engine.camera.lookAt; + if (action === 'move_camera') f.engine.destroy(); if (action === 'set_visual_style') { if (f.viewer) delete f.styleManager.setStyle; else delete f.engine.render.setStyle; @@ -197,7 +190,7 @@ for (const kind of ['cesium', 'navara']) { test(`${kind}: fly_route also requires camera verbs`, async (t) => { const f = fixture(t, kind); - delete f.engine.camera.lookAt; + f.engine.destroy(); assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE); }); diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index af2b07448..148921815 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -121,7 +121,7 @@ test('missing scene capabilities refuse with a structured result', async () => { return target[key]; }, }); - delete engine.camera.lookAt; + engine.destroy(); const runner = runnerOn(engine, { styleManager }); // The runner subscribes to camera handoffs when it is made. touched.length = 0; @@ -157,7 +157,7 @@ test('with a viewer radio still reports a missing layer', async () => { scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, camera: { moveEnd: { addEventListener() {} } }, }, - engine: engineOverAustin(), + engine: engineOverAustin({ visualStyles: { thermal: [] } }), styleManager: {}, dataManager: dataManagerWith({}), }); @@ -478,7 +478,7 @@ test('with a viewer the visual style still applies', async () => { scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, camera: { moveEnd: { addEventListener() {} } }, }, - engine: engineOverAustin(), + engine: engineOverAustin({ visualStyles: { thermal: [] } }), styleManager: navigatingStyleManager({ setStyle: (style) => calls.push(style), }), @@ -489,50 +489,6 @@ test('with a viewer the visual style still applies', async () => { assert.deepEqual(calls, ['thermal']); }); -test('without a viewer a layer the engine lacks refuses instead of throwing', async () => { - const engine = engineOverAustin({ kind: 'navara' }); - const runner = runnerOn(engine, { - dataManager: dataManagerWith({ flights: {} }), - }); - const result = await runner('set_layer_visibility', { - layerId: 'cctv', - enabled: true, - }); - assert.equal(result.ok, false); - assert.equal(result.code, UNAVAILABLE_ON_ENGINE); - assert.equal(result.layerId, 'cctv'); - assert.equal(result.suggestion, '?engine=cesium'); - assert.match( - result.error, - /The cctv layer is not available on the Navara engine yet/, - ); - await assert.rejects( - runner('set_layer_visibility', { layerId: 'no-such-layer', enabled: true }), - /Unknown data layer/, - 'a name no engine knows is still an unknown layer', - ); -}); - -test('without a viewer analyst_query on a layer the engine lacks refuses', async () => { - const engine = engineOverAustin({ kind: 'navara' }); - const runner = runnerOn(engine, { - dataManager: dataManagerWith({ flights: {} }), - }); - const one = await runner('analyst_query', { layers: ['cctv'] }); - assert.equal(one.ok, false); - assert.equal(one.code, UNAVAILABLE_ON_ENGINE); - assert.deepEqual(one.layerIds, ['cctv']); - assert.match( - one.error, - /^The cctv layer is not available on the Navara engine yet/, - ); - const two = await runner('analyst_query', { layers: ['cctv', 'vessels'] }); - assert.match( - two.error, - /^The cctv, ais-live-vessels layers are not available on the Navara engine yet\. Switch to the Cesium engine \(\?engine=cesium\) to use them\.$/, - ); -}); - test('stop_tracking with nothing tracked is a no-op success, also without the layers', async () => { const engine = engineOverAustin({ kind: 'navara' }); const dataManager = dataManagerWith({ @@ -545,21 +501,3 @@ test('stop_tracking with nothing tracked is a no-op success, also without the la assert.equal(result.ok, true); assert.deepEqual(result.released, []); }); - -test('the spoken refusal of an unavailable layer carries its reason', async () => { - const { SPEECH_BUILDERS } = await import('./speech.js'); - const engine = engineOverAustin({ kind: 'navara' }); - const runner = runnerOn(engine, { - dataManager: dataManagerWith({ flights: {} }), - }); - const result = await runner('set_layer_visibility', { - layerId: 'cctv', - enabled: true, - }); - const speech = SPEECH_BUILDERS.set_layer_visibility(result, { - layerId: 'cctv', - enabled: true, - }); - assert.equal(speech.say, result.error); - assert.match(speech.say, /Switch to the Cesium engine/); -}); diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index ca18b21c3..446d50038 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -1259,7 +1259,7 @@ test('real Radio Select cannot enable or play after a same-response Disable comp ui, dataManager, radioLayer, - runner: (_name, args, options) => controlRadio({}, dataManager, args, options), + runner: (_name, args, options) => controlRadio(dataManager, args, options), }); controller.debugLog = () => {}; controller.sendVisualContextIfUseful = async () => false; @@ -1386,7 +1386,7 @@ test('generic same-response Radio visibility disable supersedes delayed Select', radioLayer, runner: (name, args, options) => ( name === 'control_radio' - ? controlRadio({}, dataManager, args, options) + ? controlRadio(dataManager, args, options) : genericRunner(name, args, options) ), }); @@ -1501,7 +1501,7 @@ test('same-response Pause aborts a real manager enable already in flight', async ui, dataManager, radioLayer, - runner: (_name, args, options) => controlRadio({}, dataManager, args, options), + runner: (_name, args, options) => controlRadio(dataManager, args, options), }); controller.debugLog = () => {}; controller.sendVisualContextIfUseful = async () => false; @@ -1629,7 +1629,7 @@ test('Pause and Stop preserve independent dedicated and generic Radio ON across radioLayer, runner: (name, args, options) => ( name === 'control_radio' - ? controlRadio({}, dataManager, args, options) + ? controlRadio(dataManager, args, options) : genericRunner(name, args, options) ), }); @@ -1755,7 +1755,7 @@ test('successful same- or newer-response Stop aborts Select across real manager const controller = new GevRealtimeController({ ui, radioLayer, - runner: (_name, args, options) => controlRadio({}, dataManager, args, options), + runner: (_name, args, options) => controlRadio(dataManager, args, options), }); controller.debugLog = () => {}; controller.sendVisualContextIfUseful = async () => false; @@ -2017,7 +2017,7 @@ test('failed same-response stop does not suppress a successful playback request' const controller = new GevRealtimeController({ ui, runner: async (_name, args) => { - const result = await controlRadio({}, dataManager, args); + const result = await controlRadio(dataManager, args); if (args.action === 'play') await playGate; return result; }, @@ -2121,7 +2121,7 @@ test('failed same-response Stop preserves Select auto-enable held inside real ma ui, dataManager, radioLayer, - runner: (_name, args, options) => controlRadio({}, dataManager, args, options), + runner: (_name, args, options) => controlRadio(dataManager, args, options), }); controller.debugLog = () => {}; controller.sendVisualContextIfUseful = async () => false; @@ -2205,7 +2205,7 @@ test('failed same-response Pause and Disable do not suppress valid older playbac dataManager, radioLayer: dataManager.layers.get('radio').module, runner: async (_name, args) => { - const result = await controlRadio({}, dataManager, args); + const result = await controlRadio(dataManager, args); if (args.action === 'play') await playGate; return result; }, @@ -2351,7 +2351,7 @@ test('direct user Radio OFF aborts an in-flight voice enable before settled publ ui, dataManager, radioLayer, - runner: (_name, args, options) => controlRadio({}, dataManager, args, options), + runner: (_name, args, options) => controlRadio(dataManager, args, options), }); controller.debugLog = () => {}; controller.sendVisualContextIfUseful = async () => false; @@ -3673,7 +3673,7 @@ test('a claimed Space hold barges in on the assistant; a short tap never does', const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } -function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); } +function controlRadio(manager, args, options) { return runControlRadio(manager, args, { placeSearch: testPlaceSearch(), ...options }); } test('voice runs app actions itself and other tools through the catalog', async () => { const actions = []; diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs index 9b3e688f9..7e4b94ec5 100644 --- a/src/voice/hudPlaceLabels.test.mjs +++ b/src/voice/hudPlaceLabels.test.mjs @@ -66,7 +66,12 @@ function createViewer({ latitude, longitude, heightM, groundM = 0 }) { ), }; return { - viewer: createCesiumEngine({ camera, canvas, scene }), + viewer: createCesiumEngine({ + camera, + canvas, + scene, + clock: { onTick: new Cesium.Event() }, + }), state, degreesPerPixel, }; diff --git a/src/voice/imageryActions.js b/src/voice/imageryActions.js index 50d361f8f..7024cdefc 100644 --- a/src/voice/imageryActions.js +++ b/src/voice/imageryActions.js @@ -140,7 +140,6 @@ function waitFor(layer, test, { timeoutMs, isCurrent }) { * Run one `find_imagery` call. * @param {object} deps * @param {object} deps.dataManager - * @param {object} deps.viewer * @param {(areaId: string) => object|null} deps.getArea Stored area by id. * @param {(enabled: boolean) => Promise} deps.enableLayer set_layer_visibility for the layer. * @param {(step: string, label?: string) => void} [deps.progress] Step reports. @@ -151,7 +150,6 @@ function waitFor(layer, test, { timeoutMs, isCurrent }) { export async function findImagery( { dataManager, - viewer, getArea, enableLayer, progress = () => {}, @@ -277,7 +275,7 @@ export async function findImagery( ? layer.boxFromPinAt(box.pin.lon, box.pin.lat) : box ? layer.setBox(box) - : layer.useCurrentView(viewer); + : layer.useCurrentView(); if (!accepted) { const reason = layer.getSnapshot().boxError || 'That area cannot be searched.'; diff --git a/src/voice/voiceWorkflowHarness.mjs b/src/voice/voiceWorkflowHarness.mjs index 195b9ba4f..0b5ea9725 100644 --- a/src/voice/voiceWorkflowHarness.mjs +++ b/src/voice/voiceWorkflowHarness.mjs @@ -172,6 +172,7 @@ export function harness({ engine.fake.script('viewRectangle', () => viewer.camera.computeViewRectangle(), ); + imagery.layer.attachRenderer(null, { engine }); const referents = createReferentRegistry(); let snapshot = pointer; const osmCalls = [];