From b6a9e4c67021da1d9ce62153ba2e3f82af35d7f2 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Wed, 28 Aug 2024 11:59:39 -0400 Subject: [PATCH] Add support for editing points interactively (#878) * Add support for editing points interactively * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/notebooks/95_edit_vector.ipynb | 101 ++++++++++++ docs/tutorials.md | 1 + leafmap/leafmap.py | 228 ++++++++++++++++++++++++++++ mkdocs.yml | 1 + 4 files changed, 331 insertions(+) create mode 100644 docs/notebooks/95_edit_vector.ipynb diff --git a/docs/notebooks/95_edit_vector.ipynb b/docs/notebooks/95_edit_vector.ipynb new file mode 100644 index 0000000000..35f3f125f1 --- /dev/null +++ b/docs/notebooks/95_edit_vector.ipynb @@ -0,0 +1,101 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "[![image](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://demo.leafmap.org/lab/index.html?path=notebooks/95_edit_vector.ipynb)\n", + "[![image](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/opengeos/leafmap/blob/master/examples/notebooks/95_edit_vector.ipynb)\n", + "[![image](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/opengeos/leafmap/HEAD)\n", + "\n", + "# Edit Vector Data Interactively\n", + "\n", + "Uncomment the following line to install [leafmap](https://leafmap.org) if needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "# %pip install -U leafmap" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from leafmap import leafmap" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Edit points" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "m = leafmap.Map(center=[40, -100], zoom=4)\n", + "# Load any vector dataset that can be loaded by GeoPandas\n", + "geojson_url = \"https://github.com/opengeos/datasets/releases/download/us/cities.geojson\"\n", + "m.edit_points(geojson_url)\n", + "m" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## Save edits\n", + "\n", + "Save the edits to a new file. Choose any of the supported formats by GeoPandas, such as GeoJSON, Shapefile, or GeoPackage." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "m.save_edits(\"cities.geojson\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/tutorials.md b/docs/tutorials.md index 4edd8a85cd..357dcabe52 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -106,6 +106,7 @@ 92. Creating 3D maps with the MapLibre mapping backend ([notebook](https://leafmap.org/notebooks/92_maplibre)) 93. Visualizing PMTiles with Leafmap and MapLibre ([notebook](https://leafmap.org/notebooks/93_maplibre_pmtiles)) 94. Creating 3D maps with Mapbox ([notebook](https://leafmap.org/notebooks/94_mapbox)) +95. Editing vector data interactively ([notebook](https://leafmap.org/notebooks/95_edit_vector)) ## Demo diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index c1d4a7f767..6714fd9bee 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -4658,6 +4658,234 @@ def remove(self, widget: Any) -> None: if isinstance(widget, ipywidgets.Widget): widget.close() + def edit_points( + self, + data: Union[str, "gpd.GeoDataFrame", Dict[str, Any]], + display_props: Optional[List[str]] = None, + widget_width: str = "250px", + name: str = "Points", + radius: int = 5, + color: str = "white", + weight: int = 1, + fill_color: str = "#3388ff", + fill_opacity: float = 0.6, + **kwargs: Any, + ) -> None: + """ + Edit points on a map by creating interactive circle markers with popups. + + Args: + data (Union[str, gpd.GeoDataFrame, Dict[str, Any]]): The data source, + which can be a file path, GeoDataFrame, or GeoJSON dictionary. + display_props (Optional[List[str]], optional): List of properties to + display in the popup. Defaults to None. + widget_width (str, optional): Width of the widget in the popup. + Defaults to "250px". + name (str, optional): Name of the layer group. Defaults to "Points". + radius (int, optional): Initial radius of the circle markers. Defaults to 5. + color (str, optional): Outline color of the circle markers. Defaults to "white". + weight (int, optional): Outline weight of the circle markers. Defaults to 1. + fill_color (str, optional): Fill color of the circle markers. Defaults to "#3388ff". + fill_opacity (float, optional): Fill opacity of the circle markers. Defaults to 0.6. + **kwargs (Any): Additional arguments for the CircleMarker. + + Returns: + None + """ + + import geopandas as gpd + from ipyleaflet import CircleMarker, Popup + + if isinstance(data, gpd.GeoDataFrame): + geojson_data = data.__geo_interface__ + elif isinstance(data, str): + data = gpd.read_file(data) + geojson_data = data.__geo_interface__ + elif isinstance(data, dict): + geojson_data = data + else: + raise ValueError("The data must be a GeoDataFrame or a GeoJSON dictionary.") + + self._geojson_data = geojson_data + + def create_popup_widget( + circle_marker, properties, original_properties, display_properties=None + ): + """Create a popup widget to change circle properties and edit feature attributes.""" + # Widgets for circle properties + radius_slider = widgets.IntSlider( + value=circle_marker.radius, + min=1, + max=50, + description="Radius:", + continuous_update=False, + layout=widgets.Layout(width=widget_width), + ) + + color_picker = widgets.ColorPicker( + value=circle_marker.color, + description="Color:", + continuous_update=False, + layout=widgets.Layout(width=widget_width), + ) + + fill_color_picker = widgets.ColorPicker( + value=circle_marker.fill_color, + description="Fill color:", + continuous_update=False, + layout=widgets.Layout(width=widget_width), + ) + + # Widgets for feature properties + property_widgets = {} + display_properties = display_properties or properties.keys() + for key in display_properties: + value = properties.get(key, "") + if isinstance(value, str): + widget = widgets.Text( + value=value, + description=f"{key}:", + continuous_update=False, + layout=widgets.Layout(width=widget_width), + ) + elif isinstance(value, (int, float)): + widget = widgets.FloatText( + value=value, + description=f"{key}:", + continuous_update=False, + layout=widgets.Layout(width=widget_width), + ) + else: + widget = widgets.Label( + value=f"{key}: {value}", + layout=widgets.Layout(width=widget_width), + ) + + property_widgets[key] = widget + + def update_circle(change): + """Update circle properties based on widget values.""" + circle_marker.radius = radius_slider.value + circle_marker.color = color_picker.value + circle_marker.fill_color = fill_color_picker.value + for key, widget in property_widgets.items(): + properties[key] = widget.value + + def reset_circle(change): + """Reset circle properties to their original values.""" + circle_marker.radius = original_properties["radius"] + circle_marker.color = original_properties["color"] + circle_marker.fill_color = original_properties["fill_color"] + radius_slider.value = original_properties["radius"] + color_picker.value = original_properties["color"] + fill_color_picker.value = original_properties["fill_color"] + for key, widget in property_widgets.items(): + widget.value = original_properties["properties"].get(key, "") + + # Link widgets to update the circle marker properties and point attributes + radius_slider.observe(update_circle, "value") + color_picker.observe(update_circle, "value") + fill_color_picker.observe(update_circle, "value") + for widget in property_widgets.values(): + widget.observe(update_circle, "value") + + # Reset button + reset_button = widgets.Button( + description="Reset", layout=widgets.Layout(width=widget_width) + ) + reset_button.on_click(reset_circle) + + # Arrange widgets in a vertical box with increased width + vbox = widgets.VBox( + [radius_slider, color_picker, fill_color_picker] + + list(property_widgets.values()) + + [reset_button], + layout=widgets.Layout( + width="310px" + ), # Set the width of the popup widget + ) + return vbox + + def create_on_click_handler(circle_marker, properties, display_properties=None): + """Create an on_click handler with the circle_marker bound.""" + # Save the original properties for reset + original_properties = { + "radius": circle_marker.radius, + "color": circle_marker.color, + "fill_color": circle_marker.fill_color, + "properties": properties.copy(), + } + + def on_click(**kwargs): + if kwargs.get("type") == "click": + # Create a popup widget with controls + popup_widget = create_popup_widget( + circle_marker, + properties, + original_properties, + display_properties, + ) + popup = Popup( + location=circle_marker.location, + child=popup_widget, + close_button=True, + auto_close=False, + close_on_escape_key=True, + min_width=int(widget_width[:-2]) + 10, + ) + self.add_layer(popup) + popup.open = True + + return on_click + + layers = [] + + # Iterate over each feature in the GeoJSON data and create a CircleMarker + for feature in geojson_data["features"]: + coordinates = feature["geometry"]["coordinates"] + properties = feature["properties"] + + circle_marker = CircleMarker( + location=(coordinates[1], coordinates[0]), # (lat, lon) + radius=radius, # Initial radius of the circle + color=color, # Outline color + weight=weight, # Outline + fill_color=fill_color, # Fill color + fill_opacity=fill_opacity, + **kwargs, + ) + + # Create and bind the on_click handler for each circle_marker + circle_marker.on_click( + create_on_click_handler(circle_marker, properties, display_props) + ) + + # Add the circle marker to the map + layers.append(circle_marker) + + group = ipyleaflet.LayerGroup(layers=tuple(layers), name=name) + self.add(group) + + def save_edits(self, filename: str, **kwargs: Any) -> None: + """ + Save the edited GeoJSON data to a file. + + Args: + filename (str): The name of the file to save the edited GeoJSON data. + **kwargs (Any): Additional arguments passed to the GeoDataFrame `to_file` method. + + Returns: + None + """ + import geopandas as gpd + + if not hasattr(self, "_geojson_data"): + print("No GeoJSON data to save.") + return + + gdf = gpd.GeoDataFrame.from_features(self._geojson_data) + gdf.to_file(filename, **kwargs) + # The functions below are outside the Map class. diff --git a/mkdocs.yml b/mkdocs.yml index 53294bca06..907e489492 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -319,3 +319,4 @@ nav: - notebooks/92_maplibre.ipynb - notebooks/93_maplibre_pmtiles.ipynb - notebooks/94_mapbox.ipynb + - notebooks/95_edit_vector.ipynb