diff --git a/lonboard/_map.py b/lonboard/_map.py index 9979e476..3164cb7d 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -179,6 +179,8 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: Indicates if a click handler has been registered. """ + render_mode = t.Unicode(default_value="deck-first").tag(sync=True) + height = HeightTrait().tag(sync=True) """Height of the map in pixels, or valid CSS height property. diff --git a/src/index.tsx b/src/index.tsx index bc4aff25..911fdf5a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,11 +2,9 @@ import * as React from "react"; import { useEffect, useCallback, useState, useRef } from "react"; import { createRender, useModelState, useModel } from "@anywidget/react"; import type { Initialize, Render } from "@anywidget/types"; -import Map from "react-map-gl/maplibre"; -import DeckGL from "@deck.gl/react"; import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; import { BaseLayerModel, initializeLayer } from "./model/index.js"; -import type { WidgetModel } from "@jupyter-widgets/base"; +import type { IWidgetManager, WidgetModel } from "@jupyter-widgets/base"; import { initParquetWasm } from "./parquet.js"; import { isDefined, loadChildModels } from "./util.js"; import { v4 as uuidv4 } from "uuid"; @@ -25,6 +23,9 @@ import throttle from "lodash.throttle"; import SidePanel from "./sidepanel/index"; import { getTooltip } from "./tooltip/index.js"; import { DeckGLRef } from "@deck.gl/react"; +import OverlayRenderer from "./renderers/overlay.js"; +import { MapRendererProps } from "./renderers/types.js"; +import DeckFirstRenderer from "./renderers/deck-first.js"; await initParquetWasm(); @@ -116,6 +117,7 @@ function App() { ); const [parameters] = useModelState("parameters"); const [customAttribution] = useModelState("custom_attribution"); + const [renderMode] = useModelState("render_mode"); // initialViewState is the value of view_state on the Python side. This is // called `initial` here because it gets passed in to deck's @@ -156,7 +158,7 @@ function App() { const loadAndUpdateLayers = async () => { try { const childModels = await loadChildModels( - model.widget_manager, + model.widget_manager as IWidgetManager, childLayerIds, ); @@ -229,6 +231,45 @@ function App() { [isOnMapHoverEventEnabled, justClicked], ); + const mapRenderProps: MapRendererProps = { + mapStyle: mapStyle || DEFAULT_MAP_STYLE, + customAttribution, + deckRef, + initialViewState: ["longitude", "latitude", "zoom"].every((key) => + Object.keys(initialViewState).includes(key), + ) + ? initialViewState + : DEFAULT_INITIAL_VIEW_STATE, + layers: bboxSelectPolygonLayer + ? layers.concat(bboxSelectPolygonLayer) + : layers, + getTooltip: (showTooltip && getTooltip) || undefined, + getCursor: () => (isDrawingBBoxSelection ? "crosshair" : "grab"), + pickingRadius: pickingRadius, + onClick: onMapClickHandler, + onHover: onMapHoverHandler, + // @ts-expect-error useDevicePixels should allow number + // https://github.com/visgl/deck.gl/pull/9826 + useDevicePixels: isDefined(useDevicePixels) ? useDevicePixels : true, + onViewStateChange: (event) => { + const { viewState } = event; + + // This condition is necessary to confirm that the viewState is + // of type MapViewState. + if ("latitude" in viewState) { + const { longitude, latitude, zoom, pitch, bearing } = viewState; + setViewState({ + longitude, + latitude, + zoom, + pitch, + bearing, + }); + } + }, + parameters: parameters || {}, + }; + return (
)}
- - Object.keys(initialViewState).includes(key), - ) - ? initialViewState - : DEFAULT_INITIAL_VIEW_STATE - } - controller={true} - layers={ - bboxSelectPolygonLayer - ? layers.concat(bboxSelectPolygonLayer) - : layers - } - getTooltip={(showTooltip && getTooltip) || undefined} - getCursor={() => (isDrawingBBoxSelection ? "crosshair" : "grab")} - pickingRadius={pickingRadius} - onClick={onMapClickHandler} - onHover={onMapHoverHandler} - useDevicePixels={ - isDefined(useDevicePixels) ? useDevicePixels : true - } - // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops - _typedArrayManagerProps={{ - overAlloc: 1, - poolSize: 0, - }} - onViewStateChange={(event) => { - const { viewState } = event; - - // This condition is necessary to confirm that the viewState is - // of type MapViewState. - if ("latitude" in viewState) { - const { longitude, latitude, zoom, pitch, bearing } = viewState; - setViewState({ - longitude, - latitude, - zoom, - pitch, - bearing, - }); - } - }} - parameters={parameters || {}} - > - - + {renderMode === "overlay" ? ( + + ) : ( + + )}
diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx new file mode 100644 index 00000000..8012d288 --- /dev/null +++ b/src/renderers/deck-first.tsx @@ -0,0 +1,34 @@ +import DeckGL from "@deck.gl/react"; +import React from "react"; +import Map from "react-map-gl/maplibre"; +import type { MapRendererProps } from "./types"; + +/** + * DeckFirst renderer: DeckGL wraps Map component + * + * In this rendering mode, deck.gl is the parent component that manages + * the canvas and view state, with the map rendered as a child component. + * This is the traditional approach where deck.gl has full control over + * the rendering pipeline. + */ +const DeckFirstRenderer: React.FC = (mapProps) => { + // Remove maplibre-specific props before passing to DeckGL + const { mapStyle, customAttribution, deckRef, ...deckProps } = mapProps; + return ( + + + + ); +}; + +export default DeckFirstRenderer; diff --git a/src/renderers/index.ts b/src/renderers/index.ts new file mode 100644 index 00000000..3b97f2ff --- /dev/null +++ b/src/renderers/index.ts @@ -0,0 +1,3 @@ +export { default as DeckFirst } from "./deck-first"; +export { default as Overlay } from "./overlay"; +export type { MapRendererProps } from "./types"; diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx new file mode 100644 index 00000000..646a6f96 --- /dev/null +++ b/src/renderers/overlay.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import Map, { useControl } from "react-map-gl/maplibre"; +import { MapboxOverlay, MapboxOverlayProps } from "@deck.gl/mapbox"; +import type { MapRendererProps } from "./types"; + +/** + * DeckGLOverlay component that integrates deck.gl with react-map-gl + * + * Uses the useControl hook to create a MapboxOverlay instance that + * renders deck.gl layers on top of the base map. + */ +function DeckGLOverlay(props: MapboxOverlayProps) { + const overlay = useControl(() => new MapboxOverlay(props)); + overlay.setProps(props); + return null; +} + +/** + * Overlay renderer: Map wraps DeckGLOverlay component + * + * In this rendering mode, the map is the parent component that controls + * the view state, with deck.gl layers rendered as an overlay using the + * MapboxOverlay. This approach gives the base map more control and can + * enable features like interleaved rendering between map and deck layers. + */ +const OverlayRenderer: React.FC = (mapProps) => { + // Remove maplibre-specific props before passing to DeckGL + const { mapStyle, customAttribution, initialViewState, ...deckProps } = + mapProps; + return ( + + + + ); +}; + +export default OverlayRenderer; diff --git a/src/renderers/types.ts b/src/renderers/types.ts new file mode 100644 index 00000000..d6f806d8 --- /dev/null +++ b/src/renderers/types.ts @@ -0,0 +1,22 @@ +import type { DeckProps, View } from "@deck.gl/core"; +import type { DeckGLRef } from "@deck.gl/react"; +import type { RefObject } from "react"; + +type ViewOrViews = View | View[] | null; +export type MapRendererProps = Pick< + DeckProps, + | "getCursor" + | "getTooltip" + | "initialViewState" + | "layers" + | "onClick" + | "onHover" + | "onViewStateChange" + | "parameters" + | "pickingRadius" + | "useDevicePixels" +> & { + mapStyle: string; + customAttribution: string; + deckRef?: RefObject; +};