Storing Drawn Polygons in Session State
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.
Jump to heading Prerequisites
ipyleaflet>=0.18with itsDrawControl, orfoliumwith theDrawplugin andstreamlit-folium.shapely>=2.0for 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
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
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
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
Jump to heading Verification
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
DrawControlrectangle 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. Checkis_emptyafter 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.