Effective tooltip and click event handling is the workflow that turns a static geospatial visualization into an interactive analytical workbench. For data scientists, GIS analysts, and internal tooling teams, capturing precise pointer interactions on map layers is the bridge between seeing the data and acting on it — drilling into a feature, filtering a table, or firing a spatial query against a database. This page covers the complete event loop for both Streamlit and Panel: how a browser pick becomes a Python-side payload, how to sanitize and route that payload into framework state, and how to keep the whole pipeline responsive under rapid hover traffic.

Jump to heading Problem Statement

Map events are deceptively hard to handle reliably. The browser produces a raw pick payload with deeply nested objects, optional null values, and coordinates in whatever projection the renderer uses. That payload has to survive a trip across the client/server boundary, land in a framework state container, and drive a UI update — all without triggering a full-page reload or corrupting application state. Streamlit reruns the entire script on every interaction, so a naive handler loses the selection the moment the user clicks again. Panel’s reactive model never reruns, so a handler that assumes a fresh execution context silently never updates. Getting this wrong produces the three failure signatures every spatial dashboard author eventually meets: tooltips that flicker, clicks that return null, and selections that reset on the next interaction.

This guide details tested patterns within the broader Spatial Component Integration & Interactive Maps architecture, so event handling stays consistent with the rest of your reactive data pipeline rather than becoming a one-off hack bolted onto a single chart.

Jump to heading Prerequisites

  • Python 3.9+ with an isolated environment (venv or conda)
  • streamlit>=1.30.0 (for the stable on_select selection API) or panel>=1.4.0
  • pydeck>=0.9.0 for Deck.gl WebGL rendering, and ipyleaflet>=0.18.0 for the Panel path
  • geopandas>=0.14.0 and folium>=0.15.0 for vector data manipulation and serialization
  • Familiarity with the GeoJSON schema, CRS normalization to EPSG:4326, and the reactive execution models behind Streamlit session state and Panel’s pn.state

Install the core dependencies:

bash
pip install streamlit panel pydeck ipyleaflet folium geopandas pandas

If you are still choosing a renderer, the trade-offs between WebGL picking and DOM-based Leaflet hit-testing are covered under Deck.gl Advanced Layers and Folium & Leafmap Integration.

Jump to heading Core Implementation Workflow

Reliable event handling follows a deterministic pipeline that separates data preparation, renderer configuration, and state routing. Skipping a stage usually produces a silent payload drop or a desynchronized UI rather than a clean error.

The diagram below traces a single pointer interaction across the client/server boundary: a browser pick produces a raw payload, which is extracted and sanitized before being routed into framework state, and only then drives a tooltip or side-panel update. The dashed return path shows how that state change re-renders the UI without a full reload.

Tooltip and click event lifecycleA flow diagram split into a browser tier and a server tier. In the browser, a user click or hover on a map feature triggers WebGL or Leaflet hit-testing, which emits a raw pick payload. That payload crosses to the server tier where it is extracted from the layer object, sanitized against null and nested values, and routed into framework state (st.session_state or pn.state). The state change drives a UI feedback step that renders a tooltip or side panel, then loops back across the boundary to re-render the map canvas.BROWSER (client)Click / hoveron map featureHit-test / pickpickable layerRaw payloadobject + coordsSERVER (Python state)Extract + sanitizedrop null / nestingRoute to statesession_state / pn.stateUI feedbacktooltip / panelre-render canvas

Jump to heading 1. Serialize and validate the property schema

Frontend map renderers expect strictly formatted payloads. Convert GeoDataFrame objects to GeoJSON-compatible structures with __geo_interface__ or to_json() before passing them to the visualization layer, and reproject to EPSG:4326 first — a CRS mismatch is the single most common cause of null coordinates downstream. Validate that every feature carries the same property keys across zoom levels; missing or malformed attributes break tooltip interpolation and throw runtime errors in the browser console.

python
import geopandas as gpd

gdf = gpd.read_file("states.geojson").to_crs(epsg=4326)

# Guard the property schema the tooltip template depends on.
required = {"name", "value"}
missing = required - set(gdf.columns)
assert not missing, f"Tooltip fields missing from GeoDataFrame: {missing}"

# Drop empty geometries early — they produce null picks at layer boundaries.
gdf = gdf[~gdf.geometry.is_empty & gdf.geometry.notna()].reset_index(drop=True)

