Skip to content

Inconsistent Layer Rendering and Unexpected Reactive Behavior in ipyleaflet within shinywidgets #212

@phbol

Description

@phbol

Hi! Thank you for the work you've already done on shinywidgets. I have a question related to ipyleaflet within shinywidgets. The following example is an adaptation of the map-distance template, aiming to update the map without needing to re-render it from scratch. When the app starts, everything works as expected, but strange behaviors begin to appear upon interaction. Let's go through them one by one:

  1. When the app is launched, the layer selection button in the top right corner shows three layers in addition to the basemaps. The issue arises when a city is selected in either "Location 1" or "Location 2". The map with the two points and the connecting line is rendered, but the "line" layer disappears on the layer button. Also, note that the layer button switches position with the search button.
  2. Building on the previous item, when clicking "Sample point", no point appears on the map and the layer button disappears. If a city is selected in "Location 1" or "Location 2", the sampled point appears. Clicking "Sample point" again causes the point to disappear once more.
  3. When the app is launched and "Sample point" is clicked, the sampled point does not appear on the map and the layers button disappears. The point in the map only shows up if a city in "Location 1" or "Location 2" is changed.

When running the code that builds the map, adds and removes points and the line using Positron IDE, everything works as expected. I've been trying to get things working for a while now, but to no avail. This includes adding marker groups with ipyleaflet and other approaches with the leafmap package.

import ipyleaflet as L
from shiny import App, reactive, ui
from shinywidgets import output_widget, render_widget
from numpy import random

CITIES = {
    "New York": {"latitude": 40.7128, "longitude": -74.0060, "altitude": 33},
    "London": {"latitude": 51.5074, "longitude": -0.1278, "altitude": 36},
    "Paris": {"latitude": 48.8566, "longitude": 2.3522, "altitude": 35},
    "Tokyo": {"latitude": 35.6895, "longitude": 139.6917, "altitude": 44},
    "Sydney": {"latitude": -33.8688, "longitude": 151.2093, "altitude": 39},
    "Los Angeles": {"latitude": 34.0522, "longitude": -118.2437, "altitude": 71},
    "Berlin": {"latitude": 52.5200, "longitude": 13.4050, "altitude": 34},
    "Rome": {"latitude": 41.9028, "longitude": 12.4964, "altitude": 21},
    "Beijing": {"latitude": 39.9042, "longitude": 116.4074, "altitude": 44},
    "Moscow": {"latitude": 55.7558, "longitude": 37.6176, "altitude": 156},
    "Cairo": {"latitude": 30.0444, "longitude": 31.2357, "altitude": 23},
    "Rio de Janeiro": {"latitude": -22.9068, "longitude": -43.1729, "altitude": 8},
    "Toronto": {"latitude": 43.6511, "longitude": -79.3832, "altitude": 76},
    "Dubai": {"latitude": 25.2769, "longitude": 55.2963, "altitude": 52},
    "Mumbai": {"latitude": 19.0760, "longitude": 72.8777, "altitude": 14},
    "Seoul": {"latitude": 37.5665, "longitude": 126.9780, "altitude": 38},
    "Madrid": {"latitude": 40.4168, "longitude": -3.7038, "altitude": 667},
    "Amsterdam": {"latitude": 52.3676, "longitude": 4.9041, "altitude": -2},
    "Buenos Aires": {"latitude": -34.6037, "longitude": -58.3816, "altitude": 25},
    "Stockholm": {"latitude": 59.3293, "longitude": 18.0686, "altitude": 14},
    "Boulder": {"latitude": 40.0150, "longitude": -105.2705, "altitude": 1634},
    "Lhasa": {"latitude": 29.6500, "longitude": 91.1000, "altitude": 3650},
    "Khatmandu": {"latitude": 27.7172, "longitude": 85.3240, "altitude": 1400},
}


city_names = sorted(list(CITIES.keys()))

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_selectize(
            "loc1", "Location 1", choices=city_names, selected="New York"
        ),
        ui.input_selectize("loc2", "Location 2", choices=city_names, selected="London"),
         ui.input_action_button("sample", "Sample point")
    ),
    output_widget("map"),
    title="Location Distance Calculator",
    fillable=True,
    class_="bslib-page-dashboard",
)


