Deliver the application's pointer input through the renderer's input port

The Cesium renderer now creates the input port and exposes it as
renderer.input. The input dispatcher becomes an adapter over the port:
one port listener per event type, fanned out to its subscribers with the
ownership check read once per event, keeping the legacy event shapes.

- Ctrl+wheel zoom is switched on through renderer.input.ctrlWheelZoom.
- A dispatcher made for a viewer with no port creates its own port with
  takeStock: false, which borrows the stock select and double-click
  follow only while they are suppressed.
- The stock actions see the press as it began: a click whose press
  started while select or follow was held off never runs them.
- A scene accepts one input port; cesiumInputOf(scene) returns it.
- Releasing one mouse button while another is held no longer gives the
  held button a click. Cesium still ends that press.
This commit is contained in:
Sameh Khamis committed 2026-11-05 19:22:43 -08:00
1 parent 1f2479d3ab
commit 24bc8fce7e
16 files changed
+427 -275

No files matched your search

+87 -13
View File
@@ -14,6 +14,9 @@
"src/data/scenePick.js", "src/data/scenePick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js", "src/sources/infrastructureData.js",
@@ -884,7 +887,10 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1143,7 +1149,10 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium", "vue", "pinia", "vue-i18n"] "external": ["cesium", "vue", "pinia", "vue-i18n"]
}, },
@@ -1282,7 +1291,10 @@
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium", "vue", "pinia", "vue-i18n"] "external": ["cesium", "vue", "pinia", "vue-i18n"]
}, },
@@ -1376,6 +1388,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/render/cesium/internals.js" "src/render/cesium/internals.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1448,7 +1463,10 @@
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1533,7 +1551,10 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1620,7 +1641,10 @@
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1665,7 +1689,10 @@
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
}, },
@@ -1710,7 +1737,10 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1768,7 +1798,10 @@
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
}, },
@@ -1916,6 +1949,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js" "src/queries/heights.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1986,7 +2022,10 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["hls.js", "cesium"] "external": ["hls.js", "cesium"]
}, },
@@ -2065,6 +2104,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js" "src/queries/heights.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -2110,6 +2152,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js" "src/queries/heights.js"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
@@ -2164,6 +2209,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/render/cesium/internals.js" "src/render/cesium/internals.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -2201,7 +2249,10 @@
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3363,6 +3414,8 @@
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/input/router.js",
"src/queries/heights.js", "src/queries/heights.js",
"src/render/cesium/internals.js", "src/render/cesium/internals.js",
"src/render/cesium/tileRequests.js" "src/render/cesium/tileRequests.js"
@@ -3992,6 +4045,9 @@
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js", "src/queries/heights.js",
"src/render/cesium/tileRequests.js", "src/render/cesium/tileRequests.js",
"src/render/cesium/internals.js" "src/render/cesium/internals.js"
@@ -4104,6 +4160,9 @@
"modules": [ "modules": [
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
@@ -4362,7 +4421,10 @@
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -4695,6 +4757,9 @@
"src/input/sensorPick.js", "src/input/sensorPick.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js", "src/queries/heights.js",
"src/render/cesium/internals.js" "src/render/cesium/internals.js"
], ],
@@ -4754,6 +4819,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js", "src/queries/heights.js",
"src/render/cesium/internals.js" "src/render/cesium/internals.js"
], ],
@@ -4822,7 +4890,10 @@
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js" "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -4924,6 +4995,9 @@
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/queries/heights.js" "src/queries/heights.js"
], ],
"external": ["cesium"] "external": ["cesium"]
+22 -65
View File
@@ -263,39 +263,16 @@ try {
}), }),
); );
// The viewer's OWN click actions, captured before the tool ever runs. Both // The viewer's own select and follow, which the input port runs: on before
// must be absent while drawing and back — as the SAME functions — after. // the tool runs, off while drawing, and on again after.
await page.evaluate(() => { const stockActions = async () => {
const Cesium = window.__CESIUM__; const state = await drawState();
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; return {
window.__qaStock = { clickOn: state.stockSingleClick,
click: doubleOn: state.stockDoubleClick,
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
}; };
}); };
const stockActions = () => const beforeDraw = await stockActions();
page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
const live = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
return {
clickPresent: Boolean(live.click),
doublePresent: Boolean(live.double),
clickIsOriginal: live.click === window.__qaStock.click,
doubleIsOriginal: live.double === window.__qaStock.double,
hadClick: Boolean(window.__qaStock.click),
hadDouble: Boolean(window.__qaStock.double),
};
});
// ── enable / disable cycles leave nothing behind ──────────────────────── // ── enable / disable cycles leave nothing behind ────────────────────────
const before = await drawState(); const before = await drawState();
@@ -318,24 +295,21 @@ try {
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`, `handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
); );
// ── the viewer's own click actions are borrowed, then given back ──────── // ── the viewer's own click actions are held off, then given back ───────
await page.click('#draw-toggle'); await page.click('#draw-toggle');
const duringDraw = await stockActions(); const duringDraw = await stockActions();
check( check(
"Cesium's own selecting click and tracking double-click are both borrowed while drawing", "Cesium's own selecting click and tracking double-click are both off while drawing",
duringDraw.clickPresent === false && duringDraw.doublePresent === false, duringDraw.clickOn === false && duringDraw.doubleOn === false,
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` + JSON.stringify({ beforeDraw, duringDraw }),
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
); );
await page.click('#draw-toggle'); await page.click('#draw-toggle');
const afterDraw = await stockActions(); const afterDraw = await stockActions();
check( check(
'and both are restored as the SAME functions, not replacements', 'and both are back after',
afterDraw.clickPresent === duringDraw.hadClick && afterDraw.clickOn === beforeDraw.clickOn &&
afterDraw.doublePresent === duringDraw.hadDouble && afterDraw.doubleOn === beforeDraw.doubleOn,
afterDraw.clickIsOriginal && JSON.stringify({ beforeDraw, afterDraw }),
afterDraw.doubleIsOriginal,
JSON.stringify(afterDraw),
); );
// ── a pin, finished with Enter ────────────────────────────────────────── // ── a pin, finished with Enter ──────────────────────────────────────────
@@ -664,19 +638,6 @@ try {
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0, document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 }, { timeout: 10_000 },
); );
// The reload built a new page context: re-capture the viewer's own click
// actions so the identity comparisons below still mean something.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
await page.evaluate((view) => { await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.(); viewer.camera.cancelFlight?.();
@@ -827,24 +788,20 @@ try {
const wasActive = tool.active; const wasActive = tool.active;
await tool.destroy(); await tool.destroy();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const Cesium = window.__CESIUM__;
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
const stock = viewer.screenSpaceEventHandler;
let previews = 0; let previews = 0;
for (let i = 0; i < viewer.dataSources.length; i += 1) for (let i = 0; i < viewer.dataSources.length; i += 1)
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1; if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
const diagnostics = tool.diagnostics();
return { return {
wasActive, wasActive,
diagnostics: tool.diagnostics(), diagnostics,
previews, previews,
dataSources: viewer.dataSources.length, dataSources: viewer.dataSources.length,
handleGone: window.__gevDrawTool === undefined, handleGone: window.__gevDrawTool === undefined,
clickRestored: // The viewer's own select and follow run again.
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) === clickRestored: diagnostics.stockSingleClick,
window.__qaStock.click, doubleRestored: diagnostics.stockDoubleClick,
doubleRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
window.__qaStock.double,
drawingClass: document.body.classList.contains('gev-drawing'), drawingClass: document.body.classList.contains('gev-drawing'),
}; };
}); });
+4 -15
View File
@@ -509,21 +509,10 @@ export function initDrawTool({
previewDataSources: countPreviewDataSources(viewer), previewDataSources: countPreviewDataSources(viewer),
previewEntities: destroyed ? 0 : dataSource.entities.values.length, previewEntities: destroyed ? 0 : dataSource.entities.values.length,
pointerOwner: pointerOwner(), pointerOwner: pointerOwner(),
// The viewer's OWN click actions. While a session is open both are // The viewer's own select and follow, which the input port runs: off
// borrowed (absent here); after it closes both are back. A harness can // while a session is open, back on after it closes.
// compare the restored functions by identity with what it captured stockSingleClick: inputFor(viewer).stockEnabled('select'),
// before, which is the only way to prove they were given back rather stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'),
// than replaced.
stockSingleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
),
),
stockDoubleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
),
),
}; };
}, },
/** Resolves once every deferred teardown step has run. */ /** Resolves once every deferred teardown step has run. */
+21 -16
View File
@@ -224,11 +224,13 @@ function fakeAnnotations() {
} }
/** Stand up a tool against fresh doubles, with globals restored afterwards. */ /** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) { function harness({ navigation = null, beforeInput = () => {} } = {}) {
resetPointerOwnership(); resetPointerOwnership();
const { byId, documentDouble } = fakeDom(); const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer(); const viewer = fakeViewer();
const input = fakeInput(viewer, { eventTypes: Cesium.ScreenSpaceEventType }); // The viewer's own actions are bound before its input port takes them.
beforeInput(viewer);
const input = fakeInput(viewer);
const annotations = fakeAnnotations(); const annotations = fakeAnnotations();
const previousDocument = globalThis.document; const previousDocument = globalThis.document;
const previousWindow = globalThis.window; const previousWindow = globalThis.window;
@@ -339,30 +341,33 @@ test('a destroyed instance cannot free the pointer its replacement holds', async
} }
}); });
test('both stock viewer click actions are borrowed for the session and given back', async () => { test('both stock viewer click actions are held off for the session and given back', async () => {
const h = harness(); const stockClick = () => 'viewer picks and selects';
const stockDouble = () => 'viewer tracks';
const h = harness({
beforeInput(viewer) {
viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
},
});
try { try {
const stockClick = () => 'viewer picks and selects'; assert.equal(h.input.stockEnabled('select'), true);
const stockDouble = () => 'viewer tracks';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
h.act('toggleDraw'); h.act('toggleDraw');
assert.equal( assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), h.input.stockEnabled('select'),
undefined, false,
"the viewer's own selecting click must not run while drawing", "the viewer's own selecting click must not run while drawing",
); );
assert.equal( assert.equal(h.input.stockEnabled('dblclickFollow'), false);
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
undefined,
);
h.act('toggleDraw'); h.act('toggleDraw');
assert.equal(h.input.stockEnabled('select'), true);
assert.equal(h.input.stockEnabled('dblclickFollow'), true);
h.input.destroy();
assert.equal( assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick, stockClick,
'the exact function is restored, not a replacement', 'the exact function goes back to the viewer, not a replacement',
); );
assert.equal( assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK), h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
+2 -2
View File
@@ -1,6 +1,5 @@
import { createApplicationOperations } from './operations.js'; import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js';
import { import {
cesiumRenderer, cesiumRenderer,
cesiumViewerOf, cesiumViewerOf,
@@ -87,7 +86,8 @@ export async function createApplicationScene({
// Deferred after the viewer, so it is released before the viewer goes. // Deferred after the viewer, so it is released before the viewer goes.
const input = inputFor(viewer); const input = inputFor(viewer);
defer(() => input.destroy()); defer(() => input.destroy());
defer(installTrackpadPinchZoom(viewer)); // Ctrl+wheel and trackpad pinches zoom the globe, not the page.
renderer.input.ctrlWheelZoom = true;
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+6 -1
View File
@@ -348,7 +348,12 @@ function cameraHarness(layer = alprCamerasLayer) {
type, type,
modifier, modifier,
) { ) {
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn; // The unmodified click: the input port binds one per modifier.
if (
type === Cesium.ScreenSpaceEventType.LEFT_CLICK &&
modifier === undefined
)
click = fn;
return setInput.call(this, fn, type, modifier); return setInput.call(this, fn, type, modifier);
}; };
try { try {
+3
View File
@@ -187,15 +187,18 @@ test('consumer build includes only infrastructure code and resolves assets under
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
'cameraInput.js', 'cameraInput.js',
'ctrlWheelZoom.js',
'dispatcher.js', 'dispatcher.js',
'heights.js', 'heights.js',
'infrastructure.js', 'infrastructure.js',
'infrastructureData.js', 'infrastructureData.js',
'infrastructureOverlayEntry.js', 'infrastructureOverlayEntry.js',
'input.js',
'inputOwnership.js', 'inputOwnership.js',
'localGeojsonCore.js', 'localGeojsonCore.js',
'localGeojsonLod.js', 'localGeojsonLod.js',
'pickRegistry.js', 'pickRegistry.js',
'router.js',
'scenePick.js', 'scenePick.js',
'sensorPick.js', 'sensorPick.js',
]); ]);
+2 -1
View File
@@ -345,7 +345,8 @@ async function runInstallationLoad({
const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) {
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) clickAction = action; // The unmodified click: the input port binds one per modifier.
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK && modifier === undefined) clickAction = action;
return originalSetInputAction.call(this, action, type, modifier); return originalSetInputAction.call(this, action, type, modifier);
}; };
try { militaryInstallationsLayer.init(viewer); } try { militaryInstallationsLayer.init(viewer); }
+95 -123
View File
@@ -1,8 +1,11 @@
/** /**
* @module input/dispatcher * @module input/dispatcher
* @description The one place pointer input over the globe comes in: a single * @description The one place the application's layers and tools take
* `ScreenSpaceEventHandler` per scene, a click picked once and handed to * pointer input over the globe: the scene's input port
* every subscriber, and the camera-input and stock-click holds tools take. * (src/render/cesium/input.js), a click picked once and handed to every
* subscriber, and the camera-input and stock-click holds tools take. Each
* layer family moves onto the renderer's own `input` port as it is ported;
* until then it subscribes here, with the events and shapes it always had.
* *
* Layers used to bind a handler each and pick the same click again, so one * 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 * click with the usual layers on cost about 25 `scene.pick` passes (each one
@@ -33,6 +36,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { isLeaseCurrent, isPointerFree } from '../data/inputOwnership.js'; import { isLeaseCurrent, isPointerFree } from '../data/inputOwnership.js';
import { acquireCameraInput } from '../render/cesium/cameraInput.js'; import { acquireCameraInput } from '../render/cesium/cameraInput.js';
import { cesiumInputOf, createCesiumInput } from '../render/cesium/input.js';
import { import {
findPickOwner, findPickOwner,
pickContact, pickContact,
@@ -50,12 +54,23 @@ export {
releasePointer, releasePointer,
} from '../data/inputOwnership.js'; } from '../data/inputOwnership.js';
/** The handler event types the scene's input port binds, for test doubles. */
export const HANDLER_EVENT_TYPES = Cesium.ScreenSpaceEventType;
/** A drill is run at least this deep and sliced, so callers asking for /** A drill is run at least this deep and sliced, so callers asking for
* different depths on one click share one pass. */ * different depths on one click share one pass. */
const DRILL_DEPTH = 16; const DRILL_DEPTH = 16;
const POINTER_TYPES = ['down', 'move', 'up', 'click', 'dblclick']; const POINTER_TYPES = ['down', 'move', 'up', 'click', 'dblclick'];
/**
* The events Cesium's unmodified left-button actions raised, which is all
* the dispatcher's subscribers have ever seen: the port also has the other
* buttons and the modified presses.
*/
const unmodified = ({ modifiers: m }) => !m.shift && !m.ctrl && !m.alt;
const unmodifiedLeft = (ev) => ev.button === 0 && unmodified(ev);
/** A tool that re-arms takes a new lease each time: it may pass a getter. */ /** A tool that re-arms takes a new lease each time: it may pass a getter. */
const leaseOf = (entry) => const leaseOf = (entry) =>
typeof entry.lease === 'function' ? entry.lease() : entry.lease; typeof entry.lease === 'function' ? entry.lease() : entry.lease;
@@ -106,6 +121,9 @@ const dispatchers = new WeakMap();
* off; returns this hold's release. * off; returns this hold's release.
* @property {() => () => void} suppressStockClicks Take the viewer's stock * @property {() => () => void} suppressStockClicks Take the viewer's stock
* select and double-click-track actions off; returns this hold's release. * select and double-click-track actions off; returns this hold's release.
* @property {(name: 'select'|'dblclickFollow') => boolean} stockEnabled
* Whether the viewer's stock select or follow runs now: it does unless a
* hold is out (or the viewer has none).
* @property {() => number} subscriberCount Live click and pointer * @property {() => number} subscriberCount Live click and pointer
* subscriptions, for leak checks. * subscriptions, for leak checks.
* @property {() => void} destroy * @property {() => void} destroy
@@ -114,8 +132,9 @@ const dispatchers = new WeakMap();
/** /**
* The scene's dispatcher, made on first use. * The scene's dispatcher, made on first use.
* @param {object} viewer A Cesium viewer (or a test double with `scene`). * @param {object} viewer A Cesium viewer (or a test double with `scene`).
* @param {{createHandler?: (canvas: object) => object, eventTypes?: object, now?: () => number}} [options] * @param {{createHandler?: (canvas: object) => object, now?: () => number, document?: object|null}} [options]
* Used only by the call that makes it. * For the input port it makes when the scene has none (tests pass a fake
* handler); used only by the call that makes it.
* @returns {InputDispatcher} * @returns {InputDispatcher}
*/ */
export function inputFor(viewer, options = {}) { export function inputFor(viewer, options = {}) {
@@ -128,16 +147,23 @@ export function inputFor(viewer, options = {}) {
return dispatcher; return dispatcher;
} }
function createDispatcher( function createDispatcher(viewer, { createHandler, now, document } = {}) {
viewer,
{
createHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
eventTypes = Cesium.ScreenSpaceEventType,
now = () => performance.now(),
},
) {
const scene = viewer.scene; const scene = viewer.scene;
const handler = createHandler(scene.canvas); // The renderer's port when the application made one; a viewer of the
// caller's own gets one for as long as this dispatcher lives.
const shared = cesiumInputOf(scene);
const cesiumInput =
shared ??
createCesiumInput(viewer, {
report: (error) => console.error('Pointer input failed', error),
// The viewer is its owner's: its stock actions stay on its handler.
takeStock: false,
...(createHandler ? { createHandler } : {}),
...(now ? { now } : {}),
...(document !== undefined ? { document } : {}),
});
const { port } = cesiumInput;
const cameraInput = acquireCameraInput(scene);
/** @type {Array<{fn: Function}>} */ /** @type {Array<{fn: Function}>} */
const clickSubscribers = []; const clickSubscribers = [];
/** @type {Record<string, Array<{fn: Function, lease: object|null}>>} */ /** @type {Record<string, Array<{fn: Function, lease: object|null}>>} */
@@ -145,42 +171,8 @@ function createDispatcher(
POINTER_TYPES.map((type) => [type, []]), POINTER_TYPES.map((type) => [type, []]),
); );
const stockHolds = new Set(); const stockHolds = new Set();
let stockSaved = null;
let destroyed = false; 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) => { const run = (list, free, arg) => {
for (const entry of [...list]) { for (const entry of [...list]) {
if (entry.lease ? !isLeaseCurrent(leaseOf(entry)) : !free) continue; if (entry.lease ? !isLeaseCurrent(leaseOf(entry)) : !free) continue;
@@ -192,43 +184,45 @@ function createDispatcher(
} }
}; };
handler.setInputAction((event) => { const cartesian = (p) => new Cesium.Cartesian2(p.x, p.y);
pressActive = true; // Each type is one port listener; the pointer's owner is read once per
pressStartedAt = now(); // event, before any subscriber runs.
previousPosition = null; const offPort = [
travelPx = 0; port.on('down', (ev) => {
completedGesture = null; if (!unmodifiedLeft(ev)) return;
appendTravel(event?.position); run(pointerSubscribers.down, isPointerFree(), {
run(pointerSubscribers.down, isPointerFree(), event); position: cartesian(ev.position),
}, eventTypes.LEFT_DOWN); });
}),
handler.setInputAction((event) => { port.on('move', (ev) => {
appendTravel(event?.endPosition ?? event?.position); if (!unmodified(ev)) return;
run(pointerSubscribers.move, true, event); run(pointerSubscribers.move, true, {
}, eventTypes.MOUSE_MOVE); startPosition: cartesian(ev.position),
endPosition: cartesian(ev.endPosition ?? ev.position),
handler.setInputAction((event) => { });
finishPress(event?.position); }),
run(pointerSubscribers.up, true, event); port.on('up', (ev) => {
}, eventTypes.LEFT_UP); if (!unmodifiedLeft(ev)) return;
run(pointerSubscribers.up, true, { position: cartesian(ev.position) });
handler.setInputAction((click) => { }),
if (pressActive) finishPress(click?.position); port.on('click', (ev) => {
const gesture = completedGesture || { travelPx: 0, durationMs: 0 }; if (!unmodifiedLeft(ev)) return;
completedGesture = null; const free = isPointerFree();
const free = isPointerFree(); const event = createClickEvent(
const event = createClickEvent(viewer, click, gesture); viewer,
run(pointerSubscribers.click, free, event); { position: cartesian(ev.position) },
if (free) run(clickSubscribers, true, event); ev.gesture ?? { travelPx: 0, durationMs: 0 },
}, eventTypes.LEFT_CLICK); );
run(pointerSubscribers.click, free, event);
handler.setInputAction((event) => { if (free) run(clickSubscribers, true, event);
run(pointerSubscribers.dblclick, isPointerFree(), event); }),
}, eventTypes.LEFT_DOUBLE_CLICK); port.on('dblclick', (ev) => {
if (!unmodifiedLeft(ev)) return;
// Once the handler is bound, so a setup that throws holds no share of the run(pointerSubscribers.dblclick, isPointerFree(), {
// scene's camera input. position: cartesian(ev.position),
const cameraInput = acquireCameraInput(scene); });
}),
];
const subscribe = (list, entry) => { const subscribe = (list, entry) => {
if (destroyed) return () => {}; if (destroyed) return () => {};
@@ -239,22 +233,6 @@ function createDispatcher(
}; };
}; };
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 { return {
onClick(fn) { onClick(fn) {
return subscribe(clickSubscribers, { fn, lease: null }); return subscribe(clickSubscribers, { fn, lease: null });
@@ -273,26 +251,22 @@ function createDispatcher(
suppressStockClicks() { suppressStockClicks() {
if (destroyed) return () => {}; if (destroyed) return () => {};
const hold = {}; // Cesium's own select and double-click-track never see the pointer
if (stockHolds.size === 0) { // lease; the port runs them, and holds them off here.
const stock = stockHandler(); const releases = [
if (stock) { port.behaviours.select.suppress(),
// Cesium's own select and double-click-track run on the viewer's port.behaviours.dblclickFollow.suppress(),
// handler and never see the pointer lease. ];
stockSaved = stockTypes().map((type) => [ const hold = () => {
type, if (!stockHolds.delete(hold)) return;
stock.getInputAction(type), for (const release of releases) release();
]);
for (const type of stockTypes()) stock.removeInputAction(type);
}
}
stockHolds.add(hold);
return () => {
if (!stockHolds.delete(hold) || stockHolds.size > 0) return;
restoreStock();
}; };
stockHolds.add(hold);
return hold;
}, },
stockEnabled: (name) => port.behaviours[name]?.enabled ?? false,
subscriberCount() { subscriberCount() {
return POINTER_TYPES.reduce( return POINTER_TYPES.reduce(
(sum, type) => sum + pointerSubscribers[type].length, (sum, type) => sum + pointerSubscribers[type].length,
@@ -306,12 +280,10 @@ function createDispatcher(
dispatchers.delete(scene); dispatchers.delete(scene);
clickSubscribers.length = 0; clickSubscribers.length = 0;
for (const type of POINTER_TYPES) pointerSubscribers[type].length = 0; for (const type of POINTER_TYPES) pointerSubscribers[type].length = 0;
for (const off of offPort) off();
for (const hold of [...stockHolds]) hold();
cameraInput.release(); cameraInput.release();
if (stockHolds.size) { if (!shared) cesiumInput.dispose();
stockHolds.clear();
restoreStock();
}
if (!handler.isDestroyed?.()) handler.destroy?.();
}, },
}; };
} }
+20 -20
View File
@@ -3,30 +3,30 @@
* `ScreenSpaceEventHandler`, and a dispatcher made on it ahead of the code * `ScreenSpaceEventHandler`, and a dispatcher made on it ahead of the code
* under test, so that code's `inputFor(viewer)` gets the fake. * under test, so that code's `inputFor(viewer)` gets the fake.
*/ */
import { inputFor } from './dispatcher.js'; import { HANDLER_EVENT_TYPES, inputFor } from './dispatcher.js';
export const FAKE_EVENT_TYPES = Object.freeze({ /** Cesium's own event types, the ones the input port binds. */
LEFT_DOWN: 'down', export const FAKE_EVENT_TYPES = HANDLER_EVENT_TYPES;
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
LEFT_DOUBLE_CLICK: 'dblclick',
});
export class FakeScreenSpaceEventHandler { export class FakeScreenSpaceEventHandler {
constructor() { constructor() {
this.actions = new Map(); this.actions = new Map();
this.destroyed = false; this.destroyed = false;
} }
setInputAction(fn, type) { /** One action per type and modifier, as Cesium keeps them. */
this.actions.set(type, fn); static key(type, modifier) {
return modifier === undefined ? type : `${type}+${modifier}`;
} }
getInputAction(type) { setInputAction(fn, type, modifier) {
return this.actions.get(type); this.actions.set(FakeScreenSpaceEventHandler.key(type, modifier), fn);
} }
removeInputAction(type) { getInputAction(type, modifier) {
this.actions.delete(type); return this.actions.get(FakeScreenSpaceEventHandler.key(type, modifier));
} }
removeInputAction(type, modifier) {
this.actions.delete(FakeScreenSpaceEventHandler.key(type, modifier));
}
/** Raise an action as Cesium does for a press with no modifier held. */
fire(type, event) { fire(type, event) {
this.actions.get(type)?.(event); this.actions.get(type)?.(event);
} }
@@ -41,18 +41,16 @@ export class FakeScreenSpaceEventHandler {
/** /**
* Make `viewer`'s dispatcher on a fake handler and return ways to drive it. * Make `viewer`'s dispatcher on a fake handler and return ways to drive it.
* @param {object} viewer * @param {object} viewer
* @param {{now?: () => number, eventTypes?: object}} [options] `eventTypes` * @param {{now?: () => number}} [options]
* 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 } = {}) { export function fakeInput(viewer, { now } = {}) {
const handler = new FakeScreenSpaceEventHandler(); const handler = new FakeScreenSpaceEventHandler();
const input = inputFor(viewer, { const input = inputFor(viewer, {
createHandler: () => handler, createHandler: () => handler,
eventTypes, document: null,
...(now ? { now } : {}), ...(now ? { now } : {}),
}); });
const T = eventTypes; const T = FAKE_EVENT_TYPES;
return { return {
input, input,
handler, handler,
@@ -71,6 +69,8 @@ export function fakeInput(viewer, { now, eventTypes = FAKE_EVENT_TYPES } = {}) {
}, },
dblclick: (position = { x: 10, y: 10 }) => dblclick: (position = { x: 10, y: 10 }) =>
handler.fire(T.LEFT_DOUBLE_CLICK, { position }), handler.fire(T.LEFT_DOUBLE_CLICK, { position }),
/** Whether the viewer's stock `select` or `dblclickFollow` runs now. */
stockEnabled: (name) => input.stockEnabled(name),
destroy: () => input.destroy(), destroy: () => input.destroy(),
}; };
} }
+2 -1
View File
@@ -133,7 +133,7 @@ export function boxToolFakes() {
}, },
}; };
const input = fakeInput(viewer, { eventTypes: EVENT_TYPES }); const input = fakeInput(viewer);
const pickWorld = (_viewer, nx, ny) => const pickWorld = (_viewer, nx, ny) =>
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 }; ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
@@ -162,6 +162,7 @@ export function boxToolFakes() {
documentRef, documentRef,
pickWorld, pickWorld,
input: input.input, input: input.input,
stockEnabled: input.stockEnabled,
bound, bound,
fire, fire,
key, key,
+11 -1
View File
@@ -18,13 +18,15 @@
*/ */
import { createApplicationViewer } from '../../app/viewer.js'; import { createApplicationViewer } from '../../app/viewer.js';
import { CONTRACT_VERSION } from '../contract/index.js'; import { CONTRACT_VERSION } from '../contract/index.js';
import { createCesiumInput } from './input.js';
import { cesiumCapabilities, createCesiumPorts } from './ports.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js';
/** /**
* The members a transitional renderer has today. * The members a transitional renderer has today.
* @typedef {Pick<import('../contract/renderer').Renderer, * @typedef {Pick<import('../contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'frame' | 'draw' * 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'frame' | 'draw'
* | 'camera' | 'query' | 'capture' | 'on' | 'resize' | 'destroy'>} * | 'camera' | 'query' | 'input' | 'capture' | 'on' | 'resize'
* | 'destroy'>}
* TransitionalRenderer * TransitionalRenderer
*/ */
@@ -83,12 +85,18 @@ export function createCesiumRendererPlugin({
document.body.appendChild(creditContainer); document.body.appendChild(creditContainer);
let viewer; let viewer;
let ports; let ports;
let input;
let capabilities; let capabilities;
try { try {
viewer = createViewer({ container, creditContainer }); viewer = createViewer({ container, creditContainer });
capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene)); capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene));
ports = createCesiumPorts(viewer, { capabilities }); ports = createCesiumPorts(viewer, { capabilities });
input = createCesiumInput(viewer, {
query: ports.query,
report: ports.report,
});
} catch (error) { } catch (error) {
ports?.dispose();
if (viewer && !viewer.isDestroyed()) viewer.destroy(); if (viewer && !viewer.isDestroyed()) viewer.destroy();
creditContainer.remove(); creditContainer.remove();
throw error; throw error;
@@ -102,6 +110,7 @@ export function createCesiumRendererPlugin({
draw: ports.draw, draw: ports.draw,
camera: ports.camera, camera: ports.camera,
query: ports.query, query: ports.query,
input: input.port,
capture: ports.capture, capture: ports.capture,
on: ports.on, on: ports.on,
resize() { resize() {
@@ -109,6 +118,7 @@ export function createCesiumRendererPlugin({
}, },
destroy() { destroy() {
try { try {
input.dispose();
ports.dispose(); ports.dispose();
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
} finally { } finally {
+9 -2
View File
@@ -27,6 +27,9 @@ function fakeDocument() {
createElement: make, createElement: make,
body: { appendChild: (el) => children.push(el) }, body: { appendChild: (el) => children.push(el) },
getElementById: () => null, getElementById: () => null,
// The input port watches the document's pointer events.
addEventListener() {},
removeEventListener() {},
}; };
} }
@@ -53,7 +56,12 @@ function fakeViewer(log, { failDestroy = false } = {}) {
preRender: fakeEvent(listeners), preRender: fakeEvent(listeners),
postRender: fakeEvent(listeners), postRender: fakeEvent(listeners),
}, },
canvas: { tag: 'canvas' }, canvas: {
tag: 'canvas',
// Cesium's input handler binds the canvas's events.
addEventListener() {},
removeEventListener() {},
},
resize: () => log.push('resize'), resize: () => log.push('resize'),
isDestroyed: () => destroyed, isDestroyed: () => destroyed,
destroy() { destroy() {
@@ -346,7 +354,6 @@ const EXPECTED_MISSING = [
'renderer.credits', 'renderer.credits',
'renderer.effects', 'renderer.effects',
'renderer.globe', 'renderer.globe',
'renderer.input',
'renderer.quality', 'renderer.quality',
'renderer.readPixels', 'renderer.readPixels',
'renderer.releaseUnused', 'renderer.releaseUnused',
+84 -13
View File
@@ -91,17 +91,33 @@ function pointerTypeOf(dom, otherwise = 'mouse') {
return otherwise; return otherwise;
} }
/** @type {WeakMap<object, object>} scene -> its input port */
const inputs = new WeakMap();
/** /**
* The input port made for a scene, while it lives: the application's
* dispatcher (src/input/dispatcher.js) delivers through it.
* @param {object} scene
* @returns {ReturnType<typeof createCesiumInput> | null}
*/
export const cesiumInputOf = (scene) => inputs.get(scene) ?? null;
/**
* One per scene: a second would find the stock actions already taken.
* @param {object} widget A Cesium `CesiumWidget` or `Viewer`. * @param {object} widget A Cesium `CesiumWidget` or `Viewer`.
* @param {object} options * @param {object} options
* @param {object} options.query The renderer's query port: clicks pick * @param {object} [options.query] The renderer's query port: clicks pick
* through it. * through it. Without one, clicks carry no picks, for a caller that makes
* its own (the application's dispatcher, over a viewer of its own).
* @param {(error: unknown) => void} options.report Where a listener's or a * @param {(error: unknown) => void} options.report Where a listener's or a
* `grab`'s error goes. * `grab`'s error goes.
* @param {(canvas: object) => object} [options.createHandler] * @param {(canvas: object) => object} [options.createHandler]
* @param {() => number} [options.now] Event time, ms. * @param {() => number} [options.now] Event time, ms.
* @param {object} [options.document] Where the DOM events are watched. * @param {object} [options.document] Where the DOM events are watched.
* @param {typeof installTrackpadPinchZoom} [options.ctrlWheelZoom] * @param {typeof installTrackpadPinchZoom} [options.ctrlWheelZoom]
* @param {boolean} [options.takeStock] False leaves the viewer's stock
* actions on its own handler, taken off only while suppressed, for a
* viewer that is not this renderer's: its owner may still rebind them.
* @returns {{port: object, feed: Function, listenerCount: () => number, dispose: () => void}} * @returns {{port: object, feed: Function, listenerCount: () => number, dispose: () => void}}
* `feed(type, x, y, extra)` delivers one event as the handler would, for * `feed(type, x, y, extra)` delivers one event as the handler would, for
* the conformance harness. * the conformance harness.
@@ -115,31 +131,52 @@ export function createCesiumInput(
now = () => performance.now(), now = () => performance.now(),
document: dom = globalThis.document, document: dom = globalThis.document,
ctrlWheelZoom = installTrackpadPinchZoom, ctrlWheelZoom = installTrackpadPinchZoom,
takeStock = true,
}, },
) { ) {
const scene = widget.scene; const scene = widget.scene;
if (inputs.has(scene))
throw new TypeError('This scene already has an input port');
const canvas = scene.canvas; const canvas = scene.canvas;
let disposed = false; let disposed = false;
// The viewer's stock actions, now this port's to run. // The viewer's stock actions, now this port's to run; or, not taking
// them, borrowed from its handler while suppressed.
const stockHandler = widget.screenSpaceEventHandler; const stockHandler = widget.screenSpaceEventHandler;
const stock = {}; const stock = {};
const has = {};
for (const [name, type] of Object.entries(STOCK)) { for (const [name, type] of Object.entries(STOCK)) {
const action = stockHandler?.getInputAction?.(type); const action = stockHandler?.getInputAction?.(type);
if (!action) continue; has[name] = Boolean(action);
if (!action || !takeStock) continue;
stock[name] = action; stock[name] = action;
stockHandler.removeInputAction(type); stockHandler.removeInputAction(type);
} }
/** Actions borrowed while suppressed, to give back by identity. */
const borrowed = {};
const giveBack = (name) => {
const action = borrowed[name];
delete borrowed[name];
if (!action || !stockHandler || stockHandler.isDestroyed?.()) return;
if (!stockHandler.getInputAction(STOCK[name]))
stockHandler.setInputAction(action, STOCK[name]);
};
function borrowStock(name, enabled) {
if (takeStock || !stockHandler) return;
if (enabled) return giveBack(name);
const action = stockHandler.getInputAction?.(STOCK[name]);
if (!action) return;
borrowed[name] = action;
stockHandler.removeInputAction(STOCK[name]);
}
const cameraInput = acquireCameraInput(scene); const cameraInput = acquireCameraInput(scene);
let uninstallCtrlWheel = null; let uninstallCtrlWheel = null;
const router = createInputRouter({ const router = createInputRouter({
report, report,
suspendCamera: (reason) => cameraInput.suspend(reason), suspendCamera: (reason) => cameraInput.suspend(reason),
supported: { supported: { select: has.select, dblclickFollow: has.dblclickFollow },
select: Boolean(stock.select), onBehaviour: borrowStock,
dblclickFollow: Boolean(stock.dblclickFollow),
},
onCtrlWheelZoom(on) { onCtrlWheelZoom(on) {
if (disposed) return; if (disposed) return;
if (on && !uninstallCtrlWheel) uninstallCtrlWheel = ctrlWheelZoom(widget); if (on && !uninstallCtrlWheel) uninstallCtrlWheel = ctrlWheelZoom(widget);
@@ -179,6 +216,13 @@ export function createCesiumInput(
const finished = new Map(); const finished = new Map();
/** The button of the last event, when it was an up. */ /** The button of the last event, when it was an up. */
let lastUp = null; let lastUp = null;
/** The type of the last event delivered. */
let lastType = null;
/**
* The stock behaviours as they stood at the last left down: a press that
* began suppressed stays so, even when its tool lets go on the up.
*/
let stockAtDown = null;
const heldButton = () => { const heldButton = () => {
for (const button of [0, 1, 2]) if (pressing.has(button)) return button; for (const button of [0, 1, 2]) if (pressing.has(button)) return button;
return 0; return 0;
@@ -277,6 +321,18 @@ export function createCesiumInput(
track(type, at, end ?? at, button); track(type, at, end ?? at, button);
const justUp = lastUp; const justUp = lastUp;
lastUp = type === 'up' ? button : null; lastUp = type === 'up' ? button : null;
const previous = lastType;
lastType = type;
if (type === 'down' && button === 0)
stockAtDown = {
select: behaviours.select.enabled,
dblclickFollow: behaviours.dblclickFollow.enabled,
};
// A click straight after its up, and a double-click straight after
// that click, belong to the last press.
const ofPress =
(type === 'click' && justUp === 0) ||
(type === 'dblclick' && previous === 'click');
const ev = { const ev = {
type, type,
position: at, position: at,
@@ -300,15 +356,22 @@ export function createCesiumInput(
: undefined; : undefined;
finished.delete(button); finished.delete(button);
ev.gesture = extra.gesture ?? press ?? { travelPx: 0, durationMs: 0 }; ev.gesture = extra.gesture ?? press ?? { travelPx: 0, durationMs: 0 };
withPicks(ev); if (query) withPicks(ev);
} }
// The viewer's own select and follow run first, as they did on its // The viewer's own select and follow run first, as they did on its
// handler. // handler.
router.dispatch(ev, (to) => runStock(ev, to)); router.dispatch(ev, (to) => runStock(ev, to, ofPress ? stockAtDown : null));
// A hold a down subscriber takes covers its press too.
if (type === 'down' && button === 0 && stockAtDown)
stockAtDown = {
select: stockAtDown.select && behaviours.select.enabled,
dblclickFollow:
stockAtDown.dblclickFollow && behaviours.dblclickFollow.enabled,
};
} }
/** The viewer's own select or follow, as it would have run. */ /** The viewer's own select or follow, as it would have run. */
function runStock(ev, to) { function runStock(ev, to, atDown) {
const name = const name =
ev.type === 'click' ev.type === 'click'
? 'select' ? 'select'
@@ -319,6 +382,7 @@ export function createCesiumInput(
// Cesium bound them with no modifier: a modified click never ran them. // Cesium bound them with no modifier: a modified click never ran them.
const { shift, ctrl, alt } = ev.modifiers; const { shift, ctrl, alt } = ev.modifiers;
if (shift || ctrl || alt || !behaviours[name].enabled) return; if (shift || ctrl || alt || !behaviours[name].enabled) return;
if (atDown && !atDown[name]) return;
try { try {
stock[name]({ stock[name]({
position: new Cesium.Cartesian2(ev.position.x, ev.position.y), position: new Cesium.Cartesian2(ev.position.x, ev.position.y),
@@ -352,7 +416,10 @@ export function createCesiumInput(
deliver('dblclick', e.position, { button: 0, modifier }), deliver('dblclick', e.position, { button: 0, modifier }),
); );
bind(T.MOUSE_MOVE, (e, modifier) => bind(T.MOUSE_MOVE, (e, modifier) =>
deliver('move', e.startPosition, { endPosition: e.endPosition, modifier }), deliver('move', e.startPosition ?? e.endPosition, {
endPosition: e.endPosition,
modifier,
}),
); );
// Pinch is at the two fingers' midpoint where it started; its delta is // Pinch is at the two fingers' midpoint where it started; its delta is
// the scale since the last event. // the scale since the last event.
@@ -390,7 +457,7 @@ export function createCesiumInput(
}; };
canvas?.addEventListener?.('wheel', onWheel, { passive: true }); canvas?.addEventListener?.('wheel', onWheel, { passive: true });
return { const self = {
port: router.port, port: router.port,
feed(type, x, y, extra = {}) { feed(type, x, y, extra = {}) {
deliver(type, { x, y }, extra, null); deliver(type, { x, y }, extra, null);
@@ -400,6 +467,7 @@ export function createCesiumInput(
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
router.dispose(); router.dispose();
if (inputs.get(scene) === self) inputs.delete(scene);
canvas?.removeEventListener?.('wheel', onWheel, { passive: true }); canvas?.removeEventListener?.('wheel', onWheel, { passive: true });
for (const type of DOM_EVENTS) for (const type of DOM_EVENTS)
dom?.removeEventListener?.(type, record, { capture: true }); dom?.removeEventListener?.(type, record, { capture: true });
@@ -407,10 +475,13 @@ export function createCesiumInput(
uninstallCtrlWheel?.(); uninstallCtrlWheel?.();
uninstallCtrlWheel = null; uninstallCtrlWheel = null;
cameraInput.release(); cameraInput.release();
for (const name of Object.keys(borrowed)) giveBack(name);
if (stockHandler && !stockHandler.isDestroyed?.()) if (stockHandler && !stockHandler.isDestroyed?.())
for (const [name, type] of Object.entries(STOCK)) for (const [name, type] of Object.entries(STOCK))
if (stock[name] && !stockHandler.getInputAction(type)) if (stock[name] && !stockHandler.getInputAction(type))
stockHandler.setInputAction(stock[name], type); stockHandler.setInputAction(stock[name], type);
}, },
}; };
inputs.set(scene, self);
return self;
} }
+52 -1
View File
@@ -48,7 +48,7 @@ function fakeDocument() {
}; };
} }
function setup({ stock = true, document = null } = {}) { function setup({ stock = true, document = null, takeStock = true } = {}) {
const stockHandler = fakeHandler(); const stockHandler = fakeHandler();
const selected = []; const selected = [];
const followed = []; const followed = [];
@@ -86,6 +86,7 @@ function setup({ stock = true, document = null } = {}) {
createHandler: () => handler, createHandler: () => handler,
now: () => clock, now: () => clock,
document, document,
takeStock,
ctrlWheelZoom: () => { ctrlWheelZoom: () => {
wheelZoom.push('on'); wheelZoom.push('on');
return () => wheelZoom.push('off'); return () => wheelZoom.push('off');
@@ -374,3 +375,53 @@ test('a double-tap is a touch; a click on Cesium’s timer ends no press', () =>
s.handler.fire(T.RIGHT_CLICK, { position: s.at(5, 6) }); s.handler.fire(T.RIGHT_CLICK, { position: s.at(5, 6) });
assert.deepEqual(heard, ['touch', { travelPx: 0, durationMs: 0 }]); assert.deepEqual(heard, ['touch', { travelPx: 0, durationMs: 0 }]);
}); });
test('a press that began suppressed never selects, even if its tool lets go on the up', () => {
const s = setup();
let release = s.port.behaviours.select.suppress();
s.port.on('up', () => {
release?.();
release = null;
});
s.press(5, 6);
assert.equal(s.selected.length, 0, 'the click of the suppressed press');
s.press(5, 6);
assert.equal(s.selected.length, 1, 'the next press selects');
});
test('a hold a down subscriber takes keeps its press from selecting', () => {
const s = setup();
let release = null;
s.port.on('down', () => {
release = s.port.behaviours.select.suppress();
});
s.port.on('up', () => release?.());
s.press(5, 6);
assert.equal(s.selected.length, 0);
});
test('not taking the stock, they stay on the viewer’s handler but while held', () => {
const s = setup({ takeStock: false });
const original = s.stockHandler.getInputAction(T.LEFT_CLICK);
assert.equal(typeof original, 'function', 'left where the viewer put them');
s.press(5, 6);
assert.equal(s.selected.length, 0, 'the port never runs them itself');
const release = s.port.behaviours.select.suppress();
assert.equal(s.stockHandler.getInputAction(T.LEFT_CLICK), undefined);
assert.equal(
typeof s.stockHandler.getInputAction(T.LEFT_DOUBLE_CLICK),
'function',
'follow is its own',
);
release();
assert.equal(s.stockHandler.getInputAction(T.LEFT_CLICK), original);
s.port.behaviours.dblclickFollow.suppress();
const follow = s.stockHandler.getInputAction(T.LEFT_DOUBLE_CLICK);
assert.equal(follow, undefined);
s.input.dispose();
assert.equal(
typeof s.stockHandler.getInputAction(T.LEFT_DOUBLE_CLICK),
'function',
'dispose gives a held one back',
);
});
+7 -1
View File
@@ -29,8 +29,14 @@ function fixture(options = {}) {
...options, ...options,
}); });
const camera = fakes.viewer.scene.screenSpaceCameraController; const camera = fakes.viewer.scene.screenSpaceCameraController;
// The stock action as it runs now: the input port holds them, so it is
// the original while not suppressed, and none while it is.
const stockAction = (type) => const stockAction = (type) =>
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]); fakes.stockEnabled(type === 'LEFT_CLICK' ? 'select' : 'dblclickFollow')
? type === 'LEFT_CLICK'
? fakes.originalClick
: fakes.originalDouble
: undefined;
const preview = () => const preview = () =>
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show); fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
return { return {