Show&Tell: Using Mapbox Scatter selections to filter rows in Datatable while keeping row selections

Hey everyone!

I was trying to use a mapbox scatter plot to interactively filter data in a dash datatable and was having some problem with it because I was actually updating the data by removing the points which were not selected. This caused the issue where if filtered rows were selected and the maps selection was changed, the selections would become messed up. I later realized this may be because the number of rows was changing and the data table could not keep track of the selected rows so came up with a work around -

To make sure the number of rows remain the same and take advantage of native filtering and selections, I simply added an extra column to the data which has true/false based on whether the point was in the selection or not. This allowed me to make use of native filtering by using contains true to filter based on the map. Now the thing left to do was to automate this so the user doesn’t have to input true to get filtering. To do this I simply put {OnMap} contains true using a callback which ensured the user doesn’t have to fill that in. Finally I used the styling to keep the column always hidden to the user and that’s it! Filter rows using Map Selections. Hope this is useful to people if they get stuck on this and do let me know if there is an easier way to do this :slight_smile:

Here’s the code with explanation of how it works →

"""
This is a demo file which shows how you can use mapbox scatter plots to 
filter data in dash datatable when you want to be able to filter data
in other columns using native filtering and require row selection.
"""

import dash
import numpy as np
import plotly.express as px
import dash_core_components as dcc
import dash_bootstrap_components as dbc
from dash.dependencies import Input, Output, State
from dash.exceptions import PreventUpdate
import pandas as pd
import dash_html_components as html
import dash_table
import json
import plotly.graph_objects as go

px.set_mapbox_access_token(open(".mapbox_token").read())
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

layout = dbc.Container(dbc.Row([
    dcc.Location(id='url', refresh=False),
    dcc.Store('df'),
    dbc.Col(dbc.Row([
        dbc.Col([
            dcc.Graph(id='map')
        ],
            width=12
        ),
        dbc.Col([
            dash_table.DataTable(
                id='data-table',
                columns=[{'name': str(x), 'id': str(x)} for x in pd.DataFrame(columns=['']).columns],
                row_selectable='multi',
                filter_action='native',
                page_action='native',
                sort_action='native',
                page_size=10,
                page_current=0,
                style_table={'overflowX': 'auto', 'cellspacing': '0', 'width': '100%'},
                # We add the below style to hide the extra column we added for filtering
                style_cell_conditional=[
                    {
                        'if': {'column_id': 'OnMap',},
                        'display': 'None',
                    }
                ],
            ),
        ],
            width=12
        ),
    ])),
]),
    fluid=True,
)


app.layout = layout

@app.callback(
    Output('df', 'data'),
    Input('url', 'pathname'),
    prevent_initial_call=True
)
def load_data(unused):
    df = pd.read_csv('worldcities.csv')
    df = df[['city', 'lat', 'lng', 'country', 'population']]
    df = df.dropna()
    df = df.loc[0:1000, :]
    df = df.sort_values(by='population', ascending=False)
    df = df.to_json()
    if df is None:
        PreventUpdate
    return json.dumps(df)


@app.callback(
    Output('map', 'figure'),
    Input('df', 'data'),
)
def populate_map(df):
    if df is None:
        raise PreventUpdate
    df = json.loads(df)
    df = pd.read_json(df)

    # Map
    map = px.scatter_mapbox(
        df,
        lat='lat',
        lon='lng',
        hover_name="city"
    )
    map.update_layout(
        margin=dict(
            l=0,
            r=0,
            t=0,
            b=0
        ),
        mapbox=dict(
            bearing=0,
            center=go.layout.mapbox.Center(
                lat=df['lat'].median(),
                lon=df['lng'].median()
            ),
            pitch=0,
            # zoom=zoom
        ),
    )
    return map


@app.callback(
    Output('data-table', 'data'),
    Output('data-table', 'columns'),
    Input('df', 'data'),
    Input('map', 'selectedData'),
    prevent_initial_call=True
)
def populate_table(df, selectedData):
    """
    Callback to populate datatable. This callback also contains 
    logic required to allow filtering by map.
    """
    if df is None:
        raise PreventUpdate
    df = json.loads(df)
    df = pd.read_json(df)
    """
    Map filtering
    How it works ->
    We get the selected rows from the map and create a list of
    rows which are selected on the map. Using this data we create
    a new column which is of boolean type. It is True if the city/point
    is selected else False.
    If no data is selected, the whole columns should be True which is handled
    by the else condition.
    """
    if selectedData is not None:
        selectedCities = []
        if 'points' in selectedData:
            if selectedData['points'] is not None:
                for point in selectedData['points']:
                    selectedCities.append(point['hovertext'])
        df['OnMap'] = df['city'].isin(selectedCities)
    else:
        df['OnMap'] = pd.Series(np.full(len(df['city']), True))
    # Data Table
    data = df.to_dict('records')
    columns = [{'name': str(x), 'id': str(x), "type": 'any', 'selectable': True} for x in df.columns]

    return data, columns


@app.callback(
    Output('data-table', 'filter_query'),
    Input('data-table', 'data'),
    State('data-table', 'filter_query'),
    prevent_initial_call=True
)
def filter_query_for_edit(trigger, query):
    """
    Callback to inject query to allow the table to be filtered 
    by the hidden OnMap column. This callback enures that the 
    map filtering will work even when other column filters are 
    being used.
    """
    if query is None:
        """
        This is called on the first time this callback is fired
        after the datatable is populated. What is essentially does is
        inserts 'contains true' in the filter input box in the column
        for OnMap. 
        """
        query = '{OnMap} contains true'

    if '{OnMap} contains true' not in query:
        """
        This is here to essentially ensure the query is nevery missing
        'contains true' in the OnMap column. Generally it won't be called.
        """
        query += ' && {OnMap} contains true'

    return query

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

if you want to try it out you can download the code with the dataset from - ammaar8/dash-datatable-filtering-using-maps: Demo for filtering Dash Datatable using Mapbox Scatter map (github.com)

Dataset - World cities database | Kaggle