{"spec_id":"circlepacking-basic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\ncirclepacking-basic: Circle Packing Chart\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 94/100 | Updated: 2026-05-11\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nGRID = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\"]\n\nnp.random.seed(42)\n\n# Data: Investment portfolio hierarchy by asset class and holdings\nportfolio = {\n    \"Equities\": {\n        \"US Large Cap\": {\"AAPL\": 45000, \"MSFT\": 38000, \"JPM\": 32000},\n        \"International\": {\"ASML\": 28000, \"TSM\": 35000, \"SAP\": 22000},\n        \"Emerging Markets\": {\"BABA\": 18000, \"TCEHY\": 15000},\n    },\n    \"Fixed Income\": {\n        \"Government Bonds\": {\"US 10Y\": 50000, \"DE Bund\": 35000},\n        \"Corporate Bonds\": {\"AAA\": 42000, \"BBB\": 28000, \"High Yield\": 18000},\n    },\n    \"Real Estate\": {\n        \"REITs\": {\"Industrial\": 22000, \"Residential\": 28000, \"Commercial\": 18000},\n        \"Direct\": {\"Property A\": 65000, \"Property B\": 55000},\n    },\n    \"Alternatives\": {\n        \"Commodities\": {\"Gold\": 20000, \"Oil Futures\": 15000},\n        \"Private Equity\": {\"Fund 1\": 40000, \"Fund 2\": 35000},\n    },\n}\n\n# Calculate total portfolio value\ntotal_value = sum(sum(sum(items.values()) for items in subcats.values()) for subcats in portfolio.values())\n\n# Build circles with packing algorithm\nall_circles = []\n\n# Root circle\nroot_radius = 450\nall_circles.append(\n    {\"x\": 0, \"y\": 0, \"r\": root_radius, \"label\": \"Portfolio\", \"value\": total_value, \"color\": \"#808080\", \"level\": -1}\n)\n\n# Calculate asset class data and sort by value\nasset_data = []\nfor asset_class, subcats in portfolio.items():\n    total = sum(sum(items.values()) for items in subcats.values())\n    asset_data.append((asset_class, total, subcats))\nasset_data.sort(key=lambda x: x[1], reverse=True)\n\n# Calculate asset class radii\nasset_radii = [(name, np.sqrt(total) * 7.0, total, subcats) for name, total, subcats in asset_data]\n\n# Pack asset classes\npacked_assets = []\nfor i, (name, radius, value, subcats) in enumerate(asset_radii):\n    if i == 0:\n        packed_assets.append(\n            {\"name\": name, \"r\": radius, \"value\": value, \"subcats\": subcats, \"x\": 0, \"y\": radius * 0.35}\n        )\n    else:\n        best_pos = None\n        min_dist_from_center = float(\"inf\")\n        for existing in packed_assets:\n            for angle in np.linspace(0, 2 * np.pi, 36):\n                dist = existing[\"r\"] + radius + 12\n                nx = existing[\"x\"] + dist * np.cos(angle)\n                ny = existing[\"y\"] + dist * np.sin(angle)\n                d_to_center = np.sqrt(nx**2 + ny**2)\n                if d_to_center + radius > root_radius * 0.93:\n                    continue\n                overlaps = False\n                for other in packed_assets:\n                    d = np.sqrt((nx - other[\"x\"]) ** 2 + (ny - other[\"y\"]) ** 2)\n                    if d < other[\"r\"] + radius + 10:\n                        overlaps = True\n                        break\n                if not overlaps and d_to_center < min_dist_from_center:\n                    min_dist_from_center = d_to_center\n                    best_pos = (nx, ny)\n        if best_pos:\n            packed_assets.append(\n                {\"name\": name, \"r\": radius, \"value\": value, \"subcats\": subcats, \"x\": best_pos[0], \"y\": best_pos[1]}\n            )\n\n# Build hierarchy with subcategories and leaf holdings\nfor idx, asset_info in enumerate(packed_assets):\n    asset_class = asset_info[\"name\"]\n    cx, cy = asset_info[\"x\"], asset_info[\"y\"]\n    asset_radius = asset_info[\"r\"]\n    subcats = asset_info[\"subcats\"]\n    asset_color = IMPRINT[idx % len(IMPRINT)]\n\n    all_circles.append(\n        {\n            \"x\": cx,\n            \"y\": cy,\n            \"r\": asset_radius,\n            \"label\": asset_class,\n            \"value\": asset_info[\"value\"],\n            \"color\": asset_color,\n            \"level\": 0,\n        }\n    )\n\n    # Pack subcategories within asset class\n    subcat_list = sorted(\n        [(name, sum(items.values()), items) for name, items in subcats.items()], key=lambda x: x[1], reverse=True\n    )\n    packed_subs = []\n    sub_scale = 3.5\n\n    for j, (sub_name, sub_value, sub_items) in enumerate(subcat_list):\n        sub_r = np.sqrt(sub_value) * sub_scale\n        if j == 0:\n            packed_subs.append({\"name\": sub_name, \"value\": sub_value, \"items\": sub_items, \"r\": sub_r, \"x\": cx, \"y\": cy})\n        else:\n            best_pos = None\n            min_dist = float(\"inf\")\n            for existing in packed_subs:\n                for angle in np.linspace(0, 2 * np.pi, 24):\n                    dist = existing[\"r\"] + sub_r + 5\n                    nx = existing[\"x\"] + dist * np.cos(angle)\n                    ny = existing[\"y\"] + dist * np.sin(angle)\n                    d_to_parent = np.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)\n                    if d_to_parent + sub_r > asset_radius * 0.87:\n                        continue\n                    overlaps = False\n                    for other in packed_subs:\n                        d = np.sqrt((nx - other[\"x\"]) ** 2 + (ny - other[\"y\"]) ** 2)\n                        if d < other[\"r\"] + sub_r + 4:\n                            overlaps = True\n                            break\n                    if not overlaps:\n                        d_center = np.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)\n                        if d_center < min_dist:\n                            min_dist = d_center\n                            best_pos = (nx, ny)\n            if best_pos:\n                packed_subs.append(\n                    {\n                        \"name\": sub_name,\n                        \"value\": sub_value,\n                        \"items\": sub_items,\n                        \"r\": sub_r,\n                        \"x\": best_pos[0],\n                        \"y\": best_pos[1],\n                    }\n                )\n\n    for sub in packed_subs:\n        sub_x, sub_y, sub_r = sub[\"x\"], sub[\"y\"], sub[\"r\"]\n        sub_color = asset_color\n        all_circles.append(\n            {\n                \"x\": sub_x,\n                \"y\": sub_y,\n                \"r\": sub_r,\n                \"label\": sub[\"name\"],\n                \"value\": sub[\"value\"],\n                \"color\": sub_color,\n                \"level\": 1,\n                \"parent\": asset_class,\n            }\n        )\n\n        # Pack leaf nodes (holdings) within subcategory\n        leaf_list = sorted(sub[\"items\"].items(), key=lambda x: x[1], reverse=True)\n        packed_leaves = []\n        leaf_scale = 2.0\n\n        for k, (leaf_name, leaf_value) in enumerate(leaf_list):\n            leaf_r = np.sqrt(leaf_value) * leaf_scale\n            if k == 0:\n                packed_leaves.append({\"name\": leaf_name, \"value\": leaf_value, \"r\": leaf_r, \"x\": sub_x, \"y\": sub_y})\n            else:\n                best_pos = None\n                min_dist = float(\"inf\")\n                for existing in packed_leaves:\n                    for angle in np.linspace(0, 2 * np.pi, 24):\n                        dist = existing[\"r\"] + leaf_r + 2\n                        nx = existing[\"x\"] + dist * np.cos(angle)\n                        ny = existing[\"y\"] + dist * np.sin(angle)\n                        d_to_parent = np.sqrt((nx - sub_x) ** 2 + (ny - sub_y) ** 2)\n                        if d_to_parent + leaf_r > sub_r * 0.84:\n                            continue\n                        overlaps = False\n                        for other in packed_leaves:\n                            d = np.sqrt((nx - other[\"x\"]) ** 2 + (ny - other[\"y\"]) ** 2)\n                            if d < other[\"r\"] + leaf_r + 1:\n                                overlaps = True\n                                break\n                        if not overlaps:\n                            d_center = np.sqrt((nx - sub_x) ** 2 + (ny - sub_y) ** 2)\n                            if d_center < min_dist:\n                                min_dist = d_center\n                                best_pos = (nx, ny)\n                if best_pos:\n                    packed_leaves.append(\n                        {\"name\": leaf_name, \"value\": leaf_value, \"r\": leaf_r, \"x\": best_pos[0], \"y\": best_pos[1]}\n                    )\n\n        for leaf in packed_leaves:\n            all_circles.append(\n                {\n                    \"x\": leaf[\"x\"],\n                    \"y\": leaf[\"y\"],\n                    \"r\": leaf[\"r\"],\n                    \"label\": leaf[\"name\"],\n                    \"value\": leaf[\"value\"],\n                    \"color\": sub_color,\n                    \"level\": 2,\n                    \"parent\": sub[\"name\"],\n                }\n            )\n\n# Create figure\nfig = go.Figure()\n\n# Draw circles by level (background to foreground)\nfor level in [-1, 0, 1, 2]:\n    for circle in all_circles:\n        if circle[\"level\"] == level:\n            if level == -1:\n                opacity, line_width = 0.08, 4\n            elif level == 0:\n                opacity, line_width = 0.8, 4\n            elif level == 1:\n                opacity, line_width = 0.7, 3\n            else:\n                opacity, line_width = 0.85, 2\n\n            fig.add_shape(\n                type=\"circle\",\n                xref=\"x\",\n                yref=\"y\",\n                x0=circle[\"x\"] - circle[\"r\"],\n                y0=circle[\"y\"] - circle[\"r\"],\n                x1=circle[\"x\"] + circle[\"r\"],\n                y1=circle[\"y\"] + circle[\"r\"],\n                fillcolor=circle[\"color\"],\n                opacity=opacity,\n                line={\"color\": INK_SOFT, \"width\": line_width},\n            )\n\n# Add labels\nfor circle in all_circles:\n    level = circle[\"level\"]\n\n    if level == -1:\n        fig.add_annotation(\n            x=circle[\"x\"],\n            y=circle[\"y\"] - circle[\"r\"] * 0.88,\n            text=f\"<b>{circle['label']}</b><br>${circle['value']:,.0f}\",\n            showarrow=False,\n            font={\"size\": 22, \"color\": INK},\n        )\n    elif level == 0:\n        fig.add_annotation(\n            x=circle[\"x\"],\n            y=circle[\"y\"] + circle[\"r\"] * 0.7,\n            text=f\"<b>{circle['label']}</b>\",\n            showarrow=False,\n            font={\"size\": 18, \"color\": INK},\n        )\n        fig.add_annotation(\n            x=circle[\"x\"],\n            y=circle[\"y\"] + circle[\"r\"] * 0.5,\n            text=f\"${circle['value']:,.0f}\",\n            showarrow=False,\n            font={\"size\": 14, \"color\": INK_SOFT},\n        )\n    elif level == 1 and circle[\"r\"] > 30:\n        fig.add_annotation(\n            x=circle[\"x\"], y=circle[\"y\"], text=f\"{circle['label']}\", showarrow=False, font={\"size\": 13, \"color\": INK}\n        )\n    elif level == 2 and circle[\"r\"] > 12:\n        fig.add_annotation(\n            x=circle[\"x\"], y=circle[\"y\"], text=circle[\"label\"], showarrow=False, font={\"size\": 10, \"color\": INK_SOFT}\n        )\n\n# Add hover traces for interactivity\nfor circle in all_circles:\n    level_names = {-1: \"Portfolio\", 0: \"Asset Class\", 1: \"Category\", 2: \"Holding\"}\n    fig.add_trace(\n        go.Scatter(\n            x=[circle[\"x\"]],\n            y=[circle[\"y\"]],\n            mode=\"markers\",\n            marker={\"size\": max(circle[\"r\"], 12), \"opacity\": 0},\n            hovertemplate=f\"<b>{circle['label']}</b><br>{level_names[circle['level']]}: ${circle['value']:,.0f}<extra></extra>\",\n            showlegend=False,\n        )\n    )\n\n# Add legend traces for asset classes\nfor idx, (name, _, _) in enumerate(asset_data):\n    fig.add_trace(\n        go.Scatter(\n            x=[None],\n            y=[None],\n            mode=\"markers\",\n            marker={\"size\": 16, \"color\": IMPRINT[idx % len(IMPRINT)], \"line\": {\"color\": INK_SOFT, \"width\": 2}},\n            name=name,\n            showlegend=True,\n        )\n    )\n\n# Layout\nfig.update_layout(\n    title={\n        \"text\": \"circlepacking-basic · plotly · anyplot.ai\",\n        \"font\": {\"size\": 28, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n        \"y\": 0.97,\n    },\n    xaxis={\n        \"showgrid\": False,\n        \"zeroline\": False,\n        \"showticklabels\": False,\n        \"range\": [-550, 550],\n        \"scaleanchor\": \"y\",\n        \"scaleratio\": 1,\n    },\n    yaxis={\"showgrid\": False, \"zeroline\": False, \"showticklabels\": False, \"range\": [-550, 550]},\n    plot_bgcolor=PAGE_BG,\n    paper_bgcolor=PAGE_BG,\n    margin={\"t\": 80, \"l\": 40, \"r\": 40, \"b\": 40},\n    showlegend=True,\n    legend={\n        \"x\": 0.98,\n        \"y\": 0.98,\n        \"xanchor\": \"right\",\n        \"yanchor\": \"top\",\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 2,\n        \"font\": {\"size\": 14, \"color\": INK_SOFT},\n        \"title\": {\"text\": \"Asset Classes\", \"font\": {\"size\": 16, \"color\": INK}},\n    },\n)\n\n# Save outputs\nfig.write_image(f\"plot-{THEME}.png\", width=1600, height=900, scale=3)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}