{"spec_id":"bubble-packed","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nbubble-packed: Basic Packed Bubble Chart\nLibrary: plotnine 0.15.4 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-29\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script's own directory from sys.path so the installed plotnine package is found\n_here = os.path.dirname(os.path.abspath(__file__))\nif _here in sys.path:\n    sys.path.remove(_here)\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    coord_fixed,\n    element_rect,\n    element_text,\n    geom_polygon,\n    geom_text,\n    ggplot,\n    labs,\n    scale_fill_manual,\n    theme,\n    theme_void,\n)\n\n\n# Theme tokens\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# Imprint palette — positions 1-4 for four tech segments\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data — global tech industry revenue by segment (billions USD, 2024 est.)\nmarket_data = {\n    \"label\": [\n        \"Cloud\",\n        \"SaaS\",\n        \"Security\",\n        \"Analytics\",\n        \"Dev Tools\",\n        \"Chips\",\n        \"Storage\",\n        \"Networks\",\n        \"Displays\",\n        \"Peripherals\",\n        \"Consulting\",\n        \"Tech Svc\",\n        \"Training\",\n        \"Managed\",\n        \"APIs\",\n        \"Mobile\",\n        \"Gaming\",\n        \"Streaming\",\n        \"Smart Home\",\n        \"Wearables\",\n    ],\n    \"value\": [52, 38, 29, 24, 16, 48, 31, 27, 18, 12, 34, 22, 14, 11, 9, 44, 35, 26, 17, 13],\n    \"group\": [\n        \"Software\",\n        \"Software\",\n        \"Software\",\n        \"Software\",\n        \"Software\",\n        \"Hardware\",\n        \"Hardware\",\n        \"Hardware\",\n        \"Hardware\",\n        \"Hardware\",\n        \"Services\",\n        \"Services\",\n        \"Services\",\n        \"Services\",\n        \"Services\",\n        \"Consumer\",\n        \"Consumer\",\n        \"Consumer\",\n        \"Consumer\",\n        \"Consumer\",\n    ],\n}\n\ndf = pd.DataFrame(market_data)\n\n# Scale values to radii (area-based for accurate visual perception)\nmax_radius = 1.0\nmin_radius = 0.22\ndf[\"radius\"] = min_radius + (max_radius - min_radius) * np.sqrt(df[\"value\"] / df[\"value\"].max())\n\n# Circle packing — greedy placement with vectorized collision detection\nn = len(df)\nradii = df[\"radius\"].values\nidx = np.argsort(-radii)\nsorted_radii = radii[idx]\ngap = 0.03\n\nx_pos = np.zeros(n)\ny_pos = np.zeros(n)\nangles_sweep = np.linspace(0, 2 * np.pi, 72, endpoint=False)\n\nfor i in range(1, n):\n    best_dist = float(\"inf\")\n    best_x, best_y = 0.0, 0.0\n    target_r = sorted_radii[i]\n\n    for ref in range(i):\n        place_r = sorted_radii[ref] + target_r + gap\n        cx = x_pos[ref] + place_r * np.cos(angles_sweep)\n        cy = y_pos[ref] + place_r * np.sin(angles_sweep)\n\n        dx_c = cx[:, np.newaxis] - x_pos[:i][np.newaxis, :]\n        dy_c = cy[:, np.newaxis] - y_pos[:i][np.newaxis, :]\n        dists_c = np.hypot(dx_c, dy_c)\n        valid = np.all(dists_c >= target_r + sorted_radii[:i] + gap, axis=1)\n\n        center_dists = cx**2 + cy**2\n        valid_dists = np.where(valid, center_dists, float(\"inf\"))\n        best_k = np.argmin(valid_dists)\n        if valid_dists[best_k] < best_dist:\n            best_dist = valid_dists[best_k]\n            best_x, best_y = cx[best_k], cy[best_k]\n\n    x_pos[i] = best_x\n    y_pos[i] = best_y\n\n# Force simulation to tighten packing (vectorized with numpy)\ntri = np.triu(np.ones((n, n), dtype=bool), k=1)\nmin_dists = sorted_radii[:, np.newaxis] + sorted_radii[np.newaxis, :] + gap\n\nfor _ in range(2000):\n    x_pos *= 0.997\n    y_pos *= 0.997\n\n    dx = x_pos[:, np.newaxis] - x_pos[np.newaxis, :]\n    dy = y_pos[:, np.newaxis] - y_pos[np.newaxis, :]\n    dists = np.hypot(dx, dy)\n\n    overlap = tri & (dists < min_dists) & (dists > 1e-3)\n    if overlap.any():\n        safe_dists = np.where(dists > 1e-3, dists, 1.0)\n        push = ((min_dists - dists) / (2 * safe_dists)) * overlap\n        corr_x = push * dx\n        corr_y = push * dy\n        x_pos += corr_x.sum(axis=1) - corr_x.sum(axis=0)\n        y_pos += corr_y.sum(axis=1) - corr_y.sum(axis=0)\n\n# Restore original order\nx_final = np.zeros(n)\ny_final = np.zeros(n)\nfor i, orig_idx in enumerate(idx):\n    x_final[orig_idx] = x_pos[i]\n    y_final[orig_idx] = y_pos[i]\n\ndf[\"x\"] = x_final\ndf[\"y\"] = y_final\n\n# Build circle polygons for geom_polygon\ncircle_dfs = []\nangles = np.linspace(0, 2 * np.pi, 64)\nfor i, row in df.iterrows():\n    cx = row[\"x\"] + row[\"radius\"] * np.cos(angles)\n    cy = row[\"y\"] + row[\"radius\"] * np.sin(angles)\n    circle_dfs.append(pd.DataFrame({\"x\": cx, \"y\": cy, \"label\": row[\"label\"], \"group\": row[\"group\"], \"circle_id\": i}))\ncircles_df = pd.concat(circle_dfs, ignore_index=True)\ncircles_df[\"group\"] = pd.Categorical(circles_df[\"group\"], categories=[\"Software\", \"Hardware\", \"Services\", \"Consumer\"])\n\n# Labels — name and revenue value per bubble\nlabels_df = df.copy()\nlabels_df[\"value_label\"] = labels_df[\"value\"].apply(lambda v: f\"${v}B\")\n\n# Group colors using Imprint palette (canonical positions 1-4)\ngroup_colors = {\n    \"Software\": IMPRINT_PALETTE[0],  # #009E73 brand green\n    \"Hardware\": IMPRINT_PALETTE[1],  # #C475FD lavender\n    \"Services\": IMPRINT_PALETTE[2],  # #4467A3 blue\n    \"Consumer\": IMPRINT_PALETTE[3],  # #BD8233 ochre\n}\n\n# Group totals for subtitle\ngroup_order = [\"Software\", \"Hardware\", \"Services\", \"Consumer\"]\ngroup_totals = df.groupby(\"group\")[\"value\"].sum()\nsubtitle_text = \" · \".join(f\"{g}: ${group_totals[g]}B\" for g in group_order)\n\n# Tight viewport bounds for optimal canvas utilization\npad = 0.12\nx_lo = (df[\"x\"] - df[\"radius\"]).min() - pad\nx_hi = (df[\"x\"] + df[\"radius\"]).max() + pad\ny_lo = (df[\"y\"] - df[\"radius\"]).min() - pad\ny_hi = (df[\"y\"] + df[\"radius\"]).max() + pad\nhalf_span = max(x_hi - x_lo, y_hi - y_lo) / 2\ncx_mid = (x_lo + x_hi) / 2\ncy_mid = (y_lo + y_hi) / 2\n\n# Title with auto-scaled fontsize (67-char baseline)\ntitle = \"bubble-packed · python · plotnine · anyplot.ai\"\ntitle_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\n\n# Plot — layered grammar of graphics composition\nplot = (\n    ggplot()\n    # Layer 1: Circle fills with theme-adaptive borders for group separation\n    + geom_polygon(data=circles_df, mapping=aes(x=\"x\", y=\"y\", fill=\"group\", group=\"circle_id\"), color=PAGE_BG, size=0.6)\n    # Layer 2: Name labels — large bubbles (value ≥ 26)\n    + geom_text(\n        data=labels_df[labels_df[\"value\"] >= 26],\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=3.5,\n        color=\"white\",\n        fontweight=\"bold\",\n        nudge_y=0.09,\n    )\n    # Layer 3: Name labels — medium bubbles (12 ≤ value < 26)\n    + geom_text(\n        data=labels_df[(labels_df[\"value\"] >= 12) & (labels_df[\"value\"] < 26)],\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=3.0,\n        color=\"white\",\n        fontweight=\"bold\",\n        nudge_y=0.06,\n    )\n    # Layer 4: Name labels — small bubbles (value < 12)\n    + geom_text(\n        data=labels_df[labels_df[\"value\"] < 12],\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=2.5,\n        color=\"white\",\n        fontweight=\"bold\",\n    )\n    # Layer 5: Revenue value labels — large bubbles\n    + geom_text(\n        data=labels_df[labels_df[\"value\"] >= 26],\n        mapping=aes(x=\"x\", y=\"y\", label=\"value_label\"),\n        size=3.0,\n        color=\"white\",\n        alpha=0.9,\n        nudge_y=-0.12,\n    )\n    # Layer 6: Revenue value labels — medium bubbles\n    + geom_text(\n        data=labels_df[(labels_df[\"value\"] >= 12) & (labels_df[\"value\"] < 26)],\n        mapping=aes(x=\"x\", y=\"y\", label=\"value_label\"),\n        size=2.5,\n        color=\"white\",\n        alpha=0.85,\n        nudge_y=-0.09,\n    )\n    + scale_fill_manual(values=group_colors, name=\"Tech Segment\")\n    + coord_fixed(xlim=(cx_mid - half_span, cx_mid + half_span), ylim=(cy_mid - half_span, cy_mid + half_span))\n    + labs(title=title, subtitle=subtitle_text)\n    + theme_void()\n    + theme(\n        figure_size=(6, 6),\n        plot_title=element_text(size=title_fontsize, ha=\"center\", weight=\"bold\", color=INK, margin={\"b\": 4}),\n        plot_subtitle=element_text(size=8, ha=\"center\", color=INK_SOFT, margin={\"t\": 3, \"b\": 8}),\n        legend_title=element_text(size=9, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_position=\"right\",\n        legend_direction=\"vertical\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_key=element_rect(fill=ELEVATED_BG, color=\"none\"),\n        legend_key_size=12,\n        plot_background=element_rect(fill=PAGE_BG, color=\"none\"),\n        plot_margin=0.02,\n    )\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=6, height=6, units=\"in\", verbose=False)\n"}