Basic Packed Bubble Chart — Makie.jl

A packed bubble chart displays data as circles where size represents value, and circles are packed together without overlap using physics simulation. Unlike scatter or traditional bubble charts, position has no meaning - only size and optional grouping matter. This visualization efficiently uses space for comparing values across many categories.

Basic Packed Bubble Chart rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# bubble-packed: Basic Packed Bubble Chart
# Library: makie 0.22.10 | Julia 1.11.9
# Quality: 88/100 | Created: 2026-05-29

using CairoMakie
using Colors
using Random

Random.seed!(42)

# Theme tokens
const THEME       = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG     = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const ELEVATED_BG = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420"
const INK         = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT    = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"

const IMPRINT_PALETTE = [
    colorant"#009E73",  # 1 — brand green   → Africa
    colorant"#C475FD",  # 2 — lavender       → Americas
    colorant"#4467A3",  # 3 — blue           → Asia
    colorant"#BD8233",  # 4 — ochre          → Europe
]

# Data — World Population 2023 (millions), Top 30 Countries by continent
labels = [
    "India",      "China",    "USA",      "Indonesia", "Pakistan",
    "Nigeria",    "Brazil",   "Bangladesh","Russia",   "Ethiopia",
    "Mexico",     "Japan",    "Philippines","Egypt",   "DR Congo",
    "Vietnam",    "Iran",     "Germany",  "Thailand",  "UK",
    "France",     "Tanzania", "S. Africa","Kenya",    "Myanmar",
    "Colombia",   "S. Korea", "Spain",    "Argentina", "Algeria"
]

values = Float64[
    1429, 1412,  339,  278,  231,
     224,  215,  170,  144,  127,
     128,  123,  115,  106,  102,
      98,   89,   84,   72,   68,
      68,   65,   60,   56,   54,
      52,   52,   47,   46,   46
]

groups = [
    "Asia",    "Asia",    "Americas", "Asia",    "Asia",
    "Africa",  "Americas","Asia",    "Europe",  "Africa",
    "Americas","Asia",    "Asia",    "Africa",  "Africa",
    "Asia",    "Asia",    "Europe",  "Asia",    "Europe",
    "Europe",  "Africa",  "Africa",  "Africa",  "Asia",
    "Americas","Asia",    "Europe",  "Americas","Africa"
]

group_names     = ["Africa", "Americas", "Asia", "Europe"]
group_color_map = Dict(zip(group_names, IMPRINT_PALETTE))
circle_colors   = [group_color_map[g] for g in groups]

# Radii: circle area proportional to population value
max_radius = 118.0
radii = max_radius .* sqrt.(values ./ maximum(values))
n = length(labels)

# Initial positions: random spread scaled to expected packed area
init_spread = sqrt(sum(radii .^ 2)) * 0.55
positions_x = randn(n) .* init_spread
positions_y = randn(n) .* init_spread

# Force-directed circle packing — repel overlapping pairs, attract toward center
pad = 3.0
for _ in 1:6000
    for i in 1:n
        for j in (i + 1):n
            dx   = positions_x[i] - positions_x[j]
            dy   = positions_y[i] - positions_y[j]
            dist = sqrt(dx^2 + dy^2)
            min_d = radii[i] + radii[j] + pad
            if dist < min_d
                if dist < 1.0e-8
                    θr   = 2π * rand()
                    dx, dy = cos(θr), sin(θr)
                    dist  = 1.0
                end
                push_amt = (min_d - dist) / dist * 0.5
                positions_x[i] += dx * push_amt
                positions_y[i] += dy * push_amt
                positions_x[j] -= dx * push_amt
                positions_y[j] -= dy * push_amt
            end
        end
        positions_x[i] *= 0.9988
        positions_y[i] *= 0.9988
    end
end

# Center the cluster on the canvas after simulation
cx_mean = sum(positions_x) / n
cy_mean = sum(positions_y) / n
positions_x .-= cx_mean
positions_y .-= cy_mean

