diff --git a/CHANGELOG.md b/CHANGELOG.md index a41bf1a1417..7f473132d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ Each version should: Ref: http://keepachangelog.com/en/0.3.0/ --> +## deck.gl v9.4 + +### deck.gl v9.4 Prereleases + +- feat(core,react,docs,test): add multi-canvas rendering with view-bound presentation targets +- fix(core): clear and present canvases that no longer have mapped viewports + ## deck.gl v9.3 ### deck.gl v9.3 Prereleases diff --git a/docs/api-reference/core/deck.md b/docs/api-reference/core/deck.md index e1e34bf66e1..973cd6ac5c7 100644 --- a/docs/api-reference/core/deck.md +++ b/docs/api-reference/core/deck.md @@ -48,6 +48,20 @@ The following properties are used to initialize a `Deck` instance. Any custom va The canvas to render into. Can be either a HTMLCanvasElement or the element id. Will be auto-created if not supplied. +#### `canvases` ((HTMLCanvasElement | String)[], optional) {#canvases} + +Presentation canvases to use for multi-canvas rendering. + +When this prop is defined, Deck renders into an offscreen default context and presents the result into one `PresentationContext` per canvas entry. String entries are resolved as DOM element ids. Views without an explicit [`canvasId`](./view.md#canvasid) render into the first configured canvas. + +Unlike the other initialization settings in this section, `canvases` is maintained when updated with `setProps()`. Deck diffs the array and creates, reuses, or destroys presentation targets as needed. + +Notes: + +* This prop is not compatible with `gl`. +* In multi-canvas mode, each canvas gets its own event manager and controller routing. +* `canvases: []` keeps the offscreen-backed device path active but does not create any presentation targets. + #### `device` ([Device](https://luma.gl/docs/api-reference/core/device)) {#device} luma.gl Device used to manage the application's connection with the GPU. Will be auto-created if not supplied. @@ -616,6 +630,23 @@ Returns: Notes: * See the [canvas](#canvas) prop for more information. +* In multi-canvas mode, this returns the first configured presentation canvas. + +#### `getEventManager` {#geteventmanager} + +Get the event manager for the specified view, or the default canvas if no view id is supplied. + +```js +deck.getEventManager(viewId) +``` + +Parameters: + +* `viewId` (string, optional) - the id of the view whose event manager should be returned + +Returns: + +* An `EventManager` instance, or `null` if deck has not initialized one yet. #### `getViews` {#getviews} @@ -658,6 +689,7 @@ Parameters: + `y` (number) - top of the bounding box in pixels + `width` (number, optional) - width of the bounding box in pixels + `height` (number, optional) - height of the bounding box in pixels + + `canvasId` (string, optional) - limit the search to viewports rendered into the given presentation canvas Returns: @@ -695,13 +727,14 @@ Parameters: Get the closest pickable and visible object at the given screen coordinate. ```ts -await deck.pickObjectAsync({x, y, radius, layerIds, unproject3D}) +await deck.pickObjectAsync({x, y, canvasId, radius, layerIds, unproject3D}) ``` Parameters: * `x` (number) - x position in pixels * `y` (number) - y position in pixels +* `canvasId` (string, optional) - query within the specified presentation canvas in multi-canvas mode * `radius` (number, optional) - radius of tolerance in pixels. Default `0`. * `layerIds` (string[], optional) - a list of layer ids to query from. If not specified, then all pickable and visible layers are queried. * `unproject3D` (boolean, optional) - if `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`. @@ -716,7 +749,7 @@ Returns: Get all pickable and visible objects within a bounding box. ```ts -await deck.pickObjectsAsync({x, y, width, height, layerIds, maxObjects}) +await deck.pickObjectsAsync({x, y, width, height, canvasId, layerIds, maxObjects}) ``` Parameters: @@ -725,6 +758,7 @@ Parameters: * `y` (number) - top of the bouding box in pixels * `width` (number, optional) - width of the bouding box in pixels. Default `1`. * `height` (number, optional) - height of the bouding box in pixels. Default `1`. +* `canvasId` (string, optional) - query within the specified presentation canvas in multi-canvas mode * `layerIds` (string[], optional) - a list of layer ids to query from. If not specified, then all pickable and visible layers are queried. * `maxObjects` (number, optional) - if specified, limits the number of objects that can be returned. @@ -744,13 +778,14 @@ Notes: Get the closest pickable and visible object at the given screen coordinate. ```js -deck.pickObject({x, y, radius, layerIds, unproject3D}) +deck.pickObject({x, y, canvasId, radius, layerIds, unproject3D}) ``` Parameters: * `x` (number) - x position in pixels * `y` (number) - y position in pixels +* `canvasId` (string, optional) - query within the specified presentation canvas in multi-canvas mode * `radius` (number, optional) - radius of tolerance in pixels. Default `0`. * `layerIds` (string[], optional) - a list of layer ids to query from. If not specified, then all pickable and visible layers are queried. * `unproject3D` (boolean, optional) - if `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`. @@ -767,13 +802,14 @@ Returns: Performs deep picking. Finds all close pickable and visible object at the given screen coordinate, even if those objects are occluded by other objects. ```js -deck.pickMultipleObjects({x, y, radius, layerIds, depth, unproject3D}) +deck.pickMultipleObjects({x, y, canvasId, radius, layerIds, depth, unproject3D}) ``` Parameters: * `x` (number) - x position in pixels * `y` (number) - y position in pixels +* `canvasId` (string, optional) - query within the specified presentation canvas in multi-canvas mode * `radius` (number, optional) - radius of tolerance in pixels. Default `0`. * `layerIds` (string[], optional) - a list of layer ids to query from. If not specified, then all pickable and visible layers are queried. * `depth` - Specifies the max number of objects to return. Default `10`. For layers without explicit picking index buffers, only the default depth of 10 unique objects per layer is guaranteed; higher custom depths may return duplicate results for these layers. @@ -796,7 +832,7 @@ Notes: Get all pickable and visible objects within a bounding box. ```js -deck.pickObjects({x, y, width, height, layerIds, maxObjects}) +deck.pickObjects({x, y, width, height, canvasId, layerIds, maxObjects}) ``` Parameters: @@ -805,6 +841,7 @@ Parameters: * `y` (number) - top of the bouding box in pixels * `width` (number, optional) - width of the bouding box in pixels. Default `1`. * `height` (number, optional) - height of the bouding box in pixels. Default `1`. +* `canvasId` (string, optional) - query within the specified presentation canvas in multi-canvas mode * `layerIds` (string[], optional) - a list of layer ids to query from. If not specified, then all pickable and visible layers are queried. * `maxObjects` (number, optional) - if specified, limits the number of objects that can be returned. diff --git a/docs/api-reference/core/view.md b/docs/api-reference/core/view.md index f8a00893185..712000e610d 100644 --- a/docs/api-reference/core/view.md +++ b/docs/api-reference/core/view.md @@ -7,6 +7,7 @@ The `View` class and its subclasses are used to specify where and how your deck. Views allow you to specify: * A unique `id`. +* An optional `canvasId` that selects which presentation canvas renders the view in multi-canvas mode. * The position and extent of the view on the canvas: `x`, `y`, `width`, and `height`. * Certain camera parameters specifying how your data should be projected into this view, e.g. field of view, near/far planes, perspective vs. orthographic, etc. * The [controller](./controller.md) to be used for this view. A controller listens to pointer events and touch gestures, and translates user input into changes in the view state. If enabled, the camera becomes interactive. @@ -26,6 +27,10 @@ Parameters: 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. +#### `canvasId` (string, optional) {#canvasid} + +When [`Deck.canvases`](./deck.md#canvases) is supplied, selects which presentation canvas renders this view. If omitted, the view renders into the first configured canvas. + #### `x` (string | number, optional) {#x} A relative (e.g. `'50%'`) or absolute position. Accepts CSS-like expressions that mix numbers, `%`, `px`, whitespace/parentheses, and `calc()` with `+`/`-` to combine units. Default `0`. diff --git a/docs/api-reference/react/deckgl.md b/docs/api-reference/react/deckgl.md index e7631d1ad47..fb4800eb3b7 100644 --- a/docs/api-reference/react/deckgl.md +++ b/docs/api-reference/react/deckgl.md @@ -49,6 +49,14 @@ const App = (data) => ( `DeckGL` accepts all [Deck](../core/deck.md#properties) properties, with these additional semantics: +#### `canvases` ((HTMLCanvasElement | String)[], optional) {#canvases} + +Presentation canvases to use when rendering a single `DeckGL` instance into multiple DOM canvases. + +This prop mirrors [`Deck.canvases`](../core/deck.md#canvases). When supplied, `DeckGL` creates one DOM host per canvas, binds each [`View`](../core/view.md) to a target canvas through `view.props.canvasId`, and renders view children such as `react-map-gl` maps into the matching canvas host automatically. + +Like [`Deck.canvases`](../core/deck.md#canvases), this prop can be updated after mount. `DeckGL` keeps the per-canvas DOM hosts in sync with the current array. + ### React Context #### `ContextProvider` (React.Component, optional) {#contextprovider} @@ -149,6 +157,8 @@ The containing view of each element is determined as follows: - If the element is a direct child of `DeckGL`, it is positioned inside the default (first) view. - If the element is nested under a `` tag, it is positioned inside the view corresponding to the `id` prop. +In multi-canvas mode, the element is also rendered inside the DOM host for that view's [`canvasId`](../core/view.md#canvasid). + #### Render callbacks diff --git a/docs/developer-guide/views.md b/docs/developer-guide/views.md index ba68c27371d..087af8d705f 100644 --- a/docs/developer-guide/views.md +++ b/docs/developer-guide/views.md @@ -32,6 +32,7 @@ View classes enable applications to specify one or more rectangular viewports an A [View](../api-reference/core/view.md) instance defines the following information: * A unique `id`. +* An optional `canvasId` that selects the presentation canvas in multi-canvas mode. * The position and extent of the view on the canvas: `x`, `y`, `width`, and `height`. These properties (and padding) accept CSS-style expressions that combine numbers, percentages, `px` units, parentheses, and `calc()` addition/subtraction so you can mix relative and absolute measurements like `calc(50% - 10px)`. * Certain camera parameters specifying how your data should be projected into this view, e.g. field of view, near/far planes, perspective vs. orthographic, etc. @@ -297,6 +298,12 @@ deck.gl also supports multiple views by taking a `views` prop that is a list of Views allow the application to specify the position and extent of the viewport (i.e. the target rendering area on the screen) with `x` (left), `y` (top), `width` and `height`. These can be specified in either numbers or CSS-like percentage strings (e.g. `width: '50%'`), which is evaluated at runtime when the canvas resizes. +If [`Deck.canvases`](../api-reference/core/deck.md#canvases) or [`DeckGL.canvases`](../api-reference/react/deckgl.md#canvases) is supplied, each view may also specify a `canvasId`. In that mode: + +* each canvas gets its own presentation target and event manager +* view layout is resolved relative to the assigned canvas, not a global deck rectangle +* controllers, picking, and React view children are scoped to the assigned canvas + Common examples in 3D applications that render a 3D scene multiple times with different "cameras": * To show views from multiple viewpoints (cameras), e.g. in a split screen setup. @@ -379,6 +386,33 @@ function App() { +#### Rendering into Multiple Canvases + +Multi-canvas mode is useful when a page layout needs several independent map surfaces embedded alongside other content, while still sharing one `Deck` instance. + +```tsx +import React from 'react'; +import DeckGL from '@deck.gl/react'; +import {MapView, View} from '@deck.gl/core'; +import {Map} from 'react-map-gl/maplibre'; + +const views = [ + new MapView({id: 'london', canvasId: 'canvas-london', controller: true}), + new MapView({id: 'tokyo', canvasId: 'canvas-tokyo', controller: true}) +]; + + + + + + + + +; +``` + +For a larger example, see the multi-canvas cities test app in the [repository](https://github.com/visgl/deck.gl/tree/master/test/apps/multi-canvas-cities). + ### Using Multiple Views with View States diff --git a/docs/get-started/using-with-react.md b/docs/get-started/using-with-react.md index e7c07dc2d7c..509b1274a29 100644 --- a/docs/get-started/using-with-react.md +++ b/docs/get-started/using-with-react.md @@ -49,6 +49,10 @@ The vis.gl community maintains two React libraries that seamlessly work with dec - `react-map-gl` - a React wrapper for [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/guides) and [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/). Several integration options are discussed in [using with Mapbox](../developer-guide/base-maps/using-with-mapbox.md). - `@vis.gl/react-google-maps` - a React wrapper for [Google Maps JavaScript API](https://developers.google.com/maps/documentation/javascript). See [using with Google Maps](../developer-guide/base-maps/using-with-google-maps.md). +Starting in v9.4, `DeckGL` can also render into multiple canvases with the `canvases` prop. When a `Map` is nested under a ``, it is mounted into the DOM host for that view automatically, including when that view targets a specific [`canvasId`](../api-reference/core/view.md#canvasid). + +See [Views and Projections](../developer-guide/views.md#rendering-into-multiple-canvases) for a full example. + ## Using JSX Layers, Views, and Widgets It is possible to use JSX syntax to create deck.gl layers, views, and widgets as React children of the `DeckGL` React components, instead of providing them as ES6 class instances to the `layers`, `views`, or `widgets` prop, respectively. There are no performance advantages to this syntax but it can allow for a more consistent, React-like coding style. diff --git a/docs/whats-new.md b/docs/whats-new.md index 2da341753ff..b2215ef5ff4 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -4,6 +4,19 @@ This page contains highlights of each deck.gl release. Also check our [vis.gl bl ## deck.gl v9.4 +Release date: In development + +### Multi-canvas rendering + +deck.gl can now render a single `Deck` or `DeckGL` instance into multiple HTML canvases. + +- New [`Deck.canvases`](./api-reference/core/deck.md#canvases) and [`DeckGL.canvases`](./api-reference/react/deckgl.md#canvases) props create one presentation target per canvas while keeping one shared layer stack and one shared offscreen device context. +- Views can opt into a specific target with [`View.canvasId`](./api-reference/core/view.md#canvasid). Controllers, picking, and layout resolution are all scoped to that canvas. +- In React, `View` children such as `react-map-gl` maps automatically render into the correct canvas host, so basemaps stay view-bound without a separate canvas API. +- Unused canvases are now explicitly cleared when views move or are removed, avoiding stale frames during dynamic layouts. + +There is a new multi-canvas cities example in the repository that shows four independently navigable city maps with shared scatterplot interactions and a toggle between `react-map-gl` basemaps and `BasemapLayer` overlays: [test/apps/multi-canvas-cities](https://github.com/visgl/deck.gl/tree/master/test/apps/multi-canvas-cities). + ### Views - Views now support a `parameters` prop for per-view GPU draw state overrides. `GlobeView` uses this to enable back-face culling by default, and applications can override it with `new GlobeView({parameters: {cullMode: 'none'}})`. diff --git a/modules/core/src/lib/canvas-manager.ts b/modules/core/src/lib/canvas-manager.ts new file mode 100644 index 00000000000..6123f681511 --- /dev/null +++ b/modules/core/src/lib/canvas-manager.ts @@ -0,0 +1,188 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import assert from '../utils/assert'; + +import type {Device, PresentationContext} from '@luma.gl/core'; +import type {EventManager} from 'mjolnir.js'; + +/** + * Runtime state for a single presentation canvas in multi-canvas mode. + * @internal + */ +export type CanvasTarget = { + id: string; + canvas: HTMLCanvasElement; + eventRoot: HTMLElement; + presentationContext: PresentationContext; + eventManager: EventManager; + width: number; + height: number; +}; + +/** + * Tracks presentation canvases, their {@link PresentationContext}s and their per-canvas + * {@link EventManager}s for Deck's multi-canvas mode. + * @internal + */ +export default class CanvasManager { + private _createEventManager: (root: HTMLElement) => EventManager; + private _targets: Record = {}; + private _order: string[] = []; + private _eventRootToCanvasId = new WeakMap(); + + constructor(props: {createEventManager: (root: HTMLElement) => EventManager}) { + this._createEventManager = props.createEventManager; + } + + /** The active canvas target registry keyed by canvas id. */ + get targets(): Record { + return this._targets; + } + + /** Canvas ids in presentation order. */ + get order(): string[] { + return this._order; + } + + /** The default canvas id used for views and picks that do not specify one. */ + get defaultCanvasId(): string { + return this._order[0] || 'default-canvas'; + } + + /** The default presentation canvas, if any. */ + get canvas(): HTMLCanvasElement | null { + return this._targets[this.defaultCanvasId]?.canvas || null; + } + + /** The default event manager, if any. */ + get eventManager(): EventManager | null { + return this.eventManagers[this.defaultCanvasId] || null; + } + + /** Event managers keyed by canvas id. */ + get eventManagers(): Record { + return Object.fromEntries( + Object.entries(this._targets).map(([id, target]) => [id, target.eventManager]) + ); + } + + /** Destroy all presentation contexts and event managers. */ + finalize(): void { + for (const target of Object.values(this._targets)) { + target.eventManager.destroy(); + target.presentationContext.destroy(); + } + this._targets = {}; + this._order = []; + this._eventRootToCanvasId = new WeakMap(); + } + + /** + * Diff the configured presentation canvases against the current registry and create, reuse, + * or destroy canvas targets as needed. + */ + sync(props: { + device: Device; + canvases?: (string | HTMLCanvasElement)[]; + useDevicePixels: number | boolean; + }): void { + const normalizedCanvases = this._normalizeCanvasList(props.canvases); + const nextTargets: Record = {}; + const nextOrder: string[] = []; + + for (const {id, canvas} of normalizedCanvases) { + const eventRoot = this._getCanvasEventRoot(canvas); + let target = this._targets[id]; + if (!target || target.canvas !== canvas || target.eventRoot !== eventRoot) { + target?.eventManager.destroy(); + target?.presentationContext.destroy(); + + const presentationContext = props.device.createPresentationContext({ + id, + canvas, + useDevicePixels: props.useDevicePixels, + autoResize: true + }); + target = { + id, + canvas, + eventRoot, + presentationContext, + eventManager: this._createEventManager(eventRoot), + width: canvas.clientWidth || canvas.width, + height: canvas.clientHeight || canvas.height + }; + } + + this._eventRootToCanvasId.set(eventRoot, id); + this._eventRootToCanvasId.set(canvas, id); + nextTargets[id] = target; + nextOrder.push(id); + } + + for (const [id, target] of Object.entries(this._targets)) { + if (!nextTargets[id]) { + target.eventManager.destroy(); + target.presentationContext.destroy(); + } + } + + this._targets = nextTargets; + this._order = nextOrder; + } + + /** Resolve the presentation canvas id that produced a DOM event. */ + getCanvasIdFromEvent(rootElement?: HTMLElement | null): string | undefined { + return rootElement ? this._eventRootToCanvasId.get(rootElement) : undefined; + } + + /** Look up a canvas target by id, defaulting to the first configured canvas. */ + getTarget(canvasId?: string): CanvasTarget | null { + return this._targets[canvasId || this.defaultCanvasId] || null; + } + + /** Return CSS pixel sizes for each active presentation canvas. */ + getMetrics(width: number, height: number): Record { + const metrics: Record = {}; + if (!this._order.length) { + metrics[this.defaultCanvasId] = {width, height}; + return metrics; + } + + for (const [id, target] of Object.entries(this._targets)) { + metrics[id] = {width: target.width, height: target.height}; + } + return metrics; + } + + private _normalizeCanvasList( + canvases: (string | HTMLCanvasElement)[] = [] + ): {id: string; canvas: HTMLCanvasElement}[] { + const ids = new Set(); + return canvases.map((canvasLike, index) => { + let canvas: HTMLCanvasElement | null; + let id: string; + + if (typeof canvasLike === 'string') { + canvas = document.getElementById(canvasLike) as HTMLCanvasElement | null; + assert(canvas, `Canvas with id ${canvasLike} not found`); + id = canvasLike; + } else { + canvas = canvasLike; + id = canvas.id || `deckgl-canvas-${index}`; + } + + assert(!ids.has(id), `Duplicate canvas id ${id}`); + ids.add(id); + + return {id, canvas}; + }); + } + + private _getCanvasEventRoot(canvas: HTMLCanvasElement): HTMLElement { + const eventRoot = canvas.parentElement; + return eventRoot?.dataset.deckCanvasRoot === 'true' ? eventRoot : canvas; + } +} diff --git a/modules/core/src/lib/deck-picker.ts b/modules/core/src/lib/deck-picker.ts index 48d291c8f4c..cd910ff264b 100644 --- a/modules/core/src/lib/deck-picker.ts +++ b/modules/core/src/lib/deck-picker.ts @@ -25,19 +25,25 @@ import type Viewport from '../viewports/viewport'; export type PickByPointOptions = { x: number; y: number; + canvasId?: string; radius?: number; depth?: number; mode?: string; unproject3D?: boolean; + devicePixelRatio?: number; + shaderModuleProps?: any; }; export type PickByRectOptions = { x: number; y: number; + canvasId?: string; width?: number; height?: number; mode?: string; maxObjects?: number | null; + devicePixelRatio?: number; + shaderModuleProps?: any; }; type PickOperationContext = { @@ -219,6 +225,8 @@ export default class DeckPicker { depth = 1, mode = 'query', unproject3D, + devicePixelRatio, + shaderModuleProps, canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects @@ -227,7 +235,7 @@ export default class DeckPicker { emptyInfo: PickingInfo; }> { // Picking starts in CSS pixels, so use the canvas context's current conversion ratio. - const pixelRatio = canvasContext.cssToDeviceRatio(); + const pixelRatio = devicePixelRatio ?? canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); @@ -243,7 +251,10 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] // And compensate for the context's current CSS-to-device ratio. - const devicePixelRange = canvasContext.cssToDevicePixels([x, y], true); + const devicePixelRange = + devicePixelRatio !== undefined + ? this._cssToDevicePixels([x, y], devicePixelRatio) + : canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) @@ -282,7 +293,8 @@ export default class DeckPicker { deviceRect, cullRect, effects, - pass: `picking:${mode}` + pass: `picking:${mode}`, + shaderModuleProps }); pickInfo = getClosestObject({ @@ -316,7 +328,8 @@ export default class DeckPicker { }, cullRect, effects, - pass: `picking:${mode}:z` + pass: `picking:${mode}:z`, + shaderModuleProps }, true ); @@ -383,6 +396,8 @@ export default class DeckPicker { depth = 1, mode = 'query', unproject3D, + devicePixelRatio, + shaderModuleProps, canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects @@ -391,7 +406,7 @@ export default class DeckPicker { emptyInfo: PickingInfo; } { // Keep the sync picking path aligned with the same canvas context state used for drawing. - const pixelRatio = canvasContext.cssToDeviceRatio(); + const pixelRatio = devicePixelRatio ?? canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); @@ -407,7 +422,10 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] // And compensate for the context's current CSS-to-device ratio. - const devicePixelRange = canvasContext.cssToDevicePixels([x, y], true); + const devicePixelRange = + devicePixelRatio !== undefined + ? this._cssToDevicePixels([x, y], devicePixelRatio) + : canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) @@ -446,7 +464,8 @@ export default class DeckPicker { deviceRect, cullRect, effects, - pass: `picking:${mode}` + pass: `picking:${mode}`, + shaderModuleProps }); pickInfo = getClosestObject({ @@ -480,7 +499,8 @@ export default class DeckPicker { }, cullRect, effects, - pass: `picking:${mode}:z` + pass: `picking:${mode}:z`, + shaderModuleProps }, true ); @@ -546,6 +566,8 @@ export default class DeckPicker { height = 1, mode = 'query', maxObjects = null, + devicePixelRatio, + shaderModuleProps, canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects @@ -560,15 +582,21 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for the context's current CSS-to-device ratio. - const pixelRatio = canvasContext.cssToDeviceRatio(); - const leftTop = canvasContext.cssToDevicePixels([x, y], true); + const pixelRatio = devicePixelRatio ?? canvasContext.cssToDeviceRatio(); + const leftTop = + devicePixelRatio !== undefined + ? this._cssToDevicePixels([x, y], devicePixelRatio) + : canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location - const rightBottom = canvasContext.cssToDevicePixels([x + width, y + height], true); + const rightBottom = + devicePixelRatio !== undefined + ? this._cssToDevicePixels([x + width, y + height], devicePixelRatio) + : canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; @@ -588,7 +616,8 @@ export default class DeckPicker { deviceRect, cullRect: {x, y, width, height}, effects, - pass: `picking:${mode}` + pass: `picking:${mode}`, + shaderModuleProps }); const pickInfos = getUniqueObjects(pickedResult); @@ -651,6 +680,8 @@ export default class DeckPicker { height = 1, mode = 'query', maxObjects = null, + devicePixelRatio, + shaderModuleProps, canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects @@ -665,15 +696,21 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for the context's current CSS-to-device ratio. - const pixelRatio = canvasContext.cssToDeviceRatio(); - const leftTop = canvasContext.cssToDevicePixels([x, y], true); + const pixelRatio = devicePixelRatio ?? canvasContext.cssToDeviceRatio(); + const leftTop = + devicePixelRatio !== undefined + ? this._cssToDevicePixels([x, y], devicePixelRatio) + : canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location - const rightBottom = canvasContext.cssToDevicePixels([x + width, y + height], true); + const rightBottom = + devicePixelRatio !== undefined + ? this._cssToDevicePixels([x + width, y + height], devicePixelRatio) + : canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; @@ -693,7 +730,8 @@ export default class DeckPicker { deviceRect, cullRect: {x, y, width, height}, effects, - pass: `picking:${mode}` + pass: `picking:${mode}`, + shaderModuleProps }); const pickInfos = getUniqueObjects(pickedResult); @@ -751,6 +789,7 @@ export default class DeckPicker { onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; + shaderModuleProps?: any; }): Promise<{ pickedColors: Uint8Array; decodePickingColor: PickingColorDecoder; @@ -767,6 +806,7 @@ export default class DeckPicker { onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; + shaderModuleProps?: any; }, pickZ: true ): Promise<{ @@ -784,7 +824,8 @@ export default class DeckPicker { deviceRect, cullRect, effects, - pass + pass, + shaderModuleProps }: { deviceRect: Rect; pass: string; @@ -794,6 +835,7 @@ export default class DeckPicker { onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; + shaderModuleProps?: any; }, pickZ: boolean = false ): Promise<{ @@ -812,6 +854,7 @@ export default class DeckPicker { cullRect, effects, pass, + shaderModuleProps, pickZ, preRenderStats: {}, isPicking: true @@ -921,6 +964,7 @@ export default class DeckPicker { onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; + shaderModuleProps?: any; }): { pickedColors: Uint8Array; decodePickingColor: PickingColorDecoder; @@ -940,6 +984,7 @@ export default class DeckPicker { onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; + shaderModuleProps?: any; }, pickZ: true ): { @@ -957,7 +1002,8 @@ export default class DeckPicker { deviceRect, cullRect, effects, - pass + pass, + shaderModuleProps }: { deviceRect: Rect; pass: string; @@ -967,6 +1013,7 @@ export default class DeckPicker { onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; + shaderModuleProps?: any; }, pickZ: boolean = false ): { @@ -985,6 +1032,7 @@ export default class DeckPicker { cullRect, effects, pass, + shaderModuleProps, pickZ, preRenderStats: {}, isPicking: true @@ -1072,4 +1120,26 @@ export default class DeckPicker { return {x, y, width, height}; } + + private _cssToDevicePixels( + pixel: [number, number], + pixelRatio: number + ): {x: number; y: number; width: number; height: number} { + const [width, height] = this.device.canvasContext!.getDrawingBufferSize(); + const x = Math.min(Math.round(pixel[0] * pixelRatio), width - 1); + const yHigh = Math.max(0, height - 1 - Math.round(pixel[1] * pixelRatio)); + + let temporary = Math.min(Math.round((pixel[0] + 1) * pixelRatio), width - 1); + const xHigh = temporary === width - 1 ? temporary : temporary - 1; + + temporary = Math.max(0, height - 1 - Math.round((pixel[1] + 1) * pixelRatio)); + temporary = temporary === 0 ? temporary : temporary + 1; + + return { + x, + y: temporary, + width: Math.max(xHigh - x + 1, 1), + height: Math.max(yHigh - temporary + 1, 1) + }; + } } diff --git a/modules/core/src/lib/deck-renderer.ts b/modules/core/src/lib/deck-renderer.ts index c34d40df355..d9f1366aa90 100644 --- a/modules/core/src/lib/deck-renderer.ts +++ b/modules/core/src/lib/deck-renderer.ts @@ -65,6 +65,8 @@ export default class DeckRenderer { onViewportActive: (viewport: Viewport) => void; effects: Effect[]; target?: Framebuffer | null; + shaderModuleProps?: any; + renderPassId?: string; layerFilter?: LayerFilter; clearStack?: boolean; clearCanvas?: boolean; diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 9cff26cb0e9..4a93686fe02 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -11,6 +11,7 @@ import DeckPicker from './deck-picker'; import {Widget} from './widget'; import {WidgetManager} from './widget-manager'; import {TooltipWidget} from './tooltip-widget'; +import CanvasManager, {type CanvasTarget} from './canvas-manager'; import log from '../utils/log'; import {deepEqual} from '../utils/deep-equal'; import typedArrayManager from '../utils/typed-array-manager'; @@ -136,6 +137,17 @@ export type DeckProps = { * Will be auto-created if not supplied. */ canvas?: HTMLCanvasElement | string | null; + /** + * Presentation canvases to use for multi-canvas rendering. + * + * When this prop is defined, Deck renders into an offscreen default context and presents the + * result into one `PresentationContext` per canvas entry. String entries are resolved as + * DOM element ids. Views without an explicit `canvasId` render into the first configured canvas. + * + * This prop is not compatible with `gl`. Deck diffs this array on `setProps` and creates, + * reuses, or destroys presentation targets as the configured canvases change. + */ + canvases?: (string | HTMLCanvasElement)[]; /** Use an existing luma.gl GPU device. @note If not supplied, a new device will be created using props.deviceProps */ device?: Device | null; @@ -258,6 +270,7 @@ const defaultProps: DeckProps = { deviceProps: {} as DeviceProps, gl: null, canvas: null, + canvases: undefined, layers: [], effects: [], views: null, @@ -317,6 +330,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; @@ -361,18 +375,22 @@ export default class Deck { private _pointerDownPickSequence: number = 0; private _needsRedraw: false | string = 'Initial render'; + private _canvasManager = new CanvasManager({createEventManager: root => this._createEventManager(root)}); + private _ownedCanvas: HTMLCanvasElement | null = null; private _pickRequest: { mode: string; event: MjolnirPointerEvent | null; x: number; y: number; radius: number; + canvasId?: string; unproject3D?: boolean; } = { mode: 'hover', x: -1, y: -1, radius: 0, + canvasId: undefined, event: null, unproject3D: false }; @@ -384,12 +402,22 @@ export default class Deck { private _lastPointerDownInfo: PickingInfo | null = null; private _lastPointerDownInfoPromise: Promise | null = null; + private get _canvasTargets(): Record { + return this._canvasManager.targets; + } + + private get _canvasTargetOrder(): string[] { + return this._canvasManager.order; + } + constructor(props: DeckProps) { const initialProps = props; // @ts-ignore views this.props = {...defaultProps, ...props}; props = this.props; + this._validateCanvasConfiguration(props); + if (props.viewState && props.initialViewState) { log.warn( 'View state tracking is disabled. Use either `initialViewState` for auto update or `viewState` for manual update.' @@ -448,31 +476,14 @@ export default class Deck { this._lastPointerDownInfo = null; this._lastPointerDownInfoPromise = null; - this.layerManager?.finalize(); - this.layerManager = null; + this._teardownManagers(); + this._destroyCanvasTargets(); - this.viewManager?.finalize(); - this.viewManager = null; - - this.effectManager?.finalize(); - this.effectManager = null; - - this.deckRenderer?.finalize(); - this.deckRenderer = null; - - this.deckPicker?.finalize(); - this.deckPicker = null; - - this.eventManager?.destroy(); - this.eventManager = null; - - this.widgetManager?.finalize(); - this.widgetManager = null; - - if (!this.props.canvas && !this.props.device && !this.props.gl && this.canvas) { + if (!this._isMultiCanvasMode() && this.canvas && this.canvas === this._ownedCanvas) { // remove internally created canvas this.canvas.parentElement?.removeChild(this.canvas); this.canvas = null; + this._ownedCanvas = null; } this._canvasContext = null; } @@ -480,6 +491,7 @@ export default class Deck { /** Partially update props */ setProps(props: DeckProps): void { this.stats.get('setProps Time').timeStart(); + const previousCanvases = this.props.canvases; if ('onLayerHover' in props) { log.removed('onLayerHover', 'onHover')(); @@ -498,8 +510,24 @@ export default class Deck { // Merge with existing props Object.assign(this.props, props); + this._validateCanvasConfiguration(this.props); this._validateInternalPickingMode(); + if ( + this.device && + !this.props.device && + !this.props.gl && + this._isMultiCanvasProp(previousCanvases) !== this._isMultiCanvasMode() + ) { + this._rebuildDeckOwnedDevice(); + this.stats.get('setProps Time').timeEnd(); + return; + } + + if (this.device) { + this._syncCanvasTargets(); + } + // Update CSS size of canvas this._setCanvasSize(this.props); @@ -509,12 +537,16 @@ export default class Deck { height: number; views: View[]; viewState: ViewStateObject | null; + canvasMetrics: Record; + eventManagers: Record; } = Object.create(this.props); Object.assign(resolvedProps, { views: this._getViews(), width: this.width, height: this.height, - viewState: this._getViewState() + viewState: this._getViewState(), + canvasMetrics: this._getCanvasMetrics(), + eventManagers: this.eventManagers }); if (props.device && props.device.id !== this.device?.id) { @@ -542,8 +574,11 @@ export default class Deck { // Update the animation loop this.animationLoop?.setProps(resolvedProps); - if (props.useDevicePixels !== undefined && this._canvasContext?.setProps) { - this._canvasContext.setProps({useDevicePixels: props.useDevicePixels}); + if (props.useDevicePixels !== undefined) { + this._canvasContext?.setProps?.({useDevicePixels: props.useDevicePixels}); + for (const target of Object.values(this._canvasTargets)) { + target.presentationContext.setProps({useDevicePixels: props.useDevicePixels}); + } } // If initialized, update sub manager props @@ -648,22 +683,46 @@ export default class Deck { /** Get a list of viewports that are currently rendered. * @param rect If provided, only returns viewports within the given bounding box. */ - getViewports(rect?: {x: number; y: number; width?: number; height?: number}): Viewport[] { + getViewports(rect?: { + x: number; + y: number; + width?: number; + height?: number; + canvasId?: string; + }): Viewport[] { assert(this.viewManager); return this.viewManager.getViewports(rect); } - /** Get the current canvas element. */ + /** + * Get the current canvas element. + * + * In multi-canvas mode this returns the first configured presentation canvas. + */ getCanvas(): HTMLCanvasElement | null { return this.canvas; } + /** + * Get the event manager associated with the specified view or the default canvas. + * + * In multi-canvas mode, controllers are bound to one event manager per presentation canvas. + */ + getEventManager(viewId?: string): EventManager | null { + if (!viewId || !this.viewManager) { + return this.eventManager; + } + return this.eventManagers[this.viewManager.getCanvasId(viewId) || ''] || this.eventManager; + } + /** Query the object rendered on top at a given point */ async pickObjectAsync(opts: { /** x position in pixels */ x: number; /** y position in pixels */ y: number; + /** Canvas id when querying a presented canvas in multi-canvas mode. */ + canvasId?: string; /** Radius of tolerance in pixels. Default `0`. */ radius?: number; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ @@ -688,6 +747,8 @@ export default class Deck { width?: number; /** Height of the bounding box in pixels. Default `1` */ height?: number; + /** Canvas id when querying a presented canvas in multi-canvas mode. */ + canvasId?: string; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ layerIds?: string[]; /** If specified, limits the number of objects that can be returned. */ @@ -705,6 +766,8 @@ export default class Deck { x: number; /** y position in pixels */ y: number; + /** Canvas id when querying a presented canvas in multi-canvas mode. */ + canvasId?: string; /** Radius of tolerance in pixels. Default `0`. */ radius?: number; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ @@ -727,6 +790,8 @@ export default class Deck { y: number; /** Radius of tolerance in pixels. Default `0`. */ radius?: number; + /** Canvas id when querying a presented canvas in multi-canvas mode. */ + canvasId?: string; /** Specifies the max number of objects to return. Default `10`. */ depth?: number; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ @@ -751,6 +816,8 @@ export default class Deck { width?: number; /** Height of the bounding box in pixels. Default `1` */ height?: number; + /** Canvas id when querying a presented canvas in multi-canvas mode. */ + canvasId?: string; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ layerIds?: string[]; /** If specified, limits the number of objects that can be returned. */ @@ -763,13 +830,23 @@ export default class Deck { * Internal method used by controllers to pick 3D position at a screen coordinate * @private */ - private _pickPositionForController(x: number, y: number): {coordinate?: number[]} | null { + private _pickPositionForController( + x: number, + y: number, + viewId?: string + ): {coordinate?: number[]} | null { const internalPickingMode = this._getInternalPickingMode(); if (internalPickingMode !== 'sync') { return null; } - return this.pickObject({x, y, radius: 0, unproject3D: true}); + return this.pickObject({ + x, + y, + radius: 0, + unproject3D: true, + canvasId: viewId ? this.viewManager?.getCanvasId(viewId) : undefined + }); } /** Experimental @@ -855,6 +932,7 @@ export default class Deck { return { x, y, + canvasId: opts.canvasId, radius: this.props.pickingRadius, unproject3D: this._shouldUnproject3D(layers), ...opts @@ -872,6 +950,7 @@ export default class Deck { private _getLastPointerDownPickingInfo( x: number, y: number, + canvasId?: string, layers = this.layerManager?.getLayers() || [] ): PickingInfo { return this.deckPicker!.getLastPickedObject( @@ -879,7 +958,7 @@ export default class Deck { x, y, layers, - viewports: this.getViewports({x, y}) + viewports: this.getViewports({x, y, canvasId}) }, this._lastPointerDownInfo ) as PickingInfo; @@ -953,19 +1032,31 @@ export default class Deck { assert(this.deckPicker); const {stats} = this; + const canvasId = this._isMultiCanvasMode() + ? opts.canvasId || this._getDefaultCanvasId() + : opts.canvasId; + const canvasTarget = this._getCanvasTarget(canvasId); + const devicePixelRatio = canvasTarget?.presentationContext.cssToDeviceRatio(); stats.get('Pick Count').incrementCount(); stats.get(statKey).timeStart(); + this._resizeForCanvasTarget(canvasId); const infos = this.deckPicker[method]({ // layerManager, viewManager and effectManager are always defined if deckPicker is layers: this.layerManager!.getLayers(opts), views: this.viewManager!.getViews(), - viewports: this.getViewports(opts), + viewports: this.getViewports({ + ...(opts as {x: number; y: number; width?: number; height?: number}), + canvasId + }), onViewportActive: this.layerManager!.activateViewport, effects: this.effectManager!.getEffects(), ...opts, - canvasContext: this._canvasContext || undefined + canvasId, + canvasContext: this._canvasContext || undefined, + devicePixelRatio, + shaderModuleProps: devicePixelRatio ? {project: {devicePixelRatio}} : undefined }); stats.get(statKey).timeEnd(); @@ -995,19 +1086,31 @@ export default class Deck { assert(this.deckPicker); const {stats} = this; + const canvasId = this._isMultiCanvasMode() + ? opts.canvasId || this._getDefaultCanvasId() + : opts.canvasId; + const canvasTarget = this._getCanvasTarget(canvasId); + const devicePixelRatio = canvasTarget?.presentationContext.cssToDeviceRatio(); stats.get('Pick Count').incrementCount(); stats.get(statKey).timeStart(); + this._resizeForCanvasTarget(canvasId); const infos = this.deckPicker[method]({ // layerManager, viewManager and effectManager are always defined if deckPicker is layers: this.layerManager!.getLayers(opts), views: this.viewManager!.getViews(), - viewports: this.getViewports(opts), + viewports: this.getViewports({ + ...(opts as {x: number; y: number; width?: number; height?: number}), + canvasId + }), onViewportActive: this.layerManager!.activateViewport, effects: this.effectManager!.getEffects(), ...opts, - canvasContext: this._canvasContext || undefined + canvasId, + canvasContext: this._canvasContext || undefined, + devicePixelRatio, + shaderModuleProps: devicePixelRatio ? {project: {devicePixelRatio}} : undefined }); stats.get(statKey).timeEnd(); @@ -1015,6 +1118,186 @@ export default class Deck { return infos; } + private _isMultiCanvasProp( + canvases: DeckProps['canvases'] | Required>['canvases'] + ): boolean { + return canvases !== undefined; + } + + private _isMultiCanvasMode(): boolean { + return this._isMultiCanvasProp(this.props.canvases); + } + + private _getDefaultCanvasId(): string { + return this._canvasManager.defaultCanvasId; + } + + private _validateCanvasConfiguration(props: DeckProps): void { + if (!this._isMultiCanvasProp(props.canvases)) { + return; + } + + if (props.gl) { + throw new Error( + '`canvases` is not supported with `gl`. Provide neither and let Deck create the device.' + ); + } + + if (props.device?.canvasContext && !props.device.getDefaultCanvasContext().offscreenCanvas) { + throw new Error( + '`canvases` requires an offscreen-backed default canvas context when using an external device.' + ); + } + } + + private _createEventManager(root: HTMLElement): EventManager { + const eventManager = new EventManager(root, { + touchAction: this.props.touchAction, + recognizers: Object.keys(RECOGNIZERS).map((eventName: string) => { + const [RecognizerConstructor, defaultOptions, recognizeWith, requestFailure] = + RECOGNIZERS[eventName]; + const optionsOverride = this.props.eventRecognizerOptions?.[eventName]; + const options = {...defaultOptions, ...optionsOverride, event: eventName}; + return { + recognizer: new RecognizerConstructor(options), + recognizeWith, + requestFailure + }; + }), + events: { + pointerdown: this._onPointerDown, + pointermove: this._onPointerMove, + pointerleave: this._onPointerMove + } + }); + + for (const eventType in EVENT_HANDLERS) { + eventManager.on(eventType, this._onEvent); + } + return eventManager; + } + + private _destroyCanvasTargets(): void { + this._canvasManager.finalize(); + this.eventManagers = {}; + this.eventManager = null; + if (this._isMultiCanvasMode()) { + this.canvas = null; + } + } + + private _syncCanvasTargets(): void { + if (!this.device) { + return; + } + + if (!this._isMultiCanvasMode()) { + this._destroyCanvasTargets(); + return; + } + + this._canvasManager.sync({ + device: this.device, + canvases: this.props.canvases, + useDevicePixels: this.props.useDevicePixels + }); + this.eventManagers = this._canvasManager.eventManagers; + this.eventManager = this._canvasManager.eventManager; + this.canvas = this._canvasManager.canvas; + } + + /** Return per-canvas CSS pixel sizes used to resolve view layouts. */ + private _getCanvasMetrics(): Record { + return this._isMultiCanvasMode() + ? this._canvasManager.getMetrics(this.width, this.height) + : {[this._getDefaultCanvasId()]: {width: this.width, height: this.height}}; + } + /** Resolve the presentation canvas id that produced a deck-managed DOM event. */ + private _getCanvasIdFromEvent(event?: {rootElement?: HTMLElement | null} | null): string | undefined { + return this._canvasManager.getCanvasIdFromEvent(event?.rootElement); + } + + /** Look up the presentation target for a canvas id in multi-canvas mode. */ + private _getCanvasTarget(canvasId?: string): CanvasTarget | null { + if (!this._isMultiCanvasMode()) { + return null; + } + return this._canvasManager.getTarget(canvasId); + } + + /** Resize the offscreen default canvas context to match a presentation target. */ + private _resizeForCanvasTarget(canvasId?: string): void { + const target = this._getCanvasTarget(canvasId); + if (!target || !this.device?.canvasContext) { + return; + } + + const [width, height] = target.presentationContext.getDrawingBufferSize(); + this.device.canvasContext.setDrawingBufferSize(width, height); + } + + private _teardownManagers(): void { + this.layerManager?.finalize(); + this.layerManager = null; + + this.viewManager?.finalize(); + this.viewManager = null; + + this.effectManager?.finalize(); + this.effectManager = null; + + this.deckRenderer?.finalize(); + this.deckRenderer = null; + + this.deckPicker?.finalize(); + this.deckPicker = null; + + if (!this._isMultiCanvasMode()) { + this.eventManager?.destroy(); + } + this.eventManager = null; + this.eventManagers = {}; + + this.widgetManager?.finalize(); + this.widgetManager = null; + } + + private _rebuildDeckOwnedDevice(): void { + const ownedCanvas = this._ownedCanvas; + this.animationLoop?.stop(); + this.animationLoop?.destroy(); + this.animationLoop = null; + this._teardownManagers(); + this._destroyCanvasTargets(); + this.device = null; + this.canvas = null; + + if (ownedCanvas) { + ownedCanvas.remove(); + this._ownedCanvas = null; + } + + const deviceOrPromise = this._createDevice(this.props); + this.animationLoop = this._createAnimationLoop(deviceOrPromise, this.props); + this.animationLoop.start(); + } + + private _createDeviceCanvas(props: DeckProps): HTMLCanvasElement | OffscreenCanvas { + if (this._isMultiCanvasMode()) { + const OffscreenCanvasConstructor = globalThis.OffscreenCanvas; + if (!OffscreenCanvasConstructor) { + throw new Error('`canvases` requires OffscreenCanvas support.'); + } + const width = + typeof props.width === 'number' && Number.isFinite(props.width) ? props.width : 1; + const height = + typeof props.height === 'number' && Number.isFinite(props.height) ? props.height : 1; + return new OffscreenCanvasConstructor(width, height); + } + + return this._createCanvas(props); + } + /** Resolve props.canvas to element */ private _createCanvas(props: DeckProps): HTMLCanvasElement { let canvas = props.canvas; @@ -1039,6 +1322,9 @@ export default class Deck { } const parent = props.parent || document.body; parent.appendChild(canvas); + this._ownedCanvas = canvas; + } else { + this._ownedCanvas = null; } Object.assign(canvas.style, props.style); @@ -1093,6 +1379,9 @@ export default class Deck { /** Updates canvas width and/or height, if provided as props */ private _setCanvasSize(props: Required>): void { + if (this._isMultiCanvasMode()) { + return; + } if (!this.canvas) { return; } @@ -1117,6 +1406,10 @@ export default class Deck { * canvases. Attached WebGL contexts still need Deck to mirror external drawing-buffer changes. */ private _updateCanvasSize(canvasContext: CanvasContext | null = this._canvasContext): void { + if (this._isMultiCanvasMode()) { + this._updateCanvasMetrics(); + return; + } const {canvas} = this; const [newWidth, newHeight] = canvasContext ? // The canvas context owns the authoritative CSS size after resize/DPR observation. @@ -1129,13 +1422,54 @@ export default class Deck { this.width = newWidth; // @ts-expect-error private assign to read-only property this.height = newHeight; - this.viewManager?.setProps({width: newWidth, height: newHeight}); + this.viewManager?.setProps({ + width: newWidth, + height: newHeight, + canvasMetrics: {[this._getDefaultCanvasId()]: {width: newWidth, height: newHeight}} + }); // Make sure that any new layer gets initialized with the current viewport this.layerManager?.activateViewport(this.getViewports()[0]); this.props.onResize({width: newWidth, height: newHeight}, canvasContext || undefined); } } + private _updateCanvasMetrics(): void { + const defaultCanvasId = this._getDefaultCanvasId(); + let resized = false; + + for (const target of Object.values(this._canvasTargets)) { + const width = target.canvas.clientWidth || target.canvas.width; + const height = target.canvas.clientHeight || target.canvas.height; + if (width !== target.width || height !== target.height) { + target.width = width; + target.height = height; + resized = true; + } + } + + const defaultTarget = this._canvasTargets[defaultCanvasId]; + const newWidth = defaultTarget?.width || 0; + const newHeight = defaultTarget?.height || 0; + if (newWidth !== this.width || newHeight !== this.height) { + // @ts-expect-error private assign to read-only property + this.width = newWidth; + // @ts-expect-error private assign to read-only property + this.height = newHeight; + resized = true; + this.props.onResize({width: newWidth, height: newHeight}); + } + + if (resized) { + this._needsRedraw = 'Canvas resized'; + this.viewManager?.setProps({ + width: this.width, + height: this.height, + canvasMetrics: this._getCanvasMetrics() + }); + this.layerManager?.activateViewport(this.getViewports()[0]); + } + } + private _onCanvasContextResize( canvasContext: CanvasContext, opts: {syncDrawingBuffer?: boolean} = {} @@ -1166,7 +1500,7 @@ export default class Deck { return new AnimationLoop({ device: deviceOrPromise, // TODO v9 - autoResizeDrawingBuffer: !gl, // do not auto resize external context + autoResizeDrawingBuffer: !gl && !this._isMultiCanvasProp(props.canvases), // do not auto resize external or multi-canvas contexts autoResizeViewport: false, // @ts-expect-error luma.gl needs to accept Promise return value onInitialize: context => this._setDevice(context.device), @@ -1215,7 +1549,7 @@ export default class Deck { createCanvasContext: { ...defaultCanvasProps, ...canvasContextProps, - canvas: this._createCanvas(props), + canvas: this._createDeviceCanvas(props), useDevicePixels: this.props.useDevicePixels, autoResize: true } @@ -1257,10 +1591,12 @@ export default class Deck { /** Internal use only: event handler for pointerdown */ _onPointerMove = (event: MjolnirPointerEvent) => { const {_pickRequest} = this; + const canvasId = this._getCanvasIdFromEvent(event); if (event.type === 'pointerleave') { _pickRequest.x = -1; _pickRequest.y = -1; _pickRequest.radius = 0; + _pickRequest.canvasId = canvasId; } else if (event.leftButton || event.rightButton) { // Do not trigger onHover callbacks if mouse button is down. return; @@ -1274,6 +1610,7 @@ export default class Deck { _pickRequest.x = pos.x; _pickRequest.y = pos.y; _pickRequest.radius = this.props.pickingRadius; + _pickRequest.canvasId = canvasId; } if (this.layerManager) { @@ -1294,6 +1631,7 @@ export default class Deck { _pickRequest.x, _pickRequest.y, { + canvasId: _pickRequest.canvasId, radius: _pickRequest.radius, mode: _pickRequest.mode }, @@ -1303,6 +1641,7 @@ export default class Deck { const hoverPickSequence = ++this._hoverPickSequence; _pickRequest.event = null; + _pickRequest.canvasId = undefined; if (!internalPickingMode) { return; @@ -1324,9 +1663,17 @@ export default class Deck { } private _updateCursor(): void { + const cursor = this.props.getCursor(this.cursorState); + if (this._isMultiCanvasMode()) { + for (const target of Object.values(this._canvasTargets)) { + target.canvas.style.cursor = cursor; + } + return; + } + const container = this.props.parent || this.canvas; if (container) { - container.style.cursor = this.props.getCursor(this.cursorState); + container.style.cursor = cursor; } } @@ -1339,6 +1686,13 @@ export default class Deck { return; } + if (this._isMultiCanvasMode()) { + this._syncCanvasTargets(); + } else if (!this.canvas) { + // if external context... + this.canvas = this.device.canvasContext?.canvas as HTMLCanvasElement; + } + this._setDeviceCanvasContext(device, { syncDrawingBuffer: Boolean(this.props.gl && this.props.device !== device) }); @@ -1375,35 +1729,20 @@ export default class Deck { timeline.play(); this.animationLoop.attachTimeline(timeline); - 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, requestFailure] = - RECOGNIZERS[eventName]; - const optionsOverride = this.props.eventRecognizerOptions?.[eventName]; - const options = {...defaultOptions, ...optionsOverride, event: eventName}; - return { - recognizer: new RecognizerConstructor(options), - recognizeWith, - requestFailure - }; - }), - events: { - pointerdown: this._onPointerDown, - pointermove: this._onPointerMove, - pointerleave: this._onPointerMove - } - }); - for (const eventType in EVENT_HANDLERS) { - this.eventManager.on(eventType, this._onEvent); + if (!this._isMultiCanvasMode()) { + const eventRoot = + this.props.parent?.querySelector('.deck-events-root') || this.canvas; + this.eventManager = eventRoot ? this._createEventManager(eventRoot) : null; + this.eventManagers = this.eventManager + ? {[this._getDefaultCanvasId()]: this.eventManager} + : {}; } this.viewManager = new ViewManager({ timeline, eventManager: this.eventManager, + eventManagers: this.eventManagers, + canvasMetrics: this._getCanvasMetrics(), onViewStateChange: this._onViewStateChange.bind(this), onInteractionStateChange: this._onInteractionStateChange.bind(this), pickPosition: this._pickPositionForController.bind(this), @@ -1462,6 +1801,8 @@ export default class Deck { views?: {[viewId: string]: View}; pass?: string; effects?: Effect[]; + shaderModuleProps?: any; + renderPassId?: string; clearStack?: boolean; clearCanvas?: boolean; } @@ -1480,7 +1821,43 @@ export default class Deck { effects: this.effectManager!.getEffects(), ...renderOptions }; - this.deckRenderer?.renderLayers(opts); + + if ( + this._isMultiCanvasMode() && + opts.pass === 'screen' && + !opts.target && + this._canvasTargetOrder.length + ) { + for (const canvasId of this._canvasTargetOrder) { + const canvasViewports = opts.viewports.filter( + viewport => this.viewManager!.getCanvasId(viewport.id) === canvasId + ); + if (!canvasViewports.length) { + this._clearCanvasTarget(canvasId, `screen-${canvasId}`); + continue; + } + + const target = this._canvasTargets[canvasId]; + this._resizeForCanvasTarget(canvasId); + const framebuffer = target.presentationContext.getCurrentFramebuffer(); + this.deckRenderer?.renderLayers({ + ...opts, + shaderModuleProps: { + ...opts.shaderModuleProps, + project: { + ...opts.shaderModuleProps?.project, + devicePixelRatio: target.presentationContext.cssToDeviceRatio() + } + }, + renderPassId: `screen-${canvasId}`, + target: framebuffer, + viewports: canvasViewports + }); + target.presentationContext.present(); + } + } else { + this.deckRenderer?.renderLayers(opts); + } if (opts.pass === 'screen') { // This method could be called when drawing to picking buffer, texture etc. @@ -1494,6 +1871,29 @@ export default class Deck { this.props.onAfterRender({device, gl}); } + /** Clear and present a canvas that currently has no mapped viewports. */ + private _clearCanvasTarget(canvasId: string, renderPassId: string): void { + const target = this._canvasTargets[canvasId]; + if (!target || !this.device?.canvasContext) { + return; + } + + this._resizeForCanvasTarget(canvasId); + const framebuffer = target.presentationContext.getCurrentFramebuffer(); + const [width, height] = this.device.canvasContext.getDrawingBufferSize(); + const renderPass = this.device.beginRenderPass({ + id: renderPassId, + framebuffer, + parameters: {viewport: [0, 0, width, height]}, + clearColor: [0, 0, 0, 0], + clearDepth: 1, + clearStencil: 0 + }); + renderPass.end(); + this.device.submit(); + target.presentationContext.present(); + } + // Callbacks private _onRenderFrame() { @@ -1558,6 +1958,7 @@ export default class Deck { _onEvent = (event: MjolnirGestureEvent) => { const eventHandlerProp = EVENT_HANDLERS[event.type]; const pos = event.offsetCenter; + const canvasId = this._getCanvasIdFromEvent(event); if (!eventHandlerProp || !pos || !this.layerManager) { return; @@ -1575,10 +1976,10 @@ export default class Deck { event.type === 'click' && this._shouldUnproject3D(layers) ? this._getFirstPickedInfo( this._pickPointSync( - this._getPointPickOptions(pos.x, pos.y, {unproject3D: true}, layers) + this._getPointPickOptions(pos.x, pos.y, {unproject3D: true, canvasId}, layers) ) ) - : this._getLastPointerDownPickingInfo(pos.x, pos.y, layers); + : this._getLastPointerDownPickingInfo(pos.x, pos.y, canvasId, layers); this._dispatchPickingEvent(info, event); return; @@ -1586,7 +1987,7 @@ export default class Deck { const pointerDownInfoPromise = this._lastPointerDownInfoPromise || - Promise.resolve(this._getLastPointerDownPickingInfo(pos.x, pos.y, layers)); + Promise.resolve(this._getLastPointerDownPickingInfo(pos.x, pos.y, canvasId, layers)); pointerDownInfoPromise .then(info => { @@ -1598,6 +1999,7 @@ export default class Deck { /** Internal use only: evnet handler for pointerdown */ _onPointerDown = (event: MjolnirPointerEvent) => { const pos = event.offsetCenter; + const canvasId = this._getCanvasIdFromEvent(event); if (!pos) { return; } @@ -1614,6 +2016,7 @@ export default class Deck { const pickedInfo = this._pickPointSync({ x: pos.x, y: pos.y, + canvasId, radius: this.props.pickingRadius }); const info = this._getFirstPickedInfo(pickedInfo); @@ -1622,7 +2025,9 @@ export default class Deck { return; } - const pickPromise = this._pickPointAsync(this._getPointPickOptions(pos.x, pos.y, {}, layers)) + const pickPromise = this._pickPointAsync( + this._getPointPickOptions(pos.x, pos.y, {canvasId}, layers) + ) .then(pickResult => this._getFirstPickedInfo(pickResult)) .then(info => { if (pointerDownPickSequence === this._pointerDownPickSequence) { @@ -1634,7 +2039,7 @@ export default class Deck { this.props.onError?.(error); const fallbackInfo = this.deckPicker && this.viewManager - ? this._getLastPointerDownPickingInfo(pos.x, pos.y, layers) + ? this._getLastPointerDownPickingInfo(pos.x, pos.y, canvasId, layers) : ({} as PickingInfo); if (pointerDownPickSequence === this._pointerDownPickSequence) { this._lastPointerDownInfo = fallbackInfo; diff --git a/modules/core/src/lib/tooltip-widget.ts b/modules/core/src/lib/tooltip-widget.ts index ae18eaa38d9..8726a994736 100644 --- a/modules/core/src/lib/tooltip-widget.ts +++ b/modules/core/src/lib/tooltip-widget.ts @@ -80,7 +80,8 @@ export class TooltipWidget extends Widget { return; } const displayInfo = getTooltip(info); - this.setTooltip(displayInfo, info.x, info.y); + const {x, y} = this.widgetManager?.getTooltipPosition(info) || info; + this.setTooltip(displayInfo, x, y); } setTooltip(displayInfo: TooltipContent, x?: number, y?: number): void { diff --git a/modules/core/src/lib/view-manager.ts b/modules/core/src/lib/view-manager.ts index 6575a59f823..158c9086c78 100644 --- a/modules/core/src/lib/view-manager.ts +++ b/modules/core/src/lib/view-manager.ts @@ -43,9 +43,13 @@ type ViewManagerProps = { viewState: ViewStateObject | null; onViewStateChange?: (params: ViewStateChangeParameters>) => void; onInteractionStateChange?: (state: InteractionState) => void; - pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null; + pickPosition?: (x: number, y: number, viewId?: string) => {coordinate?: number[]} | null; width?: number; height?: number; + /** CSS pixel sizes for each presentation canvas, keyed by canvas id. */ + canvasMetrics?: Record; + /** Per-canvas event managers, keyed by canvas id. */ + eventManagers?: Record; }; export default class ViewManager { @@ -61,18 +65,23 @@ export default class ViewManager { private _isUpdating: boolean; private _needsRedraw: string | false; private _needsUpdate: string | false; - private _eventManager: EventManager; + private _eventManager: EventManager | null; + private _eventManagers: Record; private _eventCallbacks: { onViewStateChange?: (params: ViewStateChangeParameters) => void; onInteractionStateChange?: (state: InteractionState) => void; }; - private _pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null; + private _pickPosition?: (x: number, y: number, viewId?: string) => {coordinate?: number[]} | null; + private _canvasMetrics: Record; + private _viewCanvasIds: {[viewId: string]: string}; + private _viewportsByCanvasId: {[canvasId: string]: Viewport[]}; + private _eventManagersChanged: boolean; constructor( props: ViewManagerProps & { // Initial options timeline: Timeline; - eventManager: EventManager; + eventManager: EventManager | null; } ) { // List of view descriptors, gets re-evaluated when width/height changes @@ -90,11 +99,16 @@ export default class ViewManager { this._needsUpdate = 'Initialize'; this._eventManager = props.eventManager; + this._eventManagers = props.eventManagers || {}; this._eventCallbacks = { onViewStateChange: props.onViewStateChange, onInteractionStateChange: props.onInteractionStateChange }; this._pickPosition = props.pickPosition; + this._canvasMetrics = props.canvasMetrics || {}; + this._viewCanvasIds = {}; + this._viewportsByCanvasId = {}; + this._eventManagersChanged = false; Object.seal(this); @@ -149,12 +163,14 @@ export default class ViewManager { * + 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 + * + {canvasId} - limit the search to the specified presentation canvas */ - getViewports(rect?: {x: number; y: number; width?: number; height?: number}): Viewport[] { + getViewports(rect?: {x: number; y: number; width?: number; height?: number; canvasId?: string}): Viewport[] { + const viewports = rect?.canvasId ? this._viewportsByCanvasId[rect.canvasId] || [] : this._viewports; if (rect) { - return this._viewports.filter(viewport => viewport.containsPixel(rect)); + return viewports.filter(viewport => viewport.containsPixel(rect)); } - return this._viewports; + return viewports; } /** Get a map of all views */ @@ -188,6 +204,12 @@ export default class ViewManager { return this._viewportMap[viewId]; } + /** Return the presentation canvas id currently assigned to a view. */ + getCanvasId(viewOrViewId: string | View): string | undefined { + const viewId = typeof viewOrViewId === 'string' ? viewOrViewId : viewOrViewId.id; + return this._viewCanvasIds[viewId]; + } + /** * Unproject pixel coordinates on screen onto world coordinates, * (possibly [lon, lat]) on map. @@ -231,6 +253,14 @@ export default class ViewManager { this._pickPosition = props.pickPosition; } + if ('canvasMetrics' in props) { + this._setCanvasMetrics(props.canvasMetrics || {}); + } + + 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,25 +328,63 @@ export default class ViewManager { } } + private _setCanvasMetrics(canvasMetrics: Record): void { + if (!deepEqual(canvasMetrics, this._canvasMetrics, 1)) { + this._canvasMetrics = canvasMetrics; + this.setNeedsUpdate('canvasMetrics changed'); + } + } + + private _setEventManagers(eventManagers: Record): void { + if (!deepEqual(Object.keys(eventManagers), Object.keys(this._eventManagers), 1)) { + this._eventManagers = eventManagers; + this._eventManagersChanged = true; + this.setNeedsUpdate('eventManagers changed'); + return; + } + + for (const id in eventManagers) { + if (this._eventManagers[id] !== eventManagers[id]) { + this._eventManagers = eventManagers; + this._eventManagersChanged = true; + this.setNeedsUpdate(`eventManager changed for ${id}`); + return; + } + } + } + + private _getCanvasMetrics(view: View): {width: number; height: number; canvasId: string} { + const canvasIds = Object.keys(this._canvasMetrics); + const canvasId = view.props.canvasId || canvasIds[0] || 'default-canvas'; + const metrics = this._canvasMetrics[canvasId]; + return { + canvasId, + width: metrics?.width ?? this.width, + height: metrics?.height ?? this.height + }; + } + private _createController( view: View, props: {id: string; type: ConstructorOf>} ): Controller { const Controller = props.type; + const eventManager = + this._eventManagers[this._viewCanvasIds[view.id]] || this._eventManager; const controller = new Controller({ timeline: this.timeline, - eventManager: this._eventManager, + eventManager, // Set an internal callback that calls the prop callback if provided onViewStateChange: this._eventCallbacks.onViewStateChange, onStateChange: this._eventCallbacks.onInteractionStateChange, makeViewport: viewState => this.getView(view.id)?.makeViewport({ viewState, - width: this.width, - height: this.height + width: this._getCanvasMetrics(view).width, + height: this._getCanvasMetrics(view).height }), - pickPosition: this._pickPosition + pickPosition: (x, y) => this._pickPosition?.(x, y, view.id) }); return controller; @@ -358,18 +426,29 @@ export default class ViewManager { const {views} = this; const oldControllers = this.controllers; + const oldViewCanvasIds = this._viewCanvasIds; + const eventManagersChanged = this._eventManagersChanged; this._viewports = []; this.controllers = {}; + this._viewCanvasIds = {}; + this._viewportsByCanvasId = {}; + this._eventManagersChanged = false; 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 {canvasId, width, height} = this._getCanvasMetrics(view); + this._viewCanvasIds[view.id] = canvasId; 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 = oldControllers[view.id]; const hasController = Boolean(view.controller); + if (oldController && (eventManagersChanged || oldViewCanvasIds[view.id] !== canvasId)) { + oldController.finalize(); + oldController = null; + } if (hasController && !oldController) { // When a new controller is added, invalidate all controllers below it so that // events are registered in the correct order @@ -386,6 +465,8 @@ export default class ViewManager { if (viewport) { this._viewports.unshift(viewport); + this._viewportsByCanvasId[canvasId] ||= []; + this._viewportsByCanvasId[canvasId].unshift(viewport); } } diff --git a/modules/core/src/lib/widget-manager.ts b/modules/core/src/lib/widget-manager.ts index cbcec21a8b1..dd1e7f2305b 100644 --- a/modules/core/src/lib/widget-manager.ts +++ b/modules/core/src/lib/widget-manager.ts @@ -125,6 +125,24 @@ export class WidgetManager { } } + /** Resolve tooltip coordinates relative to the shared widget root. */ + getTooltipPosition(info: PickingInfo): {x: number; y: number} { + const {x, y, viewport} = info; + const parentRect = this.parentElement?.getBoundingClientRect(); + const deck = this.deck as any; + const canvasId = viewport && deck.viewManager?.getCanvasId(viewport.id); + const canvas = (canvasId && deck._canvasTargets?.[canvasId]?.canvas) || deck.getCanvas?.(); + if (!parentRect || !canvas) { + return {x, y}; + } + + const rect = canvas.getBoundingClientRect(); + return { + x: x + rect.left - parentRect.left, + y: y + rect.top - parentRect.top + }; + } + onEvent(info: PickingInfo, event: MjolnirGestureEvent) { const eventHandlerProp = EVENT_HANDLERS[event.type]; if (!eventHandlerProp) { @@ -253,8 +271,8 @@ export class WidgetManager { } private _updateContainers() { - const canvasWidth = this.deck.width; - const canvasHeight = this.deck.height; + const canvasWidth = this.parentElement?.clientWidth || this.deck.width; + const canvasHeight = this.parentElement?.clientHeight || this.deck.height; for (const id in this.containers) { const viewport = this.lastViewports[id] || null; const visible = id === ROOT_CONTAINER_ID || viewport; diff --git a/modules/core/src/passes/layers-pass.ts b/modules/core/src/passes/layers-pass.ts index 90b166f2a81..7d2ae7fee64 100644 --- a/modules/core/src/passes/layers-pass.ts +++ b/modules/core/src/passes/layers-pass.ts @@ -36,6 +36,7 @@ const WEBGPU_DEFAULT_DRAW_PARAMETERS: RenderPipelineParameters = { export type LayersPassRenderOptions = { /** @deprecated TODO v9 recommend we rename this to framebuffer to minimize confusion */ target?: Framebuffer | null; + renderPassId?: string; isPicking?: boolean; pass: string; layers: Layer[]; @@ -107,6 +108,7 @@ export default class LayersPass extends Pass { } const renderPass = this.device.beginRenderPass({ + id: options.renderPassId || options.pass, framebuffer, parameters, clearColor: clearColor as NumberArray4, diff --git a/modules/core/src/views/view.ts b/modules/core/src/views/view.ts index 01df0e0938f..1dd1d4f4545 100644 --- a/modules/core/src/views/view.ts +++ b/modules/core/src/views/view.ts @@ -18,6 +18,13 @@ 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; + /** + * The id of the presentation canvas this view should render into when `Deck` is using + * multi-canvas presentation. + * + * When not supplied, the view renders into the first configured 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`. */ diff --git a/modules/react/src/deckgl.ts b/modules/react/src/deckgl.ts index 26a209b0414..0592243c8b1 100644 --- a/modules/react/src/deckgl.ts +++ b/modules/react/src/deckgl.ts @@ -4,6 +4,7 @@ import * as React from 'react'; import {createElement, useRef, useState, useMemo, useEffect, useImperativeHandle} from 'react'; +import {createPortal} from 'react-dom'; import {Deck} from '@deck.gl/core'; import useIsomorphicLayoutEffect from './utils/use-isomorphic-layout-effect'; @@ -34,6 +35,8 @@ export type DeckGLProps = Omit< DeckProps, 'width' | 'height' | 'gl' | 'parent' | 'canvas' | '_customRender' > & { + /** Presentation canvases to use when rendering a single Deck instance into multiple DOM canvases. */ + canvases?: (string | HTMLCanvasElement)[]; Deck?: typeof Deck; width?: string | number; height?: string | number; @@ -51,6 +54,106 @@ export type DeckGLRef = { pickMultipleObjects: Deck['pickMultipleObjects']; }; +type CanvasHost = { + id: string; + canvas?: HTMLCanvasElement; +}; + +/** Normalize the React `canvases` prop into ids plus optional DOM canvas handles. */ +function getCanvasHosts(canvases?: (string | HTMLCanvasElement)[]): CanvasHost[] { + return (canvases || []).map((canvas, index) => + typeof canvas === 'string' + ? {id: canvas} + : { + id: canvas.id || `deckgl-canvas-${index}`, + canvas + } + ); +} + +/** Mount an individual multi-canvas host, optionally adopting an existing canvas element. */ +function MultiCanvasHost({ + host, + canvasStyle, + children +}: { + host: CanvasHost; + canvasStyle: React.CSSProperties; + children?: React.ReactNode[]; +}) { + const hostRef = useRef(null); + const restoreRef = useRef<{parent: ParentNode | null; nextSibling: ChildNode | null} | null>( + null + ); + + useIsomorphicLayoutEffect(() => { + if (!host.canvas || !hostRef.current) { + return undefined; + } + + if (!restoreRef.current) { + restoreRef.current = { + parent: host.canvas.parentNode, + nextSibling: host.canvas.nextSibling + }; + } + + if (host.canvas.parentElement !== hostRef.current) { + hostRef.current.insertBefore(host.canvas, hostRef.current.firstChild); + } + + return () => { + const restore = restoreRef.current; + if (restore?.parent) { + restore.parent.insertBefore(host.canvas!, restore.nextSibling); + } + restoreRef.current = null; + }; + }, [host.canvas]); + + useIsomorphicLayoutEffect(() => { + if (!host.canvas) { + return undefined; + } + + Object.assign(host.canvas.style, canvasStyle, { + position: 'absolute', + zIndex: '1', + width: '100%', + height: '100%' + }); + + return undefined; + }, [host.canvas, canvasStyle]); + + return createElement( + 'div', + { + ref: hostRef, + id: `${host.id}-host`, + className: 'deck-canvas-host', + 'data-deck-canvas-root': 'true', + style: {position: 'relative', width: '100%', height: '100%', overflow: 'hidden'} + }, + ...(host.canvas + ? (children || []) + : [ + createElement('canvas', { + key: `canvas-${host.id}`, + id: host.id, + style: { + ...canvasStyle, + position: 'absolute', + zIndex: 1, + width: '100%', + height: '100%' + } + }), + ...(children || []) + ]) + ); +} + function getRefHandles( thisRef: DeckInstanceRef ): DeckGLRef { @@ -126,6 +229,8 @@ function DeckGLWithRef( // DOM refs const containerRef = useRef(null); const canvasRef = useRef(null); + const isMultiCanvas = props.canvases !== undefined; + const canvasHosts = getCanvasHosts(props.canvases); // extract any deck.gl layers masquerading as react elements from props.children const jsxProps = useMemo( @@ -172,12 +277,15 @@ function DeckGLWithRef( width: '100%', height: '100%', parent: containerRef.current, - canvas: canvasRef.current, layers: jsxProps.layers, onViewStateChange: handleViewStateChange, onInteractionStateChange: handleInteractionStateChange }; + if (!isMultiCanvas) { + forwardProps.canvas = canvasRef.current; + } + if (jsxProps.views) { forwardProps.views = jsxProps.views; } @@ -197,7 +305,7 @@ function DeckGLWithRef( } return forwardProps; - }, [props]); + }, [props, isMultiCanvas]); useEffect(() => { const DeckClass = props.Deck || Deck; @@ -205,7 +313,7 @@ function DeckGLWithRef( thisRef.deck = createDeckInstance(thisRef, DeckClass, { ...deckProps, parent: containerRef.current, - canvas: canvasRef.current + canvas: isMultiCanvas ? undefined : canvasRef.current }); return () => thisRef.deck?.finalize(); @@ -265,33 +373,61 @@ function DeckGLWithRef( ContextProvider }); - const canvas = createElement('canvas', { - key: 'canvas', - id: id || 'deckgl-overlay', - ref: canvasRef, - style: canvasStyle - }); - - const eventRoot = createElement( - 'div', - { - key: 'deck-events-root', - className: 'deck-events-root', - style: {width, height} - }, - [canvas, childrenUnderViews] - ); - const widgetRoot = createElement('div', { key: 'deck-widgets-root', - className: 'deck-widgets-root' + className: 'deck-widgets-root', + style: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + overflow: 'hidden' + } }); + let content: React.ReactNode; + if (isMultiCanvas) { + content = canvasHosts.map(host => + createElement(MultiCanvasHost, { + key: `deck-canvas-host-${host.id}`, + host, + canvasStyle, + children: childrenUnderViews[host.id] + }) + ); + } else { + const canvas = createElement('canvas', { + key: 'canvas', + id: id || 'deckgl-overlay', + ref: canvasRef, + style: canvasStyle + }); + + content = createElement( + 'div', + { + key: 'deck-events-root', + className: 'deck-events-root', + style: {width, height} + }, + [canvas, ...Object.values(childrenUnderViews).flat()] + ); + } + // Render deck.gl as the last child thisRef.control = createElement( 'div', {id: `${id || 'deckgl'}-wrapper`, ref: containerRef, style: containerStyle}, - [eventRoot, widgetRoot] + [ + content, + ...Object.entries(childrenUnderViews) + .filter(([canvasId]) => !canvasHosts.find(host => host.id === canvasId)) + .flatMap(([canvasId, canvasChildren]) => { + const targetContainer = + thisRef.deck?.getCanvas()?.parentElement || containerRef.current || document.body; + return createPortal(canvasChildren, targetContainer, `deck-canvas-portal-${canvasId}`); + }), + widgetRoot + ] ); } diff --git a/modules/react/src/utils/deckgl-context.ts b/modules/react/src/utils/deckgl-context.ts index ed7e2fe802a..f0ac133c669 100644 --- a/modules/react/src/utils/deckgl-context.ts +++ b/modules/react/src/utils/deckgl-context.ts @@ -5,7 +5,7 @@ import type {Deck, DeckProps, Viewport, Widget} from '@deck.gl/core'; export type DeckGLContextValue = { viewport: Viewport; container: HTMLElement; - eventManager: EventManager; + eventManager: EventManager | null; onViewStateChange: DeckProps['onViewStateChange']; deck?: Deck; widgets?: Widget[]; diff --git a/modules/react/src/utils/position-children-under-views.ts b/modules/react/src/utils/position-children-under-views.ts index 818a3524301..60f2588aa13 100644 --- a/modules/react/src/utils/position-children-under-views.ts +++ b/modules/react/src/utils/position-children-under-views.ts @@ -3,17 +3,30 @@ // Copyright (c) vis.gl contributors import * as React from 'react'; -import {createElement} from 'react'; +import {Children, createElement} from 'react'; import {View} from '@deck.gl/core'; import {inheritsFrom} from './inherits-from'; import evaluateChildren, {isComponent} from './evaluate-children'; import type {ViewOrViews} from '../deckgl'; import type {Deck, Viewport} from '@deck.gl/core'; +import type {EventManager} from 'mjolnir.js'; import {DeckGlContext, type DeckGLContextValue} from './deckgl-context'; -// Iterate over views and reposition children associated with views -// TODO - Can we supply a similar function for the non-React case? +type DeckWithInternals = { + _canvasTargets?: Record; + canvas?: HTMLCanvasElement | null; + eventManager?: EventManager | null; + getEventManager?: (viewId?: string) => EventManager | null; + _onViewStateChange: (params: {viewId: string} & Record) => void; +}; + +/** + * Group React children by view and canvas, then wrap them in DOM containers that track the + * resolved viewport rectangles for the current frame. + * + * TODO - Can we supply a similar function for the non-React case? + */ export default function positionChildrenUnderViews({ children, deck, @@ -22,22 +35,25 @@ export default function positionChildrenUnderViews({ children: React.ReactNode[]; deck?: Deck; ContextProvider?: React.Context['Provider']; -}): React.ReactNode[] { +}): Record { // @ts-expect-error accessing protected property const {viewManager} = deck || {}; if (!viewManager || !viewManager.views.length) { - return []; + return {}; } + const deckWithInternals = deck as unknown as DeckWithInternals; const views: Record< string, { + canvasId: string; viewport: Viewport; children: React.ReactNode[]; } > = {}; const defaultViewId = (viewManager.views[0] as View).id; + const defaultCanvasId = viewManager.getCanvasId?.(defaultViewId) || 'default-canvas'; // Sort children by view id for (const child of children) { @@ -52,6 +68,7 @@ export default function positionChildrenUnderViews({ const viewport = viewManager.getViewport(viewId) as Viewport; const viewState = viewManager.getViewState(viewId); + const canvasId = viewManager.getCanvasId?.(viewId) || defaultCanvasId; // Drop (auto-hide) elements with viewId that are not matched by any current view if (viewport) { @@ -69,17 +86,18 @@ export default function positionChildrenUnderViews({ if (!views[viewId]) { views[viewId] = { + canvasId, viewport, children: [] }; } - views[viewId].children.push(viewChildren); + views[viewId].children.push(...Children.toArray(viewChildren)); } } // Render views - return Object.keys(views).map(viewId => { - const {viewport, children: viewChildren} = views[viewId]; + return Object.keys(views).reduce>((viewsByCanvasId, viewId) => { + const {canvasId, viewport, children: viewChildren} = views[viewId]; const {x, y, width, height} = viewport; const style = { position: 'absolute', @@ -97,18 +115,24 @@ export default function positionChildrenUnderViews({ const contextValue: DeckGLContextValue = { deck, viewport, - // @ts-expect-error accessing protected property - container: deck.canvas.offsetParent, - // @ts-expect-error accessing protected property - eventManager: deck.eventManager, + container: + deckWithInternals._canvasTargets?.[canvasId]?.canvas.parentElement || + (deckWithInternals.canvas?.offsetParent as HTMLElement | null) || + deckWithInternals.canvas?.parentElement || + document.body, + eventManager: + deckWithInternals.getEventManager?.(viewId) || deckWithInternals.eventManager || null, onViewStateChange: params => { params.viewId = viewId; - // @ts-expect-error accessing protected method - deck._onViewStateChange(params); + deckWithInternals._onViewStateChange(params); }, widgets: [] }; const providerKey = `view-${viewId}-context`; - return createElement(ContextProvider, {key: providerKey, value: contextValue}, viewElement); - }); + viewsByCanvasId[canvasId] ||= []; + viewsByCanvasId[canvasId].push( + createElement(ContextProvider, {key: providerKey, value: contextValue}, viewElement) + ); + return viewsByCanvasId; + }, {}); } diff --git a/test/apps/multi-canvas-cities/app.tsx b/test/apps/multi-canvas-cities/app.tsx new file mode 100644 index 00000000000..4cf0394ee0f --- /dev/null +++ b/test/apps/multi-canvas-cities/app.tsx @@ -0,0 +1,255 @@ +import React, {useMemo, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import DeckGL from '@deck.gl/react'; +import {MapView, View} from '@deck.gl/core'; +import {ScatterplotLayer} from '@deck.gl/layers'; +import {BasemapLayer} from '@deck.gl-community/basemap-layers'; +import {Map} from 'react-map-gl/maplibre'; + +type Landmark = { + id: string; + cityId: string; + name: string; + position: [number, number]; +}; + +const CITY_PANELS = [ + { + id: 'new-york', + title: 'New York', + subtitle: 'Midtown lights and waterfront routes', + mapStyle: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', + viewState: {longitude: -73.9857, latitude: 40.7484, zoom: 10.8, pitch: 35, bearing: -12}, + landmarks: [ + {id: 'times-square', cityId: 'new-york', name: 'Times Square', position: [-73.9851, 40.758]}, + {id: 'central-park', cityId: 'new-york', name: 'Central Park', position: [-73.9712, 40.7831]}, + {id: 'brooklyn-bridge', cityId: 'new-york', name: 'Brooklyn Bridge', position: [-73.9969, 40.7061]} + ] + }, + { + id: 'london', + title: 'London', + subtitle: 'River crossings and west end clusters', + mapStyle: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', + viewState: {longitude: -0.1276, latitude: 51.5072, zoom: 10.8, pitch: 40, bearing: 18}, + landmarks: [ + {id: 'soho', cityId: 'london', name: 'Soho', position: [-0.1337, 51.5138]}, + {id: 'tower-bridge', cityId: 'london', name: 'Tower Bridge', position: [-0.0754, 51.5055]}, + {id: 'greenwich', cityId: 'london', name: 'Greenwich', position: [0.0005, 51.4826]} + ] + }, + { + id: 'tokyo', + title: 'Tokyo', + subtitle: 'Station density across the eastern core', + mapStyle: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', + viewState: {longitude: 139.7588, latitude: 35.6762, zoom: 10.7, pitch: 45, bearing: -22}, + landmarks: [ + {id: 'shibuya', cityId: 'tokyo', name: 'Shibuya', position: [139.7016, 35.6595]}, + {id: 'tokyo-station', cityId: 'tokyo', name: 'Tokyo Station', position: [139.7671, 35.6812]}, + {id: 'asakusa', cityId: 'tokyo', name: 'Asakusa', position: [139.7967, 35.7148]} + ] + }, + { + id: 'sydney', + title: 'Sydney', + subtitle: 'Harbor landmarks with coastal spillover', + mapStyle: 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json', + viewState: {longitude: 151.2093, latitude: -33.8688, zoom: 10.9, pitch: 42, bearing: 24}, + landmarks: [ + {id: 'opera-house', cityId: 'sydney', name: 'Opera House', position: [151.2153, -33.8568]}, + {id: 'bondi', cityId: 'sydney', name: 'Bondi Beach', position: [151.2743, -33.8915]}, + {id: 'newtown', cityId: 'sydney', name: 'Newtown', position: [151.179, -33.8981]} + ] + } +] as const; + +const VIEWS = CITY_PANELS.map( + city => + new MapView({ + id: city.id, + canvasId: city.id, + controller: true + }) +); + +const CITY_TITLES = Object.fromEntries(CITY_PANELS.map(city => [city.id, city.title])) as Record< + string, + string +>; + +function App() { + const [hoveredLandmark, setHoveredLandmark] = useState(null); + const [useDeckBasemap, setUseDeckBasemap] = useState(false); + + const layers = useMemo( + () => [ + ...(useDeckBasemap + ? CITY_PANELS.map( + city => + new BasemapLayer({ + id: `${city.id}-basemap`, + style: city.mapStyle + }) + ) + : []), + ...CITY_PANELS.map( + city => + new ScatterplotLayer({ + id: `${city.id}-landmarks`, + data: city.landmarks, + pickable: true, + parameters: {depthTest: false}, + radiusUnits: 'pixels', + radiusMinPixels: 18, + radiusMaxPixels: 36, + stroked: true, + lineWidthMinPixels: 3, + getPosition: d => d.position, + getRadius: d => (hoveredLandmark?.id === d.id ? 28 : 20), + getFillColor: d => + hoveredLandmark?.id === d.id + ? [255, 215, 110] + : hoveredLandmark?.cityId === d.cityId + ? [255, 122, 89] + : [84, 196, 255], + getLineColor: [255, 255, 255], + onHover: info => setHoveredLandmark((info.object as Landmark) || null) + }) + ) + ], + [hoveredLandmark, useDeckBasemap] + ); + + return ( +
+
+
+ deck.gl multi-canvas +
+

