Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 53 additions & 25 deletions modules/core/src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -317,6 +317,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
protected deckRenderer: DeckRenderer | null = null;
protected deckPicker: DeckPicker | null = null;
protected eventManager: EventManager | null = null;
protected eventManagers: Record<string, EventManager> = {};
protected widgetManager: WidgetManager | null = null;
protected tooltip: TooltipWidget | null = null;
protected animationLoop: AnimationLoop | null = null;
Expand Down Expand Up @@ -465,6 +466,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {

this.eventManager?.destroy();
this.eventManager = null;
this.eventManagers = {};

this.widgetManager?.finalize();
this.widgetManager = null;
Expand Down Expand Up @@ -509,12 +511,14 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
height: number;
views: View[];
viewState: ViewStateObject<ViewsT> | null;
eventManagers: Record<string, EventManager>;
} = 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) {
Expand All @@ -526,6 +530,8 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
// 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;
Expand Down Expand Up @@ -658,6 +664,18 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
return this.canvas;
}

/**
* Get the event manager associated with a view or the default Deck canvas.
*/
getEventManager(viewId?: string): EventManager | null {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should console.warn. Fallback makes sense but should be a little less silent on a public method.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should console.warn. Fallback makes sense but should be a little less silent on a public method.

While it would be nice to show developer friendly messages all over the API in cases like this, such message strings would all be adding to the bundle size of the application and since they have no value to end user, we generally avoid adding them unless value is very high.

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 */
Expand Down Expand Up @@ -1046,6 +1064,35 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
return canvas;
}

private _createEventManager(root: HTMLElement): EventManager {
const eventManager = new EventManager(root, {

@charlieforward9 charlieforward9 Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrisgervang does it make sense to refactor my recent mobile tip+tricks work:

CSS guards on the Deck canvas/root element

within cssProps here (rather than forcing user to workaround with external CSS)?

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;

Expand Down Expand Up @@ -1377,33 +1424,14 @@ export default class Deck<ViewsT extends ViewOrViews = null> {

const eventRoot =
this.props.parent?.querySelector<HTMLDivElement>('.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),
Expand Down
83 changes: 78 additions & 5 deletions modules/core/src/lib/view-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export type ViewStateObject<ViewsT extends ViewOrViews> =
| AnyViewStateOf<ViewsT>
| {[viewId: string]: AnyViewStateOf<ViewsT>};

/** Canvas id used by views that do not declare a presentation canvas. */
export const DEFAULT_CANVAS_ID = 'default-canvas';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


type ViewEventManager = {
canvasId: string;
eventManager: EventManager;
};

/** ViewManager props directly supplied by the user */
type ViewManagerProps<ViewsT extends ViewOrViews> = {
views: ViewsT;
Expand All @@ -46,6 +54,8 @@ type ViewManagerProps<ViewsT extends ViewOrViews> = {
pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
width?: number;
height?: number;
/** Event managers keyed by presentation canvas id. */
eventManagers?: Record<string, EventManager>;
};

export default class ViewManager<ViewsT extends View[]> {
Expand All @@ -62,6 +72,8 @@ export default class ViewManager<ViewsT extends View[]> {
private _needsRedraw: string | false;
private _needsUpdate: string | false;
private _eventManager: EventManager;
private _eventManagers: Record<string, EventManager>;
private _viewEventManagers: {[viewId: string]: ViewEventManager};
private _eventCallbacks: {
onViewStateChange?: (params: ViewStateChangeParameters) => void;
onInteractionStateChange?: (state: InteractionState) => void;
Expand Down Expand Up @@ -90,6 +102,8 @@ export default class ViewManager<ViewsT extends View[]> {
this._needsUpdate = 'Initialize';

this._eventManager = props.eventManager;
this._eventManagers = props.eventManagers || {};
this._viewEventManagers = {};
this._eventCallbacks = {
onViewStateChange: props.onViewStateChange,
onInteractionStateChange: props.onInteractionStateChange
Expand Down Expand Up @@ -188,6 +202,14 @@ export default class ViewManager<ViewsT extends View[]> {
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
? this._viewEventManagers[view.id]?.canvasId || view.props.canvasId || DEFAULT_CANVAS_ID
: undefined;
}

/**
* Unproject pixel coordinates on screen onto world coordinates,
* (possibly [lon, lat]) on map.
Expand Down Expand Up @@ -231,6 +253,10 @@ export default class ViewManager<ViewsT extends View[]> {
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
Expand Down Expand Up @@ -298,6 +324,49 @@ export default class ViewManager<ViewsT extends View[]> {
}
}

private _setEventManagers(eventManagers: Record<string, EventManager>): void {
if (this._eventManagers !== eventManagers) {
this._eventManagers = eventManagers;
this.setNeedsUpdate('eventManagers changed');
}
}

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<any> | null};
oldViewEventManagers: {[viewId: string]: ViewEventManager};
} {
const oldControllers = this.controllers;
const oldViewEventManagers = this._viewEventManagers;
this._viewports = [];
this.controllers = {};
this._viewEventManagers = {};
return {oldControllers, oldViewEventManagers};
}

private _getReusableController(
controller: Controller<any> | null | undefined,
oldViewEventManager: ViewEventManager | undefined,
viewEventManager: ViewEventManager
): Controller<any> | null | undefined {
if (
controller &&
(oldViewEventManager?.canvasId !== viewEventManager.canvasId ||
oldViewEventManager?.eventManager !== viewEventManager.eventManager)
) {
controller.finalize();
return null;
}
return controller;
}

private _createController(
view: View,
props: {id: string; type: ConstructorOf<Controller<any>>}
Expand All @@ -306,7 +375,7 @@ export default class ViewManager<ViewsT extends View[]> {

const controller = new Controller({
timeline: this.timeline,
eventManager: this._eventManager,
eventManager: this._getViewEventManager(view).eventManager,
// Set an internal callback that calls the prop callback if provided
onViewStateChange: this._eventCallbacks.onViewStateChange,
onStateChange: this._eventCallbacks.onInteractionStateChange,
Expand Down Expand Up @@ -357,18 +426,22 @@ export default class ViewManager<ViewsT extends View[]> {
private _rebuildViewports(): void {
const {views} = this;

const oldControllers = this.controllers;
this._viewports = [];
this.controllers = {};
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];
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 = oldControllers[view.id];
let oldController = this._getReusableController(
oldControllers[view.id],
oldViewEventManagers[view.id],
viewEventManager
);
const hasController = Boolean(view.controller);
if (hasController && !oldController) {
// When a new controller is added, invalidate all controllers below it so that
Expand Down
6 changes: 6 additions & 0 deletions modules/core/src/views/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export type CommonViewState = TransitionProps;
export type CommonViewProps<ViewState> = {
/** 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* resources such as event managers.
* resources such as event managers. Default: `default-canvas`

*/
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`. */
Expand Down
30 changes: 30 additions & 0 deletions test/modules/core/lib/deck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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,
Expand Down
Loading
Loading