diff --git a/lonboard/_map.py b/lonboard/_map.py index 3b0750ff..bfbe4dbc 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -14,6 +14,7 @@ from lonboard._layer import BaseLayer from lonboard._viewport import compute_view from lonboard.basemap import CartoStyle, MaplibreBasemap +from lonboard.controls import BaseControl from lonboard.traits import ( DEFAULT_INITIAL_VIEW_STATE, HeightTrait, @@ -196,6 +197,12 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: """One or more `Layer` objects to display on this map. """ + controls = VariableLengthTuple(t.Instance(BaseControl)).tag( + sync=True, + **ipywidgets.widget_serialization, + ) + """One or more map controls to display on this map.""" + views: t.Instance[BaseView | None] = t.Instance(BaseView, allow_none=True).tag( sync=True, **ipywidgets.widget_serialization, diff --git a/lonboard/controls.py b/lonboard/controls.py index 0a855559..e9fc1744 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -2,13 +2,15 @@ from functools import partial from typing import Any -import traitlets +import traitlets as t from ipywidgets import FloatRangeSlider from ipywidgets.widgets.trait_types import TypedTuple # Import from source to allow mkdocstrings to link to base class from ipywidgets.widgets.widget_box import VBox +from lonboard._base import BaseWidget + class MultiRangeSlider(VBox): """A widget for multiple ranged sliders. @@ -62,7 +64,7 @@ class MultiRangeSlider(VBox): # We use a tuple to force reassignment to update the list # This is because list mutations do not get propagated as events # https://github.com/jupyter-widgets/ipywidgets/blob/b2531796d414b0970f18050d6819d932417b9953/python/ipywidgets/ipywidgets/widgets/widget_box.py#L52-L54 - value = TypedTuple(trait=TypedTuple(trait=traitlets.Float())).tag(sync=True) + value = TypedTuple(trait=TypedTuple(trait=t.Float())).tag(sync=True) def __init__(self, children: Sequence[FloatRangeSlider], **kwargs: Any) -> None: """Create a new MultiRangeSlider.""" @@ -88,3 +90,74 @@ def callback(change: dict, *, i: int) -> None: initial_values.append(child.value) super().__init__(children, value=initial_values, **kwargs) + + +class BaseControl(BaseWidget): + """A deck.gl or Maplibre Control.""" + + position = t.Union( + [ + t.Unicode("top-left"), + t.Unicode("top-right"), + t.Unicode("bottom-left"), + t.Unicode("bottom-right"), + ], + allow_none=True, + default_value=None, + ).tag(sync=True) + """Position of the control in the map. + """ + + +class FullscreenControl(BaseControl): + """A deck.gl FullscreenControl.""" + + _control_type = t.Unicode("fullscreen").tag(sync=True) + + +class NavigationControl(BaseControl): + """A deck.gl NavigationControl.""" + + _control_type = t.Unicode("navigation").tag(sync=True) + + show_compass = t.Bool(allow_none=True, default_value=None).tag(sync=True) + """Whether to show the compass button. + + Default `true`. + """ + + show_zoom = t.Bool(allow_none=True, default_value=None).tag(sync=True) + """Whether to show the zoom buttons. + + Default `true`. + """ + + visualize_pitch = t.Bool(allow_none=True, default_value=None).tag(sync=True) + """Whether to enable pitch visualization. + + Default `true`. + """ + + visualize_roll = t.Bool(allow_none=True, default_value=None).tag(sync=True) + """Whether to enable roll visualization. + + Default `false`. + """ + + +class ScaleControl(BaseControl): + """A deck.gl ScaleControl.""" + + _control_type = t.Unicode("scale").tag(sync=True) + + max_width = t.Int(allow_none=True, default_value=None).tag(sync=True) + """The maximum width of the scale control in pixels. + + Default `100`. + """ + + unit = t.Unicode(allow_none=True, default_value=None).tag(sync=True) + """The unit of the scale. + + One of `'metric'`, `'imperial'`, or `'nautical'`. Default is `'metric'`. + """ diff --git a/src/index.tsx b/src/index.tsx index e9b3a778..fd7c1625 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,8 +15,10 @@ import { initializeLayer, type BaseLayerModel, initializeChildModels, + BaseMapControlModel, } from "./model/index.js"; import { loadModel } from "./model/initialize.js"; +import { initializeControl } from "./model/map-control.js"; import { BaseViewModel, initializeView } from "./model/view.js"; import { initParquetWasm } from "./parquet.js"; import DeckFirstRenderer from "./renderers/deck-first.js"; @@ -94,6 +96,7 @@ function App() { const [mapId] = useState(uuidv4()); const [childLayerIds] = useModelState("layers"); const [viewIds] = useModelState("views"); + const [controlsIds] = useModelState("controls"); // initialViewState is the value of view_state on the Python side. This is // called `initial` here because it gets passed in to deck's @@ -157,6 +160,36 @@ function App() { loadBasemap(); }, [basemapModelId]); + ////////////////////// + // Controls state + ////////////////////// + + const [controlsState, setControlsState] = useState< + Record + >({}); + + useEffect(() => { + const loadMapControls = async () => { + try { + const controlsModels = await initializeChildModels( + model.widget_manager as IWidgetManager, + controlsIds, + controlsState, + async (model: WidgetModel) => + initializeControl(model, updateStateCallback), + ); + + setControlsState(controlsModels); + } catch (error) { + console.error("Error loading controls:", error); + } + }; + + loadMapControls(); + }, [controlsIds]); + + const controls = Object.values(controlsState); + ////////////////////// // Layers state ////////////////////// @@ -311,6 +344,7 @@ function App() { }, parameters: parameters || {}, views, + controls, }; const overlayRenderProps: OverlayRendererProps = { diff --git a/src/model/index.ts b/src/model/index.ts index 65c3f848..21085b91 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -13,4 +13,10 @@ export { PathStyleExtension, initializeExtension, } from "./extension.js"; +export { + BaseMapControlModel, + FullscreenControlModel, + NavigationControlModel, + ScaleControlModel, +} from "./map-control.js"; export { initializeChildModels } from "./initialize.js"; diff --git a/src/model/map-control.tsx b/src/model/map-control.tsx new file mode 100644 index 00000000..983b5387 --- /dev/null +++ b/src/model/map-control.tsx @@ -0,0 +1,158 @@ +import { + CompassWidget, + FullscreenWidget, + _ScaleWidget as ScaleWidget, + ZoomWidget, +} from "@deck.gl/react"; +import type { WidgetModel } from "@jupyter-widgets/base"; +import React from "react"; +import { + FullscreenControl, + NavigationControl, + ScaleControl, +} from "react-map-gl/maplibre"; + +import { isDefined } from "../util"; +import { BaseModel } from "./base"; + +export abstract class BaseMapControlModel extends BaseModel { + static controlType: string; + + protected position?: + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right"; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("position", "position"); + } + + baseDeckProps() { + return { + ...(isDefined(this.position) ? { placement: this.position } : {}), + }; + } + + baseMaplibreProps() { + return { + ...(isDefined(this.position) ? { position: this.position } : {}), + }; + } + + abstract renderDeck(): React.JSX.Element | null; + abstract renderMaplibre(): React.JSX.Element | null; +} + +export class FullscreenControlModel extends BaseMapControlModel { + static controlType = "fullscreen"; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + } + + renderDeck() { + return
{}
; + } + + renderMaplibre() { + return
{}
; + } +} + +export class NavigationControlModel extends BaseMapControlModel { + static controlType = "navigation"; + + protected showCompass?: boolean; + protected showZoom?: boolean; + protected visualizePitch?: boolean; + protected visualizeRoll?: boolean; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("show_compass", "showCompass"); + this.initRegularAttribute("show_zoom", "showZoom"); + this.initRegularAttribute("visualize_pitch", "visualizePitch"); + this.initRegularAttribute("visualize_roll", "visualizeRoll"); + } + + renderDeck() { + return ( +
+ {this.showZoom && } + {this.showCompass && } +
+ ); + } + + renderMaplibre() { + const props = { + ...this.baseMaplibreProps(), + ...(isDefined(this.showCompass) && { showCompass: this.showCompass }), + ...(isDefined(this.showZoom) && { showZoom: this.showZoom }), + ...(isDefined(this.visualizePitch) && { + visualizePitch: this.visualizePitch, + }), + ...(isDefined(this.visualizeRoll) && { + visualizeRoll: this.visualizeRoll, + }), + }; + return ; + } +} + +export class ScaleControlModel extends BaseMapControlModel { + static controlType = "scale"; + + protected maxWidth?: number; + protected unit?: "imperial" | "metric" | "nautical"; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("max_width", "maxWidth"); + this.initRegularAttribute("unit", "unit"); + } + + renderDeck() { + return ; + } + + renderMaplibre() { + const props = { + ...this.baseMaplibreProps(), + ...(isDefined(this.maxWidth) && { maxWidth: this.maxWidth }), + ...(isDefined(this.unit) && { unit: this.unit }), + }; + return
{}
; + } +} + +export async function initializeControl( + model: WidgetModel, + updateStateCallback: () => void, +): Promise { + const controlType = model.get("_control_type"); + let controlModel: BaseMapControlModel; + switch (controlType) { + case FullscreenControlModel.controlType: + controlModel = new FullscreenControlModel(model, updateStateCallback); + break; + + case NavigationControlModel.controlType: + controlModel = new NavigationControlModel(model, updateStateCallback); + break; + + case ScaleControlModel.controlType: + controlModel = new ScaleControlModel(model, updateStateCallback); + break; + + default: + throw new Error(`no control supported for ${controlType}`); + } + + return controlModel; +} diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx index dcf6cfc5..187899fe 100644 --- a/src/renderers/deck-first.tsx +++ b/src/renderers/deck-first.tsx @@ -16,8 +16,14 @@ const DeckFirstRenderer: React.FC = ( mapProps, ) => { // Remove maplibre-specific props before passing to DeckGL - const { mapStyle, customAttribution, deckRef, renderBasemap, ...deckProps } = - mapProps; + const { + controls, + mapStyle, + customAttribution, + deckRef, + renderBasemap, + ...deckProps + } = mapProps; return ( = ( }} {...deckProps} > + {controls.map((control) => control.renderDeck())} {renderBasemap && ( )} diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx index a2b96043..5ea46df9 100644 --- a/src/renderers/overlay.tsx +++ b/src/renderers/overlay.tsx @@ -29,8 +29,14 @@ const OverlayRenderer: React.FC = ( mapProps, ) => { // Remove maplibre-specific props before passing to DeckGL - const { mapStyle, customAttribution, initialViewState, views, ...deckProps } = - mapProps; + const { + controls, + mapStyle, + customAttribution, + initialViewState, + views, + ...deckProps + } = mapProps; return ( = ( style={{ width: "100%", height: "100%" }} {...(isGlobeView(views) && { projection: "globe" })} > + {controls.map((control) => control.renderMaplibre())} = Pick< DeckProps, @@ -20,6 +22,7 @@ export type MapRendererProps = Pick< mapStyle: string; customAttribution: string; deckRef?: RefObject; + controls: BaseMapControlModel[]; }; export type OverlayRendererProps = {