Jump to heading 2. Configure the layer with explicit picking

Attach event bindings (pickable, auto_highlight, onClick/on_click) during layer instantiation. Explicitly disable picking on background and reference layers to prevent event collision and unintended state triggers. For high-density datasets, lean on the aggregation and hit-testing optimizations in Deck.gl Advanced Layers so picking stays cheap.

python
import pydeck as pdk

layer = pdk.Layer(
    "GeoJsonLayer",
    data=gdf.__geo_interface__,
    pickable=True,            # this layer participates in hit-testing
    auto_highlight=True,      # eliminates hover flicker
    stroked=True,
    filled=True,
    get_fill_color=[200, 200, 200, 150],
    highlight_color=[0, 128, 255, 255],
    get_line_color=[0, 0, 0, 255],
    get_line_width=2,
)

Jump to heading 3. Capture the pick and route it into state

Route captured payloads into the framework-specific state manager: st.session_state for Streamlit, pn.state (or a reactive parameter) for Panel. Because Streamlit reruns the whole script on each interaction, the selection must be persisted explicitly, exactly as covered in Session State Patterns — otherwise it is lost on the next click.

python
import streamlit as st

if "selected_feature" not in st.session_state:
    st.session_state.selected_feature = None

deck = pdk.Deck(
    layers=[layer],
    initial_view_state=pdk.ViewState(latitude=39.8, longitude=-98.5, zoom=3),
    tooltip={"html": "<b>{name}</b>"},
)

# on_select="rerun" surfaces the selection on the next script run.
selection = st.pydeck_chart(deck, on_select="rerun", selection_mode="single-object")

Jump to heading 4. Extract and sanitize the payload

Raw coordinate arrays and feature objects frequently contain null values or unexpected nesting when users click on a layer boundary. Implement explicit existence checks and type validation before any downstream processing — never index blindly into the payload. For database-bound workflows, customizing click events for spatial data extraction workflows maps sanitized payloads to query parameters safely.

python
def extract_feature(selection, layer_id="GeoJsonLayer"):
    """Return clicked feature properties or None — never raise on an empty pick."""
    if not selection or "selection" not in selection:
        return None
    objects = selection["selection"].get("objects", {}).get(layer_id, [])
    if not objects:
        return None
    props = objects[0].get("properties") or {}
    # Strip null-valued keys so the tooltip template never renders "None".
    return {k: v for k, v in props.items() if v is not None}

st.session_state.selected_feature = extract_feature(selection) or st.session_state.selected_feature
Four shapes the same payload arrives inA pick payload has four common shapes and only one of them is the happy path. A hit returns an objects list whose first entry carries the feature properties, which is the case every tutorial shows. A click on empty basemap returns a selection whose objects list is empty, so indexing element zero raises an IndexError and takes the whole script down rather than clearing the selection. A click exactly on a boundary between two polygons returns two objects, and taking the first silently picks whichever the renderer happened to order first — a result that is not wrong so much as arbitrary, and that changes between reruns. A second click landing while the previous rerun is still in flight returns None entirely, because the component has not produced a new value yet. The extraction function has to return None rather than raise for all three of the unhappy shapes, which is why it is written as a series of guards rather than as a chain of subscripts.WHAT THE PICK PAYLOAD ACTUALLY CONTAINShit — objects[0] carries the propertiesthe only shape most examples show, and the only one that survives blind indexingempty basemap — objects is []objects[0] raises IndexError and takes the script down, when it should simply have cleared the selectionon a boundary — objects has two entriestaking the first picks whichever the renderer ordered first — arbitrary rather than wrong, and it changes between runsclick during a rerun — the payload is Nonethe component has not produced a new value yet, so the previous selection must be kept rather than overwrittenThree of the four shapes need the extractor to return None rather than raise — which is why it is guards, not subscripts.

Jump to heading 5. Render UI feedback and close the loop

Render contextual tooltips, update a side panel, or fire an asynchronous spatial query from the extracted coordinates, feature IDs, or bounding box. Keep the map canvas and the feedback layer strictly separate so updating one does not force a relayout of the other.

python
if st.session_state.selected_feature:
    with st.sidebar:
        st.subheader("Feature inspection")
        st.json(st.session_state.selected_feature)

