{"spec_id":"bubble-packed","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nbubble-packed: Basic Packed Bubble Chart\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    coord_fixed,\n    element_rect,\n    element_text,\n    geom_point,\n    geom_text,\n    ggplot,\n    ggsave,\n    ggsize,\n    guide_legend,\n    guides,\n    labs,\n    layer_tooltips,\n    scale_fill_manual,\n    scale_size_identity,\n    theme,\n    theme_void,\n    xlim,\n    ylim,\n)\n\n\nLetsPlot.setup_html()\n\n# Theme-adaptive chrome (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\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# Data - department budget allocation ($M)\ncategories = [\n    \"Engineering\",\n    \"Marketing\",\n    \"Sales\",\n    \"Operations\",\n    \"HR\",\n    \"Finance\",\n    \"R&D\",\n    \"Customer Support\",\n    \"Legal\",\n    \"IT\",\n    \"Product\",\n    \"Design\",\n    \"Analytics\",\n    \"QA\",\n    \"Security\",\n]\nvalues = np.array([85, 62, 58, 45, 32, 48, 72, 38, 22, 55, 68, 35, 42, 28, 30])\ndivisions = [\n    \"Tech\",\n    \"Business\",\n    \"Business\",\n    \"Operations\",\n    \"Operations\",\n    \"Operations\",\n    \"Tech\",\n    \"Operations\",\n    \"Operations\",\n    \"Tech\",\n    \"Tech\",\n    \"Tech\",\n    \"Tech\",\n    \"Tech\",\n    \"Tech\",\n]\n\n# Circle packing with group-based spatial clustering\nn = len(values)\nradii = np.sqrt(values / np.pi) * 3.5\ndiv_names = [\"Tech\", \"Business\", \"Operations\"]\ndiv_angles = {g: i * 2 * np.pi / len(div_names) for i, g in enumerate(div_names)}\n\nnp.random.seed(42)\nx = np.zeros(n, dtype=float)\ny = np.zeros(n, dtype=float)\nfor i in range(n):\n    angle = div_angles[divisions[i]] + np.random.uniform(-0.4, 0.4)\n    r_init = np.random.uniform(3, 20)\n    x[i] = r_init * np.cos(angle)\n    y[i] = r_init * np.sin(angle)\n\n# Force-directed packing: gravity, group attraction, and collision resolution\nfor step in range(1700):\n    if step < 1200:\n        x *= 0.995\n        y *= 0.995\n        for g in div_names:\n            mask = np.array([divisions[i] == g for i in range(n)])\n            if mask.sum() > 1:\n                cx, cy = x[mask].mean(), y[mask].mean()\n                x[mask] += (cx - x[mask]) * 0.025\n                y[mask] += (cy - y[mask]) * 0.025\n\n    settled = True\n    for i in range(n):\n        for j in range(i + 1, n):\n            dx = x[j] - x[i]\n            dy = y[j] - y[i]\n            dist = np.sqrt(dx * dx + dy * dy)\n            spacing = 1.0 if divisions[i] != divisions[j] else 0.25\n            min_dist = radii[i] + radii[j] + spacing\n            if dist < min_dist and dist > 0:\n                settled = False\n                overlap = (min_dist - dist) / 2\n                ux, uy = dx / dist, dy / dist\n                x[i] -= ux * overlap\n                y[i] -= uy * overlap\n                x[j] += ux * overlap\n                y[j] += uy * overlap\n\n    if step >= 1200 and settled:\n        break\n\nx -= x.mean()\ny -= y.mean()\n\n# Build DataFrame with diameter in data units for geom_point size_unit='x'\nabbrev = {\"Customer Support\": \"Support\", \"Operations\": \"Ops\"}\ndf = pd.DataFrame(\n    {\n        \"x\": x,\n        \"y\": y,\n        \"division\": divisions,\n        \"label\": categories,\n        \"budget\": [f\"${v}M\" for v in values],\n        \"diameter\": radii * 2,\n        \"display_label\": [\n            (f\"{abbrev.get(c, c)}\\n${v}M\" if v >= 45 else (abbrev.get(c, c) if v >= 30 else \"\"))\n            for c, v in zip(categories, values, strict=True)\n        ],\n    }\n)\n\n# Axis limits ensuring all circles are fully visible (tight padding = good canvas fill)\nx_lo = min(x[i] - radii[i] for i in range(n))\nx_hi = max(x[i] + radii[i] for i in range(n))\ny_lo = min(y[i] - radii[i] for i in range(n))\ny_hi = max(y[i] + radii[i] for i in range(n))\npad = (x_hi - x_lo) * 0.02\n\n# Imprint palette - canonical order, Tech (dominant story) leads as brand green\npalette = {\"Tech\": \"#009E73\", \"Business\": \"#C475FD\", \"Operations\": \"#4467A3\"}\n\n# Title scaled off the 67-char baseline (see prompts/plot-generator.md)\ntitle = \"Department Budget Allocation · bubble-packed · python · letsplot · anyplot.ai\"\ntitle_size = max(11, round(16 * 67 / len(title))) if len(title) > 67 else 16\n\nplot = (\n    ggplot(df)\n    + geom_point(\n        aes(x=\"x\", y=\"y\", fill=\"division\", size=\"diameter\"),\n        shape=21,\n        color=PAGE_BG,\n        stroke=1.5,\n        alpha=0.92,\n        size_unit=\"x\",\n        tooltips=(layer_tooltips().title(\"@label\").line(\"Budget|@budget\").line(\"Division|@division\")),\n    )\n    + scale_size_identity(guide=\"none\")\n    # Dark in-bubble text reads well across green/lavender/blue fills (white fails on the lighter hues)\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"display_label\"), size=5, color=\"#1A1A17\", fontface=\"bold\")\n    + scale_fill_manual(values=palette, breaks=[\"Tech\", \"Business\", \"Operations\"])\n    + guides(fill=guide_legend(nrow=1))\n    + coord_fixed()\n    + xlim(x_lo - pad, x_hi + pad)\n    + ylim(y_lo - pad, y_hi + pad)\n    + labs(\n        title=title, subtitle=\"Tech departments dominate — 8 of 15 teams control 58% of total budget\", fill=\"Division\"\n    )\n    + theme_void()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_title=element_text(size=title_size, color=INK, hjust=0.5),\n        plot_subtitle=element_text(size=11, color=INK_SOFT, hjust=0.5),\n        legend_position=\"bottom\",\n        legend_title=element_text(size=12, color=INK),\n        legend_text=element_text(size=11, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.5),\n    )\n    + ggsize(600, 600)\n)\n\n# Save (square: ggsize 600 x scale 4 = 2400 x 2400 px)\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}