zauberzeug / nicegui

Create web-based user interfaces with Python. The nice way.
https://nicegui.io
MIT License
10.05k stars 595 forks source link

Running leaflet.js methods #2500

Closed elkarouh closed 8 months ago

elkarouh commented 9 months ago

Description

The following code doesn't work and it is not clear from the doc what the problem is

from nicegui import ui

p1 = [0, 4]
p2 = [1, 2]
p3 = [3, 3]

m = ui.leaflet(center=p2, zoom=7).style('height: 800px').style('width: 100%')
m.marker(latlng=m.center)
m.marker(latlng=p1)
m.marker(latlng=p2)
m.marker(latlng=p3)
polygon = m.generic_layer(name='polygon', args=[[p1, p2, p3]])  # this works
m.run_method('fitBounds', [p1, p2])  # this doesn't work
bounds = polygon.run_method('getBounds')  # this doesn't work
print(f"{bounds=}")  # gets a NullResponse object
m.run_method('fitBounds', bounds)  # this doesn't work

ui.run()
kleynjan commented 9 months ago

Isn't fitBounds a map method? In that case, m.run_map_method should do the trick.

kleynjan commented 9 months ago

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": '&copy; <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.

falkoschindler commented 9 months ago

Thanks for reporting this issue, @elkarouh!

Let me try to break down this problem:

  1. Since we're trying to communicate with a specific client instance, we should move the UI into a page function.
  2. It also makes sense to wait for a client connection before calling methods.
  3. As mentioned by @kleynjan, there is an initialization step we need to await. In the example below I'm simply using sleep(1).
  4. Also as @kleynjan pointed out, "fitBounds" is a map method, so we should use 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=}')
kleynjan commented 8 months ago

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.

kleynjan commented 8 months ago

OK, it looks like there are two issues with leaflet.js:

  1. In the event handlers. The 'layeradd' and 'layerremove' events don't work properly because they have a different target. So we need to give them a separate handler:
--- 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,
+        });
+      });
+    }
+


  1. In run_layer_method. The leaflet .eachLayer function always iterates over all layers. The current code seems to assume that it can break out with a 'return', but the result will be overridden by the next iteration. Solve by adding a 'res' variable outside the loop.
@@ -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.

kleynjan commented 8 months ago

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()
falkoschindler commented 8 months ago

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:

https://github.com/zauberzeug/nicegui/blob/38137810f2600102811762d74409f97e5a9f1c2b/nicegui/elements/button.py#L43-L48

This allows you to await the "click" event. Maybe we can adapt it for the map "init" event.

falkoschindler commented 8 months ago

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']]])