{"spec_id":"circlepacking-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ncirclepacking-basic: Circle Packing Chart\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-11\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, LabelSet, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\nnp.random.seed(42)\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\"\n\n# Okabe-Ito palette for hierarchy levels\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Build hierarchical data: Portfolio composition by asset class (in millions)\nhierarchy = [\n    {\"id\": \"Portfolio\", \"parent\": None, \"value\": 0, \"label\": \"Portfolio\"},\n    # Equities\n    {\"id\": \"Equities\", \"parent\": \"Portfolio\", \"value\": 0, \"label\": \"Equities\"},\n    {\"id\": \"US-Large-Cap\", \"parent\": \"Equities\", \"value\": 450, \"label\": \"US Large Cap\"},\n    {\"id\": \"US-Mid-Cap\", \"parent\": \"Equities\", \"value\": 280, \"label\": \"US Mid Cap\"},\n    {\"id\": \"US-Small-Cap\", \"parent\": \"Equities\", \"value\": 170, \"label\": \"US Small Cap\"},\n    {\"id\": \"Intl-Dev\", \"parent\": \"Equities\", \"value\": 320, \"label\": \"Intl Dev\"},\n    {\"id\": \"Emerging\", \"parent\": \"Equities\", \"value\": 180, \"label\": \"Emerging\"},\n    # Fixed Income\n    {\"id\": \"Fixed-Income\", \"parent\": \"Portfolio\", \"value\": 0, \"label\": \"Fixed Income\"},\n    {\"id\": \"US-Govt\", \"parent\": \"Fixed-Income\", \"value\": 250, \"label\": \"US Govt\"},\n    {\"id\": \"Corp-Bonds\", \"parent\": \"Fixed-Income\", \"value\": 180, \"label\": \"Corp Bonds\"},\n    {\"id\": \"Int-Bonds\", \"parent\": \"Fixed-Income\", \"value\": 120, \"label\": \"Intl Bonds\"},\n    {\"id\": \"High-Yield\", \"parent\": \"Fixed-Income\", \"value\": 100, \"label\": \"High Yield\"},\n    # Real Assets\n    {\"id\": \"Real-Assets\", \"parent\": \"Portfolio\", \"value\": 0, \"label\": \"Real Assets\"},\n    {\"id\": \"Real-Estate\", \"parent\": \"Real-Assets\", \"value\": 200, \"label\": \"Real Estate\"},\n    {\"id\": \"Commodities\", \"parent\": \"Real-Assets\", \"value\": 90, \"label\": \"Commodities\"},\n    {\"id\": \"Infrastructure\", \"parent\": \"Real-Assets\", \"value\": 110, \"label\": \"Infrastructure\"},\n    # Alternatives\n    {\"id\": \"Alternatives\", \"parent\": \"Portfolio\", \"value\": 0, \"label\": \"Alternatives\"},\n    {\"id\": \"Hedge-Funds\", \"parent\": \"Alternatives\", \"value\": 150, \"label\": \"Hedge Funds\"},\n    {\"id\": \"Private-Equity\", \"parent\": \"Alternatives\", \"value\": 130, \"label\": \"Private Equity\"},\n    {\"id\": \"Crypto\", \"parent\": \"Alternatives\", \"value\": 40, \"label\": \"Crypto\"},\n]\n\n# Build tree structure\nnodes = {item[\"id\"]: {**item, \"children\": [], \"x\": 0.0, \"y\": 0.0, \"r\": 0.0, \"depth\": 0} for item in hierarchy}\nroot = None\nfor _node_id, node in nodes.items():\n    if node[\"parent\"] is None:\n        root = node\n    else:\n        parent = nodes[node[\"parent\"]]\n        parent[\"children\"].append(node)\n        node[\"depth\"] = parent[\"depth\"] + 1\n\nscale_factor = 12\n\n# Compute layout bottom-up\nfor node in nodes.values():\n    if not node[\"children\"]:\n        node[\"r\"] = np.sqrt(node[\"value\"]) * scale_factor\n\nmax_depth = max(n[\"depth\"] for n in nodes.values())\n\nfor current_depth in range(max_depth, -1, -1):\n    nodes_at_depth = [n for n in nodes.values() if n[\"depth\"] == current_depth and n[\"children\"]]\n\n    for node in nodes_at_depth:\n        children = node[\"children\"]\n        children.sort(key=lambda c: -c[\"r\"])\n        n_children = len(children)\n\n        if n_children == 1:\n            children[0][\"x\"] = 0.0\n            children[0][\"y\"] = 0.0\n        elif n_children >= 2:\n            c0, c1 = children[0], children[1]\n            c0[\"x\"] = 0.0\n            c0[\"y\"] = 0.0\n            c1[\"x\"] = c0[\"r\"] + c1[\"r\"]\n            c1[\"y\"] = 0.0\n\n            if n_children >= 3:\n                c2 = children[2]\n                d01 = c0[\"r\"] + c1[\"r\"]\n                d02 = c0[\"r\"] + c2[\"r\"]\n                d12 = c1[\"r\"] + c2[\"r\"]\n                x2 = (d02**2 - d12**2 + d01**2) / (2 * d01)\n                y2_sq = d02**2 - x2**2\n                c2[\"x\"] = x2\n                c2[\"y\"] = np.sqrt(max(0, y2_sq))\n\n                for i in range(3, n_children):\n                    ci = children[i]\n                    best_score = float(\"inf\")\n                    best_pos = (0.0, 0.0)\n\n                    for j in range(i):\n                        for k in range(j + 1, i):\n                            cj, ck = children[j], children[k]\n                            dx = ck[\"x\"] - cj[\"x\"]\n                            dy = ck[\"y\"] - cj[\"y\"]\n                            d = np.sqrt(dx**2 + dy**2)\n\n                            if d < 1e-10:\n                                continue\n\n                            r1 = cj[\"r\"] + ci[\"r\"]\n                            r2 = ck[\"r\"] + ci[\"r\"]\n\n                            if d > r1 + r2 + 1e-6 or d < abs(r1 - r2) - 1e-6:\n                                continue\n\n                            a = (r1**2 - r2**2 + d**2) / (2 * d)\n                            h_sq = r1**2 - a**2\n                            if h_sq < 0:\n                                continue\n\n                            h = np.sqrt(h_sq)\n                            mx = cj[\"x\"] + a * dx / d\n                            my = cj[\"y\"] + a * dy / d\n\n                            for px, py in [(mx - h * dy / d, my + h * dx / d), (mx + h * dy / d, my - h * dx / d)]:\n                                valid = True\n                                for m in range(i):\n                                    cm = children[m]\n                                    dist = np.sqrt((px - cm[\"x\"]) ** 2 + (py - cm[\"y\"]) ** 2)\n                                    if dist < ci[\"r\"] + cm[\"r\"] - 1e-6:\n                                        valid = False\n                                        break\n\n                                if valid:\n                                    cx = sum(children[m][\"x\"] for m in range(i)) / i\n                                    cy = sum(children[m][\"y\"] for m in range(i)) / i\n                                    score = np.sqrt((px - cx) ** 2 + (py - cy) ** 2)\n                                    if score < best_score:\n                                        best_score = score\n                                        best_pos = (px, py)\n\n                    ci[\"x\"], ci[\"y\"] = best_pos\n\n        if children:\n            min_x = min(c[\"x\"] - c[\"r\"] for c in children)\n            max_x = max(c[\"x\"] + c[\"r\"] for c in children)\n            min_y = min(c[\"y\"] - c[\"r\"] for c in children)\n            max_y = max(c[\"y\"] + c[\"r\"] for c in children)\n            cx = (min_x + max_x) / 2\n            cy = (min_y + max_y) / 2\n            enc_r = max(np.sqrt((c[\"x\"] - cx) ** 2 + (c[\"y\"] - cy) ** 2) + c[\"r\"] for c in children)\n\n            for child in children:\n                child[\"x\"] -= cx\n                child[\"y\"] -= cy\n\n            node[\"r\"] = enc_r + 30\n\n# Position children relative to parent (top-down)\nstack = [(root, 0.0, 0.0)]\nwhile stack:\n    current, px, py = stack.pop()\n    current[\"x\"] = px\n    current[\"y\"] = py\n    for child in current[\"children\"]:\n        stack.append((child, px + child[\"x\"], py + child[\"y\"]))\n\n# Collect all nodes for plotting\nall_circles = []\nstack = [root]\nwhile stack:\n    current = stack.pop()\n    all_circles.append(current)\n    stack.extend(current[\"children\"])\n\n# Prepare data for plotting\nx_vals = [n[\"x\"] for n in all_circles]\ny_vals = [n[\"y\"] for n in all_circles]\nradii = [n[\"r\"] for n in all_circles]\ndepths = [n[\"depth\"] for n in all_circles]\nlabels = [n[\"label\"] for n in all_circles]\nvalues = [n[\"value\"] for n in all_circles]\n\n# Color by depth using Okabe-Ito palette\ncolors = [IMPRINT[min(d, 2)] for d in depths]\ndepth_names = [\"Portfolio\", \"Asset Class\", \"Investment\"]\ndepth_labels = [depth_names[min(d, 2)] for d in depths]\n\n# Create figure (square aspect)\np = figure(\n    width=3600,\n    height=3600,\n    title=\"circlepacking-basic · bokeh · anyplot.ai\",\n    match_aspect=True,\n    toolbar_location=None,\n    tools=\"\",\n)\n\n# Sort by depth and radius for proper layering\nsorted_indices = sorted(range(len(all_circles)), key=lambda i: (depths[i], -radii[i]))\n\n# Draw circles with ColumnDataSource for hover\ncircle_data = {\n    \"x\": [x_vals[i] for i in sorted_indices],\n    \"y\": [y_vals[i] for i in sorted_indices],\n    \"radius\": [radii[i] for i in sorted_indices],\n    \"color\": [colors[i] for i in sorted_indices],\n    \"alpha\": [0.6 if depths[i] == 0 else (0.65 if depths[i] == 1 else 0.75) for i in sorted_indices],\n    \"line_width\": [3 if depths[i] == 0 else 2 for i in sorted_indices],\n    \"label\": [labels[i] for i in sorted_indices],\n    \"depth_label\": [depth_labels[i] for i in sorted_indices],\n    \"value\": [values[i] for i in sorted_indices],\n}\ncircle_source = ColumnDataSource(data=circle_data)\n\ncircles_glyph = p.circle(\n    x=\"x\",\n    y=\"y\",\n    radius=\"radius\",\n    fill_color=\"color\",\n    fill_alpha=\"alpha\",\n    line_color=INK_SOFT,\n    line_width=\"line_width\",\n    source=circle_source,\n)\n\n# Add HoverTool for interactivity\nhover = HoverTool(\n    tooltips=[(\"Name\", \"@label\"), (\"Level\", \"@depth_label\"), (\"Value\", \"@value{0} M$\")],\n    renderers=[circles_glyph],\n    mode=\"mouse\",\n)\np.add_tools(hover)\n\n# Create legend for depth colors\nlegend_items = []\nfor color, name in zip(IMPRINT[:3], depth_names, strict=True):\n    dummy_source = ColumnDataSource(data={\"x\": [-99999], \"y\": [-99999], \"r\": [10]})\n    dummy_circle = p.circle(\n        x=\"x\", y=\"y\", radius=\"r\", fill_color=color, fill_alpha=0.7, line_color=INK_SOFT, source=dummy_source\n    )\n    legend_items.append(LegendItem(label=name, renderers=[dummy_circle]))\n\nlegend = Legend(items=legend_items, location=\"top_right\", label_text_font_size=\"24pt\", glyph_height=40, glyph_width=40)\nlegend.background_fill_color = ELEVATED_BG\nlegend.background_fill_alpha = 0.95\nlegend.border_line_color = INK_SOFT\nlegend.label_text_color = INK_SOFT\nlegend.padding = 15\nlegend.spacing = 10\np.add_layout(legend)\n\n# Prepare labels for larger circles\nlabel_data = {\"x\": [], \"y\": [], \"label\": []}\nfor node in all_circles:\n    if not node[\"children\"] and node[\"r\"] >= 50:\n        label_data[\"x\"].append(node[\"x\"])\n        label_data[\"y\"].append(node[\"y\"])\n        label_data[\"label\"].append(node[\"label\"])\n    elif node[\"depth\"] == 1:\n        label_data[\"x\"].append(node[\"x\"])\n        label_data[\"y\"].append(node[\"y\"] + node[\"r\"] * 0.7)\n        label_data[\"label\"].append(node[\"label\"])\n\nlabel_source = ColumnDataSource(data=label_data)\nlabel_set = LabelSet(\n    x=\"x\",\n    y=\"y\",\n    text=\"label\",\n    source=label_source,\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    text_font_size=\"26pt\",\n    text_color=INK,\n    text_font_style=\"bold\",\n)\np.add_layout(label_set)\n\n# Style with theme-adaptive chrome\np.title.text_font_size = \"36pt\"\np.title.text_color = INK\np.title.align = \"center\"\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\n# Set axis ranges\nextent = root[\"r\"] * 1.08\np.x_range.start = -extent\np.x_range.end = extent\np.y_range.start = -extent\np.y_range.end = extent\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with Selenium\nW, H = 3600, 3600\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}