A circle packing chart displays hierarchical data as nested circles, where each circle contains smaller circles representing its children. Circle size is proportional to node value, and circles are packed efficiently without overlap. This visualization excels at revealing hierarchical structures while simultaneously showing quantitative relationships through area encoding.

""" anyplot.ai
circlepacking-basic: Circle Packing Chart
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-11
"""
import math
import os
import numpy as np
import pandas as pd
from lets_plot import (
LetsPlot,
aes,
coord_fixed,
element_blank,
element_rect,
element_text,
geom_polygon,
geom_text,
ggplot,
ggsize,
labs,
scale_fill_manual,
scale_x_continuous,
scale_y_continuous,
theme,
)
from lets_plot.export import ggsave
LetsPlot.setup_html()
# Theme tokens
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"
# Okabe-Ito palette
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477"]
# Hierarchical data - File system storage breakdown (GB)
np.random.seed(42)
hierarchy = [
# Level 1: Main folders
{"id": "Documents", "parent": "root", "value": None, "label": "Documents"},
{"id": "Media", "parent": "root", "value": None, "label": "Media"},
{"id": "Code", "parent": "root", "value": None, "label": "Code"},
# Level 2: Subfolders under Documents
{"id": "Work", "parent": "Documents", "value": 25, "label": "Work"},
{"id": "Personal", "parent": "Documents", "value": 18, "label": "Personal"},
{"id": "Archive", "parent": "Documents", "value": 12, "label": "Archive"},
# Level 2: Subfolders under Media
{"id": "Photos", "parent": "Media", "value": 45, "label": "Photos"},
{"id": "Videos", "parent": "Media", "value": 65, "label": "Videos"},
{"id": "Music", "parent": "Media", "value": 22, "label": "Music"},
# Level 2: Subfolders under Code
{"id": "Projects", "parent": "Code", "value": 35, "label": "Projects"},
{"id": "Libraries", "parent": "Code", "value": 15, "label": "Libraries"},
{"id": "Backups", "parent": "Code", "value": 8, "label": "Backups"},
]
# Data preparation
df = pd.DataFrame(hierarchy)
for idx, row in df[df["value"].isna()].iterrows():
children = df[df["parent"] == row["id"]]
df.loc[idx, "value"] = children["value"].sum()
root_radius = 90
root_cx, root_cy = 0, 0
total_value = df[df["parent"] == "root"]["value"].sum()
polygon_rows = []
label_rows = []
circle_id = 0
# Root circle
angles = np.linspace(0, 2 * np.pi, 60, endpoint=False)
x_root = root_cx + root_radius * np.cos(angles)
y_root = root_cy + root_radius * np.sin(angles)
for x, y in zip(x_root, y_root, strict=True):
polygon_rows.append({"x": x, "y": y, "circle_id": circle_id, "depth": 0, "color": "root"})
circle_id += 1
# Level 1 circles
level1 = df[df["parent"] == "root"].copy()
level1["radius"] = np.sqrt(level1["value"] / total_value) * root_radius * 0.95
level1 = level1.sort_values("radius", ascending=False).reset_index(drop=True)
# Pack level 1 circles with force simulation
level1_radii = level1["radius"].tolist()
n = len(level1_radii)
radii = np.array(level1_radii)
angles_init = np.linspace(0, 2 * np.pi, n, endpoint=False)
level1_x = root_cx + root_radius * 0.4 * np.cos(angles_init)
level1_y = root_cy + root_radius * 0.4 * np.sin(angles_init)
for _iteration in range(800):
# Repulsion between pairs
for i in range(n):
for j in range(i + 1, n):
dx = level1_x[j] - level1_x[i]
dy = level1_y[j] - level1_y[i]
dist = math.sqrt(dx * dx + dy * dy)
min_dist = radii[i] + radii[j] + 1.0
if dist < min_dist and dist > 0.001:
overlap = (min_dist - dist) / 2
norm_x = dx / dist
norm_y = dy / dist
level1_x[i] -= norm_x * overlap * 0.5
level1_y[i] -= norm_y * overlap * 0.5
level1_x[j] += norm_x * overlap * 0.5
level1_y[j] += norm_y * overlap * 0.5
# Boundary constraint
for i in range(n):
dx = level1_x[i] - root_cx
dy = level1_y[i] - root_cy
dist = math.sqrt(dx * dx + dy * dy)
max_dist = root_radius - radii[i] - 1.0
if dist > max_dist and dist > 0.001:
scale = max_dist / dist
level1_x[i] = root_cx + dx * scale
level1_y[i] = root_cy + dy * scale
# Gentle center attraction
level1_x = root_cx + (level1_x - root_cx) * 0.998
level1_y = root_cy + (level1_y - root_cy) * 0.998
# Draw level 1 circles
level1_positions = {}
for i, (_, row) in enumerate(level1.iterrows()):
level1_positions[row["id"]] = {"x": level1_x[i], "y": level1_y[i], "radius": row["radius"]}
# Circle polygon
angles = np.linspace(0, 2 * np.pi, 60, endpoint=False)
x_circ = level1_x[i] + row["radius"] * np.cos(angles)
y_circ = level1_y[i] + row["radius"] * np.sin(angles)
for x, y in zip(x_circ, y_circ, strict=True):
polygon_rows.append({"x": x, "y": y, "circle_id": circle_id, "depth": 1, "color": row["id"]})
# Label
label_rows.append(
{"x": level1_x[i], "y": level1_y[i] + row["radius"] * 0.65, "label": row["label"], "depth": 1, "size": 14}
)
circle_id += 1
# Level 2 circles
for parent_id, pos in level1_positions.items():
children = df[df["parent"] == parent_id].copy()
if children.empty:
continue
parent_value = children["value"].sum()
children["radius"] = np.sqrt(children["value"] / parent_value) * pos["radius"] * 0.75
children = children.sort_values("radius", ascending=False).reset_index(drop=True)
# Pack children with force simulation
children_radii = children["radius"].tolist()
n_c = len(children_radii)
radii_c = np.array(children_radii)
angles_init = np.linspace(0, 2 * np.pi, n_c, endpoint=False)
children_x = pos["x"] + pos["radius"] * 0.4 * np.cos(angles_init)
children_y = pos["y"] + pos["radius"] * 0.4 * np.sin(angles_init)
for _iteration in range(600):
for i in range(n_c):
for j in range(i + 1, n_c):
dx = children_x[j] - children_x[i]
dy = children_y[j] - children_y[i]
dist = math.sqrt(dx * dx + dy * dy)
min_dist = radii_c[i] + radii_c[j] + 1.0
if dist < min_dist and dist > 0.001:
overlap = (min_dist - dist) / 2
norm_x = dx / dist
norm_y = dy / dist
children_x[i] -= norm_x * overlap * 0.5
children_y[i] -= norm_y * overlap * 0.5
children_x[j] += norm_x * overlap * 0.5
children_y[j] += norm_y * overlap * 0.5
for i in range(n_c):
dx = children_x[i] - pos["x"]
dy = children_y[i] - pos["y"]
dist = math.sqrt(dx * dx + dy * dy)
max_dist = pos["radius"] * 0.92 - radii_c[i] - 1.0
if dist > max_dist and dist > 0.001:
scale = max_dist / dist
children_x[i] = pos["x"] + dx * scale
children_y[i] = pos["y"] + dy * scale
children_x = pos["x"] + (children_x - pos["x"]) * 0.998
children_y = pos["y"] + (children_y - pos["y"]) * 0.998
# Draw children circles
for i, (_, row) in enumerate(children.iterrows()):
angles = np.linspace(0, 2 * np.pi, 60, endpoint=False)
x_circ = children_x[i] + row["radius"] * np.cos(angles)
y_circ = children_y[i] + row["radius"] * np.sin(angles)
for x, y in zip(x_circ, y_circ, strict=True):
polygon_rows.append({"x": x, "y": y, "circle_id": circle_id, "depth": 2, "color": row["id"]})
# Label (if circle large enough)
if row["radius"] > 6:
label_rows.append({"x": children_x[i], "y": children_y[i], "label": row["label"], "depth": 2, "size": 11})
circle_id += 1
polygon_df = pd.DataFrame(polygon_rows)
label_df = pd.DataFrame(label_rows)
# Color mapping: depth-based with Okabe-Ito
color_map = {
"root": INK_SOFT,
"Documents": IMPRINT[0],
"Media": IMPRINT[1],
"Code": IMPRINT[2],
"Work": IMPRINT[0],
"Personal": IMPRINT[0],
"Archive": IMPRINT[0],
"Photos": IMPRINT[1],
"Videos": IMPRINT[1],
"Music": IMPRINT[1],
"Projects": IMPRINT[2],
"Libraries": IMPRINT[2],
"Backups": IMPRINT[2],
}
unique_colors = polygon_df["color"].unique()
color_values = [color_map.get(c, INK_SOFT) for c in unique_colors]
# Plot
plot = (
ggplot(polygon_df)
+ geom_polygon(aes(x="x", y="y", fill="color", group="circle_id"), color="white", size=0.8, alpha=0.92)
+ geom_text(
aes(x="x", y="y", label="label"), data=label_df[label_df["depth"] == 1], size=14, color="white", fontface="bold"
)
+ geom_text(aes(x="x", y="y", label="label"), data=label_df[label_df["depth"] == 2], size=11, color=INK)
+ scale_fill_manual(values=color_values)
+ coord_fixed(ratio=1)
+ scale_x_continuous(limits=(-105, 105))
+ scale_y_continuous(limits=(-105, 105))
+ labs(title="circlepacking-basic · letsplot · anyplot.ai")
+ ggsize(1200, 1200)
+ theme(
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
plot_title=element_text(size=24, hjust=0.5, color=INK),
legend_position="none",
axis_title=element_blank(),
axis_text=element_blank(),
axis_ticks=element_blank(),
axis_line=element_blank(),
panel_grid=element_blank(),
)
)
# Save
ggsave(plot, f"plot-{THEME}.png", path=".", scale=3)
ggsave(plot, f"plot-{THEME}.html", path=".")
Part of Circle Packing Chart on anyplot.ai.