Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
90ca74b
feat(mapbox): Add widget support to MapboxOverlay via IControl adapter
chrisgervang Jan 25, 2026
c042e51
test(mapbox): Add tests for widget support in MapboxOverlay
chrisgervang Jan 25, 2026
0d22779
test(mapbox): Use isolated WebGL device for overlaid mode tests
chrisgervang Jan 27, 2026
6ac76e3
fix(mapbox): Preserve widget container on setProps to prevent orphane…
chrisgervang Jan 29, 2026
b3e8bd6
docs(mapbox): Mark DeckWidgetControl as internal
chrisgervang Feb 4, 2026
1bc5480
fix(mapbox): Handle null widgets defensively in _processWidgets
chrisgervang Feb 5, 2026
e793c90
fix(mapbox): Update widget reference when reusing DeckWidgetControl
chrisgervang Feb 5, 2026
12cb439
Merge origin/master into chr/mapbox-widget-support
chrisgervang Apr 1, 2026
6253be2
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 1, 2026
e65714b
fix(mapbox): Convert widget tests from tape to vitest assertions
chrisgervang Apr 1, 2026
7dee0ed
Merge branch 'master' into chr/mapbox-widget-support
chrisgervang Apr 2, 2026
70853f4
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 6, 2026
c9e3935
test(mapbox): Add widget unit tests for coverage gaps
chrisgervang Apr 6, 2026
5eb1fd8
feat(examples): Add mixed widgets + native controls to maplibre example
chrisgervang Apr 6, 2026
c916ef9
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 6, 2026
267dcbe
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 6, 2026
b1babd7
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 6, 2026
88dc10d
docs(mapbox): Add widget guide and what's-new entry for basemap integ…
chrisgervang Apr 7, 2026
afec60a
fix(docs): Fix broken widget link in MapboxOverlay docs
chrisgervang Apr 7, 2026
6e5af4f
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 7, 2026
85e7f66
fix(widgets): Suppress widget margin inside basemap control containers
chrisgervang Apr 7, 2026
7f52cbd
feat(test): Add widget-browser test app consolidating widget test sce…
chrisgervang Apr 7, 2026
4732990
Merge remote-tracking branch 'origin/master' into chr/mapbox-widget-s…
chrisgervang Apr 7, 2026
dca92b5
docs(whats-new): Add PR links to basemap integration entries
chrisgervang Apr 7, 2026
e4dda1a
docs(whats-new): Remove duplicate sections and reorder by audience size
chrisgervang Apr 7, 2026
e361d75
docs(whats-new): Move Views items into Improved 3D Support section
chrisgervang Apr 7, 2026
4c33b76
docs(whats-new): Restore Views as its own section after Improved 3D S…
chrisgervang Apr 7, 2026
bc131cc
docs(whats-new): Split basemap sections by module, fix wording
chrisgervang Apr 7, 2026
5627955
Merge branch 'master' into chr/mapbox-widget-support
chrisgervang Apr 7, 2026
3ef91cd
Merge branch 'master' into chr/mapbox-widget-support
chrisgervang Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/api-reference/mapbox/mapbox-overlay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 13 additions & 5 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion examples/get-started/pure-js/maplibre/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -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');
1 change: 1 addition & 0 deletions examples/get-started/pure-js/maplibre/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
78 changes: 78 additions & 0 deletions modules/mapbox/src/deck-widget-control.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
82 changes: 81 additions & 1 deletion modules/mapbox/src/mapbox-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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<any>({
...this._props,
parent: container,
Expand Down Expand Up @@ -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({
Expand All @@ -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') ?? [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hardcoded string instead of imported constant MAPBOX_VIEW_ID

Low Severity

_processWidgets uses the hardcoded string 'mapbox' to filter widgets, but the constant MAPBOX_VIEW_ID is already imported in this same file (line 13) and used for the same conceptual purpose at line 348 in _getViews. Using the literal instead of the constant creates an inconsistency — if MAPBOX_VIEW_ID were ever updated, this filter would silently stop matching.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1babd7. Configure here.


// Build a map of existing controls by widget id
const existingControlsById = new Map<string, DeckWidgetControl>();
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Widget control references diverge from WidgetManager's resolved widgets

Low Severity

When reusing a control for a new widget instance with the same id, setWidget(widget) updates the control to reference the new widget instance. However, WidgetManager._setWidgets independently reuses the old widget instance (calling oldWidget.setProps(widget.props) and keeping oldWidget). This means DeckWidgetControl._widget and the widget actually managed by WidgetManager are different objects. Consequently, DeckWidgetControl.onRemove clears _container on the wrong (unmanaged) widget instance, leaving the managed widget's _container stale and pointing to a removed DOM element.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1babd7. Configure here.

} 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 {
Expand Down
6 changes: 6 additions & 0 deletions modules/widgets/src/stylesheet.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading