diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index fd395e6aa..14eeb59ea 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -3,9 +3,6 @@ import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js'; import { PICK_PREFIX } from './providers/mapillary/policy.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. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; @@ -54,104 +51,40 @@ export function createSelection({ state, parts }) { parts.sequences.clearSelection(); } - let hoverQueued = false; - let hoverCursor = false; + /** Where the pointer is, until a frame picks it; a drag picks nothing. */ let hoverPosition = null; - let hoverLastPickAt = -Infinity; - let hoverTimer = null; - let pointerButtons = 0; - let removeHoverWatchers = null; + let hoverCursor = false; + let dragging = false; function setHoverCursor(canvas, hit) { - if (hit && !hoverCursor) { - canvas.style.cursor = 'pointer'; - hoverCursor = true; - } else if (!hit && hoverCursor) { - canvas.style.cursor = ''; - hoverCursor = false; - } + if (hit === hoverCursor) return; + canvas.style.cursor = hit ? 'pointer' : ''; + hoverCursor = hit; } function pickHover() { - hoverQueued = false; - const viewer = state.viewer; + const position = hoverPosition; + hoverPosition = null; + const canvas = state.viewer?.scene?.canvas; // Switched off (or torn down) since the frame was queued. - if (!viewer || !state.enabled || !state.clickHandler) 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(); + if (!canvas || !state.enabled || !state.clickHandler || dragging) return; let hit = false; try { - const picked = viewer.scene.pick(hoverPosition); - const id = picking?.resolvePickId - ? picking.resolvePickId(picked) - : picked?.id; - hit = ownsPick(id); + const picked = state.viewer.scene.pick(position); + hit = ownsPick( + picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id, + ); } catch { hit = false; } setHoverCursor(canvas, hit); } - /** - * Pointer cursor over anything this layer owns. Throttled; a move inside - * the interval is picked when it ends, so the cursor never sticks. - */ + /** Pointer cursor over anything this layer owns, picked at most once a frame. */ function onMove(movement) { - const viewer = state.viewer; - if (!viewer || !state.enabled) return; - hoverPosition = 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; + if (!state.enabled || dragging) return; + if (!hoverPosition) requestAnimationFrame(pickHover); + hoverPosition = Cesium.Cartesian2.clone(movement.endPosition); } function install(viewer) { @@ -167,7 +100,12 @@ export function createSelection({ state, parts }) { onMove, 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); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick); } @@ -175,7 +113,8 @@ export function createSelection({ state, parts }) { function uninstall() { // Nothing to undo for a layer that was never enabled. if (!state.clickHandler) return; - unwatchHover(); + hoverPosition = null; + dragging = false; if (hoverCursor && state.viewer?.scene?.canvas) { state.viewer.scene.canvas.style.cursor = ''; hoverCursor = false; diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index 724efc713..7eaedf4f5 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -1,19 +1,17 @@ import assert from 'node:assert/strict'; -import test, { mock } from 'node:test'; +import test from 'node:test'; import * as Cesium from 'cesium'; import { createSelection } from './selection.js'; /** * 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 - * has a known timeline. + * `frame()` runs them. */ function harness({ selected = false } = {}) { const saved = { document: globalThis.document, requestAnimationFrame: globalThis.requestAnimationFrame, }; - mock.timers.enable({ apis: ['setTimeout', 'Date'] }); const frames = []; globalThis.requestAnimationFrame = (task) => frames.push(task); globalThis.document = new EventTarget(); @@ -43,7 +41,6 @@ function harness({ selected = false } = {}) { disableRootEvents: true, onwheel: null, }); - const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() }; const viewer = { scene: { canvas, @@ -52,8 +49,6 @@ function harness({ selected = false } = {}) { return scene.under && { id: scene.under }; }, }, - camera, - isDestroyed: () => false, }; const state = { viewer, @@ -79,9 +74,9 @@ function harness({ selected = false } = {}) { }, }); selection.install(viewer); - const onMove = state.clickHandler.getInputAction( - Cesium.ScreenSpaceEventType.MOUSE_MOVE, - ); + const action = (type) => + state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]); + const onMove = action('MOUSE_MOVE'); return { state, sequences, @@ -100,110 +95,55 @@ function harness({ selected = false } = {}) { }, scene, canvas, - camera, picks, move: (x) => onMove({ endPosition: { x, y: 10 } }), + down: () => action('LEFT_DOWN')(), + up: () => action('LEFT_UP')(), frame() { 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() { selection.uninstall(); - mock.timers.reset(); Object.assign(globalThis, saved); }, }; } -test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => { - 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', () => { +test('hover picks once a frame, at the latest position, and shows a pointer over a line', () => { const h = harness(); try { h.scene.under = 'mly:seq:1'; - h.move(5); - h.wait(200); - assert.equal(h.canvas.style.cursor, 'pointer', 'over a line'); - // The camera moves the line out from under a still pointer. - h.camera.moveStart.raiseEvent(); + for (let x = 0; x < 5; x++) h.move(x); + h.frame(); + assert.deepEqual( + h.picks.map(({ x }) => x), + [4], + 'one pick for the burst', + ); + assert.equal(h.canvas.style.cursor, 'pointer'); h.scene.under = null; - h.camera.moveEnd.raiseEvent(); - h.wait(200); - assert.equal(h.picks.length, 2, 'one pick when the camera rests'); - assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 }); - assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone'); + h.move(6); + h.frame(); + assert.equal(h.canvas.style.cursor, '', 'off the line'); + h.frame(); + assert.equal(h.picks.length, 2, 'a still pointer costs nothing'); } finally { 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(); try { + h.down(); 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.wait(200); - assert.equal(h.picks.length, 0); + h.frame(); + assert.equal(h.picks.length, 0, 'the layer went off'); } finally { h.restore(); }