+ Four live city views, one Deck instance +

+

+ Each panel has its own basemap, its own controller, and its own presentation canvas. Hover a landmark in + any city and the signal carries through the rest of the page. +

+
+ +
+
+ {hoveredLandmark ? ( + + {hoveredLandmark.name} in {CITY_PANELS.find(city => city.id === hoveredLandmark.cityId)?.title} + + ) : ( + Hover any highlighted landmark + )} +
+ +
+ + city.id)} + views={VIEWS} + initialViewState={Object.fromEntries(CITY_PANELS.map(city => [city.id, city.viewState]))} + layers={layers} + getTooltip={({object}) => { + const landmark = object as Landmark | null; + return landmark + ? { + text: `${landmark.name}\n${CITY_TITLES[landmark.cityId]}` + } + : null; + }} + layerFilter={({layer, viewport}) => Boolean(viewport && layer.id.startsWith(`${viewport.id}-`))} + style={{ + position: 'relative', + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gridTemplateRows: 'repeat(2, minmax(0, 1fr))', + gap: '16px', + width: '100%', + height: '100%', + minHeight: 0 + }} + > + {CITY_PANELS.map(city => ( + + +
+
{city.title}
+
{city.subtitle}
+
+
+ ))} +
+
+ ); +} + +const root = document.getElementById('app'); +if (!root) { + throw new Error('App root not found'); +} + +createRoot(root).render(); diff --git a/test/apps/multi-canvas-cities/index.html b/test/apps/multi-canvas-cities/index.html new file mode 100644 index 00000000000..fc8aecf4b63 --- /dev/null +++ b/test/apps/multi-canvas-cities/index.html @@ -0,0 +1,26 @@ + + + + + deck.gl Multi-Canvas Cities + + + +
+ + + diff --git a/test/apps/multi-canvas-cities/package.json b/test/apps/multi-canvas-cities/package.json new file mode 100644 index 00000000000..1bbfdbddad6 --- /dev/null +++ b/test/apps/multi-canvas-cities/package.json @@ -0,0 +1,24 @@ +{ + "name": "deckgl-example-multi-canvas-cities", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.mjs", + "build": "vite build" + }, + "dependencies": { + "@deck.gl-community/basemap-layers": "^9.3.1", + "@deck.gl/layers": "^9.3.0-alpha.11", + "@deck.gl/react": "^9.3.0-alpha.11", + "maplibre-gl": "^3.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-map-gl": "^7.1.0" + }, + "devDependencies": { + "vite": "^7.3.1" + } +} diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 2e3702d1f9d..d320b0c3baa 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -396,6 +396,243 @@ webglTest('Deck#rendering, picking, logging', async () => { }); }); +webglTest('Deck#multi-canvas presentation', async () => { + const canvasA = document.createElement('canvas'); + canvasA.id = 'deck-test-canvas-a'; + canvasA.width = 64; + canvasA.height = 64; + document.body.appendChild(canvasA); + + const canvasB = document.createElement('canvas'); + canvasB.id = 'deck-test-canvas-b'; + canvasB.width = 32; + canvasB.height = 48; + document.body.appendChild(canvasB); + + const deck = new Deck({ + width: 64, + height: 64, + canvases: [canvasA, canvasB], + initialViewState: { + left: {longitude: 0, latitude: 0, zoom: 1}, + right: {longitude: 10, latitude: 10, zoom: 1} + }, + views: [ + new MapView({id: 'left', canvasId: 'deck-test-canvas-a'}), + new MapView({id: 'right', canvasId: 'deck-test-canvas-b'}) + ], + layers: [] + }); + + await waitForRender(deck); + + expect(deck.getCanvas()).toBe(canvasA); + // @ts-expect-error testing private state + expect(Object.keys(deck._canvasTargets)).toEqual(['deck-test-canvas-a', 'deck-test-canvas-b']); + expect(deck.getViewports({x: 0, y: 0, canvasId: 'deck-test-canvas-a'}).map(v => v.id)).toEqual([ + 'left' + ]); + expect(deck.getViewports({x: 0, y: 0, canvasId: 'deck-test-canvas-b'}).map(v => v.id)).toEqual([ + 'right' + ]); + + deck.finalize(); + canvasA.remove(); + canvasB.remove(); +}); + +webglTest('Deck#multi-canvas picking routes by canvas', async () => { + const canvasA = document.createElement('canvas'); + canvasA.id = 'deck-test-pick-canvas-a'; + canvasA.width = 64; + canvasA.height = 64; + canvasA.style.width = '64px'; + canvasA.style.height = '64px'; + document.body.appendChild(canvasA); + + const canvasB = document.createElement('canvas'); + canvasB.id = 'deck-test-pick-canvas-b'; + canvasB.width = 32; + canvasB.height = 48; + canvasB.style.width = '32px'; + canvasB.style.height = '48px'; + document.body.appendChild(canvasB); + + const deck = new Deck({ + width: 64, + height: 64, + canvases: [canvasA, canvasB], + initialViewState: { + left: {longitude: 0, latitude: 0, zoom: 10}, + right: {longitude: 10, latitude: 10, zoom: 10} + }, + views: [ + new MapView({id: 'left', canvasId: 'deck-test-pick-canvas-a'}), + new MapView({id: 'right', canvasId: 'deck-test-pick-canvas-b'}) + ], + layers: [] + }); + + await waitForRender(deck); + + const syncCalls: any[] = []; + const asyncCalls: any[] = []; + const rectCalls: any[] = []; + + // @ts-expect-error test override + deck.deckPicker.pickObject = opts => { + syncCalls.push(opts); + return createPointPickResult({ + layer: {id: opts.canvasId === 'deck-test-pick-canvas-b' ? 'right-layer' : 'left-layer'} + }); + }; + // @ts-expect-error test override + deck.deckPicker.pickObjectAsync = opts => { + asyncCalls.push(opts); + return Promise.resolve( + createPointPickResult({ + layer: {id: opts.canvasId === 'deck-test-pick-canvas-b' ? 'right-layer' : 'left-layer'} + }) + ); + }; + // @ts-expect-error test override + deck.deckPicker.pickObjects = opts => { + rectCalls.push(opts); + return [ + createPickingInfo({ + layer: {id: opts.canvasId === 'deck-test-pick-canvas-b' ? 'right-layer' : 'left-layer'} + }) + ]; + }; + + expect(deck.pickObject({x: 32, y: 32})?.layer?.id).toBe('left-layer'); + expect(syncCalls[0].canvasId).toBe('deck-test-pick-canvas-a'); + expect(syncCalls[0].viewports.map(viewport => viewport.id)).toEqual(['left']); + + expect(deck.pickObject({x: 16, y: 24, canvasId: 'deck-test-pick-canvas-b'})?.layer?.id).toBe( + 'right-layer' + ); + expect(syncCalls[1].canvasId).toBe('deck-test-pick-canvas-b'); + expect(syncCalls[1].viewports.map(viewport => viewport.id)).toEqual(['right']); + + expect( + (await deck.pickObjectAsync({x: 16, y: 24, canvasId: 'deck-test-pick-canvas-b'}))?.layer?.id + ).toBe('right-layer'); + expect(asyncCalls[0].canvasId).toBe('deck-test-pick-canvas-b'); + expect(asyncCalls[0].viewports.map(viewport => viewport.id)).toEqual(['right']); + + expect( + deck.pickObjects({x: 16, y: 24, width: 1, height: 1, canvasId: 'deck-test-pick-canvas-b'})[0] + ?.layer?.id + ).toBe('right-layer'); + expect(rectCalls[0].canvasId).toBe('deck-test-pick-canvas-b'); + expect(rectCalls[0].viewports.map(viewport => viewport.id)).toEqual(['right']); + + deck.finalize(); + canvasA.remove(); + canvasB.remove(); +}); + +webglTest('Deck#multi-canvas mode transitions', async () => { + const deck = new Deck({ + width: 64, + height: 64, + initialViewState: {longitude: 0, latitude: 0, zoom: 1}, + layers: [] + }); + + await waitForRender(deck); + + const originalCanvas = deck.getCanvas(); + expect(originalCanvas).toBeTruthy(); + expect(originalCanvas?.isConnected).toBe(true); + + deck.setProps({canvases: []}); + await waitForRender(deck); + + expect(deck.getCanvas()).toBe(null); + expect(originalCanvas?.isConnected).toBe(false); + // @ts-expect-error testing private state + expect(Object.keys(deck._canvasTargets)).toEqual([]); + + deck.setProps({canvases: undefined}); + await waitForRender(deck); + + const rebuiltCanvas = deck.getCanvas(); + expect(rebuiltCanvas).toBeTruthy(); + expect(rebuiltCanvas).not.toBe(originalCanvas); + expect(rebuiltCanvas?.isConnected).toBe(true); + + deck.finalize(); +}); + +webglTest('Deck#multi-canvas clears orphaned canvases', async () => { + const canvasA = document.createElement('canvas'); + canvasA.id = 'deck-test-orphan-canvas-a'; + canvasA.width = 64; + canvasA.height = 64; + document.body.appendChild(canvasA); + + const canvasB = document.createElement('canvas'); + canvasB.id = 'deck-test-orphan-canvas-b'; + canvasB.width = 64; + canvasB.height = 64; + document.body.appendChild(canvasB); + + const deck = new Deck({ + width: 64, + height: 64, + canvases: [canvasA, canvasB], + initialViewState: { + left: {longitude: 0, latitude: 0, zoom: 1}, + right: {longitude: 10, latitude: 10, zoom: 1} + }, + views: [ + new MapView({id: 'left', canvasId: 'deck-test-orphan-canvas-a'}), + new MapView({id: 'right', canvasId: 'deck-test-orphan-canvas-b'}) + ], + layers: [] + }); + + await waitForRender(deck); + + // @ts-expect-error testing private state + const targetA = deck._canvasTargets['deck-test-orphan-canvas-a']; + // @ts-expect-error testing private state + const targetB = deck._canvasTargets['deck-test-orphan-canvas-b']; + const presentCalls = {a: 0, b: 0}; + const renderCalls: string[][] = []; + const originalPresentA = targetA.presentationContext.present.bind(targetA.presentationContext); + const originalPresentB = targetB.presentationContext.present.bind(targetB.presentationContext); + const originalRenderLayers = deck.deckRenderer.renderLayers.bind(deck.deckRenderer); + + targetA.presentationContext.present = () => { + presentCalls.a++; + originalPresentA(); + }; + targetB.presentationContext.present = () => { + presentCalls.b++; + originalPresentB(); + }; + // @ts-expect-error test override + deck.deckRenderer.renderLayers = opts => { + renderCalls.push(opts.viewports.map(viewport => viewport.id)); + originalRenderLayers(opts); + }; + + deck.setProps({ + views: [new MapView({id: 'left', canvasId: 'deck-test-orphan-canvas-a'})] + }); + await waitForRender(deck); + + expect(renderCalls).toEqual([['left']]); + expect(presentCalls).toEqual({a: 1, b: 1}); + + deck.finalize(); + canvasA.remove(); + canvasB.remove(); +}); + test('Deck#async picking', async () => { const deck = new Deck({ device, diff --git a/test/modules/core/lib/tooltip.spec.ts b/test/modules/core/lib/tooltip.spec.ts index dd36e7b95fd..32a4a8b157c 100644 --- a/test/modules/core/lib/tooltip.spec.ts +++ b/test/modules/core/lib/tooltip.spec.ts @@ -139,3 +139,33 @@ test('TooltipWidget#onViewportChange', () => { widgetManager.finalize(); }); + +test('TooltipWidget#onHover offsets for multi-canvas', () => { + const container = document.createElement('div'); + container.getBoundingClientRect = () => ({left: 10, top: 20} as DOMRect); + + const canvas = document.createElement('canvas'); + canvas.getBoundingClientRect = () => ({left: 110, top: 220} as DOMRect); + + const deck = { + props: {getTooltip: () => 'Test tooltip'}, + _isMultiCanvasMode: () => true, + viewManager: {getCanvasId: () => 'right-canvas'}, + _canvasTargets: {'right-canvas': {canvas}} + }; + + const widgetManager = new WidgetManager({deck, parentElement: container}); + const tooltip = new TooltipWidget(); + widgetManager.addDefault(tooltip); + + tooltip.onHover({ + object: {elevationValue: 10}, + viewport: new WebMercatorViewport({id: 'right-view'}), + x: 5, + y: 7 + }); + + expect(tooltip.rootElement?.style.transform).toBe('translate(105px, 207px)'); + + widgetManager.finalize(); +}); diff --git a/test/modules/core/lib/view-manager.spec.ts b/test/modules/core/lib/view-manager.spec.ts index 96399336465..a7e0f031849 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,50 @@ test('ViewManager#update view props', () => { viewManager.finalize(); }); +test('ViewManager#multi-canvas layout and controller rebinding', () => { + const leftEventManager = new EventManager(document.createElement('div')); + const rightEventManager = 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: 1, + height: 1, + canvasMetrics: { + 'left-canvas': {width: 200, height: 100}, + 'right-canvas': {width: 120, height: 180} + }, + eventManagers: { + 'left-canvas': leftEventManager, + 'right-canvas': rightEventManager + }, + eventManager: leftEventManager + }); + + expect(viewManager.getCanvasId('left')).toBe('left-canvas'); + expect(viewManager.getCanvasId('right')).toBe('right-canvas'); + expect(viewManager.getViewport('left')?.width).toBe(200); + expect(viewManager.getViewport('right')?.height).toBe(180); + + const originalLeftController = viewManager.controllers.left; + 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(originalLeftController); + + leftEventManager.destroy(); + rightEventManager.destroy(); + viewManager.finalize(); +}); + /* eslint-disable max-statements */ test('ViewManager#zero-size', () => { const mainView = new MapView({id: 'main', controller: true}); diff --git a/test/modules/core/lib/widget-manager.spec.ts b/test/modules/core/lib/widget-manager.spec.ts index 94044c291cb..b5f8aa7607e 100644 --- a/test/modules/core/lib/widget-manager.spec.ts +++ b/test/modules/core/lib/widget-manager.spec.ts @@ -220,6 +220,26 @@ test('WidgetManager#onRedraw#without viewId', () => { widgetManager.finalize(); }); +test('WidgetManager#onRedraw#without viewId in multi-canvas uses parent size', () => { + const parentElement = document.createElement('div'); + Object.defineProperty(parentElement, 'clientWidth', {value: 1200}); + Object.defineProperty(parentElement, 'clientHeight', {value: 800}); + const widgetManager = new WidgetManager({ + deck: {...mockDeckInstance, _isMultiCanvasMode: () => true}, + parentElement + }); + + const widget = new TestWidget({id: 'A'}); + widgetManager.addDefault(widget); + widgetManager.onRedraw({viewports: [], layers: []}); + + const container = widgetManager.containers['root']; + expect(container.style.width, 'root container width uses parent size').toBe('1200px'); + expect(container.style.height, 'root container height uses parent size').toBe('800px'); + + widgetManager.finalize(); +}); + test('WidgetManager#onRedraw#viewId', () => { const parentElement = document.createElement('div'); const widgetManager = new WidgetManager({deck: mockDeckInstance, parentElement}); diff --git a/test/modules/react/deckgl.spec.ts b/test/modules/react/deckgl.spec.ts index db600d12980..93b40eff4b4 100644 --- a/test/modules/react/deckgl.spec.ts +++ b/test/modules/react/deckgl.spec.ts @@ -16,7 +16,13 @@ import { } from 'react'; import {createRoot} from 'react-dom/client'; -import {Layer, Widget, type WebMercatorViewport, type MapViewState} from '@deck.gl/core'; +import { + Layer, + MapView, + Widget, + type WebMercatorViewport, + type MapViewState +} from '@deck.gl/core'; import DeckGL, {type DeckGLRef} from '@deck.gl/react'; import {type WidgetProps, type WidgetPlacement} from '@deck.gl/core'; @@ -292,3 +298,45 @@ test('DeckGL#controlled view state', async () => { }); container.remove(); }); + +test('DeckGL#multi-canvas event manager uses host root', async () => { + const ref = createRef(); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + const loadPromise = new Promise(resolve => { + act(() => { + root.render( + createElement(DeckGL, { + ref, + width: 200, + height: 100, + canvases: ['left-canvas', 'right-canvas'], + views: [ + new MapView({id: 'left', canvasId: 'left-canvas', controller: true}), + new MapView({id: 'right', canvasId: 'right-canvas', controller: true}) + ], + initialViewState: { + left: TEST_VIEW_STATE, + right: TEST_VIEW_STATE + }, + onLoad: () => resolve() + }) + ); + }); + }); + await loadPromise; + + const {deck} = ref.current!; + expect(deck).toBeTruthy(); + const leftHost = container.querySelector('#left-canvas-host'); + expect(leftHost).toBeTruthy(); + // @ts-expect-error deck exposes getEventManager at runtime + expect(deck.getEventManager('left')?.getElement()).toBe(leftHost); + + act(() => { + root.render(null); + }); + container.remove(); +}); diff --git a/test/modules/react/utils/position-children-under-views.spec.ts b/test/modules/react/utils/position-children-under-views.spec.ts index 3a32a748d97..fa1716d30c2 100644 --- a/test/modules/react/utils/position-children-under-views.spec.ts +++ b/test/modules/react/utils/position-children-under-views.spec.ts @@ -45,26 +45,27 @@ test('positionChildrenUnderViews#before initialization', () => { children: TEST_CHILDREN, deck: null }); - expect(children.length, 'Should not fail if deck is not initialized').toBe(0); + expect(Object.keys(children).length, 'Should not fail if deck is not initialized').toBe(0); children = positionChildrenUnderViews({ children: TEST_CHILDREN, deck: {} }); - expect(children.length, 'Should not fail if deck is not initialized').toBe(0); + expect(Object.keys(children).length, 'Should not fail if deck is not initialized').toBe(0); children = positionChildrenUnderViews({ children: TEST_CHILDREN, deck: {viewManager: {views: []}} }); - expect(children.length, 'Should not fail if deck has no view').toBe(0); + expect(Object.keys(children).length, 'Should not fail if deck has no view').toBe(0); }); test('positionChildrenUnderViews', () => { - const children = positionChildrenUnderViews({ + const viewsByCanvasId = positionChildrenUnderViews({ children: TEST_CHILDREN, deck: {viewManager: dummyViewManager, canvas: document.createElement('canvas')} }); + const children = Object.values(viewsByCanvasId).flat(); expect(children.length, 'Returns wrapped children').toBe(2); expect(children[0].key, 'Child has deck context').toBe('view-map-context'); @@ -104,7 +105,7 @@ test('positionChildrenUnderViews', () => { test('positionChildrenUnderViews#override ContextProvider', () => { const context = React.createContext(); - const children = positionChildrenUnderViews({ + const viewsByCanvasId = positionChildrenUnderViews({ children: TEST_CHILDREN, deck: { viewManager: dummyViewManager, @@ -112,6 +113,7 @@ test('positionChildrenUnderViews#override ContextProvider', () => { }, ContextProvider: context.Provider }); + const children = Object.values(viewsByCanvasId).flat(); expect(children.length, 'Returns wrapped children').toBe(2);