Circle Packing Chart — Plotly

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.

Circle Packing Chart rendered with Plotly

Python source (Plotly)

""" anyplot.ai
circlepacking-basic: Circle Packing Chart
Library: plotly 6.7.0 | Python 3.13.13
Quality: 94/100 | Updated: 2026-05-11
"""

import os

import numpy as np
import plotly.graph_objects as go


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"
GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"

IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477"]

np.random.seed(42)

# Data: Investment portfolio hierarchy by asset class and holdings
portfolio = {
    "Equities": {
        "US Large Cap": {"AAPL": 45000, "MSFT": 38000, "JPM": 32000},
        "International": {"ASML": 28000, "TSM": 35000, "SAP": 22000},
        "Emerging Markets": {"BABA": 18000, "TCEHY": 15000},
    },
    "Fixed Income": {
        "Government Bonds": {"US 10Y": 50000, "DE Bund": 35000},
        "Corporate Bonds": {"AAA": 42000, "BBB": 28000, "High Yield": 18000},
    },
    "Real Estate": {
        "REITs": {"Industrial": 22000, "Residential": 28000, "Commercial": 18000},
        "Direct": {"Property A": 65000, "Property B": 55000},
    },
    "Alternatives": {
        "Commodities": {"Gold": 20000, "Oil Futures": 15000},
        "Private Equity": {"Fund 1": 40000, "Fund 2": 35000},
    },
}

# Calculate total portfolio value
total_value = sum(sum(sum(items.values()) for items in subcats.values()) for subcats in portfolio.values())

# Build circles with packing algorithm
all_circles = []

# Root circle
root_radius = 450
all_circles.append(
    {"x": 0, "y": 0, "r": root_radius, "label": "Portfolio", "value": total_value, "color": "#808080", "level": -1}
)

# Calculate asset class data and sort by value
asset_data = []
for asset_class, subcats in portfolio.items():
    total = sum(sum(items.values()) for items in subcats.values())
    asset_data.append((asset_class, total, subcats))
asset_data.sort(key=lambda x: x[1], reverse=True)

# Calculate asset class radii
asset_radii = [(name, np.sqrt(total) * 7.0, total, subcats) for name, total, subcats in asset_data]

# Pack asset classes
packed_assets = []
for i, (name, radius, value, subcats) in enumerate(asset_radii):
    if i == 0:
        packed_assets.append(
            {"name": name, "r": radius, "value": value, "subcats": subcats, "x": 0, "y": radius * 0.35}
        )
    else:
        best_pos = None
        min_dist_from_center = float("inf")
        for existing in packed_assets:
            for angle in np.linspace(0, 2 * np.pi, 36):
                dist = existing["r"] + radius + 12
                nx = existing["x"] + dist * np.cos(angle)
                ny = existing["y"] + dist * np.sin(angle)
                d_to_center = np.sqrt(nx**2 + ny**2)
                if d_to_center + radius > root_radius * 0.93:
                    continue
                overlaps = False
                for other in packed_assets:
                    d = np.sqrt((nx - other["x"]) ** 2 + (ny - other["y"]) ** 2)
                    if d < other["r"] + radius + 10:
                        overlaps = True
                        break
                if not overlaps and d_to_center < min_dist_from_center:
                    min_dist_from_center = d_to_center
                    best_pos = (nx, ny)
        if best_pos:
            packed_assets.append(
                {"name": name, "r": radius, "value": value, "subcats": subcats, "x": best_pos[0], "y": best_pos[1]}
            )