Reliability notes for the Streamlit path:

  • Always check selection["selection"]["objects"] exists before indexing — empty clicks return None or an empty list.
  • on_select="rerun" triggers a rerun on click; the updated selection is only available on the next execution.
  • Wrap spatial data loading in @st.cache_data to avoid redundant serialization on every rerun (see @st.cache_data implementation).

Jump to heading Advanced Patterns

Jump to heading Panel + ipyleaflet reactive binding

Panel’s reactive model differs fundamentally from Streamlit’s rerun architecture — there is no full re-execution, so handlers mutate reactive objects in place. Using ipyleaflet with pn.pane.IPyWidget gives native click bindings without any custom JS bridge.

python
import panel as pn
import ipyleaflet as ipyl
import geopandas as gpd
import json

pn.extension("ipywidgets")

gdf = gpd.read_file("states.geojson").to_crs(epsg=4326)
feature_info = pn.pane.JSON({}, name="Clicked feature")

geo_layer = ipyl.GeoJSON(
    data=json.loads(gdf.to_json()),
    style={"fillOpacity": 0.5, "weight": 1},
    hover_style={"fillOpacity": 0.8},
)

def handle_click(**kwargs):
    feature = kwargs.get("feature", {})
    feature_info.object = feature.get("properties", {})

geo_layer.on_click(handle_click)

m = ipyl.Map(center=(39.8, -98.5), zoom=3)
m.add_layer(geo_layer)
pn.Column(pn.pane.IPyWidget(m, height=500, sizing_mode="stretch_width"), feature_info).servable()

The on_click callback receives **kwargs including feature, coordinates, and id; always unpack with kwargs.get() to tolerate missing keys. For complex queries, pull kwargs["coordinates"] and run a point-in-polygon test against your GeoDataFrame before updating downstream panels. When scaling to multi-layer maps, the layer-grouping strategies in Folium & Leafmap Integration prevent event shadowing between stacked layers.

Hover is not click, and the difference is a round-tripA pointer moving across a dense layer crosses dozens of features per second. On the client-side path the tooltip is rendered from properties the browser already holds, so it appears in about a millisecond and the server is never involved — hovering fifty features costs nothing at all. On the server round-trip path each hover triggers a rerun, a lookup and a redraw, so a single tooltip costs roughly two hundred and forty milliseconds and fifty of them arrive as a queue the session works through long after the pointer has moved on, producing tooltips for features the analyst is no longer near. The rule that follows is not about performance tuning but about which events belong to which side: hover is answered by data the client already has; click is a decision, so it may cross the wire.TOOLTIP RESOLVED IN THE BROWSERpointer enters a featurerender from properties already there≈ 1 ms · the server is never toldhovering fifty features costs nothingTOOLTIP FETCHED FROM PYTHONpointer enters a featurererunlookupredraw≈ 240 mseach, and they queueA pointer crossing a dense layer generates dozens of these per second, so the tooltips arrive for features the analysthas already moved away from — the map feels haunted rather than slow.The division is not a performance tweak but a question of which side owns which event: hover is answered from whatthe client already has; a click is a decision, and a decision may cross the wire. ### Template-driven, XSS-safe tooltips

Default tooltip renderers truncate long strings and offer no semantic structure. For internal tooling, inject a precompiled HTML template that supports conditional formatting, iconography, and localized units — but escape every dynamic value so a user-supplied attribute can never inject markup.

python
import html

def tooltip_html(props: dict) -> str:
    name = html.escape(str(props.get("name", "—")))
    value = html.escape(str(props.get("value", "n/a")))
    return f"<div class='tt'><b>{name}</b><br>value: {value}</div>"

Jump to heading Click-to-query against a spatial backend

A clicked feature ID or bounding box is the natural input to a bounding box filter or a PostGIS lookup. When the query is slow, push it onto a background task instead of blocking the render path — the async data loading patterns page covers the coroutine and locking details for spatial workloads.

Jump to heading Making an empty selection say something

The most common event-handling defect in a spatial dashboard is not an exception — it is silence. An analyst clicks, nothing changes, and there is no way to tell whether the click missed the layer, matched a feature with no attributes, was swallowed by a rerun already in flight, or hit a bug. All four look identical, and all four are reported the same way.

Distinguish them in the interface rather than only in the logs. A click that resolves to no feature should say so — a brief “no feature at that point” is enough, and it immediately separates a miss from a failure. A click that resolves to a feature whose displayed fields are all empty should say that too, because that is a data problem and reporting it as such saves an afternoon of debugging the event path. A click that arrives while a previous interaction is still being processed should either be queued or visibly ignored, never silently dropped, since silently dropping it is what produces the “I have to click twice” complaint that is almost impossible to reproduce deliberately.

None of this requires new machinery. The extraction function already distinguishes these cases in order to return safely; the only change is that its outcome is surfaced rather than discarded. A single status line beside the map, updated on every pick, converts the entire class of silent event failures into something a user can describe accurately in a ticket — which is usually worth more than the fix for any individual one of them.

Jump to heading Verification & Testing

Confirm the event loop end to end before shipping. The sanitizer is pure, so unit-test it directly against the payload shapes the renderers actually emit:

python
def test_extract_handles_empty_pick():
    assert extract_feature(None) is None
    assert extract_feature({"selection": {"objects": {}}}) is None

def test_extract_strips_nulls():
    payload = {"selection": {"objects": {"GeoJsonLayer": [
        {"properties": {"name": "Texas", "value": 7, "note": None}}
    ]}}}
    assert extract_feature(payload) == {"name": "Texas", "value": 7}

For the live Streamlit surface, drive the selection programmatically with the app testing harness and assert the state mutation:

python
from streamlit.testing.v1 import AppTest

def test_selection_persists():
    at = AppTest.from_file("app.py").run()
    at.session_state["selected_feature"] = {"name": "Texas", "value": 7}
    at.run()
    assert at.session_state["selected_feature"]["name"] == "Texas"

In the browser, open the developer console while clicking: a correctly wired layer logs no Cannot read properties of null errors, and the returned coordinates fall inside your dataset’s bounding box. A coordinate outside [-180, 180] longitude or [-90, 90] latitude is a reliable signal of a CRS or [lat, lon] vs [lon, lat] ordering bug.

Jump to heading Troubleshooting

Tooltips flicker or disappear on hover

Overlapping pickable layers are competing for the same pointer event, or auto_highlight is missing on the target layer. Verify the layer stacking order, set pickable=False on background and reference layers, and enable auto_highlight=True only on the layer the user is meant to interact with.

Click events return null coordinates

This is a CRS mismatch between the frontend renderer and the backend data. Both PyDeck and ipyleaflet expect WGS84. Standardize every input with to_crs(epsg=4326) before serialization, and validate returned coordinates against expected bounds before running a spatial query.

State resets after every interaction

In Streamlit this is a missing st.session_state initialization — the key is reset to its default on each rerun. Initialize state at module level with a guard clause and only overwrite it when a non-empty selection arrives, as in Step 4. In Panel it is usually a callback scope leak: the handler closes over a stale local instead of mutating the shared reactive object.

High CPU usage on hover

An unoptimized GeoJSON with excessive vertices makes every hit-test expensive. Simplify geometry with gdf.geometry.simplify(tolerance) or switch to a PyDeck aggregation layer to cut hit-test complexity, and debounce hover handlers as described below.

KeyError indexing selection['selection']['objects']

The pick was empty (a click on the basemap, not a feature) so the layer key is absent. Never index the payload directly — route it through a sanitizer that returns None on an empty pick, exactly like extract_feature in Step 4.

Jump to heading Performance Considerations

Debounce hover traffic. Rapid pointer movement can fire dozens of hover events per second. Batch DOM updates by wrapping tooltip refreshes in a 150–250 ms debounce; for click-driven queries, also gate on a meaningful change in the selected feature ID so an identical re-pick does not re-issue the query.

Filter before you query. Constrain any click-triggered spatial lookup to the current viewport bounds before it reaches the database. This caps payload size and avoids main-thread blocking on heavy geometry — the same bounding box filter discipline used elsewhere in the pipeline applies here.

Lazy-load analytical overlays. Render the base layer synchronously, then defer analytical overlays until the user interacts with or zooms into a region. This shrinks the initial payload and improves Time to Interactive, especially on mobile networks.

Cache the serialized payload. Serializing a large GeoDataFrame to GeoJSON on every rerun dominates latency in Streamlit. Key the serialization on the bounding box and filter criteria and cache it — see query result caching for cache-key design that scales to many concurrent users.

Sync vs async. Tooltip rendering should stay synchronous and instant. Anything heavier than ~500 ms — a PostGIS join or a remote feature lookup — belongs on an async background task so the pointer interaction never stalls the event loop (async data loading patterns).


Back to Spatial Component Integration & Interactive Maps

Related