opengeos/leafmap

Support on_click popup for GeoJSON layer

giswqs opened this issue · 9 comments

Reference issue: jupyter-widgets/ipyleaflet#371

import ipywidgets as widgets
from ipyleaflet import Map, Polygon

polygon = Polygon(
    locations=[(42, -49), (43, -49), (43, -48)],
    color="green",
    fill_color="green"
)

m = Map(center=(42.5531, -48.6914), zoom=6)
m.add(polygon)
polygon.popup = widgets.HTML('Hello World!!')
m

image

A complete example. One limitation is that the event handler can't capture the mouse clicked location. The polygon centroid is used as a workaround.

from ipyleaflet import Map, GeoJSON, Popup
import json
from shapely.geometry import shape
import ipywidgets as widgets

# Create a map centered at a specific location
m = Map(center=(51.55, -0.09), zoom=10)

# Example GeoJSON data with two polygons
geojson_data = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {
                "name": "Polygon A",
                "popup_content": "This is Polygon A."
            },
            "geometry": {
                "type": "Polygon",
                "coordinates": [[
                    [-0.1, 51.5],
                    [-0.1, 51.6],
                    [-0.05, 51.6],
                    [-0.05, 51.5],
                    [-0.1, 51.5]
                ]]
            }
        },
        {
            "type": "Feature",
            "properties": {
                "name": "Polygon B",
                "popup_content": "This is Polygon B."
            },
            "geometry": {
                "type": "Polygon",
                "coordinates": [[
                    [-0.08, 51.48],
                    [-0.08, 51.52],
                    [-0.03, 51.52],
                    [-0.03, 51.48],
                    [-0.08, 51.48]
                ]]
            }
        }
    ]
}

# Create a GeoJSON layer
geojson_layer = GeoJSON(data=geojson_data)

output = widgets.Output()

# Function to calculate the centroid of a polygon
def calculate_centroid(polygon_coordinates):
    polygon = shape({
        "type": "Polygon",
        "coordinates": polygon_coordinates
    })
    centroid = polygon.centroid
    return centroid.y, centroid.x  # Return as (lat, lon)

# Define a function to handle the on-click event
def on_click_handler(event, feature, **kwargs):
    # Calculate the centroid of the polygon
    centroid = calculate_centroid(feature['geometry']['coordinates'])
    with output:
        print(event)
        print(centroid)
        print(feature['properties']['popup_content'])
     
    # Create a popup with the content from GeoJSON properties
    popup = Popup(
        location=centroid,
        child=widgets.HTML(feature['properties']['popup_content']),
        close_button=False,
        auto_close=True
    )
    
    # Add the popup to the map
    m.add_layer(popup)

# Bind the click event to the GeoJSON layer
geojson_layer.on_click(on_click_handler)

# Add the GeoJSON layer to the map
m.add_layer(geojson_layer)

# Display the map
m

image

Marker on_click event

from ipyleaflet import Map, CircleMarker

m = Map(center=(52.204793, 0.121558), zoom=15)
marker = CircleMarker(location=(52.204793, 0.121558), radius=10, color="red", fill_opacity=0.7)

def on_click_handler(**kwargs):
    print(f"CircleMarker clicked at location: {marker.location}")
marker.on_click(on_click_handler)
m.add_layer(marker)
m

Example for editing data.

import requests
import ipywidgets as widgets
from ipyleaflet import Map, CircleMarker, Popup

# URL to the GeoJSON data
geojson_url = "https://github.com/opengeos/datasets/releases/download/world/world_cities.geojson"

# Download the GeoJSON data
response = requests.get(geojson_url)
geojson_data = response.json()

# Create a map centered on a point in the US
m = Map(center=(39.8283, -98.5795), zoom=4, scroll_wheel_zoom=True)
m.layout.height = '600px'

def create_popup_widget(circle_marker, properties):
    """Create a popup widget to change circle properties and display point attributes."""
    radius_slider = widgets.IntSlider(
        value=circle_marker.radius,
        min=1,
        max=50,
        description='Radius:',
        continuous_update=False
    )
    
    color_picker = widgets.ColorPicker(
        value=circle_marker.color,
        description='Color:',
        continuous_update=False
    )
    
    fill_color_picker = widgets.ColorPicker(
        value=circle_marker.fill_color,
        description='Fill color:',
        continuous_update=False
    )
    
    name_text = widgets.Text(
        value=properties.get('name', ''),
        description='Name:',
        continuous_update=False
    )
    
    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
        properties['name'] = name_text.value
    
    # 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')
    name_text.observe(update_circle, 'value')
    
    # Arrange widgets in a vertical box with increased width
    vbox = widgets.VBox(
        [name_text, radius_slider, color_picker, fill_color_picker],
        layout=widgets.Layout(width='310px')  # Set the width of the popup widget
    )
    return vbox

