How to Make a Dorling Cartogram in Python

On this page

Problem statement

A Dorling cartogram replaces every region with a circle whose area is proportional to a value, then nudges the circles apart until none overlap. Each circle stays as close to home as the crowd allows. Shape is given up entirely, but size is exact and position is approximately right. For thousands of small units — US counties, for example — it is often more readable than a contiguous cartogram, which would have to twist 3,000 outlines.

This page builds a Dorling cartogram of the 2024 presidential vote by county in about 20 lines of NumPy and SciPy. It measures how far the circles have to move, and shows the one design decision that controls everything: how much of the map the circles are allowed to fill.

Quick answer

Place one circle per county at a point inside it, with radius from its share of the votes, and repeatedly push overlapping pairs apart, the larger circle moving less:

def dorling(xy, r, iterations=1000, tolerance=1.0):
    p = xy.copy()
    for it in range(iterations):
        pairs = cKDTree(p).query_pairs(2 * r.max(), output_type="ndarray")
        d = np.hypot(*(p[pairs[:, 0]] - p[pairs[:, 1]]).T)
        overlap = r[pairs[:, 0]] + r[pairs[:, 1]] - d
        hit = overlap > tolerance                                        # metres
        if not hit.any():
            return p, it
        a, b = pairs[hit].T
        u = (p[a] - p[b]) / np.maximum(d[hit], 1e-6)[:, None]
        w = r[b] ** 2 / (r[a] ** 2 + r[b] ** 2)                           # small circles move more
        move = np.zeros_like(p)
        np.add.at(move, a, u * (overlap[hit] * w)[:, None])
        np.add.at(move, b, -u * (overlap[hit] * (1 - w))[:, None])
        p += move
    return p, iterations

r = np.sqrt(c.total_votes / c.total_votes.sum() * c.area.sum() * FILL / np.pi).values
moved, passes = dorling(XY, r)
shift = np.hypot(*(moved - XY).T) / 1000
print(f"{len(c):,} circles, fill {FILL:.0%}: no overlaps after {passes} passes; "
      f"median move {np.median(shift):.1f} km, 95th percentile {np.percentile(shift, 95):.1f} km, max {shift.max():.0f} km")
3,109 circles, fill 12%: no overlaps after 320 passes; median move 0.0 km, 95th percentile 17.0 km, max 132 km
Map of the lower 48 states with one circle per county, sized by 2024 votes cast and coloured by margin. Large blue circles mark Los Angeles, the Bay Area, Seattle, Chicago, the New York and Boston areas, Philadelphia, Atlanta and Houston; thousands of tiny red dots cover the rural interior, and light-red circles cluster in Florida and Texas. Map of the lower 48 states with one circle per county, sized by 2024 votes cast and coloured by margin. Large blue circles mark Los Angeles, the Bay Area, Seattle, Chicago, the New York and Boston areas, Philadelphia, Atlanta and Houston; thousands of tiny red dots cover the rural interior, and light-red circles cluster in Florida and Texas.
A Dorling cartogram of the 2024 presidential vote: one circle per county, area proportional to votes cast, circles filling 12% of the land. Data: county results compiled by tonmcg; US Census Bureau boundaries.

Start inside each county, choose the fill, find who moved

1. Start each circle inside its county

Use representative_point(), not centroid. A centroid can fall outside a concave or multi-part county, and the circle should start on land the county owns:

cent = c.geometry.centroid
outside = ~c.geometry.contains(cent)
print(f"{outside.sum()} of {len(c):,} county centroids fall outside their county, e.g. {c.loc[outside, 'NAME'].head(4).tolist()}")
8 of 3,109 county centroids fall outside their county, e.g. ['Iron', 'Henry', 'Pepin', 'Keweenaw']

2. Choose how much of the map the circles fill

The circles' total area is a free choice: a fraction of the land area. Small circles barely move but leave rural counties as specks; large circles are readable but must travel far from home:

for fill in (0.06, 0.12, 0.25):
    rr = np.sqrt(c.total_votes / c.total_votes.sum() * c.area.sum() * fill / np.pi).values
    pp, n = dorling(XY, rr)
    s = np.hypot(*(pp - XY).T) / 1000
    print(f"fill {fill:4.0%}: largest radius {rr.max() / 1000:5.1f} km, smallest {rr.min():6.0f} m; "
          f"{n:4d} passes{' (not resolved)' if n == 1000 else ''}; 95th-percentile move {np.percentile(s, 95):5.1f} km, max {s.max():4.0f} km")
