From 90ca74b8b1b1b10aa946a8e9ee20bd080fe1473c Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Sun, 25 Jan 2026 11:30:24 -0800 Subject: [PATCH 01/19] feat(mapbox): Add widget support to MapboxOverlay via IControl adapter Enable deck widgets to coexist with native Mapbox/MapLibre controls without DOM overlap by rendering widgets with viewId: 'mapbox' into the map's control container system. - Add DeckWidgetControl class that wraps deck widgets as IControls - Process widgets with viewId: 'mapbox' in MapboxOverlay - Set widget._container to Mapbox-positioned element via IControl - Widgets stack correctly with native controls in same container Closes #9962 Co-Authored-By: Claude Haiku 4.5 --- modules/mapbox/src/deck-widget-control.ts | 78 +++++++++++++++++++++++ modules/mapbox/src/mapbox-overlay.ts | 55 +++++++++++++++- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 modules/mapbox/src/deck-widget-control.ts diff --git a/modules/mapbox/src/deck-widget-control.ts b/modules/mapbox/src/deck-widget-control.ts new file mode 100644 index 00000000000..a364cadba87 --- /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. + * + * Used internally by MapboxOverlay for widgets with `viewId: 'mapbox'`. + * Can also be used directly for more control over widget positioning. + * + * @example + * ```typescript + * const zoomWidget = new ZoomWidget({placement: 'top-right'}); + * const control = new DeckWidgetControl(zoomWidget); + * map.addControl(control, 'top-right'); + * ``` + */ +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; + } +} diff --git a/modules/mapbox/src/mapbox-overlay.ts b/modules/mapbox/src/mapbox-overlay.ts index 8241f297fe4..e3647cde499 100644 --- a/modules/mapbox/src/mapbox-overlay.ts +++ b/modules/mapbox/src/mapbox-overlay.ts @@ -11,10 +11,11 @@ import { getDefaultParameters, getProjection } 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 {resolveLayers} from './resolve-layers'; import {resolveLayerGroups} from './resolve-layer-groups'; @@ -55,6 +56,8 @@ export default class MapboxOverlay implements IControl { private _interleaved: boolean; private _renderLayersInGroups: 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; @@ -79,6 +82,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) { @@ -112,6 +121,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, @@ -143,6 +156,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({ @@ -171,11 +189,46 @@ export default class MapboxOverlay implements IControl { } } + /** + * 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. + */ + private _processWidgets(widgets: Widget[] | undefined): void { + const map = this._map; + if (!map) return; + + // Remove old widget controls + for (const control of this._widgetControls) { + map.removeControl(control); + } + this._widgetControls = []; + + if (!widgets) return; + + // Wrap widgets with viewId: 'mapbox' as IControls + for (const widget of widgets) { + if (widget.viewId === 'mapbox') { + const control = new DeckWidgetControl(widget); + // Add to map - this calls onAdd() synchronously, setting _container + // Use control.getDefaultPosition() which handles 'fill' -> 'top-left' conversion + map.addControl(control, control.getDefaultPosition()); + this._widgetControls.push(control); + } + } + } + /** 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 { From c042e516c1e7f436888268dd83e25d977a614f5e Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Sun, 25 Jan 2026 11:35:18 -0800 Subject: [PATCH 02/19] test(mapbox): Add tests for widget support in MapboxOverlay - Add TestWidget class for testing widget integration - Test regular widgets render in deck container - Test widgets with viewId: 'mapbox' are wrapped as IControl - Test mixed widgets (regular + mapbox) - Test setProps updates widget controls - Test interleaved mode widget support - Update mock map to support control positions and hasControl Co-Authored-By: Claude Haiku 4.5 --- test/modules/mapbox/mapbox-gl-mock/map.ts | 12 +- test/modules/mapbox/mapbox-overlay.spec.ts | 167 ++++++++++++++++++++- 2 files changed, 175 insertions(+), 4 deletions(-) diff --git a/test/modules/mapbox/mapbox-gl-mock/map.ts b/test/modules/mapbox/mapbox-gl-mock/map.ts index b3d4c57e4ee..5a78646f1c1 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 05385a210f3..366ca191b62 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -6,12 +6,35 @@ import test from 'tape-promise/tape'; import {ScatterplotLayer} from '@deck.gl/layers'; import {MapboxOverlay} from '@deck.gl/mapbox'; -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 {objectEqual} from './mapbox-layer.spec'; import MockMapboxMap from './mapbox-gl-mock/map'; import {DEFAULT_PARAMETERS} from './fixtures'; +// 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); @@ -476,3 +499,145 @@ test('MapboxOverlay#renderLayersInGroups - setProps', t => { t.end(); }); }); + +// Widget support tests + +test('MapboxOverlay#widgets - regular widgets render in deck container', t => { + 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({ + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + t.ok(overlay._deck, 'Deck instance is created'); + t.is(overlay._widgetControls.length, 0, 'No widget controls for regular widgets'); + t.ok(overlay._deck.props.widgets.includes(widget), 'Widget is passed to Deck'); + + map.removeControl(overlay); + t.notOk(overlay._deck, 'Deck instance is finalized'); + t.end(); +}); + +test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', t => { + 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({ + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + t.ok(overlay._deck, 'Deck instance is created'); + t.is(overlay._widgetControls.length, 1, 'Widget control is created'); + t.ok(map.hasControl(overlay._widgetControls[0]), 'Widget control is added to map'); + t.ok(widget.props._container, 'Widget _container is set'); + t.ok(overlay._deck.props.widgets.includes(widget), 'Widget is still passed to Deck for events'); + + map.removeControl(overlay); + t.is(overlay._widgetControls.length, 0, 'Widget controls are cleaned up'); + t.notOk(overlay._deck, 'Deck instance is finalized'); + t.end(); +}); + +test('MapboxOverlay#widgets - mixed widgets', t => { + 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({ + layers: [new ScatterplotLayer()], + widgets: [regularWidget, mapboxWidget1, mapboxWidget2] + }); + + map.addControl(overlay); + + t.ok(overlay._deck, 'Deck instance is created'); + t.is(overlay._widgetControls.length, 2, 'Two widget controls for mapbox widgets'); + t.notOk(regularWidget.props._container, 'Regular widget _container is not set'); + t.ok(mapboxWidget1.props._container, 'Mapbox widget1 _container is set'); + t.ok(mapboxWidget2.props._container, 'Mapbox widget2 _container is set'); + + // All widgets passed to Deck + t.is(overlay._deck.props.widgets.length, 3, 'All widgets passed to Deck'); + + map.removeControl(overlay); + t.end(); +}); + +test('MapboxOverlay#widgets - setProps updates widget controls', t => { + 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({ + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + t.is(overlay._widgetControls.length, 1, 'Initial widget control created'); + + const widget2 = new TestWidget({id: 'widget2', viewId: 'mapbox', placement: 'bottom-left'}); + overlay.setProps({ + widgets: [widget2] + }); + + t.is(overlay._widgetControls.length, 1, 'Widget control count updated'); + t.ok(widget2.props._container, 'New widget _container is set'); + + // Clear all widgets + overlay.setProps({ + widgets: [] + }); + t.is(overlay._widgetControls.length, 0, 'Widget controls cleared'); + + map.removeControl(overlay); + t.end(); +}); + +test('MapboxOverlay#widgets - interleaved mode', t => { + 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); + + t.ok(overlay._deck, 'Deck instance is created'); + t.is(overlay._widgetControls.length, 1, 'Widget control is created in interleaved mode'); + t.ok(widget.props._container, 'Widget _container is set'); + + map.removeControl(overlay); + t.is(overlay._widgetControls.length, 0, 'Widget controls are cleaned up'); + t.end(); +}); From 0d22779abde9d291b271a06bfb89557c12a794b1 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Tue, 27 Jan 2026 14:23:04 -0800 Subject: [PATCH 03/19] test(mapbox): Use isolated WebGL device for overlaid mode tests Overlaid mode tests create their own Deck instances which can cause GL context corruption when sharing contexts across tests. Using an isolated WebGLDevice for these tests prevents hangs in the test suite. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- modules/test-utils/src/utils/setup-gl.ts | 2 -- test/modules/mapbox/mapbox-overlay.spec.ts | 13 ++++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/modules/test-utils/src/utils/setup-gl.ts b/modules/test-utils/src/utils/setup-gl.ts index c47c2571294..71d6f5286d2 100644 --- a/modules/test-utils/src/utils/setup-gl.ts +++ b/modules/test-utils/src/utils/setup-gl.ts @@ -2,8 +2,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {CanvasContextProps} from '@luma.gl/core'; -import {WebGLDevice} from '@luma.gl/webgl'; import {webglDevice, NullDevice} from '@luma.gl/test-utils'; export const device = webglDevice || new NullDevice({}); diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index 366ca191b62..df25fd79787 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -8,11 +8,15 @@ import {ScatterplotLayer} from '@deck.gl/layers'; import {MapboxOverlay} from '@deck.gl/mapbox'; import {_GlobeView as GlobeView, MapView, Widget} from '@deck.gl/core'; import type {WidgetPlacement} from '@deck.gl/core'; +import {WebGLDevice} from '@luma.gl/webgl'; import {objectEqual} from './mapbox-layer.spec'; import MockMapboxMap from './mapbox-gl-mock/map'; import {DEFAULT_PARAMETERS} from './fixtures'; +// Create an isolated device for overlaid mode tests to prevent GL context corruption +const overlaidTestDevice = new WebGLDevice({createCanvasContext: {width: 1, height: 1}}); + // Simple test widget for testing MapboxOverlay widget support class TestWidget extends Widget<{placement?: WidgetPlacement; viewId?: string | null}> { static defaultProps = { @@ -58,6 +62,7 @@ test('MapboxOverlay#overlaid', t => { zoom: 14 }); const overlay = new MapboxOverlay({ + device: overlaidTestDevice, layers: [new ScatterplotLayer()] }); @@ -120,7 +125,9 @@ test('MapboxOverlay#overlaidNoIntitalLayers', t => { center: {lng: -122.45, lat: 37.78}, zoom: 14 }); - const overlay = new MapboxOverlay({}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice + }); map.addControl(overlay); @@ -510,6 +517,7 @@ test('MapboxOverlay#widgets - regular widgets render in deck container', t => { const widget = new TestWidget({id: 'regular-widget', placement: 'top-right'}); const overlay = new MapboxOverlay({ + device: overlaidTestDevice, layers: [new ScatterplotLayer()], widgets: [widget] }); @@ -533,6 +541,7 @@ test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', t => { const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); const overlay = new MapboxOverlay({ + device: overlaidTestDevice, layers: [new ScatterplotLayer()], widgets: [widget] }); @@ -566,6 +575,7 @@ test('MapboxOverlay#widgets - mixed widgets', t => { }); const overlay = new MapboxOverlay({ + device: overlaidTestDevice, layers: [new ScatterplotLayer()], widgets: [regularWidget, mapboxWidget1, mapboxWidget2] }); @@ -593,6 +603,7 @@ test('MapboxOverlay#widgets - setProps updates widget controls', t => { const widget1 = new TestWidget({id: 'widget1', viewId: 'mapbox', placement: 'top-right'}); const overlay = new MapboxOverlay({ + device: overlaidTestDevice, layers: [new ScatterplotLayer()], widgets: [widget1] }); From 6ac76e3b23170136a5031ddf5ed8d3d4ab00fd14 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Wed, 28 Jan 2026 16:22:02 -0800 Subject: [PATCH 04/19] fix(mapbox): Preserve widget container on setProps to prevent orphaned rootElement When setProps was called with widgets, _processWidgets would destroy and recreate all DeckWidgetControls. This removed the container from the DOM, orphaning the widget's rootElement since WidgetManager wouldn't re-attach it (same widget id means no change detected). Now matches widgets by id (like WidgetManager) and only recreates controls when the widget is new or placement changes. For existing widgets with same id and placement, the container is preserved and copied to the new widget instance to support React patterns where widgets are recreated on render. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- modules/mapbox/src/mapbox-overlay.ts | 43 +++++++++++--- test/modules/mapbox/mapbox-overlay.spec.ts | 65 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/modules/mapbox/src/mapbox-overlay.ts b/modules/mapbox/src/mapbox-overlay.ts index e3647cde499..d5e2efb857c 100644 --- a/modules/mapbox/src/mapbox-overlay.ts +++ b/modules/mapbox/src/mapbox-overlay.ts @@ -193,29 +193,54 @@ export default class MapboxOverlay implements IControl { * 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; - // Remove old widget controls + const mapboxWidgets = widgets?.filter(w => w.viewId === 'mapbox') ?? []; + + // Build a map of existing controls by widget id + const existingControlsById = new Map(); for (const control of this._widgetControls) { - map.removeControl(control); + existingControlsById.set(control.widget.id, control); } - this._widgetControls = []; - if (!widgets) return; + const newControls: DeckWidgetControl[] = []; + + for (const widget of mapboxWidgets) { + const existingControl = existingControlsById.get(widget.id); - // Wrap widgets with viewId: 'mapbox' as IControls - for (const widget of widgets) { - if (widget.viewId === 'mapbox') { + 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; + 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 - // Use control.getDefaultPosition() which handles 'fill' -> 'top-left' conversion map.addControl(control, control.getDefaultPosition()); - this._widgetControls.push(control); + 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 */ diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index df25fd79787..3a513736ee8 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -629,6 +629,71 @@ test('MapboxOverlay#widgets - setProps updates widget controls', t => { t.end(); }); +test('MapboxOverlay#widgets - setProps preserves container for same widget instance', t => { + 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); + t.is(overlay._widgetControls.length, 1, 'Widget control created'); + const originalContainer = widget.props._container; + t.ok(originalContainer, 'Widget _container is set'); + const originalControl = overlay._widgetControls[0]; + + // Call setProps with the same widget instance + overlay.setProps({ + widgets: [widget] + }); + + t.is(overlay._widgetControls.length, 1, 'Still one widget control'); + t.is(overlay._widgetControls[0], originalControl, 'Same control instance preserved'); + t.is(widget.props._container, originalContainer, 'Container preserved - not recreated'); + + map.removeControl(overlay); + t.end(); +}); + +test('MapboxOverlay#widgets - setProps preserves container for new widget instance with same id', t => { + 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); + t.is(overlay._widgetControls.length, 1, 'Widget control created'); + const originalContainer = widget1.props._container; + t.ok(originalContainer, 'Widget _container is set'); + 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] + }); + + t.is(overlay._widgetControls.length, 1, 'Still one widget control'); + t.is(overlay._widgetControls[0], originalControl, 'Same control instance preserved'); + t.is(widget2.props._container, originalContainer, 'New widget gets existing container'); + + map.removeControl(overlay); + t.end(); +}); + test('MapboxOverlay#widgets - interleaved mode', t => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, From b3e8bd6bf07a6adb61f93f952f046edad114e164 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Wed, 4 Feb 2026 15:58:27 -0800 Subject: [PATCH 05/19] docs(mapbox): Mark DeckWidgetControl as internal Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- modules/mapbox/src/deck-widget-control.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/modules/mapbox/src/deck-widget-control.ts b/modules/mapbox/src/deck-widget-control.ts index a364cadba87..64c24b0b533 100644 --- a/modules/mapbox/src/deck-widget-control.ts +++ b/modules/mapbox/src/deck-widget-control.ts @@ -11,15 +11,7 @@ import type {IControl, ControlPosition, Map} from './types'; * This enables deck widgets to be positioned alongside native map controls * in the same DOM container, preventing overlap issues. * - * Used internally by MapboxOverlay for widgets with `viewId: 'mapbox'`. - * Can also be used directly for more control over widget positioning. - * - * @example - * ```typescript - * const zoomWidget = new ZoomWidget({placement: 'top-right'}); - * const control = new DeckWidgetControl(zoomWidget); - * map.addControl(control, 'top-right'); - * ``` + * @internal Used by MapboxOverlay for widgets with `viewId: 'mapbox'`. */ export class DeckWidgetControl implements IControl { private _widget: Widget; From 1bc548031d8eeaaebe0a78541a18105e6c4df105 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Wed, 4 Feb 2026 16:00:22 -0800 Subject: [PATCH 06/19] fix(mapbox): Handle null widgets defensively in _processWidgets Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- modules/mapbox/src/mapbox-overlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mapbox/src/mapbox-overlay.ts b/modules/mapbox/src/mapbox-overlay.ts index d5e2efb857c..1a2ebf9ed22 100644 --- a/modules/mapbox/src/mapbox-overlay.ts +++ b/modules/mapbox/src/mapbox-overlay.ts @@ -202,7 +202,7 @@ export default class MapboxOverlay implements IControl { const map = this._map; if (!map) return; - const mapboxWidgets = widgets?.filter(w => w.viewId === 'mapbox') ?? []; + const mapboxWidgets = widgets?.filter(w => w && w.viewId === 'mapbox') ?? []; // Build a map of existing controls by widget id const existingControlsById = new Map(); From e793c904d9726068d4af729c452ceea623059f6f Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Wed, 4 Feb 2026 16:08:30 -0800 Subject: [PATCH 07/19] fix(mapbox): Update widget reference when reusing DeckWidgetControl When _processWidgets reuses a DeckWidgetControl for a new widget instance with the same id and placement, the control's internal _widget reference must be updated. Otherwise the control references the old widget, causing incorrect behavior in onRemove() and potential memory leaks. Co-Authored-By: Claude Opus 4.5 --- modules/mapbox/src/deck-widget-control.ts | 8 ++++++++ modules/mapbox/src/mapbox-overlay.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/modules/mapbox/src/deck-widget-control.ts b/modules/mapbox/src/deck-widget-control.ts index 64c24b0b533..7719e19fc19 100644 --- a/modules/mapbox/src/deck-widget-control.ts +++ b/modules/mapbox/src/deck-widget-control.ts @@ -67,4 +67,12 @@ export class DeckWidgetControl implements IControl { 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 1a2ebf9ed22..3d858ccf95a 100644 --- a/modules/mapbox/src/mapbox-overlay.ts +++ b/modules/mapbox/src/mapbox-overlay.ts @@ -219,6 +219,8 @@ export default class MapboxOverlay implements IControl { // 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 { From e65714b1979781e2e84cf4b9345a72f025d8b821 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Wed, 1 Apr 2026 11:08:29 -0700 Subject: [PATCH 08/19] fix(mapbox): Convert widget tests from tape to vitest assertions The widget tests used tape-style assertions (t.ok, t.is, t.notOk, t.end) but the file uses vitest's test() which doesn't pass a t argument. Converted all widget tests to use vitest's expect() style. Co-Authored-By: Claude Opus 4.6 --- test/modules/mapbox/mapbox-overlay.spec.ts | 94 +++++++++++----------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index 64bd2e59e1c..87a3920505b 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -519,7 +519,7 @@ webglTest('MapboxOverlay#renderLayersInGroups - setProps', async () => { // Widget support tests -test('MapboxOverlay#widgets - regular widgets render in deck container', t => { +test('MapboxOverlay#widgets - regular widgets render in deck container', () => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, zoom: 14 @@ -534,16 +534,15 @@ test('MapboxOverlay#widgets - regular widgets render in deck container', t => { map.addControl(overlay); - t.ok(overlay._deck, 'Deck instance is created'); - t.is(overlay._widgetControls.length, 0, 'No widget controls for regular widgets'); - t.ok(overlay._deck.props.widgets.includes(widget), 'Widget is passed to Deck'); + 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); - t.notOk(overlay._deck, 'Deck instance is finalized'); - t.end(); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); }); -test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', t => { +test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', () => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, zoom: 14 @@ -558,19 +557,21 @@ test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', t => { map.addControl(overlay); - t.ok(overlay._deck, 'Deck instance is created'); - t.is(overlay._widgetControls.length, 1, 'Widget control is created'); - t.ok(map.hasControl(overlay._widgetControls[0]), 'Widget control is added to map'); - t.ok(widget.props._container, 'Widget _container is set'); - t.ok(overlay._deck.props.widgets.includes(widget), 'Widget is still passed to Deck for events'); + 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); - t.is(overlay._widgetControls.length, 0, 'Widget controls are cleaned up'); - t.notOk(overlay._deck, 'Deck instance is finalized'); - t.end(); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); }); -test('MapboxOverlay#widgets - mixed widgets', t => { +test('MapboxOverlay#widgets - mixed widgets', () => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, zoom: 14 @@ -592,20 +593,19 @@ test('MapboxOverlay#widgets - mixed widgets', t => { map.addControl(overlay); - t.ok(overlay._deck, 'Deck instance is created'); - t.is(overlay._widgetControls.length, 2, 'Two widget controls for mapbox widgets'); - t.notOk(regularWidget.props._container, 'Regular widget _container is not set'); - t.ok(mapboxWidget1.props._container, 'Mapbox widget1 _container is set'); - t.ok(mapboxWidget2.props._container, 'Mapbox widget2 _container is set'); + 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 - t.is(overlay._deck.props.widgets.length, 3, 'All widgets passed to Deck'); + expect(overlay._deck.props.widgets.length, 'All widgets passed to Deck').toBe(3); map.removeControl(overlay); - t.end(); }); -test('MapboxOverlay#widgets - setProps updates widget controls', t => { +test('MapboxOverlay#widgets - setProps updates widget controls', () => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, zoom: 14 @@ -619,27 +619,26 @@ test('MapboxOverlay#widgets - setProps updates widget controls', t => { }); map.addControl(overlay); - t.is(overlay._widgetControls.length, 1, 'Initial widget control created'); + 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] }); - t.is(overlay._widgetControls.length, 1, 'Widget control count updated'); - t.ok(widget2.props._container, 'New widget _container is set'); + 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: [] }); - t.is(overlay._widgetControls.length, 0, 'Widget controls cleared'); + expect(overlay._widgetControls.length, 'Widget controls cleared').toBe(0); map.removeControl(overlay); - t.end(); }); -test('MapboxOverlay#widgets - setProps preserves container for same widget instance', t => { +test('MapboxOverlay#widgets - setProps preserves container for same widget instance', () => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, zoom: 14 @@ -653,9 +652,9 @@ test('MapboxOverlay#widgets - setProps preserves container for same widget insta }); map.addControl(overlay); - t.is(overlay._widgetControls.length, 1, 'Widget control created'); + expect(overlay._widgetControls.length, 'Widget control created').toBe(1); const originalContainer = widget.props._container; - t.ok(originalContainer, 'Widget _container is set'); + expect(originalContainer, 'Widget _container is set').toBeTruthy(); const originalControl = overlay._widgetControls[0]; // Call setProps with the same widget instance @@ -663,15 +662,14 @@ test('MapboxOverlay#widgets - setProps preserves container for same widget insta widgets: [widget] }); - t.is(overlay._widgetControls.length, 1, 'Still one widget control'); - t.is(overlay._widgetControls[0], originalControl, 'Same control instance preserved'); - t.is(widget.props._container, originalContainer, 'Container preserved - not recreated'); + 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); - t.end(); }); -test('MapboxOverlay#widgets - setProps preserves container for new widget instance with same id', t => { +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 @@ -685,9 +683,9 @@ test('MapboxOverlay#widgets - setProps preserves container for new widget instan }); map.addControl(overlay); - t.is(overlay._widgetControls.length, 1, 'Widget control created'); + expect(overlay._widgetControls.length, 'Widget control created').toBe(1); const originalContainer = widget1.props._container; - t.ok(originalContainer, 'Widget _container is set'); + 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) @@ -696,15 +694,14 @@ test('MapboxOverlay#widgets - setProps preserves container for new widget instan widgets: [widget2] }); - t.is(overlay._widgetControls.length, 1, 'Still one widget control'); - t.is(overlay._widgetControls[0], originalControl, 'Same control instance preserved'); - t.is(widget2.props._container, originalContainer, 'New widget gets existing container'); + 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); - t.end(); }); -test('MapboxOverlay#widgets - interleaved mode', t => { +test('MapboxOverlay#widgets - interleaved mode', () => { const map = new MockMapboxMap({ center: {lng: -122.45, lat: 37.78}, zoom: 14 @@ -719,11 +716,10 @@ test('MapboxOverlay#widgets - interleaved mode', t => { map.addControl(overlay); - t.ok(overlay._deck, 'Deck instance is created'); - t.is(overlay._widgetControls.length, 1, 'Widget control is created in interleaved mode'); - t.ok(widget.props._container, 'Widget _container is set'); + 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); - t.is(overlay._widgetControls.length, 0, 'Widget controls are cleaned up'); - t.end(); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); }); From c9e393548cd92e0f3533ecd14f15e701f9114977 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Mon, 6 Apr 2026 12:35:06 -0700 Subject: [PATCH 09/19] test(mapbox): Add widget unit tests for coverage gaps - Placement change recreates control - setWidget updates control widget reference - onRemove clears widget _container - getDefaultPosition maps placement correctly (incl. fill fallback) - Null widgets in array are filtered Co-Authored-By: Claude Opus 4.6 --- test/modules/mapbox/mapbox-overlay.spec.ts | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index 87a3920505b..417a2ea2831 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -723,3 +723,147 @@ test('MapboxOverlay#widgets - interleaved mode', () => { 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); +}); From 5eb1fd894712f0b591f040f22ecc97ec99d280d7 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Mon, 6 Apr 2026 12:36:19 -0700 Subject: [PATCH 10/19] feat(examples): Add mixed widgets + native controls to maplibre example Demonstrates deck.gl widgets (viewId: 'mapbox') coexisting with native NavigationControl in the same map control container. Co-Authored-By: Claude Opus 4.6 --- examples/get-started/pure-js/maplibre/app.js | 14 +++++++++++++- examples/get-started/pure-js/maplibre/package.json | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) 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": { From 88dc10dbcf21516a89a5f195d7b2dc183345d1d5 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Mon, 6 Apr 2026 17:06:16 -0700 Subject: [PATCH 11/19] docs(mapbox): Add widget guide and what's-new entry for basemap integration - Add "Using Widgets" section to MapboxOverlay docs explaining viewId: 'mapbox' positioning, default deck overlay positioning, and known widget limitations (view controls, canvas capture) - Add "Basemap Integration" section to what's-new collecting 9.3 improvements: grouped rendering, auto-injected mapbox view, widget support, and bug fixes - Remove stale comment from test file Co-Authored-By: Claude Opus 4.6 --- docs/api-reference/mapbox/mapbox-overlay.md | 47 +++++++++++++++++++++ docs/whats-new.md | 11 +++-- test/modules/mapbox/mapbox-overlay.spec.ts | 2 - 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index 151ab7ba827..cb80a37f057 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/widget.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 dbe23c787cd..54cf898989e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,9 +13,14 @@ Target release date: March 2026 - [TextLayer](./api-reference/layers/text-layer.md) now supports per-object clipping box; and making text "sticky" when its container is partially off-screen. See a demo with this [new example](https://deck.gl/examples/text-layer-clipping). - WebGPU now materializes constant layer attributes into full buffers through `AttributeManager`, improving compatibility for layers that rely on constant accessors. -### @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. +### Basemap Integration + +- 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. +- `MapboxOverlay` now automatically injects a `MapView` with id `"mapbox"` when custom views are provided, ensuring consistent behavior between overlaid and interleaved modes. +- 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. +- Fixed null viewport handling when the map canvas has zero dimensions. +- Fixed heatmap layer blending in interleaved mode. +- Fixed Google Maps DOM positioning when `interleaved: false`. ### Views diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index 3c56088aed3..6bda8ed8e15 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -1092,8 +1092,6 @@ test('MapboxOverlay#widgets - null widgets in array are filtered', () => { map.removeControl(overlay); }); -// Tests ported from mapbox-layer.spec.ts, adapted for MapboxLayerGroup - test('MapboxLayerGroup#external Deck lifecycle', async () => { const deck = new Deck({ device, From afec60a54116c045c5af0f5b19a4d669a18f4ae8 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Mon, 6 Apr 2026 17:22:59 -0700 Subject: [PATCH 12/19] fix(docs): Fix broken widget link in MapboxOverlay docs Co-Authored-By: Claude Opus 4.6 --- docs/api-reference/mapbox/mapbox-overlay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index cb80a37f057..54337f7b44b 100644 --- a/docs/api-reference/mapbox/mapbox-overlay.md +++ b/docs/api-reference/mapbox/mapbox-overlay.md @@ -166,7 +166,7 @@ See [Deck.getCanvas](../core/deck.md#getcanvas). When using `interleaved: true`, ### Using Widgets -deck.gl [widgets](../widgets/widget.md) can be used with `MapboxOverlay`. There are two positioning modes, controlled by the widget's `viewId` prop: +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) From 85e7f66c60b85411474ae27f0afba6dd78cdca7a Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Mon, 6 Apr 2026 21:27:04 -0700 Subject: [PATCH 13/19] fix(widgets): Suppress widget margin inside basemap control containers When widgets are placed in a basemap control container (via viewId: 'mapbox'), the map already provides spacing between controls. Remove the widget's own margin to prevent double-spacing. Co-Authored-By: Claude Opus 4.6 --- modules/widgets/src/stylesheet.css | 6 ++++++ 1 file changed, 6 insertions(+) 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 { From 7f52cbd07b19c52e4fe3b6d441f9cfe8ab08a1d7 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Mon, 6 Apr 2026 21:29:44 -0700 Subject: [PATCH 14/19] feat(test): Add widget-browser test app consolidating widget test scenarios Combines coverage from widgets-9.2, widgets-infovis, widgets-multi-view-9.2, and controlled-widgets into a single React app with four tabs: - MapView: full widget showcase with controlled state/callbacks - InfoVis: OrbitView + OrthographicView with GimbalWidget, ScrollbarWidget - Multi-View: nested SplitterWidget with 3-way MapView layout - Basemap: MapLibre + MapboxOverlay with viewId: 'mapbox' widget positioning Co-Authored-By: Claude Opus 4.6 --- test/apps/widget-browser/app.tsx | 657 ++++++++++++++++++++++++++ test/apps/widget-browser/index.html | 13 + test/apps/widget-browser/package.json | 31 ++ 3 files changed, 701 insertions(+) create mode 100644 test/apps/widget-browser/app.tsx create mode 100644 test/apps/widget-browser/index.html create mode 100644 test/apps/widget-browser/package.json diff --git a/test/apps/widget-browser/app.tsx b/test/apps/widget-browser/app.tsx new file mode 100644 index 00000000000..2922b75edc4 --- /dev/null +++ b/test/apps/widget-browser/app.tsx @@ -0,0 +1,657 @@ +// 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" + } +} From dca92b5fe9d39cebd52c10de2628b7e5f6bd1060 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Tue, 7 Apr 2026 09:55:08 -0700 Subject: [PATCH 15/19] docs(whats-new): Add PR links to basemap integration entries Co-Authored-By: Claude Opus 4.6 --- docs/whats-new.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 5b517644eb3..c276aa73953 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -15,12 +15,12 @@ Target release date: March 2026 ### Basemap Integration -- 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. -- `MapboxOverlay` now automatically injects a `MapView` with id `"mapbox"` when custom views are provided, ensuring consistent behavior between overlaid and interleaved modes. -- 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. -- Fixed null viewport handling when the map canvas has zero dimensions. -- Fixed heatmap layer blending in interleaved mode. -- Fixed Google Maps DOM positioning when `interleaved: false`. +- 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, ensuring consistent behavior between overlaid and interleaved modes. ([#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)) +- Fixed Google Maps DOM positioning when `interleaved: false`. ([#9992](https://github.com/visgl/deck.gl/pull/9992)) ### Views From e4dda1aa3398b4ac3e8ac9559edf274114760b6a Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Tue, 7 Apr 2026 09:59:03 -0700 Subject: [PATCH 16/19] docs(whats-new): Remove duplicate sections and reorder by audience size Removed duplicate Layers/Views sections that were introduced during merge conflict resolution. Merged basemap integration content (with PR links) into the existing @deck.gl/mapbox section, renamed to "Basemap Integration". Dropped standalone Views section (content covered by Improved 3D Support). Section order is now: Widgets > Layers > Basemap Integration > Improved 3D Support. Co-Authored-By: Claude Opus 4.6 --- docs/whats-new.md | 46 +++++++--------------------------------------- 1 file changed, 7 insertions(+), 39 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index c276aa73953..149153128a0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -6,37 +6,6 @@ This page contains highlights of each deck.gl release. Also check our [vis.gl bl Target release date: March 2026 -### Layers - -![TextLayer clipping feature](https://github.com/visgl/deck.gl-data/blob/master/images/whats-new/text-clipping.gif?raw=true) - -- [TextLayer](./api-reference/layers/text-layer.md) now supports per-object clipping box; and making text "sticky" when its container is partially off-screen. See a demo with this [new example](https://deck.gl/examples/text-layer-clipping). -- WebGPU now materializes constant layer attributes into full buffers through `AttributeManager`, improving compatibility for layers that rely on constant accessors. - -### Basemap Integration - -- 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, ensuring consistent behavior between overlaid and interleaved modes. ([#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)) -- Fixed Google Maps DOM positioning when `interleaved: false`. ([#9992](https://github.com/visgl/deck.gl/pull/9992)) - -### 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. - -It is a common use case for apps to constrain view state to the area where data is available. In 9.3, all controllers add a new option `maxBounds` that will: -- Automatically zoom/pan viewport to fit content -- Prevent user from navigating outside of the content bounding box - -Individual view improvements: -- [MapController](./api-reference/core/map-controller.md) adds a new option `rotationPivot` for more natural interaction with terrain / 3D tiles that are not at sea level. See [PR#9938](https://github.com/visgl/deck.gl/pull/9938) for demos. -- [GlobeController](./api-reference/core/globe-controller.md) gets major bug fixes and is more stable. -- [OrthographicView](./api-reference/core/orthographic-view.md) is moving away from 2d-array zoom and adds per-axis `zoom*`, `minZoom*`, `maxZoom*` props. -- [OrbitController](./api-reference/core/orbit-controller.md) works more intuitively when used with `maxBounds` and pickable layers. - - ### Widgets @@ -85,15 +54,14 @@ Aside from the above, all widgets also received the following improvements: - [TileLayer](./api-reference/geo-layers/tile-layer.md) adds new `visibleMinZoom` and `visibleMaxZoom` props to control the zoom range at which tiles are drawn, independent of the zoom range at which data is loaded. - WebGPU now materializes constant layer attributes into full buffers through `AttributeManager`, improving compatibility for layers that rely on constant accessors. -### @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. - -### 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. +### Basemap Integration -- [OrthographicView](./api-reference/core/orthographic-view.md) is moving away from 2d-array zoom and adds per-axis `zoom*`, `minZoom*`, `maxZoom*` props. +- 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, ensuring consistent behavior between overlaid and interleaved modes. ([#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)) +- Fixed Google Maps DOM positioning when `interleaved: false`. ([#9992](https://github.com/visgl/deck.gl/pull/9992)) ### Improved 3D Support From e361d75d6eb1c5552b85bb152f6bced11f2ee8da Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Tue, 7 Apr 2026 11:29:42 -0700 Subject: [PATCH 17/19] docs(whats-new): Move Views items into Improved 3D Support section Co-Authored-By: Claude Opus 4.6 --- docs/whats-new.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/whats-new.md b/docs/whats-new.md index 149153128a0..1f9ab77f3ad 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -74,6 +74,8 @@ deck.gl v9.3 is a substantial step forward in 3D navigation and rendering suppor - [OrbitController](./api-reference/core/orbit-controller.md) now uses 3D picking to determine zoom and pan anchors, providing more intuitive navigation around 3D content. - 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. +- 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 From 4c33b762fb12666984c26090954fb49aeeb55d2f Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Tue, 7 Apr 2026 11:46:12 -0700 Subject: [PATCH 18/19] docs(whats-new): Restore Views as its own section after Improved 3D Support Co-Authored-By: Claude Opus 4.6 --- docs/whats-new.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 1f9ab77f3ad..001bbf8ac36 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -74,7 +74,11 @@ deck.gl v9.3 is a substantial step forward in 3D navigation and rendering suppor - [OrbitController](./api-reference/core/orbit-controller.md) now uses 3D picking to determine zoom and pan anchors, providing more intuitive navigation around 3D content. - 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. -- 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. + +### 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 From bc131cc4335bbf4e706bbb14bb4f7de0e9e3363d Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Tue, 7 Apr 2026 11:50:19 -0700 Subject: [PATCH 19/19] docs(whats-new): Split basemap sections by module, fix wording Split "Basemap Integration" into @deck.gl/mapbox and @deck.gl/google-maps sections. Reword "ensuring consistent behavior" to sound less robotic. Co-Authored-By: Claude Opus 4.6 --- docs/whats-new.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 001bbf8ac36..49a6add4e0f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -54,14 +54,17 @@ Aside from the above, all widgets also received the following improvements: - [TileLayer](./api-reference/geo-layers/tile-layer.md) adds new `visibleMinZoom` and `visibleMaxZoom` props to control the zoom range at which tiles are drawn, independent of the zoom range at which data is loaded. - WebGPU now materializes constant layer attributes into full buffers through `AttributeManager`, improving compatibility for layers that rely on constant accessors. -### Basemap Integration +### @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. ([#10163](https://github.com/visgl/deck.gl/pull/10163)) -- `MapboxOverlay` now automatically injects a `MapView` with id `"mapbox"` when custom views are provided, ensuring consistent behavior between overlaid and interleaved modes. ([#9947](https://github.com/visgl/deck.gl/pull/9947)) +- `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)) -- Fixed Google Maps DOM positioning when `interleaved: false`. ([#9992](https://github.com/visgl/deck.gl/pull/9992)) + +### @deck.gl/google-maps + +- Fixed DOM positioning when `interleaved: false`. ([#9992](https://github.com/visgl/deck.gl/pull/9992)) ### Improved 3D Support