From c327a25744a862c3c7ac0b5d6232521406789211 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 18:25:12 +0400 Subject: [PATCH] feat(data): one selection dispatcher src/data/selectionDispatcher.js: one engine click subscription with picks (pick, drill 8), the press gesture (travel and duration, as bindTrackingClickGesture) and one Escape listener for the layers that move onto the engine. Owners register owns(id) (also in pickRegistry, so layers still on their own handlers see the ownership) and onSelect / onEmptyClick / onEscape. Rules of the existing handlers: another layer's pick is not empty space; passThrough picks (trails) never take the click; a drillThrough owner (local ADS-B) takes its object from under another layer's; a drag selects nothing; deselection needs a clean click; a pointer owned by a tool selects nothing. Tested on the fake engine: own / foreign / empty / trail clicks, drill-through, drag and long press, Escape, an owner without handlers, unregister and destroy. No layer uses it yet (the *a tasks move them). --- src/data/selectionDispatcher.js | 195 ++++++++++++++++++++++++++ src/data/selectionDispatcher.test.mjs | 175 +++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 src/data/selectionDispatcher.js create mode 100644 src/data/selectionDispatcher.test.mjs 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); +});