diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..135543f3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +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. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs new file mode 100644 index 000000000..83a370f26 --- /dev/null +++ b/src/layers/launches/hover.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Event } from 'cesium'; +import { createLaunchHover } from './lifecycle.js'; + +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, + }); + 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'); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 75b0276a3..4d4962242 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,6 +1,75 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +/** Bound hover picking independently of precise click selection. */ +export function createLaunchHover({ + viewer, + enabled, + entityLaunchId, + now = () => performance.now(), + documentRef = globalThis.document, + pointerFree = isPointerFree, +}) { + let moving = false; + let lastPick = -Infinity; + let ownsCursor = false; + const clearCursor = () => { + if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') + viewer.scene.canvas.style.cursor = ''; + ownsCursor = false; + }; + const reset = () => { + clearCursor(); + lastPick = -Infinity; + }; + const start = () => { + moving = true; + reset(); + }; + const end = () => { + moving = false; + reset(); + }; + const visibility = () => { + if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') + reset(); + }; + 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() + ) { + 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(); + }, + reset, + destroy() { + reset(); + removeStart?.(); + removeEnd?.(); + documentRef?.removeEventListener?.('visibilitychange', visibility); + }, + }; +} + export function createLifecycle({ state: layerState, services, @@ -8,6 +77,7 @@ export function createLifecycle({ source, }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + let hover = null; const methods = { init(viewer) { @@ -45,14 +115,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 +171,7 @@ export function createLifecycle({ }, async disable() { + hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; releaseContinuousRender('rocket-launches'); @@ -122,6 +194,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;