Basic Packed Bubble Chart — Pygal

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 Pygal

Python source (Pygal)

""" anyplot.ai
bubble-packed: Basic Packed Bubble Chart
Library: pygal 3.1.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-29
"""

import math
import os
import sys


# Prevent self-import: script file 'pygal.py' would shadow the 'pygal' package
_self_dir = os.path.abspath(os.path.dirname(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p or ".") != _self_dir]
del _self_dir

import pygal
from pygal.etree import etree
from pygal.style import Style


# 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 categorical palette — positions 1–4 mapped to the four groups
IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314")
GROUP_COLORS = {
    "Technology": "#009E73",  # brand green — position 1
    "Marketing": "#C475FD",  # lavender — position 2
    "Operations": "#4467A3",  # blue — position 3
    "Sales": "#BD8233",  # ochre — position 4
}
# In-bubble text: fixed per group fill (bubble fill is theme-independent)
GROUP_TEXT_COLOR = {
    "Technology": "white",
    "Marketing": "#1A1A17",  # lavender is too light for white text
    "Operations": "white",
    "Sales": "white",
}
GROUP_NAMES = ["Technology", "Marketing", "Operations", "Sales"]

WIDTH = 3200
HEIGHT = 1800
PADDING = 10  # gap between packed circles in pixels
FONT_FAMILY = "'Trebuchet MS', 'Lucida Grande', sans-serif"

# Department budget allocation ($K) — varied group sizes to show chart flexibility
data = [
    {"label": "Software Dev", "value": 480, "group": "Technology"},
    {"label": "Cloud Infra", "value": 290, "group": "Technology"},
    {"label": "Data Analytics", "value": 185, "group": "Technology"},
    {"label": "Cybersecurity", "value": 140, "group": "Technology"},
    {"label": "AI Research", "value": 95, "group": "Technology"},
    {"label": "Digital Marketing", "value": 360, "group": "Marketing"},
    {"label": "Brand & Creative", "value": 210, "group": "Marketing"},
    {"label": "Events", "value": 130, "group": "Marketing"},
    {"label": "Facilities", "value": 270, "group": "Operations"},
    {"label": "HR & Recruiting", "value": 195, "group": "Operations"},
    {"label": "Legal", "value": 155, "group": "Operations"},
    {"label": "Admin", "value": 105, "group": "Operations"},
    {"label": "Enterprise", "value": 390, "group": "Sales"},
    {"label": "SMB", "value": 240, "group": "Sales"},
    {"label": "Partnerships", "value": 175, "group": "Sales"},
]

# Compute group totals for legend labels and sort order
group_totals = {}
for item in data:
    group_totals[item["group"]] = group_totals.get(item["group"], 0) + item["value"]

# Scale values to radii (sqrt ensures area-based visual perception)
max_val = max(item["value"] for item in data)
max_radius = min(WIDTH, HEIGHT) * 0.11  # 198 px for 1800 px height

circles = []
for item in data:
    r = math.sqrt(item["value"] / max_val) * max_radius
    circles.append({"r": r, "item": item, "x": 0.0, "y": 0.0})

# Sort: largest-total group first, then descending radius within each group
sorted_groups = sorted(GROUP_NAMES, key=lambda g: -group_totals[g])
group_order = {g: i for i, g in enumerate(sorted_groups)}
circles.sort(key=lambda c: (group_order[c["item"]["group"]], -c["r"]))

cx, cy = WIDTH / 2, HEIGHT / 2
circles[0]["x"] = cx
circles[0]["y"] = cy
placed = [circles[0]]

