Merge pull request #1279 from bilawalsidhu/codex/fix-launch-hover-throttle

fix(missions): throttle hover picks and clear stale cursors
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-24 01:11:13 +01:00
commit 8b7cdac910
4 files changed
+357 -18

No files matched your search

+1
View File
@@ -0,0 +1 @@
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
+1 -10
View File
@@ -9,16 +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.
KML namespace repair locates the opening token once and scans its quoted tag
delimiter once, skipping prolog and DOCTYPE comments and processing
instructions whole. The scanner is a boundary check only: an opening KML tag
that never closes, or one with raw markup starting inside it, fails before DOM
parsing, so a file of repeated incomplete `<kml` prefixes cannot trigger
quadratic regex rescans. All other XML grammar, including attribute syntax
inside a complete root tag, is left to DOMParser. Namespace declarations are
recognized only as whole root attributes, never inside quoted values; duplicate
namespace repair and missing xsi declarations remain supported. GPX continues
through its existing XML parsing path.
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
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
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,
enabled = true;
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_n, fn) => listeners.add(fn),
removeEventListener: (_n, fn) => listeners.delete(fn),
};
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: {} },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: doc,
pointerFree: () => true,
...fakeTimers(),
});
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
hover.move({ endPosition: {} });
assert.equal(picks, 2);
viewer.camera.moveStart.raiseEvent();
at = 500;
hover.move({ endPosition: {} });
assert.equal(picks, 2);
assert.equal(viewer.scene.canvas.style.cursor, '');
viewer.camera.moveEnd.raiseEvent();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
doc.hidden = true;
for (const fn of listeners) fn();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
assert.equal(viewer.scene.canvas.style.cursor, '');
doc.hidden = false;
enabled = false;
hover.move({ endPosition: {} });
assert.equal(picks, 3);
hover.destroy();
assert.equal(listeners.size, 0);
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
});
test('mission hover preserves the cursor of a pointer-owning tool', () => {
let free = true,
picks = 0;
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => true,
entityLaunchId: () => true,
documentRef: null,
pointerFree: () => free,
});
hover.move({ endPosition: {} });
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
free = false;
viewer.scene.canvas.style.cursor = 'crosshair';
hover.move({ endPosition: {} });
viewer.camera.moveStart.raiseEvent();
hover.destroy();
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
});
+123 -8
View File
@@ -1,6 +1,116 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
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,
entityLaunchId,
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;
};
const start = () => {
moving = true;
reset();
};
const end = () => {
moving = false;
reset();
};
const visibility = () => {
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 (!eligible()) {
reset();
return;
}
const at = now();
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() {
reset();
removeStart?.();
removeEnd?.();
documentRef?.removeEventListener?.('visibilitychange', visibility);
},
};
}
export function createLifecycle({
state: layerState,
services,
@@ -8,6 +118,7 @@ export function createLifecycle({
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
const methods = {
init(viewer) {
@@ -45,14 +156,15 @@ export function createLifecycle({
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
layerState._moveHandler.setInputAction((movement) => {
if (!layerState._enabled || !layerState._dataSource?.show) return;
const missionEntity = viewer.scene
.drillPick(movement.endPosition, 12)
.map((picked) => picked?.id)
.find((candidate) => parts.selection.entityLaunchId(candidate));
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
hover = createLaunchHover({
viewer,
enabled: () => layerState._enabled && layerState._dataSource?.show,
entityLaunchId: parts.selection.entityLaunchId,
});
layerState._moveHandler.setInputAction(
hover.move,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
// Apply horizon visibility before Cesium traverses and picks the scene.
// A postRender write is one frame late and can remain visibly stale when
// request-on-demand rendering stops after a globe camera move.
@@ -100,6 +212,7 @@ export function createLifecycle({
},
async disable() {
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
releaseContinuousRender('rocket-launches');
@@ -122,6 +235,8 @@ export function createLifecycle({
},
async destroy(viewer) {
hover?.destroy();
hover = null;
layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false;