A sketched shape is user input. Filter on the draw action, repair the self-intersection a freehand trace almost always has, simplify it to the scale the filter works at, and store it as WKT — not as a live Shapely object.

Jump to heading Why this matters

Letting an analyst draw their own area of interest is the single most useful interaction a spatial dashboard offers, because it removes the need to anticipate every region somebody might care about. It is also the interaction that most reliably breaks, and for a reason that has nothing to do with mapping: a drawn polygon is arbitrary user input arriving over a network, and it tends to be handled with the trust normally reserved for data the application produced itself.

Four things go wrong, each with a different symptom. The callback fires for edits and deletions as well as creations, so a shape the user removed keeps filtering the map. A freehand outline crosses itself somewhere, so the predicate raises instead of returning features. The trace carries thousands of vertices, so storing it makes every subsequent rerun serialise a hundred kilobytes over the socket. And a stray drag encloses half a continent, so the “filter” scans the whole layer. None of the four is exotic; all four appear within a week of shipping the feature.

What a drawn shape is before you trust itA polygon sketched on a map arrives as GeoJSON and is user input in every sense that matters. The action field distinguishes a created shape from an edited or deleted one, and handling all three identically leaves a filter applied to a shape the analyst has already removed. Validity matters because a hand-drawn outline self-intersects wherever the pointer crossed its own path, and a predicate against an invalid geometry raises rather than returning nothing. Vertex count matters because a freehand trace can carry thousands of points, and storing that in session state serialises it over the socket on every rerun. Area matters because one stray drag can enclose a continent, turning an interactive filter into a full scan. None of the four checks is about geometry mathematics; all four are about a shape a person drew with a mouse.FOUR CHECKS BETWEEN A SKETCH AND A STORED FILTERaction == "created"the same callback fires for edits and deletes — treating them alike leaves a filter on a shape that is gonevalid, or repaireda freehand outline self-intersects where the pointer crossed itself, and the predicate raises rather than returning nothingvertex count boundeda trace can carry thousands of points; simplify before storing, because session state crosses the socket on every rerunarea boundedone stray drag encloses a continent and turns the filter into a full scan of the layerStore the simplified, validated geometry — and store it as WKT or GeoJSON rather than as a live Shapely object, sothe state stays serialisable and small.

Jump to heading Prerequisites

  • ipyleaflet>=0.18 with its DrawControl, or folium with the Draw plugin and streamlit-folium.
  • shapely>=2.0 for the validity and simplification work.
  • The state discipline from session state patterns: identifiers and small values in state, frames behind a cache.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Handle the three actions separately

python
import streamlit as st
from shapely.geometry import shape

def handle_draw(target, action: str, geo_json: dict) -> None:
    if action == "deleted":
        st.session_state.pop("aoi_wkt", None)      # clear, do not leave it applied
        return
    if action not in ("created", "edited"):
        return
    geom = shape(geo_json["geometry"])             # EPSG:4326, as drawn
    st.session_state.aoi_wkt = prepare(geom).wkt   # prepare() is Step 2

Popping the key on deletion is the whole fix for the most confusing of the four failures — the one where an analyst deletes their shape, sees the map keep filtering, and reasonably concludes the delete button does not work.

Jump to heading Step 2 — Repair, simplify and bound, in that order

python
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform
import pyproj

_to_m = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
_to_deg = pyproj.Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True).transform
MAX_AREA_KM2 = 25_000

def prepare(geom: BaseGeometry, tolerance_m: float = 10.0) -> BaseGeometry:
    if not geom.is_valid:
        geom = geom.buffer(0)                       # resolve the self-intersection
    metric = transform(_to_m, geom)                 # simplify in metres, not degrees
    if metric.area / 1e6 > MAX_AREA_KM2:
        raise ValueError("drawn area is implausibly large — redraw it")
    metric = metric.simplify(tolerance_m, preserve_topology=True)
    return transform(_to_deg, metric)

The reprojection is not optional. simplify interprets its tolerance in the units of the geometry, so passing 10 to a shape in degrees asks for a ten-degree tolerance — roughly a thousand kilometres — and returns a triangle. This is the same trap described in CRS & coordinate systems, and it is unusually easy to hit here because the drawn shape arrives in degrees and the number you want to pass is in metres.

Order matters too: repair before simplifying, because simplifying an invalid polygon can produce a different invalid polygon, and check the area before doing either, so a runaway shape is rejected before you spend anything on it.

Jump to heading Step 3 — Store text, parse on use

