Callback for repeated data click

I want a scatter plot of data points where I can click a data point to toggle the colour between its original colour and black (to indicate it has been investigated). I want to be able to click the same data point repeatedly to toggle it back and forth. At the moment if you click the same data point more than once, the callback is not triggered. Here is a simple example of my code so far:


# Sample DataFrame
df = pd.DataFrame({
    'Latitude': [51.5074, 48.8566, 40.7128],
    'Longitude': [-0.1278, 2.3522, -74.0060],
    'GinNumber': ['A123', 'B456', 'C789'],
    'DateIncident': ['2024-01-01', '2024-02-01', '2024-03-01'],
    'IncidentLocation': ['London', 'Paris', 'New York'],
    'CasualtyName': ['John Doe', 'Jane Smith', 'Ben Johnson'],
    'Inspected': [False, False, False]
})

app = dash.Dash(__name__)

app.layout = html.Div([
    dcc.Graph(id='map'),
    dcc.Store(id='df-store', data=df.to_json()),
    html.Button(id='dummy-button', style={'display': 'none'})  # Dummy button to trigger callback
])

@app.callback(
    Output('map', 'figure'),
    [Input('map', 'clickData'),
     Input('dummy-button', 'n_clicks')],
    [State('df-store', 'data')]
)
def update_marker_color(clickData, dummy_button_n_clicks, df_json):
    df_new = pd.read_json(df_json)
    if clickData is not None:
        pointIndex = clickData['points'][0]['pointIndex']
        # Toggle the 'Inspected' value
        df_new.at[pointIndex, 'Inspected'] = not df_new.at[pointIndex, 'Inspected']
    else:
        print('click is none')

    # Update marker color based on 'Inspected' column
    fig = px.scatter_mapbox(df_new, lat="Latitude", lon="Longitude", hover_name="GinNumber", color="Inspected", zoom=3, height=300, hover_data=['GinNumber','DateIncident','IncidentLocation','CasualtyName','Latitude','Longitude'])
    fig.update_layout(mapbox_style="open-street-map")
    fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0})
    fig['layout']['uirevision'] = 'some-constant'
    return fig

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

Thanks so much for your quick response… any chance you can send a code snippet to describe what you mean?

@pengz also… are you sure the callback is called if you click on the same data point multiple times? I think it is only called on the first click

Hey @bhowey

You are right, the clickData does not get reset automatically. So if you click twice on the same point, the callback gets triggered only once. You have to manually reset the clickData:

@app.callback(
    Output('map', 'figure'),
    Output('map', 'clickData'),
    [Input('map', 'clickData'),
     Input('dummy-button', 'n_clicks')],
    [State('df-store', 'data')]
)
def update_marker_color(clickData, dummy_button_n_clicks, df_json):
    df_new = pd.read_json(df_json)
    if clickData:
        pointIndex = clickData['points'][0]['pointIndex']
        # Toggle the 'Inspected' value
        df_new.at[pointIndex, 'Inspected'] = not df_new.at[pointIndex, 'Inspected']
    else:
        print('click is none')

    # Update marker color based on 'Inspected' column
    fig = px.scatter_mapbox(df_new, lat="Latitude", lon="Longitude", hover_name="GinNumber", color="Inspected", zoom=3, height=300, hover_data=['GinNumber','DateIncident','IncidentLocation','CasualtyName','Latitude','Longitude'])
    fig.update_layout(mapbox_style="open-street-map")
    fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0})
    fig['layout']['uirevision'] = 'some-constant'
    return fig, {}

This does not solve the other issue you have, however.

1 Like

Okay I think I misunderstood your problem with the other problem in your code, but yes, the same click doesn’t trigger the callback.

OK… update… think I have found a solution to both parts of the issue. Updated code:

# Sample DataFrame
pd.options.mode.chained_assignment = None

