javascriptpythonleafletfoliumpy-shiny

Retaining zoom and map center in Shiny for Python with Folium when changing map layer


I am working on a project to visualise weather forecasts using Shiny for Python and Folium. The objective is to allow users to interact with a slider to select the forecasted time, and display the corresponding weather data on a map.

The problem I'm encountering: Every time the user changes the forecasted time using the slider, the map resets to the initial zoom level and center position. Ideally, I would like the map to retain the current zoom and center, even when switching the displayed layer (i.e., forecasted time).

I’ve tried to implement custom JavaScript in the app to capture the zoom and center changes and reapply them after the slider update, but it doesn’t seem to work as expected. The zoom and center are always reset to the initial values defined when the map is first created. Upon inspecting the web app’s source code, it looks like my JavaScript might not be correctly integrated with the rest of the application.

My setup: Shiny for Python for the web app structure. Folium for rendering the map and adding the weather data as layers. A slider for selecting different forecasted time steps. Attempted to use JavaScript to capture and restore the map's view settings (zoom level and center) when changing the slider.

Question: Is it possible to retain the zoom level and map center across updates when changing the forecast time using my current stack (probably similar to In shiny, how to fix (lock) leaflet map view zoom and center?, just in Python and not in R)? If so, how can I properly include JavaScript to achieve this?

This is the shortest version still exhibiting the problem:

from shiny import App, render, ui
import folium

# Define the Shiny UI
app_ui = ui.page_fluid(
    ui.h2("Dynamic Folium Map with Layer Switching"),
    ui.input_slider("layer_slider", "Choose Layer (1 = Marker, 2 = Circle)", min=1, max=2, value=1),
    ui.output_text_verbatim("slider_value"),
    ui.output_ui("map")  # Output container for the map
)

# Define the Shiny server
def server(input, output, session):
    
    # Display the slider value
    @output
    @render.text
    def slider_value():
        return f"Layer selected: {input.layer_slider()}"
    
    # Generate and display the map dynamically
    @output
    @render.ui
    def map():
        
        # Create a base map centered on Germany
        m = folium.Map(location=[51.1657, 10.4515], zoom_start=6)
        
        # Add layers depending on the slider value
        if input.layer_slider() == 1:
            folium.Marker([51.1657, 10.4515], popup="Marker in Germany").add_to(m)
        elif input.layer_slider() == 2:
            folium.Circle([51.1657, 10.4515], radius=50000, color='blue', popup="Circle Layer").add_to(m)

        # Return the map as an HTML string
        map_html = m._repr_html_()
        
        # Add custom JavaScript to preserve zoom level and map center
        custom_js = """
        <script>
        let mapZoom = 6;
        let mapCenter = [51.1657, 10.4515];

        // Function to capture map's zoom and center
        function captureMapState() {
            mapZoom = map.getZoom();
            mapCenter = map.getCenter();
        }

        // Function to restore map's zoom and center
        function restoreMapState() {
            map.setView(mapCenter, mapZoom);
        }

        // Capture state after zoom or move events
        map.on('zoomend', captureMapState);
        map.on('moveend', captureMapState);

        // Restore the view after the map is updated
        document.addEventListener('DOMContentLoaded', function() {
            restoreMapState();
        });
        </script>
        """
        
        # Embed the map and attach the custom JavaScript
        return ui.HTML(f"""
        <div style="width:100%; height:500px;">
            {map_html}
        </div>
        {custom_js}
        """)

# Create the Shiny app
app = App(app_ui, server)

# Run the app
app.run(port=8000)


Solution

  • I would recommend using ipyleaflet instead of folium since it is an ipywidget and hence supports methods to update the map object post creation.

    Here is editable code in Shinylive

    from ipyleaflet import Map, Marker, Circle
    from shiny.express import ui, input
    from shinywidgets import render_widget
    from shiny import reactive, render
    
    
    def remove_all_layers(m):
        for i in range(1, len(m.layers)):
            m.remove_layer(m.layers[1])
            
    ui.h2("An ipyleaflet Map")
    
    ui.input_slider('layer_slider', 'Layer Slider', 0, 3, 0, step=1)
    
    @render_widget  # <<
    def map():
        center = (51.1657, 10.4515)
        m = Map(center=center, zoom=6)
        # marker = Marker(location=center)
        # m.add(marker)
        return m
    
    @reactive.effect
    def add_layer():
        m = map.widget
        remove_all_layers(m)
        if input.layer_slider() == 1:
            marker = Marker(
                location=[51.1657, 10.4515],
                title="Marker in Germany"
            )
            m.add(marker)
        elif input.layer_slider() == 2:
          circle = Circle(
              location=[51.1657, 10.4515], 
              radius=50000, 
              color='blue', 
              title="Circle Layer"
          )
          m.add(circle)