fill   6%: largest radius  60.2 km, smallest    307 m;  110 passes; 95th-percentile move   1.5 km, max   66 km
fill  12%: largest radius  85.1 km, smallest    434 m;  320 passes; 95th-percentile move  17.0 km, max  132 km
fill  25%: largest radius 122.9 km, smallest    627 m; 1000 passes (not resolved); 95th-percentile move  59.9 km, max  260 km

3. Find who moved and why

The biggest displacements happen where large circles crowd together: dense metropolitan regions of many small, populous counties.

c["moved_km"] = shift
print(c.nlargest(6, "moved_km")[["NAME", "STUSPS", "total_votes", "moved_km"]].round(0).to_string(index=False))
    NAME STUSPS  total_votes  moved_km
  Nassau     NY       706541     132.0
  Bergen     NJ       459077     123.0
   Essex     NJ       313283     116.0
Richmond     NY       197496     113.0
   Bronx     NY       359844     111.0
   Union     NJ       240632     106.0
Three versions of the county Dorling cartogram. At 6 percent fill the circles are small and almost all stay put. At 12 percent the map is readable after 320 passes. At 25 percent the circles crowd the eastern half and overlaps remain after 1,000 passes. Three versions of the county Dorling cartogram. At 6 percent fill the circles are small and almost all stay put. At 12 percent the map is readable after 320 passes. At 25 percent the circles crowd the eastern half and overlaps remain after 1,000 passes.
The fill fraction decides how far circles move and whether the overlaps clear at all.

4. Colour and draw

Draw the largest circles first so small ones stay visible on top, and colour by margin:

order = np.argsort(-r)
fig, ax = plt.subplots(figsize=(10, 6))
ax.add_collection(PatchCollection([Circle(moved[i], r[i]) for i in order], array=c.margin.values[order],
                                  cmap="RdBu_r", clim=(-0.6, 0.6), edgecolor="white", linewidth=0.2))
ax.set_aspect("equal"); ax.autoscale_view(); ax.set_axis_off()
fig.savefig("data/dorling_2024.png", dpi=150)
print("drawn", len(order), "circles")
drawn 3109 circles

What the circles say that the map did not

The data used on this page

2024 presidential results by county from tonmcg/US_County_Level_Election_Results_08-24; county boundaries from the US Census Bureau cartographic boundary files, 2023, 1:20 million. The lower 48 states and DC; Alaska reports by house district and is left out.

from pathlib import Path

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
from matplotlib.collections import PatchCollection
from matplotlib.patches import Circle
from scipy.spatial import cKDTree

Path("data").mkdir(exist_ok=True)
for name, url in {"2024_US_County_Level_Presidential_Results.csv": "https://raw.githubusercontent.com/tonmcg/US_County_Level_Election_Results_08-24/master/2024_US_County_Level_Presidential_Results.csv",
                  "cb_2023_us_county_20m.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip"}.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)
res = pd.read_csv("data/2024_US_County_Level_Presidential_Results.csv", dtype={"county_fips": str})
res["fips"] = np.where(res.state_name == "District of Columbia", "11001", res.county_fips)       # DC's 8 wards
res = res.groupby("fips")[["votes_gop", "votes_dem", "total_votes"]].sum()
c = gpd.read_file("data/cb_2023_us_county_20m.zip")
c = c[~c.STATEFP.isin(["02", "15", "60", "66", "69", "72", "78"])].join(res, on="GEOID", how="inner").to_crs(5070).reset_index(drop=True)
c["margin"] = (c.votes_gop - c.votes_dem) / c.total_votes
pts = c.geometry.representative_point()
XY = np.c_[pts.x, pts.y]
FILL = 0.12
print(f"{len(c):,} counties, {c.total_votes.sum():,} votes")
3,109 counties, 154,022,941 votes

Example 1: what the circles say that the map did not

red = c.margin > 0
circle_area = np.pi * r ** 2
print(f"Republican-won counties: {c.area[red].sum() / c.area.sum():.0%} of the land, "
      f"{circle_area[red].sum() / circle_area.sum():.0%} of the circle area")
print(f"the 10 largest circles hold {np.sort(c.total_votes.values)[-10:].sum() / c.total_votes.sum():.0%} of all votes")
Republican-won counties: 86% of the land, 51% of the circle area
the 10 largest circles hold 11% of all votes
Republican-won counties as a share of the land and of the Dorling circle area, 2024. what the circles changeshare of the land86%share of circle area51%050%100%3,109 counties of the lower 48 and DC spatialworkflow.io
The circles show votes, not land.

