Customizing Click Events for Spatial Data Extraction Workflows
Bind map click payloads explicitly to your Python backend, validate and reproject the coordinate, then route the geometry through a spatial index — so each click becomes a clean, reproducible data-extraction event instead of a fragile UI side effect.
Jump to heading Why this matters
The hard part of click-driven extraction is not capturing the click — it is making the payload survive a framework rerun, match your extraction schema, and avoid race conditions during heavy spatial joins. A click is a structured data trigger, not a tooltip: it carries a coordinate that must be deserialized into your target CRS, checked against bounds, and handed to a query pipeline without losing precision or firing on stale state. This task sits inside Tooltip & Click Event Handling and the wider Spatial Component Integration & Interactive Maps discipline; getting it right is what separates a map that merely renders from a dashboard that extracts. Because both Streamlit and Panel abstract the raw DOM event differently, the same extraction logic has to account for Streamlit’s session state reruns and Panel’s reactive callbacks.
Python dashboard frameworks intercept callbacks from JavaScript mapping libraries — Leaflet, Mapbox GL JS, OpenLayers — serialize them into JSON-safe dictionaries, and pass them through session state or reactive parameters. Treat the result as a discrete data event: explicitly bind extraction to click or last_clicked, validate the payload structure immediately on receipt, and isolate coordinate transformation from UI rendering so a re-render never corrupts an in-flight extraction.
The diagram below traces a single click from the JavaScript map layer through serialization, validation, and CRS transformation into the indexed spatial query — the full path a payload must survive without losing precision or firing on stale state.
Jump to heading Prerequisites
- Python 3.9+ with an isolated virtual environment (
venvorconda). streamlit>=1.30andstreamlit-folium>=0.18, orpanel>=1.4withipyleaflet>=0.18for the reactive variant.pyproj>=3.5,shapely>=2.0, andgeopandas>=0.14for transformation and indexed spatial joins.- A working grasp of how reruns affect session state — the extraction store depends on it.
pip install "streamlit>=1.30" streamlit-folium folium pyproj shapely geopandas
Jump to heading Step-by-step solution
Jump to heading Step 1 — Initialize the extraction store
Because Streamlit reruns the entire script on every interaction, a click payload held in a local variable is lost the moment the next interaction fires. Persist a capped list in st.session_state so extracted coordinates accumulate across reruns without growing without bound.
import streamlit as st
# Survive reruns: initialize once, then append to this list on each valid click.
if "extracted_features" not in st.session_state:
st.session_state["extracted_features"] = []
Jump to heading Step 2 — Capture and validate the click payload
The streamlit-folium wrapper returns a last_clicked dictionary containing lat and lng in WGS84. Read it defensively — the value is None until the first click — and reject anything outside geographic bounds before doing any work downstream.
import folium
from streamlit_folium import st_folium
m = folium.Map(location=[39.9, -98.5], zoom_start=4, tiles="CartoDB positron")
folium.CircleMarker(
location=[39.9, -98.5],
radius=6,
popup="Click anywhere to extract coordinates",
color="#2563eb",
fill=True,
).add_to(m)
click_data = st_folium(
m,
width="100%",
height=550,
returned_objects=["last_clicked"],
key="spatial_click_map",
)
payload = (click_data or {}).get("last_clicked")
lat = lng = None
if payload:
lat, lng = payload.get("lat"), payload.get("lng")
def in_bounds(lat, lng) -> bool:
return (
lat is not None and lng is not None
and -90 <= lat <= 90 and -180 <= lng <= 180
)
Jump to heading Step 3 — Reproject the coordinate
Build the pyproj.Transformer once and reuse it. always_xy=True pins the axis order to (easting, northing), so you call transform(lng, lat) — the single most common source of silently misplaced points. Round before storage to cut floating-point noise and speed up downstream joins.
The trap is that EPSG:4326’s authority-defined axis order is latitude-first, while the click payload and Web Mercator both think in (x, y). The diagram contrasts the two argument orders: always_xy=True with transform(lng, lat) lands on target, while the default axis order silently swaps the coordinate and offsets every extracted point.
import pyproj
# Construct once; reusing the transformer avoids per-click setup cost.
TRANSFORMER = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
if in_bounds(lat, lng):
x, y = TRANSFORMER.transform(lng, lat) # always_xy → (lng, lat) order
record = {
"lat": round(lat, 6),
"lng": round(lng, 6),
"x": round(x, 2),
"y": round(y, 2),
}
# Skip duplicate double-clicks: same rounded coordinate as the last point.
store = st.session_state["extracted_features"]
if not store or (store[-1]["lat"], store[-1]["lng"]) != (record["lat"], record["lng"]):
store.append(record)
# Cap memory growth in long-running sessions.
if len(store) > 500:
st.session_state["extracted_features"] = store[-200:]
st.success(f"Extracted ({lat:.5f}, {lng:.5f}) → ({x:.1f}, {y:.1f}) in EPSG:3857")
if st.session_state["extracted_features"]:
st.dataframe(st.session_state["extracted_features"])
Jump to heading Step 4 — Route the geometry to an indexed spatial query
Capturing the coordinate is only useful if it lands in a fast query. Never run point-in-polygon or nearest-neighbour against an unindexed GeoDataFrame: build the spatial index once and pre-filter candidates by bounding box, dropping the per-click cost from O(n) toward O(log n).
import geopandas as gpd
from shapely.geometry import Point
@st.cache_resource
def load_regions() -> gpd.GeoDataFrame:
gdf = gpd.read_file("regions.geojson").to_crs("EPSG:3857")
_ = gdf.sindex # build the spatial index up front, cache it for reuse
return gdf
def extract_region(gdf: gpd.GeoDataFrame, x: float, y: float):
pt = Point(x, y) # already in EPSG:3857 from Step 3
# bbox pre-filter via the spatial index, then exact contains test
candidates = list(gdf.sindex.query(pt, predicate="intersects"))
hits = gdf.iloc[candidates]
match = hits[hits.contains(pt)]
return None if match.empty else match.iloc[0].to_dict()
Jump to heading Step 5 — Bind reactively in Panel
Panel resolves map interactions through reactive parameters rather than script reruns. With ipyleaflet, attach the callback to the map’s last_clicked attribute via observe() so extraction fires only when the click actually changes — never on unrelated state mutations. This avoids the re-render churn discussed in Widget Lifecycle Management.
import panel as pn
import param
import ipyleaflet as ipyl
import pyproj
import pandas as pd
pn.extension("ipywidgets")
class ClickExtractor(param.Parameterized):
extracted = param.List(default=[])
def __init__(self, **params):
super().__init__(**params)
self._transformer = pyproj.Transformer.from_crs(
"EPSG:4326", "EPSG:3857", always_xy=True
)
self.map = ipyl.Map(center=(39.9, -98.5), zoom=4)
self.map.observe(self._handle_click, names=["last_clicked"])
self._table = pn.widgets.DataFrame(name="Extracted Points")
def _handle_click(self, change):
coords = change.get("new")
if not coords:
return
lat, lng = coords["lat"], coords["lng"]
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
return
x, y = self._transformer.transform(lng, lat)
self.extracted = self.extracted + [
{"lat": round(lat, 6), "lng": round(lng, 6),
"x": round(x, 2), "y": round(y, 2)}
]
self._table.value = pd.DataFrame(self.extracted)
def view(self):
return pn.Column(pn.pane.IPyWidget(self.map, height=400), self._table)
ClickExtractor().view().servable()
Jump to heading Verification
Confirm the schema is intact and that the reprojection round-trips back to the original coordinate within sub-metre tolerance. A passing round-trip proves the axis order is correct and no precision was lost on the way into the store.
import pyproj
fwd = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
inv = pyproj.Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
lat, lng = 39.90123, -98.50456
x, y = fwd.transform(lng, lat)
lng2, lat2 = inv.transform(x, y)
assert abs(lat - lat2) < 1e-6 and abs(lng - lng2) < 1e-6, "axis-order or precision bug"
assert -90 <= lat <= 90 and -180 <= lng <= 180, "out-of-bounds coordinate stored"
print(f"Round-trip OK: ({lat}, {lng}) ↔ ({x:.1f}, {y:.1f})")
# Expected: Round-trip OK: (39.90123, -98.50456) ↔ (-10965... , 4858...)
In the running dashboard, click a known landmark and confirm the displayed EPSG:3857 easting/northing matches a reference value (for example, the value pyproj returns for that lat/lng), and that the extraction table grows by exactly one row per distinct click.
Jump to heading Edge cases and gotchas
- Stale payloads on rapid zoom/pan: Frontend map libraries occasionally emit malformed or out-of-range payloads during fast zoom and pan gestures. The bounds guard in Step 2 must run before transformation — a
Nonelat/lng or alngof200will otherwise crashpyproj.transformor push a garbage point into the indexed query. - CRS mismatch between click and data: The click arrives in WGS84 (EPSG:4326) but your
GeoDataFramemay be in Web Mercator or a local projected CRS. Reproject the point into the layer’s CRS before the spatial join — see CRS handling in Spatial Component Integration — or the contains test silently returns no matches even when the click is visually inside a polygon. - Cross-tab state bleed:
st.session_stateis scoped per browser session, so two tabs accumulate independent extraction stores. If you need shared extraction across users or tabs, persist payloads to a database or message broker rather than in-memory state; review Session State Patterns for the isolation boundaries, and offload heavy joins through Caching Strategies & Async Performance Tuning.
Jump to heading FAQ
Why does my Streamlit click payload disappear on the next interaction?
Streamlit reruns the whole script on every interaction, so any value held only in a local variable is discarded. streamlit-folium returns last_clicked as the latest click but does not accumulate history. Append each validated payload to st.session_state so it persists across reruns, and cap the list length (as in Step 1) to prevent unbounded memory growth in long sessions.
Why are my reprojected coordinates landing in the wrong place?
This is almost always an axis-order bug. pyproj defaults to the authority-defined axis order, which is latitude-first for EPSG:4326. Construct the Transformer with always_xy=True and call transform(lng, lat) so longitude maps to easting and latitude to northing in EPSG:3857. Passing transform(lat, lng) silently swaps the axes and offsets every point.
How do I stop double-clicks from firing duplicate spatial queries?
Debounce at the boundary: ignore a new click if it lands within roughly 5 metres of the previously stored point, or apply a 200–300 ms time window. Step 3 shows a minimal deduplication guard that skips a click whose rounded coordinate equals the last stored one. Deduplicating before the spatial join prevents redundant point-in-polygon work during heavy GeoDataFrame queries.
Back to Tooltip & Click Event Handling
Related
- Tooltip & Click Event Handling — parent guide to capturing and routing pointer interactions on map layers
- Syncing Deck.gl Layers with Streamlit State Variables — persist click-driven selections into state-bound WebGL layers
- Implementing Fallback Static Maps When WebGL Fails — keep extraction working when the interactive renderer degrades
- Spatial Component Integration & Interactive Maps — the broader map integration and CRS architecture