# Compute square axis limits that contain all circles with margin
margin   = 28.0
x_min    = minimum(positions_x .- radii) - margin
x_max    = maximum(positions_x .+ radii) + margin
y_min    = minimum(positions_y .- radii) - margin
y_max    = maximum(positions_y .+ radii) + margin
plot_cx  = (x_min + x_max) / 2
plot_cy  = (y_min + y_max) / 2
half_ext = max(x_max - x_min, y_max - y_min) / 2

# Title — 66 chars, within 67-char baseline → no fontsize reduction needed
title_str  = "World Population 2023 · bubble-packed · julia · makie · anyplot.ai"
n_title    = length(title_str)
title_size = max(14, round(Int, 20 * min(1.0, 67.0 / n_title)))

# Figure — square 1200×1200 × px_per_unit=2 → 2400×2400 final PNG
fig = Figure(
    size            = (1200, 1200),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title              = title_str,
    titlesize          = Float32(title_size),
    titlecolor         = INK,
    backgroundcolor    = PAGE_BG,
    aspect             = DataAspect(),
    topspinevisible    = false,
    bottomspinevisible = false,
    leftspinevisible   = false,
    rightspinevisible  = false,
    xgridvisible       = false,
    ygridvisible       = false,
    xticksvisible      = false,
    yticksvisible      = false,
    xticklabelsvisible = false,
    yticklabelsvisible = false,
)

xlims!(ax, plot_cx - half_ext, plot_cx + half_ext)
ylims!(ax, plot_cy - half_ext, plot_cy + half_ext)

# Draw packed circles — 60-point polygon per circle
θ_pts = range(0, 2π, length = 61)[1:60]
for i in 1:n
    r   = radii[i]
    pts = [Point2f(positions_x[i] + r * cos(t), positions_y[i] + r * sin(t)) for t in θ_pts]
    poly!(ax, pts; color = (circle_colors[i], 0.88), strokewidth = 1.5, strokecolor = INK_SOFT)
end

# Country labels inside circles (white text, scaled to circle size)
for i in 1:n
    r = radii[i]
    if r >= 55
        fs = clamp(round(Int, r / 6.0), 11, 20)
        # Country name shifted up to make room for population annotation
        text!(ax, positions_x[i], positions_y[i] + r * 0.18;
            text     = labels[i],
            align    = (:center, :center),
            fontsize = Float32(fs),
            color    = colorant"#FFFFFF",
        )
        # Population value annotation below the country name
        pop_str = values[i] >= 1000 ?
            string(round(values[i] / 1000; digits=2)) * "B" :
            string(round(Int, values[i])) * "M"
        text!(ax, positions_x[i], positions_y[i] - r * 0.18;
            text     = pop_str,
            align    = (:center, :center),
            fontsize = Float32(clamp(round(Int, fs * 0.72), 9, 11)),
            color    = RGBAf(1f0, 1f0, 1f0, 0.85f0),
        )
    elseif r >= 38
        text!(ax, positions_x[i], positions_y[i];
            text     = labels[i],
            align    = (:center, :center),
            fontsize = 10.0f0,
            color    = colorant"#FFFFFF",
        )
    end
end

# Continent legend — horizontal, positioned below the chart
legend_entries = [PolyElement(color = c, strokecolor = INK_SOFT, strokewidth = 1.0)
                  for c in IMPRINT_PALETTE]
Legend(
    fig[2, 1],
    legend_entries,
    group_names;
    orientation  = :horizontal,
    tellwidth    = false,
    tellheight   = true,
    framecolor   = INK_SOFT,
    framevisible    = false,
    labelcolor      = INK,
    backgroundcolor = ELEVATED_BG,
    labelsize       = 14.0f0,
    padding      = (14, 14, 8, 8),
)

rowgap!(fig.layout, 6)

save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bubble-packed/makie/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "bubble-packed",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/bubble-packed/julia/makie",
  "hub": "https://anyplot.ai/bubble-packed",
  "code_json": "https://api.anyplot.ai/specs/bubble-packed/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/bubble-packed",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-packed/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-packed/julia/makie/plot-dark.png",
  "quality_score": 88.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Packed Bubble Chart on anyplot.ai.

Other implementations