Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lonboard/_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 75 additions & 2 deletions lonboard/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand All @@ -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'`.
"""
34 changes: 34 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,6 +96,7 @@ function App() {
const [mapId] = useState(uuidv4());
const [childLayerIds] = useModelState<string[]>("layers");
const [viewIds] = useModelState<string | string[] | null>("views");
const [controlsIds] = useModelState<string[]>("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
Expand Down Expand Up @@ -157,6 +160,36 @@ function App() {
loadBasemap();
}, [basemapModelId]);

//////////////////////
// Controls state
//////////////////////

const [controlsState, setControlsState] = useState<
Record<string, BaseMapControlModel>
>({});

useEffect(() => {
const loadMapControls = async () => {
try {
const controlsModels = await initializeChildModels<BaseMapControlModel>(
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
//////////////////////
Expand Down Expand Up @@ -311,6 +344,7 @@ function App() {
},
parameters: parameters || {},
views,
controls,
};

const overlayRenderProps: OverlayRendererProps = {
Expand Down
6 changes: 6 additions & 0 deletions src/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ export {
PathStyleExtension,
initializeExtension,
} from "./extension.js";
export {
BaseMapControlModel,
FullscreenControlModel,
NavigationControlModel,
ScaleControlModel,
} from "./map-control.js";
export { initializeChildModels } from "./initialize.js";
158 changes: 158 additions & 0 deletions src/model/map-control.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>{<FullscreenWidget {...this.baseDeckProps()} />}</div>;
}

renderMaplibre() {
return <div>{<FullscreenControl {...this.baseMaplibreProps()} />}</div>;
}
}

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 (
<div>
{this.showZoom && <ZoomWidget {...this.baseDeckProps()} />}
{this.showCompass && <CompassWidget {...this.baseDeckProps()} />}
</div>
);
}

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 <NavigationControl {...props} />;
}
}

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 <ScaleWidget {...this.baseDeckProps()} />;
}

renderMaplibre() {
const props = {
...this.baseMaplibreProps(),
...(isDefined(this.maxWidth) && { maxWidth: this.maxWidth }),
...(isDefined(this.unit) && { unit: this.unit }),
};
return <div>{<ScaleControl {...props} />}</div>;
}
}

export async function initializeControl(
model: WidgetModel,
updateStateCallback: () => void,
): Promise<BaseMapControlModel> {
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;
}
11 changes: 9 additions & 2 deletions src/renderers/deck-first.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ const DeckFirstRenderer: React.FC<MapRendererProps & DeckFirstRendererProps> = (
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 (
<DeckGL
ref={deckRef}
Expand All @@ -30,6 +36,7 @@ const DeckFirstRenderer: React.FC<MapRendererProps & DeckFirstRendererProps> = (
}}
{...deckProps}
>
{controls.map((control) => control.renderDeck())}
{renderBasemap && (
<Map mapStyle={mapStyle} customAttribution={customAttribution}></Map>
)}
Expand Down
Loading