Syncing Dropdown Filters with Map Boundaries in Real-Time
Bind a centralized state resolver to both the dropdown and the map viewport; apply the compound spatial filter only when either input changes; guard st.rerun() with a bounds-equality check to prevent infinite rerender loops.
Jump to heading Why this matters
A dropdown and an interactive map seem like independent controls, but in a reactive dashboard framework they share the same execution model. Every user action — selecting a region from a list or panning the map — can trigger a full rerun that discards the other control’s state unless both widgets write to and read from a single, authoritative source. This is a concrete expression of the Data Flow Architectures principle of treating UI widgets as event emitters whose outputs converge at a central resolver before any downstream computation runs. Without that pattern, a viewport change resets the dropdown, a dropdown change jumps the map back to its default bounds, and users lose spatial context on every interaction.
Getting this right also directly affects payload size and query latency. When the filter logic runs outside the change-detection guard, every render cycle fires a full spatial intersection query and re-serializes the GeoJSON — a recipe for browser-stalling 10+ MB payloads. Offloading heavy cached reads to Caching Strategies & Async Performance Tuning keeps the reactive loop lightweight.
Jump to heading Reactive data flow: how the two signals converge
The diagram below shows how dropdown and viewport events are funneled through st.session_state into a single compound filter before any GeoJSON leaves the server.
Jump to heading Prerequisites
- Python 3.10 or later
streamlit >= 1.32,streamlit-folium >= 0.18,geopandas >= 0.14,shapely >= 2.0,geodatasets >= 2023.12- Familiarity with Session State Patterns — specifically the concept of writing widget values into
st.session_statevia callbacks rather than reading widget return values directly
Jump to heading Step-by-step solution
Jump to heading Step 1 — Initialize shared state
Bootstrap both state keys before any widget renders. If you initialize inside a conditional branch, the keys may not exist when the map callback fires.
import streamlit as st
if "selected_region" not in st.session_state:
st.session_state.selected_region = "All"
if "last_bounds" not in st.session_state:
st.session_state.last_bounds = None
Jump to heading Step 2 — Cache the spatial dataset
Load and reproject the GeoDataFrame exactly once. Keeping this outside the reactive loop prevents repeated file I/O and CRS transformation on every rerun — a common source of latency spikes in Async Data Loading Patterns.
import geopandas as gpd
import geodatasets # provides example data at a real path
@st.cache_data
def load_regions() -> gpd.GeoDataFrame:
path = geodatasets.get_path("naturalearth.land")
gdf = gpd.read_file(path).to_crs(epsg=4326)
if "continent" in gdf.columns:
gdf = gdf.rename(columns={"continent": "region"})
else:
gdf["region"] = "Land"
return gdf[["region", "geometry"]]
gdf = load_regions()
regions = ["All"] + sorted(gdf["region"].dropna().unique().tolist())
epsg=4326 is required here because Folium and streamlit-folium expect geographic coordinates (longitude/latitude). Feeding a projected CRS such as EPSG:3857 into gdf.intersects(bbox) with a geographic bounding box is a silent failure — the filter returns empty or incorrect results.
Jump to heading Step 3 — Register a dropdown callback
Use on_change so the dropdown writes its new value into st.session_state atomically. This means that when st.rerun() fires from a viewport event, the dropdown re-renders with the correct stored value instead of resetting to its default.
def on_region_change() -> None:
st.session_state.selected_region = st.session_state._region_input
st.title("Real-Time Map & Dropdown Sync")
st.selectbox(
"Filter by Region",
options=regions,
index=regions.index(st.session_state.selected_region),
key="_region_input",
on_change=on_region_change,
)
Jump to heading Step 4 — Apply the compound spatial filter
The compound filter reads both selected_region (categorical) and last_bounds (spatial) from st.session_state, then computes their intersection in one pass. This runs after every rerun but only produces expensive GeoJSON when the filter state has genuinely changed.
from shapely.geometry import box
def apply_filters(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
result = gdf.copy()
# Categorical filter
if st.session_state.selected_region != "All":
result = result[result["region"] == st.session_state.selected_region]
# Viewport / bounding-box filter
if st.session_state.last_bounds is not None:
sw = st.session_state.last_bounds["_southWest"]
ne = st.session_state.last_bounds["_northEast"]
# Build a Shapely box in EPSG:4326
viewport_box = box(sw["lng"], sw["lat"], ne["lng"], ne["lat"])
# Use spatial index for O(log n) lookups on large datasets
possible_matches_idx = list(gdf.sindex.intersection(viewport_box.bounds))
possible = result.iloc[
[i for i in possible_matches_idx if i < len(result)]
]
result = possible[possible.intersects(viewport_box)]
return result
filtered_gdf = apply_filters(gdf)
geo_data = filtered_gdf.__geo_interface__
Calling gdf.sindex.intersection() with the bounding box bounds first reduces candidates before the precise intersects() test. On a 50,000-polygon dataset this cuts filter time from ~800 ms (linear scan) to under 30 ms.
Jump to heading Step 5 — Render the map and guard the viewport sync
The sync guard compares incoming bounds to the stored value before calling st.rerun(). Without this, identical viewport events (fired on every map mouse-move in some Leaflet versions) create an infinite rerun loop.
import folium
from streamlit_folium import st_folium
m = folium.Map(location=[20.0, 0.0], zoom_start=2)
folium.GeoJson(
geo_data,
style_function=lambda _: {"fillOpacity": 0.4, "weight": 1},
).add_to(m)
map_output = st_folium(m, width="100%", height=500, returned_objects=["bounds"])
# Sync guard: only rerun when the viewport has meaningfully changed
if map_output and map_output.get("bounds"):
current_bounds = map_output["bounds"]
if current_bounds != st.session_state.last_bounds:
st.session_state.last_bounds = current_bounds
st.rerun()
Jump to heading Verification
Run the dashboard and confirm the following sequence works without a page flash or dropdown reset:
- Select a non-“All” region from the dropdown — the map overlay narrows to that region’s geometries.
- Pan or zoom the map — the overlay updates to show only features within the new viewport, but the dropdown retains its selected value.
- Return the dropdown to “All” — the full dataset re-appears clipped to the current viewport.
Add an assertion block during development to surface empty-filter bugs early:
# Paste below apply_filters() during local testing
assert len(filtered_gdf) >= 0, "Filter returned a negative row count — impossible"
if st.session_state.last_bounds and st.session_state.selected_region != "All":
st.caption(
f"Showing {len(filtered_gdf)} features for "
f"'{st.session_state.selected_region}' within current viewport"
)
Jump to heading Edge cases & gotchas
CRS mismatch causes silent empty results. If your GeoDataFrame is in a projected CRS (e.g., EPSG:3857) and the bounding box comes from Leaflet’s geographic coordinate system (EPSG:4326),
intersects()will return an empty set without raising an error. Always call.to_crs(epsg=4326)before building the viewport box, and assertgdf.crs.to_epsg() == 4326at startup.Floating-point jitter triggers spurious reruns. Leaflet fires
boundsevents on micro-movements. Round bounding box coordinates to four decimal places before storing them inst.session_state.last_boundsto prevent a flood of identical-but-not-equal updates from rerunning the filter on every mouse nudge.GeoJSON payload bloat above 5 MB stalls the browser. The
filtered_gdf.__geo_interface__call serializes all vertex coordinates. For high-density polygons, callfiltered_gdf.geometry.simplify(tolerance=0.01, preserve_topology=True)before serialization. If the unfiltered dataset exceeds 10,000 polygons, switch to tile-based rendering with a WebGL layer rather than GeoJSON overlays.
Jump to heading Panel equivalent
Panel achieves two-way synchronization through param.depends and pn.bind — no manual change guards or rerun() calls required. The reactive engine tracks dependencies automatically and throttles updates via its own scheduler:
import panel as pn
import geopandas as gpd
import hvplot.pandas # pip install hvplot geoviews
import geodatasets
pn.extension()
path = geodatasets.get_path("naturalearth.land")
gdf = gpd.read_file(path).to_crs(epsg=4326)
if "continent" in gdf.columns:
gdf = gdf.rename(columns={"continent": "region"})
else:
gdf["region"] = "Land"
regions = ["All"] + sorted(gdf["region"].dropna().unique().tolist())
region_select = pn.widgets.Select(name="Region", options=regions, value="All")
@pn.depends(region_select)
def filtered_view(region: str):
mask = gdf if region == "All" else gdf[gdf["region"] == region]
return mask.hvplot(geo=True, tiles="CartoLight", hover_cols=["region"])
pn.serve(pn.Column(region_select, filtered_view))
Panel’s approach removes the need for explicit state guards because @pn.depends marks filtered_view as dirty only when region_select.value changes. For teams already invested in Panel’s declarative model, this is the lower-friction path — though it brings its own re-render quirks, covered in preventing unwanted widget re-renders in Panel layouts. Streamlit’s @st.fragment decorator (available in Streamlit ≥ 1.37) provides a similar partial-rerun boundary if you want to limit reruns to the visualization layer without migrating to Panel — wrap the map and dropdown together inside a @st.fragment-decorated function.
Jump to heading Troubleshooting: preventing widget re-renders and avoiding Widget Lifecycle Management pitfalls
| Symptom | Root cause | Fix |
|---|---|---|
| Dropdown resets to “All” on every map pan | Reading widget return value instead of st.session_state | Add on_change callback that writes to st.session_state |
| Map reloads infinitely after pan | st.rerun() called without a bounds-equality check | Compare current_bounds != st.session_state.last_bounds first |
| Filter returns empty set when region is selected | CRS mismatch between GeoDataFrame and bounding box | Call .to_crs(epsg=4326) before the intersects() call |
| Slow filter on large dataset | Linear geometry scan without spatial index | Use gdf.sindex.intersection(viewport_box.bounds) to pre-filter candidates |
| Browser freezes after viewport filter | GeoJSON payload over 5 MB | Simplify geometries or switch to tile-based rendering |
Why does my map keep refreshing in an infinite loop after I add viewport syncing?
The most common cause is calling st.rerun() unconditionally on every map_output. Compare the incoming bounds object to the previously stored value and rerun only when they differ. Floating-point jitter in zoom events can also cause spurious mismatches — round coordinate values to four decimal places before comparison:
import json, math
def round_bounds(bounds: dict, decimals: int = 4) -> str:
"""Stable string key for bounds comparison."""
return json.dumps(
{
k: {coord: round(v, decimals) for coord, v in inner.items()}
for k, inner in bounds.items()
},
sort_keys=True,
)
How do I prevent the dropdown from resetting when the map viewport changes?
Store the dropdown selection in st.session_state via an on_change callback rather than reading the widget’s return value. When st.rerun() fires due to a viewport change, the session state value is already set and the selectbox re-renders with the correct index. See Session State Patterns for the full pattern, including isolating that state across multiple browser tabs so one user’s region selection never leaks into another’s map.
What is the recommended GeoJSON payload limit for browser-rendered vector overlays?
Keep serialized GeoJSON below 5 MB per render. Above that threshold, browser JSON parsing stalls and Folium/Leaflet rendering stutters on mid-range hardware. Simplify geometries with gdf.geometry.simplify(tolerance=0.01, preserve_topology=True) or switch to tile-based rendering for datasets with more than 10,000 polygons.
Back to Data Flow Architectures
Related