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.

Two panels: independent component states versus one selection driving both views.
Every synchronisation path is a place two views can diverge.

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.

Two panels contrasting row indices with stable identifiers as a selection.
Codes are also what let a brush on a chart and a click on a map mean the same thing.

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.

Flow from a map click through a toggle to both views re-rendering.
The alternative produces "how do I get everything back?" every time.

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.

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.