mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
@@ -1 +1 @@
|
|||||||
- Throttle hover picks and clear stale cursors (asturksever, #1279).
|
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
|
||||||
@@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
|||||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||||
search keeps a successful geocode, with no search or view-based region recovery.
|
search keeps a successful geocode, with no search or view-based region recovery.
|
||||||
|
|
||||||
Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior.
|
Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. A move inside the 120 ms window keeps a copy of its position and runs one trailing check when the window ends, so a pointer that stops is still resolved; the trailing check rechecks eligibility and is cancelled by reset, disable, camera motion, and destroy. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior.
|
||||||
|
|
||||||
Terrain height requests accept at most 64 points, matching the browser service's
|
Terrain height requests accept at most 64 points, matching the browser service's
|
||||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||||
|
|||||||
@@ -3,6 +3,146 @@ import assert from 'node:assert/strict';
|
|||||||
import { Event } from 'cesium';
|
import { Event } from 'cesium';
|
||||||
import { createLaunchHover } from './lifecycle.js';
|
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', () => {
|
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
|
||||||
let at = 0,
|
let at = 0,
|
||||||
picks = 0,
|
picks = 0,
|
||||||
@@ -30,6 +170,7 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners',
|
|||||||
now: () => at,
|
now: () => at,
|
||||||
documentRef: doc,
|
documentRef: doc,
|
||||||
pointerFree: () => true,
|
pointerFree: () => true,
|
||||||
|
...fakeTimers(),
|
||||||
});
|
});
|
||||||
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
||||||
assert.equal(picks, 1);
|
assert.equal(picks, 1);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
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({
|
export function createLaunchHover({
|
||||||
viewer,
|
viewer,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -9,16 +15,26 @@ export function createLaunchHover({
|
|||||||
now = () => performance.now(),
|
now = () => performance.now(),
|
||||||
documentRef = globalThis.document,
|
documentRef = globalThis.document,
|
||||||
pointerFree = isPointerFree,
|
pointerFree = isPointerFree,
|
||||||
|
setTimer = globalThis.setTimeout,
|
||||||
|
clearTimer = globalThis.clearTimeout,
|
||||||
}) {
|
}) {
|
||||||
let moving = false;
|
let moving = false;
|
||||||
let lastPick = -Infinity;
|
let lastPick = -Infinity;
|
||||||
let ownsCursor = false;
|
let ownsCursor = false;
|
||||||
|
let pendingPosition = null;
|
||||||
|
let trailingTimer = null;
|
||||||
const clearCursor = () => {
|
const clearCursor = () => {
|
||||||
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
||||||
viewer.scene.canvas.style.cursor = '';
|
viewer.scene.canvas.style.cursor = '';
|
||||||
ownsCursor = false;
|
ownsCursor = false;
|
||||||
};
|
};
|
||||||
|
const cancelTrailing = () => {
|
||||||
|
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||||
|
trailingTimer = null;
|
||||||
|
pendingPosition = null;
|
||||||
|
};
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
|
cancelTrailing();
|
||||||
clearCursor();
|
clearCursor();
|
||||||
lastPick = -Infinity;
|
lastPick = -Infinity;
|
||||||
};
|
};
|
||||||
@@ -34,31 +50,56 @@ export function createLaunchHover({
|
|||||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||||
reset();
|
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 removeStart = viewer.camera?.moveStart?.addEventListener(start);
|
||||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
||||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||||
return {
|
return {
|
||||||
move(movement) {
|
move(movement) {
|
||||||
if (
|
if (!eligible()) {
|
||||||
!enabled() ||
|
|
||||||
moving ||
|
|
||||||
documentRef?.hidden ||
|
|
||||||
documentRef?.visibilityState === 'hidden' ||
|
|
||||||
!pointerFree()
|
|
||||||
) {
|
|
||||||
reset();
|
reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const at = now();
|
const at = now();
|
||||||
if (at - lastPick < 120) return;
|
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||||
lastPick = at;
|
if (wait > 0) {
|
||||||
const found = viewer.scene
|
// Cesium reuses the movement object, so keep a copy of the position.
|
||||||
.drillPick(movement.endPosition, 12)
|
pendingPosition = Cesium.Cartesian2.clone(
|
||||||
.some((picked) => entityLaunchId(picked?.id));
|
movement.endPosition,
|
||||||
if (found) {
|
pendingPosition ?? undefined,
|
||||||
viewer.scene.canvas.style.cursor = 'pointer';
|
);
|
||||||
ownsCursor = true;
|
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||||
} else clearCursor();
|
return;
|
||||||
|
}
|
||||||
|
cancelTrailing();
|
||||||
|
pick(movement.endPosition, at);
|
||||||
},
|
},
|
||||||
reset,
|
reset,
|
||||||
destroy() {
|
destroy() {
|
||||||
|
|||||||
Reference in new issue
Block a user