The round trip a drawn filter makesA drawn shape travels from the draw control into the callback, is validated and simplified, and is stored in session state as text rather than as a live object. On the next render the stored text is parsed back into a geometry, used as the predicate against the cached layer, and the resulting subset is rendered. The important property is that session state holds the smallest durable representation rather than the object itself: it survives a rerun, it survives a page switch, it is small enough to put in a URL if the view needs to be shareable, and nothing in the loop depends on an object identity that a rerun would destroy.SKETCH → FILTER → RENDER, ONCE PER DRAWdrawon_draw firesvalidate + simplifystore WKT in staterenderparse from statepredicate vs cached layerrender subsetState holds text, not a Shapely object — small, serialisable, survives a rerun and a page switch, and can go in a URL.
python
from shapely import wkt

def current_aoi():
    raw = st.session_state.get("aoi_wkt")
    return wkt.loads(raw) if raw else None

aoi = current_aoi()
subset = layer[layer.intersects(aoi)] if aoi is not None else layer

Parsing on every render looks wasteful and is not: a six-hundred-vertex polygon parses in tens of microseconds, which is nothing beside the spatial predicate that follows. What it buys is a session state that is small, serialisable, survives a page switch, and can be dropped into a query string when somebody wants to share the view.

Jump to heading Step 4 — Simplify with the payload in mind

What simplification does to a freehand shapeA freehand polygon traced around a district is measured before and after simplification at three tolerances. Raw, it carries about four thousand two hundred vertices and roughly one hundred and ten kilobytes of well-known text, which is what would be written into session state and serialised on every rerun. Simplified at one metre it is down to about six hundred vertices and sixteen kilobytes, with no visible difference at any zoom the dashboard renders. At ten metres it is a hundred and forty vertices and four kilobytes, still visually faithful at district scale. At a hundred metres it is thirty vertices and under a kilobyte, and the shape has visibly straightened — acceptable for a coarse regional filter and wrong for anything a user expects to match what they drew. The predicate result barely changes across the first three, which is the point: the vertices were never carrying information the filter used.ONE FREEHAND DISTRICT OUTLINE — VERTICES AND STORED SIZEraw4,200 vertices110 KB of WKTsimplify(1 m)600 vertices · 16 KB · no visible changesimplify(10 m)140 vertices · 4 KB · faithful at district scalesimplify(100 m)30 vertices · visibly straightenedThe matched feature count is within a fraction of a percent across the first three — the extra vertices were nevercarrying information the predicate used, only bytes the socket had to move.

Jump to heading Verification

python
from shapely.geometry import Polygon

# A bow-tie: the classic freehand self-intersection.
bowtie = Polygon([(0, 0), (1, 1), (1, 0), (0, 1)])
assert not bowtie.is_valid
fixed = prepare(bowtie)
assert fixed.is_valid, "repair did not produce a valid geometry"

# The area guard actually rejects.
huge = Polygon([(-30, 20), (30, 20), (30, 60), (-30, 60)])
try:
    prepare(huge)
    raise AssertionError("an implausibly large shape was accepted")
except ValueError:
    pass

# The round trip through WKT is lossless enough for the predicate.
assert wkt.loads(fixed.wkt).equals(fixed)

The bow-tie test is worth keeping in the suite permanently. It is two lines, it reproduces the most common real failure exactly, and it will catch the day somebody removes the buffer(0) because it looked redundant.

Jump to heading Edge cases and gotchas

  • Rectangles are polygons. A DrawControl rectangle arrives as a five-point polygon, not as a distinct type, so no special handling is needed — but do not assume a shape with four corners is axis-aligned, because it will not be after a reprojection.
  • Multiple shapes. If the control allows more than one, the callback fires per shape and the last one wins unless you accumulate deliberately. Decide whether multiple shapes mean a union or a list, and make it explicit; a silent last-one-wins is the behaviour users find most surprising.
  • buffer(0) can return an empty geometry. For a degenerate trace — a shape drawn with two clicks, or one whose points are collinear — the repair produces nothing. Check is_empty after repairing and treat it as “no filter” rather than as a valid predicate that matches nothing.
  • Antimeridian crossings. A shape drawn across the 180th meridian produces a polygon spanning the whole globe in planar coordinates. If your users work near it, split the geometry rather than hoping.

Jump to heading FAQ

Should the drawn shape go in the URL as well?

Only if it is small. A ten-metre-simplified district outline is a few kilobytes of WKT, which is far past what a query string should carry. If shared links matter, store the shape server-side under a short identifier and put the identifier in the URL — the same reasoning that makes a click send a feature id rather than a geometry.

Why simplify at all if the predicate is fast?

Because the cost is not the predicate — it is that the geometry lives in session state, which is serialised on every rerun. A four-thousand-vertex polygon means a hundred kilobytes crossing the socket each time any widget moves, which is a cost the drawn filter continues to charge long after the drawing is finished.

Can I keep the original shape as well as the simplified one?

Yes, and it is worth doing when an export has to reflect exactly what the user drew. Keep the simplified version in session state for filtering and write the original to a cache keyed by an identifier — the same split as everywhere else on this site: small and durable in state, large and derived behind a cache.

Back to Session State Patterns.