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 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.
KML namespace repair locates the opening token once and scans its quoted tag 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.
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.
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
+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 * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js'; 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({ export function createLifecycle({
state: layerState, state: layerState,
services, services,
@@ -8,6 +118,7 @@ export function createLifecycle({
source, source,
}) { }) {
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
const methods = { const methods = {
init(viewer) { init(viewer) {
@@ -45,14 +156,15 @@ export function createLifecycle({
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas, viewer.scene.canvas,
); );
layerState._moveHandler.setInputAction((movement) => { hover = createLaunchHover({
if (!layerState._enabled || !layerState._dataSource?.show) return; viewer,
const missionEntity = viewer.scene enabled: () => layerState._enabled && layerState._dataSource?.show,
.drillPick(movement.endPosition, 12) entityLaunchId: parts.selection.entityLaunchId,
.map((picked) => picked?.id) });
.find((candidate) => parts.selection.entityLaunchId(candidate)); layerState._moveHandler.setInputAction(
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; hover.move,
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE); Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
// Apply horizon visibility before Cesium traverses and picks the scene. // Apply horizon visibility before Cesium traverses and picks the scene.
// A postRender write is one frame late and can remain visibly stale when // A postRender write is one frame late and can remain visibly stale when
// request-on-demand rendering stops after a globe camera move. // request-on-demand rendering stops after a globe camera move.
@@ -100,6 +212,7 @@ export function createLifecycle({
}, },
async disable() { async disable() {
hover?.reset();
layerState._sourceController.abort(); layerState._sourceController.abort();
layerState._enabled = false; layerState._enabled = false;
releaseContinuousRender('rocket-launches'); releaseContinuousRender('rocket-launches');
@@ -122,6 +235,8 @@ export function createLifecycle({
}, },
async destroy(viewer) { async destroy(viewer) {
hover?.destroy();
hover = null;
layerState._sourceController.abort(); layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false; layerState._enabled = false;