From 09832dc4850fa951e715752810e3ec82da6b86f3 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 15 Jun 2026 09:37:31 -0700 Subject: [PATCH 1/2] feat(core) prepare view event manager routing --- modules/core/src/lib/deck.ts | 78 +++++++++++------ modules/core/src/lib/view-manager.ts | 98 ++++++++++++++++++++-- modules/core/src/views/view.ts | 6 ++ test/modules/core/lib/deck.spec.ts | 30 +++++++ test/modules/core/lib/view-manager.spec.ts | 60 +++++++++++++ 5 files changed, 242 insertions(+), 30 deletions(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 3c30f5c30fe..9f52a3fe432 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import LayerManager from './layer-manager'; -import ViewManager from './view-manager'; +import ViewManager, {DEFAULT_CANVAS_ID} from './view-manager'; import MapView from '../views/map-view'; import EffectManager from './effect-manager'; import DeckRenderer from './deck-renderer'; @@ -317,6 +317,7 @@ export default class Deck { protected deckRenderer: DeckRenderer | null = null; protected deckPicker: DeckPicker | null = null; protected eventManager: EventManager | null = null; + protected eventManagers: Record = {}; protected widgetManager: WidgetManager | null = null; protected tooltip: TooltipWidget | null = null; protected animationLoop: AnimationLoop | null = null; @@ -465,6 +466,7 @@ export default class Deck { this.eventManager?.destroy(); this.eventManager = null; + this.eventManagers = {}; this.widgetManager?.finalize(); this.widgetManager = null; @@ -509,12 +511,14 @@ export default class Deck { height: number; views: View[]; viewState: ViewStateObject | null; + eventManagers: Record; } = Object.create(this.props); Object.assign(resolvedProps, { views: this._getViews(), width: this.width, height: this.height, - viewState: this._getViewState() + viewState: this._getViewState(), + eventManagers: this.eventManagers }); if (props.device && props.device.id !== this.device?.id) { @@ -526,6 +530,8 @@ export default class Deck { // DOM here might be a bit unexpected but it should be ok for most users. this.canvas?.remove(); this.eventManager?.destroy(); + this.eventManager = null; + this.eventManagers = {}; // ensure we will re-attach ourselves after createDevice callbacks this.canvas = null; @@ -658,6 +664,18 @@ export default class Deck { return this.canvas; } + /** + * Get the event manager associated with a view or the default Deck canvas. + */ + getEventManager(viewId?: string): EventManager | null { + if (!viewId || !this.viewManager) { + return this.eventManager; + } + + const canvasId = this.viewManager.getCanvasId(viewId) || DEFAULT_CANVAS_ID; + return this.eventManagers[canvasId] || this.eventManager; + } + /** Query the object rendered on top at a given point */ async pickObjectAsync(opts: { /** x position in pixels */ @@ -1046,6 +1064,35 @@ export default class Deck { return canvas; } + private _createEventManager(root: HTMLElement): EventManager { + const eventManager = new EventManager(root, { + touchAction: this.props.touchAction, + recognizers: Object.keys(RECOGNIZERS).map((eventName: string) => { + // Resolve recognizer settings + const [RecognizerConstructor, defaultOptions, recognizeWith, requireFailure] = + RECOGNIZERS[eventName]; + const optionsOverride = this.props.eventRecognizerOptions?.[eventName]; + const options = {...defaultOptions, ...optionsOverride, event: eventName}; + return { + recognizer: new RecognizerConstructor(options), + recognizeWith, + requireFailure + }; + }), + events: { + pointerdown: this._onPointerDown, + pointermove: this._onPointerMove, + pointerleave: this._onPointerMove + } + }); + + for (const eventType in EVENT_HANDLERS) { + eventManager.on(eventType, this._onEvent); + } + + return eventManager; + } + private _setCanvasContext(canvasContext: CanvasContext): void { this._canvasContext = canvasContext; @@ -1377,33 +1424,14 @@ export default class Deck { const eventRoot = this.props.parent?.querySelector('.deck-events-root') || this.canvas; - this.eventManager = new EventManager(eventRoot, { - touchAction: this.props.touchAction, - recognizers: Object.keys(RECOGNIZERS).map((eventName: string) => { - // Resolve recognizer settings - const [RecognizerConstructor, defaultOptions, recognizeWith, requireFailure] = - RECOGNIZERS[eventName]; - const optionsOverride = this.props.eventRecognizerOptions?.[eventName]; - const options = {...defaultOptions, ...optionsOverride, event: eventName}; - return { - recognizer: new RecognizerConstructor(options), - recognizeWith, - requireFailure - }; - }), - events: { - pointerdown: this._onPointerDown, - pointermove: this._onPointerMove, - pointerleave: this._onPointerMove - } - }); - for (const eventType in EVENT_HANDLERS) { - this.eventManager.on(eventType, this._onEvent); - } + assert(eventRoot); + this.eventManager = this._createEventManager(eventRoot); + this.eventManagers = {[DEFAULT_CANVAS_ID]: this.eventManager}; this.viewManager = new ViewManager({ timeline, eventManager: this.eventManager, + eventManagers: this.eventManagers, onViewStateChange: this._onViewStateChange.bind(this), onInteractionStateChange: this._onInteractionStateChange.bind(this), pickPosition: this._pickPositionForController.bind(this), diff --git a/modules/core/src/lib/view-manager.ts b/modules/core/src/lib/view-manager.ts index 6575a59f823..91f4428569b 100644 --- a/modules/core/src/lib/view-manager.ts +++ b/modules/core/src/lib/view-manager.ts @@ -37,6 +37,9 @@ export type ViewStateObject = | AnyViewStateOf | {[viewId: string]: AnyViewStateOf}; +/** Canvas id used by views that do not declare a presentation canvas. */ +export const DEFAULT_CANVAS_ID = 'default-canvas'; + /** ViewManager props directly supplied by the user */ type ViewManagerProps = { views: ViewsT; @@ -46,6 +49,8 @@ type ViewManagerProps = { pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null; width?: number; height?: number; + /** Event managers keyed by presentation canvas id. */ + eventManagers?: Record; }; export default class ViewManager { @@ -62,6 +67,9 @@ export default class ViewManager { private _needsRedraw: string | false; private _needsUpdate: string | false; private _eventManager: EventManager; + private _eventManagers: Record; + private _previousEventManagers: Record | null; + private _viewCanvasIds: {[viewId: string]: string}; private _eventCallbacks: { onViewStateChange?: (params: ViewStateChangeParameters) => void; onInteractionStateChange?: (state: InteractionState) => void; @@ -90,6 +98,9 @@ export default class ViewManager { this._needsUpdate = 'Initialize'; this._eventManager = props.eventManager; + this._eventManagers = props.eventManagers || {}; + this._previousEventManagers = null; + this._viewCanvasIds = {}; this._eventCallbacks = { onViewStateChange: props.onViewStateChange, onInteractionStateChange: props.onInteractionStateChange @@ -188,6 +199,12 @@ export default class ViewManager { return this._viewportMap[viewId]; } + /** Return the presentation canvas id assigned to a view. */ + getCanvasId(viewOrViewId: string | View): string | undefined { + const view = typeof viewOrViewId === 'string' ? this.getView(viewOrViewId) : viewOrViewId; + return view ? view.props.canvasId || DEFAULT_CANVAS_ID : undefined; + } + /** * Unproject pixel coordinates on screen onto world coordinates, * (possibly [lon, lat]) on map. @@ -231,6 +248,10 @@ export default class ViewManager { this._pickPosition = props.pickPosition; } + if ('eventManagers' in props) { + this._setEventManagers(props.eventManagers || {}); + } + // Important: avoid invoking _update() inside itself // Nested updates result in unexpected side effects inside _rebuildViewports() // when using auto control in pure-js @@ -298,15 +319,78 @@ export default class ViewManager { } } + private _setEventManagers(eventManagers: Record): void { + if (this._eventManagers === eventManagers) { + return; + } + + const eventManagerIds = Object.keys(eventManagers); + const previousEventManagerIds = Object.keys(this._eventManagers); + if ( + deepEqual(eventManagerIds, previousEventManagerIds, 1) && + eventManagerIds.every(id => eventManagers[id] === this._eventManagers[id]) + ) { + return; + } + + this._previousEventManagers ||= this._eventManagers; + this._eventManagers = eventManagers; + this.setNeedsUpdate('eventManagers changed'); + } + + private _getEventManager(canvasId: string): EventManager { + return this._eventManagers[canvasId] || this._eventManager; + } + + private _startViewportRebuild(): { + oldControllers: {[viewId: string]: Controller | null}; + oldEventManagers: Record; + oldViewCanvasIds: {[viewId: string]: string}; + } { + const oldControllers = this.controllers; + const oldEventManagers = this._previousEventManagers || this._eventManagers; + const oldViewCanvasIds = this._viewCanvasIds; + this._viewports = []; + this.controllers = {}; + this._viewCanvasIds = {}; + this._previousEventManagers = null; + return {oldControllers, oldEventManagers, oldViewCanvasIds}; + } + + private _registerCanvasId(view: View): void { + this._viewCanvasIds[view.id] = this.getCanvasId(view) || DEFAULT_CANVAS_ID; + } + + private _getReusableController( + view: View, + controller: Controller | null | undefined, + oldCanvasId: string | undefined, + oldEventManagers: Record + ): Controller | null | undefined { + if (!controller) { + return controller; + } + + const canvasId = this.getCanvasId(view) || DEFAULT_CANVAS_ID; + const oldEventManager = (oldCanvasId && oldEventManagers[oldCanvasId]) || this._eventManager; + if (oldCanvasId !== canvasId || oldEventManager !== this._getEventManager(canvasId)) { + controller.finalize(); + return null; + } + + return controller; + } + private _createController( view: View, props: {id: string; type: ConstructorOf>} ): Controller { const Controller = props.type; + const canvasId = this.getCanvasId(view) || DEFAULT_CANVAS_ID; const controller = new Controller({ timeline: this.timeline, - eventManager: this._eventManager, + eventManager: this._getEventManager(canvasId), // Set an internal callback that calls the prop callback if provided onViewStateChange: this._eventCallbacks.onViewStateChange, onStateChange: this._eventCallbacks.onInteractionStateChange, @@ -357,18 +441,22 @@ export default class ViewManager { private _rebuildViewports(): void { const {views} = this; - const oldControllers = this.controllers; - this._viewports = []; - this.controllers = {}; + const {oldControllers, oldEventManagers, oldViewCanvasIds} = this._startViewportRebuild(); let invalidateControllers = false; // Create controllers in reverse order, so that views on top receive events first for (let i = views.length; i--; ) { const view = views[i]; + this._registerCanvasId(view); const viewState = this.getViewState(view); const viewport = view.makeViewport({viewState, width: this.width, height: this.height}); - let oldController = oldControllers[view.id]; + let oldController = this._getReusableController( + view, + oldControllers[view.id], + oldViewCanvasIds[view.id], + oldEventManagers + ); const hasController = Boolean(view.controller); if (hasController && !oldController) { // When a new controller is added, invalidate all controllers below it so that diff --git a/modules/core/src/views/view.ts b/modules/core/src/views/view.ts index 01df0e0938f..a24c702a6ea 100644 --- a/modules/core/src/views/view.ts +++ b/modules/core/src/views/view.ts @@ -18,6 +18,12 @@ export type CommonViewState = TransitionProps; export type CommonViewProps = { /** A unique id of the view. In a multi-view use case, this is important for matching view states and place contents into this view. */ id?: string; + /** + * Optional id of the presentation canvas associated with this view. + * Integrations that render views into multiple canvases can use this id to route view-scoped + * resources such as event managers. + */ + canvasId?: string; /** A relative (e.g. `'50%'`) or absolute position. Default `0`. */ x?: number | string; /** A relative (e.g. `'50%'`) or absolute position. Default `0`. */ diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 7a985516d8f..16f23b17c82 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -165,6 +165,36 @@ test('Deck wires mjolnir requireFailure between recognizers', async () => { }); }); +test('Deck#getEventManager resolves the default manager for views', async () => { + await new Promise((resolve, reject) => { + const deck = new Deck({ + device, + width: 1, + height: 1, + views: [new MapView({id: 'main'}), new MapView({id: 'overlay', canvasId: 'overlay'})], + viewState: { + main: {longitude: 0, latitude: 0, zoom: 0}, + overlay: {longitude: 0, latitude: 0, zoom: 0} + }, + layers: [], + onLoad: () => { + try { + const eventManager = (deck as any).eventManager; + expect(deck.getEventManager()).toBe(eventManager); + expect(deck.getEventManager('main')).toBe(eventManager); + expect(deck.getEventManager('overlay')).toBe(eventManager); + expect(Object.keys((deck as any).eventManagers)).toEqual(['default-canvas']); + + deck.finalize(); + resolve(); + } catch (error) { + reject(error); + } + } + }); + }); +}); + test('Deck#abort', async () => { const deck = new Deck({ device, diff --git a/test/modules/core/lib/view-manager.spec.ts b/test/modules/core/lib/view-manager.spec.ts index 96399336465..e720fad6226 100644 --- a/test/modules/core/lib/view-manager.spec.ts +++ b/test/modules/core/lib/view-manager.spec.ts @@ -6,6 +6,7 @@ import {test, expect} from 'vitest'; import {MapView} from '@deck.gl/core'; import ViewManager from '@deck.gl/core/lib/view-manager'; import {equals} from '@math.gl/core'; +import {EventManager} from 'mjolnir.js'; test('ViewManager#constructor', () => { const viewManager = new ViewManager({ @@ -215,6 +216,65 @@ test('ViewManager#update view props', () => { viewManager.finalize(); }); +test('ViewManager#routes controllers by canvas event manager', () => { + const defaultEventManager = new EventManager(document.createElement('div')); + const leftEventManager = new EventManager(document.createElement('div')); + const rightEventManager = new EventManager(document.createElement('div')); + const replacementRightEventManager = new EventManager(document.createElement('div')); + const leftView = new MapView({id: 'left', canvasId: 'left-canvas', controller: true}); + const rightView = new MapView({id: 'right', canvasId: 'right-canvas', controller: true}); + + const viewManager = new ViewManager({ + views: [leftView, rightView], + viewState: { + left: {longitude: -122, latitude: 38, zoom: 10}, + right: {longitude: -74, latitude: 40.7, zoom: 11} + }, + width: 100, + height: 100, + eventManager: defaultEventManager, + eventManagers: { + 'left-canvas': leftEventManager, + 'right-canvas': rightEventManager + } + }); + + expect(viewManager.getCanvasId('left')).toBe('left-canvas'); + expect(viewManager.getCanvasId('right')).toBe('right-canvas'); + expect(viewManager.getCanvasId(new MapView({id: 'default'}))).toBe('default-canvas'); + expect((viewManager.controllers.left as any).eventManager).toBe(leftEventManager); + expect((viewManager.controllers.right as any).eventManager).toBe(rightEventManager); + + const originalRightController = viewManager.controllers.right; + viewManager.setProps({ + eventManagers: { + 'left-canvas': leftEventManager, + 'right-canvas': replacementRightEventManager + } + }); + + expect(viewManager.controllers.right).not.toBe(originalRightController); + expect((viewManager.controllers.left as any).eventManager).toBe(leftEventManager); + expect((viewManager.controllers.right as any).eventManager).toBe(replacementRightEventManager); + + const leftController = viewManager.controllers.left; + const rightController = viewManager.controllers.right; + viewManager.setProps({ + views: [new MapView({id: 'left', canvasId: 'right-canvas', controller: true}), rightView] + }); + + expect(viewManager.getCanvasId('left')).toBe('right-canvas'); + expect(viewManager.controllers.left).not.toBe(leftController); + expect((viewManager.controllers.left as any).eventManager).toBe(replacementRightEventManager); + expect(viewManager.controllers.right).toBe(rightController); + + viewManager.finalize(); + defaultEventManager.destroy(); + leftEventManager.destroy(); + rightEventManager.destroy(); + replacementRightEventManager.destroy(); +}); + /* eslint-disable max-statements */ test('ViewManager#zero-size', () => { const mainView = new MapView({id: 'main', controller: true}); From ff8fefb8796e34fdcba0b78cbab65ee7e634eabb Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 15 Jun 2026 09:38:27 -0700 Subject: [PATCH 2/2] refactor(core) simplify event manager routing --- modules/core/src/lib/view-manager.ts | 87 +++++++++------------- test/modules/core/lib/view-manager.spec.ts | 11 +++ 2 files changed, 47 insertions(+), 51 deletions(-) diff --git a/modules/core/src/lib/view-manager.ts b/modules/core/src/lib/view-manager.ts index 91f4428569b..d801c613ede 100644 --- a/modules/core/src/lib/view-manager.ts +++ b/modules/core/src/lib/view-manager.ts @@ -40,6 +40,11 @@ export type ViewStateObject = /** Canvas id used by views that do not declare a presentation canvas. */ export const DEFAULT_CANVAS_ID = 'default-canvas'; +type ViewEventManager = { + canvasId: string; + eventManager: EventManager; +}; + /** ViewManager props directly supplied by the user */ type ViewManagerProps = { views: ViewsT; @@ -68,8 +73,7 @@ export default class ViewManager { private _needsUpdate: string | false; private _eventManager: EventManager; private _eventManagers: Record; - private _previousEventManagers: Record | null; - private _viewCanvasIds: {[viewId: string]: string}; + private _viewEventManagers: {[viewId: string]: ViewEventManager}; private _eventCallbacks: { onViewStateChange?: (params: ViewStateChangeParameters) => void; onInteractionStateChange?: (state: InteractionState) => void; @@ -99,8 +103,7 @@ export default class ViewManager { this._eventManager = props.eventManager; this._eventManagers = props.eventManagers || {}; - this._previousEventManagers = null; - this._viewCanvasIds = {}; + this._viewEventManagers = {}; this._eventCallbacks = { onViewStateChange: props.onViewStateChange, onInteractionStateChange: props.onInteractionStateChange @@ -202,7 +205,9 @@ export default class ViewManager { /** Return the presentation canvas id assigned to a view. */ getCanvasId(viewOrViewId: string | View): string | undefined { const view = typeof viewOrViewId === 'string' ? this.getView(viewOrViewId) : viewOrViewId; - return view ? view.props.canvasId || DEFAULT_CANVAS_ID : undefined; + return view + ? this._viewEventManagers[view.id]?.canvasId || view.props.canvasId || DEFAULT_CANVAS_ID + : undefined; } /** @@ -320,64 +325,45 @@ export default class ViewManager { } private _setEventManagers(eventManagers: Record): void { - if (this._eventManagers === eventManagers) { - return; - } - - const eventManagerIds = Object.keys(eventManagers); - const previousEventManagerIds = Object.keys(this._eventManagers); - if ( - deepEqual(eventManagerIds, previousEventManagerIds, 1) && - eventManagerIds.every(id => eventManagers[id] === this._eventManagers[id]) - ) { - return; + if (this._eventManagers !== eventManagers) { + this._eventManagers = eventManagers; + this.setNeedsUpdate('eventManagers changed'); } - - this._previousEventManagers ||= this._eventManagers; - this._eventManagers = eventManagers; - this.setNeedsUpdate('eventManagers changed'); } - private _getEventManager(canvasId: string): EventManager { - return this._eventManagers[canvasId] || this._eventManager; + private _getViewEventManager(view: View): ViewEventManager { + const canvasId = this.getCanvasId(view) || DEFAULT_CANVAS_ID; + return { + canvasId, + eventManager: this._eventManagers[canvasId] || this._eventManager + }; } private _startViewportRebuild(): { oldControllers: {[viewId: string]: Controller | null}; - oldEventManagers: Record; - oldViewCanvasIds: {[viewId: string]: string}; + oldViewEventManagers: {[viewId: string]: ViewEventManager}; } { const oldControllers = this.controllers; - const oldEventManagers = this._previousEventManagers || this._eventManagers; - const oldViewCanvasIds = this._viewCanvasIds; + const oldViewEventManagers = this._viewEventManagers; this._viewports = []; this.controllers = {}; - this._viewCanvasIds = {}; - this._previousEventManagers = null; - return {oldControllers, oldEventManagers, oldViewCanvasIds}; - } - - private _registerCanvasId(view: View): void { - this._viewCanvasIds[view.id] = this.getCanvasId(view) || DEFAULT_CANVAS_ID; + this._viewEventManagers = {}; + return {oldControllers, oldViewEventManagers}; } private _getReusableController( - view: View, controller: Controller | null | undefined, - oldCanvasId: string | undefined, - oldEventManagers: Record + oldViewEventManager: ViewEventManager | undefined, + viewEventManager: ViewEventManager ): Controller | null | undefined { - if (!controller) { - return controller; - } - - const canvasId = this.getCanvasId(view) || DEFAULT_CANVAS_ID; - const oldEventManager = (oldCanvasId && oldEventManagers[oldCanvasId]) || this._eventManager; - if (oldCanvasId !== canvasId || oldEventManager !== this._getEventManager(canvasId)) { + if ( + controller && + (oldViewEventManager?.canvasId !== viewEventManager.canvasId || + oldViewEventManager?.eventManager !== viewEventManager.eventManager) + ) { controller.finalize(); return null; } - return controller; } @@ -386,11 +372,10 @@ export default class ViewManager { props: {id: string; type: ConstructorOf>} ): Controller { const Controller = props.type; - const canvasId = this.getCanvasId(view) || DEFAULT_CANVAS_ID; const controller = new Controller({ timeline: this.timeline, - eventManager: this._getEventManager(canvasId), + eventManager: this._getViewEventManager(view).eventManager, // Set an internal callback that calls the prop callback if provided onViewStateChange: this._eventCallbacks.onViewStateChange, onStateChange: this._eventCallbacks.onInteractionStateChange, @@ -441,21 +426,21 @@ export default class ViewManager { private _rebuildViewports(): void { const {views} = this; - const {oldControllers, oldEventManagers, oldViewCanvasIds} = this._startViewportRebuild(); + const {oldControllers, oldViewEventManagers} = this._startViewportRebuild(); let invalidateControllers = false; // Create controllers in reverse order, so that views on top receive events first for (let i = views.length; i--; ) { const view = views[i]; - this._registerCanvasId(view); + const viewEventManager = this._getViewEventManager(view); + this._viewEventManagers[view.id] = viewEventManager; const viewState = this.getViewState(view); const viewport = view.makeViewport({viewState, width: this.width, height: this.height}); let oldController = this._getReusableController( - view, oldControllers[view.id], - oldViewCanvasIds[view.id], - oldEventManagers + oldViewEventManagers[view.id], + viewEventManager ); const hasController = Boolean(view.controller); if (hasController && !oldController) { diff --git a/test/modules/core/lib/view-manager.spec.ts b/test/modules/core/lib/view-manager.spec.ts index e720fad6226..a47040de672 100644 --- a/test/modules/core/lib/view-manager.spec.ts +++ b/test/modules/core/lib/view-manager.spec.ts @@ -245,7 +245,18 @@ test('ViewManager#routes controllers by canvas event manager', () => { expect((viewManager.controllers.left as any).eventManager).toBe(leftEventManager); expect((viewManager.controllers.right as any).eventManager).toBe(rightEventManager); + const originalLeftController = viewManager.controllers.left; const originalRightController = viewManager.controllers.right; + viewManager.setProps({ + eventManagers: { + 'left-canvas': leftEventManager, + 'right-canvas': rightEventManager + } + }); + + expect(viewManager.controllers.left).toBe(originalLeftController); + expect(viewManager.controllers.right).toBe(originalRightController); + viewManager.setProps({ eventManagers: { 'left-canvas': leftEventManager,