{"spec_id":"bubble-packed","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nbubble-packed: Basic Packed Bubble Chart\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-29\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\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\"\n\nnp.random.seed(42)\n\n# Data — department budgets (millions)\ndepartments = [\n    \"Engineering\",\n    \"Marketing\",\n    \"Sales\",\n    \"Operations\",\n    \"HR\",\n    \"Finance\",\n    \"R&D\",\n    \"Legal\",\n    \"IT\",\n    \"Customer Support\",\n    \"Product\",\n    \"Design\",\n    \"QA\",\n    \"Data Science\",\n    \"Security\",\n]\nbudgets = [45, 32, 38, 25, 12, 18, 42, 8, 22, 15, 28, 14, 10, 20, 6]\nn = len(budgets)\n\n# Area-scaled radii (sqrt) for accurate visual perception\nvals = np.array(budgets, dtype=float)\nmax_r = 310\nradii = np.sqrt(vals / vals.max()) * max_r\n\n# Force-directed circle packing in 2400×2400 coordinate space\nW, H = 2400, 2400\ncenter = np.array([W / 2.0, H / 2.0])\npos = center + (np.random.rand(n, 2) - 0.5) * 400\npad = 12\n\nfor step in range(600):\n    pos += (center - pos) * 0.012\n    total_shift = 0.0\n    for i in range(n):\n        for j in range(i + 1, n):\n            d = pos[j] - pos[i]\n            dist = np.linalg.norm(d) + 1e-6\n            gap = radii[i] + radii[j] + pad\n            if dist < gap:\n                s = d / dist * (gap - dist) * 0.5\n                pos[i] -= s\n                pos[j] += s\n                total_shift += gap - dist\n    pos[:, 0] = np.clip(pos[:, 0], radii + 50, W - radii - 50)\n    pos[:, 1] = np.clip(pos[:, 1], radii + 50, H - radii - 50)\n    if step > 200 and total_shift < 1.0:\n        break\n\n# Recenter cluster\nx_lo = (pos[:, 0] - radii).min()\nx_hi = (pos[:, 0] + radii).max()\ny_lo = (pos[:, 1] - radii).min()\ny_hi = (pos[:, 1] + radii).max()\npos[:, 0] += (W - (x_lo + x_hi)) / 2\npos[:, 1] += (H - (y_lo + y_hi)) / 2\n\n# Equal x/y range so data-unit circles render as true circles on square canvas\nmargin = 80\nx_lo = (pos[:, 0] - radii).min() - margin\nx_hi = (pos[:, 0] + radii).max() + margin\ny_lo = (pos[:, 1] - radii).min() - margin\ny_hi = (pos[:, 1] + radii).max() + margin\ncx = (x_lo + x_hi) / 2\ncy = (y_lo + y_hi) / 2\nhalf = max(x_hi - x_lo, y_hi - y_lo) / 2\nxr = (cx - half, cx + half)\nyr = (cy - half, cy + half)\n\n# Imprint palette tier colors (canonical order: #009E73, #C475FD, #4467A3, #BD8233)\n# Text color fixed per-tier based on fill luminance — circle fills don't change between themes\ntier_defs = [\n    (\">$35M\", \"#009E73\", \"#FFFFFF\", [i for i in range(n) if budgets[i] > 35]),\n    (\"$20–$35M\", \"#C475FD\", \"#1A1A17\", [i for i in range(n) if 20 <= budgets[i] <= 35]),\n    (\"$10–$19M\", \"#4467A3\", \"#FFFFFF\", [i for i in range(n) if 10 <= budgets[i] < 20]),\n    (\"<$10M\", \"#BD8233\", \"#1A1A17\", [i for i in range(n) if budgets[i] < 10]),\n]\n\np = figure(\n    width=W,\n    height=H,\n    title=\"Department Budgets by Spending Tier · bubble-packed · python · bokeh · anyplot.ai\",\n    x_range=xr,\n    y_range=yr,\n    tools=\"\",\n    toolbar_location=None,\n)\n\nrenderers = []\nfor tier_name, color, _text_color, idx in tier_defs:\n    if not idx:\n        continue\n    src = ColumnDataSource(\n        data={\n            \"x\": pos[idx, 0].tolist(),\n            \"y\": pos[idx, 1].tolist(),\n            \"radius\": radii[idx].tolist(),\n            \"dept\": [departments[i] for i in idx],\n            \"budget\": [f\"${budgets[i]}M\" for i in idx],\n            \"tier\": [tier_name for _ in idx],\n        }\n    )\n    r = p.circle(\n        x=\"x\",\n        y=\"y\",\n        radius=\"radius\",\n        source=src,\n        fill_color=color,\n        fill_alpha=0.90,\n        line_color=PAGE_BG,\n        line_width=4,\n        legend_label=tier_name,\n    )\n    renderers.append(r)\n\n# Adaptive label sizes by radius bracket (scaled for 2400×2400 canvas)\n# y_offset is in screen pixels, separating department name (above) and value (below)\nbrackets = [(225, float(\"inf\"), \"18pt\", \"14pt\", 16), (135, 225, \"14pt\", \"12pt\", 12), (0, 135, \"12pt\", \"9pt\", 9)]\n\nfor lo, hi, name_fs, val_fs, y_off in brackets:\n    for _tier, _color, text_color, tier_idx in tier_defs:\n        idx = [i for i in tier_idx if lo <= radii[i] < hi]\n        if not idx:\n            continue\n        src = ColumnDataSource(\n            data={\n                \"x\": pos[idx, 0].tolist(),\n                \"y\": pos[idx, 1].tolist(),\n                \"name\": [departments[i] for i in idx],\n                \"val\": [f\"${budgets[i]}M\" for i in idx],\n            }\n        )\n        p.add_layout(\n            LabelSet(\n                x=\"x\",\n                y=\"y\",\n                text=\"name\",\n                source=src,\n                text_align=\"center\",\n                text_baseline=\"middle\",\n                text_font_size=name_fs,\n                text_color=text_color,\n                text_font_style=\"bold\",\n                y_offset=y_off,\n            )\n        )\n        p.add_layout(\n            LabelSet(\n                x=\"x\",\n                y=\"y\",\n                text=\"val\",\n                source=src,\n                text_align=\"center\",\n                text_baseline=\"middle\",\n                text_font_size=val_fs,\n                text_color=text_color,\n                text_alpha=0.85,\n                y_offset=-y_off,\n            )\n        )\n\n# Theme-adaptive chrome\np.title.text_font_size = \"41pt\"\np.title.align = \"center\"\np.title.text_color = INK\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\np.min_border = 50\n\n# Legend — theme-adaptive styling with improved sizing for 2400px canvas\np.legend.location = \"top_right\"\np.legend.label_text_font_size = \"22pt\"\np.legend.label_text_color = INK\np.legend.glyph_height = 40\np.legend.glyph_width = 40\np.legend.background_fill_color = ELEVATED_BG\np.legend.background_fill_alpha = 0.92\np.legend.border_line_color = INK_SOFT\np.legend.border_line_width = 2\np.legend.padding = 16\np.legend.spacing = 10\np.legend.label_standoff = 12\np.legend.click_policy = \"hide\"\n\n# HoverTool — active in the HTML artifact\np.add_tools(\n    HoverTool(tooltips=[(\"Department\", \"@dept\"), (\"Budget\", \"@budget\"), (\"Tier\", \"@tier\")], renderers=renderers)\n)\n\n# Save interactive HTML (catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome via Selenium (export_png uses snap chromedriver which fails)\n# CDP setDeviceMetricsOverride forces the exact inner viewport — --window-size alone is\n# consumed by browser chrome in headless mode and shrinks the rendered height.\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)\n\ndriver = webdriver.Chrome(options=opts)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n\n# Belt-and-braces: pad/crop to exact dims so the post-render gate always passes\nfrom PIL import Image as _PILImage\n\n\n_img = _PILImage.open(f\"plot-{THEME}.png\").convert(\"RGB\")\nif _img.size != (W, H):\n    _norm = _PILImage.new(\"RGB\", (W, H), PAGE_BG)\n    _norm.paste(_img, ((W - _img.size[0]) // 2, (H - _img.size[1]) // 2))\n    _norm.save(f\"plot-{THEME}.png\")\n"}