Tooltip & Click Event Handling in Spatial Dashboards
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 (
venvorconda) streamlit>=1.30.0(for the stableon_selectselection API) orpanel>=1.4.0pydeck>=0.9.0for Deck.gl WebGL rendering, andipyleaflet>=0.18.0for the Panel pathgeopandas>=0.14.0andfolium>=0.15.0for 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:
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.
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.
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.
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.
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.
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.
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 returnNoneor 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_datato 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.
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.
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:
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:
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
- Customizing click events for spatial data extraction workflows — sanitize click payloads and map them to safe database query parameters
- Deck.gl Advanced Layers — GPU-accelerated layers with cheap, scalable hit-testing
- Folium & Leafmap Integration — layer grouping that prevents event shadowing on stacked maps
- Session State Patterns — persist the selected feature across Streamlit reruns without losing it