Skip to content

Commit 1ec97a8

Browse files
authored
feat: Support for map controls (#924)
### Change list - Adds definitions for map "controls" (which deck.gl calls "widgets", but the name "widget" is too overloaded in the context of Jupyter, so I'd like to call them "controls") - Support for `ScaleControl`, `NavigationControl`, `FullscreenControl` - These controls are supported by both deck.gl and maplibre, and so implementations are defined for creating both the deck.gl and maplibre counterparts ### Example ```py from lonboard import Map from lonboard.controls import ScaleControl, FullscreenControl, NavigationControl from lonboard.basemap import MaplibreBasemap m = Map([], basemap=MaplibreBasemap(mode="overlaid"), controls=[ScaleControl(), FullscreenControl(), NavigationControl()] ) m ``` <img width="734" height="506" alt="image" src="https://github.com/user-attachments/assets/42fb5e55-57d8-4b2a-8fe2-f70b804dabce" /> We should probably change the default positioning of controls, move the bbox selector, or make the bbox selector a control itself. ---- However, for deck.gl-driven rendering, I can only see the scale control: <img width="756" height="611" alt="image" src="https://github.com/user-attachments/assets/f8e6be57-55ec-45be-9c14-cf76b4ea64d0" /> Logged in #990 ### Todo list - Figure out how to pass these react components as children of the `OverlayRenderer` or `DeckFirstRenderer` (depends on #921) - Use the refactored model initialization from #923 to create the list of control instances on the deck side - Integrate more cleanly into the Python API (an extension of #908) Closes #842, relevant to #681
1 parent 9fb6bbc commit 1ec97a8

File tree

11 files changed

+322
-7
lines changed

11 files changed

+322
-7
lines changed

lonboard/_map.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from lonboard._html_export import map_to_html
1414
from lonboard._viewport import compute_view
1515
from lonboard.basemap import CartoStyle, MaplibreBasemap
16+
from lonboard.controls import BaseControl
1617
from lonboard.layer import BaseLayer
1718
from lonboard.traits import HeightTrait, VariableLengthTuple, ViewStateTrait
1819
from lonboard.traits._map import DEFAULT_INITIAL_VIEW_STATE
@@ -192,6 +193,12 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None:
192193
"""One or more `Layer` objects to display on this map.
193194
"""
194195

196+
controls = VariableLengthTuple(t.Instance(BaseControl)).tag(
197+
sync=True,
198+
**ipywidgets.widget_serialization,
199+
)
200+
"""One or more map controls to display on this map."""
201+
195202
views: t.Instance[BaseView | None] = t.Instance(BaseView, allow_none=True).tag(
196203
sync=True,
197204
**ipywidgets.widget_serialization,

lonboard/controls.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
from functools import partial
33
from typing import Any
44

5-
import traitlets
5+
import traitlets as t
66
from ipywidgets import FloatRangeSlider
77
from ipywidgets.widgets.trait_types import TypedTuple
88

99
# Import from source to allow mkdocstrings to link to base class
1010
from ipywidgets.widgets.widget_box import VBox
1111

12+
from lonboard._base import BaseWidget
13+
1214

1315
class MultiRangeSlider(VBox):
1416
"""A widget for multiple ranged sliders.
@@ -62,7 +64,7 @@ class MultiRangeSlider(VBox):
6264
# We use a tuple to force reassignment to update the list
6365
# This is because list mutations do not get propagated as events
6466
# https://github.com/jupyter-widgets/ipywidgets/blob/b2531796d414b0970f18050d6819d932417b9953/python/ipywidgets/ipywidgets/widgets/widget_box.py#L52-L54
65-
value = TypedTuple(trait=TypedTuple(trait=traitlets.Float())).tag(sync=True)
67+
value = TypedTuple(trait=TypedTuple(trait=t.Float())).tag(sync=True)
6668

6769
def __init__(self, children: Sequence[FloatRangeSlider], **kwargs: Any) -> None:
6870
"""Create a new MultiRangeSlider."""
@@ -88,3 +90,74 @@ def callback(change: dict, *, i: int) -> None:
8890
initial_values.append(child.value)
8991

9092
super().__init__(children, value=initial_values, **kwargs)
93+
94+
95+
class BaseControl(BaseWidget):
96+
"""A deck.gl or Maplibre Control."""
97+
98+
position = t.Union(
99+
[
100+
t.Unicode("top-left"),
101+
t.Unicode("top-right"),
102+
t.Unicode("bottom-left"),
103+
t.Unicode("bottom-right"),
104+
],
105+
allow_none=True,
106+
default_value=None,
107+
).tag(sync=True)
108+
"""Position of the control in the map.
109+
"""
110+
111+
112+
class FullscreenControl(BaseControl):
113+
"""A deck.gl FullscreenControl."""
114+
115+
_control_type = t.Unicode("fullscreen").tag(sync=True)
116+
117+
118+
class NavigationControl(BaseControl):
119+
"""A deck.gl NavigationControl."""
120+
121+
_control_type = t.Unicode("navigation").tag(sync=True)
122+
123+
show_compass = t.Bool(allow_none=True, default_value=None).tag(sync=True)
124+
"""Whether to show the compass button.
125+
126+
Default `true`.
127+
"""
128+
129+
show_zoom = t.Bool(allow_none=True, default_value=None).tag(sync=True)
130+
"""Whether to show the zoom buttons.
131+
132+
Default `true`.
133+
"""
134+
135+
visualize_pitch = t.Bool(allow_none=True, default_value=None).tag(sync=True)
136+
"""Whether to enable pitch visualization.
137+
138+
Default `true`.
139+
"""
140+
141+
visualize_roll = t.Bool(allow_none=True, default_value=None).tag(sync=True)
142+
"""Whether to enable roll visualization.
143+
144+
Default `false`.
145+
"""
146+
147+
148+
class ScaleControl(BaseControl):
149+
"""A deck.gl ScaleControl."""
150+
151+
_control_type = t.Unicode("scale").tag(sync=True)
152+
153+
max_width = t.Int(allow_none=True, default_value=None).tag(sync=True)
154+
"""The maximum width of the scale control in pixels.
155+
156+
Default `100`.
157+
"""
158+
159+
unit = t.Unicode(allow_none=True, default_value=None).tag(sync=True)
160+
"""The unit of the scale.
161+
162+
One of `'metric'`, `'imperial'`, or `'nautical'`. Default is `'metric'`.
163+
"""

src/hooks/controls.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { IWidgetManager, WidgetModel } from "@jupyter-widgets/base";
2+
import { useEffect, useState } from "react";
3+
4+
import { BaseMapControlModel } from "../model";
5+
import { initializeChildModels } from "../model/index.js";
6+
import { initializeControl } from "../model/map-control";
7+
8+
/**
9+
* Hook to manage map controls state
10+
*/
11+
export function useControlsState(
12+
controlsIds: string[],
13+
widgetManager: IWidgetManager,
14+
updateStateCallback: () => void,
15+
): BaseMapControlModel[] {
16+
const [controlsState, setControlsState] = useState<
17+
Record<string, BaseMapControlModel>
18+
>({});
19+
20+
useEffect(() => {
21+
const loadMapControls = async () => {
22+
try {
23+
const controlsModels = await initializeChildModels<BaseMapControlModel>(
24+
widgetManager,
25+
controlsIds,
26+
controlsState,
27+
async (model: WidgetModel) =>
28+
initializeControl(model, updateStateCallback),
29+
);
30+
31+
setControlsState(controlsModels);
32+
} catch (error) {
33+
console.error("Error loading controls:", error);
34+
}
35+
};
36+
37+
loadMapControls();
38+
}, [controlsIds]);
39+
40+
return Object.values(controlsState);
41+
}

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { useBasemapState } from "./basemap.js";
2+
export { useControlsState } from "./controls.js";
23
export { useLayersState } from "./layers.js";
34
export { useViewsState } from "./views.js";

src/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { v4 as uuidv4 } from "uuid";
1212
import { flyTo } from "./actions/fly-to.js";
1313
import {
1414
useBasemapState,
15+
useControlsState,
1516
useLayersState,
1617
useViewsState,
1718
} from "./hooks/index.js";
@@ -92,6 +93,7 @@ function App() {
9293
const [mapId] = useState(uuidv4());
9394
const [childLayerIds] = useModelState<string[]>("layers");
9495
const [viewIds] = useModelState<string | string[] | null>("views");
96+
const [controlsIds] = useModelState<string[]>("controls");
9597
// eslint-disable-next-line @typescript-eslint/no-unused-vars
9698
const [_selectedBounds, setSelectedBounds] = useModelState<number[] | null>(
9799
"selected_bounds",
@@ -132,6 +134,12 @@ function App() {
132134
updateStateCallback,
133135
);
134136

137+
const controls = useControlsState(
138+
controlsIds,
139+
model.widget_manager as IWidgetManager,
140+
updateStateCallback,
141+
);
142+
135143
const layers = useLayersState(
136144
childLayerIds,
137145
model.widget_manager as IWidgetManager,
@@ -215,6 +223,7 @@ function App() {
215223
},
216224
parameters: parameters || {},
217225
views,
226+
controls,
218227
};
219228

220229
const overlayRenderProps: OverlayRendererProps = {

src/model/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,10 @@ export {
1313
PathStyleExtension,
1414
initializeExtension,
1515
} from "./extension.js";
16+
export {
17+
BaseMapControlModel,
18+
FullscreenControlModel,
19+
NavigationControlModel,
20+
ScaleControlModel,
21+
} from "./map-control.js";
1622
export { initializeChildModels } from "./initialize.js";

src/model/map-control.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import {
2+
CompassWidget,
3+
FullscreenWidget,
4+
_ScaleWidget as ScaleWidget,
5+
ZoomWidget,
6+
} from "@deck.gl/react";
7+
import type { WidgetModel } from "@jupyter-widgets/base";
8+
import React from "react";
9+
import {
10+
FullscreenControl,
11+
NavigationControl,
12+
ScaleControl,
13+
} from "react-map-gl/maplibre";
14+
15+
import { isDefined } from "../util";
16+
import { BaseModel } from "./base";
17+
18+
export abstract class BaseMapControlModel extends BaseModel {
19+
static controlType: string;
20+
21+
protected position?:
22+
| "top-left"
23+
| "top-right"
24+
| "bottom-left"
25+
| "bottom-right";
26+
27+
constructor(model: WidgetModel, updateStateCallback: () => void) {
28+
super(model, updateStateCallback);
29+
30+
this.initRegularAttribute("position", "position");
31+
}
32+
33+
baseDeckProps() {
34+
return {
35+
...(isDefined(this.position) ? { placement: this.position } : {}),
36+
};
37+
}
38+
39+
baseMaplibreProps() {
40+
return {
41+
...(isDefined(this.position) ? { position: this.position } : {}),
42+
};
43+
}
44+
45+
abstract renderDeck(): React.JSX.Element | null;
46+
abstract renderMaplibre(): React.JSX.Element | null;
47+
}
48+
49+
export class FullscreenControlModel extends BaseMapControlModel {
50+
static controlType = "fullscreen";
51+
52+
constructor(model: WidgetModel, updateStateCallback: () => void) {
53+
super(model, updateStateCallback);
54+
}
55+
56+
renderDeck() {
57+
const { placement, ...otherProps } = this.baseDeckProps();
58+
const props = { placement: placement || "top-right", ...otherProps };
59+
console.log(placement);
60+
return <div>{<FullscreenWidget {...props} />}</div>;
61+
}
62+
63+
renderMaplibre() {
64+
return <div>{<FullscreenControl {...this.baseMaplibreProps()} />}</div>;
65+
}
66+
}
67+
68+
export class NavigationControlModel extends BaseMapControlModel {
69+
static controlType = "navigation";
70+
71+
protected showCompass?: boolean;
72+
protected showZoom?: boolean;
73+
protected visualizePitch?: boolean;
74+
protected visualizeRoll?: boolean;
75+
76+
constructor(model: WidgetModel, updateStateCallback: () => void) {
77+
super(model, updateStateCallback);
78+
79+
this.initRegularAttribute("show_compass", "showCompass");
80+
this.initRegularAttribute("show_zoom", "showZoom");
81+
this.initRegularAttribute("visualize_pitch", "visualizePitch");
82+
this.initRegularAttribute("visualize_roll", "visualizeRoll");
83+
}
84+
85+
renderDeck() {
86+
return (
87+
<div>
88+
{this.showZoom && <ZoomWidget {...this.baseDeckProps()} />}
89+
{this.showCompass && <CompassWidget {...this.baseDeckProps()} />}
90+
</div>
91+
);
92+
}
93+
94+
renderMaplibre() {
95+
const props = {
96+
...this.baseMaplibreProps(),
97+
...(isDefined(this.showCompass) && { showCompass: this.showCompass }),
98+
...(isDefined(this.showZoom) && { showZoom: this.showZoom }),
99+
...(isDefined(this.visualizePitch) && {
100+
visualizePitch: this.visualizePitch,
101+
}),
102+
...(isDefined(this.visualizeRoll) && {
103+
visualizeRoll: this.visualizeRoll,
104+
}),
105+
};
106+
return <NavigationControl {...props} />;
107+
}
108+
}
109+
110+
export class ScaleControlModel extends BaseMapControlModel {
111+
static controlType = "scale";
112+
113+
protected maxWidth?: number;
114+
protected unit?: "imperial" | "metric" | "nautical";
115+
116+
constructor(model: WidgetModel, updateStateCallback: () => void) {
117+
super(model, updateStateCallback);
118+
119+
this.initRegularAttribute("max_width", "maxWidth");
120+
this.initRegularAttribute("unit", "unit");
121+
}
122+
123+
renderDeck() {
124+
return <ScaleWidget {...this.baseDeckProps()} />;
125+
}
126+
127+
renderMaplibre() {
128+
const props = {
129+
...this.baseMaplibreProps(),
130+
...(isDefined(this.maxWidth) && { maxWidth: this.maxWidth }),
131+
...(isDefined(this.unit) && { unit: this.unit }),
132+
};
133+
return <div>{<ScaleControl {...props} />}</div>;
134+
}
135+
}
136+
137+
export async function initializeControl(
138+
model: WidgetModel,
139+
updateStateCallback: () => void,
140+
): Promise<BaseMapControlModel> {
141+
const controlType = model.get("_control_type");
142+
let controlModel: BaseMapControlModel;
143+
switch (controlType) {
144+
case FullscreenControlModel.controlType:
145+
controlModel = new FullscreenControlModel(model, updateStateCallback);
146+
break;
147+
148+
case NavigationControlModel.controlType:
149+
controlModel = new NavigationControlModel(model, updateStateCallback);
150+
break;
151+
152+
case ScaleControlModel.controlType:
153+
controlModel = new ScaleControlModel(model, updateStateCallback);
154+
break;
155+
156+
default:
157+
throw new Error(`no control supported for ${controlType}`);
158+
}
159+
160+
return controlModel;
161+
}

0 commit comments

Comments
 (0)