# Greedy packing with group-affinity clustering
for circle in circles[1:]:
    best_pos = None
    best_score = float("inf")
    same_group = [p for p in placed if p["item"]["group"] == circle["item"]["group"]]

    for existing in placed:
        for angle_deg in range(0, 360, 6):
            angle = math.radians(angle_deg)
            dist = existing["r"] + circle["r"] + PADDING
            nx = existing["x"] + math.cos(angle) * dist
            ny = existing["y"] + math.sin(angle) * dist

            valid = True
            for other in placed:
                ddx = nx - other["x"]
                ddy = ny - other["y"]
                min_gap = circle["r"] + other["r"] + PADDING * 0.5
                if math.sqrt(ddx * ddx + ddy * ddy) < min_gap:
                    valid = False
                    break

            if valid:
                d_center = math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)
                if same_group:
                    d_group = sum(math.sqrt((nx - p["x"]) ** 2 + (ny - p["y"]) ** 2) for p in same_group) / len(
                        same_group
                    )
                    score = d_center * 0.3 + d_group * 0.7
                else:
                    score = d_center

                if score < best_score:
                    best_score = score
                    best_pos = (nx, ny)

    if best_pos:
        circle["x"], circle["y"] = best_pos
    else:
        circle["x"] = cx
        circle["y"] = max(c["y"] + c["r"] for c in placed) + circle["r"] + PADDING

    placed.append(circle)

# Recenter using area-weighted centroid to fill the chart's content zone
avail_top = 140  # below title area
avail_bottom = HEIGHT - 200  # above legend area
target_cy = (avail_top + avail_bottom) / 2
target_cx = WIDTH / 2

total_area = sum(c["r"] ** 2 for c in placed)
weighted_cx = sum(c["x"] * c["r"] ** 2 for c in placed) / total_area
weighted_cy = sum(c["y"] * c["r"] ** 2 for c in placed) / total_area
dx = target_cx - weighted_cx
dy = target_cy - weighted_cy

for c in placed:
    c["x"] += dx
    c["y"] += dy

# Gather per-group centroid and extent data for label and boundary placement
group_info = {}
for c in placed:
    g = c["item"]["group"]
    if g not in group_info:
        group_info[g] = {"xs": [], "ys": [], "rs": []}
    group_info[g]["xs"].append(c["x"])
    group_info[g]["ys"].append(c["y"])
    group_info[g]["rs"].append(c["r"])

packed = [(c["x"], c["y"], c["r"], c["item"]) for c in placed]

# Title length check for font scaling (no shrink needed for 43-char title)
title_str = "bubble-packed · python · pygal · anyplot.ai"
n_chars = len(title_str)
title_fs = round(66 * 67 / n_chars) if n_chars > 67 else 66

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT_PALETTE,
    font_family=FONT_FAMILY,
    title_font_size=title_fs,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
)

chart = pygal.Pie(
    width=WIDTH,
    height=HEIGHT,
    style=custom_style,
    title=title_str,
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=4,
    legend_box_size=28,
    inner_radius=0,
    margin=80,
    no_data_text="",
    tooltip_fancy_mode=True,
    pretty_print=True,
    truncate_legend=-1,
)

for group in GROUP_NAMES:
    chart.add(f"{group}: ${group_totals[group]:,}K", [])


