Chris Parmer — home

Beeswarm

GDP per person in 142 countries, one circle each, sized by population

Example from Plotly for highly customizable print-ready data visualization · shared helpers

Beeswarm — GDP per person in 142 countries, one circle each, sized by population

Python Code

"""Beeswarm — GDP per person in 142 countries, one circle each, sized by population.

Points packed by hand so none overlap: sorted along the axis, then each
dropped into the nearest free slot above or below the centre line. Bubbles
are sized by population, so the swarm packs unevenly, the way a newspaper
graphic of this kind does.
"""
from pathlib import Path

from _shared import save, base_layout, titles, INK, MUTED, FAINT, GRID, VIOLET, TEAL, GREEN, PINK, ORANGE, hex_to_rgba

import numpy as np
import plotly.express as px
import plotly.graph_objects as go

CHART_NUM = 23
COLORS = {"Asia": VIOLET, "Europe": TEAL, "Americas": GREEN, "Africa": PINK, "Oceania": ORANGE}
ORDER = ["Africa", "Asia", "Americas", "Europe", "Oceania"]
PLOT_W, PLOT_H = 1130, 640      # pixel size of the plot area, for the packing
XR = (np.log10(200), np.log10(60000))
LABEL = ["China", "India", "United States", "Nigeria", "Norway", "Japan", "Brazil", "Congo, Dem. Rep.", "Kuwait", "Australia", "Indonesia"]


def swarm(x_px, r_px):
    """Greedy beeswarm: place each point at the smallest |y| that avoids every placed circle."""
    order = np.argsort(x_px)
    y = np.zeros(len(x_px))
    placed = []
    for i in order:
        cands = [0.0]
        for j in placed:
            dx = abs(x_px[i] - x_px[j])
            rr = r_px[i] + r_px[j] + 1.0
            if dx < rr:
                dy = np.sqrt(rr ** 2 - dx ** 2)
                cands += [y[j] + dy, y[j] - dy]
        cands.sort(key=abs)
        for c in cands:
            ok = all(np.hypot(x_px[i] - x_px[j], c - y[j]) >= r_px[i] + r_px[j] + 0.9 for j in placed)
            if ok:
                y[i] = c
                break
        placed.append(i)
    return y


def generate():
    df = px.data.gapminder().query("year == 2007").reset_index(drop=True)
    size = 4 + np.sqrt(df["pop"] / 1e6) * 1.15          # marker diameter in px
    x_px = (np.log10(df["gdpPercap"]) - XR[0]) / (XR[1] - XR[0]) * PLOT_W
    row_h = PLOT_H / len(ORDER)
    fig = go.Figure()
    for k, cont in enumerate(ORDER):
        idx = df.index[df["continent"] == cont].to_numpy()
        y_px = swarm(x_px[idx].to_numpy(), (size[idx] / 2).to_numpy())
        y = k + y_px / row_h
        sub = df.loc[idx]
        fig.add_trace(go.Scatter(
            x=sub["gdpPercap"], y=y, mode="markers", name=cont,
            marker=dict(size=size[idx], color=hex_to_rgba(COLORS[cont], 0.8), line=dict(color="white", width=1)),
            text=sub["country"], customdata=np.c_[sub["pop"] / 1e6, sub["lifeExp"]],
            hovertemplate="<b>%{text}</b><br>$%{x:,.0f} per person<br>%{customdata[0]:.0f}M people<extra></extra>",
        ))
        for c, yy, gdp, s in zip(sub["country"], y, sub["gdpPercap"], size[idx]):
            if c in LABEL:
                fig.add_annotation(x=np.log10(gdp), y=yy, text=c.replace("Congo, Dem. Rep.", "DR Congo"), showarrow=False,
                                   font=dict(size=10, color=INK if s > 26 else MUTED), yshift=0 if s > 26 else s / 2 + 7,
                                   bgcolor="rgba(0,0,0,0)" if s > 26 else "rgba(255,255,255,0.8)", borderpad=1)
        fig.add_annotation(x=0, y=k, xref="paper", text=f"<b>{cont}</b>", showarrow=False, xanchor="right", xshift=-10, font=dict(size=13, color=COLORS[cont]))
        fig.add_shape(type="line", x0=XR[0], x1=XR[1], y0=k, y1=k, line=dict(color=GRID, width=1), layer="below")
    fig.update_layout(**base_layout(margin=dict(l=110, r=40, t=110, b=80)))
    fig.update_xaxes(type="log", range=list(XR), tickvals=[250, 500, 1000, 2000, 5000, 10000, 20000, 50000],
                     ticktext=["$250", "$500", "$1,000", "$2,000", "$5,000", "$10,000", "$20,000", "$50,000"],
                     title_text="GDP per person, 2007 (inflation-adjusted dollars, log scale)", showgrid=True)
    fig.update_yaxes(visible=False, range=[-0.6, len(ORDER) - 0.4])
    fig.add_annotation(x=1, y=1, xref="paper", yref="paper", xanchor="right", yanchor="bottom", yshift=8, showarrow=False,
                       text="Circle area ∝ population", font=dict(size=11, color=MUTED))
    titles(fig, "Every country's income, one circle each",
           "142 countries by GDP per person in 2007, grouped by continent. Circles are sized by population and packed so that none overlap.",
           "Source: Gapminder, via plotly.express.data.gapminder().")
    save(CHART_NUM, fig)

Made with Plotly