refactor(street-level): hover picks once a frame instead of a throttled timer

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-08 15:04:23 +04:00
1 parent c78c872f92
commit fd2a21c0c6
2 files changed
+55 -176

No files matched your search

+26 -87
View File
@@ -3,9 +3,6 @@ import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js';
import { PICK_PREFIX } from './providers/mapillary/policy.js'; import { PICK_PREFIX } from './providers/mapillary/policy.js';
import { sequenceIdFromPick } from './providers/mapillary/coverage.js'; import { sequenceIdFromPick } from './providers/mapillary/coverage.js';
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for coverage lines, image cones and the marker. */ /** One click/hover handler for coverage lines, image cones and the marker. */
export function createSelection({ state, parts }) { export function createSelection({ state, parts }) {
const { picking, input } = state.services; const { picking, input } = state.services;
@@ -54,104 +51,40 @@ export function createSelection({ state, parts }) {
parts.sequences.clearSelection(); parts.sequences.clearSelection();
} }
let hoverQueued = false; /** Where the pointer is, until a frame picks it; a drag picks nothing. */
let hoverCursor = false;
let hoverPosition = null; let hoverPosition = null;
let hoverLastPickAt = -Infinity; let hoverCursor = false;
let hoverTimer = null; let dragging = false;
let pointerButtons = 0;
let removeHoverWatchers = null;
function setHoverCursor(canvas, hit) { function setHoverCursor(canvas, hit) {
if (hit && !hoverCursor) { if (hit === hoverCursor) return;
canvas.style.cursor = 'pointer'; canvas.style.cursor = hit ? 'pointer' : '';
hoverCursor = true; hoverCursor = hit;
} else if (!hit && hoverCursor) {
canvas.style.cursor = '';
hoverCursor = false;
}
} }
function pickHover() { function pickHover() {
hoverQueued = false; const position = hoverPosition;
const viewer = state.viewer; hoverPosition = null;
const canvas = state.viewer?.scene?.canvas;
// Switched off (or torn down) since the frame was queued. // Switched off (or torn down) since the frame was queued.
if (!viewer || !state.enabled || !state.clickHandler) return; if (!canvas || !state.enabled || !state.clickHandler || dragging) return;
// A drag in progress: the pointer is the camera's.
if (pointerButtons !== 0) return;
const canvas = viewer.scene?.canvas;
if (!canvas || viewer.isDestroyed?.()) return;
hoverLastPickAt = Date.now();
let hit = false; let hit = false;
try { try {
const picked = viewer.scene.pick(hoverPosition); const picked = state.viewer.scene.pick(position);
const id = picking?.resolvePickId hit = ownsPick(
? picking.resolvePickId(picked) picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id,
: picked?.id; );
hit = ownsPick(id);
} catch { } catch {
hit = false; hit = false;
} }
setHoverCursor(canvas, hit); setHoverCursor(canvas, hit);
} }
/** /** Pointer cursor over anything this layer owns, picked at most once a frame. */
* Pointer cursor over anything this layer owns. Throttled; a move inside
* the interval is picked when it ends, so the cursor never sticks.
*/
function onMove(movement) { function onMove(movement) {
const viewer = state.viewer; if (!state.enabled || dragging) return;
if (!viewer || !state.enabled) return; if (!hoverPosition) requestAnimationFrame(pickHover);
hoverPosition = movement.endPosition; hoverPosition = Cesium.Cartesian2.clone(movement.endPosition);
queueHoverPick();
}
/** Pick `hoverPosition` on a coming frame, within the throttle. */
function queueHoverPick() {
if (hoverQueued) return;
hoverQueued = true;
const wait = hoverLastPickAt + HOVER_PICK_INTERVAL_MS - Date.now();
if (wait <= 0) {
requestAnimationFrame(pickHover);
return;
}
hoverTimer = setTimeout(() => {
hoverTimer = null;
requestAnimationFrame(pickHover);
}, wait);
}
/**
* Track held buttons (a drag picks nothing) and re-pick when the camera
* rests. Hover keeps running while it moves: a tracking camera never stops.
*/
function watchHover(viewer) {
const canvas = viewer.scene.canvas;
const camera = viewer.camera;
const onButtons = (event) => {
pointerButtons = event.buttons ?? 0;
};
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
canvas.addEventListener?.(type, onButtons);
const removeEnd = camera?.moveEnd?.addEventListener(() => {
if (hoverPosition) queueHoverPick();
});
removeHoverWatchers = () => {
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
canvas.removeEventListener?.(type, onButtons);
removeEnd?.();
};
}
function unwatchHover() {
removeHoverWatchers?.();
removeHoverWatchers = null;
clearTimeout(hoverTimer);
hoverTimer = null;
hoverQueued = false;
hoverPosition = null;
hoverLastPickAt = -Infinity;
pointerButtons = 0;
} }
function install(viewer) { function install(viewer) {
@@ -167,7 +100,12 @@ export function createSelection({ state, parts }) {
onMove, onMove,
Cesium.ScreenSpaceEventType.MOUSE_MOVE, Cesium.ScreenSpaceEventType.MOUSE_MOVE,
); );
watchHover(viewer); state.clickHandler.setInputAction(() => {
dragging = true;
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
state.clickHandler.setInputAction(() => {
dragging = false;
}, Cesium.ScreenSpaceEventType.LEFT_UP);
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
} }
@@ -175,7 +113,8 @@ export function createSelection({ state, parts }) {
function uninstall() { function uninstall() {
// Nothing to undo for a layer that was never enabled. // Nothing to undo for a layer that was never enabled.
if (!state.clickHandler) return; if (!state.clickHandler) return;
unwatchHover(); hoverPosition = null;
dragging = false;
if (hoverCursor && state.viewer?.scene?.canvas) { if (hoverCursor && state.viewer?.scene?.canvas) {
state.viewer.scene.canvas.style.cursor = ''; state.viewer.scene.canvas.style.cursor = '';
hoverCursor = false; hoverCursor = false;
+29 -89
View File
@@ -1,19 +1,17 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { mock } from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createSelection } from './selection.js'; import { createSelection } from './selection.js';
/** /**
* The selection installed on a stand-in viewer. Animation frames queue until * The selection installed on a stand-in viewer. Animation frames queue until
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves * `frame()` runs them.
* has a known timeline.
*/ */
function harness({ selected = false } = {}) { function harness({ selected = false } = {}) {
const saved = { const saved = {
document: globalThis.document, document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame, requestAnimationFrame: globalThis.requestAnimationFrame,
}; };
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const frames = []; const frames = [];
globalThis.requestAnimationFrame = (task) => frames.push(task); globalThis.requestAnimationFrame = (task) => frames.push(task);
globalThis.document = new EventTarget(); globalThis.document = new EventTarget();
@@ -43,7 +41,6 @@ function harness({ selected = false } = {}) {
disableRootEvents: true, disableRootEvents: true,
onwheel: null, onwheel: null,
}); });
const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() };
const viewer = { const viewer = {
scene: { scene: {
canvas, canvas,
@@ -52,8 +49,6 @@ function harness({ selected = false } = {}) {
return scene.under && { id: scene.under }; return scene.under && { id: scene.under };
}, },
}, },
camera,
isDestroyed: () => false,
}; };
const state = { const state = {
viewer, viewer,
@@ -79,9 +74,9 @@ function harness({ selected = false } = {}) {
}, },
}); });
selection.install(viewer); selection.install(viewer);
const onMove = state.clickHandler.getInputAction( const action = (type) =>
Cesium.ScreenSpaceEventType.MOUSE_MOVE, state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
); const onMove = action('MOUSE_MOVE');
return { return {
state, state,
sequences, sequences,
@@ -100,110 +95,55 @@ function harness({ selected = false } = {}) {
}, },
scene, scene,
canvas, canvas,
camera,
picks, picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }), move: (x) => onMove({ endPosition: { x, y: 10 } }),
down: () => action('LEFT_DOWN')(),
up: () => action('LEFT_UP')(),
frame() { frame() {
for (const task of frames.splice(0)) task(); for (const task of frames.splice(0)) task();
}, },
/** Let `ms` pass, running the frames that come due on the way. */
wait(ms) {
this.frame();
mock.timers.tick(ms);
this.frame();
},
buttons(type, buttons) {
const event = new Event(type);
Object.defineProperty(event, 'buttons', { value: buttons });
canvas.dispatchEvent(event);
},
restore() { restore() {
selection.uninstall(); selection.uninstall();
mock.timers.reset();
Object.assign(globalThis, saved); Object.assign(globalThis, saved);
}, },
}; };
} }
test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => { test('hover picks once a frame, at the latest position, and shows a pointer over a line', () => {
const h = harness();
try {
// One second of a pointer moving at 60 Hz.
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
h.wait(200);
assert.deepEqual(h.picks.at(-1), { x: 59, y: 10 }, 'the resting position');
const settled = h.picks.length;
h.wait(1000);
assert.equal(h.picks.length, settled, 'a still pointer costs nothing');
} finally {
h.restore();
}
});
test('hover does not pick while a mouse button is held', () => {
const h = harness();
try {
h.buttons('pointerdown', 1);
h.move(1);
h.wait(200);
assert.equal(h.picks.length, 0, 'a drag in progress');
h.buttons('pointerup', 0);
h.move(2);
h.wait(200);
assert.equal(h.picks.length, 1);
} finally {
h.restore();
}
});
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
const h = harness();
try {
// An orbiting or tracking camera raises moveStart and never moveEnd.
h.camera.moveStart.raiseEvent();
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
} finally {
h.restore();
}
});
test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
const h = harness(); const h = harness();
try { try {
h.scene.under = 'mly:seq:1'; h.scene.under = 'mly:seq:1';
h.move(5); for (let x = 0; x < 5; x++) h.move(x);
h.wait(200); h.frame();
assert.equal(h.canvas.style.cursor, 'pointer', 'over a line'); assert.deepEqual(
// The camera moves the line out from under a still pointer. h.picks.map(({ x }) => x),
h.camera.moveStart.raiseEvent(); [4],
'one pick for the burst',
);
assert.equal(h.canvas.style.cursor, 'pointer');
h.scene.under = null; h.scene.under = null;
h.camera.moveEnd.raiseEvent(); h.move(6);
h.wait(200); h.frame();
assert.equal(h.picks.length, 2, 'one pick when the camera rests'); assert.equal(h.canvas.style.cursor, '', 'off the line');
assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 }); h.frame();
assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone'); assert.equal(h.picks.length, 2, 'a still pointer costs nothing');
} finally { } finally {
h.restore(); h.restore();
} }
}); });
test('a hover frame queued before the layer went off does not pick', () => { test('a drag, or a layer gone off before the frame, picks nothing', () => {
const h = harness(); const h = harness();
try { try {
h.down();
h.move(1); h.move(1);
h.frame();
assert.equal(h.picks.length, 0, 'a drag in progress');
h.up();
h.move(2);
h.state.enabled = false; h.state.enabled = false;
h.wait(200); h.frame();
assert.equal(h.picks.length, 0); assert.equal(h.picks.length, 0, 'the layer went off');
} finally { } finally {
h.restore(); h.restore();
} }