diff --git a/modules/mapbox/src/deck-widget-control.ts b/modules/mapbox/src/deck-widget-control.ts new file mode 100644 index 00000000000..7719e19fc19 --- /dev/null +++ b/modules/mapbox/src/deck-widget-control.ts @@ -0,0 +1,78 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Widget} from '@deck.gl/core'; +import type {IControl, ControlPosition, Map} from './types'; + +/** + * Wraps a deck.gl Widget as a Mapbox/MapLibre IControl. + * + * This enables deck widgets to be positioned alongside native map controls + * in the same DOM container, preventing overlap issues. + * + * @internal Used by MapboxOverlay for widgets with `viewId: 'mapbox'`. + */ +export class DeckWidgetControl implements IControl { + private _widget: Widget; + private _container: HTMLDivElement | null = null; + + constructor(widget: Widget) { + this._widget = widget; + } + + /** + * Called when the control is added to the map. + * Creates a container element that will be positioned by Mapbox/MapLibre, + * and sets the widget's _container prop so WidgetManager appends the widget here. + */ + onAdd(map: Map): HTMLElement { + this._container = document.createElement('div'); + this._container.className = 'maplibregl-ctrl mapboxgl-ctrl deck-widget-ctrl'; + + // Set _container so WidgetManager appends the widget's rootElement here + // instead of in its own overlay container + this._widget.props._container = this._container; + + return this._container; + } + + /** + * Called when the control is removed from the map. + */ + onRemove(): void { + // Clear the _container reference so widget doesn't try to append there + if (this._widget.props._container === this._container) { + this._widget.props._container = null; + } + this._container?.remove(); + this._container = null; + } + + /** + * Returns the default position for this control. + * Uses the widget's placement, which conveniently matches Mapbox control positions. + * Note: 'fill' placement is not supported by Mapbox controls, defaults to 'top-left'. + */ + getDefaultPosition(): ControlPosition { + const placement = this._widget.placement; + // 'fill' is not a valid Mapbox control position + if (!placement || placement === 'fill') { + return 'top-left'; + } + return placement; + } + + /** Returns the wrapped widget */ + get widget(): Widget { + return this._widget; + } + + /** + * Updates the wrapped widget reference. + * Used when reusing this control for a new widget instance with the same id. + */ + setWidget(widget: Widget): void { + this._widget = widget; + } +} diff --git a/modules/mapbox/src/mapbox-overlay.ts b/modules/mapbox/src/mapbox-overlay.ts index 8241f297fe4..3d858ccf95a 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,73 @@ 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; + + const mapboxWidgets = widgets?.filter(w => w && w.viewId === 'mapbox') ?? []; + + // Build a map of existing controls by widget id + const existingControlsById = new Map(); + for (const control of this._widgetControls) { + existingControlsById.set(control.widget.id, control); + } + + const newControls: DeckWidgetControl[] = []; + + for (const widget of mapboxWidgets) { + const existingControl = existingControlsById.get(widget.id); + + if (existingControl && existingControl.widget.placement === widget.placement) { + // Same id and placement - reuse existing control to preserve container + // Set _container on the new widget instance so WidgetManager uses it + widget.props._container = existingControl.widget.props._container; + // Update the control's widget reference to the new instance + existingControl.setWidget(widget); + newControls.push(existingControl); + existingControlsById.delete(widget.id); + } else { + // New widget or placement changed - need a new control + if (existingControl) { + // Placement changed - remove old control first + map.removeControl(existingControl); + existingControlsById.delete(widget.id); + } + const control = new DeckWidgetControl(widget); + // Add to map - this calls onAdd() synchronously, setting _container + map.addControl(control, control.getDefaultPosition()); + newControls.push(control); + } + } + + // Remove controls for widgets that are no longer present + for (const control of existingControlsById.values()) { + map.removeControl(control); + } + + this._widgetControls = newControls; + } + /** Called when the control is removed from a map */ onRemove(): void { const map = this._map; if (map) { + // Remove widget controls + for (const control of this._widgetControls) { + map.removeControl(control); + } + this._widgetControls = []; + if (this._interleaved) { this._onRemoveInterleaved(map); } else { diff --git a/modules/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-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..3a513736ee8 100644 --- a/test/modules/mapbox/mapbox-overlay.spec.ts +++ b/test/modules/mapbox/mapbox-overlay.spec.ts @@ -6,12 +6,39 @@ 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 {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 = { + ...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); @@ -35,6 +62,7 @@ test('MapboxOverlay#overlaid', t => { zoom: 14 }); const overlay = new MapboxOverlay({ + device: overlaidTestDevice, layers: [new ScatterplotLayer()] }); @@ -97,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); @@ -476,3 +506,214 @@ 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({ + device: overlaidTestDevice, + 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({ + device: overlaidTestDevice, + 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({ + device: overlaidTestDevice, + 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({ + device: overlaidTestDevice, + 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 - 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}, + 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(); +});