{"spec_id":"bubble-packed","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nbubble-packed: Basic Packed Bubble Chart\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 85/100 | Updated: 2026-05-29\n\"\"\"\n\nimport os\n\nimport matplotlib.collections as mcoll\nimport matplotlib.patches as mpatches\nimport matplotlib.patheffects as pe\nimport matplotlib.pyplot as plt\nimport numpy as np\n\n\n# Theme tokens — Imprint palette, 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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — 8 hues, canonical order\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — department budget allocation (in thousands USD)\nlabels = [\n    \"Engineering\",\n    \"Marketing\",\n    \"Sales\",\n    \"Operations\",\n    \"HR\",\n    \"Finance\",\n    \"R&D\",\n    \"Customer Support\",\n    \"Legal\",\n    \"IT\",\n    \"Design\",\n    \"Product\",\n    \"Data Science\",\n    \"Security\",\n    \"QA\",\n]\nvalues = [950, 420, 680, 310, 160, 280, 820, 200, 130, 370, 230, 580, 470, 145, 175]\n\n# Group assignments — organizational structure\ngroup_map = {\n    \"Engineering\": \"Engineering\",\n    \"IT\": \"Engineering\",\n    \"Data Science\": \"Engineering\",\n    \"R&D\": \"Engineering\",\n    \"Marketing\": \"Business\",\n    \"Sales\": \"Business\",\n    \"Product\": \"Business\",\n    \"Design\": \"Business\",\n    \"Operations\": \"Operations\",\n    \"HR\": \"Operations\",\n    \"Finance\": \"Operations\",\n    \"Customer Support\": \"Operations\",\n    \"Legal\": \"Compliance\",\n    \"Security\": \"Compliance\",\n    \"QA\": \"Compliance\",\n}\n\n# Groups mapped to first 4 Imprint palette positions (canonical order)\ngroup_order = [\"Engineering\", \"Business\", \"Operations\", \"Compliance\"]\ngroup_colors = {g: IMPRINT_PALETTE[i] for i, g in enumerate(group_order)}\ncolors = [group_colors[group_map[label]] for label in labels]\n\n# Scale values to radius (sqrt for area-proportional sizing)\nmin_radius = 0.30\nmax_radius = 2.0\nvalues_array = np.array(values, dtype=float)\nradii = min_radius + (max_radius - min_radius) * np.sqrt(\n    (values_array - values_array.min()) / (values_array.max() - values_array.min())\n)\n\n# Sort by size (largest first) for better packing\nn = len(labels)\norder = np.argsort(-radii)\nradii_sorted = radii[order]\nlabels_sorted = [labels[i] for i in order]\nvalues_sorted = [values[i] for i in order]\ncolors_sorted = [colors[i] for i in order]\ngroups_sorted = [group_map[labels[i]] for i in order]\n\nunique_groups = group_order\ngroup_ids = np.array([unique_groups.index(g) for g in groups_sorted])\n\n# Initial positions in spiral pattern for tighter convergence\nangles = np.linspace(0, 4 * np.pi, n)\nspiral_r = np.linspace(0, 3, n)\npositions = np.column_stack([spiral_r * np.cos(angles), spiral_r * np.sin(angles)])\n\n# Physics simulation with group-aware clustering\nfor iteration in range(500):\n    progress = iteration / 500\n    pull_strength = 0.06 * (1 - progress * 0.8)\n    group_pull = 0.04 * (1 - progress * 0.5)\n\n    group_centers = {}\n    for gid in range(len(unique_groups)):\n        mask = group_ids == gid\n        if np.any(mask):\n            group_centers[gid] = positions[mask].mean(axis=0)\n\n    for i in range(n):\n        dist = np.linalg.norm(positions[i])\n        if dist > 0.01:\n            positions[i] -= pull_strength * positions[i] / dist\n        gc = group_centers[group_ids[i]]\n        to_group = gc - positions[i]\n        gd = np.linalg.norm(to_group)\n        if gd > 0.01:\n            positions[i] += group_pull * to_group / gd\n\n    for i in range(n):\n        for j in range(i + 1, n):\n            delta = positions[j] - positions[i]\n            dist = np.linalg.norm(delta)\n            same_group = group_ids[i] == group_ids[j]\n            gap = 0.06 if same_group else 0.20\n            min_dist = radii_sorted[i] + radii_sorted[j] + gap\n            if dist < min_dist and dist > 0.001:\n                overlap = (min_dist - dist) / 2\n                direction = delta / dist\n                positions[i] -= overlap * direction\n                positions[j] += overlap * direction\n\n# Center the layout\nbbox_min = positions.min(axis=0) - radii_sorted.max()\nbbox_max = positions.max(axis=0) + radii_sorted.max()\npositions -= (bbox_min + bbox_max) / 2\n\n# Plot — square canvas for symmetric bubble chart (2400×2400 px at 400 dpi)\nfig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Draw circles with PatchCollection for efficient batch rendering\ncircles = [mpatches.Circle((positions[i, 0], positions[i, 1]), radii_sorted[i]) for i in range(n)]\ncollection = mcoll.PatchCollection(\n    circles, facecolors=colors_sorted, edgecolors=PAGE_BG, linewidths=2.0, alpha=0.90, zorder=2\n)\nax.add_collection(collection)\n\n# Labels inside circles (if large enough)\nsmall_circles = []\nfor i in range(n):\n    label_chars = len(labels_sorted[i])\n    min_r_for_label = 0.48 + label_chars * 0.018\n    if radii_sorted[i] > min_r_for_label:\n        font_scale = min(1.0, radii_sorted[i] / 1.6)\n        label_fontsize = max(9, int(11 * font_scale))\n        value_fontsize = max(8, int(9 * font_scale))\n\n        # Contrast-appropriate text color (WCAG relative luminance)\n        bg_hex = colors_sorted[i]\n        rgb = [int(bg_hex[j : j + 2], 16) / 255 for j in (1, 3, 5)]\n        luminance = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]\n        text_color = \"#1A1A17\" if luminance > 0.35 else \"#F0EFE8\"\n        # RGBA tuples — portable across matplotlib versions\n        stroke_fg = (0, 0, 0, 0.15) if luminance > 0.35 else (1, 1, 1, 0.15)\n        # linewidth=1.0 at 400 dpi ≈ 6 px — crisp without garish stroke\n        stroke = pe.withStroke(linewidth=1.0, foreground=stroke_fg)\n\n        # Wrap multi-word labels to reduce horizontal text extent within the circle\n        words = labels_sorted[i].split(\" \")\n        if len(words) > 1:\n            display_label = \"\\n\".join(words)\n            label_y_offset = 0.05\n            value_y_offset = -0.35\n        else:\n            display_label = labels_sorted[i]\n            label_y_offset = 0.12\n            value_y_offset = -0.22\n\n        ax.text(\n            positions[i, 0],\n            positions[i, 1] + radii_sorted[i] * label_y_offset,\n            display_label,\n            ha=\"center\",\n            va=\"center\",\n            fontsize=label_fontsize,\n            fontweight=\"bold\",\n            color=text_color,\n            path_effects=[stroke],\n            zorder=3,\n        )\n        ax.text(\n            positions[i, 0],\n            positions[i, 1] + radii_sorted[i] * value_y_offset,\n            f\"${values_sorted[i]}K\",\n            ha=\"center\",\n            va=\"center\",\n            fontsize=value_fontsize,\n            color=text_color,\n            alpha=0.85,\n            path_effects=[stroke],\n            zorder=3,\n        )\n    else:\n        small_circles.append(i)\n\n# External labels with leader lines for small circles\n# Scan 16 candidate angles and pick the direction with maximum clearance from\n# all other circle edges — avoids the angle-from-origin pitfall where a small\n# circle near the cluster centre gets a label that points into a large neighbor.\nfor i in small_circles:\n    cx, cy = positions[i, 0], positions[i, 1]\n    r = radii_sorted[i]\n    offset_dist = r + 0.65\n    best_angle = np.arctan2(cy, cx)\n    best_clearance = -np.inf\n    for test_angle in np.linspace(0, 2 * np.pi, 16, endpoint=False):\n        lx = cx + offset_dist * np.cos(test_angle)\n        ly = cy + offset_dist * np.sin(test_angle)\n        clearance = min(\n            np.sqrt((lx - positions[j, 0]) ** 2 + (ly - positions[j, 1]) ** 2) - radii_sorted[j]\n            for j in range(n)\n            if j != i\n        )\n        if clearance > best_clearance:\n            best_clearance = clearance\n            best_angle = test_angle\n    angle = best_angle\n    lx = cx + offset_dist * np.cos(angle)\n    ly = cy + offset_dist * np.sin(angle)\n    ax.annotate(\n        f\"{labels_sorted[i]}\\n${values_sorted[i]}K\",\n        xy=(cx + r * np.cos(angle), cy + r * np.sin(angle)),\n        xytext=(lx, ly),\n        fontsize=9,\n        fontweight=\"bold\",\n        color=INK_SOFT,\n        ha=\"center\",\n        va=\"center\",\n        arrowprops={\"arrowstyle\": \"-\", \"color\": INK_MUTED, \"lw\": 1.2, \"shrinkA\": 4, \"shrinkB\": 0},\n        zorder=4,\n    )\n\n# Symmetric axis limits centered at origin\nall_x, all_y = positions[:, 0], positions[:, 1]\nmax_r = radii_sorted.max()\nhalf_extent = max(all_x.max() - all_x.min(), all_y.max() - all_y.min()) / 2 + max_r + 0.75\nax.set_xlim(-half_extent, half_extent)\nax.set_ylim(-half_extent, half_extent)\nax.set_aspect(\"equal\")\nax.axis(\"off\")\n\n# Title\ntitle = \"bubble-packed · python · matplotlib · anyplot.ai\"\ntitle_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", color=INK, pad=12)\n\n# Legend for group colors with theme-adaptive frame\nlegend_handles = [\n    mpatches.Patch(facecolor=color, edgecolor=PAGE_BG, linewidth=1.5, label=group)\n    for group, color in group_colors.items()\n]\nleg = ax.legend(\n    handles=legend_handles,\n    loc=\"lower right\",\n    fontsize=9,\n    fancybox=False,\n    borderpad=0.8,\n    handlelength=1.5,\n    handleheight=1.2,\n)\nleg.get_frame().set_facecolor(ELEVATED_BG)\nleg.get_frame().set_edgecolor(INK_SOFT)\nplt.setp(leg.get_texts(), color=INK_SOFT)\n\nfig.subplots_adjust(left=0.02, right=0.98, top=0.91, bottom=0.02)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}