From aaf66a339c784c1c415f177978cae057e9eefca2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 13 Oct 2025 22:29:58 -0400 Subject: [PATCH 01/12] Define split renderers --- src/renderers/deck-first.tsx | 57 +++++++++++++++++++++++++++++ src/renderers/index.ts | 3 ++ src/renderers/overlay.tsx | 70 ++++++++++++++++++++++++++++++++++++ src/renderers/types.ts | 20 +++++++++++ 4 files changed, 150 insertions(+) create mode 100644 src/renderers/deck-first.tsx create mode 100644 src/renderers/index.ts create mode 100644 src/renderers/overlay.tsx create mode 100644 src/renderers/types.ts diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx new file mode 100644 index 00000000..4664cfb4 --- /dev/null +++ b/src/renderers/deck-first.tsx @@ -0,0 +1,57 @@ +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 DeckFirst: React.FC = ({ + mapStyle, + customAttribution, + initialViewState, + layers, + deckRef, + getTooltip, + isDrawingBBoxSelection, + pickingRadius, + useDevicePixels, + parameters, + onMapClick, + onMapHover, + onViewStateChange, +}) => { + return ( + (isDrawingBBoxSelection ? "crosshair" : "grab")} + pickingRadius={pickingRadius} + onClick={onMapClick} + onHover={onMapHover} + // @ts-expect-error useDevicePixels should allow number + // https://github.com/visgl/deck.gl/pull/9826 + useDevicePixels={useDevicePixels} + // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops + _typedArrayManagerProps={{ + overAlloc: 1, + poolSize: 0, + }} + onViewStateChange={onViewStateChange} + parameters={parameters} + > + + + ); +}; + +export default DeckFirst; 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..37cdb365 --- /dev/null +++ b/src/renderers/overlay.tsx @@ -0,0 +1,70 @@ +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 Overlay: React.FC = ({ + mapStyle, + customAttribution, + initialViewState, + layers, + getTooltip, + isDrawingBBoxSelection, + pickingRadius, + useDevicePixels, + parameters, + onMapClick, + onMapHover, + onViewStateChange, +}) => { + return ( + + (isDrawingBBoxSelection ? "crosshair" : "grab")} + pickingRadius={pickingRadius} + onClick={onMapClick} + onHover={onMapHover} + // @ts-expect-error useDevicePixels should allow number + // https://github.com/visgl/deck.gl/pull/9826 + useDevicePixels={useDevicePixels} + // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops + _typedArrayManagerProps={{ + overAlloc: 1, + poolSize: 0, + }} + onViewStateChange={onViewStateChange} + parameters={parameters} + /> + + ); +}; + +export default Overlay; diff --git a/src/renderers/types.ts b/src/renderers/types.ts new file mode 100644 index 00000000..62ef98d9 --- /dev/null +++ b/src/renderers/types.ts @@ -0,0 +1,20 @@ +import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; +import { DeckGLRef } from "@deck.gl/react"; +import type { RefObject } from "react"; + +export interface MapRendererProps { + mapStyle: string; + customAttribution: string; + initialViewState: MapViewState; + layers: Layer[]; + deckRef?: RefObject; + showTooltip: boolean; + getTooltip?: ((info: PickingInfo) => string | null) | undefined; + isDrawingBBoxSelection: boolean; + pickingRadius: number; + useDevicePixels: number | boolean; + parameters: object; + onMapClick: (info: PickingInfo) => void; + onMapHover: (info: PickingInfo) => void; + onViewStateChange: (event: { viewState: MapViewState }) => void; +} From ca7bfd44d203a462e137931b2aa18f6a01a81dea Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 13 Oct 2025 22:59:37 -0400 Subject: [PATCH 02/12] Implement split renderers --- lonboard/_map.py | 2 + src/index.tsx | 106 +++++++++++++++++------------------ src/renderers/deck-first.tsx | 33 ++--------- src/renderers/overlay.tsx | 38 ++++--------- src/renderers/types.ts | 32 ++++++----- 5 files changed, 86 insertions(+), 125 deletions(-) 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 index 4664cfb4..8012d288 100644 --- a/src/renderers/deck-first.tsx +++ b/src/renderers/deck-first.tsx @@ -11,47 +11,24 @@ import type { MapRendererProps } from "./types"; * This is the traditional approach where deck.gl has full control over * the rendering pipeline. */ -const DeckFirst: React.FC = ({ - mapStyle, - customAttribution, - initialViewState, - layers, - deckRef, - getTooltip, - isDrawingBBoxSelection, - pickingRadius, - useDevicePixels, - parameters, - onMapClick, - onMapHover, - onViewStateChange, -}) => { +const DeckFirstRenderer: React.FC = (mapProps) => { + // Remove maplibre-specific props before passing to DeckGL + const { mapStyle, customAttribution, deckRef, ...deckProps } = mapProps; return ( (isDrawingBBoxSelection ? "crosshair" : "grab")} - pickingRadius={pickingRadius} - onClick={onMapClick} - onHover={onMapHover} - // @ts-expect-error useDevicePixels should allow number - // https://github.com/visgl/deck.gl/pull/9826 - useDevicePixels={useDevicePixels} // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops _typedArrayManagerProps={{ overAlloc: 1, poolSize: 0, }} - onViewStateChange={onViewStateChange} - parameters={parameters} + {...deckProps} > ); }; -export default DeckFirst; +export default DeckFirstRenderer; diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx index 37cdb365..be95039a 100644 --- a/src/renderers/overlay.tsx +++ b/src/renderers/overlay.tsx @@ -23,20 +23,15 @@ function DeckGLOverlay(props: MapboxOverlayProps) { * MapboxOverlay. This approach gives the base map more control and can * enable features like interleaved rendering between map and deck layers. */ -const Overlay: React.FC = ({ - mapStyle, - customAttribution, - initialViewState, - layers, - getTooltip, - isDrawingBBoxSelection, - pickingRadius, - useDevicePixels, - parameters, - onMapClick, - onMapHover, - onViewStateChange, -}) => { +const OverlayRenderer: React.FC = (mapProps) => { + // Remove maplibre-specific props before passing to DeckGL + const { + mapStyle, + customAttribution, + initialViewState, + // deckRef, + ...deckProps + } = mapProps; return ( = ({ style={{ width: "100%", height: "100%" }} > (isDrawingBBoxSelection ? "crosshair" : "grab")} - pickingRadius={pickingRadius} - onClick={onMapClick} - onHover={onMapHover} - // @ts-expect-error useDevicePixels should allow number - // https://github.com/visgl/deck.gl/pull/9826 - useDevicePixels={useDevicePixels} + // ref={deckRef} // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops _typedArrayManagerProps={{ overAlloc: 1, poolSize: 0, }} - onViewStateChange={onViewStateChange} - parameters={parameters} + {...deckProps} /> ); }; -export default Overlay; +export default OverlayRenderer; diff --git a/src/renderers/types.ts b/src/renderers/types.ts index 62ef98d9..b699fe62 100644 --- a/src/renderers/types.ts +++ b/src/renderers/types.ts @@ -1,20 +1,22 @@ -import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; -import { DeckGLRef } from "@deck.gl/react"; +import type { DeckProps, View } from "@deck.gl/core"; +import type { DeckGLRef } from "@deck.gl/react"; import type { RefObject } from "react"; -export interface MapRendererProps { +type ViewOrViews = View | View[] | null; +export type MapRendererProps = Pick< + DeckProps, + | "layers" + | "getTooltip" + | "getCursor" + | "pickingRadius" + | "useDevicePixels" + | "parameters" + | "initialViewState" + | "onClick" + | "onHover" + | "onViewStateChange" +> & { mapStyle: string; customAttribution: string; - initialViewState: MapViewState; - layers: Layer[]; deckRef?: RefObject; - showTooltip: boolean; - getTooltip?: ((info: PickingInfo) => string | null) | undefined; - isDrawingBBoxSelection: boolean; - pickingRadius: number; - useDevicePixels: number | boolean; - parameters: object; - onMapClick: (info: PickingInfo) => void; - onMapHover: (info: PickingInfo) => void; - onViewStateChange: (event: { viewState: MapViewState }) => void; -} +}; From a043703b75138cf3afb92758b8e2533e609b71e7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 13 Oct 2025 23:11:40 -0400 Subject: [PATCH 03/12] alphabetize --- src/renderers/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderers/types.ts b/src/renderers/types.ts index b699fe62..d6f806d8 100644 --- a/src/renderers/types.ts +++ b/src/renderers/types.ts @@ -5,16 +5,16 @@ import type { RefObject } from "react"; type ViewOrViews = View | View[] | null; export type MapRendererProps = Pick< DeckProps, - | "layers" - | "getTooltip" | "getCursor" - | "pickingRadius" - | "useDevicePixels" - | "parameters" + | "getTooltip" | "initialViewState" + | "layers" | "onClick" | "onHover" | "onViewStateChange" + | "parameters" + | "pickingRadius" + | "useDevicePixels" > & { mapStyle: string; customAttribution: string; From 00a3c0bcec57375c3b09c9f2bdf117ce52042487 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 14 Oct 2025 13:17:09 -0400 Subject: [PATCH 04/12] define map controls --- lonboard/_map.py | 8 +++ lonboard/controls.py | 77 +++++++++++++++++++++- src/model/index.ts | 6 ++ src/model/map-control.tsx | 131 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/model/map-control.tsx diff --git a/lonboard/_map.py b/lonboard/_map.py index 3164cb7d..a9f0bb32 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 CartoBasemap +from lonboard.controls import BaseControl from lonboard.traits import ( DEFAULT_INITIAL_VIEW_STATE, BasemapUrl, @@ -194,6 +195,13 @@ 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. + """ + show_tooltip = t.Bool(default_value=False).tag(sync=True) """ Whether to render a tooltip on hover on the map. 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/model/index.ts b/src/model/index.ts index 091815c9..145f7981 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -13,3 +13,9 @@ export { PathStyleExtension, initializeExtension, } from "./extension.js"; +export { + BaseMapControlModel, + FullscreenModel, + NavigationModel, + ScaleModel, +} from "./map-control.js"; diff --git a/src/model/map-control.tsx b/src/model/map-control.tsx new file mode 100644 index 00000000..32d43a0b --- /dev/null +++ b/src/model/map-control.tsx @@ -0,0 +1,131 @@ +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 FullscreenModel extends BaseMapControlModel { + static controlType = "fullscreen"; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + } + + renderDeck() { + return
{}
; + } + + renderMaplibre() { + return
{}
; + } +} + +export class NavigationModel 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 ScaleModel 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
{}
; + } +} From 92150082993803d4b64d31a6a055dd5ab6ebb9ba Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 14 Oct 2025 13:24:05 -0400 Subject: [PATCH 05/12] initializeControl --- src/model/map-control.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/model/map-control.tsx b/src/model/map-control.tsx index 32d43a0b..bf72db07 100644 --- a/src/model/map-control.tsx +++ b/src/model/map-control.tsx @@ -129,3 +129,30 @@ export class ScaleModel extends BaseMapControlModel { return
{}
; } } + +export async function initializeControl( + model: WidgetModel, + updateStateCallback: () => void, +): Promise { + const controlType = model.get("_control_type"); + let controlModel: BaseMapControlModel; + switch (controlType) { + case FullscreenModel.controlType: + controlModel = new FullscreenModel(model, updateStateCallback); + break; + + case NavigationModel.controlType: + controlModel = new NavigationModel(model, updateStateCallback); + break; + + case ScaleModel.controlType: + controlModel = new ScaleModel(model, updateStateCallback); + break; + + default: + throw new Error(`no control supported for ${controlType}`); + } + + await controlModel.loadSubModels(); + return controlModel; +} From 731af743450cf728b405c037b85f7d684f06483a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 21 Oct 2025 23:31:46 -0400 Subject: [PATCH 06/12] rename models --- src/model/index.ts | 6 +++--- src/model/map-control.tsx | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/model/index.ts b/src/model/index.ts index 042ae0fb..21085b91 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -15,8 +15,8 @@ export { } from "./extension.js"; export { BaseMapControlModel, - FullscreenModel, - NavigationModel, - ScaleModel, + 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 index bf72db07..983b5387 100644 --- a/src/model/map-control.tsx +++ b/src/model/map-control.tsx @@ -11,6 +11,7 @@ import { NavigationControl, ScaleControl, } from "react-map-gl/maplibre"; + import { isDefined } from "../util"; import { BaseModel } from "./base"; @@ -45,7 +46,7 @@ export abstract class BaseMapControlModel extends BaseModel { abstract renderMaplibre(): React.JSX.Element | null; } -export class FullscreenModel extends BaseMapControlModel { +export class FullscreenControlModel extends BaseMapControlModel { static controlType = "fullscreen"; constructor(model: WidgetModel, updateStateCallback: () => void) { @@ -61,7 +62,7 @@ export class FullscreenModel extends BaseMapControlModel { } } -export class NavigationModel extends BaseMapControlModel { +export class NavigationControlModel extends BaseMapControlModel { static controlType = "navigation"; protected showCompass?: boolean; @@ -103,7 +104,7 @@ export class NavigationModel extends BaseMapControlModel { } } -export class ScaleModel extends BaseMapControlModel { +export class ScaleControlModel extends BaseMapControlModel { static controlType = "scale"; protected maxWidth?: number; @@ -137,22 +138,21 @@ export async function initializeControl( const controlType = model.get("_control_type"); let controlModel: BaseMapControlModel; switch (controlType) { - case FullscreenModel.controlType: - controlModel = new FullscreenModel(model, updateStateCallback); + case FullscreenControlModel.controlType: + controlModel = new FullscreenControlModel(model, updateStateCallback); break; - case NavigationModel.controlType: - controlModel = new NavigationModel(model, updateStateCallback); + case NavigationControlModel.controlType: + controlModel = new NavigationControlModel(model, updateStateCallback); break; - case ScaleModel.controlType: - controlModel = new ScaleModel(model, updateStateCallback); + case ScaleControlModel.controlType: + controlModel = new ScaleControlModel(model, updateStateCallback); break; default: throw new Error(`no control supported for ${controlType}`); } - await controlModel.loadSubModels(); return controlModel; } From 9ee0b9a81664e10aa310ac775362c575d35c1bee Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 21 Oct 2025 23:52:24 -0400 Subject: [PATCH 07/12] remove accidental `render_mode` --- lonboard/_map.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 2c8c18a9..bfbe4dbc 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -184,8 +184,6 @@ 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. From 229cebf55e4a20bf905ea4e31a7e3bef7149fd5c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 21 Oct 2025 23:56:02 -0400 Subject: [PATCH 08/12] Pass controls into renderers --- src/index.tsx | 34 ++++++++++++++++++++++++++++++++++ src/renderers/deck-first.tsx | 11 +++++++++-- src/renderers/overlay.tsx | 11 +++++++++-- src/renderers/types.ts | 3 +++ 4 files changed, 55 insertions(+), 4 deletions(-) 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/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 = { From 3b08a3f0fa57c64a1141c4e6b822b1ef60835083 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 23 Oct 2025 11:31:00 -0400 Subject: [PATCH 09/12] wip: console log (revert later) --- src/model/map-control.tsx | 5 ++++- src/renderers/deck-first.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/model/map-control.tsx b/src/model/map-control.tsx index 983b5387..0b42009b 100644 --- a/src/model/map-control.tsx +++ b/src/model/map-control.tsx @@ -54,7 +54,10 @@ export class FullscreenControlModel extends BaseMapControlModel { } renderDeck() { - return
{}
; + const { placement, ...otherProps } = this.baseDeckProps(); + const props = { placement: placement || "top-right", ...otherProps }; + console.log(placement); + return
{}
; } renderMaplibre() { diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx index 187899fe..d142f38d 100644 --- a/src/renderers/deck-first.tsx +++ b/src/renderers/deck-first.tsx @@ -1,4 +1,4 @@ -import DeckGL from "@deck.gl/react"; +import DeckGL, { FullscreenWidget, ZoomWidget } from "@deck.gl/react"; import React from "react"; import Map from "react-map-gl/maplibre"; @@ -36,6 +36,7 @@ const DeckFirstRenderer: React.FC = ( }} {...deckProps} > + {controls.map((control) => control.renderDeck())} {renderBasemap && ( From 74f78c5cce597031bc87b2ea4fbc1060183178be Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 21:16:53 -0400 Subject: [PATCH 10/12] cleanup --- src/index.tsx | 2 +- src/renderers/deck-first.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 9ad2019a..cb3a9c26 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { flyTo } from "./actions/fly-to.js"; +import { useControlsState } from "./hooks/controls.js"; import { useBasemapState, useLayersState, @@ -35,7 +36,6 @@ import * as selectors from "./xstate/selectors"; import "maplibre-gl/dist/maplibre-gl.css"; import "./globals.css"; -import { useControlsState } from "./hooks/controls.js"; await initParquetWasm(); diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx index d142f38d..187899fe 100644 --- a/src/renderers/deck-first.tsx +++ b/src/renderers/deck-first.tsx @@ -1,4 +1,4 @@ -import DeckGL, { FullscreenWidget, ZoomWidget } from "@deck.gl/react"; +import DeckGL from "@deck.gl/react"; import React from "react"; import Map from "react-map-gl/maplibre"; @@ -36,7 +36,6 @@ const DeckFirstRenderer: React.FC = ( }} {...deckProps} > - {controls.map((control) => control.renderDeck())} {renderBasemap && ( From 9cf8e14e52793d4a60752938cf3b7af4ad7c3590 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 21:17:35 -0400 Subject: [PATCH 11/12] update import --- src/hooks/index.ts | 1 + src/index.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c6599294..af0bcbd5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ export { useBasemapState } from "./basemap.js"; +export { useControlsState } from "./controls.js"; export { useLayersState } from "./layers.js"; export { useViewsState } from "./views.js"; diff --git a/src/index.tsx b/src/index.tsx index cb3a9c26..d57a401b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,9 +10,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { flyTo } from "./actions/fly-to.js"; -import { useControlsState } from "./hooks/controls.js"; import { useBasemapState, + useControlsState, useLayersState, useViewsState, } from "./hooks/index.js"; From fcf87211de23943d290cc95237230ec1a073645d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 23:23:01 -0400 Subject: [PATCH 12/12] Move bounding box selection to bottom right --- src/toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toolbar.tsx b/src/toolbar.tsx index e730bfbe..cd57bfe1 100644 --- a/src/toolbar.tsx +++ b/src/toolbar.tsx @@ -28,7 +28,7 @@ const Toolbar: React.FC = () => {