Repository: zauberzeug/nicegui
Pull request #3586 (2024-08-24 19:59:32)

Improve support for drawing items in `ui.leaflet`
In #2422 we noticed that `ui.leaflet` didn't preserve drawn items, making it impossible to edit or delete them.

- This PR adds newly created layers to the `drawnItems` feature group, allowing to modify them using the draw controls. It also removes the need to add drawn items in user code. This breaking change has been deliberately scheduled for the release of version 2.0.0.
- To allow rendering drawn items with custom options like before, a new parameter `hide_drawn_items` allows to opt-out of the new behavior.
- The PR also fixes cyclic references in some event arguments, allowing to subscribe to events like "draw:created", "draw:edited" and "draw:deleted".
- **It does not** implement synchronization of drawn layers with the server. This means that drawn items are not synchronized on the auto-index page and cleared when reloading it. Extracting all information from the event arguments and using it to create drawn layers programmatically on the client turned out pretty complicated. And because Leaflet is on the verge of releasing version 2.0, I decided not to spend too much time trying to dig into the API and data structures of version Leaflet 1.9.

---

Open tasks:

- [x] demo showing how to edit and remove drawn items
- [x] demo how to draw items with custom options

rodja: I think we should add demos on how a drawn item can be modified and another showing how to draw items with custom options.

Patch:
diff --git a/examples/chat_with_ai/requirements.txt b/examples/chat_with_ai/requirements.txt
index d1a841bb..2e474235 100644
--- a/examples/chat_with_ai/requirements.txt
+++ b/examples/chat_with_ai/requirements.txt
@@ -1,3 +1,4 @@
-langchain>=0.0.142
+langchain>=0.2
+langchain-community
 langchain_openai
 nicegui
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 535d706a..4cd7e29e 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -32,7 +32,7 @@ class State(Enum):
 class App(FastAPI):
 
     def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
+        super().__init__(**kwargs, docs_url=None, redoc_url=None, openapi_url=None)
         self.native = NativeConfig()
         self.storage = Storage()
         self.urls = ObservableSet()
diff --git a/nicegui/element.py b/nicegui/element.py
index 6d476dcc..e0699e24 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -122,14 +122,14 @@ class Element(Visibility):
             return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
 
         if libraries:
-            helpers.warn_once('The `libraries` parameter is deprecated. Use `dependencies` instead.',
-                              stack_info=True)
+            helpers.warn_once(f'The `libraries` parameter for subclassing "{cls.__name__}" is deprecated. '
+                              'Use `dependencies` instead.')
         if exposed_libraries:
-            helpers.warn_once('The `exposed_libraries` parameter is deprecated. Use `dependencies` instead.',
-                              stack_info=True)
+            helpers.warn_once(f'The `exposed_libraries` parameter for subclassing "{cls.__name__}" is deprecated. '
+                              'Use `dependencies` instead.')
         if extra_libraries:
-            helpers.warn_once('The `extra_libraries` parameter is deprecated. Use `dependencies` instead.',
-                              stack_info=True)
+            helpers.warn_once(f'The `extra_libraries` parameter for subclassing "{cls.__name__}" is deprecated. '
+                              'Use `dependencies` instead.')
 
         cls.component = copy(cls.component)
         cls.libraries = copy(cls.libraries)
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index b553c125..d6d7a0f7 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -1,4 +1,5 @@
 import { loadResource } from "../../static/utils/resources.js";
