mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Run a trailing hover check for the last suppressed pointer position
A move inside the 120 ms hover throttle window was dropped, so a pointer that stopped right after a pick kept a stale cursor (pointer over empty space, or default over a mission) until the next move. Keep a copy of the last suppressed position (Cesium reuses the movement object) and run one trailing check when the window ends. The check rechecks hover eligibility and is cancelled by reset, disable, camera motion, and destroy. Tests cover a stopped pointer in both directions without another event, the copied position, one timer at a time, cancellation, and the eligibility recheck. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d41c0ccaea
commit
e60caab180
4 files changed
+201
-19
No files matched your search
@@ -3,6 +3,146 @@ import assert from 'node:assert/strict';
|
||||
import { Event } from 'cesium';
|
||||
import { createLaunchHover } from './lifecycle.js';
|
||||
|
||||
function fakeTimers() {
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const api = {
|
||||
setTimer(fn, ms) {
|
||||
const id = nextId++;
|
||||
timers.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
pending: () => [...timers.values()],
|
||||
flush() {
|
||||
const due = [...timers.entries()];
|
||||
timers.clear();
|
||||
for (const [, timer] of due) timer.fn();
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function hoverRig({ overMarker }) {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true,
|
||||
free = true;
|
||||
const timers = fakeTimers();
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick(position) {
|
||||
picks++;
|
||||
return overMarker(position) ? [{ id: 'launch' }] : [];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: { hidden: false },
|
||||
pointerFree: () => free,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
return {
|
||||
hover,
|
||||
timers,
|
||||
viewer,
|
||||
cursor: () => viewer.scene.canvas.style.cursor,
|
||||
picks: () => picks,
|
||||
setTime: (value) => (at = value),
|
||||
setEnabled: (value) => (enabled = value),
|
||||
setFree: (value) => (free = value),
|
||||
};
|
||||
}
|
||||
|
||||
const onMarker = (position) => position?.x === 1;
|
||||
const marker = () => ({ endPosition: { x: 1, y: 1 } });
|
||||
const empty = () => ({ endPosition: { x: 9, y: 9 } });
|
||||
|
||||
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.setTime(50);
|
||||
const movement = empty();
|
||||
rig.hover.move(movement);
|
||||
// Cesium reuses its movement object; the trailing check must use a copy.
|
||||
movement.endPosition.x = 1;
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.deepEqual(
|
||||
rig.timers.pending().map((timer) => timer.ms),
|
||||
[70],
|
||||
);
|
||||
rig.setTime(80);
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), '');
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
rig.hover.destroy();
|
||||
});
|
||||
|
||||
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.hover.destroy();
|
||||
assert.equal(rig.cursor(), '');
|
||||
});
|
||||
|
||||
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
|
||||
for (const cancel of [
|
||||
(rig) => rig.hover.reset(),
|
||||
(rig) => rig.hover.destroy(),
|
||||
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.timers.pending().length, 1);
|
||||
cancel(rig);
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover trailing check rechecks eligibility before picking', () => {
|
||||
for (const block of [
|
||||
(rig) => rig.setEnabled(false),
|
||||
(rig) => rig.setFree(false),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
block(rig);
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
@@ -30,6 +170,7 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners',
|
||||
now: () => at,
|
||||
documentRef: doc,
|
||||
pointerFree: () => true,
|
||||
...fakeTimers(),
|
||||
});
|
||||
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 1);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Bound hover picking independently of precise click selection. */
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
/**
|
||||
* Bound hover picking independently of precise click selection. A move inside
|
||||
* the throttle window is not dropped: its position is kept and one trailing
|
||||
* check runs when the window ends, so a pointer that stops is still resolved.
|
||||
*/
|
||||
export function createLaunchHover({
|
||||
viewer,
|
||||
enabled,
|
||||
@@ -9,16 +15,26 @@ export function createLaunchHover({
|
||||
now = () => performance.now(),
|
||||
documentRef = globalThis.document,
|
||||
pointerFree = isPointerFree,
|
||||
setTimer = globalThis.setTimeout,
|
||||
clearTimer = globalThis.clearTimeout,
|
||||
}) {
|
||||
let moving = false;
|
||||
let lastPick = -Infinity;
|
||||
let ownsCursor = false;
|
||||
let pendingPosition = null;
|
||||
let trailingTimer = null;
|
||||
const clearCursor = () => {
|
||||
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
||||
viewer.scene.canvas.style.cursor = '';
|
||||
ownsCursor = false;
|
||||
};
|
||||
const cancelTrailing = () => {
|
||||
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||
trailingTimer = null;
|
||||
pendingPosition = null;
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTrailing();
|
||||
clearCursor();
|
||||
lastPick = -Infinity;
|
||||
};
|
||||
@@ -34,31 +50,56 @@ export function createLaunchHover({
|
||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||
reset();
|
||||
};
|
||||
const eligible = () =>
|
||||
enabled() &&
|
||||
!moving &&
|
||||
!documentRef?.hidden &&
|
||||
documentRef?.visibilityState !== 'hidden' &&
|
||||
pointerFree();
|
||||
const pick = (position, at) => {
|
||||
lastPick = at;
|
||||
const found = viewer.scene
|
||||
.drillPick(position, 12)
|
||||
.some((picked) => entityLaunchId(picked?.id));
|
||||
if (found) {
|
||||
viewer.scene.canvas.style.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
};
|
||||
const trailing = () => {
|
||||
trailingTimer = null;
|
||||
const position = pendingPosition;
|
||||
pendingPosition = null;
|
||||
if (!position) return;
|
||||
// Eligibility may have changed while the check waited.
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
pick(position, now());
|
||||
};
|
||||
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
|
||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||
return {
|
||||
move(movement) {
|
||||
if (
|
||||
!enabled() ||
|
||||
moving ||
|
||||
documentRef?.hidden ||
|
||||
documentRef?.visibilityState === 'hidden' ||
|
||||
!pointerFree()
|
||||
) {
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
if (at - lastPick < 120) return;
|
||||
lastPick = at;
|
||||
const found = viewer.scene
|
||||
.drillPick(movement.endPosition, 12)
|
||||
.some((picked) => entityLaunchId(picked?.id));
|
||||
if (found) {
|
||||
viewer.scene.canvas.style.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||
if (wait > 0) {
|
||||
// Cesium reuses the movement object, so keep a copy of the position.
|
||||
pendingPosition = Cesium.Cartesian2.clone(
|
||||
movement.endPosition,
|
||||
pendingPosition ?? undefined,
|
||||
);
|
||||
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||
return;
|
||||
}
|
||||
cancelTrailing();
|
||||
pick(movement.endPosition, at);
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
|
||||
Reference in new issue
Block a user