df = pd.DataFrame({
    'Latitude': [51.5074, 48.8566, 40.7128],
    'Longitude': [-0.1278, 2.3522, -74.0060],
    'GinNumber': ['A123', 'B456', 'C789'],
    'DateIncident': ['2024-01-01', '2024-02-01', '2024-03-01'],
    'IncidentLocation': ['London', 'Paris', 'New York'],
    'CasualtyName': ['John Doe', 'Jane Smith', 'Ben Johnson'],
    'Inspected': [False, False, False]
})

app = dash.Dash(__name__)

app.layout = html.Div([
    dcc.Graph(id='map', clickData={'points': [], 'customdata': []}),
    dcc.Store(id='click-data-store', data=({'points': [], 'customdata': []})),
    dcc.Store(id='df-store', data=df.to_json()),
])

@app.callback(
    Output('map', 'figure'),
    Output('map', 'clickData'),
    Output('df-store', 'data'),
    [Input('map', 'clickData')],
    [State('df-store', 'data')]
)
def update_marker_color(clickData, df_json):
    df_new = pd.read_json(StringIO(df_json))
    if len(clickData['points']) > 0:
        # use the customdata to get the index of the point
        inspected_flag = df_new['Inspected'][df_new['GinNumber']==clickData['points'][0]['customdata'][0]]
        df_new['Inspected'][df_new['GinNumber']==clickData['points'][0]['customdata'][0]] = ~inspected_flag
    else:
        print('click is none')

    # Update marker color based on 'Inspected' column
    fig = px.scatter_mapbox(df_new,
                            lat="Latitude",
                            lon="Longitude",
                            hover_name="GinNumber",
                            color="Inspected",
                            zoom=3,
                            height=300,
                            hover_data=['GinNumber','DateIncident','IncidentLocation','CasualtyName','Latitude','Longitude'],
                            color_discrete_map={False: 'blue', True: 'red'})
    fig.update_layout(mapbox_style="open-street-map")
    fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0})
    fig['layout']['uirevision'] = 'some-constant'
    return fig, {}, df_new.to_json()

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

Is all that just for switching the color of clicked points in the graph? Or do you need the stored data anywhere else?

Yes… all I need to do is change the colour of the clicked marker. But obviously, when I click another data point, I need it to remember the state of the previous data points. Is there a better way to do this?

Hello @bhowey,

Not sure if you want to go this in-depth on it, but you can use some new features to make this call easier, it just takes two components:

from dash import *
import pandas as pd
import plotly.express as px
from io import StringIO

# Sample DataFrame
pd.options.mode.chained_assignment = None

df = pd.DataFrame({
    'Latitude': [51.5074, 48.8566, 40.7128],
    'Longitude': [-0.1278, 2.3522, -74.0060],
    'GinNumber': ['A123', 'B456', 'C789'],
    'DateIncident': ['2024-01-01', '2024-02-01', '2024-03-01'],
    'IncidentLocation': ['London', 'Paris', 'New York'],
    'CasualtyName': ['John Doe', 'Jane Smith', 'Ben Johnson'],
    'Inspected': [False, False, False]
})

def load_fig(dff):
    fig = px.scatter_mapbox(dff,
                            lat="Latitude",
                            lon="Longitude",
                            hover_name="GinNumber",
                            color="Inspected",
                            zoom=3,
                            height=300,
                            hover_data=['GinNumber', 'DateIncident', 'IncidentLocation', 'CasualtyName', 'Latitude',
                                        'Longitude'],
                            color_discrete_map={False: 'blue', True: 'red'})
    fig.update_layout(mapbox_style="open-street-map")
    fig.update_layout(margin={"r": 0, "t": 0, "l": 0, "b": 0})
    fig['layout']['uirevision'] = 'some-constant'
    return fig

app = dash.Dash(__name__)

app.layout = html.Div([
    dcc.Graph(id='map', figure=load_fig(df), clickData={'points': [], 'customdata': []}),
    dcc.Store(id='click-data-store'),
])



