{"spec_id":"bubble-packed","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nbubble-packed: Basic Packed Bubble Chart\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-29\n\"\"\"\n\nimport math\nimport os\nimport sys\n\n\n# Prevent self-import: script file 'pygal.py' would shadow the 'pygal' package\n_self_dir = os.path.abspath(os.path.dirname(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _self_dir]\ndel _self_dir\n\nimport pygal\nfrom pygal.etree import etree\nfrom pygal.style import Style\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 categorical palette — positions 1–4 mapped to the four groups\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\nGROUP_COLORS = {\n    \"Technology\": \"#009E73\",  # brand green — position 1\n    \"Marketing\": \"#C475FD\",  # lavender — position 2\n    \"Operations\": \"#4467A3\",  # blue — position 3\n    \"Sales\": \"#BD8233\",  # ochre — position 4\n}\n# In-bubble text: fixed per group fill (bubble fill is theme-independent)\nGROUP_TEXT_COLOR = {\n    \"Technology\": \"white\",\n    \"Marketing\": \"#1A1A17\",  # lavender is too light for white text\n    \"Operations\": \"white\",\n    \"Sales\": \"white\",\n}\nGROUP_NAMES = [\"Technology\", \"Marketing\", \"Operations\", \"Sales\"]\n\nWIDTH = 3200\nHEIGHT = 1800\nPADDING = 10  # gap between packed circles in pixels\nFONT_FAMILY = \"'Trebuchet MS', 'Lucida Grande', sans-serif\"\n\n# Department budget allocation ($K) — varied group sizes to show chart flexibility\ndata = [\n    {\"label\": \"Software Dev\", \"value\": 480, \"group\": \"Technology\"},\n    {\"label\": \"Cloud Infra\", \"value\": 290, \"group\": \"Technology\"},\n    {\"label\": \"Data Analytics\", \"value\": 185, \"group\": \"Technology\"},\n    {\"label\": \"Cybersecurity\", \"value\": 140, \"group\": \"Technology\"},\n    {\"label\": \"AI Research\", \"value\": 95, \"group\": \"Technology\"},\n    {\"label\": \"Digital Marketing\", \"value\": 360, \"group\": \"Marketing\"},\n    {\"label\": \"Brand & Creative\", \"value\": 210, \"group\": \"Marketing\"},\n    {\"label\": \"Events\", \"value\": 130, \"group\": \"Marketing\"},\n    {\"label\": \"Facilities\", \"value\": 270, \"group\": \"Operations\"},\n    {\"label\": \"HR & Recruiting\", \"value\": 195, \"group\": \"Operations\"},\n    {\"label\": \"Legal\", \"value\": 155, \"group\": \"Operations\"},\n    {\"label\": \"Admin\", \"value\": 105, \"group\": \"Operations\"},\n    {\"label\": \"Enterprise\", \"value\": 390, \"group\": \"Sales\"},\n    {\"label\": \"SMB\", \"value\": 240, \"group\": \"Sales\"},\n    {\"label\": \"Partnerships\", \"value\": 175, \"group\": \"Sales\"},\n]\n\n# Compute group totals for legend labels and sort order\ngroup_totals = {}\nfor item in data:\n    group_totals[item[\"group\"]] = group_totals.get(item[\"group\"], 0) + item[\"value\"]\n\n# Scale values to radii (sqrt ensures area-based visual perception)\nmax_val = max(item[\"value\"] for item in data)\nmax_radius = min(WIDTH, HEIGHT) * 0.11  # 198 px for 1800 px height\n\ncircles = []\nfor item in data:\n    r = math.sqrt(item[\"value\"] / max_val) * max_radius\n    circles.append({\"r\": r, \"item\": item, \"x\": 0.0, \"y\": 0.0})\n\n# Sort: largest-total group first, then descending radius within each group\nsorted_groups = sorted(GROUP_NAMES, key=lambda g: -group_totals[g])\ngroup_order = {g: i for i, g in enumerate(sorted_groups)}\ncircles.sort(key=lambda c: (group_order[c[\"item\"][\"group\"]], -c[\"r\"]))\n\ncx, cy = WIDTH / 2, HEIGHT / 2\ncircles[0][\"x\"] = cx\ncircles[0][\"y\"] = cy\nplaced = [circles[0]]\n\n# Greedy packing with group-affinity clustering\nfor circle in circles[1:]:\n    best_pos = None\n    best_score = float(\"inf\")\n    same_group = [p for p in placed if p[\"item\"][\"group\"] == circle[\"item\"][\"group\"]]\n\n    for existing in placed:\n        for angle_deg in range(0, 360, 6):\n            angle = math.radians(angle_deg)\n            dist = existing[\"r\"] + circle[\"r\"] + PADDING\n            nx = existing[\"x\"] + math.cos(angle) * dist\n            ny = existing[\"y\"] + math.sin(angle) * dist\n\n            valid = True\n            for other in placed:\n                ddx = nx - other[\"x\"]\n                ddy = ny - other[\"y\"]\n                min_gap = circle[\"r\"] + other[\"r\"] + PADDING * 0.5\n                if math.sqrt(ddx * ddx + ddy * ddy) < min_gap:\n                    valid = False\n                    break\n\n            if valid:\n                d_center = math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)\n                if same_group:\n                    d_group = sum(math.sqrt((nx - p[\"x\"]) ** 2 + (ny - p[\"y\"]) ** 2) for p in same_group) / len(\n                        same_group\n                    )\n                    score = d_center * 0.3 + d_group * 0.7\n                else:\n                    score = d_center\n\n                if score < best_score:\n                    best_score = score\n                    best_pos = (nx, ny)\n\n    if best_pos:\n        circle[\"x\"], circle[\"y\"] = best_pos\n    else:\n        circle[\"x\"] = cx\n        circle[\"y\"] = max(c[\"y\"] + c[\"r\"] for c in placed) + circle[\"r\"] + PADDING\n\n    placed.append(circle)\n\n# Recenter using area-weighted centroid to fill the chart's content zone\navail_top = 140  # below title area\navail_bottom = HEIGHT - 200  # above legend area\ntarget_cy = (avail_top + avail_bottom) / 2\ntarget_cx = WIDTH / 2\n\ntotal_area = sum(c[\"r\"] ** 2 for c in placed)\nweighted_cx = sum(c[\"x\"] * c[\"r\"] ** 2 for c in placed) / total_area\nweighted_cy = sum(c[\"y\"] * c[\"r\"] ** 2 for c in placed) / total_area\ndx = target_cx - weighted_cx\ndy = target_cy - weighted_cy\n\nfor c in placed:\n    c[\"x\"] += dx\n    c[\"y\"] += dy\n\n# Gather per-group centroid and extent data for label and boundary placement\ngroup_info = {}\nfor c in placed:\n    g = c[\"item\"][\"group\"]\n    if g not in group_info:\n        group_info[g] = {\"xs\": [], \"ys\": [], \"rs\": []}\n    group_info[g][\"xs\"].append(c[\"x\"])\n    group_info[g][\"ys\"].append(c[\"y\"])\n    group_info[g][\"rs\"].append(c[\"r\"])\n\npacked = [(c[\"x\"], c[\"y\"], c[\"r\"], c[\"item\"]) for c in placed]\n\n# Title length check for font scaling (no shrink needed for 43-char title)\ntitle_str = \"bubble-packed · python · pygal · anyplot.ai\"\nn_chars = len(title_str)\ntitle_fs = round(66 * 67 / n_chars) if n_chars > 67 else 66\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    font_family=FONT_FAMILY,\n    title_font_size=title_fs,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n)\n\nchart = pygal.Pie(\n    width=WIDTH,\n    height=HEIGHT,\n    style=custom_style,\n    title=title_str,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    legend_box_size=28,\n    inner_radius=0,\n    margin=80,\n    no_data_text=\"\",\n    tooltip_fancy_mode=True,\n    pretty_print=True,\n    truncate_legend=-1,\n)\n\nfor group in GROUP_NAMES:\n    chart.add(f\"{group}: ${group_totals[group]:,}K\", [])\n\n\ndef add_packed_bubbles(root):\n    def _text(parent, x, y, label, size, color, bold=False):\n        t = etree.SubElement(parent, \"text\")\n        t.set(\"x\", f\"{x:.0f}\")\n        t.set(\"y\", f\"{y:.0f}\")\n        t.set(\"text-anchor\", \"middle\")\n        t.set(\"dominant-baseline\", \"middle\")\n        t.set(\"fill\", color)\n        t.set(\"font-size\", f\"{size}\")\n        t.set(\"font-family\", FONT_FAMILY)\n        if bold:\n            t.set(\"font-weight\", \"bold\")\n        t.text = label\n\n    # Radial gradients for polished 3D bubble appearance\n    defs = etree.SubElement(root, \"defs\")\n    for gname, color in GROUP_COLORS.items():\n        grad = etree.SubElement(defs, \"radialGradient\")\n        grad.set(\"id\", f\"grad-{gname.lower()}\")\n        grad.set(\"cx\", \"35%\")\n        grad.set(\"cy\", \"35%\")\n        grad.set(\"r\", \"65%\")\n        rgb = [int(color[i : i + 2], 16) for i in (1, 3, 5)]\n        light = [min(255, c + 60) for c in rgb]\n        stop1 = etree.SubElement(grad, \"stop\")\n        stop1.set(\"offset\", \"0%\")\n        stop1.set(\"stop-color\", f\"#{light[0]:02x}{light[1]:02x}{light[2]:02x}\")\n        stop1.set(\"stop-opacity\", \"0.95\")\n        stop2 = etree.SubElement(grad, \"stop\")\n        stop2.set(\"offset\", \"100%\")\n        stop2.set(\"stop-color\", color)\n        stop2.set(\"stop-opacity\", \"0.90\")\n\n    g = etree.SubElement(root, \"g\")\n    g.set(\"class\", \"packed-bubbles\")\n\n    overall_cy = sum(c[1] for c in packed) / len(packed)\n\n    # Subtle dashed boundary circles to visually group related items\n    for gname, gdata in group_info.items():\n        gcx = sum(gdata[\"xs\"]) / len(gdata[\"xs\"])\n        gcy = sum(gdata[\"ys\"]) / len(gdata[\"ys\"])\n        extent = max(\n            math.sqrt((x - gcx) ** 2 + (y - gcy) ** 2) + r\n            for x, y, r in zip(gdata[\"xs\"], gdata[\"ys\"], gdata[\"rs\"], strict=True)\n        )\n        bg_circ = etree.SubElement(g, \"circle\")\n        bg_circ.set(\"cx\", f\"{gcx:.0f}\")\n        bg_circ.set(\"cy\", f\"{gcy:.0f}\")\n        bg_circ.set(\"r\", f\"{extent + 18:.0f}\")\n        bg_circ.set(\"fill\", GROUP_COLORS[gname])\n        bg_circ.set(\"fill-opacity\", \"0.05\")\n        bg_circ.set(\"stroke\", GROUP_COLORS[gname])\n        bg_circ.set(\"stroke-opacity\", \"0.18\")\n        bg_circ.set(\"stroke-width\", \"2\")\n        bg_circ.set(\"stroke-dasharray\", \"12,8\")\n\n    # Data circles with gradient fills and SVG tooltips\n    for x, y, r, item in packed:\n        grad_id = f\"grad-{item['group'].lower()}\"\n        circ = etree.SubElement(g, \"circle\")\n        circ.set(\"cx\", f\"{x:.1f}\")\n        circ.set(\"cy\", f\"{y:.1f}\")\n        circ.set(\"r\", f\"{r:.1f}\")\n        circ.set(\"fill\", f\"url(#{grad_id})\")\n        circ.set(\"stroke\", PAGE_BG)  # theme-adaptive gap between adjacent bubbles\n        circ.set(\"stroke-width\", \"4\")\n        tooltip = etree.SubElement(circ, \"title\")\n        tooltip.text = f\"{item['label']}: ${item['value']}K ({item['group']})\"\n\n    # Highlight ring on the largest bubble for visual hierarchy\n    top = max(packed, key=lambda c: c[2])\n    ring = etree.SubElement(g, \"circle\")\n    ring.set(\"cx\", f\"{top[0]:.1f}\")\n    ring.set(\"cy\", f\"{top[1]:.1f}\")\n    ring.set(\"r\", f\"{top[2] + 7:.1f}\")\n    ring.set(\"fill\", \"none\")\n    ring.set(\"stroke\", GROUP_COLORS[top[3][\"group\"]])\n    ring.set(\"stroke-width\", \"3\")\n    ring.set(\"stroke-opacity\", \"0.45\")\n    ring.set(\"stroke-dasharray\", \"8,5\")\n\n    # Circle labels: first-word name + value for larger bubbles, value-only for smaller\n    for x, y, r, item in packed:\n        text_color = GROUP_TEXT_COLOR[item[\"group\"]]\n        if r > 110:\n            fs = max(int(r * 0.22), 26)\n            name = item[\"label\"].split()[0]\n            _text(g, x, y - fs * 0.55, name, fs, text_color, bold=True)\n            _text(g, x, y + fs * 0.65, f\"${item['value']}K\", int(fs * 0.82), text_color)\n        else:\n            fs = max(int(r * 0.28), 24)\n            _text(g, x, y, f\"${item['value']}K\", fs, text_color, bold=True)\n\n    # Group labels: placed above or below each cluster with generous clearance\n    for gname, gdata in group_info.items():\n        gcx = sum(gdata[\"xs\"]) / len(gdata[\"xs\"])\n        gcy = sum(gdata[\"ys\"]) / len(gdata[\"ys\"])\n\n        if gcy < overall_cy:\n            label_y = min(y - r for y, r in zip(gdata[\"ys\"], gdata[\"rs\"], strict=True)) - 70\n            label_y = max(label_y, avail_top + 30)\n        else:\n            label_y = max(y + r for y, r in zip(gdata[\"ys\"], gdata[\"rs\"], strict=True)) + 90\n            label_y = min(label_y, avail_bottom - 30)\n\n        lbl = etree.SubElement(g, \"text\")\n        lbl.set(\"x\", f\"{gcx:.0f}\")\n        lbl.set(\"y\", f\"{label_y:.0f}\")\n        lbl.set(\"text-anchor\", \"middle\")\n        lbl.set(\"fill\", GROUP_COLORS[gname])\n        lbl.set(\"font-size\", \"38\")\n        lbl.set(\"font-family\", FONT_FAMILY)\n        lbl.set(\"font-weight\", \"bold\")\n        lbl.set(\"letter-spacing\", \"1.5\")\n        lbl.text = f\"{gname}: ${group_totals[gname]:,}K\"\n\n    return root\n\n\nchart.add_xml_filter(add_packed_bubbles)\n\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}