+import { cleanObject } from "../../static/utils/json.js";
 
 export default {
   template: "<div></div>",
@@ -8,6 +9,7 @@ export default {
     options: Object,
     draw_control: Object,
     resource_path: String,
+    hide_drawn_items: Boolean,
   },
   async mounted() {
     await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
@@ -85,10 +87,18 @@ export default {
     if (this.draw_control) {
       for (const key in L.Draw.Event) {
         const type = L.Draw.Event[key];
-        this.map.on(type, (e) => {
+        this.map.on(type, async (e) => {
+          await this.$nextTick(); // NOTE: allow drawn layers to be added
+          const cleanedObject = cleanObject(e, [
+            "_map",
+            "_events",
+            "_eventParents",
+            "_handlers",
+            "_mapToAdd",
+            "_initHooksCalled",
+          ]);
           this.$emit(type, {
-            ...e,
-            layer: e.layer ? { ...e.layer, editing: undefined, _events: undefined } : undefined,
+            ...cleanedObject,
             target: undefined,
             sourceTarget: undefined,
           });
@@ -97,10 +107,16 @@ export default {
       const drawnItems = new L.FeatureGroup();
       this.map.addLayer(drawnItems);
       const drawControl = new L.Control.Draw({
-        edit: { featureGroup: drawnItems },
-        ...this.draw_control,
+        draw: this.draw_control.draw,
+        edit: {
+          ...this.draw_control.edit,
+          featureGroup: drawnItems,
+        },
       });
       this.map.addControl(drawControl);
+      if (!this.hide_drawn_items) {
+        this.map.on("draw:created", (e) => drawnItems.addLayer(e.layer));
+      }
     }
     const connectInterval = setInterval(async () => {
       if (window.socket.id === undefined) return;
diff --git a/nicegui/elements/leaflet.py b/nicegui/elements/leaflet.py
index 7e6d1243..3bac4221 100644
--- a/nicegui/elements/leaflet.py
+++ b/nicegui/elements/leaflet.py
@@ -26,6 +26,7 @@ class Leaflet(Element, component='leaflet.js'):
                  *,
                  options: Dict = {},  # noqa: B006
                  draw_control: Union[bool, Dict] = False,
+                 hide_drawn_items: bool = False,
                  ) -> None:
         """Leaflet map
 
@@ -35,6 +36,7 @@ class Leaflet(Element, component='leaflet.js'):
         :param zoom: initial zoom level of the map (default: 13)
         :param draw_control: whether to show the draw toolbar (default: False)
         :param options: additional options passed to the Leaflet map (default: {})
+        :param hide_drawn_items: whether to hide drawn items on the map (default: False)
         """
         super().__init__()
         self.add_resource(Path(__file__).parent / 'lib' / 'leaflet')
@@ -49,6 +51,7 @@ class Leaflet(Element, component='leaflet.js'):
         self._props['zoom'] = zoom
         self._props['options'] = {**options}
         self._props['draw_control'] = draw_control
+        self._props['hide_drawn_items'] = hide_drawn_items
 
         self.on('init', self._handle_init)
         self.on('map-moveend', self._handle_moveend)
diff --git a/nicegui/static/utils/json.js b/nicegui/static/utils/json.js
new file mode 100644
index 00000000..36f9f2ac
--- /dev/null
+++ b/nicegui/static/utils/json.js
@@ -0,0 +1,26 @@
+// Remove keysToRemove, functions, and circular references from obj
+export function cleanObject(obj, keysToRemove = [], seen = new WeakSet()) {
+  if (obj === null || typeof obj !== "object") {
+    return obj;
+  }
+
+  if (typeof value === "function") {
+    return undefined;
+  }
+
+  if (seen.has(obj)) {
+    return undefined;
+  }
+
+  seen.add(obj);
+
+  if (Array.isArray(obj)) {
+    return obj.map((item) => cleanObject(item, keysToRemove, seen));
+  }
+
+  return Object.fromEntries(
+    Object.entries(obj)
+      .filter(([key, value]) => !keysToRemove.includes(key) && typeof value !== "function" && !seen.has(value))
+      .map(([key, value]) => [key, cleanObject(value, keysToRemove, seen)])
+  );
+}
diff --git a/nicegui/ui_run.py b/nicegui/ui_run.py
index 58a175a2..78be41d2 100644
--- a/nicegui/ui_run.py
+++ b/nicegui/ui_run.py
@@ -31,6 +31,7 @@ def run(*,
         language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
         reconnect_timeout: float = 3.0,
+        fastapi_docs: bool = False,
         show: bool = True,
         on_air: Optional[Union[str, Literal[True]]] = None,
         native: bool = False,
@@ -63,6 +64,7 @@ def run(*,
     :param language: language for Quasar elements (default: `'en-US'`)
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
+    :param fastapi_docs: whether to enable FastAPI's automatic documentation with Swagger UI, ReDoc, and OpenAPI JSON (default: `False`)
     :param show: automatically open the UI in a browser tab (default: `True`)
     :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation/section_configuration_deployment#nicegui_on_air>`_ if set to `True` (default: disabled)
     :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
@@ -104,6 +106,15 @@ def run(*,
         if route.path == '/' or route.path in Client.page_routes.values():
             route.include_in_schema = endpoint_documentation in {'page', 'all'}
 
+    if fastapi_docs:
+        if not core.app.docs_url:
+            core.app.docs_url = '/docs'
+        if not core.app.redoc_url:
+            core.app.redoc_url = '/redoc'
+        if not core.app.openapi_url:
+            core.app.openapi_url = '/openapi.json'
+        core.app.setup()
+
     if on_air:
         core.air = Air('' if on_air is True else on_air)
 
diff --git a/website/documentation/content/leaflet_documentation.py b/website/documentation/content/leaflet_documentation.py
index 720273aa..5a4b66fc 100644
--- a/website/documentation/content/leaflet_documentation.py
+++ b/website/documentation/content/leaflet_documentation.py
@@ -94,29 +94,63 @@ def disable_pan_zoom() -> None:
     You can enable a toolbar to draw on the map.
     The `draw_control` can be used to configure the toolbar.
     This demo adds markers and polygons by clicking on the map.
+    By setting "edit" and "remove" to `True` (the default), you can enable editing and deleting drawn shapes.
 ''')
 def draw_on_map() -> None:
     from nicegui import events
 
     def handle_draw(e: events.GenericEventArguments):
-        if e.args['layerType'] == 'marker':
-            m.marker(latlng=(e.args['layer']['_latlng']['lat'],
-                             e.args['layer']['_latlng']['lng']))
-        if e.args['layerType'] == 'polygon':
-            m.generic_layer(name='polygon', args=[e.args['layer']['_latlngs']])
+        layer_type = e.args['layerType']
+        coords = e.args['layer'].get('_latlng') or e.args['layer'].get('_latlngs')
+        ui.notify(f'Drawn a {layer_type} at {coords}')
 
     draw_control = {
         'draw': {
             'polygon': True,
             'marker': True,
+            'circle': True,
+            'rectangle': True,
+            'polyline': True,
+            'circlemarker': True,
+        },
+        'edit': {
+            'edit': True,
+            'remove': True,
+        },
+    }
+    m = ui.leaflet(center=(51.505, -0.09), draw_control=draw_control)
+    m.classes('h-96')
+    m.on('draw:created', handle_draw)
+    m.on('draw:edited', lambda: ui.notify('Edit completed'))
+    m.on('draw:deleted', lambda: ui.notify('Delete completed'))
+
+
+@doc.demo('Draw with Custom Options', '''
+    You can draw shapes with custom options like stroke color and weight.
+    To hide the default rendering of drawn items, set `hide_drawn_items` to `True`.
+''')
+def draw_custom_options():
+    from nicegui import events
+
+    def handle_draw(e: events.GenericEventArguments):
+        options = {'color': 'red', 'weight': 1}
+        m.generic_layer(name='polygon', args=[e.args['layer']['_latlngs'], options])
+
+    draw_control = {
+        'draw': {
+            'polygon': True,
+            'marker': False,
             'circle': False,
             'rectangle': False,
             'polyline': False,
             'circlemarker': False,
         },
-        'edit': False,
+        'edit': {
+            'edit': False,
+            'remove': False,
+        },
     }
-    m = ui.leaflet(center=(51.505, -0.09), zoom=13, draw_control=draw_control)
+    m = ui.leaflet(center=(51.5, 0), draw_control=draw_control, hide_drawn_items=True)
     m.on('draw:created', handle_draw)
 
 


Test patch:
diff --git a/tests/test_endpoint_docs.py b/tests/test_endpoint_docs.py
index 3a092d86..2921f7ed 100644
--- a/tests/test_endpoint_docs.py
+++ b/tests/test_endpoint_docs.py
@@ -1,11 +1,17 @@
 from typing import Set
 
+import pytest
 import requests
 
 from nicegui import __version__
 from nicegui.testing import Screen
 
 
+@pytest.fixture(autouse=True)
+def activate_fastapi_docs(screen: Screen):
+    screen.ui_run_kwargs['fastapi_docs'] = True
+
+
 def get_openapi_paths() -> Set[str]:
     return set(requests.get(f'http://localhost:{Screen.PORT}/openapi.json', timeout=5).json()['paths'])