@app.callback(
    Output('map', 'figure', allow_duplicate=True),
    Output('click-data-store', 'data'),
    Input('map', 'clickData'),
    State('click-data-store', 'data'),
    prevent_initial_call=True
)
def update_marker_color(clickData, store):
    print(clickData)
    if not store:
        store = {}
    num = clickData['points'][0]['customdata'][0]
    if num in store:
        del store[num]
    else:
        store[num] = True
    fig = Patch()
    dff = df.copy()
    for i, r in dff.iterrows():
        if r['GinNumber'] in store:
            dff.at[i, 'Inspected'] = True
    fig['data'] = load_fig(dff).data
    return fig, store

app.clientside_callback(
    """(id) => {
        setTimeout(() => {
            document.querySelector(`#${id} .js-plotly-plot`).on('plotly_click', (event) => {
                newPoints = event.points.map(({curveNumber, pointNumber, pointIndex, lon, lat, hovertext, bbox, customdata}) => {
                    return {curveNumber, pointNumber, pointIndex, lon, lat, hovertext, bbox, customdata}
                })
                dash_clientside.set_props(id, {clickData: {points: newPoints, ts: Date.now()}})
                event.stopPropagation() // stops regular event
                event.preventDefault() // prevent default interaction
            })
        }, 300)
        return window.dash_clientside.no_update
    }""",
Output('map','id'),
    Input('map', 'id')
)


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

This utilizes the new Patch() and the newest addition of dash_clientside.set_props intro’d in 2.16.0 (use 2.16.1 to avoid a bug)

Anyways, I overwrote the plotly_click on the plotly graph in order to include a timestamp, and set_props the new clickData with the timestamp, thus retriggering the callback.

I used Patch() to make sure the data going to the server and back from the server is smaller.

1 Like

An other approach, counting the clicks on each point:

import dash
from dash import dcc, html, Input, Output, State, Patch
import pandas as pd
import plotly.express as px


df = pd.DataFrame({
    'Latitude': [51.5074, 48.8566, 40.7128],
    'Longitude': [-0.1278, 2.3522, -74.0060],
    'GinNumber': ['A123', 'B456', 'C789'],
    'DateIncident': ['2024-01-01', '2024-02-01', '2024-03-01'],
    'IncidentLocation': ['London', 'Paris', 'New York'],
    'CasualtyName': ['John Doe', 'Jane Smith', 'Ben Johnson'],
    'Inspected': [False, False, False]
})

fig = px.scatter_mapbox(
    df,
    lat="Latitude",
    lon="Longitude",
    hover_name="GinNumber",
    color="Inspected",
    zoom=3,
    height=300,
    hover_data=['GinNumber', 'DateIncident', 'IncidentLocation', 'CasualtyName', 'Latitude', 'Longitude'],
    color_discrete_map={False: 'blue', True: 'red'}
)

fig.update_layout(mapbox_style="open-street-map")
fig.update_layout(margin={"r": 0, "t": 0, "l": 0, "b": 0})
fig['layout']['uirevision'] = 'some-constant'


app = dash.Dash(__name__)

app.layout = html.Div([
    dcc.Graph(id='map', figure=fig),
    dcc.Store(id='tracking', data={str(i): 0 for i in range(df.shape[0])}),
])


@app.callback(
    Output('map', 'figure'),
    Output('map', 'clickData'),
    Output('tracking', 'data'),
    Input('map', 'clickData'),
    State('tracking', 'data'),
    prevent_initial_call=True
)
def update_marker_color(click_data, tracking):

    # which point has been clicked?
    clicked_id = str(click_data['points'][0]['pointNumber'])

    # increment counter for clicked point
    tracking[clicked_id] += 1

    # fix colors for all points: if even number of clicks -> blue, uneven -> red
    marker_colors = [['blue', 'red'][value % 2] for value in tracking.values()]

    # create Patch() for updating
    patched = Patch()

    # apply marker colors
    patched['data'][0]['marker']['color'] = marker_colors

    # return figure Patch, reset clickData, updated tracking dictionary
    return patched, {}, tracking


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

Only thing left to tackle here would be the figure legend- if you need one.