mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 20:34:02 +00:00
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.
This commit is contained in:
1 parent
6035de88e2
commit
b51f1bf7c5
97 files changed
+2506
-1307
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+129
-23
@@ -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"]
|
||||
},
|
||||
|
||||
+28
-47
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+6
-1
@@ -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 };
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
+522
-237
File diff suppressed because it is too large.
Load diff
+5
-25
@@ -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 } });
|
||||
|
||||
+21
-18
@@ -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);
|
||||
}
|
||||
|
||||
+140
-15
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<object, InputDispatcher>} 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<object>} 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<string, Array<{fn: Function, lease: object|null}>>} */
|
||||
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<string, {limit: number, hits: object[]}>} */
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createState({ services }) {
|
||||
|
||||
layerState._lastViewContext = '';
|
||||
|
||||
layerState._clickHandler = null;
|
||||
layerState._clickSubscription = null;
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -186,7 +186,6 @@ export function createQueries({
|
||||
});
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.installClickHandler();
|
||||
return layerState._clickHandler;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._contextIds = new Set();
|
||||
|
||||
layerState._clickHandler = null;
|
||||
layerState._clickSubscription = null;
|
||||
|
||||
layerState._moveEndRemover = null;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -23,9 +23,7 @@ export function createState({ services }) {
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
state._moveHandler = null;
|
||||
state._inputSubscription = null;
|
||||
|
||||
state._viewer = null;
|
||||
|
||||
|
||||
@@ -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<object>} [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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -122,7 +122,7 @@ export function createState({ services }) {
|
||||
|
||||
layerState._playFallbackAttemptId = null;
|
||||
|
||||
layerState._clickHandler = null;
|
||||
layerState._clickSubscription = null;
|
||||
|
||||
layerState._horizonTimer = null;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) */
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,6 @@ export function createState({ services }) {
|
||||
groundClickReady: false,
|
||||
|
||||
marker: { collection: null, billboard: null, look: null },
|
||||
clickHandler: null,
|
||||
clickSubscription: null,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createState({ sweepClock, overlayHost }) {
|
||||
|
||||
state._surfaceRecords = [];
|
||||
|
||||
state._clickHandler = null;
|
||||
state._clickSubscription = null;
|
||||
|
||||
state._preRenderRemover = null;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
-50
@@ -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<void> } | 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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user