diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index 151ab7ba827..54337f7b44b 100644 --- a/docs/api-reference/mapbox/mapbox-overlay.md +++ b/docs/api-reference/mapbox/mapbox-overlay.md @@ -164,6 +164,53 @@ See [Deck.getCanvas](../core/deck.md#getcanvas). When using `interleaved: true`, ## Remarks +### Using Widgets + +deck.gl [widgets](../widgets/overview.md) can be used with `MapboxOverlay`. There are two positioning modes, controlled by the widget's `viewId` prop: + +#### Default positioning (deck.gl overlay) + +Widgets without a `viewId` (or with a `viewId` other than `'mapbox'`) are rendered inside deck.gl's own overlay container. This container is itself a map control placed at `top-left`, so these widgets appear layered on top of the map canvas. + +```ts +new MapboxOverlay({ + widgets: [ + new FullscreenWidget({placement: 'top-left'}) + ] +}); +``` + +#### Map-positioned widgets (`viewId: 'mapbox'`) + +Widgets with `viewId: 'mapbox'` are extracted from the deck overlay and wrapped as native map [IControl](https://docs.mapbox.com/mapbox-gl-js/api/markers/#icontrol) instances. They are added to the map's own control container, positioned alongside native controls like `NavigationControl`. This prevents overlap between deck widgets and native map UI. + +```ts +const overlay = new MapboxOverlay({ + widgets: [ + // Positioned by the map's control container + new ScreenshotWidget({viewId: 'mapbox', placement: 'top-right'}), + new FullscreenWidget({viewId: 'mapbox', placement: 'top-left'}), + // Positioned by deck.gl's overlay + new PopupWidget({position: [0.45, 51.47], content: 'London'}) + ] +}); + +map.addControl(overlay); +// Native controls coexist with deck widgets +map.addControl(new maplibregl.NavigationControl(), 'top-right'); +``` + +#### Limitations + +When using `MapboxOverlay`, the map library controls the camera and interaction, not deck.gl. This affects certain widgets: + +| Widget Category | Examples | Limitation | +|---|---|---| +| **View controls** | `ZoomWidget`, `CompassWidget`, `ResetViewWidget` | Button clicks do not move the camera, because view state is managed by the map. Use native map controls (e.g. `NavigationControl`) instead. | +| **Canvas capture** | `ScreenshotWidget` | In interleaved mode (`interleaved: true`), deck renders into the map's GL context. `ScreenshotWidget` captures deck's own canvas, which is empty. Use `overlay.getCanvas()` to get the map's canvas instead. | + +Informational widgets (`FullscreenWidget`, `LoadingWidget`, `PopupWidget`, `InfoWidget`, etc.) work without limitations in both modes. + ### Multi-view usage When using `MapboxOverlay` with multiple views passed to the `views` prop, only one of the views can match the base map and receive interaction. diff --git a/docs/whats-new.md b/docs/whats-new.md index 295b9400e14..09a6ce82d0e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -60,13 +60,15 @@ Aside from the above, all widgets also received the following improvements: ### @deck.gl/mapbox -In interleaved mode, `MapboxOverlay` now always renders layers in groups by `beforeId` or `slot`. This enables cross-layer extension handling (e.g. MaskExtension, CollisionFilterExtension) by default, without needing the previously experimental `_renderLayersInGroups` prop. +- In interleaved mode, `MapboxOverlay` now always renders layers in groups by `beforeId` or `slot`. This enables cross-layer extension handling (e.g. MaskExtension, CollisionFilterExtension) by default, without needing the previously experimental `_renderLayersInGroups` prop. ([#10163](https://github.com/visgl/deck.gl/pull/10163)) +- `MapboxOverlay` now automatically injects a `MapView` with id `"mapbox"` when custom views are provided, so overlaid and interleaved modes behave the same way with multi-view setups. ([#9947](https://github.com/visgl/deck.gl/pull/9947)) +- deck.gl widgets can now be positioned alongside native map controls (e.g. `NavigationControl`) by setting `viewId: 'mapbox'` on the widget. See [Using Widgets](./api-reference/mapbox/mapbox-overlay.md#using-widgets) for details. ([#9962](https://github.com/visgl/deck.gl/pull/9962)) +- Fixed null viewport handling when the map canvas has zero dimensions. ([#10076](https://github.com/visgl/deck.gl/pull/10076), [#10086](https://github.com/visgl/deck.gl/pull/10086)) +- Fixed heatmap layer blending in interleaved mode. ([#9993](https://github.com/visgl/deck.gl/pull/9993)) -### Views +### @deck.gl/google-maps -View layout props (`x`, `y`, `width`, `height`, and padding) now accept CSS-style expressions such as `calc(50% - 10px)` so you can mix relative percentages with fixed pixel offsets when arranging multi-view layouts. - -- [OrthographicView](./api-reference/core/orthographic-view.md) is moving away from 2d-array zoom and adds per-axis `zoom*`, `minZoom*`, `maxZoom*` props. +- Fixed DOM positioning when `interleaved: false`. ([#9992](https://github.com/visgl/deck.gl/pull/9992)) ### Improved 3D Support @@ -80,6 +82,12 @@ deck.gl v9.3 is a substantial step forward in 3D navigation and rendering suppor - All controllers - New `maxBounds` option constrains the camera within a (2D or 3D) bounding box, preventing users from navigating outside of the content area. - [GlobeController](./api-reference/core/globe-controller.md) - Major bug fixes and improved stability. +### Views + +View layout props (`x`, `y`, `width`, `height`, and padding) now accept CSS-style expressions such as `calc(50% - 10px)` so you can mix relative percentages with fixed pixel offsets when arranging multi-view layouts. + +- [OrthographicView](./api-reference/core/orthographic-view.md) is moving away from 2d-array zoom and adds per-axis `zoom*`, `minZoom*`, `maxZoom*` props. + ## deck.gl v9.2 Release date: October 7, 2025 diff --git a/examples/get-started/pure-js/maplibre/app.js b/examples/get-started/pure-js/maplibre/app.js index 85d75340a58..bc7357d07cd 100644 --- a/examples/get-started/pure-js/maplibre/app.js +++ b/examples/get-started/pure-js/maplibre/app.js @@ -4,6 +4,8 @@ import {MapboxOverlay as DeckOverlay} from '@deck.gl/mapbox'; import {GeoJsonLayer, ArcLayer} from '@deck.gl/layers'; +import {ZoomWidget, CompassWidget, FullscreenWidget, ResetViewWidget} from '@deck.gl/widgets'; +import '@deck.gl/widgets/stylesheet.css'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -22,6 +24,15 @@ const map = new maplibregl.Map({ const deckOverlay = new DeckOverlay({ interleaved: true, + widgets: [ + // viewId: 'mapbox' widgets are positioned by the map's control container, + // alongside native map controls like NavigationControl + new ZoomWidget({viewId: 'mapbox', placement: 'top-right'}), + new CompassWidget({viewId: 'mapbox', placement: 'top-right'}), + new ResetViewWidget({viewId: 'mapbox', placement: 'top-right'}), + // Default widgets (no viewId) are positioned by deck.gl's own overlay + new FullscreenWidget({placement: 'top-left'}) + ], layers: [ new GeoJsonLayer({ id: 'airports', @@ -55,4 +66,5 @@ const deckOverlay = new DeckOverlay({ }); map.addControl(deckOverlay); -map.addControl(new maplibregl.NavigationControl()); +// Native map control alongside deck.gl widgets with viewId: 'mapbox' +map.addControl(new maplibregl.NavigationControl(), 'top-right'); diff --git a/examples/get-started/pure-js/maplibre/package.json b/examples/get-started/pure-js/maplibre/package.json index 2088ec94a0f..3377f784bdd 100644 --- a/examples/get-started/pure-js/maplibre/package.json +++ b/examples/get-started/pure-js/maplibre/package.json @@ -12,6 +12,7 @@ "@deck.gl/core": "^9.0.0", "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.0.0", + "@deck.gl/widgets": "^9.0.0", "maplibre-gl": "^5.0.0" }, "devDependencies": { diff --git a/modules/mapbox/src/deck-widget-control.ts b/modules/mapbox/src/deck-widget-control.ts new file mode 100644 index 00000000000..7719e19fc19 --- /dev/null +++ b/modules/mapbox/src/deck-widget-control.ts @@ -0,0 +1,78 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Widget} from '@deck.gl/core'; +import type {IControl, ControlPosition, Map} from './types'; + +/** + * Wraps a deck.gl Widget as a Mapbox/MapLibre IControl. + * + * This enables deck widgets to be positioned alongside native map controls + * in the same DOM container, preventing overlap issues. + * + * @internal Used by MapboxOverlay for widgets with `viewId: 'mapbox'`. + */ +export class DeckWidgetControl implements IControl { + private _widget: Widget; + private _container: HTMLDivElement | null = null; + + constructor(widget: Widget) { + this._widget = widget; + } + + /** + * Called when the control is added to the map. + * Creates a container element that will be positioned by Mapbox/MapLibre, + * and sets the widget's _container prop so WidgetManager appends the widget here. + */ + onAdd(map: Map): HTMLElement { + this._container = document.createElement('div'); + this._container.className = 'maplibregl-ctrl mapboxgl-ctrl deck-widget-ctrl'; + + // Set _container so WidgetManager appends the widget's rootElement here + // instead of in its own overlay container + this._widget.props._container = this._container; + + return this._container; + } + + /** + * Called when the control is removed from the map. + */ + onRemove(): void { + // Clear the _container reference so widget doesn't try to append there + if (this._widget.props._container === this._container) { + this._widget.props._container = null; + } + this._container?.remove(); + this._container = null; + } + + /** + * Returns the default position for this control. + * Uses the widget's placement, which conveniently matches Mapbox control positions. + * Note: 'fill' placement is not supported by Mapbox controls, defaults to 'top-left'. + */ + getDefaultPosition(): ControlPosition { + const placement = this._widget.placement; + // 'fill' is not a valid Mapbox control position + if (!placement || placement === 'fill') { + return 'top-left'; + } + return placement; + } + + /** Returns the wrapped widget */ + get widget(): Widget { + return this._widget; + } + + /** + * Updates the wrapped widget reference. + * Used when reusing this control for a new widget instance with the same id. + */ + setWidget(widget: Widget): void { + this._widget = widget; + } +} diff --git a/modules/mapbox/src/mapbox-overlay.ts b/modules/mapbox/src/mapbox-overlay.ts index 00c2dfa470e..955aac3e424 100644 --- a/modules/mapbox/src/mapbox-overlay.ts +++ b/modules/mapbox/src/mapbox-overlay.ts @@ -12,10 +12,11 @@ import { getProjection, MAPBOX_VIEW_ID } from './deck-utils'; +import {DeckWidgetControl} from './deck-widget-control'; import type {Map, IControl, MapMouseEvent, ControlPosition} from './types'; import type {MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js'; -import type {DeckProps, LayersList} from '@deck.gl/core'; +import type {DeckProps, LayersList, Widget} from '@deck.gl/core'; import {resolveLayerGroups} from './resolve-layer-groups'; @@ -48,6 +49,8 @@ export default class MapboxOverlay implements IControl { private _container?: HTMLDivElement; private _interleaved: boolean; private _lastMouseDownPoint?: {x: number; y: number; clientX: number; clientY: number}; + /** IControl wrappers for widgets with viewId: 'mapbox' */ + private _widgetControls: DeckWidgetControl[] = []; constructor(props: MapboxOverlayProps) { const {interleaved = false} = props; @@ -71,6 +74,12 @@ export default class MapboxOverlay implements IControl { this._resolveLayers(this._map, this._deck, this._props.layers, props.layers); } + // Process widgets with viewId: 'mapbox' before updating props + // This must happen before deck.setProps so _container is set + if (props.widgets !== undefined) { + this._processWidgets(props.widgets); + } + Object.assign(this._props, this.filterProps(props)); if (this._deck && this._map) { @@ -105,6 +114,10 @@ export default class MapboxOverlay implements IControl { }); this._container = container; + // Process widgets with viewId: 'mapbox' BEFORE creating Deck + // so _container is set when WidgetManager initializes + this._processWidgets(this._props.widgets); + this._deck = new Deck({ ...this._props, parent: container, @@ -136,6 +149,11 @@ export default class MapboxOverlay implements IControl { 'Incompatible basemap library. See: https://deck.gl/docs/api-reference/mapbox/overview#compatibility' )(); } + + // Process widgets with viewId: 'mapbox' BEFORE creating Deck + // so _container is set when WidgetManager initializes + this._processWidgets(this._props.widgets); + this._deck = getDeckInstance({ map, deck: new Deck({ @@ -161,11 +179,73 @@ export default class MapboxOverlay implements IControl { resolveLayerGroups(map, prevLayers, newLayers); } + /** + * Process widgets and wrap those with viewId: 'mapbox' as IControls. + * This enables deck widgets to be positioned in Mapbox's control container + * alongside native map controls, preventing overlap. + * + * Matches widgets by id (like WidgetManager) to handle new instances with same id. + * Only recreates controls when placement changes to avoid orphaning the widget's + * rootElement when the container is removed from the DOM. + */ + private _processWidgets(widgets: Widget[] | undefined): void { + const map = this._map; + if (!map) return; + + const mapboxWidgets = widgets?.filter(w => w && w.viewId === 'mapbox') ?? []; + + // Build a map of existing controls by widget id + const existingControlsById = new Map(); + for (const control of this._widgetControls) { + existingControlsById.set(control.widget.id, control); + } + + const newControls: DeckWidgetControl[] = []; + + for (const widget of mapboxWidgets) { + const existingControl = existingControlsById.get(widget.id); + + if (existingControl && existingControl.widget.placement === widget.placement) { + // Same id and placement - reuse existing control to preserve container + // Set _container on the new widget instance so WidgetManager uses it + widget.props._container = existingControl.widget.props._container; + // Update the control's widget reference to the new instance + existingControl.setWidget(widget); + newControls.push(existingControl); + existingControlsById.delete(widget.id); + } else { + // New widget or placement changed - need a new control + if (existingControl) { + // Placement changed - remove old control first + map.removeControl(existingControl); + existingControlsById.delete(widget.id); + } + const control = new DeckWidgetControl(widget); + // Add to map - this calls onAdd() synchronously, setting _container + map.addControl(control, control.getDefaultPosition()); + newControls.push(control); + } + } + + // Remove controls for widgets that are no longer present + for (const control of existingControlsById.values()) { + map.removeControl(control); + } + + this._widgetControls = newControls; + } + /** Called when the control is removed from a map */ onRemove(): void { const map = this._map; if (map) { + // Remove widget controls + for (const control of this._widgetControls) { + map.removeControl(control); + } + this._widgetControls = []; + if (this._interleaved) { this._onRemoveInterleaved(map); } else { diff --git a/modules/widgets/src/stylesheet.css b/modules/widgets/src/stylesheet.css index 784f7fd6362..ea803d49da6 100644 --- a/modules/widgets/src/stylesheet.css +++ b/modules/widgets/src/stylesheet.css @@ -3,6 +3,12 @@ box-sizing: border-box; } +/* When a widget is inside a basemap control container (e.g. MapboxOverlay with viewId: 'mapbox'), + the map already provides spacing between controls, so remove the widget's own margin. */ +.deck-widget-ctrl .deck-widget { + margin: 0; +} + /* Common button container styles */ .deck-widget-button, .deck-widget-button-group { diff --git a/test/apps/widget-browser/app.tsx b/test/apps/widget-browser/app.tsx new file mode 100644 index 00000000000..66e9bd43a8e --- /dev/null +++ b/test/apps/widget-browser/app.tsx @@ -0,0 +1,647 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// Widget Browser — consolidated test app for all widget integration scenarios. +// Run with: cd test/apps/widget-browser && npm run start-local + +import React, {useState, useCallback, useMemo} from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, NavigationControl, useControl} from 'react-map-gl/maplibre'; +import {DeckGL} from '@deck.gl/react'; +import {Deck, MapView, OrbitView, OrthographicView, PickingInfo} from '@deck.gl/core'; +import type {MapViewState, OrbitViewState, OrthographicViewState, View} from '@deck.gl/core'; +import {DataFilterExtension} from '@deck.gl/extensions'; +import {GeoJsonLayer, ArcLayer, ScatterplotLayer} from '@deck.gl/layers'; +import {_WMSLayer as WMSLayer} from '@deck.gl/geo-layers'; +import {MapboxOverlay} from '@deck.gl/mapbox'; +import type {MapboxOverlayProps} from '@deck.gl/mapbox'; +import { + CompassWidget, + ZoomWidget, + FullscreenWidget, + ScreenshotWidget, + ResetViewWidget, + PopupWidget, + IconWidget, + ToggleWidget, + SelectorWidget, + _GeocoderWidget, + _ScaleWidget, + LoadingWidget, + ThemeWidget, + InfoWidget, + ContextMenuWidget, + _TimelineWidget, + _StatsWidget, + GimbalWidget, + ScrollbarWidget, + _SplitterWidget as SplitterWidget +} from '@deck.gl/widgets'; +import '@deck.gl/widgets/stylesheet.css'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +// ─── Shared Data URLs ──────────────────────────────────────────────── +const COUNTRIES = + 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson'; +const AIR_PORTS = + 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_airports.geojson'; +const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; + +// ─── StatePanel (shared) ───────────────────────────────────────────── +function StatePanel({state}: {state: Record}) { + return ( +
+
+ Widget State +
+ {Object.entries(state).map(([key, value]) => ( +
+ {key}: + {JSON.stringify(value)} +
+ ))} +
+ ); +} + +// ─── Tab 1: MapView ────────────────────────────────────────────────── +// Combines widgets-9.2 showcase + controlled-widgets state/callbacks + +function getMapLayers(filterRange: [number, number] = [2, 9]) { + return [ + new WMSLayer({ + data: 'https://ows.terrestris.de/osm/service', + serviceType: 'wms', + layers: ['OSM-WMS'] + }), + new GeoJsonLayer({ + id: 'base-map', + data: COUNTRIES, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + opacity: 0.4, + getLineColor: [60, 60, 60], + getFillColor: [200, 200, 200] + }), + new GeoJsonLayer({ + id: 'airports', + data: AIR_PORTS, + filled: true, + pointRadiusMinPixels: 2, + pointRadiusScale: 2000, + getPointRadius: f => 11 - f.properties.scalerank, + getFillColor: [200, 0, 80, 180], + pickable: true, + autoHighlight: true, + getFilterValue: f => f.properties.scalerank, + filterRange, + extensions: [new DataFilterExtension({filterSize: 1})] + }), + new ArcLayer({ + id: 'arcs', + data: AIR_PORTS, + dataTransform: d => d.features.filter(f => f.properties.scalerank < 4), + getSourcePosition: () => [-0.4531566, 51.4709959], + getTargetPosition: f => f.geometry.coordinates, + getSourceColor: [0, 128, 200], + getTargetColor: [200, 0, 80], + getWidth: 1 + }) + ]; +} + +function getTooltip(info: PickingInfo, widget: InfoWidget) { + if (!info.object || info.layer?.id !== 'airports') return null; + + let text: string; + switch (widget.props.mode) { + case 'hover': + text = `${info.object.properties.name} (${info.object.properties.abbrev})`; + break; + case 'click': + case 'static': + text = `${info.object.properties.name} (${info.object.properties.abbrev})\n${info.object.properties.type}\n${info.object.properties.featureclass} (${info.object.properties.location})`; + break; + } + + return { + position: info.object.geometry.coordinates, + text, + style: {minWidth: '200px'} + }; +} + +function createPin() { + const div = document.createElement('div'); + Object.assign(div.style, { + width: '32px', + height: '32px', + transform: 'translate(-50%,-24px)' + }); + div.innerHTML = + ''; + return div; +} + +const RUN_ICON = `data:image/svg+xml,`; + +const STAR_ICON = + 'data:image/svg+xml,'; +const STAR_ON_ICON = + 'data:image/svg+xml,'; + +const SINGLE_VIEW_ICON = `data:image/svg+xml,`; +const SPLIT_H_ICON = `data:image/svg+xml,`; +const SPLIT_V_ICON = `data:image/svg+xml,`; + +function MapViewTab() { + const [viewState, setViewState] = useState({ + latitude: 51.47, + longitude: 0.45, + zoom: 4, + bearing: 0, + pitch: 30 + }); + const [themeMode, setThemeMode] = useState<'light' | 'dark'>('dark'); + const [expanded, setExpanded] = useState(false); + const [playing, setPlaying] = useState(false); + const [time, setTime] = useState(0); + const [loading, setLoading] = useState(true); + const [lastCallback, setLastCallback] = useState('(none)'); + const [filterRange, setFilterRange] = useState<[number, number]>([2, 9]); + + const onViewStateChange = useCallback(({viewState: vs}) => { + setViewState(vs as MapViewState); + }, []); + + const widgets = useMemo( + () => [ + new _GeocoderWidget({geocoder: 'coordinates', _geolocation: true}), + new ZoomWidget({ + onZoom: ({delta, zoom}) => + setLastCallback(`ZoomWidget.onZoom(delta=${delta}, zoom=${zoom.toFixed(1)})`) + }), + new CompassWidget({ + onReset: ({bearing, pitch}) => + setLastCallback(`CompassWidget.onReset(bearing=${bearing}, pitch=${pitch})`) + }), + new FullscreenWidget({ + onFullscreenChange: fs => setLastCallback(`FullscreenWidget.onFullscreenChange(${fs})`) + }), + new ScreenshotWidget(), + new ResetViewWidget({ + initialViewState: {latitude: 51.47, longitude: 0.45, zoom: 4, bearing: 0, pitch: 30}, + onReset: () => setLastCallback('ResetViewWidget.onReset') + }), + new LoadingWidget({onLoadingChange: setLoading}), + new _ScaleWidget({placement: 'bottom-right'}), + new ThemeWidget({themeMode, onThemeModeChange: setThemeMode}), + new ContextMenuWidget({ + getMenuItems: (info: PickingInfo) => { + const name = info.layer?.id === 'airports' && info.object?.properties.name; + return ( + name && [ + {label: `Airport: ${name}`}, + {value: 'open', label: 'Open in new tab'}, + {value: 'favorite', label: 'Set as favorite'}, + {value: 'filter', label: 'Exclude from filter'} + ] + ); + }, + onMenuItemSelected: console.log + }), + new InfoWidget({mode: 'hover', getTooltip, arrow: 10, offset: 10}), + new PopupWidget({ + position: [-5, 52], + marker: {element: createPin()}, + placement: 'top', + offset: 20, + content: `I'm here!`, + closeOnClickOutside: true + }), + new _TimelineWidget({ + placement: 'bottom-left', + timeRange: [2, 9], + step: 1, + time, + onTimeChange: (t: number) => { + setTime(t); + setFilterRange([2, t]); + }, + playing, + onPlayingChange: (next: boolean) => { + if (next && time >= 9) setTime(2); + setPlaying(next); + }, + playInterval: 1000 + }), + new _StatsWidget({type: 'deck', expanded, onExpandedChange: setExpanded}), + new IconWidget({ + placement: 'top-right', + label: 'Run!', + icon: RUN_ICON, + onClick: () => setLastCallback('IconWidget.onClick') + }), + new ToggleWidget({ + placement: 'top-right', + icon: STAR_ICON, + onIcon: STAR_ON_ICON, + label: 'Favorite', + onLabel: 'Cancel', + onColor: 'skyblue', + onChange: checked => setLastCallback(`ToggleWidget.onChange(${checked})`) + }), + new SelectorWidget({ + placement: 'top-right', + initialValue: 'single', + options: [ + {value: 'single', label: 'Single view', icon: SINGLE_VIEW_ICON}, + {value: 'split-horizontal', label: 'Split horizontal', icon: SPLIT_H_ICON}, + {value: 'split-vertical', label: 'Split vertical', icon: SPLIT_V_ICON} + ], + onChange: v => setLastCallback(`SelectorWidget.onChange(${v})`) + }) + ], + [themeMode, expanded, time, playing] + ); + + const layers = useMemo(() => getMapLayers(filterRange), [filterRange]); + + return ( + <> + + + + ); +} + +// ─── Tab 2: InfoVis ────────────────────────────────────────────────── +// From widgets-infovis: OrbitView + OrthographicView, GimbalWidget, ScrollbarWidget + +function generateData(count: number) { + const result: {position: number[]; color: number[]}[] = []; + for (let i = 0; i < count; i++) { + result.push({ + position: [Math.random() * 100 - 50, Math.random() * 100 - 50, Math.random() * 100 - 50], + color: [Math.random() * 255, Math.random() * 255, Math.random() * 255] + }); + } + return result; +} + +const INFOVIS_DATA = generateData(500); + +function InfoVisTab() { + return ( + d.position, + getFillColor: d => d.color, + getRadius: 3, + pickable: true, + autoHighlight: true, + billboard: true + }) + ]} + widgets={[ + new ZoomWidget(), + new GimbalWidget(), + new FullscreenWidget(), + new ResetViewWidget(), + new ThemeWidget(), + new _TimelineWidget({ + viewId: 'orbit-view', + timeRange: [0, 600], + formatLabel: (t: number) => + `${Math.floor(t / 60) + .toString() + .padStart(2, '0')}:${(t % 60).toFixed(0).padStart(2, '0')}` + }), + new ScrollbarWidget({ + viewId: 'ortho-view', + contentBounds: [ + [-50, -50, -50], + [50, 50, 50] + ], + placement: 'bottom-right', + orientation: 'vertical' + }), + new ScrollbarWidget({ + viewId: 'ortho-view', + contentBounds: [ + [-50, -50, -50], + [50, 50, 50] + ], + placement: 'bottom-right', + orientation: 'horizontal' + }) + ]} + /> + ); +} + +// ─── Tab 3: Multi-View Splitter ────────────────────────────────────── +// From widgets-multi-view-9.2 (React version) + controlled-widgets splitter demo + +const SPLITTER_VIEW_LAYOUT = { + orientation: 'horizontal', + views: [ + new MapView({id: 'left', controller: true}), + { + orientation: 'vertical', + views: [ + new MapView({id: 'right-top', controller: true}), + new MapView({id: 'right-bottom', controller: true}) + ] + } + ] +} as const; + +function MultiViewTab() { + const [views, setViews] = useState([]); + + const layers = [ + new GeoJsonLayer({ + id: 'base-map', + data: COUNTRIES, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + opacity: 0.4, + getLineColor: [60, 60, 60], + getFillColor: [200, 200, 200] + }), + new GeoJsonLayer({ + id: 'airports', + data: AIR_PORTS, + filled: true, + pointRadiusMinPixels: 2, + pointRadiusScale: 2000, + getPointRadius: f => 11 - f.properties.scalerank, + getFillColor: [200, 0, 80, 180], + pickable: true, + autoHighlight: true + }), + new ArcLayer({ + id: 'arcs', + data: AIR_PORTS, + dataTransform: d => d.features.filter(f => f.properties.scalerank < 4), + getSourcePosition: () => [-0.4531566, 51.4709959], + getTargetPosition: f => f.geometry.coordinates, + getSourceColor: [0, 128, 200], + getTargetColor: [200, 0, 80], + getWidth: 1 + }) + ]; + + return ( + <> + + + + ); +} + +// ─── Tab 4: Basemap Integration ────────────────────────────────────── +// MapLibre basemap via react-map-gl + MapboxOverlay, testing viewId: 'mapbox' + +function DeckGLOverlay(props: MapboxOverlayProps & {interleaved?: boolean}) { + const overlay = useControl(() => new MapboxOverlay(props)); + overlay.setProps(props); + return null; +} + +function BasemapTab() { + const [interleaved, setInterleaved] = useState(false); + const [lastCallback, setLastCallback] = useState('(none)'); + const [loading, setLoading] = useState(true); + const [themeMode, setThemeMode] = useState<'light' | 'dark'>('dark'); + + const layers = [ + new GeoJsonLayer({ + id: 'airports', + data: AIR_PORTS, + filled: true, + pointRadiusMinPixels: 2, + pointRadiusScale: 2000, + getPointRadius: f => 11 - f.properties.scalerank, + getFillColor: [200, 0, 80, 180], + pickable: true, + autoHighlight: true + }), + new ArcLayer({ + id: 'arcs', + data: AIR_PORTS, + dataTransform: d => d.features.filter(f => f.properties.scalerank < 4), + getSourcePosition: () => [-0.4531566, 51.4709959], + getTargetPosition: f => f.geometry.coordinates, + getSourceColor: [0, 128, 200], + getTargetColor: [200, 0, 80], + getWidth: 1 + }) + ]; + + // Widgets with viewId: 'mapbox' — positioned by the map's control container + const mapboxWidgets = [ + new FullscreenWidget({ + id: 'fs-mapbox', + viewId: 'mapbox', + placement: 'top-left', + onFullscreenChange: fs => setLastCallback(`FullscreenWidget.onFullscreenChange(${fs})`) + }), + new LoadingWidget({ + id: 'loading-mapbox', + viewId: 'mapbox', + placement: 'top-left', + onLoadingChange: setLoading + }), + new CompassWidget({ + id: 'compass-mapbox', + viewId: 'mapbox', + placement: 'top-right', + onReset: ({bearing, pitch}) => + setLastCallback(`CompassWidget.onReset(bearing=${bearing}, pitch=${pitch})`) + }), + new ZoomWidget({ + id: 'zoom-mapbox', + viewId: 'mapbox', + placement: 'top-right', + onZoom: ({delta, zoom}) => + setLastCallback(`ZoomWidget.onZoom(delta=${delta}, zoom=${zoom.toFixed(1)})`) + }) + ]; + + // Regular widgets — positioned by deck's overlay + const regularWidgets = [ + new PopupWidget({ + id: 'popup-regular', + position: [-5, 52], + content: `Regular deck overlay widget`, + closeOnClickOutside: true + }), + new ThemeWidget({ + id: 'theme-regular', + placement: 'bottom-left', + themeMode, + onThemeModeChange: setThemeMode + }) + ]; + + return ( + <> + w.id).join(', '), + 'regular widgets': regularWidgets.map(w => w.id).join(', '), + themeMode, + loading, + lastCallback + }} + /> +
+ +
+ + + + + + ); +} + +// ─── App Shell ─────────────────────────────────────────────────────── + +type TabId = 'mapview' | 'infovis' | 'multiview' | 'basemap'; + +const TABS: {id: TabId; label: string}[] = [ + {id: 'mapview', label: 'MapView'}, + {id: 'infovis', label: 'InfoVis'}, + {id: 'multiview', label: 'Multi-View'}, + {id: 'basemap', label: 'Basemap'} +]; + +function App() { + const [tab, setTab] = useState('mapview'); + + return ( + <> +
+ {TABS.map(t => ( + + ))} +
+ {tab === 'mapview' && } + {tab === 'infovis' && } + {tab === 'multiview' && } + {tab === 'basemap' && } + + ); +} + +/* global document */ +const container = document.body.appendChild(document.createElement('div')); +createRoot(container).render(); diff --git a/test/apps/widget-browser/index.html b/test/apps/widget-browser/index.html new file mode 100644 index 00000000000..611f58abb1f --- /dev/null +++ b/test/apps/widget-browser/index.html @@ -0,0 +1,13 @@ + + + + + deck.gl Widget Browser + + + + + + diff --git a/test/apps/widget-browser/package.json b/test/apps/widget-browser/package.json new file mode 100644 index 00000000000..523453e49f9 --- /dev/null +++ b/test/apps/widget-browser/package.json @@ -0,0 +1,31 @@ +{ + "name": "deckgl-test-widget-browser", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.mjs", + "build": "vite build" + }, + "dependencies": { + "deck.gl": "^9.0.0", + "@deck.gl/core": "^9.0.0", + "@deck.gl/layers": "^9.0.0", + "@deck.gl/extensions": "^9.0.0", + "@deck.gl/geo-layers": "^9.0.0", + "@deck.gl/mapbox": "^9.0.0", + "@deck.gl/widgets": "^9.0.0", + "@deck.gl/react": "^9.0.0", + "maplibre-gl": "^5.0.0", + "react-map-gl": "^8.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "typescript": "^5.0.0", + "vite": "^7.3.1" + } +} diff --git a/test/modules/mapbox/mapbox-gl-mock/map.ts b/test/modules/mapbox/mapbox-gl-mock/map.ts index e7ce48bf21b..accb6380fbc 100644 --- a/test/modules/mapbox/mapbox-gl-mock/map.ts +++ b/test/modules/mapbox/mapbox-gl-mock/map.ts @@ -83,17 +83,23 @@ export default class Map extends Evented { return this.projection; } - addControl(control) { - this._controls.push(control); + addControl(control, position?) { + this._controls.push({ + control, + position: position || control.getDefaultPosition?.() || 'top-right' + }); control.onAdd(this); } removeControl(control) { - const i = this._controls.indexOf(control); + const i = this._controls.findIndex(c => c.control === control); if (i >= 0) { this._controls.splice(i, 1); control.onRemove(this); } } + hasControl(control) { + return this._controls.some(c => c.control === control); + } loaded() { return this._loaded; diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index fe957242212..6bda8ed8e15 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -9,14 +9,41 @@ import {ScatterplotLayer} from '@deck.gl/layers'; import {MapboxOverlay} from '@deck.gl/mapbox'; import {getDeckInstance} from '@deck.gl/mapbox/deck-utils'; import MapboxLayerGroup from '@deck.gl/mapbox/mapbox-layer-group'; -import {_GlobeView as GlobeView, MapView} from '@deck.gl/core'; +import {_GlobeView as GlobeView, MapView, Widget} from '@deck.gl/core'; +import type {WidgetPlacement} from '@deck.gl/core'; import {device} from '@deck.gl/test-utils/vitest'; +import {WebGLDevice} from '@luma.gl/webgl'; import MockMapboxMap from './mapbox-gl-mock/map'; import {DEFAULT_PARAMETERS, approxDeepEqual} from './fixtures'; +// Create an isolated device for overlaid mode tests to prevent GL context corruption +const overlaidTestDevice = new WebGLDevice({createCanvasContext: {width: 1, height: 1}}); + const webglTest = device.type === 'webgl' ? test : test.skip; +// Simple test widget for testing MapboxOverlay widget support +class TestWidget extends Widget<{placement?: WidgetPlacement; viewId?: string | null}> { + static defaultProps = { + ...Widget.defaultProps, + id: 'test-widget', + placement: 'top-left' as WidgetPlacement + }; + + placement: WidgetPlacement = 'top-left'; + className = 'deck-test-widget'; + + constructor(props: {id?: string; placement?: WidgetPlacement; viewId?: string | null} = {}) { + super(props); + this.viewId = props.viewId ?? null; + this.placement = props.placement ?? 'top-left'; + } + + onRenderHTML(rootElement: HTMLElement): void { + rootElement.textContent = this.id; + } +} + function sleep(milliseconds: number): Promise { return new Promise(resolve => { setTimeout(resolve, milliseconds); @@ -48,7 +75,7 @@ test('MapboxOverlay#overlaid', async () => { zoom: 14 }); const overlay = new MapboxOverlay({ - device, + device: overlaidTestDevice, layers: [new ScatterplotLayer()] }); @@ -110,7 +137,9 @@ test('MapboxOverlay#overlaidNoIntitalLayers', async () => { center: {lng: -122.45, lat: 37.78}, zoom: 14 }); - const overlay = new MapboxOverlay({device}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice + }); map.addControl(overlay); @@ -712,7 +741,356 @@ webglTest('MapboxOverlay#renderLayersInGroups - setProps', async () => { await renderPromise; }); -// Tests ported from mapbox-layer.spec.ts, adapted for MapboxLayerGroup +// Widget support tests + +test('MapboxOverlay#widgets - regular widgets render in deck container', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'regular-widget', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'No widget controls for regular widgets').toBe(0); + expect(overlay._deck.props.widgets.includes(widget), 'Widget is passed to Deck').toBeTruthy(); + + map.removeControl(overlay); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Widget control is created').toBe(1); + expect(map.hasControl(overlay._widgetControls[0]), 'Widget control is added to map').toBeTruthy(); + expect(widget.props._container, 'Widget _container is set').toBeTruthy(); + expect( + overlay._deck.props.widgets.includes(widget), + 'Widget is still passed to Deck for events' + ).toBeTruthy(); + + map.removeControl(overlay); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - mixed widgets', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const regularWidget = new TestWidget({id: 'regular', placement: 'top-left'}); + const mapboxWidget1 = new TestWidget({id: 'mapbox1', viewId: 'mapbox', placement: 'top-right'}); + const mapboxWidget2 = new TestWidget({ + id: 'mapbox2', + viewId: 'mapbox', + placement: 'bottom-right' + }); + + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [regularWidget, mapboxWidget1, mapboxWidget2] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Two widget controls for mapbox widgets').toBe(2); + expect(regularWidget.props._container, 'Regular widget _container is not set').toBeFalsy(); + expect(mapboxWidget1.props._container, 'Mapbox widget1 _container is set').toBeTruthy(); + expect(mapboxWidget2.props._container, 'Mapbox widget2 _container is set').toBeTruthy(); + + // All widgets passed to Deck + expect(overlay._deck.props.widgets.length, 'All widgets passed to Deck').toBe(3); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setProps updates widget controls', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget1 = new TestWidget({id: 'widget1', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length, 'Initial widget control created').toBe(1); + + const widget2 = new TestWidget({id: 'widget2', viewId: 'mapbox', placement: 'bottom-left'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls.length, 'Widget control count updated').toBe(1); + expect(widget2.props._container, 'New widget _container is set').toBeTruthy(); + + // Clear all widgets + overlay.setProps({ + widgets: [] + }); + expect(overlay._widgetControls.length, 'Widget controls cleared').toBe(0); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setProps preserves container for same widget instance', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'widget1', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length, 'Widget control created').toBe(1); + const originalContainer = widget.props._container; + expect(originalContainer, 'Widget _container is set').toBeTruthy(); + const originalControl = overlay._widgetControls[0]; + + // Call setProps with the same widget instance + overlay.setProps({ + widgets: [widget] + }); + + expect(overlay._widgetControls.length, 'Still one widget control').toBe(1); + expect(overlay._widgetControls[0], 'Same control instance preserved').toBe(originalControl); + expect(widget.props._container, 'Container preserved - not recreated').toBe(originalContainer); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setProps preserves container for new widget instance with same id', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget1 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length, 'Widget control created').toBe(1); + const originalContainer = widget1.props._container; + expect(originalContainer, 'Widget _container is set').toBeTruthy(); + const originalControl = overlay._widgetControls[0]; + + // Call setProps with a NEW widget instance but same id and placement (React pattern) + const widget2 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls.length, 'Still one widget control').toBe(1); + expect(overlay._widgetControls[0], 'Same control instance preserved').toBe(originalControl); + expect(widget2.props._container, 'New widget gets existing container').toBe(originalContainer); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - interleaved mode', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + interleaved: true, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Widget control is created in interleaved mode').toBe(1); + expect(widget.props._container, 'Widget _container is set').toBeTruthy(); + + map.removeControl(overlay); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); +}); + +test('MapboxOverlay#widgets - placement change recreates control', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length).toBe(1); + const originalControl = overlay._widgetControls[0]; + const originalContainer = widget.props._container; + + // Same id but different placement - should recreate the control + const widget2 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'bottom-left'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls.length, 'Still one widget control').toBe(1); + expect( + overlay._widgetControls[0] !== originalControl, + 'New control instance created' + ).toBeTruthy(); + expect(widget2.props._container, 'New widget has _container set').toBeTruthy(); + expect( + widget2.props._container !== originalContainer, + 'New container created for new placement' + ).toBeTruthy(); + expect(map.hasControl(originalControl), 'Old control removed from map').toBeFalsy(); + expect(map.hasControl(overlay._widgetControls[0]), 'New control added to map').toBeTruthy(); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setWidget updates control widget reference', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget1 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + const control = overlay._widgetControls[0]; + expect(control.widget, 'Control initially references widget1').toBe(widget1); + + // New instance with same id and placement - control is reused + const widget2 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls[0], 'Same control instance reused').toBe(control); + expect(control.widget, 'Control widget reference updated to widget2').toBe(widget2); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - onRemove clears widget _container', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + expect(widget.props._container, '_container is set after addControl').toBeTruthy(); + + map.removeControl(overlay); + expect(widget.props._container, '_container is cleared after removeControl').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - getDefaultPosition maps placement correctly', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const topRight = new TestWidget({id: 'w1', viewId: 'mapbox', placement: 'top-right'}); + const bottomLeft = new TestWidget({id: 'w2', viewId: 'mapbox', placement: 'bottom-left'}); + const fillWidget = new TestWidget({id: 'w3', viewId: 'mapbox', placement: 'fill'}); + + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [topRight, bottomLeft, fillWidget] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length).toBe(3); + + // Check that mock map recorded the correct positions + const controlEntries = map._controls.filter( + c => c.control !== overlay // exclude the overlay itself + ); + const positions = controlEntries.map(c => c.position); + expect(positions, 'Positions match widget placements').toEqual([ + 'top-right', + 'bottom-left', + 'top-left' // 'fill' falls back to 'top-left' + ]); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - null widgets in array are filtered', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'valid-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [null as any, widget, undefined as any] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Only valid mapbox widget creates a control').toBe(1); + expect(widget.props._container, 'Valid widget _container is set').toBeTruthy(); + + map.removeControl(overlay); +}); test('MapboxLayerGroup#external Deck lifecycle', async () => { const deck = new Deck({