How to Link a Map and a Chart So One Selection Drives Both
Problem statement
A map and a chart of the same data, side by side and independent, are two pictures. Linked โ click a region on the map and the chart filters; brush a range on the chart and the map highlights โ they become an analysis tool.
In Streamlit the linking runs into the execution model:
- a click on the map returns a value, which re-runs the whole script
- the chart is rebuilt, so its own selection is lost
- the two components disagree about what is selected, and neither shows the truth
- with a large dataset, every click re-filters and re-serialises everything
The pattern that works is one piece of state โ the selection โ owned by the app rather than by either component, with both components rendering from it.
Quick answer
One selection in session_state, both components reading it:
import streamlit as st
st.session_state.setdefault("selected", set())
def on_map_click(state):
feature = (state or {}).get("last_active_drawing") or (state or {}).get("last_object_clicked")
if feature and (code := (feature.get("properties") or {}).get("code")):
selected = st.session_state.selected
selected.symmetric_difference_update({code}) # click toggles
left, right = st.columns([3, 2])
with left:
state = st_folium(build_map(districts, st.session_state.selected),
height=520, returned_objects=["last_object_clicked"])
on_map_click(state)
with right:
st.altair_chart(build_chart(districts, st.session_state.selected),
use_container_width=True)
if st.session_state.selected:
st.caption(f"{len(st.session_state.selected)} selected ยท "
+ ", ".join(sorted(st.session_state.selected)[:5]))
st.button("Clear selection",
on_click=lambda: st.session_state.selected.clear())
The selection is a set of stable identifiers, not geometry and not chart indices. That is what lets both components speak the same language.
Step-by-step solution
1. Give every feature a stable identifier
Row positions change with every filter and sort; a code does not.
districts = districts.assign(code=districts["code"].astype(str))
The selection is then a set of codes, meaningful to the map, the chart, the table and a URL. Anything derived from position breaks the moment a filter changes the order.
2. Keep the selection in one place
st.session_state.setdefault("selected", set())
Not in the map's state, not in the chart's, not in two variables that are synchronised by hand. One set, owned by the app, read by both components.
That is also what makes the selection survivable: components are rebuilt on every rerun, and session_state is not.
3. Read clicks from the map
st_folium returns the clicked feature when the map is built with an interactive layer:
state = st_folium(m, returned_objects=["last_object_clicked"])
clicked = (state or {}).get("last_object_clicked")
Restricting returned_objects matters here as everywhere: without it, panning returns state and re-runs the script.
A click should usually toggle, so a second click deselects. Users expect that, and it removes the need for a separate deselect control.
4. Read brushes and clicks from the chart
Altair and Plotly both support selections that Streamlit can read:
import altair as alt
brush = alt.selection_interval(encodings=["x"])
chart = (alt.Chart(frame).mark_bar()
.encode(x="rate:Q", y="count()")
.add_params(brush))
event = st.altair_chart(chart, use_container_width=True,
on_select="rerun", key="chart")
if event and event.selection.get("param_1"):
...
Streamlit's on_select="rerun" makes the chart a widget: its selection triggers a rerun and arrives as a value.
5. Render both components from the selection
Neither component owns highlighting. Both read the set:
def build_map(gdf, selected):
def style(feature):
is_selected = feature["properties"]["code"] in selected
return {"fillColor": "#ef4444" if is_selected else "#0ea5e9",
"color": "white", "weight": 1.5 if is_selected else 0.5,
"fillOpacity": 0.75 if is_selected else 0.35}
...
Doing it this way means the two can never disagree: there is one source of truth, and both are functions of it.
6. Make the selection visible and clearable
if st.session_state.selected:
st.caption(f"{len(st.session_state.selected)} selected")
st.button("Clear", on_click=lambda: st.session_state.selected.clear())
A selection the user cannot see or clear is a trap, and "how do I get everything back?" is the most common complaint about linked views.
Code examples
Example 1 โ the full linked pair
import altair as alt
import folium
import geopandas as gpd
import streamlit as st
from streamlit_folium import st_folium
st.set_page_config(layout="wide")
st.session_state.setdefault("selected", set())
@st.cache_data
def load(path):
gdf = gpd.read_file(path).to_crs(4326)
return gdf.assign(code=gdf["code"].astype(str))
@st.cache_data
def geojson_of(_gdf, key):
import json
return json.loads(_gdf.to_json())
def build_map(gdf, selected, key):
m = folium.Map(location=[54.0, -2.0], zoom_start=6, tiles="cartodbpositron")
def style(feature):
chosen = feature["properties"]["code"] in selected
return {"fillColor": "#ef4444" if chosen else "#0ea5e9",
"color": "white",
"weight": 1.5 if chosen else 0.4,
"fillOpacity": 0.75 if chosen else 0.35}
folium.GeoJson(geojson_of(gdf, key), style_function=style,
highlight_function=lambda _: {"weight": 2, "color": "#1a3a6b"},
tooltip=folium.GeoJsonTooltip(fields=["name", "rate"]),
popup=folium.GeoJsonPopup(fields=["code", "name", "rate"])
).add_to(m)
return m
def build_chart(gdf, selected):
frame = gdf.drop(columns="geometry").assign(
chosen=gdf["code"].isin(selected))
return (alt.Chart(frame)
.mark_bar()
.encode(x=alt.X("rate:Q", bin=alt.Bin(maxbins=30), title="rate (%)"),
y=alt.Y("count()", title="districts"),
color=alt.condition("datum.chosen",
alt.value("#ef4444"), alt.value("#0ea5e9")))
.properties(height=240))
districts = load("districts.gpkg")
version = f"{len(districts)}"
left, right = st.columns([3, 2])
with left:
state = st_folium(build_map(districts, st.session_state.selected, version),
height=520, width=None,
returned_objects=["last_object_clicked"])
clicked = (state or {}).get("last_object_clicked")
if clicked and (properties := clicked.get("properties")):
code = str(properties.get("code"))
st.session_state.selected.symmetric_difference_update({code})
with right:
st.altair_chart(build_chart(districts, st.session_state.selected),
use_container_width=True)
if st.session_state.selected:
chosen = districts[districts["code"].isin(st.session_state.selected)]
st.metric("Selected", len(chosen))
st.metric("Median rate", f"{chosen['rate'].median():.1f}%")
st.button("Clear selection",
on_click=lambda: st.session_state.selected.clear())
else:
st.info("Click districts on the map to select them.")
Example 2 โ brushing the chart to filter the map
import altair as alt
import streamlit as st
def brushable_histogram(frame):
brush = alt.selection_interval(encodings=["x"], name="range")
return (alt.Chart(frame)
.mark_bar()
.encode(x=alt.X("rate:Q", bin=alt.Bin(maxbins=30)),
y="count()",
color=alt.condition(brush, alt.value("#ef4444"),
alt.value("#cbd5e1")))
.add_params(brush)
.properties(height=200))
event = st.altair_chart(brushable_histogram(frame), use_container_width=True,
on_select="rerun", key="brush")
selection = (event.selection if event else {}) or {}
if (bounds := selection.get("range", {}).get("rate")):
low, high = bounds
st.session_state.selected = set(
districts.loc[districts["rate"].between(low, high), "code"])
st.caption(f"Brushed {low:.1f}โ{high:.1f}% ยท "
f"{len(st.session_state.selected)} districts")
A brush produces a range, and turning it into the same set of codes the map uses is what keeps the two views consistent.
Example 3 โ a selection that survives a reload
import streamlit as st
def selection_from_url() -> set:
raw = st.query_params.get("sel", "")
return {code for code in raw.split(",") if code}
def selection_to_url(selected: set) -> None:
if selected:
st.query_params["sel"] = ",".join(sorted(selected))
else:
st.query_params.pop("sel", None)
if "selected" not in st.session_state:
st.session_state.selected = selection_from_url()
# ... after any change ...
selection_to_url(st.session_state.selected)
Putting the selection in the URL makes it shareable โ "look at these five districts" becomes a link โ and makes a bug report reproducible.
Explanation
Why one piece of state is the whole pattern
Two components that each hold their own idea of what is selected must be synchronised, and every synchronisation path is a place they can diverge. The map says three are selected, the chart highlights two, and neither is wrong from its own point of view.
Owning the selection in the app and rendering both components from it makes divergence impossible. Each component is a pure function of the state, which is also why the whole thing is testable without a browser.
Why the selection must be identifiers
Row indices change when a filter is applied. Chart data positions change when the data is sorted. Geometry cannot be compared cheaply.
A stable code survives all of that, joins to any other table, fits in a URL and is meaningful in a log line. It is the only representation that works for every consumer of the selection.
Why toggling is the right click behaviour
Users expect a click on a selected thing to deselect it, because that is how every list, map and file browser behaves. Implementing it as symmetric_difference_update is one line and removes the need for a separate deselect mode.
The alternative โ click to add, some other control to remove โ produces the "how do I get everything back?" question every time.
Why linked views cost more per interaction
Every click re-runs the script, re-filters the data, rebuilds the map and rebuilds the chart. With an uncached loader that is the full cost of loading the layer; measured, 0.23โ0.25 s on a 15 MB shapefile against 0.04โ0.06 s cached.
Linked views are therefore the case where caching matters most, because they generate the most interactions. Cache the load, cache the GeoJSON conversion, and keep the per-click work to a set operation and a style function.
Edge cases or notes
- Restrict
returned_objects, or panning the map re-runs the app. - Selections must be identifiers, never row positions.
- Clicking should toggle, and there must be a clear button.
on_select="rerun"makes an Altair or Plotly chart a widget.- Cache the GeoJSON conversion separately from the layer โ it runs on every rerun otherwise.
- A brush gives a range; convert it to the same identifier set the map uses.
- Show what is selected in text, not only in colour.
- Put the selection in the URL to make it shareable and bug reports reproducible.
Internal links
- How to build a Streamlit app with an interactive map โ the app the pair sits in
- Reruns and state explained: why your map app redraws everything โ why one state object is necessary
- How to add filters and widgets that drive a map โ the other kind of selection
- How to cache spatial data in a map app โ keeping clicks cheap
- How to let users draw an area of interest in a map app โ a spatial selection
- Fixing a map that resets or disappears on every interaction โ losing component state
- How to make interactive maps with folium โ the map component
- How to test a map app without a browser โ testing the selection logic
FAQ
How do I make a map click filter a chart?
Keep one selection โ a set of stable identifiers โ in st.session_state, update it from the map's returned click, and build both the map and the chart from it.
Why do my map and chart disagree about the selection?
Because each is holding its own copy. Own the selection in the app and render both components as functions of it; then they cannot diverge.
Should the selection be row indices?
No. Row positions change with every filter and sort. Use a stable code that joins to other tables and fits in a URL.
How do I get a selection out of an Altair chart?
Add a selection parameter and pass on_select="rerun" to st.altair_chart. The chart becomes a widget and its selection arrives as a value.
Why does clicking the map re-run everything?
Because st_folium returns a value, and any returned value triggers a rerun. That is expected โ cache the load so the rerun is cheap.
How do I let users share a selection?
Write the identifiers into st.query_params. The URL then carries the selection, which is also what makes a bug report reproducible.