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.

Click-event spatial extraction pipelineA click on the JavaScript map library emits a raw event that the Python framework serializes into a lat/lng payload. A bounds guard rejects out-of-range coordinates; valid ones are reprojected from EPSG:4326 to EPSG:3857, queried against a spatial index, and appended to the session extraction store. Out-of-bounds payloads are quarantined.JS map libraryLeaflet · Mapbox GLraw click eventSerializelast_clicked →{lat, lng} JSONBoundsguardout of rangeQuarantinevalidReprojectEPSG:4326 →EPSG:3857Spatial index querysindex · bbox pre-filterExtraction storesession_state · capped listfrontendPython backend extraction pipeline

Jump to heading Prerequisites

  • Python 3.9+ with an isolated virtual environment (venv or conda).
  • streamlit>=1.30 and streamlit-folium>=0.18, or panel>=1.4 with ipyleaflet>=0.18 for the reactive variant.
  • pyproj>=3.5, shapely>=2.0, and geopandas>=0.14 for transformation and indexed spatial joins.
  • A working grasp of how reruns affect session state — the extraction store depends on it.
bash
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.

python
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.

python
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.

The EPSG:4326 axis-order trap in pyproj reprojectionA WGS84 click payload carries latitude and longitude. The top path constructs the Transformer with always_xy=True and calls transform(lng, lat), so longitude maps to easting and latitude to northing, producing a correct EPSG:3857 point. The bottom path relies on the authority default latitude-first axis order; calling transform(lat, lng) swaps the axes and offsets the result, landing in the wrong place.Click payloadlat = 39.90lng = -98.50always_xy=Truetransform(lng, lat)lng → easting · lat → northingon target(-10965477, 4858342)EPSG:3857 correctauthority defaulttransform(lat, lng)lat → easting · lng → northingaxes swappedoffset · wrong placesilent failure
python
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).

python
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.

python
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.

python
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 None lat/lng or a lng of 200 will otherwise crash pyproj.transform or push a garbage point into the indexed query.
  • CRS mismatch between click and data: The click arrives in WGS84 (EPSG:4326) but your GeoDataFrame may 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_state is 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