# Build hierarchy with subcategories and leaf holdings
for idx, asset_info in enumerate(packed_assets):
    asset_class = asset_info["name"]
    cx, cy = asset_info["x"], asset_info["y"]
    asset_radius = asset_info["r"]
    subcats = asset_info["subcats"]
    asset_color = IMPRINT[idx % len(IMPRINT)]

    all_circles.append(
        {
            "x": cx,
            "y": cy,
            "r": asset_radius,
            "label": asset_class,
            "value": asset_info["value"],
            "color": asset_color,
            "level": 0,
        }
    )

    # Pack subcategories within asset class
    subcat_list = sorted(
        [(name, sum(items.values()), items) for name, items in subcats.items()], key=lambda x: x[1], reverse=True
    )
    packed_subs = []
    sub_scale = 3.5

    for j, (sub_name, sub_value, sub_items) in enumerate(subcat_list):
        sub_r = np.sqrt(sub_value) * sub_scale
        if j == 0:
            packed_subs.append({"name": sub_name, "value": sub_value, "items": sub_items, "r": sub_r, "x": cx, "y": cy})
        else:
            best_pos = None
            min_dist = float("inf")
            for existing in packed_subs:
                for angle in np.linspace(0, 2 * np.pi, 24):
                    dist = existing["r"] + sub_r + 5
                    nx = existing["x"] + dist * np.cos(angle)
                    ny = existing["y"] + dist * np.sin(angle)
                    d_to_parent = np.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)
                    if d_to_parent + sub_r > asset_radius * 0.87:
                        continue
                    overlaps = False
                    for other in packed_subs:
                        d = np.sqrt((nx - other["x"]) ** 2 + (ny - other["y"]) ** 2)
                        if d < other["r"] + sub_r + 4:
                            overlaps = True
                            break
                    if not overlaps:
                        d_center = np.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)
                        if d_center < min_dist:
                            min_dist = d_center
                            best_pos = (nx, ny)
            if best_pos:
                packed_subs.append(
                    {
                        "name": sub_name,
                        "value": sub_value,
                        "items": sub_items,
                        "r": sub_r,
                        "x": best_pos[0],
                        "y": best_pos[1],
                    }
                )

    for sub in packed_subs:
        sub_x, sub_y, sub_r = sub["x"], sub["y"], sub["r"]
        sub_color = asset_color
        all_circles.append(
            {
                "x": sub_x,
                "y": sub_y,
                "r": sub_r,
                "label": sub["name"],
                "value": sub["value"],
                "color": sub_color,
                "level": 1,
                "parent": asset_class,
            }
        )

        # Pack leaf nodes (holdings) within subcategory
        leaf_list = sorted(sub["items"].items(), key=lambda x: x[1], reverse=True)
        packed_leaves = []
        leaf_scale = 2.0

        for k, (leaf_name, leaf_value) in enumerate(leaf_list):
            leaf_r = np.sqrt(leaf_value) * leaf_scale
            if k == 0:
                packed_leaves.append({"name": leaf_name, "value": leaf_value, "r": leaf_r, "x": sub_x, "y": sub_y})
            else:
                best_pos = None
                min_dist = float("inf")
                for existing in packed_leaves:
                    for angle in np.linspace(0, 2 * np.pi, 24):
                        dist = existing["r"] + leaf_r + 2
                        nx = existing["x"] + dist * np.cos(angle)
                        ny = existing["y"] + dist * np.sin(angle)
                        d_to_parent = np.sqrt((nx - sub_x) ** 2 + (ny - sub_y) ** 2)
                        if d_to_parent + leaf_r > sub_r * 0.84:
                            continue
                        overlaps = False
                        for other in packed_leaves:
                            d = np.sqrt((nx - other["x"]) ** 2 + (ny - other["y"]) ** 2)
                            if d < other["r"] + leaf_r + 1:
                                overlaps = True
                                break
                        if not overlaps:
                            d_center = np.sqrt((nx - sub_x) ** 2 + (ny - sub_y) ** 2)
                            if d_center < min_dist:
                                min_dist = d_center
                                best_pos = (nx, ny)
                if best_pos:
                    packed_leaves.append(
                        {"name": leaf_name, "value": leaf_value, "r": leaf_r, "x": best_pos[0], "y": best_pos[1]}
                    )

        for leaf in packed_leaves:
            all_circles.append(
                {
                    "x": leaf["x"],
                    "y": leaf["y"],
                    "r": leaf["r"],
                    "label": leaf["name"],
                    "value": leaf["value"],
                    "color": sub_color,
                    "level": 2,
                    "parent": sub["name"],
                }
            )

# Create figure
fig = go.Figure()