Example 2: keep circles nearer home with a pull

A spring back towards each circle's start reduces displacement but can stop the overlaps from resolving:

def dorling_pull(xy, r, k, iterations=500):
    p = xy.copy()
    for it in range(iterations):
        pairs = cKDTree(p).query_pairs(2 * r.max(), output_type="ndarray")
        d = np.hypot(*(p[pairs[:, 0]] - p[pairs[:, 1]]).T)
        overlap = r[pairs[:, 0]] + r[pairs[:, 1]] - d
        hit = overlap > 1.0
        a, b = pairs[hit].T
        u = (p[a] - p[b]) / np.maximum(d[hit], 1e-6)[:, None]
        w = r[b] ** 2 / (r[a] ** 2 + r[b] ** 2)
        move = np.zeros_like(p)
        np.add.at(move, a, u * (overlap[hit] * w)[:, None])
        np.add.at(move, b, -u * (overlap[hit] * (1 - w))[:, None])
        p += move + (xy - p) * k
    return hit.sum()

for k in (0.0, 0.005, 0.01):
    print(f"pull {k}: {dorling_pull(XY, r, k)} overlapping pairs left after 500 passes")
pull 0.0: 0 overlapping pairs left after 500 passes
pull 0.005: 467 overlapping pairs left after 500 passes
pull 0.01: 476 overlapping pairs left after 500 passes

Even a weak pull leaves hundreds of overlaps that never clear: the circles in dense regions reach an equilibrium between push and pull instead of separating. Push alone converges.

Only value and rough location are kept

Danny Dorling's cartograms, from 1996, kept only two properties of each region: its value, as the area of a circle, and its rough location. Abandoning shape removes the hardest part of making a contiguous cartogram. Nothing has to stay connected, and each circle can move independently. The price is that readers navigate by the overall outline of the country and by a few big, labelled circles, not by the regions' own shapes.

How a Dorling cartogram is built. regions to circlespoint insideeachrepresentative_point()radius√(share × fill ×land / π)find overlapsKD-tree pairspush apartsmall circles movemorerepeat320 passes at 12%No spring back toward home: it stops the overlaps clearing spatialworkflow.io
How a Dorling cartogram is built. A spring back toward home stops the overlaps clearing.

The fill fraction is the real design choice. At 12% of the land, most of the 3,100 counties do not move at all, because their circles are smaller than the gaps around them. The big metropolitan circles shoulder each other aside, by up to 132 km around New York, but the national pattern stays readable. Double the fill to 25% and the 95th-percentile move quadruples to 60 km, and 1,000 passes are no longer enough to clear every overlap. Halve it to 6% and the smallest counties shrink to circles 300 m across, invisible at any map scale.

Starting points, draw order and circle area

Common problems with Dorling cartograms and their fixes. Dorling problemsProblemFixcircle starts outside its countyrepresentative_point(), notcentroidoverlaps never cleardrop the pull; lower the fillcircles far from homelower the fill (6%: 1.5 km p95)small circles hiddendraw large circles first8 of 3,109 county centroids fall outside the county spatialworkflow.io
Dorling cartogram problems. Eight of 3,109 county centroids fall outside the county.
  • Use representative_point() for the starting position; centroids can fall outside their county.
  • Draw large circles first so small ones are not hidden underneath.
  • Zero values become circles of zero radius; leave them out or give them a visible floor.
  • Circle area, not radius, must be proportional to the value.
  • Very different sizes (a 100-vote county next to Los Angeles) make the smallest circles sub-pixel; say so in the legend.
  • A spring force toward home sounds sensible but can stop the overlaps from resolving at all.

FAQ

What is a Dorling cartogram?

A cartogram that replaces each region with a circle whose area is proportional to a value, moved just enough that no circles overlap.

How do I make a Dorling cartogram in Python?

Compute radii from the values, start each circle at a point inside its region, and iteratively push overlapping circles apart with a KD-tree to find neighbours (as above).

How big should the circles be?

Choose a total circle area as a fraction of the land; around a tenth keeps displacement modest for US counties. Larger fills read better but move circles further.

Why do some circles end up far from their region?

They sit in dense clusters of large circles, such as metropolitan counties, and have to be pushed out to make room.