diff --git a/examples/get-started/react/basic/app.jsx b/examples/get-started/react/basic/app.jsx
index da124512468..e63d4a1bd68 100644
--- a/examples/get-started/react/basic/app.jsx
+++ b/examples/get-started/react/basic/app.jsx
@@ -5,6 +5,8 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import DeckGL, {GeoJsonLayer, ArcLayer} from 'deck.gl';
+import {Compass} from '@deck.gl/react';
+import '@deck.gl/widgets/stylesheet.css';
// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
const COUNTRIES =
@@ -62,6 +64,7 @@ function Root() {
getTargetColor={[200, 0, 80]}
getWidth={1}
/>
+
);
}
diff --git a/modules/main/src/index.ts b/modules/main/src/index.ts
index 42145fa9d20..59f39914d97 100644
--- a/modules/main/src/index.ts
+++ b/modules/main/src/index.ts
@@ -194,4 +194,12 @@ export type {
export type {MVTLayerProps, QuadkeyLayerProps, TileLayerProps} from '@deck.gl/geo-layers';
-export type {DeckGLProps, DeckGLRef, DeckGLContextValue} from '@deck.gl/react';
+export type {
+ DeckGLProps,
+ DeckGLRef,
+ DeckGLContextValue,
+ Compass,
+ Fullscreen,
+ Zoom,
+ useWidget
+} from '@deck.gl/react';
diff --git a/modules/react/package.json b/modules/react/package.json
index ad0d9ae2954..c72f66f0855 100644
--- a/modules/react/package.json
+++ b/modules/react/package.json
@@ -35,6 +35,7 @@
"scripts": {},
"peerDependencies": {
"@deck.gl/core": "^9.1.0-beta",
+ "@deck.gl/widgets": "^9.1.0-beta",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
diff --git a/modules/react/src/deckgl.ts b/modules/react/src/deckgl.ts
index 6ab52f58ea4..acae94a8f79 100644
--- a/modules/react/src/deckgl.ts
+++ b/modules/react/src/deckgl.ts
@@ -11,7 +11,7 @@ import extractJSXLayers, {DeckGLRenderCallback} from './utils/extract-jsx-layers
import positionChildrenUnderViews from './utils/position-children-under-views';
import extractStyles from './utils/extract-styles';
-import type {DeckGLContextValue} from './utils/position-children-under-views';
+import type {DeckGLContextValue} from './utils/deckgl-context';
import type {DeckProps, View, Viewport} from '@deck.gl/core';
export type ViewOrViews = View | View[] | null;
@@ -157,6 +157,7 @@ function DeckGLWithRef(
// Needs to be called both from initial mount, and when new props are received
const deckProps = useMemo(() => {
const forwardProps: DeckProps = {
+ widgets: [],
...props,
// Override user styling props. We will set the canvas style in render()
style: null,
diff --git a/modules/react/src/index.ts b/modules/react/src/index.ts
index b390f3c0231..508880503b5 100644
--- a/modules/react/src/index.ts
+++ b/modules/react/src/index.ts
@@ -5,6 +5,12 @@
export {default as DeckGL} from './deckgl';
export {default} from './deckgl';
+// Widgets
+export {Compass} from './widgets/compass';
+export {Fullscreen} from './widgets/fullscreen';
+export {Zoom} from './widgets/zoom';
+export {default as useWidget} from './utils/use-widget';
+
// Types
-export type {DeckGLContextValue} from './utils/position-children-under-views';
+export type {DeckGLContextValue} from './utils/deckgl-context';
export type {DeckGLRef, DeckGLProps} from './deckgl';
diff --git a/modules/react/src/utils/deckgl-context.ts b/modules/react/src/utils/deckgl-context.ts
new file mode 100644
index 00000000000..ed7e2fe802a
--- /dev/null
+++ b/modules/react/src/utils/deckgl-context.ts
@@ -0,0 +1,15 @@
+import {createContext} from 'react';
+import type {EventManager} from 'mjolnir.js';
+import type {Deck, DeckProps, Viewport, Widget} from '@deck.gl/core';
+
+export type DeckGLContextValue = {
+ viewport: Viewport;
+ container: HTMLElement;
+ eventManager: EventManager;
+ onViewStateChange: DeckProps['onViewStateChange'];
+ deck?: Deck;
+ widgets?: Widget[];
+};
+
+// @ts-ignore
+export const DeckGlContext = createContext();
diff --git a/modules/react/src/utils/position-children-under-views.ts b/modules/react/src/utils/position-children-under-views.ts
index 3b1228a7467..818a3524301 100644
--- a/modules/react/src/utils/position-children-under-views.ts
+++ b/modules/react/src/utils/position-children-under-views.ts
@@ -9,22 +9,15 @@ import {inheritsFrom} from './inherits-from';
import evaluateChildren, {isComponent} from './evaluate-children';
import type {ViewOrViews} from '../deckgl';
-import type {Deck, DeckProps, Viewport} from '@deck.gl/core';
-import type {EventManager} from 'mjolnir.js';
-
-export type DeckGLContextValue = {
- viewport: Viewport;
- container: HTMLElement;
- eventManager: EventManager;
- onViewStateChange: DeckProps['onViewStateChange'];
-};
+import type {Deck, Viewport} from '@deck.gl/core';
+import {DeckGlContext, type DeckGLContextValue} from './deckgl-context';
// Iterate over views and reposition children associated with views
// TODO - Can we supply a similar function for the non-React case?
export default function positionChildrenUnderViews({
children,
deck,
- ContextProvider
+ ContextProvider = DeckGlContext.Provider
}: {
children: React.ReactNode[];
deck?: Deck;
@@ -101,22 +94,21 @@ export default function positionChildrenUnderViews({
// a key" warning. Sending each child as separate arguments removes this requirement.
const viewElement = createElement('div', {key, id: key, style}, ...viewChildren);
- if (ContextProvider) {
- const contextValue: DeckGLContextValue = {
- viewport,
- // @ts-expect-error accessing protected property
- container: deck.canvas.offsetParent,
- // @ts-expect-error accessing protected property
- eventManager: deck.eventManager,
- onViewStateChange: params => {
- params.viewId = viewId;
- // @ts-expect-error accessing protected method
- deck._onViewStateChange(params);
- }
- };
- return createElement(ContextProvider, {key, value: contextValue}, viewElement);
- }
-
- return viewElement;
+ const contextValue: DeckGLContextValue = {
+ deck,
+ viewport,
+ // @ts-expect-error accessing protected property
+ container: deck.canvas.offsetParent,
+ // @ts-expect-error accessing protected property
+ eventManager: deck.eventManager,
+ onViewStateChange: params => {
+ params.viewId = viewId;
+ // @ts-expect-error accessing protected method
+ deck._onViewStateChange(params);
+ },
+ widgets: []
+ };
+ const providerKey = `view-${viewId}-context`;
+ return createElement(ContextProvider, {key: providerKey, value: contextValue}, viewElement);
});
}
diff --git a/modules/react/src/utils/use-widget.ts b/modules/react/src/utils/use-widget.ts
new file mode 100644
index 00000000000..f9b6cf56f71
--- /dev/null
+++ b/modules/react/src/utils/use-widget.ts
@@ -0,0 +1,41 @@
+import {useContext, useMemo, useEffect} from 'react';
+import {DeckGlContext} from './deckgl-context';
+import {log, type Widget, _deepEqual as deepEqual} from '@deck.gl/core';
+
+function useWidget(
+ WidgetClass: {new (props: PropsT): T},
+ props: PropsT
+): T {
+ const context = useContext(DeckGlContext);
+ const {widgets, deck} = context;
+ useEffect(() => {
+ // warn if the user supplied a vanilla widget, since it will be ignored
+ // NOTE: This effect runs once per widget. Context widgets and deck widget props are synced after first effect runs.
+ const internalWidgets = deck?.props.widgets;
+ if (widgets?.length && internalWidgets?.length && !deepEqual(internalWidgets, widgets, 1)) {
+ log.warn('"widgets" prop will be ignored because React widgets are in use.')();
+ }
+
+ return () => {
+ // Remove widget from context when it is unmounted
+ const index = widgets?.indexOf(widget);
+ if (index && index !== -1) {
+ widgets?.splice(index, 1);
+ deck?.setProps({widgets});
+ }
+ };
+ }, []);
+ const widget = useMemo(() => new WidgetClass(props), [WidgetClass]);
+
+ // Hook rebuilds widgets on every render: [] then [FirstWidget] then [FirstWidget, SecondWidget]
+ widgets?.push(widget);
+ widget.setProps(props);
+
+ useEffect(() => {
+ deck?.setProps({widgets});
+ }, [widgets]);
+
+ return widget;
+}
+
+export default useWidget;
diff --git a/modules/react/src/widgets/compass.tsx b/modules/react/src/widgets/compass.tsx
new file mode 100644
index 00000000000..461cafb88d8
--- /dev/null
+++ b/modules/react/src/widgets/compass.tsx
@@ -0,0 +1,8 @@
+import {CompassWidget} from '@deck.gl/widgets';
+import type {CompassWidgetProps} from '@deck.gl/widgets';
+import useWidget from '../utils/use-widget';
+
+export const Compass = (props: CompassWidgetProps = {}) => {
+ const widget = useWidget(CompassWidget, props);
+ return null;
+};
diff --git a/modules/react/src/widgets/fullscreen.tsx b/modules/react/src/widgets/fullscreen.tsx
new file mode 100644
index 00000000000..7ce49fa2d51
--- /dev/null
+++ b/modules/react/src/widgets/fullscreen.tsx
@@ -0,0 +1,8 @@
+import {FullscreenWidget} from '@deck.gl/widgets';
+import type {FullscreenWidgetProps} from '@deck.gl/widgets';
+import useWidget from '../utils/use-widget';
+
+export const Fullscreen = (props: FullscreenWidgetProps = {}) => {
+ const widget = useWidget(FullscreenWidget, props);
+ return null;
+};
diff --git a/modules/react/src/widgets/zoom.tsx b/modules/react/src/widgets/zoom.tsx
new file mode 100644
index 00000000000..b7fec26e46c
--- /dev/null
+++ b/modules/react/src/widgets/zoom.tsx
@@ -0,0 +1,8 @@
+import {ZoomWidget} from '@deck.gl/widgets';
+import type {ZoomWidgetProps} from '@deck.gl/widgets';
+import useWidget from '../utils/use-widget';
+
+export const Zoom = (props: ZoomWidgetProps = {}) => {
+ const widget = useWidget(ZoomWidget, props);
+ return null;
+};
diff --git a/modules/react/tsconfig.json b/modules/react/tsconfig.json
index 2a3fef31e6e..5839b6736c4 100644
--- a/modules/react/tsconfig.json
+++ b/modules/react/tsconfig.json
@@ -8,6 +8,7 @@
"outDir": "dist"
},
"references": [
- {"path": "../core"}
+ {"path": "../core"},
+ {"path": "../widgets"}
]
}
diff --git a/modules/widgets/src/compass-widget.tsx b/modules/widgets/src/compass-widget.tsx
index 1c2275ca2fa..518c4106c75 100644
--- a/modules/widgets/src/compass-widget.tsx
+++ b/modules/widgets/src/compass-widget.tsx
@@ -14,7 +14,7 @@ import {
import type {Deck, Viewport, Widget, WidgetPlacement} from '@deck.gl/core';
import {render} from 'preact';
-interface CompassWidgetProps {
+export interface CompassWidgetProps {
id?: string;
placement?: WidgetPlacement;
/**
diff --git a/modules/widgets/src/fullscreen-widget.tsx b/modules/widgets/src/fullscreen-widget.tsx
index 578aafd05c2..c0c6ab327df 100644
--- a/modules/widgets/src/fullscreen-widget.tsx
+++ b/modules/widgets/src/fullscreen-widget.tsx
@@ -12,7 +12,7 @@ import type {Deck, Widget, WidgetPlacement} from '@deck.gl/core';
import {render} from 'preact';
import {IconButton} from './components';
-interface FullscreenWidgetProps {
+export interface FullscreenWidgetProps {
id?: string;
placement?: WidgetPlacement;
/**
diff --git a/modules/widgets/src/index.ts b/modules/widgets/src/index.ts
index f98113831ea..b2355887aed 100644
--- a/modules/widgets/src/index.ts
+++ b/modules/widgets/src/index.ts
@@ -6,4 +6,8 @@ export {FullscreenWidget} from './fullscreen-widget';
export {CompassWidget} from './compass-widget';
export {ZoomWidget} from './zoom-widget';
+export type {FullscreenWidgetProps} from './fullscreen-widget';
+export type {CompassWidgetProps} from './compass-widget';
+export type {ZoomWidgetProps} from './zoom-widget';
+
export * from './themes';
diff --git a/modules/widgets/src/zoom-widget.tsx b/modules/widgets/src/zoom-widget.tsx
index 58bb34548a9..b87f580067d 100644
--- a/modules/widgets/src/zoom-widget.tsx
+++ b/modules/widgets/src/zoom-widget.tsx
@@ -13,7 +13,7 @@ import type {Deck, Viewport, Widget, WidgetPlacement} from '@deck.gl/core';
import {render} from 'preact';
import {ButtonGroup, GroupedIconButton} from './components';
-interface ZoomWidgetProps {
+export interface ZoomWidgetProps {
id?: string;
placement?: WidgetPlacement;
/**
diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts
index b46156496f2..9d494965b0d 100644
--- a/test/modules/core/lib/deck.spec.ts
+++ b/test/modules/core/lib/deck.spec.ts
@@ -5,6 +5,7 @@
import test from 'tape-promise/tape';
import {Deck, log, MapView} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';
+import {FullscreenWidget} from '@deck.gl/widgets';
import {device} from '@deck.gl/test-utils';
import {sleep} from './async-iterator-test-utils';
@@ -280,3 +281,47 @@ test('Deck#resourceManager', async t => {
deck.finalize();
t.end();
});
+
+test('Deck#props omitted are unchanged', async t => {
+ const layer = new ScatterplotLayer({
+ id: 'scatterplot-global-data',
+ data: 'deck://pins',
+ getPosition: d => d.position
+ });
+
+ const widget = new FullscreenWidget({});
+
+ // Initialize with widgets and layers.
+ const deck = new Deck({
+ device,
+ width: 1,
+ height: 1,
+
+ viewState: {
+ longitude: 0,
+ latitude: 0,
+ zoom: 0
+ },
+
+ layers: [layer],
+ widgets: [widget],
+
+ onLoad: () => {
+ const {widgets, layers} = deck.props;
+ t.is(widgets && Array.isArray(widgets) && widgets.length, 1, 'Widgets is set');
+ t.is(layers && Array.isArray(layers) && layers.length, 1, 'Layers is set');
+
+ // Render deck a second time without changing widget or layer props.
+ deck.setProps({
+ onAfterRender: () => {
+ const {widgets, layers} = deck.props;
+ t.is(widgets && Array.isArray(widgets) && widgets.length, 1, 'Widgets remain set');
+ t.is(layers && Array.isArray(layers) && layers.length, 1, 'Layers remain set');
+
+ deck.finalize();
+ t.end();
+ }
+ });
+ }
+ });
+});
diff --git a/test/modules/core/lib/widget-manager.spec.ts b/test/modules/core/lib/widget-manager.spec.ts
index 55d6d05d657..f74a5eff8dc 100644
--- a/test/modules/core/lib/widget-manager.spec.ts
+++ b/test/modules/core/lib/widget-manager.spec.ts
@@ -96,10 +96,24 @@ test('WidgetManager#setProps', t => {
t.notOk(widgetB.isVisible, 'widget.onRemove is called');
t.ok(widgetB2.isVisible, 'widget.onAdd is called');
+ widgetManager.setProps({widgets: []});
+ t.is(widgetManager.getWidgets().length, 0, 'all widgets are removed');
+ t.notOk(widgetB2.isVisible, 'widget.onRemove is called');
+
+ t.end();
+});
+
+test('WidgetManager#finalize', t => {
+ const container = document.createElement('div');
+ const widgetManager = new WidgetManager({deck: mockDeckInstance, parentElement: container});
+
+ const widgetA = new TestWidget({id: 'A'});
+ widgetManager.setProps({widgets: [widgetA]});
+
widgetManager.finalize();
t.is(widgetManager.getWidgets().length, 0, 'all widgets are removed');
t.is(container.childElementCount, 0, 'all widget containers are removed');
- t.notOk(widgetB2.isVisible, 'widget.onRemove is called');
+ t.notOk(widgetA.isVisible, 'widget.onRemove is called');
t.end();
});
diff --git a/test/modules/react/deckgl.spec.ts b/test/modules/react/deckgl.spec.ts
index f11195a2394..f3c2f7f847d 100644
--- a/test/modules/react/deckgl.spec.ts
+++ b/test/modules/react/deckgl.spec.ts
@@ -8,7 +8,7 @@ import {createElement, createRef} from 'react';
import {createRoot} from 'react-dom/client';
import {act} from 'react-dom/test-utils';
-import DeckGL from 'deck.gl';
+import DeckGL, {Layer} from 'deck.gl';
import {gl} from '@deck.gl/test-utils';
@@ -84,3 +84,77 @@ test('DeckGL#render', t => {
)
);
});
+
+class TestLayer extends Layer {
+ initializeState() {}
+}
+
+TestLayer.layerName = 'TestLayer';
+
+const LAYERS = [new TestLayer({id: 'primitive'})];
+
+class TestWidget {
+ constructor(props) {
+ this.id = props.id;
+ }
+ onAdd() {}
+}
+
+const WIDGETS = [new TestWidget({id: 'A'})];
+
+test('DeckGL#props omitted are reset', t => {
+ const ref = createRef();
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+
+ // Initialize widgets and layers on first render.
+ act(() => {
+ root.render(
+ createElement(DeckGL, {
+ initialViewState: TEST_VIEW_STATE,
+ ref,
+ width: 100,
+ height: 100,
+ gl: getMockContext(),
+ layers: LAYERS,
+ widgets: WIDGETS,
+ onLoad: () => {
+ const {deck} = ref.current;
+ t.ok(deck, 'DeckGL is initialized');
+ const {widgets, layers} = deck.props;
+ t.is(widgets && Array.isArray(widgets) && widgets.length, 1, 'Widgets is set');
+ t.is(layers && Array.isArray(layers) && layers.length, 1, 'Layers is set');
+
+ act(() => {
+ // Render deck a second time without setting widget or layer props.
+ root.render(
+ createElement(DeckGL, {
+ ref,
+ onAfterRender: () => {
+ const {deck} = ref.current;
+ const {widgets, layers} = deck.props;
+ t.is(
+ widgets && Array.isArray(widgets) && widgets.length,
+ 0,
+ 'Widgets is reset to an empty array'
+ );
+ t.is(
+ layers && Array.isArray(layers) && layers.length,
+ 0,
+ 'Layers is reset to an empty array'
+ );
+
+ root.render(null);
+ container.remove();
+ t.end();
+ }
+ })
+ );
+ });
+ }
+ })
+ );
+ });
+ t.ok(ref.current, 'DeckGL overlay is rendered.');
+});
diff --git a/test/modules/react/utils/extract-jsx-layers.spec.ts b/test/modules/react/utils/extract-jsx-layers.spec.ts
index 2fe06a87e37..c6b7472db09 100644
--- a/test/modules/react/utils/extract-jsx-layers.spec.ts
+++ b/test/modules/react/utils/extract-jsx-layers.spec.ts
@@ -32,6 +32,19 @@ const TEST_CASES = [
},
title: 'empty children'
},
+ {
+ input: {
+ children: null,
+ views: null,
+ layers: null
+ },
+ output: {
+ children: [],
+ views: null,
+ layers: null
+ },
+ title: 'empty layers'
+ },
{
input: {
children: noop,
diff --git a/test/modules/react/utils/position-children-under-views.spec.ts b/test/modules/react/utils/position-children-under-views.spec.ts
index 6bedfc6ec6f..fc1cca1eea8 100644
--- a/test/modules/react/utils/position-children-under-views.spec.ts
+++ b/test/modules/react/utils/position-children-under-views.spec.ts
@@ -5,6 +5,7 @@
import test from 'tape-promise/tape';
import React, {createElement} from 'react';
import positionChildrenUnderViews from '@deck.gl/react/utils/position-children-under-views';
+import {DeckGlContext} from '@deck.gl/react/utils/deckgl-context';
import {MapView, OrthographicView, View} from '@deck.gl/core';
const TEST_VIEW_STATES = {
@@ -64,27 +65,40 @@ test('positionChildrenUnderViews#before initialization', t => {
test('positionChildrenUnderViews', t => {
const children = positionChildrenUnderViews({
children: TEST_CHILDREN,
- deck: {viewManager: dummyViewManager}
+ deck: {viewManager: dummyViewManager, canvas: document.createElement('canvas')}
});
t.is(children.length, 2, 'Returns wrapped children');
- t.is(children[0].key, 'view-map', 'Has map view');
- t.is(children[1].key, 'view-ortho', 'Has orthographic view');
- t.is(children[0].props.style.left, 0, 'Wrapper component has x position');
- t.is(children[1].props.style.left, 400, 'Wrapper component has x position');
+ t.is(children[0].key, 'view-map-context', 'Child has deck context');
+ t.is(children[0].type, DeckGlContext.Provider, 'view is wrapped in DeckGlContext.Provider');
+ t.is(children[1].key, 'view-ortho-context', 'Child has deck context');
+ t.is(children[1].type, DeckGlContext.Provider, 'view is wrapped in DeckGlContext.Provider');
- let wrappedChild = children[0].props.children;
+ // check first view
+ let wrappedView = children[0].props.children;
+ t.is(wrappedView.key, 'view-map', 'Has map view');
+ t.is(wrappedView.props.style.left, 0, 'Wrapper component has x position');
+
+ // check first view's children
+ let wrappedChild = wrappedView.props.children;
+ t.is(wrappedChild.length, 2, 'Returns wrapped children');
t.is(wrappedChild[0].props.id, 'function-under-view', 'function child preserves id');
t.is(wrappedChild[0].props.width, 400, 'function child has width');
t.is(wrappedChild[0].props.viewState, TEST_VIEW_STATES.map, 'function child has viewState');
t.is(wrappedChild[1].props.id, 'element-without-view', 'element child preserves id');
- wrappedChild = children[1].props.children;
+ // check second view
+ wrappedView = children[1].props.children;
+ t.is(wrappedView.key, 'view-ortho', 'Has ortho view');
+ t.is(wrappedView.props.style.left, 400, 'Wrapper component has x position');
+
+ // check second view's child
+ wrappedChild = wrappedView.props.children;
t.is(wrappedChild.props.id, 'element-under-view', 'element child preserves id');
t.end();
});
-test('positionChildrenUnderViews#ContextProvider', t => {
+test('positionChildrenUnderViews#override ContextProvider', t => {
const context = React.createContext();
const children = positionChildrenUnderViews({
@@ -98,9 +112,11 @@ test('positionChildrenUnderViews#ContextProvider', t => {
t.is(children.length, 2, 'Returns wrapped children');
+ t.is(children[0].key, 'view-map-context', 'Child has deck context');
t.is(children[0].type, context.Provider, 'child is wrapped in ContextProvider');
t.is(children[0].props.value.viewport, TEST_VIEWPORTS.map, 'Context has viewport');
+ t.is(children[1].key, 'view-ortho-context', 'Child has deck context');
t.is(children[1].type, context.Provider, 'child is wrapped in ContextProvider');
t.is(children[1].props.value.viewport, TEST_VIEWPORTS.ortho, 'Context has viewport');
t.end();