# Draw circles by level (background to foreground)
for level in [-1, 0, 1, 2]:
    for circle in all_circles:
        if circle["level"] == level:
            if level == -1:
                opacity, line_width = 0.08, 4
            elif level == 0:
                opacity, line_width = 0.8, 4
            elif level == 1:
                opacity, line_width = 0.7, 3
            else:
                opacity, line_width = 0.85, 2

            fig.add_shape(
                type="circle",
                xref="x",
                yref="y",
                x0=circle["x"] - circle["r"],
                y0=circle["y"] - circle["r"],
                x1=circle["x"] + circle["r"],
                y1=circle["y"] + circle["r"],
                fillcolor=circle["color"],
                opacity=opacity,
                line={"color": INK_SOFT, "width": line_width},
            )

# Add labels
for circle in all_circles:
    level = circle["level"]

    if level == -1:
        fig.add_annotation(
            x=circle["x"],
            y=circle["y"] - circle["r"] * 0.88,
            text=f"<b>{circle['label']}</b><br>${circle['value']:,.0f}",
            showarrow=False,
            font={"size": 22, "color": INK},
        )
    elif level == 0:
        fig.add_annotation(
            x=circle["x"],
            y=circle["y"] + circle["r"] * 0.7,
            text=f"<b>{circle['label']}</b>",
            showarrow=False,
            font={"size": 18, "color": INK},
        )
        fig.add_annotation(
            x=circle["x"],
            y=circle["y"] + circle["r"] * 0.5,
            text=f"${circle['value']:,.0f}",
            showarrow=False,
            font={"size": 14, "color": INK_SOFT},
        )
    elif level == 1 and circle["r"] > 30:
        fig.add_annotation(
            x=circle["x"], y=circle["y"], text=f"{circle['label']}", showarrow=False, font={"size": 13, "color": INK}
        )
    elif level == 2 and circle["r"] > 12:
        fig.add_annotation(
            x=circle["x"], y=circle["y"], text=circle["label"], showarrow=False, font={"size": 10, "color": INK_SOFT}
        )

# Add hover traces for interactivity
for circle in all_circles:
    level_names = {-1: "Portfolio", 0: "Asset Class", 1: "Category", 2: "Holding"}
    fig.add_trace(
        go.Scatter(
            x=[circle["x"]],
            y=[circle["y"]],
            mode="markers",
            marker={"size": max(circle["r"], 12), "opacity": 0},
            hovertemplate=f"<b>{circle['label']}</b><br>{level_names[circle['level']]}: ${circle['value']:,.0f}<extra></extra>",
            showlegend=False,
        )
    )

# Add legend traces for asset classes
for idx, (name, _, _) in enumerate(asset_data):
    fig.add_trace(
        go.Scatter(
            x=[None],
            y=[None],
            mode="markers",
            marker={"size": 16, "color": IMPRINT[idx % len(IMPRINT)], "line": {"color": INK_SOFT, "width": 2}},
            name=name,
            showlegend=True,
        )
    )

# Layout
fig.update_layout(
    title={
        "text": "circlepacking-basic · plotly · anyplot.ai",
        "font": {"size": 28, "color": INK},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.97,
    },
    xaxis={
        "showgrid": False,
        "zeroline": False,
        "showticklabels": False,
        "range": [-550, 550],
        "scaleanchor": "y",
        "scaleratio": 1,
    },
    yaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "range": [-550, 550]},
    plot_bgcolor=PAGE_BG,
    paper_bgcolor=PAGE_BG,
    margin={"t": 80, "l": 40, "r": 40, "b": 40},
    showlegend=True,
    legend={
        "x": 0.98,
        "y": 0.98,
        "xanchor": "right",
        "yanchor": "top",
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 2,
        "font": {"size": 14, "color": INK_SOFT},
        "title": {"text": "Asset Classes", "font": {"size": 16, "color": INK}},
    },
)

# Save outputs
fig.write_image(f"plot-{THEME}.png", width=1600, height=900, scale=3)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Circle Packing Chart on anyplot.ai.

Other implementations