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();