From b51f1bf7c5f992e881ae0a239c6fd87261207340 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 4 Nov 2026 08:48:51 -0800 Subject: [PATCH] Take pointer input through one dispatcher per scene Every layer and tool bound its own ScreenSpaceEventHandler and picked the same click again: with the usual layers on, one click ran about 26 scene.pick and 2 drillPick passes (120-215 ms in NYC with 18 layers). Pointer input now comes in through src/input/dispatcher.js, one handler per scene. A click is picked lazily and at most once per kind of query, and every subscriber reads the same ClickEvent (pick, hit, id, ownerId, gesture, drill, ground). The same click now costs 3 picks and 2 drills, 17-70 ms. - Ambient onClick subscribers run only while no tool holds the pointer, checked once per event; tools subscribe with their lease. Moves and releases always reach ambient subscribers, so hover and drags end cleanly. - Camera input and the viewer's stock select/track clicks are ref-counted holds (suspendCamera, suppressStockClicks). The dispatcher owns the controller's enableInputs, so a Cesium flight that ends no longer turns input back on under a drag or the cockpit. - The draw tool, imagery box, CCTV gizmo, cockpit, directions and every selecting layer use it; bindTrackingClickGesture is gone (the gesture is on the event). A source scan forbids new handlers, enableInputs writes and stock-click removal outside the dispatcher. - The sensor-style pick correction transit had now applies to every layer. - Radio's 8-pick near-miss ring is one 17x17 windowed drill. - A missed pick passed to pickContact is no longer picked again, and a touch tap's contact search shares the click's drill. - A gizmo drag whose release Cesium never reported ends on the next press instead of leaving the camera held. The dispatcher is exported as ./input with the pointer-lease functions, and documented in docs/APPLICATION.md. --- docs/APPLICATION.md | 50 ++ docs/CURRENT-STATE.md | 14 +- package.json | 1 + scripts/format-scope.json | 4 + scripts/package-boundaries.json | 152 +++- src/annotations/drawTool.js | 75 +- src/annotations/drawToolBehaviour.test.mjs | 6 +- src/app/layers/firms.js | 4 - src/app/layers/ghostMaps.js | 5 - src/app/layers/perimeters.js | 5 - src/app/layers/streetLevel.js | 3 +- src/app/layers/submarineCables.js | 3 - src/app/scene.js | 7 +- src/cameraHandoff.test.mjs | 4 +- src/data/aisLiveVessels.test.mjs | 759 ++++++++++++------ src/data/cctv.test.mjs | 30 +- src/data/cctvGizmo.js | 39 +- src/data/cctvGizmo.test.mjs | 155 +++- src/data/firmsCards.test.mjs | 3 +- src/data/firmsHorizonCull.test.mjs | 3 +- src/data/firmsInteraction.test.mjs | 9 +- src/data/infrastructure.test.mjs | 16 +- src/data/inputOwnership.js | 6 +- src/data/inputOwnership.test.mjs | 69 +- src/data/localGeojson.analyst.test.mjs | 6 +- src/data/localGeojson.test.mjs | 9 +- src/data/localGeojsonCore.js | 43 +- src/data/localGeojsonLifecycle.test.mjs | 11 +- src/data/pickRegistry.js | 17 +- .../telegeographySubmarineCables.test.mjs | 10 +- src/data/trackingClickGesture.js | 84 +- src/data/trackingClickGesture.test.mjs | 41 +- src/input/dispatcher.js | 440 ++++++++++ src/input/dispatcher.test.mjs | 396 +++++++++ src/input/sensorPick.js | 52 ++ src/input/testDoubles.mjs | 76 ++ src/layers/alpr/index.js | 6 +- src/layers/alpr/presentation.js | 20 +- src/layers/bikeshare/lifecycle.js | 14 +- src/layers/bikeshare/selection.js | 15 +- src/layers/bikeshare/state.js | 4 +- src/layers/cctv/lifecycle.js | 41 +- src/layers/cctv/selection.js | 30 +- src/layers/cctv/state.js | 2 +- src/layers/cyclones/index.js | 50 +- src/layers/cyclones/index.test.mjs | 62 +- src/layers/directions/index.js | 69 +- src/layers/firms/queries.js | 1 - src/layers/firms/selection.js | 29 +- src/layers/firms/state.js | 2 +- src/layers/flights/lifecycle.js | 12 +- src/layers/flights/state.js | 4 +- src/layers/flights/tracking.js | 16 +- src/layers/ghostMaps/index.js | 27 +- src/layers/installations/lifecycle.js | 4 +- src/layers/installations/selection.js | 16 +- src/layers/installations/selection.test.mjs | 2 +- src/layers/installations/state.js | 2 +- src/layers/launches/ingestion.test.mjs | 16 +- src/layers/launches/lifecycle.js | 33 +- src/layers/launches/state.js | 4 +- src/layers/localAdsb/index.js | 32 +- src/layers/localAdsb/index.test.mjs | 21 +- src/layers/military/lifecycle.js | 12 +- src/layers/military/state.js | 4 +- src/layers/military/tracking.js | 16 +- src/layers/perimeters/index.js | 29 +- src/layers/perimeters/ownership.test.mjs | 28 +- src/layers/radio/interaction.js | 58 +- src/layers/radio/interaction.test.mjs | 60 ++ src/layers/radio/policy.js | 14 +- src/layers/radio/state.js | 2 +- src/layers/recentImagery/testDoubles.mjs | 51 +- src/layers/satellites/interaction.js | 16 +- src/layers/satellites/lifecycle.js | 12 +- src/layers/satellites/state.js | 2 +- src/layers/streetLevel/selection.js | 32 +- src/layers/streetLevel/selection.test.mjs | 13 +- src/layers/streetLevel/state.js | 2 +- src/layers/submarineCables/index.js | 5 +- src/layers/submarineCables/interaction.js | 15 +- src/layers/submarineCables/lifecycle.js | 6 +- src/layers/submarineCables/state.js | 2 +- src/layers/transit/lifecycle.test.mjs | 32 +- src/layers/transit/selection.js | 59 +- src/layers/transit/state.js | 4 +- src/layers/vessels/lifecycle.js | 3 +- src/layers/vessels/selection.js | 41 +- src/layers/vessels/state.js | 7 +- src/layers/vessels/testing.js | 11 +- src/scenes/interactions.js | 23 +- src/ui/cockpitController.js | 2 + src/ui/cockpitController.test.mjs | 10 +- src/ui/cockpitTrackingController.js | 7 +- src/ui/imageryBoxTool.js | 72 +- src/ui/imageryBoxTool.test.mjs | 4 +- src/voice/analystAccessors.test.mjs | 18 +- 97 files changed, 2506 insertions(+), 1307 deletions(-) create mode 100644 src/input/dispatcher.js create mode 100644 src/input/dispatcher.test.mjs create mode 100644 src/input/sensorPick.js create mode 100644 src/input/testDoubles.mjs create mode 100644 src/layers/radio/interaction.test.mjs 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();