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.

One selection owned by the app, with the map and the chart rendering from it. one state, two rendererstwo components, two statesmap statechart statesynchronised by handthey can disagreeone selectionselected: {codes}mapchartcannot divergeBoth components are pure functions of one set of identifiers spatialworkflow.io
Every synchronisation path is a place two views can diverge.

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.

Why a selection must be identifiers rather than row positions. what a selection should containrow indices✕change when a filter is applied✕change when the data is sorted✕meaningless in a URL or a log✕silently point at the wrong rowsstable codes✓survive filtering and sorting✓join to any other table✓fit in a query parameter✓mean something in a log lineIt is the difference between a selection that survives a filter change and one that becomes wrong spatialworkflow.io
Codes are also what let a brush on a chart and a click on a map mean the same thing.

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, not last_object_clicked. The second is only {"lat": …, "lng": …} for a click on a polygon; the feature, with its code, 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 after st_folium returns, 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 layer tags. 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.

What happens, in order, when a district is clicked in the linked app. one click, in order1click on the maplast_active_drawing2on_change callbacktoggles the code3script re-runsreads the new set4map overlay + chart + metricsone set, three viewsThe callback is what puts the selection ahead of the drawing spatialworkflow.io
Code placed after the st_folium call runs in step 3, after the map it should have changed.

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.

Browser test of four versions of the linked app: does each action leave the app and the map in agreement? app and map agree after…1st click2nd clickClearExample 1✓yes✓deselects✓yeslast_object_clicked✕KeyError——handled after call✕map lags✕ignored✕reselectsno layer tags✓yes✕ignored✓yesEach detail in Example 1 is there because its absence failed here spatialworkflow.io
Measured in headless Chromium on this page.

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.

A click on a feature toggles it in the selection set. click to select, click again to deselectclick returns acodelast_object_clickedtoggle it in a setsymmetric differenceboth viewsre-renderfrom the same seta clear buttonor the user is stuckUsers expect a click on a selected thing to deselect it — every list behaves that way spatialworkflow.io
The alternative produces "how do I get everything back?" every time.

Returned objects, click positions and toggling

  • Restrict returned_objects, or panning the map re-runs the app.
  • last_object_clicked is a position, {"lat", "lng"}; the clicked feature and its properties are last_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.

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.