diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md index 7005c53cd..ec451e7ae 100644 --- a/changelog.d/1279-launch-issue-fix.md +++ b/changelog.d/1279-launch-issue-fix.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1cd57febe..8405d4d50 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation 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 existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs index 83a370f26..d09e5168b 100644 --- a/src/layers/launches/hover.test.mjs +++ b/src/layers/launches/hover.test.mjs @@ -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); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 4d4962242..6ac777da1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -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() {