pythonleafletfoliumpy-shiny

How to save the clicked map coordinates in a reactive variable?


I have a Shiny for Python app that I would like to make interactive to allow user to see various statistics depending on where they click on the map. I am using folium to display the map. I can't find a way to return the coordinates of the clicked spot back to shiny for further processing. I found this process relatively easy to implement using ipyleaflet but its dependency on ipywidgets conflicts with plotly which my app heavily depends on to display interactive charts relevant to the place clicked on.

Here is a toy example that I would like to use for practice:

from shiny import App, render, ui, reactive
import folium

def server(input, output, session):
    # Initialize reactive value for coordinates
    coords = reactive.Value({'lat': None, 'lng': None})
    
    @output
    @render.ui
    def map():
        m = folium.Map(location=[-1.9403, 29.8739], zoom_start=8) 
        # Define a click event handler here that returns the coordinate to shiny
        return ui.HTML(m._repr_html_())
    
    @reactive.Effect
    @reactive.event(input.map_click)
    def _():
        # capture the coordinates returned by the click event handler on the map
        coords.set()
        return coords

app_ui = ui.page_fluid(
    ui.h1("Folium Map that returns Coordinates of clicked spot"),
    ui.div(
        ui.output_ui("map"),
        style="height: 600px; width: 100%;"
    )
)

app = App(app_ui, server)

Solution

  • You can add a folium.MacroElement() to the map which captures the coordinates of the click location and sends them to Shiny. It can use a jinja2.Template() having this content:

    {% macro script(this, kwargs) %}
        function getLatLng(e){
            var lat = e.latlng.lat.toFixed(6),
                lng = e.latlng.lng.toFixed(6);
            parent.Shiny.setInputValue('coords', [lat, lng], {priority: 'event'});
        }; 
        {{ this._parent.get_name() }}.on('click', getLatLng);
    {% endmacro %}
    

    What happens here is that we have a function getLatLng() which reads lat and lng and then sends these values to Shiny. Therefore, we use Shiny.setInputValue() (this link refers to an R article on Shiny because I did not find it documented for Python, but it's really similar). Important is the parent prefix because the map is rendered within an <iframe> and we need to refer to the global environment. The values are then received within an reactive.Value, here coords, and can be used for further processing.

    enter image description here

    from shiny import App, render, ui, reactive
    import folium
    from jinja2 import Template
    
    app_ui = ui.page_fluid(
        ui.h1("Folium Map that returns Coordinates of clicked spot"),
        ui.output_code("result"),
        ui.div(
            ui.output_ui("map"),
            style="height: 600px; width: 100%;"
        )
    )
    
    def server(input, output, session):
        # Initialize reactive value for coordinates
        coords = reactive.Value({'lat': None, 'lng': None})
        
        @output
        @render.ui
        def map():
            m = folium.Map(location=[-1.9403, 29.8739], zoom_start=8) 
            
            el = folium.MacroElement().add_to(m)
            el._template = Template(
                """
                {% macro script(this, kwargs) %}
                    function getLatLng(e){
                        var lat = e.latlng.lat.toFixed(6),
                            lng = e.latlng.lng.toFixed(6);
                        parent.Shiny.setInputValue('coords', [lat, lng], {priority: 'event'});
                    }; 
                    {{ this._parent.get_name() }}.on('click', getLatLng);
                {% endmacro %}
            """
            )
            
            return ui.HTML(m._repr_html_())
        
        # processing the coordinates, e.g. rendering them
        @render.code
        @reactive.event(input.coords)
        def result():
            return (
                f"""
                Coordinates of click location: \n
                Latitude: {input.coords()[0]} \n
                Longitude: {input.coords()[1]}"""
            )
    
    app = App(app_ui, server)