def create_on_click_handler(circle_marker, properties):
    """Create an on_click handler with the circle_marker bound."""
    def on_click(**kwargs):
        if kwargs.get('type') == 'click':
            # Create a popup widget with controls
            popup_widget = create_popup_widget(circle_marker, properties)
            popup = Popup(
                location=circle_marker.location,
                child=popup_widget,
                close_button=True,
                auto_close=False,
                close_on_escape_key=True, 
                min_width=300,

            )
            m.add_layer(popup)
            popup.open = True
    return on_click

# 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=5,  # Initial radius of the circle
        color="blue",  # Outline color
        fill_color="blue",  # Fill color
        fill_opacity=0.6,
    )
    
    # Create and bind the on_click handler for each circle_marker
    circle_marker.on_click(create_on_click_handler(circle_marker, properties))
    
    # Add the circle marker to the map
    m.add_layer(circle_marker)

# Display the map
m

Edit points interactively

import requests
import ipywidgets as widgets
from ipyleaflet import Map, CircleMarker, Popup
import leafmap
import ipyleaflet

# URL to the GeoJSON data
geojson_url = "https://github.com/opengeos/datasets/releases/download/us/cities.geojson"

# Download the GeoJSON data
response = requests.get(geojson_url)
geojson_data = response.json()

# Create a map centered on a point in the US
m = leafmap.Map(center=(39.8283, -98.5795), zoom=4, scroll_wheel_zoom=True)
m.layout.height = '600px'

widget_width = "250px"

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,
            )
            m.add_layer(popup)
            popup.open = True
    return on_click

layers = []
# Specify which properties to display in the popup
display_properties = ['name', 'population']  # Example: Show only 'name' and 'population'
# display_properties = None  # Example: Show only 'name' and 'population'

# 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=5,  # Initial radius of the circle
        color="white",  # Outline color
        weight=1,  # Outline
        fill_color="#3388ff",  # Fill color
        fill_opacity=0.6,
    )
    
    # Create and bind the on_click handler for each circle_marker
    circle_marker.on_click(create_on_click_handler(circle_marker, properties, display_properties))
    
    # Add the circle marker to the map
    layers.append(circle_marker)

group = ipyleaflet.LayerGroup(layers=tuple(layers), name="Markers")
m.add(group)

# Display the map
m

image

Support for editing ponits has been added in #878

Example for editing polygons

import json
from ipyleaflet import Map, GeoJSON, Popup
from ipywidgets import VBox, Button, Layout, Text
import requests
from shapely.geometry import shape
import copy

# URL to the GeoJSON data
geojson_url = "https://github.com/opengeos/datasets/releases/download/us/us_states.geojson"

# Download the GeoJSON data
response = requests.get(geojson_url)
geojson_data = response.json()

# Create map
m = Map(scroll_wheel_zoom=True)

widget_width = "250px"
layout = Layout(width=widget_width)

def calculate_centroid(polygon_coordinates, geom_type):
    polygon = shape({
        "type": geom_type,
        "coordinates": polygon_coordinates
    })
    centroid = polygon.centroid
    return centroid.y, centroid.x  # Return as (lat, lon)

def create_property_widgets(properties):
    """Dynamically create widgets for each property."""
    widgets_list = []
    for key, value in properties.items():
        if isinstance(value, dict):
            continue
        widget = Text(value=str(value), description=f'{key}:', layout=layout)
        widget._property_key = key  # Store the key in the widget for easy access later
        widgets_list.append(widget)
    return widgets_list

