How to respond to click events for polylines, shapes, etc?

If I have a map with a bunch of points and connecting lines, which are generated dynamically based on input parameters - how do I respond to mouse clicks on the points and lines?

The only examples I can find require a callback referencing the specific id of a given object (like a polyline), but I want a way to capture any click and get from the context what was clicked.

Hi @zack_nc , you can capture click events and access detailed context about what was clicked using the clickData property.

Thanks ,

I got that somewhat working - if I click on the map or on someone in the geojson object’s data, I can capture the event.

But if I click on a polyline, which is added to the parent map object, that does not trigger the event.

I see that I can explicitly capture the clickData on a specific polyline by ID, but I’m adding the polylines dynamically.

How can I capture click events for lines in this case - where the line isn’t even added until after the layout()?

Hi @zack_nc ,

Then, what you can do is to generate your own clickData in a callback. Here there is an example:

from dash import Dash, html, dcc, Input, Output
import plotly.graph_objects as go
import numpy as np

app = Dash()

# Random dataset
def generate_group_data(n_groups=3, n_points=10):
    groups = []
    for i in range(n_groups):
        x = np.linspace(0, 10, n_points)
        y = np.random.rand(n_points) + i
        groups.append({
            "x": x,
            "y": y,
            "label": f"Group {chr(65 + i)}"
        })
    return groups

app.layout = html.Div([
    html.H3("Dynamic Lines with Click Tracking"),
    
    html.Button("Generate New Lines", id="generate-btn", n_clicks=0),
    
    dcc.Graph(id='my-graph'),
    
    html.Div(id='click-output', style={'marginTop': '20px', 'fontWeight': 'bold'})
])


@app.callback(
    Output('my-graph', 'figure'),
    Input('generate-btn', 'n_clicks')
)
def update_figure(n_clicks):
    grouped_data = generate_group_data()

    fig = go.Figure()

    for group in grouped_data:
        fig.add_trace(go.Scatter(
            x=group["x"],
            y=group["y"],
            name=group["label"],
            mode="lines+markers",
            customdata=[group["label"]] * len(group["x"]),
            hoverinfo='text',
            hovertext=[f"{group['label']}<br>X={x:.1f}<br>Y={y:.2f}" for x, y in zip(group["x"], group["y"])]
        ))

    fig.update_layout(title="Click on Any Point to See Info", height=500)

    return fig


@app.callback(
    Output('click-output', 'children'),
    Input('my-graph', 'clickData')
)
def display_click_data(clickData):
    if not clickData:
        return "Click on a point to see details."

    point = clickData['points'][0]
    trace_index = point['curveNumber']
    x = point['x']
    y = point['y']
    group = point.get('customdata', 'Unknown')

    return f"You clicked on {group} (Trace #{trace_index}) — X: {x}, Y: {y}"


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

1 Like

I think you misunderstood my question.

My polyline has data embedded - but clicking on it does not trigger any callback.

I could do something like this:


@app.callback(
    Output("click-output", "children"),
    Input("my-polyline", "n_clicks"),
    State("my-polyline", "clickData"),
    prevent_initial_call=True,
)
def handle_polyline_click(n_clicks, click_data):
    if click_data:
        lat = click_data["latlng"][0]
        lon = click_data["latlng"][1]
        return f"Polyline clicked at: Lat {lat:.2f}, Lon {lon:.2f}"
    return ""

But the issue is that the polylines are added dynamically, so I don’t know the IDs in advance.

Does that make sense?

Hey @zack_nc, could you creta a minimal example for us?

This communicates the big idea.

If I know the id of a line during layout, I can add a callback (handle_polyline_click())

But if a polyline is added dynamically after layout (clicking the “add dynamic lines” button), how do I capture a click?

import dash
from dash import html, Output, Input, State
import dash_leaflet as dl

app = dash.Dash(__name__)


@app.callback(
    Output("map", "children"),
    Input("plotLines", "n_clicks"),
    State("map", "children")
)
def populateLines(n_clicks, mapChildren):
    # (make some kind of db query):
    dfPolyLines = getPolyLinesFromDB()

    for index, row in dfPolyLines.iterrows():
        line = dl.Polyline(<properties based on row>)
        mapChildren.append(line)

    return mapChildren