def add_packed_bubbles(root):
    def _text(parent, x, y, label, size, color, bold=False):
        t = etree.SubElement(parent, "text")
        t.set("x", f"{x:.0f}")
        t.set("y", f"{y:.0f}")
        t.set("text-anchor", "middle")
        t.set("dominant-baseline", "middle")
        t.set("fill", color)
        t.set("font-size", f"{size}")
        t.set("font-family", FONT_FAMILY)
        if bold:
            t.set("font-weight", "bold")
        t.text = label

    # Radial gradients for polished 3D bubble appearance
    defs = etree.SubElement(root, "defs")
    for gname, color in GROUP_COLORS.items():
        grad = etree.SubElement(defs, "radialGradient")
        grad.set("id", f"grad-{gname.lower()}")
        grad.set("cx", "35%")
        grad.set("cy", "35%")
        grad.set("r", "65%")
        rgb = [int(color[i : i + 2], 16) for i in (1, 3, 5)]
        light = [min(255, c + 60) for c in rgb]
        stop1 = etree.SubElement(grad, "stop")
        stop1.set("offset", "0%")
        stop1.set("stop-color", f"#{light[0]:02x}{light[1]:02x}{light[2]:02x}")
        stop1.set("stop-opacity", "0.95")
        stop2 = etree.SubElement(grad, "stop")
        stop2.set("offset", "100%")
        stop2.set("stop-color", color)
        stop2.set("stop-opacity", "0.90")

    g = etree.SubElement(root, "g")
    g.set("class", "packed-bubbles")

    overall_cy = sum(c[1] for c in packed) / len(packed)

    # Subtle dashed boundary circles to visually group related items
    for gname, gdata in group_info.items():
        gcx = sum(gdata["xs"]) / len(gdata["xs"])
        gcy = sum(gdata["ys"]) / len(gdata["ys"])
        extent = max(
            math.sqrt((x - gcx) ** 2 + (y - gcy) ** 2) + r
            for x, y, r in zip(gdata["xs"], gdata["ys"], gdata["rs"], strict=True)
        )
        bg_circ = etree.SubElement(g, "circle")
        bg_circ.set("cx", f"{gcx:.0f}")
        bg_circ.set("cy", f"{gcy:.0f}")
        bg_circ.set("r", f"{extent + 18:.0f}")
        bg_circ.set("fill", GROUP_COLORS[gname])
        bg_circ.set("fill-opacity", "0.05")
        bg_circ.set("stroke", GROUP_COLORS[gname])
        bg_circ.set("stroke-opacity", "0.18")
        bg_circ.set("stroke-width", "2")
        bg_circ.set("stroke-dasharray", "12,8")

    # Data circles with gradient fills and SVG tooltips
    for x, y, r, item in packed:
        grad_id = f"grad-{item['group'].lower()}"
        circ = etree.SubElement(g, "circle")
        circ.set("cx", f"{x:.1f}")
        circ.set("cy", f"{y:.1f}")
        circ.set("r", f"{r:.1f}")
        circ.set("fill", f"url(#{grad_id})")
        circ.set("stroke", PAGE_BG)  # theme-adaptive gap between adjacent bubbles
        circ.set("stroke-width", "4")
        tooltip = etree.SubElement(circ, "title")
        tooltip.text = f"{item['label']}: ${item['value']}K ({item['group']})"

    # Highlight ring on the largest bubble for visual hierarchy
    top = max(packed, key=lambda c: c[2])
    ring = etree.SubElement(g, "circle")
    ring.set("cx", f"{top[0]:.1f}")
    ring.set("cy", f"{top[1]:.1f}")
    ring.set("r", f"{top[2] + 7:.1f}")
    ring.set("fill", "none")
    ring.set("stroke", GROUP_COLORS[top[3]["group"]])
    ring.set("stroke-width", "3")
    ring.set("stroke-opacity", "0.45")
    ring.set("stroke-dasharray", "8,5")

    # Circle labels: first-word name + value for larger bubbles, value-only for smaller
    for x, y, r, item in packed:
        text_color = GROUP_TEXT_COLOR[item["group"]]
        if r > 110:
            fs = max(int(r * 0.22), 26)
            name = item["label"].split()[0]
            _text(g, x, y - fs * 0.55, name, fs, text_color, bold=True)
            _text(g, x, y + fs * 0.65, f"${item['value']}K", int(fs * 0.82), text_color)
        else:
            fs = max(int(r * 0.28), 24)
            _text(g, x, y, f"${item['value']}K", fs, text_color, bold=True)

    # Group labels: placed above or below each cluster with generous clearance
    for gname, gdata in group_info.items():
        gcx = sum(gdata["xs"]) / len(gdata["xs"])
        gcy = sum(gdata["ys"]) / len(gdata["ys"])

        if gcy < overall_cy:
            label_y = min(y - r for y, r in zip(gdata["ys"], gdata["rs"], strict=True)) - 70
            label_y = max(label_y, avail_top + 30)
        else:
            label_y = max(y + r for y, r in zip(gdata["ys"], gdata["rs"], strict=True)) + 90
            label_y = min(label_y, avail_bottom - 30)

        lbl = etree.SubElement(g, "text")
        lbl.set("x", f"{gcx:.0f}")
        lbl.set("y", f"{label_y:.0f}")
        lbl.set("text-anchor", "middle")
        lbl.set("fill", GROUP_COLORS[gname])
        lbl.set("font-size", "38")
        lbl.set("font-family", FONT_FAMILY)
        lbl.set("font-weight", "bold")
        lbl.set("letter-spacing", "1.5")
        lbl.text = f"{gname}: ${group_totals[gname]:,}K"

    return root


chart.add_xml_filter(add_packed_bubbles)

chart.render_to_png(f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

Part of Basic Packed Bubble Chart on anyplot.ai.

Other implementations