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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/api-reference/mapbox/mapbox-overlay.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,61 @@ 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',
container: map.getContainer()
})
]
});
```

#### 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',
container: map.getContainer()
}),
// 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. |
| **Fullscreen** | `FullscreenWidget` | Set `container: map.getContainer()` so that the basemap and map controls are included in the fullscreen element. |

Other informational widgets (`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
1 change: 1 addition & 0 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ Class-specific improvements:
- Multi-view setups now work consistently across overlaid and interleaved modes.
- Basemap now works correctly when canvas has zero dimensions.
- Heatmap layer now blends correctly in interleaved mode.
- 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.

### @deck.gl/google-maps

Expand Down
2 changes: 1 addition & 1 deletion examples/get-started/pure-js/mapbox/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import {MapboxOverlay as DeckOverlay} from '@deck.gl/mapbox';
import {GeoJsonLayer, ArcLayer} from '@deck.gl/layers';
import mapboxgl from 'mapbox-gl';
import mapboxgl from 'mapbox-gl'; // eslint-disable-line import/default
import 'mapbox-gl/dist/mapbox-gl.css';

// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
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<any>;
private _container: HTMLDivElement | null = null;

constructor(widget: Widget<any>) {
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;
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
* 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<any> {
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<any>): void {
this._widget = widget;
}
}
86 changes: 85 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 @@ -73,6 +76,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 @@ -107,6 +116,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 @@ -147,6 +160,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 @@ -172,11 +190,77 @@ 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<any>[] | undefined): void {
const map = this._map;
if (!map) return;

const mapboxWidgets = widgets?.filter(w => w && w.viewId === 'mapbox') ?? [];
Comment thread
cursor[bot] marked this conversation as resolved.

// 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) {
Comment thread
cursor[bot] marked this conversation as resolved.
// 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);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
} 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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

/** Called when the control is removed from a map */
onRemove(): void {
const map = this._map;

if (map) {
// Mapbox/MapLibre remove the overlay from their control list before calling
// onRemove(), except during map.remove(), which iterates the list directly.
// Do not mutate that list while it is being iterated.
if (!map.hasControl(this)) {
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
17 changes: 13 additions & 4 deletions test/modules/mapbox/mapbox-gl-mock/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,22 @@ export default class Map extends Evented {
return this.projection;
}

addControl(control) {
this._controls.push(control);
addControl(control, position?) {
control.onAdd(this);
this._controls.push({
control,
position: position || control.getDefaultPosition?.() || 'top-right'
});
}
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);
}
control.onRemove(this);
}
hasControl(control) {
return this._controls.some(c => c.control === control);
}

loaded() {
Expand Down Expand Up @@ -145,6 +151,9 @@ export default class Map extends Evented {
}

remove() {
for (const {control} of this._controls) {
control.onRemove(this);
}
this._controls = [];
this.style = null;
}
Expand Down
Loading