2025, Nov 06 13:00

Plotly map clicks in NiceGUI: retrieve pandas row by pointIndex or attach attributes via customdata

Handle Plotly map click events in NiceGUI: use pointIndex to map clicks to pandas DataFrame rows, or pass attributes via customdata for direct, reliable access.

Click events on Plotly maps inside NiceGUI are rich, but they are not magical. If you want to show attributes from a pandas DataFrame when a user clicks a marker, you need to pull the right index out of the event payload and map it back to your data. Below is a concise guide to doing exactly that, including an alternative approach using Plotly’s customdata for direct attribute access.

Reproducing the issue

The following example renders markers from a DataFrame and tries to show the site name on click. It fails because it treats the event arguments as a simple index and references variables that don’t exist in scope.

import pandas as pd
import plotly.graph_objects as go
from nicegui import ui, events
frame = pd.DataFrame([
    [12345, -95, 45, 'Cross River'],
    [12346, -94, 43, 'Snake River'],
    [12347, -92, 45, 'Temple River'],
    [12348, -96, 46, 'Gold River'],
    [12349, -95.5, 44.5, '#FT Ty7']
], columns=['site', 'long', 'lat', 'name'])
chart = go.Figure(go.Scattermap(
    mode='markers',
    lon=frame['long'],
    lat=frame['lat'],
))
chart.update_layout(
    margin=dict(l=0, r=0, t=0, b=0),
    width=400,
    showlegend=False,
    map={
        'style': 'satellite-streets',
        'center': {
            'lon': sum(frame['long'] / len(frame)),
            'lat': sum(frame['lat'] / len(frame)),
        },
        'zoom': 5,
    },
)
plot_box = ui.plotly(chart)
info_text = ui.label('Click on a point for more information.')
def on_select(ev: events.GenericEventArguments):
    idx = ev.args
    info_text.text = f"Name: {name}, Site number: {site}."
plot_box.on('plotly_click', on_select)
ui.run()

What actually happens on click

The click payload is not a plain integer. It is a dictionary with a list under the key points. Each element of that list is a dictionary describing the clicked point, including lat, lon, and most importantly pointIndex (also available as pointNumber). Printing the payload helps to see the available fields in a readable way.

import json
print(json.dumps(ev.args, indent=2))

In this structure, the DataFrame row that produced the clicked marker corresponds to ev.args['points'][0]['pointIndex'].

Fix: derive the DataFrame row from pointIndex

The direct way to show attributes is to pull pointIndex from the event and use iloc to read the corresponding row. Then format the label with the desired fields.

import pandas as pd
import plotly.graph_objects as go
from nicegui import ui, events
frame = pd.DataFrame([
    [12345, -95, 45, 'Cross River'],
    [12346, -94, 43, 'Snake River'],
    [12347, -92, 45, 'Temple River'],
    [12348, -96, 46, 'Gold River'],
    [12349, -95.5, 44.5, '#FT Ty7']
], columns=['site', 'long', 'lat', 'name'])
chart = go.Figure(go.Scattermap(
    mode='markers',
    lon=frame['long'],
    lat=frame['lat'],
))
chart.update_layout(
    margin=dict(l=0, r=0, t=0, b=0),
    width=400,
    showlegend=False,
    map={
        'style': 'satellite-streets',
        'center': {
            'lon': sum(frame['long'] / len(frame)),
            'lat': sum(frame['lat'] / len(frame)),
        },
        'zoom': 5,
    },
)
plot_box = ui.plotly(chart)
info_text = ui.label('Click on a point for more information.')
def on_select(ev: events.GenericEventArguments):
    # To inspect raw payload:
    # import json; print(json.dumps(ev.args, indent=2))
    idx = ev.args['points'][0]['pointIndex']
    row = frame.iloc[idx]
    info_text.text = f"Name: {row['name']}, Site number: {row['site']}."
plot_box.on('plotly_click', on_select)
ui.run()

If you want to be defensive, check that points is present and non-empty before accessing it. This avoids crashes on stray events.

def on_select(ev: events.GenericEventArguments):
    payload = ev.args
    if 'points' in payload and len(payload['points']) > 0 and 'pointIndex' in payload['points'][0]:
        idx = payload['points'][0]['pointIndex']
        row = frame.iloc[idx]
        info_text.text = f"Name: {row['name']}, Site number: {row['site']}."
    else:
        info_text.text = 'Wrong point'

Alternative: attach attributes with customdata

Another clean approach is to send the attributes you need as customdata when building the trace. Then the click event exposes them directly as a list for the clicked point. This avoids round-tripping through the DataFrame on the server side and can be handy when you only need a subset of columns.

import pandas as pd
import plotly.graph_objects as go
from nicegui import ui, events
frame = pd.DataFrame([
    [12345, -95, 45, 'Cross River'],
    [12346, -94, 43, 'Snake River'],
    [12347, -92, 45, 'Temple River'],
    [12348, -96, 46, 'Gold River'],
    [12349, -95.5, 44.5, '#FT Ty7']
], columns=['site', 'long', 'lat', 'name'])
chart = go.Figure(go.Scattermap(
    mode='markers',
    lon=frame['long'],
    lat=frame['lat'],
    customdata=frame[['site', 'name']],
))
chart.update_layout(
    margin=dict(l=0, r=0, t=0, b=0),
    width=400,
    showlegend=False,
    map={
        'style': 'satellite-streets',
        'center': {
            'lon': sum(frame['long'] / len(frame)),
            'lat': sum(frame['lat'] / len(frame)),
        },
        'zoom': 5,
    },
)
plot_box = ui.plotly(chart)
info_text = ui.label('Click on a point for more information.')
def on_select(ev: events.GenericEventArguments):
    site_id, place_name = ev.args['points'][0]['customdata']
    info_text.text = f"Name: {place_name}, Site number: {site_id}."
plot_box.on('plotly_click', on_select)
ui.run()

If you also want to show attributes on hover, configure hovertemplate to render values from customdata together with lat and lon.

chart = go.Figure(go.Scattermap(
    mode='markers',
    lon=frame['long'],
    lat=frame['lat'],
    customdata=frame[['site', 'name']],
    hovertemplate="Name: %{customdata[1]}, Site number: %{customdata[0]}<extra>lat: %{lat}, lon: %{lon}</extra>",
))

Why this matters

Interactive geospatial UI often hinges on a precise mapping between what a user clicks and the corresponding source data. The Plotly click payload contains everything you need, but it is nested. Understanding that the DataFrame row index is exposed as pointIndex prevents brittle parsing, unnecessary lookups, and confusing bugs. When you only need a handful of fields, customdata streamlines the flow and keeps the server-side callback minimal.

Takeaways

Inspect the event payload once and wire your handler to pointIndex to retrieve the row with iloc. If your use case benefits from pushing attributes to the client, attach them via customdata and read them straight from e.args in the callback. Both patterns are simple, robust, and make your NiceGUI + Plotly maps feel responsive and informative.

The article is based on a question from StackOverflow by DrewGIS and an answer by furas.