Skip to content
Draft
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
11 changes: 10 additions & 1 deletion lonboard/_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,15 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None:
Indicates if a click handler has been registered.
"""

_fly_to_command = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
"""Imperative camera command consumed by the frontend (one-way: control -> map).

Keys mirror ``fly_to()``: ``type``, ``longitude``, ``latitude``, ``zoom``, ``pitch``,
``bearing``, ``transitionDuration``, ``curve``, ``speed``, ``screenSpeed``. Setting
this trait repositions an already-rendered map, including in statically exported
notebooks that have no kernel. This is the mechanism external control widgets use.
"""

height = t.MapHeightTrait()
"""Height of the map in pixels, or valid CSS height property.

Expand Down Expand Up @@ -666,7 +675,7 @@ def fly_to( # noqa: PLR0913
"speed": speed,
"screenSpeed": screen_speed,
}
self.send(data)
self._fly_to_command = data

@overload
def to_html(
Expand Down
24 changes: 11 additions & 13 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import SidePanel from "./sidepanel/index";
import { useStore, useViewStateDebounced } from "./state";
import Toolbar from "./toolbar.js";
import { getTooltip } from "./tooltip/index.js";
import type { Message } from "./types.js";
import type { FlyToMessage } from "./types.js";
import { isDefined, isGlobeView, sanitizeViewState } from "./util.js";

import "maplibre-gl/dist/maplibre-gl.css";
Expand Down Expand Up @@ -163,20 +163,18 @@ function App() {

const rendererRef = useRef<RendererRef | null>(null);

// Handle custom messages
// Handle imperative fly-to commands. `_fly_to_command` is a one-way synced
// trait (control -> map): writing it from Python (via `fly_to()`) or from an
// external widget repositions the rendered map. Using a trait rather than a
// Comm message means this also works in statically exported notebooks that
// have no kernel.
useEffect(() => {
const handler = (msg: Message) => {
switch (msg.type) {
case "fly-to":
rendererRef.current?.flyTo(msg);
break;

default:
break;
}
const apply = () => {
const cmd = model.get("_fly_to_command") as FlyToMessage | null;
if (cmd) rendererRef.current?.flyTo(cmd);
};
model.on("msg:custom", handler);
return () => model.off("msg:custom", handler);
model.on("change:_fly_to_command", apply);
return () => model.off("change:_fly_to_command", apply);
}, [model]);

// Fake state just to get react to re-render when a model callback is called
Expand Down
2 changes: 0 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,3 @@ export type FlyToMessage = {
speed?: number | undefined;
screenSpeed?: number | undefined;
};

export type Message = FlyToMessage;
51 changes: 51 additions & 0 deletions tests/test_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,57 @@ def test_set_view_state_orbit():
assert m.view_state == new_view_state


def test_fly_to_sets_command():
m = Map([])
assert m._fly_to_command is None

m.fly_to(longitude=-74.0, latitude=40.7, zoom=7)
assert m._fly_to_command == {
"type": "fly-to",
"longitude": -74.0,
"latitude": 40.7,
"zoom": 7,
"pitch": 0,
"bearing": 0,
"transitionDuration": 4000,
"curve": None,
"speed": None,
"screenSpeed": None,
}


def test_fly_to_command_passes_optional_params():
m = Map([])
m.fly_to(
longitude=1,
latitude=2,
zoom=3,
duration=1000,
pitch=20,
bearing=45,
curve=1.5,
speed=2.0,
screen_speed=3.0,
)
cmd = m._fly_to_command
assert cmd["transitionDuration"] == 1000
assert cmd["pitch"] == 20
assert cmd["bearing"] == 45
assert cmd["curve"] == 1.5
assert cmd["speed"] == 2.0
assert cmd["screenSpeed"] == 3.0


def test_fly_to_validates_numeric():
m = Map([])
with pytest.raises(TypeError, match="Expected longitude"):
m.fly_to(longitude="x", latitude=2, zoom=3)
with pytest.raises(TypeError, match="Expected latitude"):
m.fly_to(longitude=1, latitude="y", zoom=3)
with pytest.raises(TypeError, match="Expected zoom"):
m.fly_to(longitude=1, latitude=2, zoom="z")


def test_map_view_validate_globe_view_basemap():
with pytest.raises(
TraitError,
Expand Down
36 changes: 36 additions & 0 deletions tests/ui/test_mapview_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,42 @@ def test_jupyter_set_view_state(page_session, sample_map):
assert m.view_state == MapViewState(**set_state)


@pytest.mark.usefixtures("solara_test")
def test_fly_to_trait_repositions(page_session, sample_map):
"""Writing the `_fly_to_command` trait repositions the rendered map.

This exercises the kernel-free path an external control widget (or a static
export) relies on: rather than calling a Python method or sending a Comm
message, we set the synced trait directly and assert the frontend reacts by
moving the camera (which deck.gl writes back to `view_state`).
"""
m = sample_map
m.set_view_state(longitude=0, latitude=0, zoom=2)
display(m)
canvas = page_session.locator("canvas").first
canvas.wait_for(timeout=5000)
page_session.wait_for_timeout(1000)

# Write the trait directly, exactly as an external widget would.
m._fly_to_command = {
"type": "fly-to",
"longitude": -100,
"latitude": 40,
"zoom": 5,
"pitch": 0,
"bearing": 0,
"transitionDuration": 0,
"curve": None,
"speed": None,
"screenSpeed": None,
}
page_session.wait_for_timeout(1500)

assert abs(m.view_state.longitude - (-100)) < 1
assert abs(m.view_state.latitude - 40) < 1
assert abs(m.view_state.zoom - 5) < 1


@pytest.mark.usefixtures("solara_test")
def test_jupyter_manual_view_state_change(page_session, sample_map):
"""Test manual view state change in Jupyter environment."""
Expand Down
Loading