app.layout = html.Div([
    dl.Map(center=[56, 10], id="map", zoom=6, children=[
        dl.TileLayer(),
        dl.Polyline(
            id="my-polyline",
            positions=[[55, 10], [57, 12], [58, 8]],
            color="blue",
            weight=5
        ),
    ], style={'width': '100%', 'height': '50vh'}),
    html.Div(id="polyline-click-output")
    dbc.Button("Add Dynamic Lines", id="plotLines", n_clicks=0),
])

@app.callback(
    Output("polyline-click-output", "children"),
    Input("my-polyline", "n_clicks"),
    State("my-polyline", "clickData"),
    prevent_initial_call=True
)
def handle_polyline_click(n_clicks, click_data):
    if click_data:
        lat = click_data["latlng"]["lat"]
        lon = click_data["latlng"]["lng"]
        return f"Polyline clicked {n_clicks} times at Lat: {lat:.2f}, Lng: {lon:.2f}"
    return "Click the polyline!"

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

That is because your only checking for click events on the initial line

You need to use pattern matching callbacks, something like this:

@app.callback(
    ...
    Input({"type": "polyline", "index": MATCH}, "n_clicks"),
    State({"type": "polyline", "index": MATCH}, "clickData"),
    ...
)

Since you only have one output container you might be forced to use ALL and search for the clicked index yourself.

Don’t forget to assign an ID to the dynamically created lines.

I think I have some examples somewhere here:
https://community.plotly.com/search?q=%40aimped%20mred%20pmcb%20order%3Alatest

EDIT: this one

2 Likes

Thanks,

I was able to get this to work.

A couple notes for the next guy to read this:

It appears that you can only set the ‘type’ and ‘index’ properties within the id property, and the index has to be a string.

 line = dl.Polyline(positions=lineArray, \
                                children=[dl.Tooltip(content=tooltipContent, sticky=True, permanent=False),
                                                            dl.Popup(content=popupContent)], \
                                 color=color, 
                                 weight=weight, 
                                 opacity=opacity,
                                 id={
                                           'type': 'polyline', 
                                           'index': f'{lineArrayIndex}'
                                  })

The callback will fire on load even when nothing has been clicked, so you have to make sure that the ‘index’ property of the triggered_id matches the index in the click array that is not None.

And the only data I was able to access within the polylineChildren is the tooltip and popup - so I had to read the contents of the popup and parse out the values I needed:

@dash.callback(
    Output('gisSandbox-feedback-main', 'children', allow_duplicate=True),
    State({'type': 'polyline', 'index': ALL}, 'children'),
    [
        Input({'type': 'polyline', 'index': ALL}, 'n_clicks'),
    ],
    prevent_initial_call=True
)
def lineClickHandler(polylineChildren, nClicks):
    if polylineChildren is None or len(polylineChildren) == 0:
        raise PreventUpdate('Ignore clicks')

    if len(nClicks) < int(dash.callback_context.triggered_id['index']):
        raise PreventUpdate('Ignore clicks')
    
    if nClicks[int(dash.callback_context.triggered_id['index'])] is None:
        # this line was not clicked
        raise PreventUpdate('Ignore clicks')

    lineData = polylineChildren[int(dash.callback_context.triggered_id['index'])]

    # lineData[0] is a tooltip
    # lineData[1] is a popup

    # can read the data like this:
    popupContent = lineData[1]['props']['content']
    
    # find ptobranchID:
    myID = popupContent.split('myID: ')[1].split('<br />')[0]

Happy you found a solution to this.

I just wanted to highlight, that this is not a requirement for using pattern matching callbacks.

MRE:

from dash import Dash, Input, Output, html, ALL, ctx

app = Dash(__name__)
app.layout = html.Div(
    [
        html.Pre(id='out'),
        html.Div(
            [
                html.Span(f'Span_{idx}', id={'type': 'span', 'index': idx})
                for idx in range(10)
            ],
        )
    ]
)


@app.callback(
    Output("out", "children"),
    Input({'type': 'span', 'index': ALL}, "n_clicks_timestamp"),
    prevent_initial_call=True
)
def do(stamp):
    trigger_id = ctx.triggered_id.index
    return f"triggered by click on span {trigger_id}. Timestamp: {stamp[trigger_id]}"


if __name__ == '__main__':
    app.run(debug=True)
1 Like