diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index c5fca987120..1346f8f252c 100644 --- a/docs/api-reference/mapbox/mapbox-overlay.md +++ b/docs/api-reference/mapbox/mapbox-overlay.md @@ -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. diff --git a/docs/whats-new.md b/docs/whats-new.md index 3409c761d9e..350b1dda36e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 diff --git a/examples/get-started/pure-js/mapbox/app.js b/examples/get-started/pure-js/mapbox/app.js index adb30909614..2d16e7e7ca0 100644 --- a/examples/get-started/pure-js/mapbox/app.js +++ b/examples/get-started/pure-js/mapbox/app.js @@ -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 diff --git a/modules/mapbox/src/deck-widget-control.ts b/modules/mapbox/src/deck-widget-control.ts new file mode 100644 index 00000000000..806fff01b5f --- /dev/null +++ b/modules/mapbox/src/deck-widget-control.ts @@ -0,0 +1,78 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Widget} from '@deck.gl/core'; +import type {IControl, ControlPosition, Map} from './types'; + +/** + * Wraps a deck.gl Widget as a Mapbox/MapLibre IControl. + * + * This enables deck widgets to be positioned alongside native map controls + * in the same DOM container, preventing overlap issues. + * + * @internal Used by MapboxOverlay for widgets with `viewId: 'mapbox'`. + */ +export class DeckWidgetControl implements IControl { + private _widget: Widget; + private _container: HTMLDivElement | null = null; + + constructor(widget: Widget) { + this._widget = widget; + } + + /** + * Called when the control is added to the map. + * Creates a container element that will be positioned by Mapbox/MapLibre, + * and sets the widget's _container prop so WidgetManager appends the widget here. + */ + onAdd(map: Map): HTMLElement { + this._container = document.createElement('div'); + this._container.className = 'maplibregl-ctrl mapboxgl-ctrl deck-widget-ctrl'; + + // Set _container so WidgetManager appends the widget's rootElement here + // instead of in its own overlay container + this._widget.props._container = this._container; + + return this._container; + } + + /** + * Called when the control is removed from the map. + */ + onRemove(): void { + // Clear the _container reference so widget doesn't try to append there + if (this._widget.props._container === this._container) { + this._widget.props._container = null; + } + this._container?.remove(); + this._container = null; + } + + /** + * Returns the default position for this control. + * Uses the widget's placement, which conveniently matches Mapbox control positions. + * Note: 'fill' placement is not supported by Mapbox controls, defaults to 'top-left'. + */ + getDefaultPosition(): ControlPosition { + const placement = this._widget.placement; + // 'fill' is not a valid Mapbox control position + if (!placement || placement === 'fill') { + return 'top-left'; + } + return placement; + } + + /** Returns the wrapped widget */ + get widget(): Widget { + return this._widget; + } + + /** + * Updates the wrapped widget reference. + * Used when reusing this control for a new widget instance with the same id. + */ + setWidget(widget: Widget): void { + this._widget = widget; + } +} diff --git a/modules/mapbox/src/mapbox-overlay.ts b/modules/mapbox/src/mapbox-overlay.ts index 8dfe683e417..a115e4c3146 100644 --- a/modules/mapbox/src/mapbox-overlay.ts +++ b/modules/mapbox/src/mapbox-overlay.ts @@ -12,10 +12,11 @@ import { getProjection, MAPBOX_VIEW_ID } from './deck-utils'; +import {DeckWidgetControl} from './deck-widget-control'; import type {Map, IControl, MapMouseEvent, ControlPosition} from './types'; import type {MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js'; -import type {DeckProps, LayersList} from '@deck.gl/core'; +import type {DeckProps, LayersList, Widget} from '@deck.gl/core'; import {resolveLayerGroups} from './resolve-layer-groups'; @@ -48,6 +49,8 @@ export default class MapboxOverlay implements IControl { private _container?: HTMLDivElement; private _interleaved: boolean; private _lastMouseDownPoint?: {x: number; y: number; clientX: number; clientY: number}; + /** IControl wrappers for widgets with viewId: 'mapbox' */ + private _widgetControls: DeckWidgetControl[] = []; constructor(props: MapboxOverlayProps) { const {interleaved = false} = props; @@ -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) { @@ -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({ ...this._props, parent: container, @@ -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({ @@ -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[] | undefined): void { + const map = this._map; + if (!map) return; + + const mapboxWidgets = widgets?.filter(w => w && w.viewId === 'mapbox') ?? []; + + // Build a map of existing controls by widget id + const existingControlsById = new Map(); + for (const control of this._widgetControls) { + existingControlsById.set(control.widget.id, control); + } + + const newControls: DeckWidgetControl[] = []; + + for (const widget of mapboxWidgets) { + const existingControl = existingControlsById.get(widget.id); + + if (existingControl && existingControl.widget.placement === widget.placement) { + // Same id and placement - reuse existing control to preserve container + // Set _container on the new widget instance so WidgetManager uses it + widget.props._container = existingControl.widget.props._container; + // Update the control's widget reference to the new instance + existingControl.setWidget(widget); + newControls.push(existingControl); + existingControlsById.delete(widget.id); + } else { + // New widget or placement changed - need a new control + if (existingControl) { + // Placement changed - remove old control first + map.removeControl(existingControl); + existingControlsById.delete(widget.id); + } + const control = new DeckWidgetControl(widget); + // Add to map - this calls onAdd() synchronously, setting _container + map.addControl(control, control.getDefaultPosition()); + newControls.push(control); + } + } + + // Remove controls for widgets that are no longer present + for (const control of existingControlsById.values()) { + map.removeControl(control); + } + + this._widgetControls = newControls; + } + /** Called when the control is removed from a map */ onRemove(): void { const map = this._map; if (map) { + // 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 { diff --git a/modules/widgets/src/stylesheet.css b/modules/widgets/src/stylesheet.css index 6d495e7d5c6..7189fbc6d7e 100644 --- a/modules/widgets/src/stylesheet.css +++ b/modules/widgets/src/stylesheet.css @@ -3,6 +3,12 @@ box-sizing: border-box; } +/* When a widget is inside a basemap control container (e.g. MapboxOverlay with viewId: 'mapbox'), + the map already provides spacing between controls, so remove the widget's own margin. */ +.deck-widget-ctrl .deck-widget { + margin: 0; +} + /* Common button container styles */ .deck-widget-button, .deck-widget-button-group { diff --git a/test/modules/mapbox/mapbox-gl-mock/map.ts b/test/modules/mapbox/mapbox-gl-mock/map.ts index e7ce48bf21b..5c4fa82dd63 100644 --- a/test/modules/mapbox/mapbox-gl-mock/map.ts +++ b/test/modules/mapbox/mapbox-gl-mock/map.ts @@ -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() { @@ -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; } diff --git a/test/modules/mapbox/mapbox-overlay.spec.ts b/test/modules/mapbox/mapbox-overlay.spec.ts index b57185d89a3..ee02caed239 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -9,14 +9,41 @@ import {ScatterplotLayer} from '@deck.gl/layers'; import {MapboxOverlay} from '@deck.gl/mapbox'; import {getDeckInstance} from '@deck.gl/mapbox/deck-utils'; import MapboxLayerGroup from '@deck.gl/mapbox/mapbox-layer-group'; -import {_GlobeView as GlobeView, MapView} from '@deck.gl/core'; +import {_GlobeView as GlobeView, MapView, Widget} from '@deck.gl/core'; +import type {WidgetPlacement} from '@deck.gl/core'; import {device} from '@deck.gl/test-utils/vitest'; +import {WebGLDevice} from '@luma.gl/webgl'; import MockMapboxMap from './mapbox-gl-mock/map'; import {DEFAULT_PARAMETERS, approxDeepEqual} from './fixtures'; +// Create an isolated device for overlaid mode tests to prevent GL context corruption +const overlaidTestDevice = new WebGLDevice({createCanvasContext: {width: 1, height: 1}}); + const webglTest = device.type === 'webgl' ? test : test.skip; +// Simple test widget for testing MapboxOverlay widget support +class TestWidget extends Widget<{placement?: WidgetPlacement; viewId?: string | null}> { + static defaultProps = { + ...Widget.defaultProps, + id: 'test-widget', + placement: 'top-left' as WidgetPlacement + }; + + placement: WidgetPlacement = 'top-left'; + className = 'deck-test-widget'; + + constructor(props: {id?: string; placement?: WidgetPlacement; viewId?: string | null} = {}) { + super(props); + this.viewId = props.viewId ?? null; + this.placement = props.placement ?? 'top-left'; + } + + onRenderHTML(rootElement: HTMLElement): void { + rootElement.textContent = this.id; + } +} + function sleep(milliseconds: number): Promise { return new Promise(resolve => { setTimeout(resolve, milliseconds); @@ -70,7 +97,7 @@ test('MapboxOverlay#overlaid', async () => { zoom: 14 }); const overlay = new MapboxOverlay({ - device, + device: overlaidTestDevice, layers: [new ScatterplotLayer()] }); @@ -132,7 +159,9 @@ test('MapboxOverlay#overlaidNoIntitalLayers', async () => { center: {lng: -122.45, lat: 37.78}, zoom: 14 }); - const overlay = new MapboxOverlay({device}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice + }); map.addControl(overlay); @@ -753,7 +782,387 @@ webglTest('MapboxOverlay#renderLayersInGroups - setProps', async () => { await renderPromise; }); -// Tests ported from mapbox-layer.spec.ts, adapted for MapboxLayerGroup +// Widget support tests + +test('MapboxOverlay#widgets - regular widgets render in deck container', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'regular-widget', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'No widget controls for regular widgets').toBe(0); + expect(overlay._deck.props.widgets.includes(widget), 'Widget is passed to Deck').toBeTruthy(); + + map.removeControl(overlay); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - viewId:mapbox widgets wrapped as IControl', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Widget control is created').toBe(1); + expect(map.hasControl(overlay._widgetControls[0]), 'Widget control is added to map').toBeTruthy(); + expect(widget.props._container, 'Widget _container is set').toBeTruthy(); + expect( + overlay._deck.props.widgets.includes(widget), + 'Widget is still passed to Deck for events' + ).toBeTruthy(); + + map.removeControl(overlay); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - mixed widgets', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const regularWidget = new TestWidget({id: 'regular', placement: 'top-left'}); + const mapboxWidget1 = new TestWidget({id: 'mapbox1', viewId: 'mapbox', placement: 'top-right'}); + const mapboxWidget2 = new TestWidget({ + id: 'mapbox2', + viewId: 'mapbox', + placement: 'bottom-right' + }); + + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [regularWidget, mapboxWidget1, mapboxWidget2] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Two widget controls for mapbox widgets').toBe(2); + expect(regularWidget.props._container, 'Regular widget _container is not set').toBeFalsy(); + expect(mapboxWidget1.props._container, 'Mapbox widget1 _container is set').toBeTruthy(); + expect(mapboxWidget2.props._container, 'Mapbox widget2 _container is set').toBeTruthy(); + + // All widgets passed to Deck + expect(overlay._deck.props.widgets.length, 'All widgets passed to Deck').toBe(3); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setProps updates widget controls', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget1 = new TestWidget({id: 'widget1', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length, 'Initial widget control created').toBe(1); + + const widget2 = new TestWidget({id: 'widget2', viewId: 'mapbox', placement: 'bottom-left'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls.length, 'Widget control count updated').toBe(1); + expect(widget2.props._container, 'New widget _container is set').toBeTruthy(); + + // Clear all widgets + overlay.setProps({ + widgets: [] + }); + expect(overlay._widgetControls.length, 'Widget controls cleared').toBe(0); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setProps preserves container for same widget instance', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'widget1', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length, 'Widget control created').toBe(1); + const originalContainer = widget.props._container; + expect(originalContainer, 'Widget _container is set').toBeTruthy(); + const originalControl = overlay._widgetControls[0]; + + // Call setProps with the same widget instance + overlay.setProps({ + widgets: [widget] + }); + + expect(overlay._widgetControls.length, 'Still one widget control').toBe(1); + expect(overlay._widgetControls[0], 'Same control instance preserved').toBe(originalControl); + expect(widget.props._container, 'Container preserved - not recreated').toBe(originalContainer); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setProps preserves container for new widget instance with same id', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget1 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length, 'Widget control created').toBe(1); + const originalContainer = widget1.props._container; + expect(originalContainer, 'Widget _container is set').toBeTruthy(); + const originalControl = overlay._widgetControls[0]; + + // Call setProps with a NEW widget instance but same id and placement (React pattern) + const widget2 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls.length, 'Still one widget control').toBe(1); + expect(overlay._widgetControls[0], 'Same control instance preserved').toBe(originalControl); + expect(widget2.props._container, 'New widget gets existing container').toBe(originalContainer); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - interleaved mode', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + interleaved: true, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Widget control is created in interleaved mode').toBe(1); + expect(widget.props._container, 'Widget _container is set').toBeTruthy(); + + map.removeControl(overlay); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); +}); + +test('MapboxOverlay#widgets - placement change recreates control', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length).toBe(1); + const originalControl = overlay._widgetControls[0]; + const originalContainer = widget.props._container; + + // Same id but different placement - should recreate the control + const widget2 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'bottom-left'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls.length, 'Still one widget control').toBe(1); + expect( + overlay._widgetControls[0] !== originalControl, + 'New control instance created' + ).toBeTruthy(); + expect(widget2.props._container, 'New widget has _container set').toBeTruthy(); + expect( + widget2.props._container !== originalContainer, + 'New container created for new placement' + ).toBeTruthy(); + expect(map.hasControl(originalControl), 'Old control removed from map').toBeFalsy(); + expect(map.hasControl(overlay._widgetControls[0]), 'New control added to map').toBeTruthy(); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - setWidget updates control widget reference', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget1 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget1] + }); + + map.addControl(overlay); + const control = overlay._widgetControls[0]; + expect(control.widget, 'Control initially references widget1').toBe(widget1); + + // New instance with same id and placement - control is reused + const widget2 = new TestWidget({id: 'my-widget', viewId: 'mapbox', placement: 'top-right'}); + overlay.setProps({ + widgets: [widget2] + }); + + expect(overlay._widgetControls[0], 'Same control instance reused').toBe(control); + expect(control.widget, 'Control widget reference updated to widget2').toBe(widget2); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - onRemove clears widget _container', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'mapbox-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [widget] + }); + + map.addControl(overlay); + expect(widget.props._container, '_container is set after addControl').toBeTruthy(); + + map.removeControl(overlay); + expect(widget.props._container, '_container is cleared after removeControl').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - map removal cleans up later controls', async () => { + 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] + }); + let laterControlRemoveCount = 0; + const laterControl = { + onAdd: () => document.createElement('div'), + onRemove: () => { + laterControlRemoveCount++; + } + }; + + map.addControl(overlay); + map.addControl(laterControl); + await sleep(0); + map.remove(); + + expect(laterControlRemoveCount, 'Later control is removed exactly once').toBe(1); + expect(widget.props._container, 'Widget _container is cleared').toBeFalsy(); + expect(overlay._widgetControls.length, 'Widget controls are cleaned up').toBe(0); + expect(overlay._deck, 'Deck instance is finalized').toBeFalsy(); +}); + +test('MapboxOverlay#widgets - getDefaultPosition maps placement correctly', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const topRight = new TestWidget({id: 'w1', viewId: 'mapbox', placement: 'top-right'}); + const bottomLeft = new TestWidget({id: 'w2', viewId: 'mapbox', placement: 'bottom-left'}); + const fillWidget = new TestWidget({id: 'w3', viewId: 'mapbox', placement: 'fill'}); + + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [topRight, bottomLeft, fillWidget] + }); + + map.addControl(overlay); + expect(overlay._widgetControls.length).toBe(3); + + // Check that mock map recorded the correct positions + const controlEntries = map._controls.filter( + c => c.control !== overlay // exclude the overlay itself + ); + const positions = controlEntries.map(c => c.position); + expect(positions, 'Positions match widget placements').toEqual([ + 'top-right', + 'bottom-left', + 'top-left' // 'fill' falls back to 'top-left' + ]); + + map.removeControl(overlay); +}); + +test('MapboxOverlay#widgets - null widgets in array are filtered', () => { + const map = new MockMapboxMap({ + center: {lng: -122.45, lat: 37.78}, + zoom: 14 + }); + + const widget = new TestWidget({id: 'valid-widget', viewId: 'mapbox', placement: 'top-right'}); + const overlay = new MapboxOverlay({ + device: overlaidTestDevice, + layers: [new ScatterplotLayer()], + widgets: [null as any, widget, undefined as any] + }); + + map.addControl(overlay); + + expect(overlay._deck, 'Deck instance is created').toBeTruthy(); + expect(overlay._widgetControls.length, 'Only valid mapbox widget creates a control').toBe(1); + expect(widget.props._container, 'Valid widget _container is set').toBeTruthy(); + + map.removeControl(overlay); +}); test('MapboxLayerGroup#external Deck lifecycle', async () => { const deck = new Deck({