Remove always-true layer availability and leftover viewer paths

Every layer draws on both engines, so the per-engine availability
machinery always answered yes. The renderer manifest is now the id to
display map plus the wind flow backend; the catalog filter, the voice and
QA-probe refusals and the engine-kind checks go with it. The Navara
notice lists the same layers in the same words.

Paths that read the Cesium viewer behind an engine that is always there
are gone: the Recent Imagery viewer fallbacks, the shell's imagery-host
service (one shared host module remains), the engineless map controller,
viewer gates in voice actions, world packets and camera verbs, the
orientation controls' tracked-entity fallback, and the Bhote Koshi and
FIRMS Cesium-shaped paths. setCameraInputs, which nothing called, leaves
the contract; suspensions remain.

Smaller duplicates go too: one tracker-release helper for navigation and
location, the no-op frame latch, missing-engine guards, one name for the
injected display, flat bikeshare and installations displays, one overlay
host and binder, one name per world-overlay method, and stub-viewer
guards in the Cesium engine. Layers that still read the viewer keep the
shared init(viewer, context) entry.

Behaviour is unchanged on both engines. Viewer lines fall from 849 to
750 and engine-name literals from 32 to 27.
This commit is contained in:
German Khokhlov committed 2026-11-08 17:06:01 +04:00
1 parent 371d848eec
commit 7f478e3815
279 files changed
+1570 -3104

No files matched your search

+2 -9
View File
@@ -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).
+3 -4
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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"
}
+5 -4
View File
@@ -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"
]
+17 -23
View File
@@ -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"]
},
+2 -9
View File
@@ -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);
}
+2 -4
View File
@@ -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.
+15 -12
View File
@@ -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/);
});
@@ -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);
@@ -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; });
+1 -11
View File
@@ -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}`);
}),
);
-38
View File
@@ -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 };
+3 -31
View File
@@ -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,
+1 -29
View File
@@ -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) => {
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
});
}
+1 -1
View File
@@ -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';
+3 -2
View File
@@ -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) {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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),
});
}
+12 -11
View File
@@ -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),
+3 -10
View File
@@ -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,
+2 -4
View File
@@ -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 });
}
+3 -9
View File
@@ -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,
});
}
+2 -9
View File
@@ -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 },
+1 -2
View File
@@ -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,
});
}
+1 -14
View File
@@ -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';
+2 -9
View File
@@ -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'),
+1 -1
View File
@@ -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';
+63 -332
View File
@@ -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<void>}
*/
/** 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 });
}
+3 -123
View File
@@ -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);
});
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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,
+2 -9
View File
@@ -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,
+2 -11
View File
@@ -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,
+4 -4
View File
@@ -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;
+1 -13
View File
@@ -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');
+9 -9
View File
@@ -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,
-1
View File
@@ -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');
+4 -5
View File
@@ -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) =>
+1 -5
View File
@@ -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);
+1 -1
View File
@@ -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,
+4 -6
View File
@@ -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.
+1
View File
@@ -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;
}
+13 -21
View File
@@ -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', () => {
+3 -38
View File
@@ -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;
-14
View File
@@ -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);
});
+5 -6
View File
@@ -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\(/,
+24 -88
View File
@@ -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;
+66 -30
View File
@@ -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() {} },
+3 -15
View File
@@ -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;
}
+16 -1
View File
@@ -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 = [];
-12
View File
@@ -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),
]),
);
}
+2 -10
View File
@@ -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;
+1
View File
@@ -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;
+4 -5
View File
@@ -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,
{
+25 -27
View File
@@ -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 }],
+1 -1
View File
@@ -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';
-2
View File
@@ -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;
+28 -28
View File
@@ -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; },
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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. */
+8 -7
View File
@@ -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);
});
+4 -7
View File
@@ -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 },
@@ -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',
);
});
+2 -2
View File
@@ -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');
+1 -1
View File
@@ -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,
@@ -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()),
} = {},
+5 -21
View File
@@ -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();
},
};
}
-18
View File
@@ -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();
+3 -8
View File
@@ -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: {
+2 -4
View File
@@ -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);
},
+2 -2
View File
@@ -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');
+2 -2
View File
@@ -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) {
+3 -4
View File
@@ -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;
}
+2 -8
View File
@@ -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));
+5 -9
View File
@@ -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)
+1 -5
View File
@@ -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,
-14
View File
@@ -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', () => {
+2 -8
View File
@@ -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() {
+3 -5
View File
@@ -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);
+3
View File
@@ -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 };
+2
View File
@@ -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 };
+1 -1
View File
@@ -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;
-5
View File
@@ -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();
+1
View File
@@ -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,
+3 -6
View File
@@ -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,
+2 -2
View File
@@ -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();
+5 -13
View File
@@ -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();
+7 -11
View File
@@ -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,
+7 -27
View File
@@ -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();
}
},
],
+5 -8
View File
@@ -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,
-3
View File
@@ -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);
+2 -2
View File
@@ -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();
-2
View File
@@ -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',
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more