Closed elkarouh closed 8 months ago
Isn't fitBounds a map method? In that case, m.run_map_method should do the trick.
I have to admit I have trouble getting fitBounds to work reliably myself:
from nicegui import ui
bounds = [[42.1898568, 1.4554517], [43.6849691, 3.4665927]]
gpx_map = ui.leaflet(center=(51.505, -0.09)).style("height: 800px;")
gpx_map.tile_layer(
url_template=r"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
options={
"maxZoom": 19,
"attribution": '© <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
},
)
gpx_map.run_map_method("fitBounds", bounds, {"maxZoom": 10})
ui.button("Fit bounds", on_click=lambda: gpx_map.run_map_method("fitBounds", bounds, {"maxZoom": 10}))
ui.run(show=False)
This code should immediately move the map to somewhere in the South of France, but it doesn't. Only when you click the button the fitBounds work, ie, at that time the map is willing to perform the method.
So methods like fitBounds apparently only work when the map has been "initialized", but there is no way for us poor souls talking to Leaflet from Python to reliably know when this is. Apparently, there is a 'map-load' event, but that isn't reliably triggered in this case. And LeafletJS has a .whenReady handler, but I wouldn't know how to add that to Nicegui. Still, these are feature requests rather than bugs, I think.
Thanks for reporting this issue, @elkarouh!
Let me try to break down this problem:
sleep(1)
.run_map_method
to call it.@ui.page('/')
async def page(client: Client):
p1 = (0, 4)
p2 = (1, 2)
p3 = (3, 3)
m = ui.leaflet(center=p2, zoom=7)
m.generic_layer(name='polygon', args=[[p1, p2, p3]])
await client.connected()
await asyncio.sleep(1)
m.run_map_method('fitBounds', [p1, p2, p3])
Of course, the need for sleeping a fixed amount of time is inacceptable. But I don't have a better solution yet. 😕 I wonder if we can remove the initialization altogether...
Apart from that I don't understand why getBounds
doesn't work. In the original code there's an await
missing, because we need to await
the method call to actually get the result. But I don't understand why the client isn't returning anything.
@ui.page('/')
async def page(client: Client):
p1 = (0, 4)
p2 = (1, 2)
p3 = (3, 3)
m = ui.leaflet(center=p2, zoom=7)
polygon = m.generic_layer(name='polygon', args=[[p1, p2, p3]])
await client.connected()
await asyncio.sleep(1)
bounds = await polygon.run_method('getBounds')
print(f'{bounds=}')
Digging in the source I've found an 'init' event that is apparently emitted when a socket is connected -- strange, because the delay is also invoked when I await client.connected(). This event leads to self.is_initialized, and all run_methods received before this are summarily dismissed:
# leaflet.py
def run_method(
self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01
) -> AwaitableResponse:
if not self.is_initialized:
return NullResponse()
return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
By hooking on to the 'init' event I can get getBounds & fitBounds to work:
from nicegui import ui
sw_fr = [[42.1898568, 1.4554517], [43.6849691, 3.4665927]]
async def on_map_init():
bounds = await m.run_map_method("getBounds")
print(f"{bounds=}")
m.run_map_method("fitBounds", sw_fr, {"maxZoom": 10})
m = ui.leaflet(center=(51.505, -0.09)).style("height: 800px;").on("init", on_map_init)
ui.run()
No luck with the getBounds on the polygon, it doesn't seem to work with a GenericLayer.
OK, it looks like there are two issues with leaflet.js:
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -30,8 +30,6 @@ export default {
"baselayerchange",
"overlayadd",
"overlayremove",
- "layeradd",
- "layerremove",
"zoomlevelschange",
"resize",
"unload",
....
]) {
this.map.on(type, (e) => {
this.$emit(`map-${type}`, {
...e,
originalEvent: undefined,
target: undefined,
sourceTarget: undefined,
center: [e.target.getCenter().lat, e.target.getCenter().lng],
zoom: e.target.getZoom(),
});
});
}
+ for (const type of ["layeradd", "layerremove"]) {
+ this.map.on(type, (e) => {
+ this.$emit(`map-${type}`, {
+ originalEvent: undefined,
+ target: undefined,
+ sourceTarget: undefined,
+ id: e.layer.id ? e.layer.id : undefined,
+ leaflet_id: e.layer._leaflet_id,
+ });
+ });
+ }
+
@@ -127,14 +137,17 @@ export default {
return this.map[name](...args);
},
run_layer_method(id, name, ...args) {
+ var res = null;
this.map.eachLayer((layer) => {
- if (layer.id !== id) return;
- if (name.startsWith(":")) {
- name = name.slice(1);
- args = args.map((arg) => new Function("return " + arg)());
+ if (layer.id == id) {
+ if (name.startsWith(":")) {
+ name = name.slice(1);
+ args = args.map((arg) => new Function("return " + arg)());
+ }
+ res = layer[name](...args);
}
- return layer[name](...args);
});
+ return res;
},
},
};
Edit: see PR . This is my first, so feel free to set me straight if I've missed some steps.
Given the fixes above, I've worked out an "AwaitableMap" that awaits the map 'init' and 'map-layeradd' events concurrently, so we don't have to await them sequentially and we can avoid the ugly asyncio.sleep(some_random_time_hoping_its_enough). The initial client.connected() is a requirement, though.
Edit 19/02: reworked the class. Note: this is example code, not part of the PR. If it's worthwhile I can try to work this into an AwaitableLeaflet, ie, a Nicegui Leaflet subclass.
from nicegui import ui, Client
from nicegui.elements.leaflet import Leaflet
from nicegui.elements.leaflet_layer import Layer
from typing import Callable
import asyncio
MAP_INIT_EVENT = "init"
LAYER_ADD_EVENT = "map-layeradd"
class AwaitableMap:
def __init__(self, map: Leaflet, on_ready: Callable | None = None):
self._map_task = asyncio.create_task(self._await_map_ready(map))
map.on(LAYER_ADD_EVENT, self._handle_layer_ready)
self.on_ready = on_ready
self._map_ready_event = asyncio.Event()
self._layer_ready_events = {} # asyncio.Event for each layer, indexed by layer.id
self._layer_tasks = [] # holds _await_layer_ready tasks
if self.on_ready:
self._ready_task = asyncio.create_task(self._auto_ready()) # task to call self.on_ready
@classmethod
def create(cls, map: Leaflet, on_ready: Callable | None = None) -> tuple[Leaflet, "AwaitableMap"]:
"""Wrap a Leaflet map in an awaitable object.
:param map: the Nicegui leaflet element to wrap
:param on_ready: async function called when map and all layers are ready
:return: tuple of the map object (+event handler added) and this AwaitableMap object
"""
awaitable_map = cls(map, on_ready)
return (map, awaitable_map)
def add_layer(self, layer: Layer) -> Layer:
"""Adds the layer to the waiting list.
:param layer: Leaflet.Layer subclass instance (eg, generic_layer, tile_layer, marker)
NOTE: layer is only added to the AwaitableMap if it has an id attribute
:return: the layer instance initially passed in
"""
if hasattr(layer, "id") and layer.id:
self._layer_tasks.append(asyncio.create_task(self._await_layer_ready(layer)))
return layer
async def ready(self) -> None:
"""Await the map and any added layers to load.
In your code, either await ready() *or* pass an on_ready callback to create()."""
await asyncio.gather(*self._layer_tasks, self._map_task)
async def _await_map_ready(self, map: Leaflet):
map.on(MAP_INIT_EVENT, self._map_ready_event.set)
await self._map_ready_event.wait()
async def _await_layer_ready(self, layer: Layer):
self._layer_ready_events[layer.id] = asyncio.Event()
await self._layer_ready_events[layer.id].wait()
def _handle_layer_ready(self, e):
layer_id = e.args.get("id", None)
if layer_id and layer_id in self._layer_ready_events:
self._layer_ready_events[layer_id].set()
async def _auto_ready(self):
await self.ready()
await self.on_ready() # type: ignore
@ui.page("/")
async def page(client: Client):
await client.connected()
# first method, using awaitable_map.on_close():
#
async def on_map_and_polygon_ready():
se_gb = [(50.5, -0.7), (51.5, 1.2)]
mbounds = await m.run_map_method("getBounds")
print(f"map bounds 1: {mbounds}")
pbounds = await m.run_layer_method(polygon.id, "getBounds")
print(f"polygon bounds 1: {pbounds}\n")
m.run_map_method("fitBounds", se_gb, {"maxZoom": 10})
p1 = (0, 4)
p2 = (1, 2)
p3 = (3, 3)
london = (51.505, -0.09)
(m, awaitable_m) = AwaitableMap.create(
ui.leaflet(center=london).style("height: 300px;"),
on_ready=on_map_and_polygon_ready,
)
polygon = awaitable_m.add_layer(m.generic_layer(name="polygon", args=[[p1, p2, p3]]))
#
await asyncio.sleep(1) # demo with a bit of delay
#
# second method, awaiting awaitable_map.ready():
#
p4 = (41, 2)
p5 = (42, -2)
p6 = (43, 0)
(m2, awaitable_m2) = AwaitableMap.create(ui.leaflet(center=p5).style("height: 300px;"))
polygon2 = awaitable_m2.add_layer(m2.generic_layer(name="polygon", args=[[p4, p5, p6]]))
await awaitable_m2.ready()
mbounds2 = await m2.run_map_method("getBounds")
print(f"map bounds 2: {mbounds2}")
pbounds2 = await m2.run_layer_method(polygon2.id, "getBounds")
print(f"polygon bounds 2: {pbounds2}")
m2.run_map_method("fitBounds", [p4, p6], {"maxZoom": 10})
ui.run()
Thank you so much for spotting the return
statement at the wrong place, @kleynjan! Of course, it isn't enough to return the result from the inner function, but we need to return it from the outer function as well. So with your PR https://github.com/zauberzeug/nicegui/pull/2557 getBounds
is working nicely:
@ui.page('/')
async def page(client: Client):
m = ui.leaflet(zoom=7)
polygon = m.generic_layer(name='polygon', args=[[(0, 4), (1, 2), (3, 3)]])
await client.connected()
await asyncio.sleep(1)
bounds = await polygon.run_method('getBounds')
m.run_map_method('fitBounds', [[bounds['_southWest'], bounds['_northEast']]])
Now we want to get rid of the async sleep. Making the initialization awaitable is a great idea! I wonder if we can incorporate it into the existing map object. The ui.button
element has a similar feature:
This allows you to await the "click" event. Maybe we can adapt it for the map "init" event.
I just created PR https://github.com/zauberzeug/nicegui/pull/2606 introducing an async initialized
method that allows writing something like this:
@ui.page('/')
async def page():
m = ui.leaflet(zoom=7)
polygon = m.generic_layer(name='polygon', args=[[(0, 4), (1, 2), (3, 3)]])
await m.initialized()
bounds = await polygon.run_method('getBounds')
m.run_map_method('fitBounds', [[bounds['_southWest'], bounds['_northEast']]])
Description
The following code doesn't work and it is not clear from the doc what the problem is