Basic Packed Bubble Chart — Matplotlib

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 Matplotlib

Python source (Matplotlib)

""" anyplot.ai
bubble-packed: Basic Packed Bubble Chart
Library: matplotlib 3.10.9 | Python 3.13.13
Quality: 85/100 | Updated: 2026-05-29
"""

import os

import matplotlib.collections as mcoll
import matplotlib.patches as mpatches
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np


# Theme tokens — Imprint palette, theme-adaptive chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — 8 hues, canonical order
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — department budget allocation (in thousands USD)
labels = [
    "Engineering",
    "Marketing",
    "Sales",
    "Operations",
    "HR",
    "Finance",
    "R&D",
    "Customer Support",
    "Legal",
    "IT",
    "Design",
    "Product",
    "Data Science",
    "Security",
    "QA",
]
values = [950, 420, 680, 310, 160, 280, 820, 200, 130, 370, 230, 580, 470, 145, 175]

# Group assignments — organizational structure
group_map = {
    "Engineering": "Engineering",
    "IT": "Engineering",
    "Data Science": "Engineering",
    "R&D": "Engineering",
    "Marketing": "Business",
    "Sales": "Business",
    "Product": "Business",
    "Design": "Business",
    "Operations": "Operations",
    "HR": "Operations",
    "Finance": "Operations",
    "Customer Support": "Operations",
    "Legal": "Compliance",
    "Security": "Compliance",
    "QA": "Compliance",
}

# Groups mapped to first 4 Imprint palette positions (canonical order)
group_order = ["Engineering", "Business", "Operations", "Compliance"]
group_colors = {g: IMPRINT_PALETTE[i] for i, g in enumerate(group_order)}
colors = [group_colors[group_map[label]] for label in labels]

# Scale values to radius (sqrt for area-proportional sizing)
min_radius = 0.30
max_radius = 2.0
values_array = np.array(values, dtype=float)
radii = min_radius + (max_radius - min_radius) * np.sqrt(
    (values_array - values_array.min()) / (values_array.max() - values_array.min())
)

# Sort by size (largest first) for better packing
n = len(labels)
order = np.argsort(-radii)
radii_sorted = radii[order]
labels_sorted = [labels[i] for i in order]
values_sorted = [values[i] for i in order]
colors_sorted = [colors[i] for i in order]
groups_sorted = [group_map[labels[i]] for i in order]

unique_groups = group_order
group_ids = np.array([unique_groups.index(g) for g in groups_sorted])

# Initial positions in spiral pattern for tighter convergence
angles = np.linspace(0, 4 * np.pi, n)
spiral_r = np.linspace(0, 3, n)
positions = np.column_stack([spiral_r * np.cos(angles), spiral_r * np.sin(angles)])

# Physics simulation with group-aware clustering
for iteration in range(500):
    progress = iteration / 500
    pull_strength = 0.06 * (1 - progress * 0.8)
    group_pull = 0.04 * (1 - progress * 0.5)

    group_centers = {}
    for gid in range(len(unique_groups)):
        mask = group_ids == gid
        if np.any(mask):
            group_centers[gid] = positions[mask].mean(axis=0)

    for i in range(n):
        dist = np.linalg.norm(positions[i])
        if dist > 0.01:
            positions[i] -= pull_strength * positions[i] / dist
        gc = group_centers[group_ids[i]]
        to_group = gc - positions[i]
        gd = np.linalg.norm(to_group)
        if gd > 0.01:
            positions[i] += group_pull * to_group / gd

    for i in range(n):
        for j in range(i + 1, n):
            delta = positions[j] - positions[i]
            dist = np.linalg.norm(delta)
            same_group = group_ids[i] == group_ids[j]
            gap = 0.06 if same_group else 0.20
            min_dist = radii_sorted[i] + radii_sorted[j] + gap
            if dist < min_dist and dist > 0.001:
                overlap = (min_dist - dist) / 2
                direction = delta / dist
                positions[i] -= overlap * direction
                positions[j] += overlap * direction

# Center the layout
bbox_min = positions.min(axis=0) - radii_sorted.max()
bbox_max = positions.max(axis=0) + radii_sorted.max()
positions -= (bbox_min + bbox_max) / 2

# Plot — square canvas for symmetric bubble chart (2400×2400 px at 400 dpi)
fig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

# Draw circles with PatchCollection for efficient batch rendering
circles = [mpatches.Circle((positions[i, 0], positions[i, 1]), radii_sorted[i]) for i in range(n)]
collection = mcoll.PatchCollection(
    circles, facecolors=colors_sorted, edgecolors=PAGE_BG, linewidths=2.0, alpha=0.90, zorder=2
)
ax.add_collection(collection)

