mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
c78c872f92
commit
fd2a21c0c6
2 files changed
+55
-176
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user