diff --git a/src/data/selectionDispatcher.js b/src/data/selectionDispatcher.js new file mode 100644 index 000000000..278c07e93 --- /dev/null +++ b/src/data/selectionDispatcher.js @@ -0,0 +1,195 @@ +// One selection dispatcher for the layers that have moved onto the globe +// engine (phase 3 Task 4): one click subscription with picks +// (engine.input.on('click', …, { pick: true, drill: 8 })), the press +// gesture and one Escape listener, instead of a ScreenSpaceEventHandler +// per layer. Owners register as with pickRegistry (registerPickOwner, so +// layers still on their own handlers keep seeing the ownership) plus +// handlers: +// onSelect(id, ctx) a click whose first pick it owns; +// onEmptyClick(ctx) a clean click (short, no drag) on the map or nothing; +// onEscape() Escape. +// The rules are the old handlers': a pick another layer owns is not empty +// space; `passThrough` picks (trails) never take the click, the next pick +// does; a `drillThrough` owner (local ADS-B) takes its object from under +// another layer's; a drag selects nothing; a pointer owned by a tool +// (inputOwnership) selects nothing. Layers not moved yet keep their own +// handlers: the dispatcher never acts on an id it does not own. + +import { isPointerFree as pointerFree } from './inputOwnership.js'; +import { + findPickOwner, + registerPickOwner, + resolvePickId, + unregisterPickOwner, +} from './pickRegistry.js'; +import { + isTrackingClickGesture, + isTrackingSelectionGesture, +} from './trackingClickGesture.js'; + +export const SELECTION_DRILL = 8; + +/** + * @param {import('../globe/contract.js').GlobeEngine} engine + * @param {{ keyTarget?: EventTarget, now?: () => number, isPointerFree?: () => boolean }} [options] + */ +export function createSelectionDispatcher( + engine, + { + keyTarget = globalThis.document, + now = () => performance.now(), + isPointerFree = pointerFree, + } = {}, +) { + const owners = new Map(); + let removers = []; + let destroyed = false; + + // The press gesture, as bindTrackingClickGesture measures it. + let press = null; + let completed = null; + const travel = (position) => { + if (!press || !position) return; + if (press.last) + press.travelPx += Math.hypot( + position.x - press.last.x, + position.y - press.last.y, + ); + press.last = { x: position.x, y: position.y }; + }; + const finish = (position) => { + if (!press) return; + travel(position); + completed = { + travelPx: press.travelPx, + durationMs: Math.max(0, now() - press.startedAt), + }; + press = null; + }; + + const ownerOf = (picked) => { + const id = resolvePickId(picked); + if (id === null) return { id, owner: null }; + for (const [name, owner] of owners) + if (safely(() => owner.owns(id))) return { id, owner: name }; + return { id, owner: findPickOwner(id) }; + }; + + const onClick = (event) => { + if (press) finish(event.position); + const gesture = completed ?? { travelPx: 0, durationMs: 0 }; + completed = null; + if (!isPointerFree()) return; + const ctx = { + position: event.position, + picked: event.picked, + drilled: event.drilled ?? [], + gesture, + }; + const candidates = [event.picked, ...(event.drilled ?? [])].filter( + (picked) => + picked && + picked.id !== undefined && + ![...owners.values()].some((owner) => + safely(() => owner.passThrough?.(picked)), + ), + ); + // Drill results repeat the pick; keep the first of each id. + const seen = new Set(); + const picks = candidates.filter((picked) => { + const id = resolvePickId(picked); + if (id === null || seen.has(id)) return false; + seen.add(id); + return true; + }); + if (!picks.length) { + if (!isTrackingClickGesture(gesture)) return; + for (const owner of [...owners.values()]) owner.onEmptyClick?.(ctx); + return; + } + if (!isTrackingSelectionGesture(gesture)) return; + const first = ownerOf(picks[0]); + if (owners.has(first.owner)) { + owners + .get(first.owner) + .onSelect?.(first.id, { ...ctx, picked: picks[0] }); + return; + } + // Another layer's object on top: a drill-through owner may still take + // its own object from below. + for (const picked of picks.slice(1)) { + const below = ownerOf(picked); + const owner = owners.get(below.owner); + if (owner?.drillThrough) { + owner.onSelect?.(below.id, { ...ctx, picked }); + return; + } + } + }; + + const onKey = (event) => { + if (event.key !== 'Escape') return; + for (const owner of [...owners.values()]) owner.onEscape?.(); + }; + + const attach = () => { + if (removers.length || destroyed) return; + removers = [ + engine.input.on('down', (event) => { + press = { + startedAt: now(), + travelPx: 0, + last: null, + }; + completed = null; + travel(event.position); + }), + engine.input.on('move', (event) => travel(event.endPosition)), + engine.input.on('up', (event) => finish(event.position)), + engine.input.on('click', onClick, { pick: true, drill: SELECTION_DRILL }), + ]; + keyTarget?.addEventListener?.('keydown', onKey); + removers.push(() => keyTarget?.removeEventListener?.('keydown', onKey)); + }; + const detach = () => { + for (const remove of removers.splice(0)) remove(); + }; + + return { + /** + * @param {string} name the owning layer's id + * @param {{ owns: (id: string) => boolean, onSelect?: Function, onEmptyClick?: Function, + * onEscape?: Function, passThrough?: (picked: object) => boolean, drillThrough?: boolean }} owner + * @returns {() => void} unregister + */ + register(name, owner) { + if (destroyed) return () => {}; + if (typeof owner?.owns !== 'function') + throw new TypeError('A selection owner needs owns(id)'); + owners.set(name, owner); + registerPickOwner(name, owner.owns); + attach(); + return () => { + if (owners.get(name) !== owner) return; + owners.delete(name); + unregisterPickOwner(name); + if (!owners.size) detach(); + }; + }, + destroy() { + if (destroyed) return; + destroyed = true; + for (const name of owners.keys()) unregisterPickOwner(name); + owners.clear(); + detach(); + }, + }; +} + +function safely(run) { + try { + return run(); + } catch { + return false; + } +} diff --git a/src/data/selectionDispatcher.test.mjs b/src/data/selectionDispatcher.test.mjs new file mode 100644 index 000000000..98c9440f5 --- /dev/null +++ b/src/data/selectionDispatcher.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createSelectionDispatcher } from './selectionDispatcher.js'; +import { + isOwnedByOtherLayer, + registerPickOwner, + unregisterPickOwner, +} from './pickRegistry.js'; + +function setup(picks = {}) { + const engine = createFakeEngine(); + // Scripted picks by window x: what lies under each column. + engine.fake.script('pick', (screen) => picks[screen.x]?.[0]); + engine.fake.script('drillPick', (screen, limit) => + (picks[screen.x] ?? []).slice(0, limit), + ); + const keys = new EventTarget(); + let now = 0; + const dispatcher = createSelectionDispatcher(engine, { + keyTarget: keys, + now: () => now, + isPointerFree: () => true, + }); + // A press, a release and the engine's click at x (an optional drag). + const click = (x, { travel = 0, ms = 50 } = {}) => { + engine.fake.input('down', { position: { x, y: 10 } }); + if (travel) + engine.fake.input('move', { + startPosition: { x, y: 10 }, + endPosition: { x: x + travel, y: 10 }, + }); + now += ms; + engine.fake.input('up', { position: { x: x + travel, y: 10 } }); + engine.fake.input('click', { position: { x: x + travel, y: 10 } }); + }; + const escape = () => + keys.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + return { engine, dispatcher, click, escape }; +} + +const recorder = () => { + const calls = []; + return { + calls, + handlers: { + onSelect: (id, ctx) => calls.push(['select', id, ctx.picked?.owner]), + onEmptyClick: (ctx) => calls.push(['empty', ctx.gesture.travelPx]), + onEscape: () => calls.push(['escape']), + }, + }; +}; + +test('a click on an owned object selects it; empty space deselects everyone', () => { + const { engine, dispatcher, click } = setup({ + 10: [{ id: 'ae01ce', owner: 'flights-fleet', instance: 1 }], + }); + const flights = recorder(); + const military = recorder(); + dispatcher.register('flights', { + owns: (id) => id === 'ae01ce', + ...flights.handlers, + }); + dispatcher.register('military', { + owns: (id) => id.startsWith('mil'), + ...military.handlers, + }); + click(10); + click(500); + assert.deepEqual(flights.calls, [ + ['select', 'ae01ce', 'flights-fleet'], + ['empty', 0], + ]); + assert.deepEqual(military.calls, [['empty', 0]]); +}); + +test("another layer's pick is not empty space, owned through the shared registry", () => { + const { dispatcher, click } = setup({ 10: [{ id: 'cctv-1' }] }); + registerPickOwner('cctv', (id) => id.startsWith('cctv')); + try { + const flights = recorder(); + dispatcher.register('flights', { + owns: (id) => id === 'x', + ...flights.handlers, + }); + assert.equal( + isOwnedByOtherLayer('cctv', 'x'), + true, + 'registered for legacy handlers', + ); + click(10); + assert.deepEqual(flights.calls, []); + } finally { + unregisterPickOwner('cctv'); + } +}); + +test('trails do not take clicks: the object under a trail is selected', () => { + const { dispatcher, click } = setup({ + 10: [{ id: 'trail-ae01ce', owner: 'flights-trail' }, { id: 'ae01ce' }], + }); + const flights = recorder(); + dispatcher.register('flights', { + owns: (id) => id === 'ae01ce' || id.startsWith('trail-'), + passThrough: (picked) => picked.owner === 'flights-trail', + ...flights.handlers, + }); + click(10); + assert.deepEqual(flights.calls, [['select', 'ae01ce', undefined]]); +}); + +test("a drill-through owner finds its object under another layer's", () => { + const { dispatcher, click } = setup({ + 10: [{ id: 'cctv-1' }, { id: 'adsb:abc' }], + }); + registerPickOwner('cctv', (id) => id.startsWith('cctv')); + try { + const local = recorder(); + dispatcher.register('local-adsb', { + owns: (id) => id.startsWith('adsb:'), + drillThrough: true, + ...local.handlers, + }); + click(10); + assert.deepEqual(local.calls, [['select', 'adsb:abc', undefined]]); + } finally { + unregisterPickOwner('cctv'); + } +}); + +test('a drag selects nothing and deselects nothing; a long press still selects', () => { + const { dispatcher, click } = setup({ 10: [{ id: 'ae01ce' }] }); + const flights = recorder(); + dispatcher.register('flights', { + owns: (id) => id === 'ae01ce', + ...flights.handlers, + }); + click(10, { travel: 20 }); + click(500, { ms: 900 }); + assert.deepEqual(flights.calls, []); + click(10, { ms: 900 }); + assert.deepEqual(flights.calls, [['select', 'ae01ce', undefined]]); +}); + +test('Escape reaches every owner; an owner without handlers only owns', () => { + const { dispatcher, click, escape } = setup({ 10: [{ id: 'quiet-1' }] }); + const flights = recorder(); + dispatcher.register('flights', { owns: () => false, ...flights.handlers }); + dispatcher.register('quiet', { owns: (id) => id.startsWith('quiet') }); + click(10); + escape(); + assert.deepEqual(flights.calls, [['escape']]); +}); + +test('unregister and destroy remove the subscriptions', () => { + const { engine, dispatcher, click, escape } = setup({ 10: [{ id: 'a' }] }); + const flights = recorder(); + const off = dispatcher.register('flights', { + owns: (id) => id === 'a', + ...flights.handlers, + }); + off(); + click(10); + escape(); + assert.deepEqual(flights.calls, []); + dispatcher.register('flights', { + owns: (id) => id === 'a', + ...flights.handlers, + }); + dispatcher.destroy(); + click(10); + assert.deepEqual(flights.calls, []); + assert.equal(isOwnedByOtherLayer('other', 'a'), false); + assert.ok(engine.fake.callsOf('input.on').length >= 1); +});