Search code examples
pythonplotly-dashdashboardgeopandas

how to color a map after user selection in Dash using GeoJSON


I have a Dash app where I show a world map and expect user to select certain countries. After each selection, a callback function is triggered and I'd like to color each country selected. As a toy example, I am showing a US map with a state selection option. Here you can click on a state and it is printed on the screen. My question is that how I can color each state selected in red.

I have attached another callback to change the color, however, it is changing the color of the whole map rather than the ones that are selected. I have used the example shared at this post.

import random, json
import dash
from dash import dcc, html, Dash, callback, Output, Input, State
import dash_leaflet as dl
import geopandas as gpd
from dash import dash_table
from dash_extensions.javascript import assign

#https://gist.github.com/incubated-geek-cc/5da3adbb2a1602abd8cf18d91016d451?short_path=2de7e44
us_states_gdf = gpd.read_file("us_states.geojson")
us_states_geojson = json.loads(us_states_gdf.to_json())
# Color the feature saved in the hideout prop in a particular way (grey).
style_handle = assign("""function(feature, context){
    const match = context.props.hideout &&  context.props.hideout.properties.name === feature.properties.name;
    if(match) return {color:'#126'};
}""")

app = Dash(__name__)
app.layout = html.Div([
    dl.Map([
        dl.TileLayer(url="http://tile.stamen.com/toner-lite/{z}/{x}/{y}.png"),
        dl.GeoJSON(data=us_states_geojson, id="state-layer",
                   options=dict(style=style_handle), hideout=dict(click_feature=None))],
        style={'width': '100%', 'height': '250px'},
        id="map",
        center=[39.8283, -98.5795],
    ),
    html.Div(id='state-container', children=[]),  #
    dash_table.DataTable(id='state-table', columns=[{"name": i, "id": i} for i in ["state"]], data=[])
])
# Update the feature saved on the hideout prop on click.
app.clientside_callback("function(feature){return feature}",
                        Output("state-layer", "hideout"),
                        [Input("state-layer", "click_feature")])

app.clientside_callback(
    """
    function(clickFeature, currentData) {
        if(!clickFeature){
            return window.dash_clientside.no_update
        }

        const state = clickFeature.properties.NAME
        const currentStates = currentData.map(item => item.state)

        let newData = []
        if(!currentStates.includes(state)){
            newData = [...currentData, {"state": state}]
        }else{
            newData = currentData
        }

        const stateText = `Clicked: ${state}`
        return [newData, stateText]
    }
    """,
    Output("state-table", "data"),
    Output("state-container", "children"),
    Input("state-layer", "click_feature"),
    State("state-table", "data"),
)

if __name__ == '__main__':
    app.run_server(debug=True)


Solution

  • As mentioned by @sandeep-polamuri, the key is to store the selection in the hideout property in order to retrieve it in the style function and react accordingly.

    To make it work, you need only one callback where the trigger is the click_feature prop (ie. only one Input), and the current selection is retrieved from the hideout prop (ie. use a State) before being updated to its Ouptut, which is basically what you did with the table data.

    Also, you may have noticed that dash_leaflet won't trigger callbacks with a click_feature input if user clicks on the last clicked feature, which can be annoying here if one comes to select then unselect (or vice versa) the same feature. This can be circumvented by adding an Output for the click_feature prop where we just put a null value.

    For more info about how to style GeoJSON lines and polygons in Leaflet, see Path options.

    url = 'https://gist.githubusercontent.com/incubated-geek-cc/5da3adbb2a1602abd8cf18d91016d451/raw/acaf26c65998bd5f53d6172dc6616c448584b19c/US_States.geojson'
    us_states_gdf = gpd.read_file(url)
    us_states_geojson = json.loads(us_states_gdf.to_json())
    
    style_handle = assign("""function (feature, context) {
        const selection = context.props.hideout || {};
        if (feature.id in selection) {
            return {color: '#AA4A44', fillColor: '#AA4A44', weight: 2};
        }
        return {color: '#333', fillColor: '#f5f0e4', weight: 1};
    }""")
    
    app = Dash(__name__)
    
    app.layout = html.Div([
        dl.Map([
            dl.TileLayer(url="http://tile.stamen.com/toner-lite/{z}/{x}/{y}.png"),
            dl.GeoJSON(data=us_states_geojson, id="state-layer",
                       options=dict(style=style_handle))],
            style={'width': '100%', 'height': '800px'},
            id="map",
            center=[39.8283, -98.5795],
        ),
        html.Div(id='state-container', children=[]),
        dash_table.DataTable(id='state-table', columns=[{"name": i, "id": i} for i in ["state"]], data=[])
    ])
    
    app.clientside_callback(
        """
        function(clickedFeature, hideout) {
            if (!clickedFeature) {
                // NB. raise PreventUpdate to prevent ALL outputs updating, 
                // dash.no_update prevents only a single output updating
                throw window.dash_clientside.PreventUpdate;
            }
    
            const id = clickedFeature.id;
            const selection = hideout || {};
    
            if (id in selection) {
                delete selection[id];
            }
            else {
                selection[id] = clickedFeature;
            }
    
            const tableData = Object.values(selection).map(f => ({state: f.properties.NAME}));
            const stateText = `Clicked: ${clickedFeature.properties.NAME}`;
    
            return [selection, tableData, stateText, null];
        }
        """,
        Output("state-layer", "hideout"),
        Output("state-table", "data"),
        Output("state-container", "children"),
        Output("state-layer", "click_feature"),
        Input("state-layer", "click_feature"),
        State("state-layer", "hideout")
    )
    
    if __name__ == '__main__':
        app.run_server(debug=True)
    

    enter image description here