Graduated Symbol Maps with Proportional Circles in Folium

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.

Linear radius versus square-root radius for the same two values With linear radius scaling, a value of four hundred draws a circle sixteen times the area of the circle for one hundred, which overstates the ratio by four. With square-root scaling the four hundred circle covers exactly four times the area, matching the data. Same two values, two scaling rules linear radius — wrong 100 400 area ratio 16 : 1 for a value ratio of 4 : 1 √ radius — correct 100 400 area ratio 4 : 1 — matches the data

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.

Which channel carries which variable Size carries magnitude such as counts and totals. Colour carries rate, category or status. Using both for the same variable doubles the apparent signal and makes small differences look decisive. Using size for a rate misleads, because a large circle implies a large quantity. Two channels, two variables — never two channels for one size → magnitude counts, totals, absolute volumes — "how much is here" the channel readers instinctively read as quantity colour → rate or category per-capita rates, status, operator — "what kind, how intense" independent of the symbol's size, so the two combine cleanly size AND colour → the same variable avoid — doubles the apparent signal small differences start to look decisive, which is exactly what symbology should not do Sizing by a rate is the other trap: a large circle says "a lot happened here", not "this is intense per head".

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.

Building a nested-circle size legend Three circles share a common baseline: a small one for around a tenth of the maximum, a medium one for around forty per cent, and the largest for the maximum. Each is labelled with the real value, and all three are drawn with the same scaling function the map uses. Nested circles on a shared baseline — the only size legend that reads correctly 2 400 000 960 000 240 000 shared baseline, labels to the right rules that make it usable ◦ draw with the SAME radius function as the map ◦ label real, rounded values — not "small, medium, large" ◦ include the maximum, so the cap is visible ◦ align on the baseline, never on centres centre-aligned circles make the middle value look larger than it is

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_PX exactly — 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.