Skip to content

Commit 15aadc7

Browse files
committed
adds a one-way fly_to_command traitlet enabling flyTo to be triggered from JS without a python kernel
1 parent 9e59d5b commit 15aadc7

5 files changed

Lines changed: 108 additions & 16 deletions

File tree

lonboard/_map.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None:
201201
Indicates if a click handler has been registered.
202202
"""
203203

204+
_fly_to_command = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
205+
"""Imperative camera command consumed by the frontend (one-way: control -> map).
206+
207+
Keys mirror ``fly_to()``: ``type``, ``longitude``, ``latitude``, ``zoom``, ``pitch``,
208+
``bearing``, ``transitionDuration``, ``curve``, ``speed``, ``screenSpeed``. Setting
209+
this trait repositions an already-rendered map, including in statically exported
210+
notebooks that have no kernel. This is the mechanism external control widgets use.
211+
"""
212+
204213
height = t.MapHeightTrait()
205214
"""Height of the map in pixels, or valid CSS height property.
206215
@@ -666,7 +675,7 @@ def fly_to( # noqa: PLR0913
666675
"speed": speed,
667676
"screenSpeed": screen_speed,
668677
}
669-
self.send(data)
678+
self._fly_to_command = data
670679

671680
@overload
672681
def to_html(

src/index.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import SidePanel from "./sidepanel/index";
3232
import { useStore, useViewStateDebounced } from "./state";
3333
import Toolbar from "./toolbar.js";
3434
import { getTooltip } from "./tooltip/index.js";
35-
import type { Message } from "./types.js";
35+
import type { FlyToMessage } from "./types.js";
3636
import { isDefined, isGlobeView, sanitizeViewState } from "./util.js";
3737

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

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

166-
// Handle custom messages
166+
// Handle imperative fly-to commands. `_fly_to_command` is a one-way synced
167+
// trait (control -> map): writing it from Python (via `fly_to()`) or from an
168+
// external widget repositions the rendered map. Using a trait rather than a
169+
// Comm message means this also works in statically exported notebooks that
170+
// have no kernel.
167171
useEffect(() => {
168-
const handler = (msg: Message) => {
169-
switch (msg.type) {
170-
case "fly-to":
171-
rendererRef.current?.flyTo(msg);
172-
break;
173-
174-
default:
175-
break;
176-
}
172+
const apply = () => {
173+
const cmd = model.get("_fly_to_command") as FlyToMessage | null;
174+
if (cmd) rendererRef.current?.flyTo(cmd);
177175
};
178-
model.on("msg:custom", handler);
179-
return () => model.off("msg:custom", handler);
176+
model.on("change:_fly_to_command", apply);
177+
return () => model.off("change:_fly_to_command", apply);
180178
}, [model]);
181179

182180
// Fake state just to get react to re-render when a model callback is called

src/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,3 @@ export type FlyToMessage = {
1010
speed?: number | undefined;
1111
screenSpeed?: number | undefined;
1212
};
13-
14-
export type Message = FlyToMessage;

tests/test_map.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,57 @@ def test_set_view_state_orbit():
181181
assert m.view_state == new_view_state
182182

183183

184+
def test_fly_to_sets_command():
185+
m = Map([])
186+
assert m._fly_to_command is None
187+
188+
m.fly_to(longitude=-74.0, latitude=40.7, zoom=7)
189+
assert m._fly_to_command == {
190+
"type": "fly-to",
191+
"longitude": -74.0,
192+
"latitude": 40.7,
193+
"zoom": 7,
194+
"pitch": 0,
195+
"bearing": 0,
196+
"transitionDuration": 4000,
197+
"curve": None,
198+
"speed": None,
199+
"screenSpeed": None,
200+
}
201+
202+
203+
def test_fly_to_command_passes_optional_params():
204+
m = Map([])
205+
m.fly_to(
206+
longitude=1,
207+
latitude=2,
208+
zoom=3,
209+
duration=1000,
210+
pitch=20,
211+
bearing=45,
212+
curve=1.5,
213+
speed=2.0,
214+
screen_speed=3.0,
215+
)
216+
cmd = m._fly_to_command
217+
assert cmd["transitionDuration"] == 1000
218+
assert cmd["pitch"] == 20
219+
assert cmd["bearing"] == 45
220+
assert cmd["curve"] == 1.5
221+
assert cmd["speed"] == 2.0
222+
assert cmd["screenSpeed"] == 3.0
223+
224+
225+
def test_fly_to_validates_numeric():
226+
m = Map([])
227+
with pytest.raises(TypeError, match="Expected longitude"):
228+
m.fly_to(longitude="x", latitude=2, zoom=3)
229+
with pytest.raises(TypeError, match="Expected latitude"):
230+
m.fly_to(longitude=1, latitude="y", zoom=3)
231+
with pytest.raises(TypeError, match="Expected zoom"):
232+
m.fly_to(longitude=1, latitude=2, zoom="z")
233+
234+
184235
def test_map_view_validate_globe_view_basemap():
185236
with pytest.raises(
186237
TraitError,

tests/ui/test_mapview_interaction.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,42 @@ def test_jupyter_set_view_state(page_session, sample_map):
6262
assert m.view_state == MapViewState(**set_state)
6363

6464

65+
@pytest.mark.usefixtures("solara_test")
66+
def test_fly_to_trait_repositions(page_session, sample_map):
67+
"""Writing the `_fly_to_command` trait repositions the rendered map.
68+
69+
This exercises the kernel-free path an external control widget (or a static
70+
export) relies on: rather than calling a Python method or sending a Comm
71+
message, we set the synced trait directly and assert the frontend reacts by
72+
moving the camera (which deck.gl writes back to `view_state`).
73+
"""
74+
m = sample_map
75+
m.set_view_state(longitude=0, latitude=0, zoom=2)
76+
display(m)
77+
canvas = page_session.locator("canvas").first
78+
canvas.wait_for(timeout=5000)
79+
page_session.wait_for_timeout(1000)
80+
81+
# Write the trait directly, exactly as an external widget would.
82+
m._fly_to_command = {
83+
"type": "fly-to",
84+
"longitude": -100,
85+
"latitude": 40,
86+
"zoom": 5,
87+
"pitch": 0,
88+
"bearing": 0,
89+
"transitionDuration": 0,
90+
"curve": None,
91+
"speed": None,
92+
"screenSpeed": None,
93+
}
94+
page_session.wait_for_timeout(1500)
95+
96+
assert abs(m.view_state.longitude - (-100)) < 1
97+
assert abs(m.view_state.latitude - 40) < 1
98+
assert abs(m.view_state.zoom - 5) < 1
99+
100+
65101
@pytest.mark.usefixtures("solara_test")
66102
def test_jupyter_manual_view_state_change(page_session, sample_map):
67103
"""Test manual view state change in Jupyter environment."""

0 commit comments

Comments
 (0)