diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 8848601d1..6a7549912 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -81,6 +81,56 @@ constructed component objects; the component instances themselves remain mutable `ready` means all constructors have returned. Background feed activity and the standalone share-restoration result keep their existing separate contracts. +## Pointer input + +`gods-eye-view/input` exports `inputFor(viewer)`, the scene's one input +dispatcher. Every layer and tool takes its pointer input from it, and so can +the caller: one `ScreenSpaceEventHandler` per scene, and a click picked once +and handed to every subscriber. + +```js +import { inputFor } from 'gods-eye-view/input'; + +const input = inputFor(viewer); +const off = input.onClick((event) => { + if (event.ownerId === 'flights') showFlight(event.id); +}); +// Later: +off(); +``` + +`onClick` runs while no tool holds the pointer (the draw tool, the imagery +box), so a vertex placed on an aircraft is not also a click on it. Its event +reads lazily; a subscriber that reads nothing costs no pick: + +| Member | What it is | +| ------------------ | -------------------------------------------------------------------- | +| `screenPosition` | Where the user clicked, in CSS pixels; for DOM and overlay hit-tests | +| `position` | Where that lands in the pick buffer (corrected under sensor styles) | +| `gesture` | `{ travelPx, durationMs }` of the press, to tell a click from a drag | +| `pick` | `scene.pick` at `position` | +| `hit` | `pick`, or on a coarse pointer the contact under a surface pick | +| `id`, `ownerId` | The hit's pick id and the layer that registered it, or `null` | +| `meta` | The hit's own metadata, when its pick carries any | +| `surface` | Whether the hit is the map itself | +| `drill(limit)` | `scene.drillPick`, shared by every subscriber of the click | +| `pickWindow(w, h)` | `scene.pick` over a `w`×`h` window around `position` | +| `ground()` | The world position under the click, or `null` over the sky | + +`onPointer(type, fn, { lease })` subscribes to `down`, `move`, `up`, `click` +or `dblclick`; with a lease it runs only while that lease is current. A tool +takes its lease with `claimPointer(ownerId)` from the same export (and gives +it back with `releasePointer(lease)`), which also keeps every ambient +subscriber off its clicks; `isPointerFree()` and `pointerOwner()` report who +holds it. `suspendCamera(reason)` and `suppressStockClicks()` return their +own release: camera input and the viewer's stock select and double-click-track +come back when the last hold is released, so two holders cannot undo each +other. The dispatcher owns the camera controller's `enableInputs`: a direct +write (Cesium's camera flights make one when they start and end) is kept, but +reads true only while no hold is out. A caller-supplied `createScene` that creates its own viewer registers +`defer(() => inputFor(viewer).destroy())` right after it; the standalone scene +does this and returns the dispatcher as `scene.input`. + ## Standalone wiring `src/main.js` reads the existing browser configuration and starts diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0d288d8ca..c125ff31c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4397,11 +4397,11 @@ the same navigation authority as every other camera destination. given; re-arming for the other endpoint reuses that lease rather than asking for a second claim, and every release names the lease, so a superseded instance can never free the claim its replacement holds. The claim is held until -the click that uses it has finished being dispatched — every layer binds its -own handler to the same canvas and they all run inside one browser event, so a -claim dropped the instant the endpoint is placed would hand the rest of that -same click to the ambient handlers bound after this one. It is returned on the -next tick after placement, and immediately on cancel, Escape, CLEAR, disable +the click that uses it has finished being dispatched: the input dispatcher +checks the claim once per click, before any subscriber runs, but DOM canvas +listeners (the evidence cards) still see the same browser event afterwards, so +a claim dropped the instant the endpoint is placed would hand them that click. +It is returned on the next tick after placement, and immediately on cancel, Escape, CLEAR, disable and destroy; if another tool already holds it nothing is armed and the row says so. Maneuver-dot selection is an ordinary ambient handler and yields while any tool holds the pointer. FLY runs through the UI shell's immediate-navigation @@ -5631,9 +5631,9 @@ request viewbox and continues searching without that geographic preference. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`. -- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. +- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`), and the scene's input dispatcher (`src/input/dispatcher.js`) hands its clicks, moves and double clicks to the draw tool's leased subscriptions only: **every** ambient subscriber — flights, military, CCTV and its calibration gizmo, vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers — is skipped for that event before anything is picked. A vertex over an aircraft is a vertex, not a selection. - **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing. -- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed. +- **Cesium's own click actions are suppressed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. The tool takes the dispatcher's `suppressStockClicks()` hold for the session; both actions come back, as the same function objects, when the last hold is released (the imagery box takes the same hold). - **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. diff --git a/package.json b/package.json index 802fe88fd..cc2eaf44b 100644 --- a/package.json +++ b/package.json @@ -228,6 +228,7 @@ "./application/layers": "./src/app/constructCatalog.js", "./application/operations": "./src/app/operations.js", "./data/entity-records": "./src/data/entityRecords.js", + "./input": "./src/input/dispatcher.js", "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 285244527..6b9eba226 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -43,6 +43,10 @@ "scripts/build-admin-packs.mjs", "scripts/qa-admin-outlines.mjs", "src/data/inputOwnership.test.mjs", + "src/input/dispatcher.test.mjs", + "src/input/testDoubles.mjs", + "src/input/sensorPick.js", + "src/input/dispatcher.js", "docs/CODE-BOUNDARIES.md", "src/main.js", "docs/APPLICATION.md", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 24d68eb72..5c3e0490f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,6 +10,10 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/inputOwnership.js", + "src/data/pickRegistry.js", + "src/data/scenePick.js", + "src/input/dispatcher.js", + "src/input/sensorPick.js", "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js" ], @@ -872,7 +876,12 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/data/inputOwnership.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1126,7 +1135,11 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/data/inputOwnership.js", + "src/data/pickRegistry.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1260,7 +1273,11 @@ "src/ui/toast.js", "src/ui/stores/sceneInteractions.js", "src/ui/stores/dataPacks.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1348,7 +1365,11 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1415,7 +1436,11 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/data/scenePick.js", + "src/input/sensorPick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1495,7 +1520,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1577,7 +1606,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1617,7 +1650,11 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium", "satellite.js"] }, @@ -1657,7 +1694,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1710,7 +1751,11 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1851,7 +1896,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -1916,7 +1965,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": ["hls.js", "cesium"] }, @@ -1988,7 +2041,12 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/data/inputOwnership.js", + "src/data/scenePick.js", + "src/input/sensorPick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -2027,7 +2085,11 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -2075,7 +2137,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/scenePick.js", + "src/input/sensorPick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -2107,7 +2173,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/scenePick.js", + "src/input/sensorPick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -3255,7 +3325,9 @@ "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", "src/data/local_data/wof_neighborhoods/files.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": [ "hls.js", @@ -3878,7 +3950,9 @@ "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", "src/data/local_data/wof_neighborhoods/files.js", - "src/data/entityRecords.js" + "src/data/entityRecords.js", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": [ "hls.js", @@ -3981,6 +4055,17 @@ "modules": ["src/data/entityRecords.js"], "external": [] }, + "input": { + "exports": ["./input"], + "modules": [ + "src/input/dispatcher.js", + "src/input/sensorPick.js", + "src/data/inputOwnership.js", + "src/data/pickRegistry.js", + "src/data/scenePick.js" + ], + "external": ["cesium"] + }, "layer-lifecycle": { "exports": ["./data/lifecycle"], "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], @@ -4226,7 +4311,12 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/data/inputOwnership.js", + "src/data/scenePick.js", + "src/input/sensorPick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -4555,7 +4645,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/input/sensorPick.js", + "src/input/dispatcher.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -4607,7 +4699,11 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium", "pbf"] }, @@ -4668,7 +4764,12 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/inputOwnership.js", + "src/input/sensorPick.js", + "src/data/scenePick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, @@ -4763,7 +4864,12 @@ "src/data/geoImportImages.js", "src/data/geoImportPins.js", "src/layers/streetLevel/tileMath.js", - "src/layers/streetLevel/view.js" + "src/layers/streetLevel/view.js", + "src/data/inputOwnership.js", + "src/data/scenePick.js", + "src/input/sensorPick.js", + "src/data/pickRegistry.js", + "src/input/dispatcher.js" ], "external": ["cesium"] }, diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index a022fdcc9..905901f47 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -18,9 +18,9 @@ * exceptions inside each. * - Cesium's Viewer binds its OWN left click (select the entity under the * pointer) and double click (track it) on the viewer's handler, outside every - * layer and therefore outside the shared claim. Both are borrowed for the + * layer and therefore outside the shared claim. Both are suppressed for the * session and given back on the way out, and `destroy()` gives back every - * listener, the Cesium handler, the preview data source and the window + * listener, the pointer subscriptions, the preview data source and the window * handle, so the tool can be turned off or the shell disposed without leaving * anything behind. * @@ -37,6 +37,8 @@ import { pointerOwner, releasePointer, } from '../data/inputOwnership.js'; +import { inputFor } from '../input/dispatcher.js'; +import { sensorPickPosition } from '../input/sensorPick.js'; import { pointerOwnerName } from '../data/pointerOwnerNames.js'; import { DRAW_SHAPES, @@ -88,10 +90,8 @@ export function initDrawTool({ let shape = 'area'; let color = 'primary'; let hint = ''; - let handler = null; + let unbindScene = null; let lease = null; - let savedDoubleClick = null; - let savedSingleClick = null; let cursor = null; // last mouse position on the canvas, for the rubber band // Bumped by anything that supersedes an in-flight finish: cancelling, clearing // the board, leaving draw mode, teardown. An `annotate()` that resolves after @@ -228,7 +228,9 @@ export function initDrawTool({ }; const onMove = (event) => { if (!session || session.shape === 'pin' || destroyed) return; - const p = worldAt(event.endPosition); + // The click's own position is sensor-corrected; the rubber band must + // follow the same point the click would place. + const p = worldAt(sensorPickPosition(viewer.scene, event.endPosition)); cursor = p ? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0) : null; @@ -384,50 +386,29 @@ export function initDrawTool({ } function bindSceneHandler() { - if (handler) return; - handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK); - handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - handler.setInputAction(() => { - void finish(); - }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); - // Cesium's Viewer binds BOTH stock click actions on its own handler: the - // single click picks an entity into `viewer.selectedEntity`, the double - // click tracks it. Neither goes through a layer, so neither can be fixed by - // the shared pointer claim — they have to be borrowed outright for the - // session and given back on the way out. Borrowing the single click is what - // stops a vertex placed on a contact from also selecting it. - const stock = viewer.screenSpaceEventHandler; - savedSingleClick = - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null; - savedDoubleClick = - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || - null; - stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); - stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); + if (unbindScene) return; + const input = inputFor(viewer); + const held = { lease: () => lease }; + const offs = [ + input.onPointer('click', onClick, held), + input.onPointer('move', onMove, held), + input.onPointer('dblclick', () => void finish(), held), + // Cesium's Viewer binds BOTH stock click actions on its own handler: the + // single click picks an entity into `viewer.selectedEntity`, the double + // click tracks it. Neither goes through a layer, so neither yields to the + // shared pointer claim — they are suppressed for the session and given + // back on the way out. Suppressing the single click is what stops a + // vertex placed on a contact from also selecting it. + input.suppressStockClicks(), + ]; + unbindScene = () => offs.forEach((off) => off()); listen(document, 'keydown', onKey, true); } function releaseSceneHandler() { - if (handler) { - handler.destroy(); - handler = null; - } - if (savedSingleClick) { - viewer.screenSpaceEventHandler.setInputAction( - savedSingleClick, - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); - savedSingleClick = null; - } - if (savedDoubleClick) { - viewer.screenSpaceEventHandler.setInputAction( - savedDoubleClick, - Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - ); - savedDoubleClick = null; - } - // Drop the keydown listener bound alongside this handler. + unbindScene?.(); + unbindScene = null; + // Drop the keydown listener bound alongside the pointer subscriptions. for (let i = domListeners.length - 1; i >= 0; i -= 1) { const [target, type, listener, options] = domListeners[i]; if (listener !== onKey) continue; @@ -523,7 +504,7 @@ export function initDrawTool({ return { active, destroyed, - sceneHandler: Boolean(handler), + sceneHandler: Boolean(unbindScene), domListeners: domListeners.length, previewDataSources: countPreviewDataSources(viewer), previewEntities: destroyed ? 0 : dataSource.entities.values.length, diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 811142193..b76181db2 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -13,6 +13,7 @@ import { releasePointer, resetPointerOwnership, } from '../data/inputOwnership.js'; +import { fakeInput } from '../input/testDoubles.mjs'; /* ── DOM doubles ───────────────────────────────────────────────────────── */ @@ -227,6 +228,7 @@ function harness({ navigation = null } = {}) { resetPointerOwnership(); const { byId, documentDouble } = fakeDom(); const viewer = fakeViewer(); + const input = fakeInput(viewer, { eventTypes: Cesium.ScreenSpaceEventType }); const annotations = fakeAnnotations(); const previousDocument = globalThis.document; const previousWindow = globalThis.window; @@ -256,7 +258,9 @@ function harness({ navigation = null } = {}) { controls, /** A control's change, as the view sends it. */ act: (name, ...args) => controls.actions?.[name]?.(...args), + input, restore() { + input.destroy(); resetPointerOwnership(); if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; @@ -468,7 +472,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act assert.equal(isPointerFree(), true, 'the pointer is free'); const after = h.tool.diagnostics(); assert.equal(after.destroyed, true); - assert.equal(after.sceneHandler, false, 'the Cesium handler is gone'); + assert.equal(after.sceneHandler, false, 'the pointer subscriptions are gone'); assert.equal(after.domListeners, 0, 'every DOM listener came off'); assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown'); assert.equal(h.controls.actions, null, 'and the controls are disconnected'); diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 6e6414878..4bb651896 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; import * as render from '../../renderGovernor.js'; import * as sprites from '../../data/spriteOrder.js'; @@ -29,8 +28,5 @@ export function createApplicationFirms({ surface, ...options }) { clearSource: overlays.clearOverlaySource, hitTest: overlays.hitTestWorldOverlay, }, - screenSpaceEventHandlerFactory: - options.screenSpaceEventHandlerFactory ?? - ((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)), }); } diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index ccca18850..56387a80f 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,8 +1,6 @@ -import * as Cesium from 'cesium'; 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'; /** Wire the S2 Underground GhostMaps layer into the application catalog. */ export function createApplicationGhostMaps(options) { @@ -14,10 +12,7 @@ export function createApplicationGhostMaps(options) { hitTest: overlays.hitTestWorldOverlay, }, openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), - screenSpaceEventHandlerFactory: (viewer) => - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), picking, - pointer: { isPointerFree }, ...options, }); } diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js index f5110f630..dd1230ce4 100644 --- a/src/app/layers/perimeters.js +++ b/src/app/layers/perimeters.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { createFirePerimetersLayer, createInciwebIndexSource, @@ -6,7 +5,6 @@ import { } 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'; /** Wire WFIGS fire perimeters into the application catalog. */ export function createApplicationFirePerimeters(options) { @@ -20,10 +18,7 @@ export function createApplicationFirePerimeters(options) { inciwebSource: createInciwebIndexSource(), inciwebPublications: createInciwebPublicationSource(), openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), - screenSpaceEventHandlerFactory: (viewer) => - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), picking, - pointer: { isPointerFree }, ...options, }); } diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 268216eaa..ec0eed217 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -5,7 +5,6 @@ import { createKartaViewProvider } from '../../layers/streetLevel/providers/kart import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; -import * as input from '../../data/inputOwnership.js'; import * as scenePick from '../../data/scenePick.js'; import * as render from '../../renderGovernor.js'; @@ -37,6 +36,6 @@ export function createApplicationStreetLevel({ }) { return createStreetLevelLayer({ providers, - services: { sprites, picking, input, scenePick, render }, + services: { sprites, picking, scenePick, render }, }); } diff --git a/src/app/layers/submarineCables.js b/src/app/layers/submarineCables.js index b84c072db..1f6dd7d81 100644 --- a/src/app/layers/submarineCables.js +++ b/src/app/layers/submarineCables.js @@ -1,12 +1,9 @@ -import * as Cesium from 'cesium'; import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js'; import { overlayHost } from './overlayHost.js'; /** Wire cable source geometry to the application overlay host. */ export function createApplicationCables(options) { return createSubmarineCableLayer({ overlayHost, - screenSpaceEventHandlerFactory: (canvas) => - new Cesium.ScreenSpaceEventHandler(canvas), mapStackEventTarget: typeof window !== 'undefined' ? window : null, ...options, }); diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..ab70da51a 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -9,6 +9,7 @@ import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; import { initLogoGaze } from '../logoGaze.js'; +import { inputFor } from '../input/dispatcher.js'; import { uninstallRenderGovernor, governorRequestRender, @@ -57,6 +58,10 @@ export async function createApplicationScene({ uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); + // Before any layer binds a click: every layer and tool shares this one. + // Deferred after the viewer, so it is released before the viewer goes. + const input = inputFor(viewer); + defer(() => input.destroy()); defer(installTrackpadPinchZoom(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); @@ -128,5 +133,5 @@ export async function createApplicationScene({ }); signal.throwIfAborted(); - return { viewer, tileset, mapStackController, operations }; + return { viewer, tileset, mapStackController, operations, input }; } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 2c0d356b2..f8c26d94a 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -389,7 +389,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => } const vesselClick = body( vessels, - /handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/, + /inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/, 'vessel click', ); ordered(vesselClick, [ @@ -405,7 +405,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => assert.match(vesselFocus, /requestWorldFocus\(\{/); const fireClick = body( firms, - /_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/, + /input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/, 'fire click', ); ordered(fireClick, [ diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 529cf545d..f9a514a5b 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -34,6 +34,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { ensureGeoidReady, geoidHeight } from './geoid.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { applyVesselOverlayPolicy } from './vesselLabels.js'; +import { fakeInput } from '../input/testDoubles.mjs'; import { layerFeedState } from './manager.js'; import { presentSelectedVessel, @@ -42,7 +43,10 @@ import { import { createIconSet } from './iconSet.js'; test('open feed with vessels is healthy (null)', () => { - assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null); + assert.equal( + deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), + null, + ); }); test('open feed without a received message is not a fresh healthy update', () => { @@ -74,7 +78,10 @@ test('snapshot classification distinguishes raw rows from accepted vessel positi assert.equal(snapshot.lastMessageAt, 123); assert.equal(snapshot.rawRowCount, 3); assert.equal(snapshot.acceptedRowCount, 1); - assert.deepEqual(snapshot.acceptedRows.map((row) => row.mmsi), ['valid']); + assert.deepEqual( + snapshot.acceptedRows.map((row) => row.mmsi), + ['valid'], + ); assert.equal(snapshot.error, null); }); @@ -101,27 +108,45 @@ test('accepted cached rows remain usable while transport reconnects', () => { test('missing key with no rows surfaces a clean reason', () => { assert.equal( - deriveAisFeedError({ status: 'missing-key', error: 'AISSTREAM_API_KEY is not set' }, 0), + deriveAisFeedError( + { status: 'missing-key', error: 'AISSTREAM_API_KEY is not set' }, + 0, + ), 'AISSTREAM_API_KEY not set', ); }); test('socket error with no rows surfaces "feed down"', () => { - assert.equal(deriveAisFeedError({ status: 'error', error: 'AISStream websocket error' }, 0), 'feed down'); + assert.equal( + deriveAisFeedError( + { status: 'error', error: 'AISStream websocket error' }, + 0, + ), + 'feed down', + ); }); test('closed feed with no rows surfaces "feed disconnected"', () => { - assert.equal(deriveAisFeedError({ status: 'closed', error: null }, 0), 'feed disconnected'); + assert.equal( + deriveAisFeedError({ status: 'closed', error: null }, 0), + 'feed disconnected', + ); }); test('non-open status but rows still flowing is treated as stale, not down (null)', () => { // e.g. reconnecting/refreshing while a cached buffer still serves rows. - assert.equal(deriveAisFeedError({ status: 'connecting', error: null }, 30), null); + assert.equal( + deriveAisFeedError({ status: 'connecting', error: null }, 30), + null, + ); }); test('unknown status falls back to a generic reason and appends server detail', () => { assert.equal( - deriveAisFeedError({ status: 'weird-state', error: 'something specific' }, 0), + deriveAisFeedError( + { status: 'weird-state', error: 'something specific' }, + 0, + ), 'feed unavailable (something specific)', ); }); @@ -136,7 +161,10 @@ test('missing status treated as healthy (older/other payload shapes) (null)', () // degraded feed must stay visible even while cached vessels are still drawn. test("'live' is the healthy status and reads exactly like the older 'open'", () => { - assert.equal(deriveAisFeedError({ status: 'live', lastMessageAt: 1, error: null }, 42), null); + assert.equal( + deriveAisFeedError({ status: 'live', lastMessageAt: 1, error: null }, 42), + null, + ); assert.equal( deriveAisFeedError({ status: 'live', lastMessageAt: null, error: null }, 0), 'awaiting first AIS message…', @@ -149,7 +177,10 @@ test("'live' is the healthy status and reads exactly like the older 'open'", () test('a stale feed is surfaced even though cached vessels are still on screen', () => { assert.equal( - deriveAisFeedError({ status: 'stale', silentForMs: 184_000, lastMessageAt: 5 }, 4_812), + deriveAisFeedError( + { status: 'stale', silentForMs: 184_000, lastMessageAt: 5 }, + 4_812, + ), 'feed silent 184s — no AIS data', ); }); @@ -166,7 +197,10 @@ test('reconnecting reports which attempt is in flight, rows or no rows', () => { deriveAisFeedError({ status: 'reconnecting', reconnectAttempt: 2 }, 900), 'reconnecting to feed… (attempt 2)', ); - assert.equal(deriveAisFeedError({ status: 'reconnecting' }, 0), 'reconnecting to feed…'); + assert.equal( + deriveAisFeedError({ status: 'reconnecting' }, 0), + 'reconnecting to feed…', + ); }); test('DOWN is a visible terminal state, not a silent retry', () => { @@ -185,15 +219,19 @@ test('a rejected API key reads as actionable, not as a countdown', () => { const runtime = makeFakeAisRuntime(1_000_000); _setAisRuntimeForTest(runtime.runtime); try { - _applyAisFeedSnapshotForTest({}, { - status: 'auth-failed', - reconnectAttempt: 3, - nextAttemptAt: 1_000_000 + 3_600_000, - refreshing: true, - rows: [], - }); + _applyAisFeedSnapshotForTest( + {}, + { + status: 'auth-failed', + reconnectAttempt: 3, + nextAttemptAt: 1_000_000 + 3_600_000, + refreshing: true, + rows: [], + }, + ); assert.equal( - aisLiveVesselsLayer.getStats().retryInSec, 0, + aisLiveVesselsLayer.getStats().retryInSec, + 0, 'an hour-long countdown would imply waiting is the fix', ); } finally { @@ -205,17 +243,23 @@ test('a degraded feed reports its retry countdown to the chip', () => { const runtime = makeFakeAisRuntime(1_000_000); _setAisRuntimeForTest(runtime.runtime); try { - _applyAisFeedSnapshotForTest({}, { - status: 'reconnecting', - reconnectAttempt: 3, - nextAttemptAt: 1_060_000, // 60s past the fake clock - refreshing: true, - rows: [], - }); + _applyAisFeedSnapshotForTest( + {}, + { + status: 'reconnecting', + reconnectAttempt: 3, + nextAttemptAt: 1_060_000, // 60s past the fake clock + refreshing: true, + rows: [], + }, + ); assert.equal(aisLiveVesselsLayer.getStats().retryInSec, 60); // A feed with nothing scheduled must not invent a countdown. - _applyAisFeedSnapshotForTest({}, { status: 'live', lastMessageAt: 5, rows: [] }); + _applyAisFeedSnapshotForTest( + {}, + { status: 'live', lastMessageAt: 5, rows: [] }, + ); assert.equal(aisLiveVesselsLayer.getStats().retryInSec, 0); } finally { _setAisRuntimeForTest(null); @@ -229,7 +273,11 @@ test('a degraded feed keeps its reason through snapshot classification', () => { refreshing: true, rows: [{ mmsi: 'cached', lat: 29.7, lon: -95.1 }], }); - assert.equal(snapshot.acceptedRowCount, 1, 'the cached vessel is still drawable'); + assert.equal( + snapshot.acceptedRowCount, + 1, + 'the cached vessel is still drawable', + ); assert.equal(snapshot.error, 'feed down — retrying slowly (attempt 5)'); }); @@ -257,7 +305,9 @@ function makeFakeAisRuntime(startMs = 1000) { return { runtime, scheduled, - get nowMs() { return nowMs; }, + get nowMs() { + return nowMs; + }, advance(deltaMs) { nowMs += deltaMs; let fired; @@ -296,11 +346,14 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti lastMessageAt: 123, }); try { - const result = _applyAisFeedSnapshotForTest({}, { - status: 'open', - lastMessageAt: 789, - rows: [{ mmsi: 'invalid', lat: 'bad', lon: 4 }], - }); + const result = _applyAisFeedSnapshotForTest( + {}, + { + status: 'open', + lastMessageAt: 789, + rows: [{ mmsi: 'invalid', lat: 'bad', lon: 4 }], + }, + ); assert.equal(result.reconciled, false); assert.deepEqual(_getVesselFeedStateForTest(), { count: 1, @@ -335,11 +388,14 @@ test('open first load stays LOADING for one bounded first-connect grace period', _setVesselStateForTest({ viewer: {}, records: [] }); try { _beginAisSessionForTest(); - const result = _applyAisFeedSnapshotForTest({}, { - status: 'open', - lastMessageAt: null, - rows: [], - }); + const result = _applyAisFeedSnapshotForTest( + {}, + { + status: 'open', + lastMessageAt: null, + rows: [], + }, + ); assert.equal(result.reconciled, false); const feed = _getVesselFeedStateForTest(); assert.equal(feed.loaded, true); @@ -383,9 +439,15 @@ test('open and connecting polls do not restart the first-connect deadline', () = const initial = _getVesselFeedStateForTest(); clock.advance(12000); _applyAisFeedSnapshotForTest({}, { status: 'connecting', rows: [] }); - _applyAisFeedSnapshotForTest({}, { status: 'open', lastMessageAt: 9, rows: [] }); + _applyAisFeedSnapshotForTest( + {}, + { status: 'open', lastMessageAt: 9, rows: [] }, + ); const afterPolls = _getVesselFeedStateForTest(); - assert.equal(afterPolls.firstConnectStartedAt, initial.firstConnectStartedAt); + assert.equal( + afterPolls.firstConnectStartedAt, + initial.firstConnectStartedAt, + ); assert.equal(afterPolls.firstConnectDeadline, initial.firstConnectDeadline); assert.equal(clock.activeCount(), 1); assert.equal(afterPolls.loading, true); @@ -402,11 +464,14 @@ test('definitive AIS transport failures end grace immediately', () => { _setVesselStateForTest({ viewer: {}, records: [] }); try { _beginAisSessionForTest(); - _applyAisFeedSnapshotForTest({}, { - status: 'missing-key', - error: 'AISSTREAM_API_KEY is not set', - rows: [], - }); + _applyAisFeedSnapshotForTest( + {}, + { + status: 'missing-key', + error: 'AISSTREAM_API_KEY is not set', + rows: [], + }, + ); const feed = _getVesselFeedStateForTest(); assert.equal(feed.loading, false); assert.equal(feed.status, 'unavailable'); @@ -431,16 +496,21 @@ test('first accepted position ends grace and warm data survives later open silen _setVesselStateForTest({ viewer: {}, records: [record] }); try { _beginAisSessionForTest(); - const accepted = _applyAisFeedSnapshotForTest({}, { - status: 'open', - lastMessageAt: 12, - rows: [{ - mmsi: record.mmsi, - name: record.name, - lat: 51.93, - lon: 4.05, - }], - }); + const accepted = _applyAisFeedSnapshotForTest( + {}, + { + status: 'open', + lastMessageAt: 12, + rows: [ + { + mmsi: record.mmsi, + name: record.name, + lat: 51.93, + lon: 4.05, + }, + ], + }, + ); assert.equal(accepted.reconciled, true); let feed = _getVesselFeedStateForTest(); assert.equal(feed.loading, false); @@ -450,11 +520,14 @@ test('first accepted position ends grace and warm data survives later open silen assert.equal(clock.activeCount(), 0); clock.advance(5000); - const silent = _applyAisFeedSnapshotForTest({}, { - status: 'open', - lastMessageAt: 13, - rows: [], - }); + const silent = _applyAisFeedSnapshotForTest( + {}, + { + status: 'open', + lastMessageAt: 13, + rows: [], + }, + ); assert.equal(silent.reconciled, false); feed = _getVesselFeedStateForTest(); assert.equal(feed.count, 1); @@ -489,7 +562,10 @@ test('superseded first-connect timer cannot expire its replacement session', () const afterStaleTimer = _getVesselFeedStateForTest(); assert.equal(afterStaleTimer.sessionId, replacement.sessionId); assert.equal(afterStaleTimer.firstConnectPhase, 'loading'); - assert.equal(afterStaleTimer.firstConnectDeadline, replacement.firstConnectDeadline); + assert.equal( + afterStaleTimer.firstConnectDeadline, + replacement.firstConnectDeadline, + ); assert.equal(afterStaleTimer.error, null); } finally { _setVesselStateForTest({ enabled: false }); @@ -499,7 +575,9 @@ test('superseded first-connect timer cannot expire its replacement session', () function deferredFetchResponse() { let resolve; - const promise = new Promise((next) => { resolve = next; }); + const promise = new Promise((next) => { + resolve = next; + }); return { promise, resolve }; } @@ -557,7 +635,12 @@ test('superseded AIS response cannot mutate or finalize a replacement request', const replacement = makeRecord({ name: 'REPLACEMENT' }); const replacementTrail = makeTrailSpy(); try { - _setVesselStateForTest({ viewer: {}, records: [oldRecord], loaded: true, lastUpdate: 100 }); + _setVesselStateForTest({ + viewer: {}, + records: [oldRecord], + loaded: true, + lastUpdate: 100, + }); const oldLoad = _loadLivePositionsForTest({}); // Simulate destroy/re-init replacement. The old fetch intentionally ignores abort. @@ -573,18 +656,32 @@ test('superseded AIS response cannot mutate or finalize a replacement request', }); const replacementLoad = _loadLivePositionsForTest({}); - first.resolve(jsonResponse({ - status: 'open', - lastMessageAt: 1000, - rows: [{ mmsi: oldRecord.mmsi, name: 'STALE RESPONSE', lat: 1, lon: 2 }], - })); + first.resolve( + jsonResponse({ + status: 'open', + lastMessageAt: 1000, + rows: [ + { mmsi: oldRecord.mmsi, name: 'STALE RESPONSE', lat: 1, lon: 2 }, + ], + }), + ); await oldLoad; let feed = _getVesselFeedStateForTest(); - assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership'); - assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness'); + assert.equal( + feed.loading, + true, + 'old finally must not clear replacement loading ownership', + ); + assert.equal( + feed.lastUpdate, + 200, + 'old response must not advance replacement freshness', + ); assert.equal(feed.selectedMmsi, replacement.mmsi); - second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] })); + second.resolve( + jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }), + ); await replacementLoad; feed = _getVesselFeedStateForTest(); assert.equal(feed.loading, false); @@ -616,14 +713,21 @@ test('disable and destroy make abort-ignoring AIS responses inert', async () => const disableResponse = deferredFetchResponse(); globalThis.fetch = () => disableResponse.promise; const record = makeRecord(); - _setVesselStateForTest({ viewer: {}, records: [record], loaded: true, lastUpdate: 300 }); + _setVesselStateForTest({ + viewer: {}, + records: [record], + loaded: true, + lastUpdate: 300, + }); const disabledLoad = _loadLivePositionsForTest({}); aisLiveVesselsLayer.disable(); - disableResponse.resolve(jsonResponse({ - status: 'open', - lastMessageAt: 1000, - rows: [{ mmsi: record.mmsi, lat: 1, lon: 2 }], - })); + disableResponse.resolve( + jsonResponse({ + status: 'open', + lastMessageAt: 1000, + rows: [{ mmsi: record.mmsi, lat: 1, lon: 2 }], + }), + ); await disabledLoad; let feed = _getVesselFeedStateForTest(); assert.equal(feed.lastUpdate, 300); @@ -632,14 +736,21 @@ test('disable and destroy make abort-ignoring AIS responses inert', async () => const destroyResponse = deferredFetchResponse(); globalThis.fetch = () => destroyResponse.promise; const viewer = { scene: { primitives: { remove() {} } } }; - _setVesselStateForTest({ viewer, records: [record], loaded: true, lastUpdate: 400 }); + _setVesselStateForTest({ + viewer, + records: [record], + loaded: true, + lastUpdate: 400, + }); const destroyedLoad = _loadLivePositionsForTest(viewer); aisLiveVesselsLayer.destroy(viewer); - destroyResponse.resolve(jsonResponse({ - status: 'open', - lastMessageAt: 2000, - rows: [{ mmsi: record.mmsi, lat: 3, lon: 4 }], - })); + destroyResponse.resolve( + jsonResponse({ + status: 'open', + lastMessageAt: 2000, + rows: [{ mmsi: record.mmsi, lat: 3, lon: 4 }], + }), + ); await destroyedLoad; feed = _getVesselFeedStateForTest(); assert.equal(feed.count, 0); @@ -659,10 +770,22 @@ test('disable and destroy make abort-ignoring AIS responses inert', async () => /** One vessel row in a fresh set, as the layer adds it, plus its record. */ function focusFixture() { const icons = createIconSet(); - const row = icons.add('353136000', { layer: 'ais-live-vessels', id: '353136000' }); + const row = icons.add('353136000', { + layer: 'ais-live-vessels', + id: '353136000', + }); icons.setSize(row, 32, 32); - const record = { iconId: '353136000', position: { x: 1, y: 2, z: 3 }, visible: true }; - return { icons, records: [record], record, focus: () => icons.focus(icons.row('353136000')) }; + const record = { + iconId: '353136000', + position: { x: 1, y: 2, z: 3 }, + visible: true, + }; + return { + icons, + records: [record], + record, + focus: () => icons.focus(icons.row('353136000')), + }; } test('vessel focus wire animates alpha with deadband and restores after tracking ends', () => { @@ -703,15 +826,27 @@ test('vessel focus wire animates alpha with deadband and restores after tracking assert.equal(apply(0, target).writes, 0); assert.equal(apply(150, target).writes, 1); assert.ok(focus() > 0.25 && focus() < 1); - assert.equal(apply(150, target).writes, 0, 'same timestamp stays inside deadband'); + assert.equal( + apply(150, target).writes, + 0, + 'same timestamp stays inside deadband', + ); assert.equal(apply(300, target).writes, 1); assert.equal(focus(), 0.25); - assert.equal(apply(300, null).writes, 0, 'release starts continuously from the floor'); + assert.equal( + apply(300, null).writes, + 0, + 'release starts continuously from the floor', + ); assert.equal(apply(600, null).writes, 1); assert.ok(focus() > 0.25 && focus() < 1); assert.equal(apply(900, null).writes, 1); assert.equal(focus(), 1); - assert.equal(apply(901, null).writes, 0, 'settled restoration causes no churn'); + assert.equal( + apply(901, null).writes, + 0, + 'settled restoration causes no churn', + ); assert.equal(writes, 4); }); @@ -749,16 +884,17 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas releaseMs: 600, writeEpsilon: 0.005, }; - const pass = (nowMs, focusTarget, previousActiveCount) => applyVesselFocusDeemphasis({ - records, - icons, - target: focusTarget, - previousActiveCount, - nowMs, - screenPositionFor: () => ({ x: 50, y: 50 }), - cameraDistanceFor: () => 1200, - params, - }); + const pass = (nowMs, focusTarget, previousActiveCount) => + applyVesselFocusDeemphasis({ + records, + icons, + target: focusTarget, + previousActiveCount, + nowMs, + screenPositionFor: () => ({ x: 50, y: 50 }), + cameraDistanceFor: () => 1200, + params, + }); pass(0, target, 0); const dimmed = pass(params.attackMs, target, 0); @@ -767,7 +903,11 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas record.visible = false; const releaseStart = pass(params.attackMs, null, dimmed.activeCount); - const restored = pass(params.attackMs + params.releaseMs, null, releaseStart.activeCount); + const restored = pass( + params.attackMs + params.releaseMs, + null, + releaseStart.activeCount, + ); assert.equal(restored.ran, true); assert.equal(restored.activeCount, 0); assert.equal(restored.writes, 1); @@ -785,7 +925,11 @@ test('vessel selection: empty-space click requests deselection', () => { test('vessel selection: no selection plus empty-space click is a no-op', () => { assert.deepEqual( - reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }), + reduceVesselSelection({ + selectedMmsi: null, + pickedMmsi: null, + gesture: 'click', + }), { action: 'none' }, ); }); @@ -795,10 +939,9 @@ test('vessel selection: Escape deselects only when this layer owns a selection', reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }), { action: 'deselect' }, ); - assert.deepEqual( - reduceVesselSelection({ gesture: 'escape' }), - { action: 'none' }, - ); + assert.deepEqual(reduceVesselSelection({ gesture: 'escape' }), { + action: 'none', + }); }); test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => { @@ -847,24 +990,14 @@ function makeRecord(overrides = {}) { function makeTrailSpy() { return { clearCalls: 0, - clear() { this.clearCalls += 1; }, + clear() { + this.clearCalls += 1; + }, setPositions() {}, destroy() {}, }; } -function makeInteractionHandler() { - return { - click: null, - destroyCalls: 0, - setInputAction(callback) { this.click = callback; }, - destroy() { - this.destroyCalls += 1; - this.click = null; - }, - }; -} - function makeCesiumEvent() { const listeners = new Set(); return { @@ -880,7 +1013,9 @@ function makeCesiumEvent() { raise() { for (const callback of [...listeners]) callback(); }, - get listenerCount() { return listeners.size; }, + get listenerCount() { + return listeners.size; + }, }; } @@ -905,14 +1040,9 @@ function installWireHarness(picked, stateOverrides = {}) { this.removed.push({ type, callback }); if (this.keydown === callback) this.keydown = null; }, - dispatch(event) { this.keydown?.(event); }, - }; - const handler = makeInteractionHandler(); - const reinstalledHandlers = []; - const interactionHandlerFactory = () => { - const next = makeInteractionHandler(); - reinstalledHandlers.push(next); - return next; + dispatch(event) { + this.keydown?.(event); + }, }; const trackedEntityChanged = makeCesiumEvent(); const viewer = { @@ -920,6 +1050,8 @@ function installWireHarness(picked, stateOverrides = {}) { trackedEntity: undefined, trackedEntityChanged, }; + const input = fakeInput(viewer); + const handler = { click: (event) => input.click(event.position) }; const record = makeRecord(); const trail = makeTrailSpy(); presentSelectedVessel({ text: `AIS: ${record.name}` }); @@ -943,11 +1075,10 @@ function installWireHarness(picked, stateOverrides = {}) { trail, trailMmsi: record.mmsi, trailPositions: [POS], - interactionHandlerFactory, interactionKeyTarget: keyTarget, ...stateOverrides, }); - _bindVesselInteractionForTest(viewer, handler, keyTarget); + _bindVesselInteractionForTest(viewer, keyTarget); return { handler, @@ -957,8 +1088,7 @@ function installWireHarness(picked, stateOverrides = {}) { hud, viewer, windowTarget, - reinstalledHandlers, - interactionHandlerFactory, + input: input.input, cleanup() { stopReadout(); aisLiveVesselsLayer.destroy(); @@ -974,7 +1104,10 @@ test('vessel interaction wire: trail pick does not deselect', () => { const harness = installWireHarness({ id: 'gev-trail:71' }); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(harness.trail.clearCalls, 0); assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi); } finally { @@ -998,7 +1131,10 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', ( const harness = installWireHarness({ id: 'a1b2c3' }); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(harness.trail.clearCalls, 0); } finally { harness.cleanup(); @@ -1018,13 +1154,18 @@ test('vessel touch interaction yields to the first nearby flight behind map tile let drills = 0; harness.viewer.scene.drillPick = (_position, limit, width, height) => { drills++; - assert.deepEqual([limit, width, height], [8, 14, 14]); + // The click's shared drill: the 14 px touch window, at least 8 deep. + assert.deepEqual([width, height], [14, 14]); + assert.ok(limit >= 8); return [tile, { id: 'a1b2c3' }]; }; try { harness.handler.click({ position: { x: 10, y: 20 } }); assert.equal(drills, 1); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(harness.trail.clearCalls, 0); } finally { harness.cleanup(); @@ -1039,7 +1180,10 @@ test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () = const harness = installWireHarness({ id: 'atx-cam-3' }); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(harness.trail.clearCalls, 0); } finally { harness.cleanup(); @@ -1053,7 +1197,10 @@ test('vessel interaction wire: own-layer unkeyed record pick does not deselect', const harness = installWireHarness({ id: 'ais-unkeyed:1' }, { icons }); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(harness.trail.clearCalls, 0); } finally { harness.cleanup(); @@ -1061,7 +1208,11 @@ test('vessel interaction wire: own-layer unkeyed record pick does not deselect', }); test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HUD', () => { - const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 }); + const harness = installWireHarness({ + primitive: {}, + content: {}, + featureId: 0, + }); const cleared = []; try { registerEntityContext(harness.record, { @@ -1070,9 +1221,12 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU label: harness.record.name, }); selectEntityContext(harness.record); - harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => { - cleared.push(event.detail); - }); + harness.windowTarget.addEventListener( + 'gev:entity-selection-cleared', + (event) => { + cleared.push(event.detail); + }, + ); harness.handler.click({ position: { x: 10, y: 20 } }); @@ -1081,7 +1235,9 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU // Clicking away is a deliberate deselect, not the vessel aging out of the // feed. Consumers that keep a readout on screen (the Cockpit Contact // panel) tear down on 'deliberate' and hold last-known on 'evicted'. - assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]); + assert.deepEqual(cleared, [ + { layerId: 'ais-live-vessels', reason: 'deliberate' }, + ]); } finally { harness.cleanup(); } @@ -1097,16 +1253,21 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti label: harness.record.name, }); selectEntityContext(harness.record); - harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => { - cleared.push(event.detail); - }); + harness.windowTarget.addEventListener( + 'gev:entity-selection-cleared', + (event) => { + cleared.push(event.detail); + }, + ); harness.handler.click({ position: { x: 10, y: 20 } }); assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null); assert.equal(harness.hud.readout, null); // Empty pick is a deliberate deselect — see the note above. - assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]); + assert.deepEqual(cleared, [ + { layerId: 'ais-live-vessels', reason: 'deliberate' }, + ]); } finally { harness.cleanup(); } @@ -1127,7 +1288,10 @@ test('vessel interaction wire: trackedEntityChanged clears only with tracking an const harness = installWireHarness(undefined); try { harness.viewer.trackedEntityChanged.raise(); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(harness.trail.clearCalls, 0); harness.viewer.trackedEntity = { id: 'tracked-flight' }; @@ -1148,7 +1312,7 @@ test('vessel interaction lifecycle: destroy removes the exact bound keydown list const added = harness.keyTarget.added[0]; aisLiveVesselsLayer.destroy(); - assert.equal(harness.handler.destroyCalls, 1); + assert.equal(harness.input.subscriberCount(), 0); assert.equal(harness.keyTarget.removed.length, 1); assert.equal(harness.keyTarget.removed[0].type, 'keydown'); assert.strictEqual(harness.keyTarget.removed[0].callback, added.callback); @@ -1168,8 +1332,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls const disabledKeydown = harness.keyTarget.keydown; aisLiveVesselsLayer.disable(); - assert.equal(harness.handler.destroyCalls, 1); - assert.equal(harness.handler.click, null); + assert.equal(harness.input.subscriberCount(), 0); assert.equal(harness.keyTarget.keydown, null); assert.equal(harness.keyTarget.removed.length, 1); assert.strictEqual(harness.keyTarget.removed[0].callback, disabledKeydown); @@ -1183,12 +1346,14 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls trail: harness.trail, trailMmsi: harness.record.mmsi, trailPositions: [POS], - interactionHandlerFactory: harness.interactionHandlerFactory, interactionKeyTarget: harness.keyTarget, }); disabledClick({ position: { x: 10, y: 20 } }); disabledKeydown({ key: 'Escape' }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); globalThis.fetch = async () => ({ ok: true, @@ -1196,12 +1361,11 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls }); await aisLiveVesselsLayer.enable(harness.viewer); - assert.equal(harness.reinstalledHandlers.length, 1); + assert.equal(harness.input.subscriberCount(), 1); assert.equal(harness.keyTarget.added.length, 2); assert.equal(harness.viewer.trackedEntityChanged.addCalls, 2); assert.equal(harness.viewer.trackedEntityChanged.listenerCount, 1); - const reinstalled = harness.reinstalledHandlers[0]; - reinstalled.click({ position: { x: 10, y: 20 } }); + harness.handler.click({ position: { x: 10, y: 20 } }); assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null); assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); @@ -1229,8 +1393,14 @@ test('enable owns one grace timer and disable/re-enable starts a new session', a await aisLiveVesselsLayer.enable(harness.viewer); const duplicateEnable = _getVesselFeedStateForTest(); assert.equal(duplicateEnable.sessionId, first.sessionId); - assert.equal(duplicateEnable.firstConnectStartedAt, first.firstConnectStartedAt); - assert.equal(duplicateEnable.firstConnectDeadline, first.firstConnectDeadline); + assert.equal( + duplicateEnable.firstConnectStartedAt, + first.firstConnectStartedAt, + ); + assert.equal( + duplicateEnable.firstConnectDeadline, + first.firstConnectDeadline, + ); assert.equal(clock.activeCount(), 1); aisLiveVesselsLayer.disable(); @@ -1239,7 +1409,10 @@ test('enable owns one grace timer and disable/re-enable starts a new session', a const replacement = _getVesselFeedStateForTest(); assert.notEqual(replacement.sessionId, first.sessionId); assert.equal(replacement.firstConnectStartedAt, 25000); - assert.equal(replacement.firstConnectDeadline, 25000 + AIS_FIRST_CONNECT_GRACE_MS); + assert.equal( + replacement.firstConnectDeadline, + 25000 + AIS_FIRST_CONNECT_GRACE_MS, + ); assert.equal(replacement.firstConnectPhase, 'loading'); assert.equal(clock.activeCount(), 1); } finally { @@ -1299,18 +1472,24 @@ test('buildVesselCard: name title + type/speed/heading detail line', () => { }); test('buildVesselCard: heading falls back to course; missing parts are omitted', () => { - const card = buildVesselCard(makeRecord({ heading: null, type: '', speed: null })); + const card = buildVesselCard( + makeRecord({ heading: null, type: '', speed: null }), + ); assert.deepEqual(card.details, ['231°']); - const bare = buildVesselCard(makeRecord({ heading: null, course: null, type: '', speed: null })); + const bare = buildVesselCard( + makeRecord({ heading: null, course: null, type: '', speed: null }), + ); assert.deepEqual(bare.details, []); }); test('vessel cards read a course that rounds to north as 0°, not 360°', () => { - const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0]; + const card = (course) => + buildVesselCard(makeRecord({ heading: null, course })).details[0]; assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°'); assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°'); assert.equal( - buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0], + buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })) + .details[0], 'CONTAINER SHIP · 14.5KT · 0°', ); }); @@ -1352,16 +1531,27 @@ test('buildVesselCard: numeric AIS type codes read as family names, not digits', test('cardScreenSeparated: rejects candidates inside the min separation radius', () => { const accepted = [{ x: 400, y: 300 }]; - assert.equal(cardScreenSeparated(accepted, { x: 400 + 149, y: 300 }, 150), false); - assert.equal(cardScreenSeparated(accepted, { x: 400 + 151, y: 300 }, 150), true); - assert.equal(cardScreenSeparated(accepted, { x: 400, y: 300 + 100 }, 150), false); + assert.equal( + cardScreenSeparated(accepted, { x: 400 + 149, y: 300 }, 150), + false, + ); + assert.equal( + cardScreenSeparated(accepted, { x: 400 + 151, y: 300 }, 150), + true, + ); + assert.equal( + cardScreenSeparated(accepted, { x: 400, y: 300 + 100 }, 150), + false, + ); assert.equal(cardScreenSeparated([], { x: 0, y: 0 }, 150), true); }); test('buildSelectedVesselCard: full detail card with MMSI + position time', () => { - const card = buildSelectedVesselCard(makeRecord({ - lastPositionUtc: '2026-07-27T11:22:33Z', - })); + const card = buildSelectedVesselCard( + makeRecord({ + lastPositionUtc: '2026-07-27T11:22:33Z', + }), + ); assert.equal(card.selected, true); assert.equal(card.id, 'vessel:353136000'); assert.equal(card.priority, 100000); @@ -1375,18 +1565,22 @@ test('buildSelectedVesselCard: full detail card with MMSI + position time', () = test('vessel host publication preserves the shipped grid winner and separation selector', () => { const publications = []; const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates; - const makeCandidate = (mmsi, name, x, y, speed = 1) => makeRecord({ - mmsi, - name, - speed, - position: { x: 1, y: 2, z: 3, screen: { x, y } }, - }); + const makeCandidate = (mmsi, name, x, y, speed = 1) => + makeRecord({ + mmsi, + name, + speed, + position: { x: 1, y: 2, z: 3, screen: { x, y } }, + }); const low = makeCandidate('100', 'VESSEL', 20, 20, 1); const winner = makeCandidate('200', 'NAMED WINNER', 40, 30, 16); const separated = makeCandidate('300', 'SEPARATED', 400, 300, 3); - Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) => position.screen; + Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) => + position.screen; _setVesselOverlayHostForTest({ - setEntries(sourceId, entries, options) { publications.push({ sourceId, entries, options }); }, + setEntries(sourceId, entries, options) { + publications.push({ sourceId, entries, options }); + }, setVisible() {}, clearSource() {}, }); @@ -1414,9 +1608,15 @@ test('vessel host publication preserves the shipped grid winner and separation s test('vessel real layer lifecycle publishes protected selection and leaves no stale host cards', () => { const calls = []; const host = { - setEntries(sourceId, entries, options) { calls.push({ op: 'set', sourceId, entries, options }); }, - setVisible(sourceId, visible) { calls.push({ op: 'visible', sourceId, visible }); }, - clearSource(sourceId) { calls.push({ op: 'clear', sourceId }); }, + setEntries(sourceId, entries, options) { + calls.push({ op: 'set', sourceId, entries, options }); + }, + setVisible(sourceId, visible) { + calls.push({ op: 'visible', sourceId, visible }); + }, + clearSource(sourceId) { + calls.push({ op: 'clear', sourceId }); + }, }; const hadWindow = Object.hasOwn(globalThis, 'window'); const priorWindow = globalThis.window; @@ -1445,16 +1645,26 @@ test('vessel real layer lifecycle publishes protected selection and leaves no st assert.equal(publication.entries[0].variant, 'selected'); assert.equal(publication.entries[0].protected, true); assert.equal(publication.entries[0].collisionGroup, 'ambient-card'); - assert.match(publication.entries[0].accessibilityLabel, /Focus vessel EVER GIVEN, MMSI 353136000/); + assert.match( + publication.entries[0].accessibilityLabel, + /Focus vessel EVER GIVEN, MMSI 353136000/, + ); assert.equal(publication.entries[0].activate(), true); assert.equal(focusRequests.length, 1); assert.equal(focusRequests[0].id, '353136000'); aisLiveVesselsLayer.disable(); - assert.ok(calls.some((call) => call.op === 'clear'), 'disable clears host cards'); - assert.ok(calls.some((call) => call.op === 'visible' && call.visible === false)); + assert.ok( + calls.some((call) => call.op === 'clear'), + 'disable clears host cards', + ); + assert.ok( + calls.some((call) => call.op === 'visible' && call.visible === false), + ); - const clearsBeforeDestroy = calls.filter((call) => call.op === 'clear').length; + const clearsBeforeDestroy = calls.filter( + (call) => call.op === 'clear', + ).length; aisLiveVesselsLayer.destroy(); assert.ok( calls.filter((call) => call.op === 'clear').length > clearsBeforeDestroy, @@ -1470,14 +1680,16 @@ test('vessel real layer lifecycle publishes protected selection and leaves no st }); test('buildSelectedVesselCard: destination line + STALE marker; placeholders for missing data', () => { - const card = buildSelectedVesselCard(makeRecord({ - type: 'Tanker', - speed: null, - heading: null, - course: null, - destination: 'ROTTERDAM', - missedRefreshes: 2, - })); + const card = buildSelectedVesselCard( + makeRecord({ + type: 'Tanker', + speed: null, + heading: null, + course: null, + destination: 'ROTTERDAM', + missedRefreshes: 2, + }), + ); assert.deepEqual(card.details, [ 'TANKER · --KT · --°', '→ ROTTERDAM', @@ -1507,23 +1719,34 @@ test('EGM96 N pins the field finding: Rotterdam sea sits ~45 m ABOVE the ellipso await ensureGeoidReady(); const rotterdam = geoidHeight(51.93, 4.05); const houston = geoidHeight(29.72, -95.08); - assert.ok(rotterdam > 40 && rotterdam < 50, `N(rotterdam) = ${rotterdam}, expected ≈ +45`); - assert.ok(houston > -32 && houston < -22, `N(houston) = ${houston}, expected ≈ −27`); + assert.ok( + rotterdam > 40 && rotterdam < 50, + `N(rotterdam) = ${rotterdam}, expected ≈ +45`, + ); + assert.ok( + houston > -32 && houston < -22, + `N(houston) = ${houston}, expected ≈ −27`, + ); }); // One-click transfer (pre-launch defect #4): clicking a vessel gives it the // camera, whether the camera was free or tracking something else. The layer // only announces the click — the UI owns the flight (src/worldFocus.js). test('vessel interaction wire: selecting a vessel by click requests a camera transfer', () => { - const harness = installWireHarness({ id: makeRecord().mmsi }, { selectedRecord: null }); + const harness = installWireHarness( + { id: makeRecord().mmsi }, + { selectedRecord: null }, + ); const requests = []; - harness.windowTarget.addEventListener( - WORLD_FOCUS_REQUEST_EVENT, - (event) => requests.push(event.detail), + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), ); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(requests.length, 1); assert.equal(requests[0].kind, 'vessel'); assert.equal(requests[0].id, harness.record.mmsi); @@ -1551,16 +1774,23 @@ function hostWithCardHit(entryId) { // on one picks the TERRAIN behind it — which read as empty space and cleared // the selection. A card hit must behave exactly like a sprite hit. test('vessel interaction wire: clicking a card selects and transfers, like the sprite', () => { - const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 }, { selectedRecord: null }); + const harness = installWireHarness( + { primitive: {}, content: {}, featureId: 0 }, + { selectedRecord: null }, + ); const requests = []; - harness.windowTarget.addEventListener( - WORLD_FOCUS_REQUEST_EVENT, - (event) => requests.push(event.detail), + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), + ); + _setVesselOverlayHostForTest( + hostWithCardHit(`vessel:${harness.record.mmsi}`), ); - _setVesselOverlayHostForTest(hostWithCardHit(`vessel:${harness.record.mmsi}`)); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(requests.length, 1); assert.equal(requests[0].kind, 'vessel'); assert.equal(requests[0].id, harness.record.mmsi); @@ -1571,16 +1801,24 @@ test('vessel interaction wire: clicking a card selects and transfers, like the s }); test('vessel interaction wire: clicking the selected vessel card refocuses exactly once', () => { - const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 }); + const harness = installWireHarness({ + primitive: {}, + content: {}, + featureId: 0, + }); const requests = []; - harness.windowTarget.addEventListener( - WORLD_FOCUS_REQUEST_EVENT, - (event) => requests.push(event.detail), + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), + ); + _setVesselOverlayHostForTest( + hostWithCardHit(`vessel:${harness.record.mmsi}`), ); - _setVesselOverlayHostForTest(hostWithCardHit(`vessel:${harness.record.mmsi}`)); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(requests.length, 1); assert.equal(requests[0].id, harness.record.mmsi); assert.equal(harness.trail.clearCalls, 0); @@ -1591,17 +1829,23 @@ test('vessel interaction wire: clicking the selected vessel card refocuses exact }); test('vessel interaction wire: an unknown card id preserves selection and never flies', () => { - const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 }); + const harness = installWireHarness({ + primitive: {}, + content: {}, + featureId: 0, + }); const requests = []; - harness.windowTarget.addEventListener( - WORLD_FOCUS_REQUEST_EVENT, - (event) => requests.push(event.detail), + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), ); // An evicted vessel's card id resolves to no live record. _setVesselOverlayHostForTest(hostWithCardHit('vessel:999999999')); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(requests.length, 0); } finally { _setVesselOverlayHostForTest(null); @@ -1615,14 +1859,18 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3'); const harness = installWireHarness({ id: 'a1b2c3' }); const requests = []; - harness.windowTarget.addEventListener( - WORLD_FOCUS_REQUEST_EVENT, - (event) => requests.push(event.detail), + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), + ); + _setVesselOverlayHostForTest( + hostWithCardHit(`vessel:${harness.record.mmsi}`), ); - _setVesselOverlayHostForTest(hostWithCardHit(`vessel:${harness.record.mmsi}`)); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(requests.length, 0); } finally { _setVesselOverlayHostForTest(null); @@ -1637,16 +1885,32 @@ for (const card of ['live', 'stale', 'absent', 'covered']) { registerPickOwner('weather-cyclones', (pickedId) => pickedId === id); const harness = installWireHarness({ id }); const requests = []; - harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail)); - const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null); - if (card === 'covered') host.hitTest = (_x, _y, options) => { - assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card'); - return { sourceId: 'cctv', entryId: 'vessel:999999999' }; - }; + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), + ); + const host = hostWithCardHit( + card === 'live' + ? `vessel:${harness.record.mmsi}` + : card === 'stale' + ? 'vessel:999999999' + : null, + ); + if (card === 'covered') + host.hitTest = (_x, _y, options) => { + assert.equal( + options?.sourceId, + undefined, + 'test topmost host hit, not a covered AIS card', + ); + return { sourceId: 'cctv', entryId: 'vessel:999999999' }; + }; _setVesselOverlayHostForTest(host); try { harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal( + aisLiveVesselsLayer.getSelectedInfo()?.mmsi, + harness.record.mmsi, + ); assert.equal(requests.length, card === 'live' ? 1 : 0); } finally { _setVesselOverlayHostForTest(null); @@ -1659,16 +1923,21 @@ for (const card of ['live', 'stale', 'absent', 'covered']) { test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => { const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord())); assert.equal(keyed.interactive, true); - const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' }))); - assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select'); + const unkeyed = applyVesselOverlayPolicy( + buildVesselCard(makeRecord({ mmsi: '' })), + ); + assert.equal( + unkeyed.interactive, + false, + 'an unkeyed card has no record to select', + ); }); test('vessel interaction wire: deselecting never moves the camera', () => { const harness = installWireHarness({ id: 'gev-empty-space' }); const requests = []; - harness.windowTarget.addEventListener( - WORLD_FOCUS_REQUEST_EVENT, - (event) => requests.push(event.detail), + harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => + requests.push(event.detail), ); try { harness.handler.click({ position: { x: 10, y: 20 } }); @@ -1686,14 +1955,24 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => { // selection keys on MMSI. The compact voice payload must carry both, or the // analyst → track_entity handoff hands over a name and nothing resolvable. const named = mapAnalystRecord({ - mmsi: '366999123', name: 'EVER GIVEN', lat: 37.8, lon: -122.4, speed: 12, type: 'Cargo', + mmsi: '366999123', + name: 'EVER GIVEN', + lat: 37.8, + lon: -122.4, + speed: 12, + type: 'Cargo', }); assert.equal(named.id, 'EVER GIVEN'); assert.equal(named.mmsi, '366999123', 'the key rides along with the label'); // A nameless vessel falls back to its MMSI for display; the key is still // present in its own field, so the payload never depends on that collapse. - const nameless = mapAnalystRecord({ mmsi: '366999124', name: null, lat: 37.9, lon: -122.5 }); + const nameless = mapAnalystRecord({ + mmsi: '366999124', + name: null, + lat: 37.9, + lon: -122.5, + }); assert.equal(nameless.id, '366999124'); assert.equal(nameless.mmsi, '366999124'); }); @@ -1705,13 +1984,19 @@ test('vessel selection passes the opaque source reference to optional history', aisLiveVesselsLayer.setSource({ label: 'Test vessel source', getSnapshot: async () => ({ records: [] }), - getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; }, + getTrack: async (reference, options) => { + requests.push({ reference, options }); + return { records: [] }; + }, + }); + const harness = installWireHarness(undefined, { + selectedRecord: null, + trailMmsi: null, }); - const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null }); try { harness.record.reference = 'opaque:test-reference'; assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(requests.length, 1); assert.equal(requests[0].reference, 'opaque:test-reference'); assert.ok(requests[0].options.signal instanceof AbortSignal); diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index db35e497b..80920bd22 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -81,27 +81,11 @@ import { LIVE_CAMERA_COLOR, } from '../layers/cctv/policy.js'; import { cctvPicks } from '../layers/cctv/pickState.js'; +import { FAKE_EVENT_TYPES, fakeInput } from '../input/testDoubles.mjs'; const ASPECT = 16 / 9; const toRad = (deg) => (deg * Math.PI) / 180; -const GESTURE_TYPES = { - LEFT_DOWN: 'left-down', - MOUSE_MOVE: 'mouse-move', - LEFT_UP: 'left-up', - LEFT_CLICK: 'left-click', -}; - -function makeGestureHandler() { - const actions = new Map(); - return { - setInputAction(callback, type) { - actions.set(type, callback); - }, - fire(type, event) { - actions.get(type)?.(event); - }, - }; -} +const GESTURE_TYPES = FAKE_EVENT_TYPES; // Same spherical earth radius projectPoint uses (R = 6371 km) so the // lat/lon→metres conversion in the assertions matches the module's math. const M_PER_DEG = (Math.PI / 180) * 6371000; @@ -987,7 +971,7 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]); assert.match( componentFunctionSource(cctvLayer.init), - /bindCctvWorldClickGesture\(_clickHandler/, + /bindCctvWorldClickGesture\(\s*input,/, ); assert.match( componentFunctionSource(cctvLayer.init), @@ -1407,14 +1391,14 @@ test('active CCTV camera is absent from host by default and protected only when test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => { let timeMs = 0; let activationCalls = 0; - const handler = makeGestureHandler(); + const { input, handler } = fakeInput({ scene: {} }, { now: () => timeMs }); const target = new EventTarget(); const requests = []; target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail), ); bindCctvWorldClickGesture( - handler, + input, () => { activateCctvCameraFromWorldClick( 'atx-cam-3', @@ -1425,10 +1409,6 @@ test('CCTV drag-then-release over a camera is inert, while a clean tap activates target, ); }, - { - now: () => timeMs, - eventTypes: GESTURE_TYPES, - }, ); handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 8e3efc863..89789d691 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -8,7 +8,7 @@ * - Pure drag math (this top section): ray↔axis closest-point, ray↔plane * intersection with a grazing-angle guard, ring angles. All unit-tested * under node:test with zero scene dependencies. - * - createCalibrationGizmo (below): the entity + ScreenSpaceEventHandler + * - createCalibrationGizmo (below): the entity + pointer-input * controller that renders grab-able handles on the ACTIVE camera and turns * drags into calibration patches through a narrow callback interface. It * never touches the layer's records/stores directly. @@ -24,7 +24,7 @@ * invariant is untouched. */ import * as Cesium from 'cesium'; -import { isPointerFree } from './inputOwnership.js'; +import { inputFor } from '../input/dispatcher.js'; // Grazing guard (spec §5): reject plane intersections when the view ray is // nearly parallel to the constraint plane — the hit point races to infinity @@ -237,7 +237,8 @@ export function createCalibrationGizmo({ const entities = new Map(); // part -> Entity const scene = viewer.scene; - const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); + const input = inputFor(viewer); + let releaseCamera = null; /** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */ function addEntity(part, options) { @@ -533,9 +534,7 @@ export function createCalibrationGizmo({ drag = state; setCursor('grabbing'); - if (scene.screenSpaceCameraController) { - scene.screenSpaceCameraController.enableInputs = false; - } + releaseCamera = input.suspendCamera('cctv-gizmo-drag'); return true; } @@ -615,9 +614,8 @@ export function createCalibrationGizmo({ if (!drag) return; const record = drag.record; drag = null; - if (scene.screenSpaceCameraController) { - scene.screenSpaceCameraController.enableInputs = true; - } + releaseCamera?.(); + releaseCamera = null; setCursor(hoveredId ? 'grab' : ''); endPatch(record); } @@ -629,18 +627,21 @@ export function createCalibrationGizmo({ } } - handler.setInputAction((event) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the press. - if (!isPointerFree()) return; + // An ambient press: the dispatcher withholds it while a tool owns the pointer + // (src/data/inputOwnership.js). + const offDown = input.onPointer('down', (event) => { if (!enabled) return; + // A release Cesium never reported (one with a modifier key held) leaves + // the last drag open; a new press ends it, hold and patch included. + if (drag) endDrag(); const part = pickGizmoPart(event.position); debugLog('LEFT_DOWN', event.position, 'part:', part); if (!part) return; const started = beginDrag(part, event.position); debugLog('beginDrag', part, '→', started); - }, Cesium.ScreenSpaceEventType.LEFT_DOWN); + }); - handler.setInputAction((event) => { + const offMove = input.onPointer('move', (event) => { if (!enabled) return; const now = Date.now(); if (drag) { @@ -667,12 +668,12 @@ export function createCalibrationGizmo({ if (now - lastHoverAt < HOVER_THROTTLE_MS) return; lastHoverAt = now; setHovered(pickGizmoPart(event.endPosition)); - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + }); - handler.setInputAction(() => { + const offUp = input.onPointer('up', () => { if (!enabled) return; endDrag(); - }, Cesium.ScreenSpaceEventType.LEFT_UP); + }); return { setEnabled(value) { @@ -688,7 +689,9 @@ export function createCalibrationGizmo({ refresh, destroy() { if (drag) endDrag(); - handler.destroy(); + offDown(); + offMove(); + offUp(); for (const entity of entities.values()) { viewer.entities.remove(entity); } diff --git a/src/data/cctvGizmo.test.mjs b/src/data/cctvGizmo.test.mjs index 850e7708e..6b6c80f76 100644 --- a/src/data/cctvGizmo.test.mjs +++ b/src/data/cctvGizmo.test.mjs @@ -8,53 +8,87 @@ // - rayPlaneIntersect refuses grazing rays (|dir·normal| < 0.08 — spec §5 // precision guard) and behind-origin hits; // - ringAngle/signedAngleDelta give quadrant-correct, wrap-safe angles for -// the heading/pitch ring drags. +// the heading/pitch ring drags; +// - the controller ends an open drag, and its camera hold, on the next press. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { + GIZMO_ID_PREFIX, closestParamOnAxis, + createCalibrationGizmo, rayPlaneIntersect, ringAngle, signedAngleDelta, } from './cctvGizmo.js'; +import { FAKE_EVENT_TYPES, fakeInput } from '../input/testDoubles.mjs'; const c3 = (x, y, z) => new Cesium.Cartesian3(x, y, z); test('closestParamOnAxis: perpendicular ray hits the axis at its own offset', () => { // Axis along +X from origin. Ray shooting straight down (-Z) from (10, 0, 5): // nearest axis point is x=10 → t=10. - const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(0, 0, 0), c3(1, 0, 0)); + const t = closestParamOnAxis( + c3(10, 0, 5), + c3(0, 0, -1), + c3(0, 0, 0), + c3(1, 0, 0), + ); assert.ok(Math.abs(t - 10) < 1e-9, `expected 10, got ${t}`); }); test('closestParamOnAxis: skew ray resolves to the geometric closest point', () => { // Axis +X. Ray from (0, 10, 0) toward (1, -1, 0)/√2 passes closest to the // axis around x=10 (it reaches y=0 at x=10). - const dir = Cesium.Cartesian3.normalize(c3(1, -1, 0), new Cesium.Cartesian3()); + const dir = Cesium.Cartesian3.normalize( + c3(1, -1, 0), + new Cesium.Cartesian3(), + ); const t = closestParamOnAxis(c3(0, 10, 0), dir, c3(0, 0, 0), c3(1, 0, 0)); assert.ok(Math.abs(t - 10) < 1e-6, `expected ~10, got ${t}`); }); test('closestParamOnAxis: axis origin offset shifts the parameter', () => { - const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(4, 0, 0), c3(1, 0, 0)); + const t = closestParamOnAxis( + c3(10, 0, 5), + c3(0, 0, -1), + c3(4, 0, 0), + c3(1, 0, 0), + ); assert.ok(Math.abs(t - 6) < 1e-9, `expected 6, got ${t}`); }); test('closestParamOnAxis: near-parallel ray/axis returns null', () => { - assert.equal(closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)), null); - const nearly = Cesium.Cartesian3.normalize(c3(1, 1e-9, 0), new Cesium.Cartesian3()); - assert.equal(closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)), null); + assert.equal( + closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)), + null, + ); + const nearly = Cesium.Cartesian3.normalize( + c3(1, 1e-9, 0), + new Cesium.Cartesian3(), + ); + assert.equal( + closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)), + null, + ); }); test('rayPlaneIntersect: straight-on hit lands at the expected point', () => { - const hit = rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, -1), c3(0, 0, 0), c3(0, 0, 1)); + const hit = rayPlaneIntersect( + c3(0, 0, 10), + c3(0, 0, -1), + c3(0, 0, 0), + c3(0, 0, 1), + ); assert.ok(hit, 'expected a hit'); assert.ok(Cesium.Cartesian3.distance(hit, c3(0, 0, 0)) < 1e-9); }); test('rayPlaneIntersect: oblique hit resolves correctly', () => { - const dir = Cesium.Cartesian3.normalize(c3(1, 0, -1), new Cesium.Cartesian3()); + const dir = Cesium.Cartesian3.normalize( + c3(1, 0, -1), + new Cesium.Cartesian3(), + ); const hit = rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)); assert.ok(hit); assert.ok(Cesium.Cartesian3.distance(hit, c3(5, 0, 0)) < 1e-9, `got ${hit}`); @@ -62,12 +96,21 @@ test('rayPlaneIntersect: oblique hit resolves correctly', () => { test('rayPlaneIntersect: grazing ray (|dir·n| < 0.08) returns null', () => { // dir almost in-plane: z component 0.05 < 0.08 threshold. - const dir = Cesium.Cartesian3.normalize(c3(1, 0, -0.05), new Cesium.Cartesian3()); - assert.equal(rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)), null); + const dir = Cesium.Cartesian3.normalize( + c3(1, 0, -0.05), + new Cesium.Cartesian3(), + ); + assert.equal( + rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)), + null, + ); }); test('rayPlaneIntersect: plane behind the ray origin returns null', () => { - assert.equal(rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)), null); + assert.equal( + rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)), + null, + ); }); test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => { @@ -75,9 +118,15 @@ test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => { const a = c3(1, 0, 0); const b = c3(0, 1, 0); assert.ok(Math.abs(ringAngle(c3(5, 0, 0), center, a, b) - 0) < 1e-9); - assert.ok(Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9); - assert.ok(Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9); - assert.ok(Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9); + assert.ok( + Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9, + ); + assert.ok( + Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9, + ); + assert.ok( + Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9, + ); }); test('signedAngleDelta: shortest-path wrap', () => { @@ -86,3 +135,79 @@ test('signedAngleDelta: shortest-path wrap', () => { assert.ok(Math.abs(signedAngleDelta(d2r(-170), d2r(170)) - d2r(-20)) < 1e-9); assert.ok(Math.abs(signedAngleDelta(d2r(10), d2r(30)) - d2r(20)) < 1e-9); }); + +test('a press after a release Cesium never reported ends the open drag and its camera hold', (t) => { + const viewer = { + entities: { + byId: new Map(), + getById(id) { + return this.byId.get(id); + }, + add(options) { + this.byId.set(options.id, options); + return options; + }, + remove(entity) { + this.byId.delete(entity.id); + }, + }, + scene: { + canvas: { style: {} }, + screenSpaceCameraController: { enableInputs: true }, + pick: () => ({ id: { id: `${GIZMO_ID_PREFIX}move-up` } }), + drillPick: () => [], + }, + }; + const mount = Cesium.Cartesian3.fromDegrees(0, 0, 10); + const east = new Cesium.Cartesian3(0, 1, 0); // east at lon 0, lat 0 + viewer.camera = { + getPickRay: () => ({ + origin: Cesium.Cartesian3.add( + mount, + Cesium.Cartesian3.multiplyByScalar(east, 100, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ), + direction: Cesium.Cartesian3.negate(east, new Cesium.Cartesian3()), + }), + }; + const { input, handler } = fakeInput(viewer); + t.after(() => input.destroy()); + const record = { + frustumPositions: { + mount, + capCenter: mount, + tl: mount, + tr: mount, + bl: mount, + br: mount, + }, + frustumGeometry: { rangeM: 50 }, + camera: { + headingDeg: 0, + pitchDeg: 0, + rangeM: 50, + calibration: {}, + basePose: {}, + }, + }; + const ended = []; + const gizmo = createCalibrationGizmo({ + viewer, + getActiveRecord: () => record, + applyPatch() {}, + endPatch: (r) => ended.push(r), + }); + t.after(() => gizmo.destroy()); + gizmo.setEnabled(true); + const controller = viewer.scene.screenSpaceCameraController; + + handler.fire(FAKE_EVENT_TYPES.LEFT_DOWN, { position: { x: 5, y: 5 } }); + assert.equal(gizmo.isDragging(), true); + assert.equal(controller.enableInputs, false); + // The release came with a modifier key held: Cesium raised no LEFT_UP. + handler.fire(FAKE_EVENT_TYPES.LEFT_DOWN, { position: { x: 5, y: 5 } }); + assert.deepEqual(ended, [record], 'the open drag was committed'); + handler.fire(FAKE_EVENT_TYPES.LEFT_UP, { position: { x: 5, y: 5 } }); + assert.equal(gizmo.isDragging(), false); + assert.equal(controller.enableInputs, true, 'no hold was stranded'); +}); diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index 3928051db..09e51e4d0 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -16,6 +16,7 @@ import { import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js'; import { getLocale, setLocale } from '../i18n/index.js'; import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js'; +import { fakeInput } from '../input/testDoubles.mjs'; const NOW = Date.UTC(2026, 6, 17, 4, 0); const H = 3600000; @@ -216,6 +217,7 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async () primitives: { contains() { return false; } }, }, }; + fakeInput(viewer); const layer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS', @@ -224,7 +226,6 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async () setVisible: (...args) => calls.push(['visible', ...args]), clearSource: (...args) => calls.push(['clear', ...args]), }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), }); try { layer.init(viewer); diff --git a/src/data/firmsHorizonCull.test.mjs b/src/data/firmsHorizonCull.test.mjs index 105d2eb20..c6ae77b9b 100644 --- a/src/data/firmsHorizonCull.test.mjs +++ b/src/data/firmsHorizonCull.test.mjs @@ -13,6 +13,7 @@ import { unregisterSpriteCollection } from './spriteOrder.js'; import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js'; import { createIconSet, ICON_CULL_LIFT } from './iconSet.js'; import { writeHoldSegment } from './iconSegment.js'; +import { fakeInput } from '../input/testDoubles.mjs'; const AUSTIN = { lon: -97.7, lat: 30.2 }; /** Antipode of Austin — as far behind the limb as a point on Earth can be. */ @@ -378,6 +379,7 @@ function createHarness(rawFires) { mapProjection: new Cesium.GeographicProjection(), }, }; + fakeInput(viewer); const layer = createFirmsHeatmapLayer({ id: 'firms', @@ -387,7 +389,6 @@ function createHarness(rawFires) { setVisible: () => {}, clearSource: () => {}, }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), }); return { diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index eeac4c979..77e1f8d49 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { fakeInput } from '../input/testDoubles.mjs'; const LAYER_SOURCE = readLayerSource( path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'), @@ -59,7 +60,6 @@ function harness({ }; const published = []; - let handler = null; const layer = createFirmsHeatmapLayer({ ...(feed ? { feed } : {}), id: 'firms-test', @@ -70,10 +70,6 @@ function harness({ clearSource() {}, hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null), }, - screenSpaceEventHandlerFactory: () => { - handler = { click: null, setInputAction(cb) { this.click = cb; }, destroy() {} }; - return handler; - }, }); const viewer = { @@ -85,6 +81,7 @@ function harness({ }, dataSources: { add() {}, remove() {} }, }; + const input = fakeInput(viewer); // The LOD rebuild (and with it the context-registration sweep) is inert // without a data source, so tests that drive a real refresh need one. if (withDataSource) layer.init(viewer); @@ -100,7 +97,7 @@ function harness({ requests, published, fires, - click: () => handler.click({ position: { x: 400, y: 300 } }), + click: () => input.click({ x: 400, y: 300 }), cleanup() { Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection; if (hadWindow) globalThis.window = priorWindow; diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 45fb3d317..16aef75d1 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; import { createInfrastructureLayers } from 'gods-eye-view/infrastructure'; import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson'; +import { fakeInput } from '../input/testDoubles.mjs'; function services() { const records = new Map(); @@ -119,7 +120,6 @@ test('two viewers use their supplied contexts and dispose independently', async const hosts = [services(), services()]; const instances = hosts.map((host) => { const sources = new Cesium.DataSourceCollection(); - let click; const layer = createLocalGeoJsonLayer( { id: 'local-dams', @@ -127,12 +127,6 @@ test('two viewers use their supplied contexts and dispose independently', async osmDerived: true, color: '#0088ff', url: '/dams.geojsonl', - screenSpaceEventHandlerFactory: () => ({ - setInputAction(fn) { - click = fn; - }, - destroy() {}, - }), }, host, ); @@ -147,7 +141,8 @@ test('two viewers use their supplied contexts and dispose independently', async }, camera: { moveEnd: new Cesium.Event(), flyTo() {} }, }; - return { layer, viewer, click: () => click({ position: {} }) }; + const input = fakeInput(viewer); + return { layer, viewer, click: () => input.click({ x: 0, y: 0 }) }; }); t.after(() => instances.forEach(({ layer, viewer }) => layer.destroy(viewer)), @@ -191,11 +186,16 @@ test('consumer build includes only infrastructure code and resolves assets under const entry = output.find((item) => item.type === 'chunk' && item.isEntry); const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ + 'dispatcher.js', 'infrastructure.js', 'infrastructureData.js', 'infrastructureOverlayEntry.js', + 'inputOwnership.js', 'localGeojsonCore.js', 'localGeojsonLod.js', + 'pickRegistry.js', + 'scenePick.js', + 'sensorPick.js', ]); assert.deepEqual( entry.imports, diff --git a/src/data/inputOwnership.js b/src/data/inputOwnership.js index 4b6a2adea..11caea833 100644 --- a/src/data/inputOwnership.js +++ b/src/data/inputOwnership.js @@ -1,7 +1,7 @@ /** * Who owns the pointer over the globe. * - * Most layers bind their own `ScreenSpaceEventHandler` to the same canvas and + * Most layers read the same globe clicks (src/input/dispatcher.js) and * select whatever is under a left click. That is right when nothing else is * going on, and wrong the moment a tool needs the pointer for itself: a draw * session placing a vertex on top of an aircraft must not also track the @@ -15,8 +15,8 @@ * `releasePointer(lease)` when it turns off. It does not consult anyone; * holding the claim is what makes it the owner. * - An AMBIENT SELECTION HANDLER — anything that picks an entity because the - * user clicked — early-returns while `isPointerFree()` is false. It never - * claims. + * user clicked — is not called while `isPointerFree()` is false: the input + * dispatcher skips it. It never claims. * * Claims do not stack and are never stolen: while one owner holds the pointer, * a second `claimPointer` returns null rather than displacing it — and so does diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 718ef7068..4c75ac7f8 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -109,62 +109,49 @@ test('reset reports who was holding it, for teardown', () => { assert.equal(isLeaseCurrent(lease), false); }); -test('every scene click handler consults ownership before it picks', () => { - // This is the whole point of a shared contract: a new tool must not have to - // find and edit each of these. If a layer grows a click handler it has to - // appear here too. - const guarded = [ - ['src/data/trackingClickGesture.js', 'onClick(click, gesture);'], - ['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'], - ['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'], - ['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'], - ['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'], - ['src/layers/firms/selection.js', 'scene.pick(click.position)'], +test('pointer input, camera holds and the stock clicks go through the one dispatcher', () => { + // The dispatcher (src/input/dispatcher.js) applies the pointer claim once + // per event for every subscriber, so a new tool does not have to find and + // edit each layer. A layer that binds its own handler, writes the camera + // flag or takes the stock clicks itself steps around that contract. + const DISPATCHER = 'src/input/dispatcher.js'; + const rules = [ [ - 'src/layers/installations/selection.js', - 'viewer.scene.pick(click.position)', - ], - ['src/layers/launches/lifecycle.js', 'drillPick(movement.position'], - ['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'], - [ - 'src/layers/satellites/interaction.js', - 'viewer.scene.pick(click.position)', + /new\s+[\w.]*ScreenSpaceEventHandler\(/, + 'binds its own ScreenSpaceEventHandler', ], [ - 'src/layers/submarineCables/interaction.js', - 'viewer.scene.pick(click.position)', + /\.enableInputs\s*=(?!=)/, + 'writes enableInputs instead of suspendCamera()', ], [ - 'src/layers/vessels/selection.js', - 'pickContact(viewer.scene, click.position)', + /screenSpaceEventHandler\??\.removeInputAction\(/, + 'removes stock clicks instead of suppressStockClicks()', ], ]; - for (const [file, firstPick] of guarded) { + const walk = (dir) => + fs + .readdirSync(path.join(ROOT, dir), { withFileTypes: true }) + .flatMap((entry) => { + const rel = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(rel); + return entry.name.endsWith('.js') ? [rel] : []; + }); + const offenders = []; + for (const file of walk('src')) { + if (file === DISPATCHER) continue; const source = read(file); - assert.match( - source, - /import \{ isPointerFree \} from '[^']*inputOwnership\.js';/, - `${file} must consult the shared pointer claim`, - ); - const guardAt = source.indexOf('if (!isPointerFree()) return;'); - const pickAt = source.indexOf(firstPick); - assert.ok(guardAt >= 0, `${file} is missing the guard`); - assert.ok( - pickAt >= 0, - `${file}: could not find its pick call (${firstPick})`, - ); - assert.ok( - guardAt < pickAt, - `${file} must yield before it picks, not after`, - ); + for (const [pattern, what] of rules) + if (pattern.test(source)) offenders.push(`${file} ${what}`); } + assert.deepEqual(offenders, []); }); test('ambient selection handlers never claim the pointer themselves', () => { // Claiming from a selection handler would deadlock every other layer the // moment a user clicked anything. Only tools claim. for (const file of [ - 'src/data/trackingClickGesture.js', + 'src/input/dispatcher.js', 'src/data/localGeojsonCore.js', 'src/data/cctvGizmo.js', 'src/layers/vessels/selection.js', diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs index 33d35a039..ea7ede7a6 100644 --- a/src/data/localGeojson.analyst.test.mjs +++ b/src/data/localGeojson.analyst.test.mjs @@ -186,9 +186,9 @@ test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable ret color: '#0088ff', overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, projectToWindow: () => ({ x: 400, y: 300 }), - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, + inputFor: () => ({ + onClick: () => () => {}, + suspendCamera: () => () => {}, }), }); try { diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 0535fc2e6..32f7254d8 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -133,10 +133,7 @@ async function createRealLocalLayerHarness({ color: '#0088ff', overlayHost, projectToWindow: () => ({ x: 400, y: 300 }), - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), + inputFor: () => ({ onClick: () => () => {}, suspendCamera: () => () => {} }), }); try { await layer.enable(viewer); @@ -533,7 +530,7 @@ async function enableLayerWithFetch(fetchImpl, { dataSources, windowStub } = {}) name: 'Dams', color: '#0088ff', overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + inputFor: () => ({ onClick: () => () => {}, suspendCamera: () => () => {} }), }); const enable = async () => { globalThis.fetch = fetchImpl; @@ -1182,7 +1179,7 @@ async function createMultiFeatureLodHarness({ featureCount = 150, cameraHeightM color: '#00ffff', overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, projectToWindow: () => ({ x: 700, y: 450 }), - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + inputFor: () => ({ onClick: () => () => {}, suspendCamera: () => () => {} }), }); try { await layer.enable(viewer); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 90276634a..6448911d0 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -5,7 +5,7 @@ import { parseGeojsonLines, } from '../sources/infrastructureData.js'; import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js'; -import { isPointerFree } from './inputOwnership.js'; +import { inputFor as defaultInputFor } from '../input/dispatcher.js'; import { selectInfraLod, applyInfraEvictionGrace, @@ -310,8 +310,7 @@ export function createLocalGeoJsonLayer( labels = true, labelMax = DEFAULT_LABEL_MAX, labelGridPx = DEFAULT_LABEL_GRID_PX, - screenSpaceEventHandlerFactory = (canvas) => - new Cesium.ScreenSpaceEventHandler(canvas), + inputFor = defaultInputFor, projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), }, @@ -330,7 +329,7 @@ export function createLocalGeoJsonLayer( // The entities this layer loaded, so its click and teardown recognise them. const _ownEntities = new WeakSet(); let _enabled = false; - let _clickHandler = null; + let _clickSubscription = null; let _count = 0; /** @type {number|null} Timestamp of the last successful dataset load. */ let _lastUpdate = null; @@ -784,15 +783,11 @@ export function createLocalGeoJsonLayer( if (_destroyed) return; // 2. Install native global click handler - if (!_clickHandler) { - _clickHandler = screenSpaceEventHandlerFactory( - viewer.scene.canvas, - ); - _clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js). - if (!isPointerFree()) return; + if (!_clickSubscription) { + const input = inputFor(viewer); + _clickSubscription = input.onClick((event) => { if (!_enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = event.pick; if (picked && _ownEntities.has(picked.id)) { const entity = picked.id; @@ -825,8 +820,12 @@ export function createLocalGeoJsonLayer( if (targetPos) { const carto = Cesium.Cartographic.fromCartesian(targetPos); - // Disable interactions so Cesium doesn't magically cancel the flight - viewer.scene.screenSpaceCameraController.enableInputs = false; + // Hold camera input so Cesium doesn't cancel the flight. + // A hold, not a flag: the flight ending must not hand input + // back under a drag or the cockpit. + const releaseCamera = input.suspendCamera( + 'local-geojson-flight', + ); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromRadians( @@ -835,16 +834,12 @@ export function createLocalGeoJsonLayer( 5000, ), duration: 1.5, - complete: () => { - viewer.scene.screenSpaceCameraController.enableInputs = true; - }, - cancel: () => { - viewer.scene.screenSpaceCameraController.enableInputs = true; - }, + complete: releaseCamera, + cancel: releaseCamera, }); } } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } })(); try { @@ -1074,10 +1069,8 @@ export function createLocalGeoJsonLayer( // torn down even if destroy is called while the layer is enabled. disableLayer(viewer); removeEntityContextsForLayer(id); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } + _clickSubscription?.(); + _clickSubscription = null; if (_dataSource && viewer) { viewer.dataSources.remove(_dataSource, true); } diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs index 8b5621ec4..96b817599 100644 --- a/src/data/localGeojsonLifecycle.test.mjs +++ b/src/data/localGeojsonLifecycle.test.mjs @@ -46,10 +46,13 @@ function harness(t) { const create = () => createLocalGeoJsonLayer({ id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl', overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - screenSpaceEventHandlerFactory() { - handlers++; - return { setInputAction() {}, destroy() { handlers--; } }; - }, + inputFor: () => ({ + onClick() { + handlers++; + return () => { handlers--; }; + }, + suspendCamera: () => () => {}, + }), }); const layer = create(); return { layer, create, viewer, sources, listeners, handlers: () => handlers }; diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index a891b6c5b..814da2f71 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -1,6 +1,6 @@ /** - * Shared pick-ownership registry for layers that install their own - * ScreenSpaceEventHandler click handlers. + * Shared pick-ownership registry for the layers that read globe clicks + * (src/input/dispatcher.js). * * Problem solved: each entity layer (commercial flights, military flights) * receives every LEFT_CLICK. When the user clicks a military aircraft while @@ -109,10 +109,14 @@ export function isOwnedByOtherLayer(layerId, pickedId) { * * @param {object} scene Cesium scene with pick and optional drillPick. * @param {{x: number, y: number}} position Screen position in CSS pixels. + * @param {{picked?: object|null, drill?: (limit: number, width: number, height: number) => object[]}} [options] + * The click's own passes when the caller has them, so a click is picked + * once: `picked` is `scene.pick(position)` (a miss is passed as `undefined` + * too, so the key's presence counts), `drill` a shared `scene.drillPick`. * @returns {object|null|undefined} Original pick or the first registered touch hit. */ -export function pickContact(scene, position) { - const picked = scene.pick(position); +export function pickContact(scene, position, options = {}) { + const picked = 'picked' in options ? options.picked : scene.pick(position); if ( !globalThis.matchMedia?.('(pointer: coarse)').matches || findPickOwner(resolvePickId(picked)) || @@ -122,7 +126,10 @@ export function pickContact(scene, position) { return picked; let hits; try { - hits = scene.drillPick(position, 8, 14, 14) || []; + hits = + (options.drill + ? options.drill(8, 14, 14) + : scene.drillPick(position, 8, 14, 14)) || []; } catch { return picked; } diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index d1373be44..673041976 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -849,10 +849,7 @@ function createDeferredCableLayerHarness() { setEntries: (...args) => hostCalls.push(['entries', ...args]), clearSource: (...args) => hostCalls.push(['clear', ...args]), }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), + inputFor: () => ({ onClick: () => () => {} }), }); const viewer = makeStubViewer(listeners, dataSources, addCalls, addControl); layer.init(viewer); @@ -1306,10 +1303,7 @@ async function createRealCableLayerHarness({ setEntries: (...args) => hostCalls.push(['entries', ...args]), clearSource: (...args) => hostCalls.push(['clear', ...args]), }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), + inputFor: () => ({ onClick: () => () => {} }), ...(source ? { source } : {}), mapStackEventTarget, sweepClock: () => clockMs, diff --git a/src/data/trackingClickGesture.js b/src/data/trackingClickGesture.js index 86cc30814..d0abf7be4 100644 --- a/src/data/trackingClickGesture.js +++ b/src/data/trackingClickGesture.js @@ -1,6 +1,8 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from './inputOwnership.js'; - +/** + * Click-gesture classifiers for the tracking layers. The gesture itself + * (summed travel and press time) is measured by the input dispatcher and + * arrives on every click as `event.gesture` (src/input/dispatcher.js). + */ export const MAX_TRACKING_CLICK_TRAVEL_PX = 6; export const MAX_TRACKING_CLICK_DURATION_MS = 400; @@ -33,79 +35,3 @@ export function isTrackingClickGesture(gesture = {}) { durationMs <= MAX_TRACKING_CLICK_DURATION_MS ); } - -/** - * Bind LEFT_DOWN/MOUSE_MOVE/LEFT_UP accounting ahead of a scene click. - * Travel is accumulated segment-by-segment, so an orbit nudge that returns to - * its starting pixel cannot masquerade as a zero-distance click. Every scene - * click reaches `onClick` with its gesture metadata; the caller decides whether - * selection (travel-only) or deselection (travel + duration) is allowed. - * @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler. - * @param {(click: Object, gesture: {travelPx: number, durationMs: number}) => void} onClick - Scene-click callback. - * @param {{now?: () => number, eventTypes?: Object, onMouseMove?: (event: Object) => void}} [options] - Test/interop seams. - * @returns {void} - */ -export function bindTrackingClickGesture(handler, onClick, options = {}) { - const now = options.now || (() => performance.now()); - const eventTypes = options.eventTypes || Cesium.ScreenSpaceEventType; - let pressActive = false; - let pressStartedAt = 0; - let previousPosition = null; - let travelPx = 0; - let completedGesture = null; - - const appendTravel = (position) => { - if ( - !pressActive || - !Number.isFinite(position?.x) || - !Number.isFinite(position?.y) - ) - return; - if (previousPosition) { - travelPx += Math.hypot( - position.x - previousPosition.x, - position.y - previousPosition.y, - ); - } - previousPosition = { x: position.x, y: position.y }; - }; - - const finishPress = (position) => { - if (!pressActive) return; - appendTravel(position); - completedGesture = { - travelPx, - durationMs: Math.max(0, now() - pressStartedAt), - }; - pressActive = false; - previousPosition = null; - }; - - handler.setInputAction((event) => { - pressActive = true; - pressStartedAt = now(); - previousPosition = null; - travelPx = 0; - completedGesture = null; - appendTravel(event?.position); - }, eventTypes.LEFT_DOWN); - - handler.setInputAction((event) => { - appendTravel(event?.endPosition ?? event?.position); - options.onMouseMove?.(event); - }, eventTypes.MOUSE_MOVE); - - handler.setInputAction((event) => { - finishPress(event?.position); - }, eventTypes.LEFT_UP); - - handler.setInputAction((click) => { - if (pressActive) finishPress(click?.position); - const gesture = completedGesture || { travelPx: 0, durationMs: 0 }; - completedGesture = null; - // A tool owns the pointer: a draw vertex placed over an aircraft is a - // vertex, not a track request (src/data/inputOwnership.js). - if (!isPointerFree()) return; - onClick(click, gesture); - }, eventTypes.LEFT_CLICK); -} diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index 764c60104..f148ee8d9 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -2,26 +2,18 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { - bindTrackingClickGesture, isTrackingClickGesture, isTrackingSelectionGesture, } from './trackingClickGesture.js'; +import { FAKE_EVENT_TYPES as TYPES, fakeInput } from '../input/testDoubles.mjs'; import { TRACKED_MODEL_MAX_PX as CIVIL_TRACKED_MODEL_MAX_PX } from './flights.js'; import { TRACKED_MODEL_MAX_PX as MILITARY_TRACKED_MODEL_MAX_PX } from './militaryFlights.js'; -const TYPES = { - LEFT_DOWN: 'left-down', - MOUSE_MOVE: 'mouse-move', - LEFT_UP: 'left-up', - LEFT_CLICK: 'left-click', -}; - -function makeHandler() { - const actions = new Map(); - return { - setInputAction(callback, type) { actions.set(type, callback); }, - fire(type, event) { actions.get(type)?.(event); }, - }; +/** A dispatcher on a fake handler, with `onClick(click, gesture)` as the layers read it. */ +function gestureInput(now, onClick) { + const { input, handler } = fakeInput({ scene: {} }, { now }); + input.onClick((event) => onClick(event.screenPosition, event.gesture)); + return { input, handler }; } test('tracking click discrimination pins the travel/duration boundary matrix', () => { @@ -43,12 +35,8 @@ test('tracking click discrimination pins the travel/duration boundary matrix', ( test('synthetic drag-then-click sequence does not reach the untrack callback', () => { let timeMs = 0; let untrackCalls = 0; - const handler = makeHandler(); - bindTrackingClickGesture(handler, (_click, gesture) => { + const { input, handler } = gestureInput(() => timeMs, (_click, gesture) => { if (isTrackingClickGesture(gesture)) untrackCalls += 1; - }, { - now: () => timeMs, - eventTypes: TYPES, }); handler.fire(TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); @@ -64,29 +52,27 @@ test('synthetic drag-then-click sequence does not reach the untrack callback', ( handler.fire(TYPES.LEFT_CLICK, { position: { x: 10, y: 10 } }); assert.equal(untrackCalls, 1, 'suppression is consumed and cannot poison the next click'); + input.destroy(); }); test('slow clean sprite clicks select, while long presses and orbit nudges cannot untrack', () => { let timeMs = 0; let selections = 0; let untracks = 0; - const handler = makeHandler(); - bindTrackingClickGesture(handler, (click, gesture) => { + // A click at x 0 lands on a sprite; anywhere else is empty space. + const { input, handler } = gestureInput(() => timeMs, (click, gesture) => { if (!isTrackingSelectionGesture(gesture)) return; - if (click.sprite) { + if (click.x === 0 && click.y === 0) { selections += 1; return; } if (isTrackingClickGesture(gesture)) untracks += 1; - }, { - now: () => timeMs, - eventTypes: TYPES, }); handler.fire(TYPES.LEFT_DOWN, { position: { x: 0, y: 0 } }); timeMs = 401; handler.fire(TYPES.LEFT_UP, { position: { x: 0, y: 0 } }); - handler.fire(TYPES.LEFT_CLICK, { position: { x: 0, y: 0 }, sprite: true }); + handler.fire(TYPES.LEFT_CLICK, { position: { x: 0, y: 0 } }); assert.equal(selections, 1, 'duration alone must not suppress entity selection'); assert.equal(untracks, 0); @@ -106,6 +92,7 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno handler.fire(TYPES.LEFT_UP, { position: { x: 3, y: 4 } }); handler.fire(TYPES.LEFT_CLICK, { position: { x: 3, y: 4 } }); assert.equal(untracks, 1, 'a short clean empty-space tap still untracks'); + input.destroy(); }); test('civilian and military click handlers apply duration only at the deselect branch', () => { @@ -114,7 +101,7 @@ test('civilian and military click handlers apply duration only at the deselect b readLayerSource(new URL('./militaryFlights.js', import.meta.url)), ]; for (const source of sources) { - assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/); + assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+event\.hit/); assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/); } assert.doesNotMatch( diff --git a/src/input/dispatcher.js b/src/input/dispatcher.js new file mode 100644 index 000000000..39a908f6c --- /dev/null +++ b/src/input/dispatcher.js @@ -0,0 +1,440 @@ +/** + * @module input/dispatcher + * @description The one place pointer input over the globe comes in: a single + * `ScreenSpaceEventHandler` per scene, a click picked once and handed to + * every subscriber, and the camera-input and stock-click holds tools take. + * + * Layers used to bind a handler each and pick the same click again, so one + * click with the usual layers on cost about 25 `scene.pick` passes (each one + * redraws the scene for the pick buffer). Now the dispatcher picks lazily on + * first read and every subscriber reads the same {@link ClickEvent}; what a + * layer does with a click that is not its own stays the layer's decision. + * + * Who gets the pointer: + * - Ambient subscribers ({@link InputDispatcher.onClick}, and `onPointer` + * without a lease) run only while no tool holds the pointer + * (src/data/inputOwnership.js). The check is made once per event, before + * any subscriber runs, so a tool that releases its lease while handling a + * click does not let that same click through to ambient selection. Ambient + * `move` and `up` are the exceptions: hover sees every move, so it can drop + * its cursor or card when a tool takes the pointer, and a release always + * reaches whoever began the press, so a drag started while the pointer was + * free still ends (and gives its camera hold back). + * - Tool subscribers pass their lease and run only while it is current. + * + * Camera input is suspended by holds, not by a flag: every suspender gets its + * own release, and input comes back when the last one releases. The + * dispatcher owns the controller's `enableInputs`: what others write to it + * (Cesium's own camera flights turn it off and back on) is kept, and it reads + * true only while that is true and no hold is out, so a flight that ends can + * no longer re-enable input under a drag or the cockpit. + * + * A dispatcher is made per scene on first use ({@link inputFor}) and lives + * until {@link InputDispatcher.destroy}; the application scene owns it. + */ +import * as Cesium from 'cesium'; +import { isLeaseCurrent, isPointerFree } from '../data/inputOwnership.js'; +import { + findPickOwner, + pickContact, + resolvePickId, +} from '../data/pickRegistry.js'; +import { isSurfacePick, pickGroundPosition } from '../data/scenePick.js'; +import { sensorPickPosition } from './sensorPick.js'; + +// A tool takes the pointer with a lease before it subscribes with one. +export { + claimPointer, + isLeaseCurrent, + isPointerFree, + pointerOwner, + releasePointer, +} from '../data/inputOwnership.js'; + +/** A drill is run at least this deep and sliced, so callers asking for + * different depths on one click share one pass. */ +const DRILL_DEPTH = 16; + +const POINTER_TYPES = ['down', 'move', 'up', 'click', 'dblclick']; + +/** A tool that re-arms takes a new lease each time: it may pass a getter. */ +const leaseOf = (entry) => + typeof entry.lease === 'function' ? entry.lease() : entry.lease; + +/** @type {WeakMap} scene -> dispatcher */ +const dispatchers = new WeakMap(); + +/** + * @typedef {object} ClickGesture + * @property {number} travelPx Pointer travel during the press, summed per + * segment (an orbit nudge that comes back is not a zero-distance click). + * @property {number} durationMs Press time. + */ + +/** + * One left click, picked at most once per kind of query. Every getter and + * method is memoized, so subscribers share the passes. + * @typedef {object} ClickEvent + * @property {{x: number, y: number}} screenPosition Where the user clicked. + * @property {Cesium.Cartesian2} position Where that lands in the pick buffer + * (corrected for sensor styles; see sensorPick.js). + * @property {ClickGesture} gesture + * @property {object|undefined} pick `scene.pick` at `position`. + * @property {object|undefined} hit `pick`, or on a coarse pointer the first + * registered contact under a surface pick (`pickContact`). + * @property {string|null} id The hit's canonical pick id. + * @property {string|null} ownerId The layer that registered the hit. + * @property {object|undefined} meta The hit's own metadata, when its pick + * carries any (icon-set rows do). + * @property {boolean} surface Whether the hit is the map itself. + * @property {(width: number, height: number) => object|undefined} pickWindow + * `scene.pick` with a wider window. + * @property {(limit?: number, width?: number, height?: number) => object[]} drill + * `scene.drillPick`, at most `limit` hits. + * @property {() => Cesium.Cartesian3|null} ground The world position under + * the click: rendered surface, else ellipsoid; null over the sky. + */ + +/** + * @typedef {object} InputDispatcher + * @property {(fn: (event: ClickEvent) => void) => () => void} onClick + * Ambient click subscription. Returns its unsubscribe. + * @property {(type: 'down'|'move'|'up'|'click'|'dblclick', fn: Function, options?: {lease?: object|(() => object|null)}) => () => void} onPointer + * Raw pointer subscription: `click` gets the {@link ClickEvent}, the others + * Cesium's event. With a lease, runs only while that lease is current; + * without one, only while the pointer is free (`move` and `up` always run). + * @property {(reason: string) => () => void} suspendCamera Hold camera input + * off; returns this hold's release. + * @property {() => () => void} suppressStockClicks Take the viewer's stock + * select and double-click-track actions off; returns this hold's release. + * @property {() => number} subscriberCount Live click and pointer + * subscriptions, for leak checks. + * @property {() => void} destroy + */ + +/** + * The scene's dispatcher, made on first use. + * @param {object} viewer A Cesium viewer (or a test double with `scene`). + * @param {{createHandler?: (canvas: object) => object, eventTypes?: object, now?: () => number}} [options] + * Used only by the call that makes it. + * @returns {InputDispatcher} + */ +export function inputFor(viewer, options = {}) { + const scene = viewer.scene; + let dispatcher = dispatchers.get(scene); + if (!dispatcher) { + dispatcher = createDispatcher(viewer, options); + dispatchers.set(scene, dispatcher); + } + return dispatcher; +} + +/** + * Take over the controller's `enableInputs`: what others write to it is kept + * (through an accessor already there, if any), and it reads true only while + * that is true and no hold is out. + * @param {object|undefined} controller + * @param {Set} holds + * @returns {() => void} Gives the controller its own property back. + */ +function ownCameraInput(controller, holds) { + if (!controller) return () => {}; + const own = Object.getOwnPropertyDescriptor(controller, 'enableInputs'); + if (own && !own.configurable) { + console.warn('Camera input holds are unavailable: enableInputs is fixed'); + return () => {}; + } + // A class may define it on its prototype; the hold shadows that for a while. + let original = own; + for (let proto = Object.getPrototypeOf(controller); !original && proto;) { + original = Object.getOwnPropertyDescriptor(proto, 'enableInputs'); + proto = Object.getPrototypeOf(proto); + } + original ??= { value: true, writable: true, enumerable: true }; + let written = Boolean(original.get ? true : original.value); + const read = () => + original.get ? Boolean(original.get.call(controller)) : written; + Object.defineProperty(controller, 'enableInputs', { + configurable: true, + enumerable: original.enumerable ?? true, + get: () => read() && holds.size === 0, + set: (value) => { + written = Boolean(value); + original.set?.call(controller, value); + }, + }); + return () => { + delete controller.enableInputs; + if (own?.get || own?.set) + Object.defineProperty(controller, 'enableInputs', own); + else if (own || !(original.get || original.set)) + controller.enableInputs = written; + }; +} + +function createDispatcher( + viewer, + { + createHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), + eventTypes = Cesium.ScreenSpaceEventType, + now = () => performance.now(), + }, +) { + const scene = viewer.scene; + const cameraHolds = new Set(); + // Before the handler, so a controller that cannot be owned leaks nothing. + const releaseCameraInput = ownCameraInput( + scene.screenSpaceCameraController, + cameraHolds, + ); + const handler = createHandler(scene.canvas); + /** @type {Array<{fn: Function}>} */ + const clickSubscribers = []; + /** @type {Record>} */ + const pointerSubscribers = Object.fromEntries( + POINTER_TYPES.map((type) => [type, []]), + ); + const stockHolds = new Set(); + let stockSaved = null; + let destroyed = false; + + let pressActive = false; + let pressStartedAt = 0; + let previousPosition = null; + let travelPx = 0; + let completedGesture = null; + + const appendTravel = (position) => { + if ( + !pressActive || + !Number.isFinite(position?.x) || + !Number.isFinite(position?.y) + ) + return; + if (previousPosition) { + travelPx += Math.hypot( + position.x - previousPosition.x, + position.y - previousPosition.y, + ); + } + previousPosition = { x: position.x, y: position.y }; + }; + + const finishPress = (position) => { + if (!pressActive) return; + appendTravel(position); + completedGesture = { + travelPx, + durationMs: Math.max(0, now() - pressStartedAt), + }; + pressActive = false; + previousPosition = null; + }; + + const run = (list, free, arg) => { + for (const entry of [...list]) { + if (entry.lease ? !isLeaseCurrent(leaseOf(entry)) : !free) continue; + try { + entry.fn(arg); + } catch (error) { + console.error('Pointer input handler failed', error); + } + } + }; + + handler.setInputAction((event) => { + pressActive = true; + pressStartedAt = now(); + previousPosition = null; + travelPx = 0; + completedGesture = null; + appendTravel(event?.position); + run(pointerSubscribers.down, isPointerFree(), event); + }, eventTypes.LEFT_DOWN); + + handler.setInputAction((event) => { + appendTravel(event?.endPosition ?? event?.position); + run(pointerSubscribers.move, true, event); + }, eventTypes.MOUSE_MOVE); + + handler.setInputAction((event) => { + finishPress(event?.position); + run(pointerSubscribers.up, true, event); + }, eventTypes.LEFT_UP); + + handler.setInputAction((click) => { + if (pressActive) finishPress(click?.position); + const gesture = completedGesture || { travelPx: 0, durationMs: 0 }; + completedGesture = null; + const free = isPointerFree(); + const event = createClickEvent(viewer, click, gesture); + run(pointerSubscribers.click, free, event); + if (free) run(clickSubscribers, true, event); + }, eventTypes.LEFT_CLICK); + + handler.setInputAction((event) => { + run(pointerSubscribers.dblclick, isPointerFree(), event); + }, eventTypes.LEFT_DOUBLE_CLICK); + + const subscribe = (list, entry) => { + if (destroyed) return () => {}; + list.push(entry); + return () => { + const at = list.indexOf(entry); + if (at >= 0) list.splice(at, 1); + }; + }; + + const stockHandler = () => viewer.screenSpaceEventHandler; + const stockTypes = () => [ + eventTypes.LEFT_CLICK, + eventTypes.LEFT_DOUBLE_CLICK, + ]; + + const restoreStock = () => { + const stock = stockHandler(); + if (stockSaved && stock && !stock.isDestroyed?.()) { + for (const [type, action] of stockSaved) { + if (action) stock.setInputAction(action, type); + } + } + stockSaved = null; + }; + + return { + onClick(fn) { + return subscribe(clickSubscribers, { fn, lease: null }); + }, + + onPointer(type, fn, { lease = null } = {}) { + const list = pointerSubscribers[type]; + if (!list) throw new TypeError(`Unknown pointer event: ${type}`); + return subscribe(list, { fn, lease }); + }, + + suspendCamera(reason) { + if (destroyed) return () => {}; + const hold = { reason }; + cameraHolds.add(hold); + return () => { + cameraHolds.delete(hold); + }; + }, + + suppressStockClicks() { + if (destroyed) return () => {}; + const hold = {}; + if (stockHolds.size === 0) { + const stock = stockHandler(); + if (stock) { + // Cesium's own select and double-click-track run on the viewer's + // handler and never see the pointer lease. + stockSaved = stockTypes().map((type) => [ + type, + stock.getInputAction(type), + ]); + for (const type of stockTypes()) stock.removeInputAction(type); + } + } + stockHolds.add(hold); + return () => { + if (!stockHolds.delete(hold) || stockHolds.size > 0) return; + restoreStock(); + }; + }, + + subscriberCount() { + return POINTER_TYPES.reduce( + (sum, type) => sum + pointerSubscribers[type].length, + clickSubscribers.length, + ); + }, + + destroy() { + if (destroyed) return; + destroyed = true; + dispatchers.delete(scene); + clickSubscribers.length = 0; + for (const type of POINTER_TYPES) pointerSubscribers[type].length = 0; + cameraHolds.clear(); + releaseCameraInput(); + if (stockHolds.size) { + stockHolds.clear(); + restoreStock(); + } + if (!handler.isDestroyed?.()) handler.destroy?.(); + }, + }; +} + +/** + * @param {object} viewer + * @param {{position: {x: number, y: number}}} click + * @param {ClickGesture} gesture + * @returns {ClickEvent} + */ +function createClickEvent(viewer, click, gesture) { + const scene = viewer.scene; + const position = sensorPickPosition(scene, click.position); + const memo = new Map(); + const once = (key, compute) => { + if (!memo.has(key)) memo.set(key, compute()); + return memo.get(key); + }; + /** @type {Map} */ + const drills = new Map(); + + return { + screenPosition: click.position, + position, + gesture, + get pick() { + return once('pick', () => scene.pick(position)); + }, + get hit() { + return once('hit', () => + pickContact(scene, position, { + picked: this.pick, + drill: (limit, width, height) => this.drill(limit, width, height), + }), + ); + }, + get id() { + return once('id', () => resolvePickId(this.hit)); + }, + get ownerId() { + return once('ownerId', () => findPickOwner(this.id)); + }, + get meta() { + const hit = this.hit; + return hit?.id?.meta ?? hit?.meta; + }, + get surface() { + return isSurfacePick(this.hit); + }, + pickWindow(width, height) { + return once(`window:${width}x${height}`, () => + scene.pick(position, width, height), + ); + }, + drill(limit = DRILL_DEPTH, width, height) { + const key = `${width}x${height}`; + const done = drills.get(key); + // A pass that came back short already holds every hit there is. + if (done && (limit <= done.limit || done.hits.length < done.limit)) + return done.hits.slice(0, limit); + if (typeof scene.drillPick !== 'function') return []; + // Only the default window is shared between layers; round that one up. + const depth = + width === undefined && height === undefined + ? Math.max(limit, DRILL_DEPTH) + : limit; + const hits = scene.drillPick(position, depth, width, height) || []; + drills.set(key, { limit: depth, hits }); + return hits.slice(0, limit); + }, + ground() { + return once('ground', () => pickGroundPosition(viewer, position)); + }, + }; +} diff --git a/src/input/dispatcher.test.mjs b/src/input/dispatcher.test.mjs new file mode 100644 index 000000000..20908077f --- /dev/null +++ b/src/input/dispatcher.test.mjs @@ -0,0 +1,396 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { inputFor } from './dispatcher.js'; +import { + FAKE_EVENT_TYPES, + FakeScreenSpaceEventHandler as FakeHandler, + fakeInput, +} from './testDoubles.mjs'; +import { sensorPickPosition } from './sensorPick.js'; +import { claimPointer, releasePointer } from '../data/inputOwnership.js'; +import { + registerPickOwner, + unregisterPickOwner, +} from '../data/pickRegistry.js'; + +const TYPES = FAKE_EVENT_TYPES; + +function harness({ pick = () => undefined, drillPick = () => [] } = {}) { + const calls = { pick: 0, drillPick: 0 }; + const stock = new FakeHandler(); + const stockClick = () => {}; + const stockDouble = () => {}; + stock.setInputAction(stockClick, TYPES.LEFT_CLICK); + stock.setInputAction(stockDouble, TYPES.LEFT_DOUBLE_CLICK); + let clock = 0; + const viewer = { + screenSpaceEventHandler: stock, + camera: { pickEllipsoid: () => null }, + scene: { + canvas: {}, + screenSpaceCameraController: { enableInputs: true }, + pick(...args) { + calls.pick += 1; + return pick(...args); + }, + drillPick(...args) { + calls.drillPick += 1; + return drillPick(...args); + }, + }, + }; + const { input, handler } = fakeInput(viewer, { now: () => clock }); + return { + viewer, + input, + handler, + stock, + stockClick, + stockDouble, + calls, + advance(ms) { + clock += ms; + }, + click(x = 10, y = 10) { + handler.fire(TYPES.LEFT_DOWN, { position: { x, y } }); + handler.fire(TYPES.LEFT_UP, { position: { x, y } }); + handler.fire(TYPES.LEFT_CLICK, { position: { x, y } }); + }, + }; +} + +test('every subscriber reads one pick of the click', (t) => { + registerPickOwner('test-owner', (id) => id === 'plane-1'); + t.after(() => unregisterPickOwner('test-owner')); + const h = harness({ pick: () => ({ id: 'plane-1' }) }); + t.after(() => h.input.destroy()); + const seen = []; + for (let i = 0; i < 5; i++) + h.input.onClick((event) => seen.push([event.id, event.ownerId])); + h.click(); + assert.equal(h.calls.pick, 1); + assert.deepEqual(seen, Array(5).fill(['plane-1', 'test-owner'])); +}); + +test('a click on nothing is picked once, however its hit is read', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + h.input.onClick((event) => [event.pick, event.hit, event.id, event.ownerId]); + h.click(); + assert.equal(h.calls.pick, 1); +}); + +test('a click nobody reads is never picked', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + h.input.onClick(() => {}); + h.click(); + assert.equal(h.calls.pick, 0); +}); + +test('the gesture carries summed travel and press time', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + let gesture; + h.input.onClick((event) => (gesture = event.gesture)); + h.handler.fire(TYPES.LEFT_DOWN, { position: { x: 0, y: 0 } }); + h.handler.fire(TYPES.MOUSE_MOVE, { endPosition: { x: 4, y: 0 } }); + h.handler.fire(TYPES.MOUSE_MOVE, { endPosition: { x: 0, y: 0 } }); + h.advance(250); + h.handler.fire(TYPES.LEFT_UP, { position: { x: 0, y: 0 } }); + h.handler.fire(TYPES.LEFT_CLICK, { position: { x: 0, y: 0 } }); + assert.deepEqual(gesture, { travelPx: 8, durationMs: 250 }); +}); + +test('a held pointer goes to its tool, not to ambient selection', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const lease = claimPointer('test-tool'); + t.after(() => releasePointer(lease)); + const order = []; + h.input.onClick(() => order.push('ambient')); + h.input.onPointer( + 'click', + () => { + order.push('tool'); + // Releasing mid-click must not hand THIS click to ambient selection. + releasePointer(lease); + }, + { lease }, + ); + h.click(); + assert.deepEqual(order, ['tool']); + h.click(); + assert.deepEqual(order, ['tool', 'ambient']); +}); + +test('moves and releases reach ambient subscribers while a tool holds the pointer; presses do not', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const seen = []; + h.input.onPointer('move', () => seen.push('move')); + h.input.onPointer('down', () => seen.push('down')); + h.input.onPointer('up', () => seen.push('up')); + const lease = claimPointer('test-tool'); + t.after(() => releasePointer(lease)); + h.handler.fire(TYPES.LEFT_DOWN, { position: { x: 0, y: 0 } }); + h.handler.fire(TYPES.MOUSE_MOVE, { endPosition: { x: 1, y: 0 } }); + h.handler.fire(TYPES.LEFT_UP, { position: { x: 1, y: 0 } }); + assert.deepEqual(seen, ['move', 'up']); +}); + +test('a stale lease gets nothing once its successor holds the pointer', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const first = claimPointer('test-tool'); + let staleRan = 0; + h.input.onPointer('down', () => (staleRan += 1), { lease: first }); + releasePointer(first); + const second = claimPointer('test-tool'); + t.after(() => releasePointer(second)); + h.click(); + assert.equal(staleRan, 0); +}); + +test('camera input returns only when the last hold releases', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const controller = h.viewer.scene.screenSpaceCameraController; + const drag = h.input.suspendCamera('drag'); + const flight = h.input.suspendCamera('flight'); + assert.equal(controller.enableInputs, false); + // The flight ends while the drag still holds the camera. + flight(); + assert.equal(controller.enableInputs, false); + flight(); + assert.equal(controller.enableInputs, false, 'a double release is a no-op'); + drag(); + assert.equal(controller.enableInputs, true); +}); + +test('a camera flight that ends does not give input back under a hold', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const controller = h.viewer.scene.screenSpaceCameraController; + // Cesium's CameraFlightPath turns input off for the flight and back on + // when it completes or is cancelled, whoever else holds the camera. + controller.enableInputs = false; + const drag = h.input.suspendCamera('drag'); + controller.enableInputs = true; + assert.equal(controller.enableInputs, false, 'the drag still holds it'); + drag(); + assert.equal(controller.enableInputs, true); + controller.enableInputs = false; + assert.equal(controller.enableInputs, false, 'a flight still turns it off'); + controller.enableInputs = true; +}); + +test('a controller with its own enableInputs accessor keeps it', () => { + const h = harness(); + h.input.destroy(); + const controller = h.viewer.scene.screenSpaceCameraController; + let stored = true; + const get = () => stored; + const set = (value) => (stored = value); + Object.defineProperty(controller, 'enableInputs', { + configurable: true, + enumerable: true, + get, + set, + }); + const input = inputFor(h.viewer, { + createHandler: () => new FakeHandler(), + eventTypes: TYPES, + }); + const hold = input.suspendCamera('drag'); + assert.equal(controller.enableInputs, false); + controller.enableInputs = false; + assert.equal(stored, false, 'writes reach the original setter'); + controller.enableInputs = true; + hold(); + assert.equal(controller.enableInputs, true); + input.destroy(); + const restored = Object.getOwnPropertyDescriptor(controller, 'enableInputs'); + assert.equal(restored.get, get); + assert.equal(restored.set, set); +}); + +test('a controller whose enableInputs is inherited keeps its class accessor', () => { + class Controller { + constructor() { + this.stored = false; + } + get enableInputs() { + return this.stored; + } + set enableInputs(value) { + this.stored = value; + } + } + const controller = new Controller(); + const viewer = { + scene: { canvas: {}, screenSpaceCameraController: controller }, + }; + const input = inputFor(viewer, { + createHandler: () => new FakeHandler(), + eventTypes: TYPES, + }); + assert.equal(controller.enableInputs, false, 'the inherited getter is read'); + controller.enableInputs = true; + assert.equal(controller.stored, true, 'and the inherited setter written'); + const hold = input.suspendCamera('drag'); + assert.equal(controller.enableInputs, false); + hold(); + assert.equal(controller.enableInputs, true); + input.destroy(); + assert.equal( + Object.getOwnPropertyDescriptor(controller, 'enableInputs'), + undefined, + 'nothing is left shadowing the class', + ); +}); + +test('a controller whose enableInputs cannot be owned still gets a dispatcher', () => { + const warn = console.warn; + console.warn = () => {}; + try { + const controller = {}; + Object.defineProperty(controller, 'enableInputs', { + configurable: false, + get: () => true, + set() {}, + }); + const handler = new FakeHandler(); + const viewer = { + scene: { canvas: {}, screenSpaceCameraController: controller }, + }; + const input = inputFor(viewer, { + createHandler: () => handler, + eventTypes: TYPES, + }); + const hold = input.suspendCamera('drag'); + hold(); + input.destroy(); + assert.equal(handler.destroyed, true); + } finally { + console.warn = warn; + } +}); + +test('stock clicks come back by identity after the last hold', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const a = h.input.suppressStockClicks(); + const b = h.input.suppressStockClicks(); + assert.equal(h.stock.getInputAction(TYPES.LEFT_CLICK), undefined); + a(); + assert.equal(h.stock.getInputAction(TYPES.LEFT_DOUBLE_CLICK), undefined); + b(); + assert.equal(h.stock.getInputAction(TYPES.LEFT_CLICK), h.stockClick); + assert.equal(h.stock.getInputAction(TYPES.LEFT_DOUBLE_CLICK), h.stockDouble); +}); + +test('drills of different depths on one click share a pass', (t) => { + const hits = Array.from({ length: 20 }, (_, i) => ({ id: `hit-${i}` })); + const h = harness({ drillPick: (_p, limit) => hits.slice(0, limit) }); + t.after(() => h.input.destroy()); + const lengths = []; + h.input.onClick((event) => lengths.push(event.drill(12).length)); + h.input.onClick((event) => lengths.push(event.drill(8).length)); + h.input.onClick((event) => lengths.push(event.drill(16).length)); + h.click(); + assert.deepEqual(lengths, [12, 8, 16]); + assert.equal(h.calls.drillPick, 1); +}); + +test('a touch tap’s contact search is the click’s own drill', (t) => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query) => ({ matches: query === '(pointer: coarse)' }), + }); + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + }); + registerPickOwner('test-owner', (id) => id === 'plane-1'); + t.after(() => unregisterPickOwner('test-owner')); + const tile = { primitive: { isCesium3DTileset: true } }; + const h = harness({ pick: () => tile, drillPick: () => [{ id: 'plane-1' }] }); + t.after(() => h.input.destroy()); + const seen = []; + h.input.onClick((event) => seen.push(event.id)); + h.input.onClick((event) => seen.push(event.drill(8, 14, 14).length)); + h.click(); + assert.deepEqual(seen, ['plane-1', 1]); + assert.equal(h.calls.drillPick, 1); +}); + +test('a short drill answers deeper asks without another pass', (t) => { + const h = harness({ drillPick: () => [{ id: 'only' }] }); + t.after(() => h.input.destroy()); + h.input.onClick((event) => event.drill(8)); + h.input.onClick((event) => event.drill(40)); + h.click(); + assert.equal(h.calls.drillPick, 1); +}); + +test('one failing subscriber does not stop the rest', (t) => { + const h = harness(); + t.after(() => h.input.destroy()); + const error = console.error; + console.error = () => {}; + t.after(() => (console.error = error)); + let ran = false; + h.input.onClick(() => { + throw new Error('boom'); + }); + h.input.onClick(() => (ran = true)); + h.click(); + assert.equal(ran, true); +}); + +test('destroy releases holds and the handler, and the scene gets a new one', () => { + const h = harness(); + h.input.suspendCamera('drag'); + h.input.suppressStockClicks(); + h.input.destroy(); + assert.equal(h.handler.destroyed, true); + const controller = h.viewer.scene.screenSpaceCameraController; + assert.equal(controller.enableInputs, true); + assert.ok( + Object.getOwnPropertyDescriptor(controller, 'enableInputs').writable, + 'the controller gets its plain flag back', + ); + assert.equal(h.stock.getInputAction(TYPES.LEFT_CLICK), h.stockClick); + const next = inputFor(h.viewer, { + createHandler: () => new FakeHandler(), + eventTypes: TYPES, + }); + assert.notEqual(next, h.input); + next.destroy(); +}); + +test('sensor styles move the pick to where the picture came from', () => { + const stage = { enabled: true, uniforms: { intensity: 1, pixelation: 4 } }; + const scene = { + canvas: { clientWidth: 400, clientHeight: 200, width: 400, height: 200 }, + postProcessStages: { + getStageByName: (name) => + name === 'godsEyeView_surveillance' ? stage : undefined, + }, + }; + const click = { x: 300, y: 50 }; + const mapped = sensorPickPosition(scene, click); + assert.notDeepEqual([mapped.x, mapped.y], [300, 50]); + assert.deepEqual(click, { x: 300, y: 50 }, 'the click is left alone'); + stage.enabled = false; + const plain = sensorPickPosition(scene, click); + assert.deepEqual([plain.x, plain.y], [300, 50]); +}); + +test('the dispatcher is the package input export', async () => { + const exported = await import('gods-eye-view/input'); + assert.equal(exported.inputFor, inputFor); +}); diff --git a/src/input/sensorPick.js b/src/input/sensorPick.js new file mode 100644 index 000000000..9506475f8 --- /dev/null +++ b/src/input/sensorPick.js @@ -0,0 +1,52 @@ +/** + * @module sensorPick + * @description Where a click lands in the pick buffer while a sensor style + * distorts the picture. Picks read the scene before post-processing, so under + * the thermal or surveillance shader the pixel the user clicked shows a + * different spot of the scene: map it back through the shader's barrel + * distortion and quantization first. + */ +import * as Cesium from 'cesium'; + +const SENSOR_STYLES = ['thermal', 'surveillance']; + +/** + * The pick-buffer position for a screen click. + * @param {Cesium.Scene} scene + * @param {{x: number, y: number}} position CSS pixels on the canvas. + * @returns {Cesium.Cartesian2} A new position; the click's own is left alone. + */ +export function sensorPickPosition(scene, position) { + const out = new Cesium.Cartesian2(position.x, position.y); + const stages = scene.postProcessStages; + if (typeof stages?.getStageByName !== 'function') return out; + for (const style of SENSOR_STYLES) { + const stage = stages.getStageByName(`godsEyeView_${style}`); + if (!stage?.enabled) continue; + const intensity = stage.uniforms.intensity; + const canvas = scene.canvas, + w = canvas.clientWidth, + h = canvas.clientHeight; + let u = out.x / w, + v = 1 - out.y / h; + if (style === 'surveillance') { + const x = u * 2 - 1, + y = v * 2 - 1, + r2 = x * x + y * y; + const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075; + u = (x * d + 1) / 2; + v = (y * d + 1) / 2; + } + const grid = 1 + (stage.uniforms.pixelation - 1) * intensity; + u += + ((Math.floor((u * canvas.width) / grid) * grid) / canvas.width - u) * + intensity; + v += + ((Math.floor((v * canvas.height) / grid) * grid) / canvas.height - v) * + intensity; + out.x = u * w; + out.y = (1 - v) * h; + return out; + } + return out; +} diff --git a/src/input/testDoubles.mjs b/src/input/testDoubles.mjs new file mode 100644 index 000000000..106ecaf50 --- /dev/null +++ b/src/input/testDoubles.mjs @@ -0,0 +1,76 @@ +/** + * Test doubles for the input dispatcher: a stand-in for Cesium's + * `ScreenSpaceEventHandler`, and a dispatcher made on it ahead of the code + * under test, so that code's `inputFor(viewer)` gets the fake. + */ +import { inputFor } from './dispatcher.js'; + +export const FAKE_EVENT_TYPES = Object.freeze({ + LEFT_DOWN: 'down', + MOUSE_MOVE: 'move', + LEFT_UP: 'up', + LEFT_CLICK: 'click', + LEFT_DOUBLE_CLICK: 'dblclick', +}); + +export class FakeScreenSpaceEventHandler { + constructor() { + this.actions = new Map(); + this.destroyed = false; + } + setInputAction(fn, type) { + this.actions.set(type, fn); + } + getInputAction(type) { + return this.actions.get(type); + } + removeInputAction(type) { + this.actions.delete(type); + } + fire(type, event) { + this.actions.get(type)?.(event); + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + } +} + +/** + * Make `viewer`'s dispatcher on a fake handler and return ways to drive it. + * @param {object} viewer + * @param {{now?: () => number, eventTypes?: object}} [options] `eventTypes` + * defaults to {@link FAKE_EVENT_TYPES}; pass Cesium's own when the code under + * test reads the viewer's stock actions by Cesium's type values. + */ +export function fakeInput(viewer, { now, eventTypes = FAKE_EVENT_TYPES } = {}) { + const handler = new FakeScreenSpaceEventHandler(); + const input = inputFor(viewer, { + createHandler: () => handler, + eventTypes, + ...(now ? { now } : {}), + }); + const T = eventTypes; + return { + input, + handler, + down: (position) => handler.fire(T.LEFT_DOWN, { position }), + move: (endPosition, startPosition = endPosition) => + handler.fire(T.MOUSE_MOVE, { startPosition, endPosition }), + up: (position) => handler.fire(T.LEFT_UP, { position }), + /** A bare LEFT_CLICK, as Cesium raises it after a tap. */ + click: (position = { x: 10, y: 10 }) => + handler.fire(T.LEFT_CLICK, { position }), + /** Down, up and click at one spot. */ + tap(position = { x: 10, y: 10 }) { + handler.fire(T.LEFT_DOWN, { position }); + handler.fire(T.LEFT_UP, { position }); + handler.fire(T.LEFT_CLICK, { position }); + }, + dblclick: (position = { x: 10, y: 10 }) => + handler.fire(T.LEFT_DOUBLE_CLICK, { position }), + destroy: () => input.destroy(), + }; +} diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 474ec1de0..6be6523e6 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -63,7 +63,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { moveEndRemove: null, postRenderRemove: null, lastAnchorSampleAt: 0, - clickHandler: null, + clickSubscription: null, debounceTimer: null, /** Snapped box of the last successful query; a view still inside it reuses its records. */ lastQueryBox: null, @@ -343,8 +343,8 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.moveEndRemove = null; state.postRenderRemove?.(); state.postRenderRemove = null; - state.clickHandler?.destroy(); - state.clickHandler = null; + state.clickSubscription?.(); + state.clickSubscription = null; clearRendered(); if (state.dataSource && viewer) viewer.dataSources.remove(state.dataSource, true); diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 9df584a7a..c1ea8a4eb 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { LAYER_ID, MAX_VIEWPORT_DEGREES, @@ -624,18 +624,14 @@ export function createAlprPresentation({ state, services, source }) { } function installInteraction(viewer) { - if (state.clickHandler) return; - state.clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - state.clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + if (state.clickSubscription) return; + state.clickSubscription = inputFor(viewer).onClick((event) => { if (!state.enabled) return; - const picked = viewer.scene.pick(click.position); + // The painted overlay answers first; the scene is picked only if not. + const pickedId = event.pick?.id?.id; const id = - overlay.pick(click.position) || - (typeof picked?.id?.id === 'string' ? picked.id.id : null); + overlay.pick(event.screenPosition) || + (typeof pickedId === 'string' ? pickedId : null); if ( id && state.recordById.has(id) && @@ -643,7 +639,7 @@ export function createAlprPresentation({ state, services, source }) { ) selectRecord(id); else if (state.selectedId) clearSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } return { initOverlay: () => overlay.init(), diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js index 3e46b5972..d6086b78c 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/lifecycle.js @@ -44,7 +44,7 @@ export function createLifecycle({ layerState._inFlightInfo = new Map(); layerState._inFlightStatus = new Map(); layerState._stationRenderMap = new Map(); - layerState._clickHandler = null; + layerState._clickSubscription = null; layerState._selectedKey = null; layerState._selectedEntity = null; layerState._count = 0; @@ -117,10 +117,8 @@ export function createLifecycle({ false, ); - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } + layerState._clickSubscription?.(); + layerState._clickSubscription = null; document.removeEventListener('keydown', parts.selection._onKeyDown); unregisterPickOwner('bikeshare'); @@ -150,10 +148,8 @@ export function createLifecycle({ BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false, ); - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } + layerState._clickSubscription?.(); + layerState._clickSubscription = null; document.removeEventListener('keydown', parts.selection._onKeyDown); unregisterPickOwner('bikeshare'); } diff --git a/src/layers/bikeshare/selection.js b/src/layers/bikeshare/selection.js index ad5024908..f293dcaac 100644 --- a/src/layers/bikeshare/selection.js +++ b/src/layers/bikeshare/selection.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, @@ -155,15 +155,10 @@ export function createSelection({ */ function _installClickHandler(viewer) { - if (layerState._clickHandler) return; + if (layerState._clickSubscription) return; - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; - const picked = viewer.scene.pick(click.position); + layerState._clickSubscription = inputFor(viewer).onClick((event) => { + const picked = event.pick; if (picked) { // Clicking selected entity itself — ignore (don't deselect). @@ -190,7 +185,7 @@ export function createSelection({ // Clicked empty space — deselect. if (layerState._selectedKey) _clearSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); document.addEventListener('keydown', _onKeyDown); } diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 3617914bf..bc63d081f 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -71,9 +71,9 @@ export function createState({ services }) { layerState._stationRenderMap = new Map(); - /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */ + /** @type {(() => void)|null} Unsubscribes the station-selection click. */ - layerState._clickHandler = null; + layerState._clickSubscription = null; /** @type {string|null} Key of the currently selected station, or null. */ diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 5db0a9280..7545f70c1 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -2,6 +2,7 @@ import * as Cesium from 'cesium'; import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js'; import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js'; import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js'; +import { inputFor } from '../../input/dispatcher.js'; import { GROUND_PRIOR_INIT_WAIT_MS, CALIBRATION_RANGE_FLOOR_M, @@ -382,14 +383,12 @@ export function createLifecycle({ } parts.rendering.refreshHorizonCulling(); - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - layerState._viewer.scene.canvas, - ); - parts.selection.bindCctvWorldClickGesture( - layerState._clickHandler, - (click) => { + const input = inputFor(layerState._viewer); + const unsubscribeClick = parts.selection.bindCctvWorldClickGesture( + input, + (event) => { if (!layerState._enabled) return; - const picked = layerState._viewer.scene.pick(click.position); + const picked = event.pick; const cameraId = parts.selection.extractPickedCameraId(picked); if (cameraId) { activateCctvCameraFromWorldClick( @@ -410,8 +409,8 @@ export function createLifecycle({ // its camera exactly like a click on the icon. Cesium click positions // and the recorded rects are both CSS px — direct comparison. const cardId = layerState._cctvOverlayHost.hitTest( - click.position.x, - click.position.y, + event.screenPosition.x, + event.screenPosition.y, { sourceId: CCTV_OVERLAY_SOURCE_ID }, )?.entryId; if (cardId && layerState._recordById.has(cardId)) { @@ -430,14 +429,15 @@ export function createLifecycle({ parts.selection.deactivateActiveCamera(); } }, - { - // Item B: hover summons a card on a cardless camera icon. The gesture - // classifier owns MOUSE_MOVE too, so chain hover work through its seam - // instead of replacing the travel accumulator's handler. - onMouseMove: (movement) => - parts.hover.handleHoverMove(movement?.endPosition), - }, ); + // Item B: hover summons a card on a cardless camera icon. + const unsubscribeHover = input.onPointer('move', (movement) => + parts.hover.handleHoverMove(movement?.endPosition), + ); + layerState._clickSubscription = () => { + unsubscribeClick(); + unsubscribeHover(); + }; await parts.health.syncHealthState(true); // Health ranks crowded cells: thin again now that it has arrived (a @@ -584,13 +584,8 @@ export function createLifecycle({ } layerState._calibrationMode = false; releaseContinuousRender('cctv-adjust'); - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } - if (teardownViewer?.scene?.screenSpaceCameraController) { - teardownViewer.scene.screenSpaceCameraController.enableInputs = true; - } + layerState._clickSubscription?.(); + layerState._clickSubscription = null; parts.projection.stopProjectionLoop(); parts.geometryQueue.stopGeometryLoadQueue(); parts.cards.teardownAmbientCards(); diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index 8b36daa0b..52d36d3f9 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -1,7 +1,4 @@ -import { - bindTrackingClickGesture, - isTrackingClickGesture, -} from '../../data/trackingClickGesture.js'; +import { isTrackingClickGesture } from '../../data/trackingClickGesture.js'; import * as Cesium from 'cesium'; import { cctvPicks } from './pickState.js'; @@ -35,24 +32,19 @@ export function createSelection({ } /** - * Bind CCTV activation to clean taps while preserving the layer's hover-move - * callback on the shared Cesium handler. Drag-like and long-press gestures do + * Bind CCTV activation to clean taps. Drag-like and long-press gestures do * not reach camera activation or focus dispatch. - * @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler. - * @param {(click: Object) => void} onClick - Accepted CCTV click callback. - * @param {Object} [options] - Gesture test seams and optional onMouseMove hook. - * @returns {void} + * @param {import('../../input/dispatcher.js').InputDispatcher} input + * @param {(event: import('../../input/dispatcher.js').ClickEvent) => void} onClick + * Accepted CCTV click callback. + * @returns {() => void} Unsubscribe. */ - function bindCctvWorldClickGesture(handler, onClick, options = {}) { - bindTrackingClickGesture( - handler, - (click, gesture) => { - if (!isTrackingClickGesture(gesture)) return; - onClick(click); - }, - options, - ); + function bindCctvWorldClickGesture(input, onClick) { + return input.onClick((event) => { + if (!isTrackingClickGesture(event.gesture)) return; + onClick(event); + }); } /** diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index ab4dc6aed..0bddab569 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -50,7 +50,7 @@ export function createState({ services }) { layerState._lastViewContext = ''; - layerState._clickHandler = null; + layerState._clickSubscription = null; layerState._count = 0; diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 9138e8830..08bc2fcb5 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -1,7 +1,7 @@ import * as Cesium from 'cesium'; import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { registerPickOwner, unregisterPickOwner, @@ -82,7 +82,7 @@ export function createCyclonesLayer({ selectedId = null, selectionIntent = 'auto', navigationGeneration = 0, - clickHandler = null, + selectionOwner = null, removeClickCapture = null; let enabled = false, loading = false, @@ -106,14 +106,9 @@ export function createCyclonesLayer({ (typeof cesium.Cesium3DTileset === 'function' && picked.primitive instanceof cesium.Cesium3DTileset))); function installSelection() { - if ( - clickHandler || - !viewer?.scene?.canvas || - typeof cesium.ScreenSpaceEventHandler !== 'function' - ) - return; - const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - clickHandler = owner; + if (selectionOwner || !viewer?.scene?.canvas) return; + const owner = {}; + selectionOwner = owner; // Snapshot before sibling bubble listeners can rebuild the overlay hit // rectangles. A vessel selection does that synchronously in the same click. const canvas = viewer.scene.canvas; @@ -154,28 +149,24 @@ export function createCyclonesLayer({ canvas.removeEventListener?.(type, resetCapture, { capture: true }); capturedHit = null; }; - owner.setInputAction((click) => { + const unsubscribe = inputFor(viewer).onClick((event) => { const nativeHit = capturedHit; capturedHit = null; - // Ambient selection yields to draw tools and Director; it never claims - // the pointer, camera, or tracking state. - if ( - !enabled || - destroyed || - clickHandler !== owner || - !isPointerFree() || - !click?.position - ) + // Ambient selection yields to draw tools and Director (the dispatcher + // skips a held pointer); it never claims the pointer, camera, or + // tracking state. + const position = event.screenPosition; + if (!enabled || destroyed || selectionOwner !== owner || !position) return; // AIS cards paint above the globe on a pointer-events:none canvas. The // vessel handler resolves this same topmost hit before cyclone geometry. const captureMatches = nativeHit && - Math.abs(nativeHit.x - click.position.x) < 1 && - Math.abs(nativeHit.y - click.position.y) < 1; + Math.abs(nativeHit.x - position.x) < 1 && + Math.abs(nativeHit.y - position.y) < 1; const hit = captureMatches ? nativeHit - : overlayHit(click.position.x, click.position.y); + : overlayHit(position.x, position.y); if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return; // Storm cards and lead-hour labels paint on the same canvas; a click on // one selects its storm. An id from a superseded advisory changes nothing. @@ -184,18 +175,19 @@ export function createCyclonesLayer({ if (id) layer.setParams({ stormId: id }); return; } - const picked = viewer.scene.pick(click.position); + const picked = event.pick; const id = rendering?.pickStorm(picked); if (id) layer.setParams({ stormId: id }); else if (isSurfacePick(picked)) layer.setParams({ clear: true }); - }, cesium.ScreenSpaceEventType.LEFT_CLICK); + }); + owner.unsubscribe = unsubscribe; } function removeSelection() { - const owner = clickHandler; - clickHandler = null; + const owner = selectionOwner; + selectionOwner = null; removeClickCapture?.(); removeClickCapture = null; - if (owner && !owner.isDestroyed?.()) owner.destroy(); + owner?.unsubscribe?.(); } const layer = { id: 'weather-cyclones', @@ -520,7 +512,7 @@ export function createCyclonesLayer({ enabled, loading, requestPending: !!request, - selectionActive: clickHandler !== null, + selectionActive: selectionOwner !== null, selectedId, selectionIntent, timerActive: false, diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index aff654659..f87f25c63 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -7,6 +7,35 @@ import { isPointerFree, } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; +import { fakeInput } from '../../input/testDoubles.mjs'; + +/** Record each click subscription the layer makes, as the old handler fakes did. */ +function recordHandlers(viewer, handlers) { + const fake = fakeInput(viewer); + const subscribe = fake.input.onClick; + fake.input.onClick = (fn) => { + const handler = { + destroyed: false, + // A live subscription hears clicks through the dispatcher; a removed + // one is called directly, as a queued callback would be. + click: (click) => + handler.destroyed + ? fn({ + screenPosition: click.position, + get pick() { + return viewer.scene.pick(click.position); + }, + }) + : fake.click(click.position), + }; + handlers.push(handler); + const off = subscribe(fn); + return () => { + handler.destroyed = true; + off(); + }; + }; +} const time = '2026-09-16T03:00:00.000Z'; const storm = (id = 'ep152026') => ({ @@ -64,23 +93,6 @@ test('map selection yields to pointer owners and owns only its enabled handler', }; const cesium = { ScreenSpaceEventType: { LEFT_CLICK: 'left' }, - ScreenSpaceEventHandler: class { - constructor(canvas) { - assert.ok(canvas); - handlers.push(this); - this.destroyed = false; - } - setInputAction(callback, type) { - assert.equal(type, 'left'); - this.click = callback; - } - destroy() { - this.destroyed = true; - } - isDestroyed() { - return this.destroyed; - } - }, }; const layer = createCyclonesLayer({ feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, @@ -108,6 +120,7 @@ test('map selection yields to pointer owners and owns only its enabled handler', }, }, }; + recordHandlers(viewer, handlers); layer.init(viewer); layer.setRowControlsListener(() => notices++); assert.equal(handlers.length, 0, 'disabled module installs no handler'); @@ -270,15 +283,6 @@ test('a click on a storm card or lead-hour label selects that storm without pick feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, cesium: { ScreenSpaceEventType: { LEFT_CLICK: 'left' }, - ScreenSpaceEventHandler: class { - constructor() { - handlers.push(this); - } - setInputAction(callback) { - this.click = callback; - } - destroy() {} - }, }, overlayHost: 'host', createRendering: ({ overlayHost }) => { @@ -297,7 +301,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick }); const canvas = new EventTarget(); canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); - layer.init({ + const viewer = { scene: { canvas, pick() { @@ -305,7 +309,9 @@ test('a click on a storm card or lead-hour label selects that storm without pick return { content: {} }; }, }, - }); + }; + recordHandlers(viewer, handlers); + layer.init(viewer); assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer'); layer.enable(); await layer.update(); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 1dcb9e347..fbe774638 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -26,6 +26,7 @@ import { translateKnown, } from '../../i18n/index.js'; import { pointerOwnerName } from '../../data/pointerOwnerNames.js'; +import { inputFor } from '../../input/dispatcher.js'; import { formatQuantity, quantitySeparator, @@ -897,7 +898,7 @@ export function createDirectionsLayer({ services, source }) { let _routeEntity = null; let _stepPoints = null; let _selectedStep = null; - let _clickHandler = null; + let _clickSubscription = null; let _renderHeld = false; let _rowControlsListener = null; let _dataManager = null; @@ -1212,12 +1213,12 @@ export function createDirectionsLayer({ services, source }) { * Give the pointer back, but only once the click that consumed it has finished * being dispatched. * - * Every layer binds its own handler to the same canvas, and they all run - * inside ONE browser event. Releasing the claim the instant this layer places - * an endpoint hands the rest of that same click to every ambient handler - * registered after this one — placing A on top of a bikeshare station also - * deselected the station. A timer, not a microtask: a microtask checkpoint - * runs between two DOM listeners, which is exactly the gap this closes. + * The input dispatcher decides who hears a click before any subscriber runs, + * but DOM listeners on the canvas (evidence cards) still run later inside + * the SAME browser event. Releasing the claim the instant this layer places + * an endpoint would hand that same click to them. A timer, not a microtask: + * a microtask checkpoint runs between two DOM listeners, which is exactly the + * gap this closes. */ function releaseClaimAfterDispatch() { if (_pointerClaim === null || _releaseTimer) return; @@ -1243,8 +1244,8 @@ export function createDirectionsLayer({ services, source }) { * Place one endpoint at a picked ground point and reroute when both are set. * * The globe click handler is the only caller in the running app; it is - * exported because a ScreenSpaceEventHandler needs a real canvas, and the - * ownership timing this function decides is exactly what has to be tested. + * exported so the ownership timing this function decides can be tested on + * its own. * @param {'a'|'b'} which Which endpoint to place. * @param {{lat: number, lon: number}} point Ground point in degrees. * @returns {boolean} Whether the endpoint was placed. @@ -1521,25 +1522,28 @@ export function createDirectionsLayer({ services, source }) { } function _installClickHandler(viewer) { - if (_clickHandler) return; - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_enabled) return; - // Placement acts only while this layer actually HOLDS the pointer, and the - // maneuver-dot selection below is an ambient handler like any other: it - // yields the moment a tool (this one included) owns the click. - // The lease, not the owner name: a superseded instance of this layer must - // not act on a click its replacement owns. - if (_armed && services.input.isLeaseCurrent(_pointerClaim)) { - const point = pickGround(click.position); + if (_clickSubscription) return; + const input = inputFor(viewer); + // Placement acts only while this layer actually HOLDS the pointer. The + // lease, not the owner name: a superseded instance of this layer must not + // act on a click its replacement owns. + const offPlace = input.onPointer( + 'click', + (event) => { + if (!_enabled || !_armed) return; + const point = pickGround(event.position); if (!point) return; placeDirectionsEndpoint(_armed, point); - return; - } - if (!services.input.isPointerFree()) return; + }, + { lease: () => _pointerClaim }, + ); + // The maneuver-dot selection is an ambient handler like any other: it + // yields the moment a tool (this one included) owns the click. + const offSelect = input.onClick((event) => { + if (!_enabled) return; let picked = null; try { - picked = viewer.scene.pick(click.position); + picked = event.pick; } catch { picked = null; } @@ -1549,16 +1553,18 @@ export function createDirectionsLayer({ services, source }) { return; } if (_selectedStep !== null) _clearStepSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); + _clickSubscription = () => { + offPlace(); + offSelect(); + }; if (typeof document !== 'undefined') document.addEventListener('keydown', _onKeyDown); } function _removeClickHandler() { - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } + _clickSubscription?.(); + _clickSubscription = null; // Teardown can outlive the document (page unload, and every headless test). if (typeof document !== 'undefined') document.removeEventListener('keydown', _onKeyDown); @@ -1739,9 +1745,8 @@ export function createDirectionsLayer({ services, source }) { /** * Place one endpoint at a picked ground point. The globe click handler is - * the only caller in the running app; it is on the instance because a - * ScreenSpaceEventHandler needs a real canvas, and the ownership timing - * this decides is exactly what has to be tested. + * the only caller in the running app; it is on the instance so the + * ownership timing this decides can be tested on its own. */ placeEndpoint: placeDirectionsEndpoint, diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5f663cc97..0656abf16 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -186,7 +186,6 @@ export function createQueries({ }); components.cards.rebuildAmbientLabels(); components.selection.installClickHandler(); - return layerState._clickHandler; }, }; diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index fcba3ecbd..b89a3ae6e 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -2,8 +2,7 @@ import { FIRMS_OVERLAY_SOURCE_ID, fireDetectionKey, } from '../../data/firmsLabels.js'; -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { CONTEXT_TOP_N } from './policy.js'; import { t } from '../../i18n/index.js'; @@ -22,7 +21,7 @@ export function createSelection({ getContextStore, registerEntityContext, } = services.context; - const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config; + const { id, name, overlayHost } = config; /** * Install the LEFT_CLICK handler for fire selection (enable-time only, @@ -32,14 +31,10 @@ export function createSelection({ */ function installClickHandler() { - if (layerState._clickHandler || !layerState._viewer) return; - layerState._clickHandler = screenSpaceEventHandlerFactory( - layerState._viewer, - ); - layerState._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; - const picked = layerState._viewer.scene.pick(click.position); + if (layerState._clickSubscription || !layerState._viewer) return; + const input = inputFor(layerState._viewer); + layerState._clickSubscription = input.onClick((event) => { + const picked = event.pick; const fire = pickedFire(picked); if (fire) { selectAndFocusFire(fire); @@ -52,8 +47,8 @@ export function createSelection({ if (pickedId && isOwnedByOtherLayer(id, pickedId)) return; } const cardHit = overlayHost.hitTest?.( - click.position?.x, - click.position?.y, + event.screenPosition?.x, + event.screenPosition?.y, { sourceId: FIRMS_OVERLAY_SOURCE_ID, }, @@ -64,14 +59,12 @@ export function createSelection({ return; } clearFireSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } function removeClickHandler() { - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } + layerState._clickSubscription?.(); + layerState._clickSubscription = null; } /** Select one stable detection and request one UI-owned camera transfer. */ diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 6f13165a8..4b33afeff 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -67,7 +67,7 @@ export function createFirmsState({ services, config }) { layerState._contextIds = new Set(); - layerState._clickHandler = null; + layerState._clickSubscription = null; layerState._moveEndRemover = null; diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 225e8bf82..b30b153dc 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -199,10 +199,8 @@ export function createLifecycle({ parts.tracking._destroyTrail(); // Remove click handler + keydown listener while disabled to avoid // intercepting input when the layer is off - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } + flightState._clickSubscription?.(); + flightState._clickSubscription = null; if (flightState._trackedEntityChangedRemove) { flightState._trackedEntityChangedRemove(); flightState._trackedEntityChangedRemove = null; @@ -237,10 +235,8 @@ export function createLifecycle({ parts.tracking._clearTracking(); parts.tracking._destroyTrail(); parts.tracking._cancelPendingTrackingRestore(); - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } + flightState._clickSubscription?.(); + flightState._clickSubscription = null; if (flightState._trackedEntityChangedRemove) { flightState._trackedEntityChangedRemove(); flightState._trackedEntityChangedRemove = null; diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index b3ac6ab0d..7486cf34b 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -168,9 +168,9 @@ export function createFlightState({ source, services }) { flightState._trackedModelLoading = false; - /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */ + /** @type {(() => void)|null} Unsubscribes the scene click. */ - flightState._clickHandler = null; + flightState._clickSubscription = null; /** @type {Cesium.Viewer|null} Cached viewer reference */ diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index d1ad04150..721f5b2d3 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -12,10 +12,10 @@ import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { routePlausible } from '../../data/routePlausible.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { - bindTrackingClickGesture, isTrackingSelectionGesture, isTrackingClickGesture, } from '../../data/trackingClickGesture.js'; +import { inputFor } from '../../input/dispatcher.js'; import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, @@ -50,7 +50,7 @@ export function createTracking({ const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -1140,7 +1140,7 @@ export function createTracking({ */ function _installClickHandler(viewer) { - if (flightState._clickHandler) return; // already installed + if (flightState._clickSubscription) return; // already installed // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our // tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity @@ -1164,10 +1164,8 @@ export function createTracking({ }); } - flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + flightState._clickSubscription = inputFor(viewer).onClick((event) => { + const gesture = event.gesture; // Camera drags never select or deselect, even if they finish over a plane. // Duration alone is allowed through so a stationary long press can still // select/switch contacts; the destructive empty-space branch below applies @@ -1177,9 +1175,7 @@ export function createTracking({ // first-person reference. A globe click must not fall through to the // normal empty-space deselection path; cockpit has explicit exit controls. if (document.body.classList.contains('cockpit-mode')) return; - const picked = pickContact - ? pickContact(viewer.scene, click.position) - : viewer.scene.pick(click.position); + const picked = event.hit; if (picked) { // Clicking the tracked entity itself — ignore (don't deselect) diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 01d564d05..81229696a 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -17,6 +17,7 @@ import { import { createHorizonCull } from '../streetLevel/view.js'; import { readGhostMapsKml } from './kml.js'; import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; +import { inputFor as defaultInputFor } from '../../input/dispatcher.js'; export * from './catalog.js'; export * from './records.js'; export { readGhostMapsKml } from './kml.js'; @@ -132,9 +133,8 @@ function anchorOf(geometry) { export function createGhostMapsLayer({ source, overlayHost = null, - screenSpaceEventHandlerFactory = null, + inputFor = defaultInputFor, picking = null, - pointer = null, openExternal = null, storage = defaultStorage(), } = {}) { @@ -147,7 +147,7 @@ export function createGhostMapsLayer({ let _unsubscribe = null; let _stream = 'closed'; let _queue = Promise.resolve(); - let _clickHandler = null; + let _clickSubscription = null; let _rowListener = null; let _selectedKey = null; let _selectedCardId = null; @@ -172,8 +172,7 @@ export function createGhostMapsLayer({ }, onChange: render, }); - const canSelect = () => - overlayHost && screenSpaceEventHandlerFactory && picking; + const canSelect = () => overlayHost && inputFor && picking; function wanted(product) { return _selection ? _selection.has(product.id) : product.defaultOn; @@ -496,25 +495,23 @@ export function createGhostMapsLayer({ } function installClickHandler() { - if (!canSelect() || _clickHandler || !_viewer) return; + if (!canSelect() || _clickSubscription || !_viewer) return; picking.registerPickOwner?.( GHOSTMAPS_LAYER_ID, (pickedId) => typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), ); - _clickHandler = screenSpaceEventHandlerFactory(_viewer); - _clickHandler.setInputAction((click) => { - if (pointer && !pointer.isPointerFree()) return; + _clickSubscription = inputFor(_viewer).onClick((event) => { const cardHit = overlayHost.hitTest?.( - click.position?.x, - click.position?.y, + event.screenPosition?.x, + event.screenPosition?.y, { sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID }, ); if (cardHit && cardHit.entryId === _selectedCardId) { if (_selectedLink && openExternal) openExternal(_selectedLink); return; } - const picked = _viewer.scene.pick(click.position); + const picked = event.pick; const key = picked ? pickedKey(picked) : null; if (key) { _selectedKey = key; @@ -531,12 +528,12 @@ export function createGhostMapsLayer({ clearSelection(); render(); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } function removeClickHandler() { - _clickHandler?.destroy(); - _clickHandler = null; + _clickSubscription?.(); + _clickSubscription = null; picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); } diff --git a/src/layers/installations/lifecycle.js b/src/layers/installations/lifecycle.js index cded4ca2f..966359727 100644 --- a/src/layers/installations/lifecycle.js +++ b/src/layers/installations/lifecycle.js @@ -93,8 +93,8 @@ export function createLifecycle({ layerState.moveEndRemove?.(); layerState.changedRemove?.(); parts.selection.destroy?.(); - layerState.clickHandler?.destroy(); - layerState.clickHandler = null; + layerState.clickSubscription?.(); + layerState.clickSubscription = null; parts.namedMarkers?.destroy(); parts.rendering.clearRendered(); if (layerState.dataSource && viewer) diff --git a/src/layers/installations/selection.js b/src/layers/installations/selection.js index bcf623b78..79563c17e 100644 --- a/src/layers/installations/selection.js +++ b/src/layers/installations/selection.js @@ -1,5 +1,4 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { LAYER_ID } from './policy.js'; import { installationMarkers } from './markerState.js'; @@ -39,7 +38,7 @@ export function createSelection({ } function installInteraction(viewer) { - if (layerState.clickHandler) return; + if (layerState.clickSubscription) return; const events = [ 'gev:entity-selected', 'gev:entity-selection-cleared', @@ -51,14 +50,9 @@ export function createSelection({ for (const event of events) globalThis.window?.removeEventListener?.(event, syncContextSelection); }; - layerState.clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState.clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + layerState.clickSubscription = inputFor(viewer).onClick((event) => { if (!layerState.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = event.pick; const target = picked?.id; const id = target?.installationId || @@ -74,7 +68,7 @@ export function createSelection({ clearSelectedEntityContextForLayer(LAYER_ID); parts.rendering.renderRecords(); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } return { selectRecord, diff --git a/src/layers/installations/selection.test.mjs b/src/layers/installations/selection.test.mjs index b66f1d91b..7634e554d 100644 --- a/src/layers/installations/selection.test.mjs +++ b/src/layers/installations/selection.test.mjs @@ -88,7 +88,7 @@ function harness(t) { selection.installInteraction(state.viewer); t.after(() => { selection.destroy(); - state.clickHandler.destroy(); + state.clickSubscription(); parts.namedMarkers.destroy(); parts.rendering.clearRendered(); if (previousWindow === undefined) delete globalThis.window; diff --git a/src/layers/installations/state.js b/src/layers/installations/state.js index 99be04af0..033408152 100644 --- a/src/layers/installations/state.js +++ b/src/layers/installations/state.js @@ -29,7 +29,7 @@ export function createState({ services }) { retryAt: 0, failureReason: null, moveEndRemove: null, - clickHandler: null, + clickSubscription: null, timer: null, googleSearchRequested: false, /** Contacts subject position; when set, it replaces the camera viewport. */ diff --git a/src/layers/launches/ingestion.test.mjs b/src/layers/launches/ingestion.test.mjs index bc1a526d6..bee7d99ce 100644 --- a/src/layers/launches/ingestion.test.mjs +++ b/src/layers/launches/ingestion.test.mjs @@ -9,6 +9,7 @@ import { createIngestion } from './ingestion.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; import { layerFeedState } from '../../data/feedState.js'; +import { inputFor } from '../../input/dispatcher.js'; const noop = () => {}; @@ -297,17 +298,12 @@ test('destroy followed by actual init starts a clean instance with owned handler assert.equal(value.enabled, false); assert.equal(f.viewer.dataSources.length, 1); assert.equal(f.viewer.scene.preRender.numberOfListeners, 1); - const click = f.state._clickHandler; - const move = f.state._moveHandler; - assert.ok(click instanceof Cesium.ScreenSpaceEventHandler); - assert.ok(move instanceof Cesium.ScreenSpaceEventHandler); - assert.equal( - typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK), - 'function', - ); + const input = inputFor(f.viewer); + const before = input.subscriberCount(); + assert.equal(typeof f.state._inputSubscription, 'function'); await f.destroy(); - assert.equal(click.isDestroyed(), true); - assert.equal(move.isDestroyed(), true); + assert.equal(f.state._inputSubscription, null); + assert.equal(input.subscriberCount(), before - 2, 'click and hover'); assert.equal(f.viewer.dataSources.length, 0); assert.equal(f.viewer.scene.preRender.numberOfListeners, 0); } finally { diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index ca36a87ec..449be72ee 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; const HOVER_PICK_INTERVAL_MS = 120; @@ -135,15 +136,11 @@ export function createLifecycle({ layerState._lastError = null; layerState._orbitMatches = 0; parts.overlays.clearMissionOverlaySources(); - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((movement) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + const input = inputFor(viewer); + const unsubscribeClick = input.onClick((event) => { if (!layerState._enabled || !layerState._dataSource?.show) return; - const entity = viewer.scene - .drillPick(movement.position, 12) + const entity = event + .drill(12) .map((picked) => picked?.id) .find((candidate) => parts.selection.entityLaunchId(candidate)); const launchId = parts.selection.entityLaunchId(entity); @@ -152,19 +149,17 @@ export function createLifecycle({ parts.selection.focusMission( layerState._launches.find((launch) => launch.id === launchId), ); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); + }); hover = createLaunchHover({ viewer, enabled: () => layerState._enabled && layerState._dataSource?.show, entityLaunchId: parts.selection.entityLaunchId, }); - layerState._moveHandler.setInputAction( - hover.move, - Cesium.ScreenSpaceEventType.MOUSE_MOVE, - ); + const unsubscribeHover = input.onPointer('move', hover.move); + layerState._inputSubscription = () => { + unsubscribeClick(); + unsubscribeHover(); + }; // Apply horizon visibility before Cesium traverses and picks the scene. // A postRender write is one frame late and can remain visibly stale when // request-on-demand rendering stops after a globe camera move. @@ -246,10 +241,8 @@ export function createLifecycle({ parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); parts.ingestion.clearPostTleRetry(); - if (layerState._clickHandler) layerState._clickHandler.destroy(); - layerState._clickHandler = null; - if (layerState._moveHandler) layerState._moveHandler.destroy(); - layerState._moveHandler = null; + layerState._inputSubscription?.(); + layerState._inputSubscription = null; if (layerState._declutterHandler) layerState._declutterHandler(); layerState._declutterHandler = null; parts.launchPad.destroyLaunchPadZonePrimitive(); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index b2c4d266b..06a8c8be1 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -23,9 +23,7 @@ export function createState({ services }) { state._orbitMatches = 0; - state._clickHandler = null; - - state._moveHandler = null; + state._inputSubscription = null; state._viewer = null; diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 8fde6069d..7df67c1e6 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -6,7 +6,7 @@ import { screenProjectedRotation, stabilizeScreenRotation, } from '../../data/iconOrientation.js'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { routePlausible } from '../../data/routePlausible.js'; import { @@ -95,8 +95,6 @@ const DEFAULT_DISPLAY = Object.freeze({ * @param {(url: string) => string} [options.resolveAsset] Model URL resolver. * @param {(options: object) => Promise} [options.loadModel] Model * loader, injectable for tests. - * @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler] - * Click-handler factory, injectable for tests. * @returns {object} Data-layer module. */ export function createLocalAdsbLayer({ @@ -106,7 +104,6 @@ export function createLocalAdsbLayer({ now = Date.now, resolveAsset = (url) => url, loadModel, - createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), }) { if (typeof receiver?.getState !== 'function') throw new TypeError('Local ADS-B requires a receiver session'); @@ -135,7 +132,7 @@ export function createLocalAdsbLayer({ let unsubscribeFeeds = null; let tickTimer = null; let syncTimer = null; - let clickHandler = null; + let clickSubscription = null; let removePreRender = null; let renderHeld = false; let selectedId = null; @@ -599,7 +596,7 @@ export function createLocalAdsbLayer({ } /** - * What a click at `position` means for this layer: a local aircraft id, + * What a click means for this layer: a local aircraft id, * `'other'` when a sibling layer's contact is under the pointer, `'trail'` * for a trail line, or null for empty map. * @@ -607,15 +604,14 @@ export function createLocalAdsbLayer({ * photoreal tile, a ring) or a trail line drawn over or through a local * aircraft does not hide it: the click looks underneath for the aircraft. */ - function clickTarget(position) { - const scene = viewer.scene; - const top = pickedId(scene.pick(position)); + function clickTarget(event) { + const top = pickedId(event.pick); if (top && markers.has(top)) return top; const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX); if (ownedElsewhere(top) && !trail) return 'other'; let hits = []; try { - hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || []; + hits = event.drill(CLICK_DRILL_LIMIT); } catch { hits = []; } @@ -630,12 +626,10 @@ export function createLocalAdsbLayer({ } function installInteraction() { - if (clickHandler || !viewer?.scene?.canvas) return; - clickHandler = createInputHandler(viewer.scene.canvas); - clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree() || !enabled) return; - const target = clickTarget(click.position); + if (clickSubscription || !viewer?.scene?.canvas) return; + clickSubscription = inputFor(viewer).onClick((event) => { + if (!enabled) return; + const target = clickTarget(event); // Clicking a local aircraft always (re)selects it and republishes its // card, including the one already selected: its card may have been // displaced, and a click on a marker must never read as "deselect". @@ -649,12 +643,12 @@ export function createLocalAdsbLayer({ // leaving a sibling layer's new selection intact. clearSelection(); governorRequestRender('local-adsb-selection'); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } function releaseInteraction() { - clickHandler?.destroy(); - clickHandler = null; + clickSubscription?.(); + clickSubscription = null; } function installFrame() { diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 566433950..c14ce8c1a 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -13,6 +13,7 @@ import { localAdsbStatus, } from './index.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; +import { fakeInput } from '../../input/testDoubles.mjs'; function fakeReceiver(initial = {}) { const state = { @@ -199,9 +200,6 @@ async function enabledLayer(receiver, clock, feeds = null, options = {}) { services: { ...services, ...options.services }, now: () => clock.now, loadModel: options.loadModel, - ...(options.createInputHandler - ? { createInputHandler: options.createInputHandler } - : {}), }); layer.init(viewer); await layer.enable(); @@ -1070,20 +1068,13 @@ function clickHarness() { return scene.drillResult; }, }; - const handler = { actions: new Map() }; + const viewer = { ...base.viewer, scene }; + const input = fakeInput(viewer); return { scene, - viewer: { ...base, viewer: { ...base.viewer, scene } }, - createInputHandler: () => ({ - setInputAction(action, type) { - handler.actions.set(type, action); - }, - destroy() {}, - }), + viewer: { ...base, viewer }, click() { - handler.actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)({ - position: new Cesium.Cartesian2(10, 10), - }); + input.click(new Cesium.Cartesian2(10, 10)); }, }; } @@ -1094,7 +1085,6 @@ test('clicking the selected aircraft keeps it selected and republishes its card' const harness = clickHarness(); const { layer, calls } = await enabledLayer(receiver, clock, null, { viewer: harness.viewer, - createInputHandler: harness.createInputHandler, }); t.after(() => layer.destroy()); receiver.set({ aircraft: [record()] }); @@ -1119,7 +1109,6 @@ test('a click on a local aircraft under an unowned or trail pick still selects i const { resolvePickId } = await import('../../data/pickRegistry.js'); const { layer, calls } = await enabledLayer(receiver, clock, null, { viewer: harness.viewer, - createInputHandler: harness.createInputHandler, services: { picking: { registerPickOwner: (id, predicate) => owners.set(id, predicate), diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 31eabd2e4..de513502b 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -178,10 +178,8 @@ export function createLifecycle({ parts.tracking._destroyTrail(); // Remove click handler + keydown listener while disabled to avoid // intercepting input when the layer is off - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } + flightState._clickSubscription?.(); + flightState._clickSubscription = null; if (flightState._trackedEntityChangedRemove) { flightState._trackedEntityChangedRemove(); flightState._trackedEntityChangedRemove = null; @@ -216,10 +214,8 @@ export function createLifecycle({ parts.tracking._clearTracking(); parts.tracking._destroyTrail(); parts.tracking._cancelPendingTrackingRestore(); - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } + flightState._clickSubscription?.(); + flightState._clickSubscription = null; if (flightState._trackedEntityChangedRemove) { flightState._trackedEntityChangedRemove(); flightState._trackedEntityChangedRemove = null; diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 8b71c2299..e6c9d5c2a 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -140,9 +140,9 @@ export function createFlightState({ source, services }) { flightState._trackedModelLoading = false; - /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ + /** @type {(() => void)|null} Unsubscribes the aircraft-selection click. */ - flightState._clickHandler = null; + flightState._clickSubscription = null; /** @type {Cesium.Viewer|null} Cached viewer reference */ diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index b61b03f71..475ac410f 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -10,10 +10,10 @@ import { import { trailHeadStart } from '../../data/modelVisualAnchor.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { - bindTrackingClickGesture, isTrackingSelectionGesture, isTrackingClickGesture, } from '../../data/trackingClickGesture.js'; +import { inputFor } from '../../input/dispatcher.js'; import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, @@ -49,7 +49,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -1047,7 +1047,7 @@ export function createTracking({ */ function _installClickHandler(viewer) { - if (flightState._clickHandler) return; // already installed + if (flightState._clickSubscription) return; // already installed // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our // tracking without touching viewer.trackedEntity (the new owner controls it). @@ -1069,10 +1069,8 @@ export function createTracking({ }); } - flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { + flightState._clickSubscription = inputFor(viewer).onClick((event) => { + const gesture = event.gesture; // Camera drags never select or deselect, even if they finish over a plane. // Duration alone is allowed through so a stationary long press can still // select/switch contacts; the destructive empty-space branch below applies @@ -1082,9 +1080,7 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = pickContact - ? pickContact(viewer.scene, click.position) - : viewer.scene.pick(click.position); + const picked = event.hit; if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 83826a865..f8c1aee04 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -11,6 +11,7 @@ import { isCurrentPublication, } from './inciweb.js'; import { msg, t } from '../../i18n/index.js'; +import { inputFor as defaultInputFor } from '../../input/dispatcher.js'; export { normalizeFirePerimeterSnapshot } from './records.js'; export { createWfigsPerimeterSource } from './source.js'; export * from './cards.js'; @@ -39,9 +40,8 @@ const INCIWEB_INDEX_TTL_MS = 6 * 3600000; export function createFirePerimetersLayer({ source, overlayHost = null, - screenSpaceEventHandlerFactory = null, + inputFor = defaultInputFor, picking = null, - pointer = null, inciwebSource = null, inciwebPublications = null, openExternal = null, @@ -59,7 +59,7 @@ export function createFirePerimetersLayer({ let _stale = false; let _lastError = null; let _enabled = false; - let _clickHandler = null; + let _clickSubscription = null; let _selectedId = null; let _selectedLink = null; let _selectedCardId = null; @@ -76,8 +76,7 @@ export function createFirePerimetersLayer({ _linkRequest = null; } - const canSelect = () => - overlayHost && screenSpaceEventHandlerFactory && picking; + const canSelect = () => overlayHost && inputFor && picking; /** * Refresh the InciWeb catalog off the render path: a hanging or failed @@ -185,27 +184,25 @@ export function createFirePerimetersLayer({ } function installClickHandler() { - if (!canSelect() || _clickHandler || !_viewer) return; + if (!canSelect() || _clickSubscription || !_viewer) return; // Deliberately NOT registered in the pick-ownership registry: sibling // layers yield to owned picks before their own overlay-card hit-tests, // so claiming these large ground polygons would make FIRMS/vessel/CCTV // cards inert anywhere over a perimeter. Unowned, our picks read as // "empty space" to siblings — the pre-existing behavior for the globe. - _clickHandler = screenSpaceEventHandlerFactory(_viewer); - _clickHandler.setInputAction((click) => { - if (pointer && !pointer.isPointerFree()) return; + _clickSubscription = inputFor(_viewer).onClick((event) => { // A click on the incident card itself opens its InciWeb page (when the // incident has one) and never disturbs the selection. const cardHit = overlayHost.hitTest?.( - click.position?.x, - click.position?.y, + event.screenPosition?.x, + event.screenPosition?.y, { sourceId: PERIMETER_OVERLAY_SOURCE_ID }, ); if (cardHit && cardHit.entryId === _selectedCardId) { if (_selectedLink && openExternal) openExternal(_selectedLink); return; } - const picked = _viewer.scene.pick(click.position); + const picked = event.pick; const incidentId = picked ? pickedIncidentId(picked) : null; if (incidentId) { abortLinkVerification(); @@ -224,14 +221,12 @@ export function createFirePerimetersLayer({ _selectedId = null; publishSelectedCard(); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } function removeClickHandler() { - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } + _clickSubscription?.(); + _clickSubscription = null; } function clearSelection() { diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index bd5c3fc70..dc0a753cf 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createFirePerimetersLayer } from './index.js'; +import { fakeInput } from '../../input/testDoubles.mjs'; function harness( source, @@ -16,7 +17,6 @@ function harness( ) { const sources = []; const overlay = { entries: new Map(), visible: null }; - const clicks = { handler: null, destroyed: 0 }; const owners = new Map(); const opened = []; const viewer = { @@ -30,6 +30,13 @@ function harness( }, }, }; + const input = fakeInput(viewer); + const clicks = { + handler: (click) => input.click(click.position), + get destroyed() { + return input.input.subscriberCount() === 0 ? 1 : 0; + }, + }; const layer = createFirePerimetersLayer({ source, overlayHost: { @@ -44,15 +51,7 @@ function harness( }, hitTest: (x, y, options) => cardHit(x, y, options), }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction(callback) { - clicks.handler = callback; - }, - destroy() { - clicks.destroyed += 1; - clicks.handler = null; - }, - }), + inputFor: () => input.input, picking: { resolvePickId: (picked) => picked?.id ?? null, isOwnedByOtherLayer: (layerId, pickedId) => @@ -60,7 +59,6 @@ function harness( registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate), unregisterPickOwner: (layerId) => owners.delete(layerId), }, - pointer: { isPointerFree: () => true }, inciwebSource: inciwebIndex ? { getIndex: async () => inciwebIndex } : { getIndex: async () => [] }, @@ -331,7 +329,7 @@ test('a stale or unverifiable publication yields no link', async () => { } }); -test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => { +test('a hanging InciWeb index fetch never blocks the perimeter refresh', async (t) => { const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null }); // Replace the index source with one that never resolves. h.layer.destroy(h.viewer); @@ -345,6 +343,8 @@ test('a hanging InciWeb index fetch never blocks the perimeter refresh', async ( remove: (v) => sources.splice(sources.indexOf(v), 1), }, }; + const input = fakeInput(viewer); + t.after(() => input.destroy()); const layer = createFirePerimetersLayer({ source: { getSnapshot: async () => [row] }, overlayHost: { @@ -377,7 +377,7 @@ test('a hanging InciWeb index fetch never blocks the perimeter refresh', async ( layer.destroy(viewer); }); -test('an InciWeb outage never breaks the perimeter refresh', async () => { +test('an InciWeb outage never breaks the perimeter refresh', async (t) => { const h = harness({ getSnapshot: async () => [row] }); const failing = createFirePerimetersLayer({ source: { getSnapshot: async () => [row] }, @@ -409,6 +409,8 @@ test('an InciWeb outage never breaks the perimeter refresh', async () => { scene: { pick: () => null }, dataSources: { add() {}, remove() {} }, }; + const failingInput = fakeInput(viewer); + t.after(() => failingInput.destroy()); failing.init(viewer); failing.enable(viewer); assert.equal(await failing.update(viewer), true); diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js index 11a22b9dd..c11a1e94c 100644 --- a/src/layers/radio/interaction.js +++ b/src/layers/radio/interaction.js @@ -1,8 +1,7 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { RADIO_PREFIX, - RADIO_PICK_OFFSETS, + RADIO_PICK_WINDOW_PX, RADIO_OVERLAY_SOURCE_ID, HORIZON_TICK_MS, } from './policy.js'; @@ -46,9 +45,15 @@ export function createInteraction({ return null; } - function pickedRadioStationAt(position) { - const scene = layerState._viewer?.scene; - if (!scene || !position) return null; + /** + * The station under a click: the pick itself, then one drill through a + * window around it, which also catches a near miss. Both passes are the + * click's own, so other layers reading the same click share them. + * @param {import('../../input/dispatcher.js').ClickEvent} event + * @returns {string|null} + */ + function pickedRadioStationAt(event) { + if (!event) return null; const stationFromPick = (picked) => { const stationId = radioStationIdFromPick(picked); @@ -56,28 +61,17 @@ export function createInteraction({ ? stationId : null; }; - const primaryPick = scene.pick(position); + const primaryPick = event.pick; const primaryStationId = stationFromPick(primaryPick); if (primaryStationId) return primaryStationId; const primaryId = resolvePickId(primaryPick); if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null; - if (typeof scene.drillPick === 'function') { - const drilled = scene.drillPick(position, 16) || []; - for (const picked of drilled) { - const stationId = stationFromPick(picked); - if (stationId) return stationId; - } - } - - for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) { - const offsetPosition = new Cesium.Cartesian2( - position.x + offsetX, - position.y + offsetY, - ); - const picked = scene.pick(offsetPosition); - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue; + for (const picked of event.drill( + 16, + RADIO_PICK_WINDOW_PX, + RADIO_PICK_WINDOW_PX, + )) { const stationId = stationFromPick(picked); if (stationId) return stationId; } @@ -112,16 +106,12 @@ export function createInteraction({ } function installInteraction() { - if (!layerState._viewer || layerState._clickHandler) return; + if (!layerState._viewer || layerState._clickSubscription) return; registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX)); - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - layerState._viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + const viewer = layerState._viewer; + layerState._clickSubscription = inputFor(viewer).onClick((event) => { if (!radioPresentationAllowed()) return; - const stationId = pickedRadioStationAt(click.position); + const stationId = pickedRadioStationAt(event); if (!stationId) return; layerState._playFallbackId = null; layerState._playFallbackFocus = null; @@ -134,7 +124,7 @@ export function createInteraction({ new CustomEvent('gev:radio-selected', { detail: { stationId } }), ); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); // Polling remains bounded during flights, but a stationary camera no longer // rewrites every station's visibility four times per second while voice and // audio processing share the main thread. @@ -146,8 +136,8 @@ export function createInteraction({ function removeInteraction() { unregisterPickOwner('radio'); - layerState._clickHandler?.destroy(); - layerState._clickHandler = null; + layerState._clickSubscription?.(); + layerState._clickSubscription = null; if (layerState._horizonTimer) clearInterval(layerState._horizonTimer); layerState._horizonTimer = null; } diff --git a/src/layers/radio/interaction.test.mjs b/src/layers/radio/interaction.test.mjs new file mode 100644 index 000000000..cac4e5292 --- /dev/null +++ b/src/layers/radio/interaction.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createInteraction } from './interaction.js'; +import { RADIO_PICK_WINDOW_PX } from './policy.js'; + +function interaction() { + const state = { _stationById: new Map([['kexp', {}]]) }; + return createInteraction({ + state, + services: { + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (layer, id) => id === 'flight-1', + registerPickOwner() {}, + unregisterPickOwner() {}, + }, + overlays: { setOverlaySourceVisible() {} }, + }, + parts: {}, + }); +} + +/** A click event whose drill answers only with the window it is asked for. */ +function clickEvent({ pick, windowHits = [] }) { + const drills = []; + return { + drills, + pick, + drill(limit, width, height) { + drills.push([limit, width, height]); + return width === RADIO_PICK_WINDOW_PX && height === RADIO_PICK_WINDOW_PX + ? windowHits + : []; + }, + }; +} + +test('a near miss finds the station through one windowed drill', () => { + const { pickedRadioStationAt } = interaction(); + const event = clickEvent({ + pick: undefined, + windowHits: [{ id: 'tile' }, { id: 'radio:kexp' }], + }); + assert.equal(pickedRadioStationAt(event), 'kexp'); + assert.deepEqual(event.drills, [ + [16, RADIO_PICK_WINDOW_PX, RADIO_PICK_WINDOW_PX], + ]); +}); + +test('a direct hit needs no drill, and another layer’s contact keeps its click', () => { + const { pickedRadioStationAt } = interaction(); + const direct = clickEvent({ pick: { id: 'radio:kexp' } }); + assert.equal(pickedRadioStationAt(direct), 'kexp'); + assert.deepEqual(direct.drills, []); + const other = clickEvent({ + pick: { id: 'flight-1' }, + windowHits: [{ id: 'radio:kexp' }], + }); + assert.equal(pickedRadioStationAt(other), null); +}); diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index e0438c780..61949cc04 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -12,6 +12,9 @@ export const SELECTED_LIFT_M = 5; const RADIO_PICK_TOLERANCE_PX = 8; +/** A click this close to a station picks it: one drill window of this size. */ +export const RADIO_PICK_WINDOW_PX = RADIO_PICK_TOLERANCE_PX * 2 + 1; + export const RADIO_TUNER_DIRECTORY_LIMIT = 750; export const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT; @@ -51,17 +54,6 @@ export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({ moving: false, }); -export const RADIO_PICK_OFFSETS = Object.freeze([ - [0, -RADIO_PICK_TOLERANCE_PX], - [RADIO_PICK_TOLERANCE_PX, 0], - [0, RADIO_PICK_TOLERANCE_PX], - [-RADIO_PICK_TOLERANCE_PX, 0], - [6, -6], - [6, 6], - [-6, 6], - [-6, -6], -]); - export const DEFAULT_RADIO_FILTER = 'all'; export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index a420c165e..99241162d 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -122,7 +122,7 @@ export function createState({ services }) { layerState._playFallbackAttemptId = null; - layerState._clickHandler = null; + layerState._clickSubscription = null; layerState._horizonTimer = null; diff --git a/src/layers/recentImagery/testDoubles.mjs b/src/layers/recentImagery/testDoubles.mjs index 2b00e5b01..7d4b338be 100644 --- a/src/layers/recentImagery/testDoubles.mjs +++ b/src/layers/recentImagery/testDoubles.mjs @@ -3,6 +3,11 @@ * responses, and the Cesium/viewer/document fakes the box tool needs. */ import { thumbnailOrder } from './model.js'; +import { + FAKE_EVENT_TYPES as EVENT_TYPES, + FakeScreenSpaceEventHandler, + fakeInput, +} from '../../input/testDoubles.mjs'; export const BOX = Object.freeze({ west: -97.8, @@ -60,40 +65,11 @@ export function response({ }; } -const EVENT_TYPES = { - LEFT_DOWN: 'down', - MOUSE_MOVE: 'move', - LEFT_UP: 'up', - LEFT_CLICK: 'click', - LEFT_DOUBLE_CLICK: 'dblclick', -}; - /** * Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is * 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky. */ export function boxToolFakes() { - const handlers = []; - class ScreenSpaceEventHandler { - constructor(canvas) { - this.canvas = canvas; - this.actions = new Map(); - this.destroyed = false; - handlers.push(this); - } - setInputAction(fn, type) { - this.actions.set(type, fn); - } - getInputAction(type) { - return this.actions.get(type); - } - removeInputAction(type) { - this.actions.delete(type); - } - destroy() { - this.destroyed = true; - } - } class CustomDataSource { constructor(name) { this.name = name; @@ -107,8 +83,6 @@ export function boxToolFakes() { } const color = { withAlpha: (alpha) => ({ alpha }) }; const cesium = { - ScreenSpaceEventHandler, - ScreenSpaceEventType: EVENT_TYPES, CustomDataSource, CallbackProperty: class {}, PolylineDashMaterialProperty: class {}, @@ -118,7 +92,7 @@ export function boxToolFakes() { Cartesian3: { fromDegreesArray: (values) => values }, }; - const stock = new ScreenSpaceEventHandler('stock'); + const stock = new FakeScreenSpaceEventHandler(); const originalClick = () => 'select'; const originalDouble = () => 'track'; stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK); @@ -159,13 +133,13 @@ export function boxToolFakes() { }, }; + const input = fakeInput(viewer, { eventTypes: EVENT_TYPES }); + const pickWorld = (_viewer, nx, ny) => ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 }; - /** The live (non-stock) canvas handler, if the tool has one bound. */ - const handler = () => - handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null; - const fire = (type, event) => - handler()?.actions.get(EVENT_TYPES[type])?.(event); + /** Whether the tool has pointer input bound on the scene's dispatcher. */ + const bound = () => input.input.subscriberCount() > 0; + const fire = (type, event) => input.handler.fire(EVENT_TYPES[type], event); /** Dispatch a keydown to the tool's document listener. */ const key = (name) => { const event = { @@ -187,7 +161,8 @@ export function boxToolFakes() { viewer, documentRef, pickWorld, - handler, + input: input.input, + bound, fire, key, types: EVENT_TYPES, diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 9715d9166..40dc7f20c 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -1,5 +1,4 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { readEntityRecord } from '../../data/entityRecords.js'; export function createInteraction({ @@ -18,7 +17,7 @@ export function createInteraction({ } function _installClickHandler(viewer) { - if (layerState._clickHandler) return; // already installed + if (layerState._clickSubscription) return; // already installed // Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights, // military, …) grabs the follow-camera, drop our tracking so the orbit ring / @@ -44,14 +43,9 @@ export function createInteraction({ }); } - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + layerState._clickSubscription = inputFor(viewer).onClick((event) => { if (!layerState._enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = event.pick; if (picked) { // Clicking tracked entity itself — ignore @@ -82,7 +76,7 @@ export function createInteraction({ parts.tracking._cancelPendingTrackingRestore(); parts.tracking._clearTracking(false, { origin: 'user' }); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); document.addEventListener('keydown', _onKeyDown); } diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 642a6879f..9bba63b12 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -105,10 +105,8 @@ export function createLifecycle({ parts.tracking._clearTracking(); parts.labels._syncIssOverlay(); // Remove click handler + keydown listener + preRender propagation while disabled - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } + layerState._clickSubscription?.(); + layerState._clickSubscription = null; if (layerState._trackedEntityChangedRemove) { layerState._trackedEntityChangedRemove(); layerState._trackedEntityChangedRemove = null; @@ -127,10 +125,8 @@ export function createLifecycle({ layerState._enabled = false; parts.tracking._clearTracking(); parts.tracking._cancelPendingTrackingRestore(); - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } + layerState._clickSubscription?.(); + layerState._clickSubscription = null; if (layerState._trackedEntityChangedRemove) { layerState._trackedEntityChangedRemove(); layerState._trackedEntityChangedRemove = null; diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 50a127b54..c47857fe4 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -84,7 +84,7 @@ export function createState({ services }) { state._trackedEntity = null; - state._clickHandler = null; + state._clickSubscription = null; /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index c76c3d9f5..82233ccc6 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -1,33 +1,29 @@ import * as Cesium from 'cesium'; import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js'; +import { inputFor } from '../../input/dispatcher.js'; /** One click handler for every provider's lines and cones, and the marker. */ export function createSelection({ state, parts }) { - const { picking, input } = state.services; + const { picking } = state.services; /** The id carries a provider's prefix: that provider handles the pick. */ - function onClick(click) { + function onClick(event) { const viewer = state.viewer; if (!viewer || !state.enabled) return; - if (input?.isPointerFree && !input.isPointerFree()) return; - const picked = viewer.scene.pick( - click.position, - CLICK_PICK_WINDOW_PX, - CLICK_PICK_WINDOW_PX, - ); + const picked = event.pickWindow(CLICK_PICK_WINDOW_PX, CLICK_PICK_WINDOW_PX); const id = picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id; const route = parts.router.resolve(id); // Not ours: the ground handler decides whether it is the map itself. if (!route) { - parts.openAtGround?.(click.position, picked); + parts.openAtGround?.(event.position, picked); return; } // The position marker is the core's own: nothing to select. if (!route.instance) return; route.instance.handlePick(route.id, { - groundPoint: () => groundPointAt(viewer, click.position), + groundPoint: () => groundPointAt(viewer, event.position), }); } @@ -67,23 +63,17 @@ export function createSelection({ state, parts }) { } function install(viewer) { - if (state.clickHandler) return; - state.clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - state.clickHandler.setInputAction( - onClick, - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); + if (state.clickSubscription) return; + state.clickSubscription = inputFor(viewer).onClick(onClick); document.addEventListener('keydown', onKeyDown); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick); } function uninstall() { // Nothing to undo for a layer that was never enabled. - if (!state.clickHandler) return; - if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy(); - state.clickHandler = null; + if (!state.clickSubscription) return; + state.clickSubscription(); + state.clickSubscription = null; document.removeEventListener('keydown', onKeyDown); picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID); } diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index 9fb0362a1..5e034ea53 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createSelection } from './selection.js'; import { createPickRouter } from './pickRouter.js'; +import { fakeInput } from '../../input/testDoubles.mjs'; /** The selection installed on a stand-in viewer, with what its clicks reach. */ function harness({ selected = false, services = {}, providers = [] } = {}) { @@ -40,7 +41,7 @@ function harness({ selected = false, services = {}, providers = [] } = {}) { const state = { viewer, enabled: true, - clickHandler: null, + clickSubscription: null, services: { picking: null, input: null, ...services }, providers: new Map([ [ @@ -71,17 +72,16 @@ function harness({ selected = false, services = {}, providers = [] } = {}) { sequences.selected = false; }, }; + const input = fakeInput(viewer); const selection = createSelection({ state, parts }); selection.install(viewer); - const action = (type) => - state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]); return { state, sequences, opened, groundClicks, scene, - click: (x = 10) => action('LEFT_CLICK')({ position: { x, y: 10 } }), + click: (x = 10) => input.click({ x, y: 10 }), /** A keydown reaching the document from `target`; the event as handled. */ key(key, target, { defaultPrevented = false } = {}) { const event = { @@ -248,7 +248,10 @@ test('a click on a provider line hands it the ground under the click, measured o const point = handled[0].click.groundPoint(); assert.ok(Math.abs(point.lon + 83.0458) < 1e-9); assert.ok(Math.abs(point.lat - 42.3314) < 1e-9); - assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]); + assert.deepEqual( + groundPicks.map(({ x, y }) => ({ x, y })), + [{ x: 40, y: 10 }], + ); } finally { h.restore(); } diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index f9e5e4c88..268cc00b1 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -56,6 +56,6 @@ export function createState({ services }) { groundClickReady: false, marker: { collection: null, billboard: null, look: null }, - clickHandler: null, + clickSubscription: null, }; } diff --git a/src/layers/submarineCables/index.js b/src/layers/submarineCables/index.js index 5de4fe6e2..c786e272c 100644 --- a/src/layers/submarineCables/index.js +++ b/src/layers/submarineCables/index.js @@ -4,10 +4,11 @@ import { defaultSweepClock } from './policy.js'; import { createRendering } from './rendering.js'; import { createIngestion } from './ingestion.js'; import { createInteraction } from './interaction.js'; +import { inputFor as defaultInputFor } from '../../input/dispatcher.js'; export function createSubmarineCableLayer({ source, overlayHost, - screenSpaceEventHandlerFactory, + inputFor = defaultInputFor, mapStackEventTarget = null, sweepClock = defaultSweepClock, }) { @@ -21,7 +22,7 @@ export function createSubmarineCableLayer({ state, parts, source, - screenSpaceEventHandlerFactory, + inputFor, mapStackEventTarget, }; parts.rendering = createRendering(context); diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js index 19500db6d..2c749f10b 100644 --- a/src/layers/submarineCables/interaction.js +++ b/src/layers/submarineCables/interaction.js @@ -1,24 +1,19 @@ import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; import { cablePicks } from './pickState.js'; -export function createInteraction({ state, screenSpaceEventHandlerFactory }) { +export function createInteraction({ state, inputFor }) { function registerPickEntity(entity, info) { cablePicks.write(entity).info = info; } function beginInteraction(viewer) { - if (state._clickHandler) return; - state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); - state._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + if (state._clickSubscription) return; + state._clickSubscription = inputFor(viewer).onClick((event) => { if (!state._enabled) return; - const picked = viewer.scene.pick(click.position); - const record = resolvePickRecord(picked); + const record = resolvePickRecord(event.pick); if (!record?.reference) return; flyToReference(viewer, record.reference); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } function resolvePickRecord(picked) { diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js index 87da52ee7..561d3f485 100644 --- a/src/layers/submarineCables/lifecycle.js +++ b/src/layers/submarineCables/lifecycle.js @@ -101,10 +101,8 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) { // Pinned by the direct-destroy host test: removing this line leaves a // visible overlay source with orphan labels. state._overlayPublisher.hide(); - if (state._clickHandler) { - state._clickHandler.destroy(); - state._clickHandler = null; - } + state._clickSubscription?.(); + state._clickSubscription = null; if (state._preRenderRemover) { state._preRenderRemover(); state._preRenderRemover = null; diff --git a/src/layers/submarineCables/state.js b/src/layers/submarineCables/state.js index 885cd722f..4bd67d12f 100644 --- a/src/layers/submarineCables/state.js +++ b/src/layers/submarineCables/state.js @@ -51,7 +51,7 @@ export function createState({ sweepClock, overlayHost }) { state._surfaceRecords = []; - state._clickHandler = null; + state._clickSubscription = null; state._preRenderRemover = null; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..d5039f11f 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -42,6 +42,7 @@ import { releaseContinuousRender, uninstallRenderGovernor, } from '../../renderGovernor.js'; +import { fakeInput } from '../../input/testDoubles.mjs'; const BOSTON = { lat: 42.3601, lon: -71.0589 }; @@ -319,9 +320,11 @@ function harness( else globalThis.window = originalWindow; }); + const input = fakeInput(viewer); return { layer, viewer, + input, primitives, sprites, pickOwners, @@ -3169,11 +3172,7 @@ test('click handler selects a migrated scripted billboard and prepares a valid d const entry = app.vehicles()[0]; assert.equal(entry.markerCollection, app.state()._animatedMarkers); app.viewer.scene.pick = () => ({ primitive: entry.marker, id: entry.key }); - app - .state() - ._clickHandler.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK)({ - position: new Cesium.Cartesian2(500, 400), - }); + app.input.click(new Cesium.Cartesian2(500, 400)); assert.equal(app.state()._selectedKey, entry.key); const body = app.layer._transitPartsForTest().trails.diagnostics().body; assert.ok(body.geometryInstances.length > 0); @@ -3401,11 +3400,7 @@ test('preset clicks preserve both pick id paths through moving and stationary co assert.equal(entry.marker.id, entry.key); for (const pick of [{ id: entry.key }, { primitive: entry.marker }]) { app.viewer.scene.pick = () => pick; - app - .state() - ._clickHandler.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK)( - { position: new Cesium.Cartesian2(500, 400) }, - ); + app.input.click(new Cesium.Cartesian2(500, 400)); assert.equal(app.state()._selectedKey, entry.key); parts.selection.clearSelection(); } @@ -3449,19 +3444,20 @@ test('sensor clicks map displayed pixels into the pre-shader pick buffer', async clientHeight: 1080, }; const uniforms = { intensity: 1, pixelation: 1 }; + let preset = 'surveillance'; scene.postProcessStages = { - getStageByName: () => ({ enabled: true, uniforms }), + getStageByName: (name) => + name === `godsEyeView_${preset}` + ? { enabled: true, uniforms } + : undefined, }; - const click = app - .state() - ._clickHandler.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); - app.layer._transitPartsForTest().rendering.setStylePreset('surveillance'); - click({ position: new Cesium.Cartesian2(1440, 540) }); + const click = (position) => app.input.click(position); + click(new Cesium.Cartesian2(1440, 540)); assert.ok(Math.abs(actual.x - 1472) < 1e-6); assert.equal(actual.y, 540); - app.layer._transitPartsForTest().rendering.setStylePreset('thermal'); + preset = 'thermal'; uniforms.pixelation = 1.5; - click({ position: new Cesium.Cartesian2(100.9, 200.1) }); + click(new Cesium.Cartesian2(100.9, 200.1)); assert.ok(Math.abs(actual.x - 100.5) < 1e-6); assert.ok(Math.abs(actual.y - 201) < 1e-6); assert.equal(app.state()._selectedKey, entry.key); diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index fd066ca55..d4223c8d3 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -7,7 +7,7 @@ import { createTransitSelectedOverlayEntry, } from './policy.js'; import { getRegisteredTransitFeed } from '../../data/transitFeeds.js'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { t } from '../../i18n/index.js'; /** @@ -115,49 +115,12 @@ export function createSelection({ state, services, parts }) { } function installClickHandler(viewer) { - if (state._clickHandler) return; - state._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - state._clickHandler.setInputAction((click) => { - // Ambient selection never competes for a pointer a tool has claimed: - // placing a draw vertex on top of a bus must not also select the bus. - if (!isPointerFree()) return; - const scene = viewer.scene; - // Pick buffers precede post-processing. Map a sensor pixel back through - // the active shader's barrel distortion and quantization before picking. - const position = Cesium.Cartesian2.clone(click.position); - const style = state._stylePreset; - const stage = scene.postProcessStages?.getStageByName?.( - `godsEyeView_${style}`, - ); - if (stage?.enabled && (style === 'thermal' || style === 'surveillance')) { - const intensity = stage.uniforms.intensity; - const canvas = scene.canvas, - w = canvas.clientWidth, - h = canvas.clientHeight; - let u = position.x / w, - v = 1 - position.y / h; - if (style === 'surveillance') { - const x = u * 2 - 1, - y = v * 2 - 1, - r2 = x * x + y * y; - const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075; - u = (x * d + 1) / 2; - v = (y * d + 1) / 2; - } - const grid = 1 + (stage.uniforms.pixelation - 1) * intensity; - u += - ((Math.floor((u * canvas.width) / grid) * grid) / canvas.width - u) * - intensity; - v += - ((Math.floor((v * canvas.height) / grid) * grid) / canvas.height - - v) * - intensity; - position.x = u * w; - position.y = (1 - v) * h; - } - const picked = scene.pick(position); + if (state._clickSubscription) return; + // The dispatcher maps a sensor-style click back into the pick buffer + // (src/input/sensorPick.js) and skips it while a tool holds the pointer. + state._clickSubscription = inputFor(viewer).onClick((event) => { + const position = event.position; + const picked = event.pick; state._lastPickForTest = { id: typeof picked?.id === 'string' ? picked.id : null, primitiveId: @@ -188,15 +151,13 @@ export function createSelection({ state, services, parts }) { } } if (state._selectedKey) clearSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); document.addEventListener('keydown', onKeyDown); } function removeClickHandler() { - if (state._clickHandler) { - state._clickHandler.destroy(); - state._clickHandler = null; - } + state._clickSubscription?.(); + state._clickSubscription = null; document.removeEventListener('keydown', onKeyDown); } diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..a4379b77d 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -47,8 +47,8 @@ export function createState({ services }) { /** @type {(() => void)|null} */ state._preRenderRemove = null; state._renderHeld = false; - /** @type {Cesium.ScreenSpaceEventHandler|null} */ - state._clickHandler = null; + /** @type {(() => void)|null} Unsubscribes the vehicle-selection click. */ + state._clickSubscription = null; /** * The vehicles currently mid-glide. The per-frame pass walks THIS, not the * whole fleet, and the render hold is derived from it together with the diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index eafd05b35..4fb628a97 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -119,11 +119,10 @@ export function createLifecycle({ state.records.all = []; state.records.byMmsi = new Map(); state.records.unkeyed = []; - state.clickHandler = null; + state.clickSubscription = null; state.keyTarget = null; state.keydownHandler = null; state.trackedEntityRemover = null; - state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; components.rendering.cancelTrailingVisibilityPass(); diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 5111d8ed4..3200610c1 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,5 +1,4 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { inputFor } from '../../input/dispatcher.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; export function createSelection({ @@ -10,7 +9,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; + const { isOwnedByOtherLayer } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -20,27 +19,15 @@ export function createSelection({ const aisLiveVesselsLayer = layer; function installInteraction(viewer) { - if (state.clickHandler || !viewer) return; - const handler = state.interactionHandlerFactory - ? state.interactionHandlerFactory(viewer) - : new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindVesselInteraction( - viewer, - handler, - state.interactionKeyTarget || document, - ); + if (state.clickSubscription || !viewer) return; + bindVesselInteraction(viewer, state.interactionKeyTarget || document); } - function bindVesselInteraction(viewer, handler, keyTarget) { - state.clickHandler = handler; - handler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; + function bindVesselInteraction(viewer, keyTarget) { + state.clickSubscription = inputFor(viewer).onClick((event) => { if (!state.feed.enabled) return; - const picked = pickContact - ? pickContact(viewer.scene, click.position) - : viewer.scene.pick(click.position); - const pickedId = resolvePickId(picked); + const picked = event.hit; + const pickedId = event.id; let record = pickedId ? state.records.byMmsi.get(pickedId) : null; // A vessel icon's pick id is its row id: the MMSI, or an unkeyed row's. // Removing a row destroys its pick id, so an evicted vessel cannot be @@ -73,8 +60,8 @@ export function createSelection({ // actionable hit rectangles before treating the click as empty space. const cardHit = !record ? vesselState._vesselOverlayHost.hitTest?.( - click.position?.x, - click.position?.y, + event.screenPosition?.x, + event.screenPosition?.y, { ...(cycloneBackground ? {} @@ -106,7 +93,7 @@ export function createSelection({ }); if (transition.action === 'deselect') clearVesselInspection(); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); state.keyTarget = keyTarget; state.keydownHandler = onVesselKeyDown; keyTarget.addEventListener('keydown', state.keydownHandler); @@ -140,10 +127,8 @@ export function createSelection({ } function removeVesselInteraction() { - if (state.clickHandler) { - state.clickHandler.destroy(); - state.clickHandler = null; - } + state.clickSubscription?.(); + state.clickSubscription = null; if (state.keyTarget && state.keydownHandler) { state.keyTarget.removeEventListener('keydown', state.keydownHandler); } diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 02ce6e752..b7ea571f4 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -58,16 +58,15 @@ export function createVesselState({ source, services }) { /** The vessels' icon set and the primitive that draws it. */ icons: null, iconPrimitive: null, - clickHandler: null, + /** The click subscription's unsubscribe. */ + clickSubscription: null, /** Exact EventTarget currently holding the Escape listener. */ keyTarget: null, /** Exact callback registered on keyTarget. */ keydownHandler: null, /** Cesium trackedEntityChanged listener disposer. */ trackedEntityRemover: null, - /** Test-only factory used to exercise enable-time interaction installation. */ - interactionHandlerFactory: null, - /** Test-only key target paired with interactionHandlerFactory. */ + /** Test-only key target for enable-time interaction installation. */ interactionKeyTarget: null, preRenderRemover: null, /** Pending timer that renders one frame after a cadence-skipped pass. */ diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 9aa35d8ff..789e80cad 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -12,16 +12,16 @@ export function createTesting({ const aisLiveVesselsLayer = layer; /** - * Bind the production interaction callbacks to mockable viewer/handler - * surfaces. Test-only seam; behavior is shared with installInteraction(). + * Bind the production interaction callbacks to a mockable viewer. Clicks + * come through the viewer's input dispatcher (src/input/testDoubles.mjs). + * Test-only seam; behavior is shared with installInteraction(). * @param {Object} viewer - Viewer-like object with scene.pick(). - * @param {Object} handler - Handler-like object with setInputAction(). * @param {Object} keyTarget - EventTarget-like object with add/removeEventListener(). * @returns {void} */ - function _bindVesselInteractionForTest(viewer, handler, keyTarget) { - components.selection.bindVesselInteraction(viewer, handler, keyTarget); + function _bindVesselInteractionForTest(viewer, keyTarget) { + components.selection.bindVesselInteraction(viewer, keyTarget); } /** @@ -67,7 +67,6 @@ export function createTesting({ state.feed.firstConnectPhase = options.firstConnectPhase || 'idle'; state.feed.firstConnectStartedAt = options.firstConnectStartedAt ?? null; state.feed.firstConnectDeadline = options.firstConnectDeadline ?? null; - state.interactionHandlerFactory = options.interactionHandlerFactory || null; state.interactionKeyTarget = options.interactionKeyTarget || null; } diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js index 57cf6b436..f4d3883a5 100644 --- a/src/scenes/interactions.js +++ b/src/scenes/interactions.js @@ -1,6 +1,6 @@ -import * as Cesium from 'cesium'; import { nextTick } from 'vue'; import { isPointerFree } from '../data/inputOwnership.js'; +import { inputFor } from '../input/dispatcher.js'; import { createInteractionSession } from '../director/interactions/session.js'; import { t } from '../i18n/index.js'; import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js'; @@ -23,7 +23,7 @@ export function createSceneInteractions( }, ) { let panel = null, - handler, + unsubscribeClick, items = [], targets = new Map(); // Only the owner whose panel the store shows may change or remove it. @@ -55,8 +55,8 @@ export function createSceneInteractions( }); function clear() { session.clear(); - handler?.destroy(); - handler = null; + unsubscribeClick?.(); + unsubscribeClick = null; if (shown()) { store.connect(null); store.publish(null); @@ -117,16 +117,9 @@ export function createSceneInteractions( }, }); session.activate(items); - handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction((event) => { - if ( - panel?.owner !== owner || - !shown() || - !isPointerFree() || - !available() - ) - return; - const picked = viewer.scene.pick(event.position)?.id; + unsubscribeClick = inputFor(viewer).onClick((event) => { + if (panel?.owner !== owner || !shown() || !available()) return; + const picked = event.pick?.id; const matches = items.filter( (item) => targets.get( @@ -150,7 +143,7 @@ export function createSceneInteractions( ) ?.focus({ preventScroll: true }); }); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); }, }; } diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 50a397fc8..577cd2375 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -84,6 +84,8 @@ export class CockpitViewController { this.destroyed = false; this.disposed = false; this.active = false; + /** Gives camera input back; held from enter to exit. */ + this.releaseCameraInput = null; this.trackedEntity = null; this.trackedEntityWasShown = true; this.heading = null; diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 6a4c0d13a..21419b029 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { CockpitViewController } from './cockpitController.js'; import { withEntityRecord } from '../data/entityRecords.js'; +import { fakeInput } from '../input/testDoubles.mjs'; function environment() { const original = { document: globalThis.document, window: globalThis.window }; @@ -23,7 +24,7 @@ function environment() { const viewer = { scene: { preUpdate: event, - screenSpaceCameraController: { enableInputs: false }, + screenSpaceCameraController: { enableInputs: true }, }, trackedEntityChanged: event, entities: { contains: () => false }, @@ -62,7 +63,14 @@ test('disposing an active Cockpit releases the supplied render owner without ret const owner = new CockpitViewController(env.viewer, { services: { releaseContinuousRender: (reason) => released.push(reason) }, }); + // Entered: the cockpit holds camera input, as enter() takes it. + const { input } = fakeInput(env.viewer); owner.active = true; + owner.releaseCameraInput = input.suspendCamera('cockpit'); + assert.equal( + env.viewer.scene.screenSpaceCameraController.enableInputs, + false, + ); owner.dispose(); owner.dispose(); assert.deepEqual(released, ['cockpit']); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index f7f4a7e6e..fd375a698 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -6,6 +6,7 @@ import { resolveTrackedAircraftInfo, } from '../cockpitMath.js'; import { readEntityRecord } from '../data/entityRecords.js'; +import { inputFor } from '../input/dispatcher.js'; export function readAircraftInfo() { // In cockpit mode the controller takes the entity off `viewer.trackedEntity` @@ -171,7 +172,8 @@ export function enter() { // (perf wave 2) this.services.holdContinuousRender('cockpit'); this.viewer.trackedEntity = undefined; - this.viewer.scene.screenSpaceCameraController.enableInputs = false; + this.releaseCameraInput?.(); + this.releaseCameraInput = inputFor(this.viewer).suspendCamera('cockpit'); document.body.classList.add('cockpit-mode'); // Activation hides the entry and TR-3B chip; clearing the chip's signature // lets the exit-path syncEntry publish it again. @@ -238,7 +240,8 @@ export function exit({ restoreTracking = true } = {}) { this.routeView.visible = false; this.clearPredictiveRoute(); this.setVisionMode('optical'); - this.viewer.scene.screenSpaceCameraController.enableInputs = true; + this.releaseCameraInput?.(); + this.releaseCameraInput = null; if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; this.trackedEntityWasShown = true; diff --git a/src/ui/imageryBoxTool.js b/src/ui/imageryBoxTool.js index 5bd58a414..55d7c0311 100644 --- a/src/ui/imageryBoxTool.js +++ b/src/ui/imageryBoxTool.js @@ -2,8 +2,8 @@ * Box selection for the Recent Imagery layer: press on the world, drag, and * release to hand the layer a degrees box. Follows the draw tool's ownership * rules — the pointer is claimed for the session, the viewer's own click - * actions are borrowed and given back, and `destroy()` returns everything — - * and freezes the camera only while a drag is in progress. + * actions are suppressed and given back, and `destroy()` returns everything — + * and holds the camera only while a drag is in progress. */ import * as Cesium from 'cesium'; import { pickWorldFromScreen } from '../annotations/annotationResolver.js'; @@ -12,6 +12,7 @@ import { pointerOwner, releasePointer, } from '../data/inputOwnership.js'; +import { inputFor as defaultInputFor } from '../input/dispatcher.js'; import { validateBox } from '../layers/recentImagery/model.js'; import { msg } from '../i18n/index.js'; @@ -20,7 +21,7 @@ export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box'; /** * Wire the box tool. - * @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps + * @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, inputFor?: Function, pickWorld?: Function, documentRef?: object }} deps * `onEscape` runs before the tool cancels on Escape; returning `true` claims * the key (it is still consumed) and leaves the tool armed. * @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise } | null} @@ -32,6 +33,7 @@ export function initImageryBoxTool({ onActive, onEscape, cesium = Cesium, + inputFor = defaultInputFor, pickWorld = pickWorldFromScreen, documentRef = globalThis.document, } = {}) { @@ -42,11 +44,9 @@ export function initImageryBoxTool({ /** Drag corners in degrees; `anchor` is null while no drag is live. */ let anchor = null; let corner = null; - let handler = null; + let unbindScene = null; let lease = null; - let savedSingleClick = null; - let savedDoubleClick = null; - let savedCameraInputs = null; + let releaseCamera = null; const dragBox = () => anchor && { @@ -125,19 +125,13 @@ export function initImageryBoxTool({ const lat = Number(point?.lat); return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null; }; - const cameraController = () => viewer.scene.screenSpaceCameraController; function freezeCamera() { - const controller = cameraController(); - if (!controller || savedCameraInputs !== null) return; - savedCameraInputs = controller.enableInputs; - controller.enableInputs = false; + releaseCamera ??= inputFor(viewer).suspendCamera('recent-imagery-box'); } function thawCamera() { - const controller = cameraController(); - if (controller && savedCameraInputs !== null) - controller.enableInputs = savedCameraInputs; - savedCameraInputs = null; + releaseCamera?.(); + releaseCamera = null; } function endDrag() { @@ -190,45 +184,23 @@ export function initImageryBoxTool({ }; function bindScene() { - if (handler) return; - handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN); - handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE); - handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP); - const stock = viewer.screenSpaceEventHandler; - if (stock) { - savedSingleClick = - stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null; - savedDoubleClick = - stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || - null; - stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK); - stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); - } + if (unbindScene) return; + const input = inputFor(viewer); + const held = { lease: () => lease }; + const offs = [ + input.onPointer('down', onDown, held), + input.onPointer('move', onMove, held), + input.onPointer('up', onUp, held), + input.suppressStockClicks(), + ]; + unbindScene = () => offs.forEach((off) => off()); documentRef?.addEventListener?.('keydown', onKey, true); documentRef?.body?.classList?.add?.('gev-imagery-box'); } function releaseScene() { - if (handler) { - handler.destroy(); - handler = null; - } - const stock = viewer.screenSpaceEventHandler; - if (stock && savedSingleClick) { - stock.setInputAction( - savedSingleClick, - cesium.ScreenSpaceEventType.LEFT_CLICK, - ); - } - if (stock && savedDoubleClick) { - stock.setInputAction( - savedDoubleClick, - cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - ); - } - savedSingleClick = null; - savedDoubleClick = null; + unbindScene?.(); + unbindScene = null; documentRef?.removeEventListener?.('keydown', onKey, true); documentRef?.body?.classList?.remove?.('gev-imagery-box'); } diff --git a/src/ui/imageryBoxTool.test.mjs b/src/ui/imageryBoxTool.test.mjs index e84938ed7..f6a2b6cb9 100644 --- a/src/ui/imageryBoxTool.test.mjs +++ b/src/ui/imageryBoxTool.test.mjs @@ -74,7 +74,7 @@ test('a session claims the pointer, borrows the stock clicks, freezes the camera assert.equal(f.preview(), false); assert.equal(f.documentRef.classes.has('gev-imagery-box'), false); assert.equal(f.documentRef.listeners.length, 0); - assert.equal(f.handler(), null); + assert.equal(f.bound(), false); assert.deepEqual(f.actives, [true, false]); await f.tool.destroy(); }); @@ -133,7 +133,7 @@ test('a held pointer refuses the session and nothing is borrowed', async () => { ['pointer-busy', 'draw is using the pointer — close it first'], ]); assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick); - assert.equal(f.handler(), null); + assert.equal(f.bound(), false); releasePointer(lease); assert.equal(f.tool.start(), true); await f.tool.destroy(); diff --git a/src/voice/analystAccessors.test.mjs b/src/voice/analystAccessors.test.mjs index bd8ac611a..a7aed26ca 100644 --- a/src/voice/analystAccessors.test.mjs +++ b/src/voice/analystAccessors.test.mjs @@ -6,6 +6,7 @@ import { createControls as installationControls } from '../layers/installations/ import { createQueries as transitQueries } from '../layers/transit/queries.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { ANALYST_LAYERS } from '../data/analystEngine.js'; +import { fakeInput } from '../input/testDoubles.mjs'; /** Every declared analyst field must be a key the accessor actually emits. */ function assertDeclaredFields(layerId, record) { @@ -181,16 +182,7 @@ test('cyclones flatten the storm position and stay empty while off', async () => fetchedAt: Date.now(), }), }, - cesium: { - ScreenSpaceEventType: { LEFT_CLICK: 'left' }, - ScreenSpaceEventHandler: class { - setInputAction() {} - destroy() {} - isDestroyed() { - return false; - } - }, - }, + cesium: {}, createRendering: () => ({ setSnapshot: async () => true, setSelection() {}, @@ -202,10 +194,12 @@ test('cyclones flatten the storm position and stay empty while off', async () => }), hitTestOverlay: () => null, }); - layer.init({ + const viewer = { scene: { canvas: new EventTarget(), pick: () => null }, camera: {}, - }); + }; + const input = fakeInput(viewer); + layer.init(viewer); assert.deepEqual(layer.getAnalystRecords(), []); layer.enable(); await layer.update();