diff --git a/modules/core/src/lib/view-manager.ts b/modules/core/src/lib/view-manager.ts index d801c613ede..4a5456f1f44 100644 --- a/modules/core/src/lib/view-manager.ts +++ b/modules/core/src/lib/view-manager.ts @@ -10,6 +10,7 @@ import type Controller from '../controllers/controller'; import type {ViewStateChangeParameters, InteractionState} from '../controllers/controller'; import type Viewport from '../viewports/viewport'; import type View from '../views/view'; +import type {CanvasContext, PresentationContext} from '@luma.gl/core'; import type {Timeline} from '@luma.gl/engine'; import type {EventManager} from 'mjolnir.js'; import type {ConstructorOf} from '../types/types'; @@ -45,7 +46,44 @@ type ViewEventManager = { eventManager: EventManager; }; -/** ViewManager props directly supplied by the user */ +/** + * Looks up an existing canvas context without duplicating its resize or pixel-size bookkeeping. + * @param canvasId - Presentation canvas to resolve, or undefined to select the default canvas. + * @returns The existing render or presentation context, or null when no canvas matches. + */ +type CanvasContextResolver = (canvasId?: string) => CanvasContext | PresentationContext | null; + +/** + * Restricts viewport selection to one presentation canvas, optionally at a CSS-pixel location. + * Canvas-only filters intentionally omit coordinates so they return every matching viewport. + */ +type ViewportFilter = + | { + /** Presentation canvas whose viewports should be returned. */ + canvasId: string; + } + | { + /** Horizontal CSS-pixel coordinate to match. */ + x: number; + /** Vertical CSS-pixel coordinate to match. */ + y: number; + /** Optional width of the CSS-pixel query rectangle. */ + width?: number; + /** Optional height of the CSS-pixel query rectangle. */ + height?: number; + /** Optional presentation canvas that further restricts the pixel query. */ + canvasId?: string; + }; + +/** CSS-pixel dimensions read directly from an existing canvas context. */ +type CanvasDimensions = { + /** Canvas width in CSS pixels. */ + width: number; + /** Canvas height in CSS pixels. */ + height: number; +}; + +/** Internal view, controller, and canvas-routing properties supplied by Deck. */ type ViewManagerProps = { views: ViewsT; viewState: ViewStateObject | null; @@ -54,10 +92,13 @@ type ViewManagerProps = { pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null; width?: number; height?: number; + /** Resolve existing luma contexts, which own all canvas resize and pixel-size tracking. */ + getCanvasContext?: CanvasContextResolver; /** Event managers keyed by presentation canvas id. */ eventManagers?: Record; }; +/** Builds viewports and routes controllers using their assigned canvas coordinate systems. */ export default class ViewManager { width: number; height: number; @@ -79,7 +120,13 @@ export default class ViewManager { onInteractionStateChange?: (state: InteractionState) => void; }; private _pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null; + /** Context lookup supplied by Deck; context dimensions remain owned and observed by luma. */ + private _getCanvasContext?: CanvasContextResolver; + /** + * Initializes the viewport layout, controller routing, and optional canvas-context lookup. + * @param props - Initial views, view state, event managers, dimensions, and context resolver. + */ constructor( props: ViewManagerProps & { // Initial options @@ -109,6 +156,7 @@ export default class ViewManager { onInteractionStateChange: props.onInteractionStateChange }; this._pickPosition = props.pickPosition; + this._getCanvasContext = props.getCanvasContext; Object.seal(this); @@ -157,16 +205,21 @@ export default class ViewManager { } } - /** Get a set of viewports for a given width and height - * TODO - Intention is for deck.gl to autodeduce width and height and drop the need for props - * @param rect (object, optional) - filter the viewports - * + not provided - return all viewports - * + {x, y} - only return viewports that contain this pixel - * + {x, y, width, height} - only return viewports that overlap with this rectangle + /** + * Returns active viewports, optionally restricted by canvas and/or CSS-pixel coordinates. + * @param filter - A canvas-only selection or a point/rectangle with an optional canvas id. + * @returns Matching viewports in their original rendering order. */ - getViewports(rect?: {x: number; y: number; width?: number; height?: number}): Viewport[] { - if (rect) { - return this._viewports.filter(viewport => viewport.containsPixel(rect)); + getViewports(filter?: ViewportFilter): Viewport[] { + if (filter) { + return this._viewports.filter(viewport => { + // Different canvases can contain the same CSS coordinates. First select the requested + // canvas, then test pixel containment only when coordinates were actually supplied; + // canvas-only queries must retain every viewport on that canvas. + const matchesCanvas = !filter.canvasId || this.getCanvasId(viewport.id) === filter.canvasId; + const matchesPixel = !('x' in filter) || viewport.containsPixel(filter); + return matchesCanvas && matchesPixel; + }); } return this._viewports; } @@ -202,11 +255,15 @@ export default class ViewManager { return this._viewportMap[viewId]; } - /** Return the presentation canvas id assigned to a view. */ + /** + * Resolves the presentation canvas associated with an existing or supplied view. + * @param viewOrViewId - View instance or id whose canvas association should be resolved. + * @returns The assigned canvas id, or undefined when the view does not exist. + */ 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 + ? this._viewEventManagers[view.id]?.canvasId || this._getCanvasIdFromView(view) : undefined; } @@ -331,6 +388,28 @@ export default class ViewManager { } } + /** + * Resolves a view's explicit canvas id, falling back to the existing default canvas context. + * @param view - View whose presentation canvas is being determined. + * @returns The explicit, default-context, or legacy default canvas id. + */ + private _getCanvasIdFromView(view: View): string { + return view.props.canvasId || this._getCanvasContext?.()?.id || DEFAULT_CANVAS_ID; + } + + /** + * Reads the CSS dimensions used to lay out a view and its controller viewport. + * @param view - View whose assigned canvas provides the layout coordinate space. + * @returns Context-owned CSS dimensions, or the manager dimensions without a canvas context. + */ + private _getCanvasDimensions(view: View): CanvasDimensions { + // Viewport layout uses CSS pixels, not framebuffer pixels. Read them directly from the + // observed luma context instead of maintaining a second per-canvas size snapshot. + const canvasContext = this._getCanvasContext?.(this._getCanvasIdFromView(view)); + const [width, height] = canvasContext?.getCSSSize() || [this.width, this.height]; + return {width, height}; + } + private _getViewEventManager(view: View): ViewEventManager { const canvasId = this.getCanvasId(view) || DEFAULT_CANVAS_ID; return { @@ -367,6 +446,12 @@ export default class ViewManager { return controller; } + /** + * Creates a controller that resolves its viewport against the view's assigned canvas. + * @param view - View receiving the controller. + * @param props - Controller identity and implementation class. + * @returns The initialized controller for the view. + */ private _createController( view: View, props: {id: string; type: ConstructorOf>} @@ -382,8 +467,7 @@ export default class ViewManager { makeViewport: viewState => this.getView(view.id)?.makeViewport({ viewState, - width: this.width, - height: this.height + ...this._getCanvasDimensions(view) }), pickPosition: this._pickPosition }); @@ -422,7 +506,7 @@ export default class ViewManager { return null; } - // Rebuilds viewports from descriptors towards a certain window size + /** Rebuilds each viewport and controller using its presentation canvas's CSS dimensions. */ private _rebuildViewports(): void { const {views} = this; @@ -432,10 +516,11 @@ export default class ViewManager { // Create controllers in reverse order, so that views on top receive events first for (let i = views.length; i--; ) { const view = views[i]; + const {width, height} = this._getCanvasDimensions(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}); + const viewport = view.makeViewport({viewState, width, height}); let oldController = this._getReusableController( oldControllers[view.id], diff --git a/test/modules/core/lib/view-manager.spec.ts b/test/modules/core/lib/view-manager.spec.ts index a47040de672..c74e173849c 100644 --- a/test/modules/core/lib/view-manager.spec.ts +++ b/test/modules/core/lib/view-manager.spec.ts @@ -7,6 +7,7 @@ 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'; +import type {CanvasContext} from '@luma.gl/core'; test('ViewManager#constructor', () => { const viewManager = new ViewManager({ @@ -287,6 +288,93 @@ test('ViewManager#routes controllers by canvas event manager', () => { }); /* eslint-disable max-statements */ +test('ViewManager#layouts and filters views by canvas', () => { + const leftEventManager = new EventManager(document.createElement('div')); + const rightEventManager = new EventManager(document.createElement('div')); + const leftView = new MapView({id: 'left', canvasId: 'left-canvas'}); + const rightView = new MapView({id: 'right', canvasId: 'right-canvas'}); + const canvasSizes: Record = { + 'left-canvas': [200, 100], + 'right-canvas': [120, 180] + }; + const canvasContexts = Object.fromEntries( + Object.keys(canvasSizes).map(canvasId => [ + canvasId, + {id: canvasId, getCSSSize: () => canvasSizes[canvasId]} as CanvasContext + ]) + ); + + const viewManager = new ViewManager({ + views: [leftView, rightView], + viewState: { + left: {longitude: -122, latitude: 38, zoom: 10}, + right: {longitude: -74, latitude: 40.7, zoom: 11} + }, + width: 1, + height: 1, + getCanvasContext: canvasId => canvasContexts[canvasId || 'left-canvas'] || null, + eventManager: leftEventManager, + eventManagers: { + 'left-canvas': leftEventManager, + 'right-canvas': rightEventManager + } + }); + + expect(viewManager.getViewport('left')?.width).toBe(200); + expect(viewManager.getViewport('right')?.height).toBe(180); + expect(viewManager.getViewports({x: 1, y: 1, canvasId: 'left-canvas'})).toEqual([ + viewManager.getViewport('left') + ]); + expect(viewManager.getViewports({canvasId: 'left-canvas'})).toEqual([ + viewManager.getViewport('left') + ]); + expect(viewManager.getViewports({canvasId: 'right-canvas'})).toEqual([ + viewManager.getViewport('right') + ]); + expect(viewManager.getViewports({canvasId: 'unknown-canvas'})).toEqual([]); + expect(viewManager.getViewports({x: 1, y: 1, canvasId: 'unknown-canvas'})).toEqual([]); + + const viewports = viewManager.getViewports(); + viewManager.setProps({}); + expect(viewManager.getViewports(), 'unchanged canvas dimensions preserve viewports').toBe( + viewports + ); + + canvasSizes['left-canvas'] = [240, 140]; + viewManager.setNeedsUpdate('Canvas resized'); + viewManager.setProps({}); + expect(viewManager.getViewport('left')?.width, 'updated dimensions rebuild the viewport').toBe( + 240 + ); + + viewManager.finalize(); + leftEventManager.destroy(); + rightEventManager.destroy(); +}); + +test('ViewManager#uses the first canvas for views without an explicit canvas id', () => { + const eventManager = new EventManager(document.createElement('div')); + const canvasContext = { + id: 'first-canvas', + getCSSSize: () => [160, 90] + } as CanvasContext; + const viewManager = new ViewManager({ + views: [new MapView({id: 'main'})], + viewState: {longitude: -122, latitude: 38, zoom: 10}, + width: 1, + height: 1, + getCanvasContext: () => canvasContext, + eventManager + }); + + expect(viewManager.getCanvasId('main')).toBe('first-canvas'); + expect(viewManager.getViewport('main')?.width).toBe(160); + expect(viewManager.getViewports({x: 1, y: 1, canvasId: 'first-canvas'})).toHaveLength(1); + + viewManager.finalize(); + eventManager.destroy(); +}); + test('ViewManager#zero-size', () => { const mainView = new MapView({id: 'main', controller: true});