def server(input, output, session):
    # Reactive values to store location information
    loc1 = reactive.value()
    loc2 = reactive.value()
    locpoint = reactive.value()

    # Update the reactive values when the selectize inputs change
    @reactive.effect
    def _():
        loc1.set(CITIES.get(input.loc1()))
        loc2.set(CITIES.get(input.loc2()))

    @reactive.effect
    @reactive.event(input.sample)
    def _():
        city = random.choice(list(CITIES.keys()), 1)[0]
        locpoint.set(CITIES.get(city))
    
    
    # Convenient way to get the lat/lons as a tuple
    @reactive.calc
    def loc1xy():
        return loc1()["latitude"], loc1()["longitude"]

    @reactive.calc
    def loc2xy():
        return loc2()["latitude"], loc2()["longitude"]

    @reactive.calc
    def locpointxy():
        return locpoint()["latitude"], locpoint()["longitude"]

       
    # For performance, render the map once and then perform partial updates
    # via reactive side-effects
    @render_widget
    def map():        
        esri_sat = L.basemap_to_tiles(L.basemaps.Esri.WorldImagery)
        esri_sat.name = "Esri.WorldImagery"
        esri_sat.base = True

        osm = L.basemap_to_tiles(L.basemaps.OpenStreetMap.Mapnik)
        osm.name = "OpenStreetMap"
        osm.base = True
        
        m = L.Map(zoom=2, center=(0, 0), scroll_wheel_zoom=True, zoom_control = False, layers=[esri_sat, osm])
        
        m.add(L.ZoomControl(position='topright'))        
        m.add(L.LayersControl(position='topleft'))
        
        search_control = L.SearchControl(
            position="topleft",
            url='https://nominatim.openstreetmap.org/search?format=json&q={s}',
            zoom=10,
            auto_collapse = True
        )

        m.add(search_control)

        return m

    # Add marker for first location
    @reactive.effect
    def _():
        update_marker(map.widget, loc1xy(), "loc1")

    # Add marker for second location
    @reactive.effect
    def _():
        update_marker(map.widget, loc2xy(), "loc2")

    # Add marker for sample location
    @reactive.effect
    def _():
        update_marker(map.widget, locpointxy(), "point")
        
    # Add line between first and second location
    @reactive.effect
    def _():
        update_line(map.widget, loc1xy(), loc2xy())
       
    
    def update_marker(map: L.Map, loc: tuple, name: str):
        remove_layer(map, name)
        map.add(L.Marker(location=loc, draggable=False, name=name, title = name))

        # Trying map.substitute() instead of map.remove() and map.add()        
        # m = L.Marker(location=loc, draggable=False, name=name)

        # is_new_layer = True

        # for layer in map.layers:
        #     if layer.name == name:
        #         map.substitute(layer, m)
        #         is_new_layer = False
        #         break

        # if is_new_layer:
        #     map.add(m)

    def update_line(map: L.Map, loc1: tuple, loc2: tuple):
        remove_layer(map, "line")
        map.add(
            L.Polyline(locations=[loc1, loc2], color="blue", weight=2, name="line")
        )

        # Trying map.substitute() instead of map.remove() and map.add()     
        # name="line"
        # is_new_layer = True
        # for layer in map.layers:
        #     if layer.name == name:
        #         map.substitute(layer,  L.Polyline(locations=[loc1, loc2], color="blue", weight=2, name="line"))
        #         is_new_layer = False
        #         break

        # if is_new_layer:
        #     map.add(m)

    def remove_layer(map: L.Map, name: str):
        for layer in map.layers:
            if layer.name == name:
                map.remove(layer)
        


app = App(app_ui, server)

I am running the app on Ubuntu 24.04.3 LTS using Python 3.13.7 and the following packages.

anywidget==0.9.18
appdirs==1.4.4
asgiref==3.9.1
asttokens==3.0.0
branca==0.8.1
click==8.2.1
comm==0.2.3
decorator==5.2.1
executing==2.2.0
h11==0.16.0
htmltools==0.6.0
idna==3.10
ipyleaflet==0.20.0
ipython==9.4.0
ipython_pygments_lexers==1.1.1
ipywidgets==8.1.7
jedi==0.19.2
Jinja2==3.1.6
jupyter-leaflet==0.20.0
jupyter_core==5.8.1
jupyterlab_widgets==3.0.15
linkify-it-py==2.0.3
markdown-it-py==4.0.0
MarkupSafe==3.0.2
matplotlib-inline==0.1.7
mdit-py-plugins==0.5.0
mdurl==0.1.2
narwhals==2.1.2
numpy==2.3.2
orjson==3.11.2
packaging==25.0
parso==0.8.4
pexpect==4.9.0
platformdirs==4.3.8
prompt_toolkit==3.0.51
psygnal==0.14.1
ptyprocess==0.7.0
pure_eval==0.2.3
Pygments==2.19.2
python-dateutil==2.9.0.post0
python-multipart==0.0.20
questionary==2.1.0
setuptools==80.9.0
shiny==1.4.0
shinywidgets==0.7.0
six==1.17.0
sniffio==1.3.1
stack-data==0.6.3
starlette==0.47.2
traitlets==5.14.3
traittypes==0.2.1
typing_extensions==4.14.1
uc-micro-py==1.0.3
uvicorn==0.35.0
watchfiles==1.1.0
wcwidth==0.2.13
websockets==15.0.1
widgetsnbextension==4.0.14
xyzservices==2025.4.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions