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

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.

Jump to heading 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 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