How to Link a Map and a Chart So One Selection Drives Both
On this page
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, and the map click handled in a callback so it lands before anything is drawn:
import streamlit as st
from streamlit_folium import st_folium
st.session_state.setdefault("selected", set())
def on_map_click():
clicked = (st.session_state.map or {}).get("last_active_drawing")
if clicked:
st.session_state.selected ^= {clicked["properties"]["code"]} # click toggles
left, right = st.columns([3, 2])
with left:
st_folium(base_map(), key="map", height=520,
feature_group_to_add=selected_layer(districts, st.session_state.selected),
returned_objects=["last_active_drawing"], on_change=on_map_click)
with right:
st.altair_chart(build_chart(districts, st.session_state.selected))
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.
What you get — from The data used on this page
361 districts; EPSG:4326 | median change 3.84%, range -3.45% to 87.62%
Stable ids, one selection, clicks and brushes
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 reports the clicked feature, properties and all, as last_active_drawing. The similarly named last_object_clicked holds only the point that was clicked — {"lat": 51.93, "lng": 0.29} for a click on Uttlesford — so a selection keyed on codes never gets one from it.
st_folium(m, key="map", returned_objects=["last_active_drawing"], on_change=on_map_click)
Two parts of that call matter as much as the key. Restricting returned_objects means panning and zooming return nothing and do not re-run the script. And on_change runs the handler before the script, so the rest of the run is drawn from the new selection; code placed after the call runs after the map has already been drawn from the old one.
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 — but the map reads it through a separate layer, not by restyling itself:
def selected_layer(gdf, selected):
group = folium.FeatureGroup(name="selected")
chosen = gdf[gdf["code"].isin(selected)]
if len(chosen):
folium.GeoJson(chosen.assign(layer="selected"),
style_function=lambda _: {"fillColor": "#ef4444", "weight": 1.5,
"fillOpacity": 0.75}).add_to(group)
return group
The base map stays identical from run to run, and st_folium(..., feature_group_to_add=selected_layer(...)) swaps the red layer in place. A style_function that colours the base layer by selection hands the component a different map after every selection change instead. Either way there is one source of truth, and both views 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.
A linked map and chart of UK population change
The data used on this page
The UK's local authority districts (ONS, December 2024, generalised) with one number each: the change in resident population between the mid-2020 and mid-2025 ONS estimates, from NOMIS. Northern Ireland's districts now have a mid-2025 value there too, so the app has all 361.
import os
import geopandas as gpd
import pandas as pd
import requests
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query")
NOMIS = ("https://www.nomisweb.co.uk/api/v01/dataset/NM_2002_1.data.csv?geography=TYPE424&gender=0"
"&c_age=200&measures=20100&select=date_name,geography_code,obs_value&date=")
if not os.path.exists("districts.gpkg"):
reply = requests.get(ONS, timeout=120, params={
"where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 4326, "f": "geojson"})
reply.raise_for_status()
boundaries = (gpd.read_file(reply.text)
.rename(columns={"LAD24CD": "code", "LAD24NM": "name"})[["code", "name", "geometry"]])
population = (pd.concat([pd.read_csv(NOMIS + year) for year in ("2020", "2025")])
.pivot(index="GEOGRAPHY_CODE", columns="DATE_NAME", values="OBS_VALUE"))
rate = ((population[2025] / population[2020] - 1) * 100).round(2).rename("rate")
(boundaries.merge(rate, left_on="code", right_index=True)
.dropna(subset=["rate"]).to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(len(districts), "districts;", districts.crs, "| median change",
f"{districts['rate'].median():.2f}%, range {districts['rate'].min():.2f}% to {districts['rate'].max():.2f}%")
361 districts; EPSG:4326 | median change 3.84%, range -3.45% to 87.62%
Example 1 — the full linked pair
%%writefile linked_app.py
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 base_geojson(path):
return load(path).assign(layer="base").__geo_interface__
def base_map(path):
"""Identical on every run, so the component is never rebuilt."""
m = folium.Map(location=[54.0, -2.0], zoom_start=6, tiles="OpenStreetMap")
folium.GeoJson(base_geojson(path),
style_function=lambda _: {"fillColor": "#0ea5e9", "color": "white",
"weight": 0.4, "fillOpacity": 0.35},
highlight_function=lambda _: {"weight": 2, "color": "#1a3a6b"},
tooltip=folium.GeoJsonTooltip(fields=["name", "rate"])).add_to(m)
return m
def selected_layer(gdf, selected):
"""The selection as its own layer, swapped in without rebuilding the map."""
group = folium.FeatureGroup(name="selected")
chosen = gdf[gdf["code"].isin(selected)]
if len(chosen):
folium.GeoJson(chosen.assign(layer="selected"),
style_function=lambda _: {"fillColor": "#ef4444", "color": "white",
"weight": 1.5, "fillOpacity": 0.75}).add_to(group)
return group
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="population change 2020–25 (%)"),
y=alt.Y("count()", title="districts"),
color=alt.condition("datum.chosen", alt.value("#ef4444"), alt.value("#0ea5e9")))
.properties(height=240))
def on_map_click():
"""Runs before the script, so everything below is drawn from the new selection."""
clicked = (st.session_state.map or {}).get("last_active_drawing")
if clicked:
st.session_state.selected ^= {clicked["properties"]["code"]} # a click toggles
districts = load("districts.gpkg")
left, right = st.columns([3, 2])
with left:
st_folium(base_map("districts.gpkg"), key="map", height=520, width=None,
feature_group_to_add=selected_layer(districts, st.session_state.selected),
returned_objects=["last_active_drawing"], on_change=on_map_click)
with right:
st.altair_chart(build_chart(districts, st.session_state.selected), width="stretch")
if st.session_state.selected:
chosen = districts[districts["code"].isin(st.session_state.selected)]
st.metric("Selected", len(chosen))
st.metric("Median change", 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.")
st.caption("Selected: " + (", ".join(sorted(chosen["name"])) if st.session_state.selected else "none"))
Three details carry the whole pattern, and each is there because the version without it failed in the browser test below:
last_active_drawing, notlast_object_clicked. The second is only{"lat": …, "lng": …}for a click on a polygon; the feature, with itscode, arrives in the first.on_change, not code after the call. The callback runs before the script, so the map, the chart and the metrics are drawn from the selection the click produced. Read afterst_foliumreturns, the click arrives after the map was drawn: the red overlay is always one click behind, and every later rerun — the Clear button included — toggles the last clicked district again.- The
layertags. The component only reports a click whose feature differs from the last one it reported. Tagging the base copy and the overlay copy of a district differently makes a click on a selected district a new value, which is what lets a second click deselect it.
The map itself never changes: highlighting is a separate layer passed as feature_group_to_add, which the component swaps in place. Restyling the base map from the selection instead gives the component a new map on every selection change.
Example 2 — brushing the chart to select on the map
The same app with the histogram made brushable. A brush produces a range; turning it into the same set of codes the map uses is what keeps the two views consistent, and doing it in on_select puts it ahead of the map, like the click:
%%writefile brush_app.py
exec(open("linked_app.py").read().split("districts = load(")[0])
def on_brush():
bounds = st.session_state.brush.selection.get("range", {}).get("rate")
if bounds:
low, high = bounds
st.session_state.selected = set(districts.loc[districts["rate"].between(low, high), "code"])
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), title="population change 2020–25 (%)"),
y="count()",
color=alt.condition(brush, alt.value("#ef4444"), alt.value("#cbd5e1")))
.add_params(brush)
.properties(height=200))
districts = load("districts.gpkg")
st.altair_chart(brushable_histogram(districts.drop(columns="geometry")), width="stretch",
on_select=on_brush, key="brush")
st_folium(base_map("districts.gpkg"), key="map", height=520, width=None,
feature_group_to_add=selected_layer(districts, st.session_state.selected),
returned_objects=["last_active_drawing"], on_change=on_map_click)
st.metric("Selected", len(st.session_state.selected))
Example 3 — a selection that survives a reload
%%writefile url_app.py
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)
import streamlit as st
if "selected" not in st.session_state:
st.session_state.selected = selection_from_url()
exec(open("linked_app.py").read())
# ... 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.
Test it in a browser
A linked view cannot be checked by reading it. This serves each app with streamlit run, drives it in headless Chromium, and after each action reads three things: how many districts the app says are selected, how many red overlay shapes the map shows, and how many times the script has run. Alongside Example 1 it runs three copies that each undo one of the details above:
%%writefile counted.py
import os, time
start = time.perf_counter()
try:
exec(open(os.environ["APP"]).read())
finally:
with open("runs.log", "a") as log: # one line per script run, with its duration
log.write(f"{time.perf_counter() - start:.3f}\n")
import os, re, socket, statistics, subprocess, sys, time, urllib.request
from playwright.sync_api import sync_playwright
app = open("linked_app.py").read()
variants = {
"linked_app.py": app,
"clicked_app.py": app.replace('"last_active_drawing"', '"last_object_clicked"'),
"after_app.py": app.replace('returned_objects=["last_active_drawing"], on_change=on_map_click)',
'returned_objects=["last_active_drawing"])\n on_map_click()'),
"untagged_app.py": app.replace('.assign(layer="selected")', '').replace('.assign(layer="base")', ''),
"uncached_app.py": app.replace("@st.cache_data\n", ""),
}
for name, source in variants.items():
assert name == "linked_app.py" or source != app, name
open(name, "w").write(source)
def serve(app, query=""):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
server = subprocess.Popen(
[sys.executable, "-m", "streamlit", "run", "counted.py", "--server.headless", "true",
"--server.port", str(port), "--browser.gatherUsageStats", "false"],
env=dict(os.environ, APP=app), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
url = f"http://127.0.0.1:{port}"
for _ in range(120):
try:
urllib.request.urlopen(url + "/_stcore/health", timeout=1)
break
except OSError:
time.sleep(0.5)
return server, url + query
def runs():
return [float(line) for line in open("runs.log")] if os.path.exists("runs.log") else []
def look(page):
shown = " ".join(page.locator('[data-testid="stMetricValue"]').all_inner_texts()[:1]) or "0"
red = page.frame_locator("iframe").first.locator('path[fill="#ef4444"]').count()
error = page.locator('[data-testid="stException"]').all_inner_texts()
error = f" · {error[0].splitlines()[0]}" if error else ""
return f"app {shown:>2} · map {red:>2} · {len(runs())} runs{error}"
def click_district(page, index):
shapes = page.frame_locator("iframe").first.locator("path.leaflet-interactive")
if shapes.count() <= index:
return # no map left to click
box = shapes.nth(index).bounding_box()
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
time.sleep(6)
def pan(page):
box = page.locator("iframe").first.bounding_box()
page.mouse.move(box["x"] + 300, box["y"] + 300)
page.mouse.down()
page.mouse.move(box["x"] + 360, box["y"] + 320, steps=10)
page.mouse.up()
time.sleep(6)
def session(app, steps, query=""):
if os.path.exists("runs.log"):
os.remove("runs.log")
server, url = serve(app, query)
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(url)
page.wait_for_selector("iframe", timeout=90_000)
time.sleep(8)
print(app)
for label, act in steps(page):
act()
print(f" {label:26} {look(page)}")
if page.locator('[data-testid="stException"]').count():
break # the app has crashed; stop here
url_now = page.url
browser.close()
finally:
server.terminate()
server.wait(timeout=30)
return url_now
def clicks(page):
def clear():
button = page.get_by_role("button", name="Clear selection")
if button.count():
button.click()
time.sleep(6)
return [("click Uttlesford", lambda: click_district(page, 100)),
("click Uttlesford again", lambda: click_district(page, 100)),
("click Uttlesford a third", lambda: click_district(page, 100)),
("click Tandridge", lambda: click_district(page, 200)),
("press Clear selection", clear),
("pan the map", lambda: pan(page))]
for app in ["linked_app.py", "clicked_app.py", "after_app.py", "untagged_app.py"]:
session(app, clicks)
linked_app.py
click Uttlesford app 1 · map 1 · 2 runs
click Uttlesford again app 0 · map 0 · 3 runs
click Uttlesford a third app 1 · map 1 · 4 runs
click Tandridge app 2 · map 2 · 5 runs
press Clear selection app 0 · map 0 · 6 runs
pan the map app 0 · map 0 · 6 runs
clicked_app.py
click Uttlesford app 0 · map 0 · 1 runs · KeyError: 'properties'
after_app.py
click Uttlesford app 1 · map 0 · 2 runs
click Uttlesford again app 1 · map 0 · 2 runs
click Uttlesford a third app 1 · map 0 · 2 runs
click Tandridge app 2 · map 1 · 3 runs
press Clear selection app 1 · map 0 · 4 runs
pan the map app 1 · map 0 · 4 runs
untagged_app.py
click Uttlesford app 1 · map 1 · 2 runs
click Uttlesford again app 1 · map 1 · 2 runs
click Uttlesford a third app 1 · map 1 · 2 runs
click Tandridge app 2 · map 2 · 3 runs
press Clear selection app 0 · map 0 · 4 runs
pan the map app 0 · map 0 · 4 runs
Read the app and map columns together: in a working linked view they are always equal. Example 1 keeps them equal through every step, including the second click that deselects and the third that selects again. With last_object_clicked the first click crashes the callback, because a click carries a position and no properties. Handling the click after the call leaves the map a step behind the app — Uttlesford is selected but never drawn red, so clicking it again hits the unchanged base layer and nothing happens — and the Clear button's rerun toggles Tandridge straight back on. Without the tags, the second and third clicks on Uttlesford never reach the app: the overlay's copy of the feature is identical to the base copy, so the component sees no change to report. No version re-ran on a pan, because returned_objects is restricted.
The brush and the URL, in the same harness:
def brushing(page):
box = page.locator('[data-testid="stVegaLiteChart"]').first.bounding_box()
def brush(start, end):
page.mouse.move(box["x"] + box["width"] * start, box["y"] + box["height"] * 0.5)
page.mouse.down()
page.mouse.move(box["x"] + box["width"] * end, box["y"] + box["height"] * 0.5, steps=10)
page.mouse.up()
time.sleep(6)
return [("brush the low bins", lambda: brush(0.12, 0.30)),
("then click a district", lambda: click_district(page, 100))]
session("brush_app.py", brushing)
final = session("url_app.py", lambda page: [("click Tandridge", lambda: click_district(page, 200))],
query="?sel=E07000077,S12000011")
print(" URL afterwards:", final.split("?")[1])
brush_app.py
brush the low bins app 275 · map 275 · 2 runs
then click a district app 274 · map 274 · 3 runs
url_app.py
click Tandridge app 3 · map 3 · 2 runs
URL afterwards: sel=E07000077%2CE07000215%2CS12000011
The brush selects every district whose change falls in the brushed range, and a click then toggles one district on top of it — the two inputs edit the same set. The URL app opened with two districts selected from the query string, and the click wrote the third back into the address bar, ready to share.
What each click costs
Every click is a full run of the script. Cached and uncached, and where the time goes:
for app in ["linked_app.py", "uncached_app.py"]:
session(app, lambda page: [(f"click {n}", lambda n=n: click_district(page, n)) for n in (100, 200, 300, 150)])
times = runs()[1:] # the first run loads the page
print(f" median run after a click: {statistics.median(times):.2f} s")
import folium, json
from streamlit_folium import st_folium
parts = {}
start = time.perf_counter(); gdf = gpd.read_file("districts.gpkg").to_crs(4326); parts["read the layer"] = time.perf_counter() - start
start = time.perf_counter(); geo = gdf.assign(layer="base").__geo_interface__; parts["to GeoJSON"] = time.perf_counter() - start
start = time.perf_counter()
m = folium.Map(location=[54.0, -2.0], zoom_start=6)
folium.GeoJson(geo, tooltip=folium.GeoJsonTooltip(fields=["name", "rate"])).add_to(m)
html = m.get_root().render()
parts["build + render the map"] = time.perf_counter() - start
for step, seconds in parts.items():
print(f" {step:24} {seconds:.2f} s")
print(f" map HTML sent to the browser: {len(html) / 1e6:.1f} MB")
linked_app.py
click 100 app 1 · map 1 · 2 runs
click 200 app 2 · map 2 · 3 runs
click 300 app 3 · map 3 · 4 runs
click 150 app 4 · map 4 · 5 runs
median run after a click: 3.35 s
uncached_app.py
click 100 app 1 · map 1 · 2 runs
click 200 app 2 · map 2 · 3 runs
click 300 app 3 · map 3 · 4 runs
click 150 app 4 · map 4 · 5 runs
median run after a click: 3.41 s
read the layer 0.01 s
to GeoJSON 0.16 s
build + render the map 1.09 s
map HTML sent to the browser: 20.7 MB
One piece of state is the whole pattern
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. Measured on this page, a click costs a median 3.35 s with the loaders cached and 3.41 s without — the cache saves less than a tenth of a second, because reading an 8.7 MB GeoPackage is not where the time goes. Building and rendering the folium map takes about a second, and what it renders is 20.7 MB of HTML that goes to the browser on every run.
Linked views are therefore the case where per-run cost matters most, because they generate the most interactions. Cache the load and the GeoJSON conversion, and then look at the size of the map: fewer vertices, fewer attributes in the GeoJSON, or a vector-tile layer instead of embedded GeoJSON is what moves a number like that.
Returned objects, click positions and toggling
- Restrict
returned_objects, or panning the map re-runs the app. last_object_clickedis a position,{"lat", "lng"}; the clicked feature and its properties arelast_active_drawing.- Handle clicks in
on_change, so the map is drawn from the selection the click produced. - 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 in st_folium's on_change callback from last_active_drawing, 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, but measure: in the app on this page the cache saved 0.1 s of a 2.8 s click, and the rest was building and sending the map.
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.