def on_click(event, feature, **kwargs):
    # Dynamically create input widgets for each property
    property_widgets = create_property_widgets(feature['properties'])
    save_button = Button(description="Save", layout=layout)
    geom_type = feature['geometry']['type']
    centroid = calculate_centroid(feature['geometry']['coordinates'], geom_type)

    # Create and open the popup
    popup_content = VBox(property_widgets + [save_button])
    
    popup = Popup(
        location=centroid,
        child=popup_content,
        close_button=True,
        auto_close=False,
        close_on_escape_key=True,
        min_width=int(widget_width[:-2]) + 5,
    )

    m.add_layer(popup)

    def save_changes(_):

        original_data = copy.deepcopy(geojson_layer.data)
        original_feature = copy.deepcopy(feature)

        # Update the properties with the new values
        for widget in property_widgets:
            feature['properties'][widget._property_key] = widget.value
        
        for i, f in enumerate(original_data['features']):
            if f == original_feature:
                original_data['features'][i] = feature
                break

        # Update the GeoJSON layer to reflect the changes
        geojson_layer.data = original_data
        m._geojson_data = original_data
        
        m.remove_layer(popup)  # Close the popup by removing it from the map

    save_button.on_click(save_changes)

# Add GeoJSON layer to the map
geojson_layer = GeoJSON(data=geojson_data, style={'color': '#3388ff'}, name='GeoJSON Layer')

# Attach click event to the GeoJSON layer
geojson_layer.on_click(on_click)

# Add layers to map
m.add_layer(geojson_layer)
m._geojson_data = geojson_layer.data

m.center = [40, -100]
m.zoom = 4
m

image

Edit feature properties and save changes

from ipyleaflet import Map, GeoJSON, Popup
import ipywidgets as widgets
import requests
from shapely.geometry import shape
import copy
import geopandas as gpd

# URL to the GeoJSON data
geojson_url = "https://github.com/opengeos/datasets/releases/download/places/nyc_buildings.geojson"
# geojson_url = "https://github.com/opengeos/datasets/releases/download/places/nyc_roads.geojson"

# Download the GeoJSON data
response = requests.get(geojson_url)
geojson_data = response.json()

# Create map
m = Map(scroll_wheel_zoom=True)
m.layout.height = '600px'

widget_width = "250px"
layout = widgets.Layout(width=widget_width)

def calculate_centroid(polygon_coordinates, geom_type):
    polygon = shape({
        "type": geom_type,
        "coordinates": polygon_coordinates
    })
    centroid = polygon.centroid
    return centroid.y, centroid.x  # Return as (lat, lon)

def create_property_widgets(properties):
    """Dynamically create widgets for each property."""
    widgets_list = []
    for key, value in properties.items():
        if key == "style":
            continue
        if isinstance(value, (int, float)):
            widget = widgets.FloatText(value=value, description=f'{key}:', layout=layout)
        else:
            widget = widgets.Text(value=str(value), description=f'{key}:', layout=layout)
        widget._property_key = key  # Store the key in the widget for easy access later
        widgets_list.append(widget)
    return widgets_list

def on_click(event, feature, **kwargs):
    # Dynamically create input widgets for each property
    property_widgets = create_property_widgets(feature['properties'])
    save_button = widgets.Button(description="Save", layout=layout)
    geom_type = feature['geometry']['type']
    centroid = calculate_centroid(feature['geometry']['coordinates'], geom_type)

    # Create and open the popup
    popup_content = widgets.VBox(property_widgets + [save_button])
    
    popup = Popup(
        location=centroid,
        child=popup_content,
        close_button=True,
        auto_close=False,
        close_on_escape_key=True,
        min_width=int(widget_width[:-2]) + 5,
    )

    m.add_layer(popup)

    def save_changes(_):

        original_data = copy.deepcopy(geojson_layer.data)
        original_feature = copy.deepcopy(feature)
        # Update the properties with the new values
        for widget in property_widgets:
            feature['properties'][widget._property_key] = widget.value
        
        for i, f in enumerate(original_data['features']):
            if f == original_feature:
                original_data['features'][i] = feature
                # print(original_feature)
                break

        # Update the GeoJSON layer to reflect the changes

        geojson_layer.data = original_data
        m._geojson_data = original_data
        
        m.remove_layer(popup)  # Close the popup by removing it from the map

    save_button.on_click(save_changes)

# Add GeoJSON layer to the map
geojson_layer = GeoJSON(data=geojson_data, style={'color': '#3388ff'}, hover_style={"color": "yellow", "weight": 5}, name='GeoJSON Layer')

# Attach click event to the GeoJSON layer
geojson_layer.on_click(on_click)

# Add layers to map
m.add_layer(geojson_layer)
m._geojson_data = geojson_layer.data

m.center = [40.7131, -73.9923]
m.zoom = 14

m

image

Edit points, lines, and polygons interactively.

95_edit_vector.mp4

Implemented in #879