Part of the Symbology & Data-Driven Styling guide.
Operative rule: radius must scale with the square root of the value, never linearly — linear radius scaling exaggerates large values by exactly the factor a reader is trying to estimate.
Why Area, Not Radius
A circle’s area is proportional to the square of its radius. A reader looking at two circles does not measure their radii; they form an impression of how much ink is there. So if a value of 100 gets radius 10 and a value of 400 gets radius 40, the second circle covers sixteen times the area to represent four times the quantity — an exaggeration of four, applied precisely where the map’s message is loudest.
Scaling by sqrt(value / max_value) fixes it. The largest symbol reaches the cap, everything else is proportionally correct by area, and a reader’s intuitive comparison matches the data. This is not a stylistic preference; it is the difference between a map that supports a decision and one that overstates its own headline.
Production-Ready Implementation
from __future__ import annotations
import math
import folium
import geopandas as gpd
MIN_PX = 3.0
MAX_PX = 28.0
def radius_px(value: float, max_value: float) -> float:
"""Area-proportional radius in screen pixels, floored so tiny values stay visible."""
if value <= 0 or max_value <= 0:
return 0.0
return max(MIN_PX, MAX_PX * math.sqrt(value / max_value))
def graduated_symbol_map(
gdf: gpd.GeoDataFrame, field: str, label_field: str = "name"
) -> folium.Map:
"""Circle markers sized by value, largest drawn first so small ones stay clickable."""
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
points = gdf.copy()
points["geometry"] = points.geometry.representative_point()
points = points[points[field].notna()]
max_value = float(points[field].max())
centre = points.geometry.union_all().centroid
m = folium.Map(location=[centre.y, centre.x], zoom_start=6,
tiles="cartodbpositron")
group = folium.FeatureGroup(name=f"{field} (graduated)")
# Descending: big circles first, so small ones land on top and stay pickable.
for _, row in points.sort_values(field, ascending=False).iterrows():
folium.CircleMarker(
location=[row.geometry.y, row.geometry.x],
radius=radius_px(float(row[field]), max_value),
color="#0C6E94",
weight=1.2,
fill=True,
fill_color="#2B8CBE",
fill_opacity=0.55, # overlap must stay readable
tooltip=f"{row.get(label_field, '')}: {row[field]:,.0f}",
).add_to(group)
group.add_to(m)
folium.LayerControl(collapsed=False).add_to(m)
return m
if __name__ == "__main__":
cities = gpd.read_file("cities.geojson")
graduated_symbol_map(cities, "population").save("graduated.html")
Three details in that function are load-bearing. representative_point() is used instead of centroid because a centroid can fall outside a concave polygon, putting the symbol in the sea. The descending sort makes small circles clickable rather than buried. And fill_opacity below one keeps overlapping symbols individually legible, which is what makes the map survive at low zoom.
Size and colour are separate channels and can carry separate variables. Deciding which channel carries what — before writing any styling code — is what stops a map from encoding the same fact twice and doubling its apparent signal.
The Size Legend
A colour legend is a list of swatches; a size legend has to be nested circles, because that is the only form that lets a reader compare an area on the map with an area in the legend without moving their eye twice.
Handling Density Without Losing the Data
Proportional symbols degrade in a specific way: as density rises, circles overlap until the map shows a mass rather than a distribution. There are three honest responses and one dishonest one.
The honest responses are to reduce the maximum radius so overlap is rarer, to switch to aggregation so that one symbol represents a bin rather than a feature, or to constrain the map’s minimum zoom so readers never see the extent at which the symbols merge. Which is right depends on whether the message is about individual places or about the pattern as a whole.
The dishonest response is to shrink the symbols until they no longer overlap while leaving the legend unchanged. The map then looks tidy, but every quantity has been silently rescaled, and a reader comparing it against an earlier version — or against the legend — is being misled. If the scaling changes, the legend must change with it, which is exactly why both are generated from the same function.
There is also a middle path worth knowing: keep the proportional symbols for the zoom range where they are legible, and swap to an aggregated representation below it. Because both representations derive from the same values, the transition is a rendering decision rather than a data one, and the reader sees a map that stays readable at every scale rather than one that gives up at the bottom of its zoom range.
One practical note on the cap itself: choose it against the smallest viewport the dashboard supports, not the largest. A twenty-eight pixel maximum that looks balanced on a desktop occupies a substantial share of a phone screen, and on a narrow viewport a handful of large symbols can cover most of the visible extent. Deriving the cap from the container width — a fixed fraction of the shorter side, clamped to a sensible range — keeps the map legible everywhere without maintaining two sets of numbers.
Verification Steps
- Assert the largest symbol’s radius equals
MAX_PXexactly — if it does not, the maximum used for scaling is not the maximum in the data. - Confirm a value of zero renders nothing rather than a minimum-size dot, which would claim presence where there is none.
- Zoom to the densest area and confirm individual symbols are still distinguishable; if not, the cap is too high for that extent.
- Click the smallest symbol in a dense group and confirm it is reachable — that is what the descending sort buys.
- Compare a legend circle against a map circle of the same value on screen; they must be identical in size.
Common Errors & Fixes
Circles change size when the reader zooms
Circle was used instead of CircleMarker. Circle takes a radius in metres and therefore scales with the map; CircleMarker takes pixels and stays constant, which is what symbology needs.
One outlier dominates the entire map
The maximum is an anomaly. Either cap the scaling at a high percentile rather than the true maximum and mark clipped symbols distinctly, or move to a classed scheme where the top class is open-ended.
Symbols sit off the polygon they belong to
centroid was used on a concave or multipart geometry. Use representative_point(), which is guaranteed to fall inside the shape.
Overlapping circles read as one blob
Fill opacity is too high or the stroke is missing. A solid stroke at one to one-and-a-half pixels with a fill around fifty per cent keeps boundaries visible even when three symbols overlap.
The through-line for all of this is that a proportional symbol makes a quantitative claim, and the claim has to survive a reader measuring it against the legend. Area-proportional scaling, a legend drawn with the same function, and a cap chosen for the smallest supported viewport are what keep that claim true on every screen the dashboard reaches.
Related
- Symbology & Data-Driven Styling — the parent guide, including when to prefer size over colour
- Building a Choropleth Colour Ramp with geopandas and branca — the area-based alternative for polygon data
- Rendering a Million Points with PyDeck ScatterplotLayer — when the point count outgrows Folium entirely
- Popups, Tooltips & Feature Interaction — keeping small symbols clickable