# Labels inside circles (if large enough)
small_circles = []
for i in range(n):
    label_chars = len(labels_sorted[i])
    min_r_for_label = 0.48 + label_chars * 0.018
    if radii_sorted[i] > min_r_for_label:
        font_scale = min(1.0, radii_sorted[i] / 1.6)
        label_fontsize = max(9, int(11 * font_scale))
        value_fontsize = max(8, int(9 * font_scale))

        # Contrast-appropriate text color (WCAG relative luminance)
        bg_hex = colors_sorted[i]
        rgb = [int(bg_hex[j : j + 2], 16) / 255 for j in (1, 3, 5)]
        luminance = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
        text_color = "#1A1A17" if luminance > 0.35 else "#F0EFE8"
        # RGBA tuples — portable across matplotlib versions
        stroke_fg = (0, 0, 0, 0.15) if luminance > 0.35 else (1, 1, 1, 0.15)
        # linewidth=1.0 at 400 dpi ≈ 6 px — crisp without garish stroke
        stroke = pe.withStroke(linewidth=1.0, foreground=stroke_fg)

        # Wrap multi-word labels to reduce horizontal text extent within the circle
        words = labels_sorted[i].split(" ")
        if len(words) > 1:
            display_label = "\n".join(words)
            label_y_offset = 0.05
            value_y_offset = -0.35
        else:
            display_label = labels_sorted[i]
            label_y_offset = 0.12
            value_y_offset = -0.22

        ax.text(
            positions[i, 0],
            positions[i, 1] + radii_sorted[i] * label_y_offset,
            display_label,
            ha="center",
            va="center",
            fontsize=label_fontsize,
            fontweight="bold",
            color=text_color,
            path_effects=[stroke],
            zorder=3,
        )
        ax.text(
            positions[i, 0],
            positions[i, 1] + radii_sorted[i] * value_y_offset,
            f"${values_sorted[i]}K",
            ha="center",
            va="center",
            fontsize=value_fontsize,
            color=text_color,
            alpha=0.85,
            path_effects=[stroke],
            zorder=3,
        )
    else:
        small_circles.append(i)

# External labels with leader lines for small circles
# Scan 16 candidate angles and pick the direction with maximum clearance from
# all other circle edges — avoids the angle-from-origin pitfall where a small
# circle near the cluster centre gets a label that points into a large neighbor.
for i in small_circles:
    cx, cy = positions[i, 0], positions[i, 1]
    r = radii_sorted[i]
    offset_dist = r + 0.65
    best_angle = np.arctan2(cy, cx)
    best_clearance = -np.inf
    for test_angle in np.linspace(0, 2 * np.pi, 16, endpoint=False):
        lx = cx + offset_dist * np.cos(test_angle)
        ly = cy + offset_dist * np.sin(test_angle)
        clearance = min(
            np.sqrt((lx - positions[j, 0]) ** 2 + (ly - positions[j, 1]) ** 2) - radii_sorted[j]
            for j in range(n)
            if j != i
        )
        if clearance > best_clearance:
            best_clearance = clearance
            best_angle = test_angle
    angle = best_angle
    lx = cx + offset_dist * np.cos(angle)
    ly = cy + offset_dist * np.sin(angle)
    ax.annotate(
        f"{labels_sorted[i]}\n${values_sorted[i]}K",
        xy=(cx + r * np.cos(angle), cy + r * np.sin(angle)),
        xytext=(lx, ly),
        fontsize=9,
        fontweight="bold",
        color=INK_SOFT,
        ha="center",
        va="center",
        arrowprops={"arrowstyle": "-", "color": INK_MUTED, "lw": 1.2, "shrinkA": 4, "shrinkB": 0},
        zorder=4,
    )

# Symmetric axis limits centered at origin
all_x, all_y = positions[:, 0], positions[:, 1]
max_r = radii_sorted.max()
half_extent = max(all_x.max() - all_x.min(), all_y.max() - all_y.min()) / 2 + max_r + 0.75
ax.set_xlim(-half_extent, half_extent)
ax.set_ylim(-half_extent, half_extent)
ax.set_aspect("equal")
ax.axis("off")

# Title
title = "bubble-packed · python · matplotlib · anyplot.ai"
title_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK, pad=12)

# Legend for group colors with theme-adaptive frame
legend_handles = [
    mpatches.Patch(facecolor=color, edgecolor=PAGE_BG, linewidth=1.5, label=group)
    for group, color in group_colors.items()
]
leg = ax.legend(
    handles=legend_handles,
    loc="lower right",
    fontsize=9,
    fancybox=False,
    borderpad=0.8,
    handlelength=1.5,
    handleheight=1.2,
)
leg.get_frame().set_facecolor(ELEVATED_BG)
leg.get_frame().set_edgecolor(INK_SOFT)
plt.setp(leg.get_texts(), color=INK_SOFT)

fig.subplots_adjust(left=0.02, right=0.98, top=0.91, bottom=0.02)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Part of Basic Packed Bubble Chart on anyplot.ai.

Other implementations