{"spec_id":"circlepacking-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ncirclepacking-basic: Circle Packing Chart\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 95/100 | Updated: 2026-05-11\n\"\"\"\n\nimport os\nimport sys\n\n\n# Work around the naming conflict (local file named altair.py shadows the package)\n# Temporarily remove current directory from path\ncwd = os.getcwd()\nif cwd in sys.path:\n    sys.path.remove(cwd)\ncurrent_dir = os.path.dirname(os.path.abspath(__file__))\nif current_dir in sys.path:\n    sys.path.remove(current_dir)\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\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# Data - Company budget allocation by department and team (values in $K)\nnp.random.seed(42)\n\n# Leaf nodes (teams) with their budgets\nteams = [\n    # Engineering Department\n    {\"id\": \"eng-backend\", \"parent\": \"Engineering\", \"label\": \"Backend\", \"value\": 180},\n    {\"id\": \"eng-frontend\", \"parent\": \"Engineering\", \"label\": \"Frontend\", \"value\": 150},\n    {\"id\": \"eng-devops\", \"parent\": \"Engineering\", \"label\": \"DevOps\", \"value\": 90},\n    {\"id\": \"eng-mobile\", \"parent\": \"Engineering\", \"label\": \"Mobile\", \"value\": 120},\n    # Marketing Department\n    {\"id\": \"mkt-digital\", \"parent\": \"Marketing\", \"label\": \"Digital\", \"value\": 100},\n    {\"id\": \"mkt-content\", \"parent\": \"Marketing\", \"label\": \"Content\", \"value\": 80},\n    {\"id\": \"mkt-brand\", \"parent\": \"Marketing\", \"label\": \"Brand\", \"value\": 60},\n    # Operations Department\n    {\"id\": \"ops-support\", \"parent\": \"Operations\", \"label\": \"Support\", \"value\": 70},\n    {\"id\": \"ops-hr\", \"parent\": \"Operations\", \"label\": \"HR\", \"value\": 50},\n    {\"id\": \"ops-admin\", \"parent\": \"Operations\", \"label\": \"Admin\", \"value\": 40},\n    # Sales Department\n    {\"id\": \"sales-enterprise\", \"parent\": \"Sales\", \"label\": \"Enterprise\", \"value\": 130},\n    {\"id\": \"sales-smb\", \"parent\": \"Sales\", \"label\": \"SMB\", \"value\": 85},\n    {\"id\": \"sales-partners\", \"parent\": \"Sales\", \"label\": \"Partners\", \"value\": 55},\n]\n\n# Calculate department totals\ndept_totals = {}\nfor t in teams:\n    dept_totals[t[\"parent\"]] = dept_totals.get(t[\"parent\"], 0) + t[\"value\"]\n\n# Okabe-Ito palette for departments (theme-independent data colors)\nokabe_ito = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\ndept_colors = {\n    \"Engineering\": okabe_ito[0],  # bluish green\n    \"Sales\": okabe_ito[1],  # vermillion\n    \"Marketing\": okabe_ito[2],  # blue\n    \"Operations\": okabe_ito[3],  # reddish purple\n}\n\n# Scale value to radius (sqrt for area-proportional sizing)\nmax_value = max(t[\"value\"] for t in teams)\nmin_radius = 25\nmax_radius = 55\n\n\ndef get_team_radius(value):\n    \"\"\"Calculate radius from value using sqrt for area-proportional sizing.\"\"\"\n    return min_radius + (max_radius - min_radius) * np.sqrt(value / max_value)\n\n\ndef pack_circles_in_parent(circles, parent_center, parent_radius):\n    \"\"\"\n    Pack child circles inside a parent circle using force-directed placement.\n    Returns list of (x, y) positions for each circle.\n    \"\"\"\n    n = len(circles)\n    if n == 0:\n        return []\n\n    radii = [c[\"radius\"] for c in circles]\n\n    # Start with circular arrangement\n    positions = []\n    if n == 1:\n        positions = [(parent_center[0], parent_center[1])]\n    else:\n        arrangement_r = parent_radius * 0.4\n        for i in range(n):\n            angle = 2 * np.pi * i / n - np.pi / 2\n            x = parent_center[0] + arrangement_r * np.cos(angle)\n            y = parent_center[1] + arrangement_r * np.sin(angle)\n            positions.append((x, y))\n\n    # Force-directed relaxation to remove overlaps\n    for _ in range(100):\n        forces = [(0.0, 0.0) for _ in range(n)]\n\n        # Repulsion between circles\n        for i in range(n):\n            for j in range(i + 1, n):\n                dx = positions[i][0] - positions[j][0]\n                dy = positions[i][1] - positions[j][1]\n                dist = np.sqrt(dx * dx + dy * dy)\n                min_dist = radii[i] + radii[j] + 3  # 3px gap\n\n                if dist < min_dist and dist > 0:\n                    overlap = min_dist - dist\n                    fx = (dx / dist) * overlap * 0.5\n                    fy = (dy / dist) * overlap * 0.5\n                    forces[i] = (forces[i][0] + fx, forces[i][1] + fy)\n                    forces[j] = (forces[j][0] - fx, forces[j][1] - fy)\n\n        # Keep circles inside parent\n        for i in range(n):\n            dx = positions[i][0] - parent_center[0]\n            dy = positions[i][1] - parent_center[1]\n            dist_from_center = np.sqrt(dx * dx + dy * dy)\n            max_dist = parent_radius - radii[i] - 5\n\n            if dist_from_center > max_dist and dist_from_center > 0:\n                scale = max_dist / dist_from_center\n                positions[i] = (parent_center[0] + dx * scale, parent_center[1] + dy * scale)\n\n        # Apply forces\n        positions = [(positions[i][0] + forces[i][0], positions[i][1] + forces[i][1]) for i in range(n)]\n\n    return positions\n\n\n# Build circle packing structure\ncircles_data = []\n\n# Calculate department radii based on team radii\ndept_radii = {}\nfor dept in dept_totals.keys():\n    dept_teams = [t for t in teams if t[\"parent\"] == dept]\n    team_radii = [get_team_radius(t[\"value\"]) for t in dept_teams]\n    # Department radius should contain all teams with padding\n    total_team_area = sum(r * r * np.pi for r in team_radii)\n    dept_radii[dept] = np.sqrt(total_team_area / np.pi) * 1.8 + 20\n\n# Sort departments by radius (largest first for better packing)\nsorted_depts = sorted(dept_radii.keys(), key=lambda d: dept_radii[d], reverse=True)\n\n# Calculate root circle radius\ntotal_dept_area = sum(r * r * np.pi for r in dept_radii.values())\nroot_radius = np.sqrt(total_dept_area / np.pi) * 1.6 + 30\n\n# Position departments inside root circle\ndept_circles = [{\"name\": dept, \"radius\": dept_radii[dept]} for dept in sorted_depts]\ndept_positions = pack_circles_in_parent(dept_circles, (0, 0), root_radius)\n\n# Add root circle (Company)\ncompany_total = sum(t[\"value\"] for t in teams)\ncircles_data.append(\n    {\n        \"x\": 0,\n        \"y\": 0,\n        \"radius\": root_radius,\n        \"label\": \"Company\",\n        \"value\": company_total,\n        \"depth\": 0,\n        \"color\": okabe_ito[0],  # Use brand color for root\n        \"department\": \"Company\",\n    }\n)\n\n# Add departments and their teams\nfor i, dept in enumerate(sorted_depts):\n    dept_x, dept_y = dept_positions[i]\n    dept_r = dept_radii[dept]\n    dept_value = dept_totals[dept]\n\n    # Add department circle\n    circles_data.append(\n        {\n            \"x\": dept_x,\n            \"y\": dept_y,\n            \"radius\": dept_r,\n            \"label\": dept,\n            \"value\": dept_value,\n            \"depth\": 1,\n            \"color\": dept_colors[dept],\n            \"department\": dept,\n        }\n    )\n\n    # Position teams inside department\n    dept_teams = [t for t in teams if t[\"parent\"] == dept]\n    team_circles = [{\"name\": t[\"label\"], \"radius\": get_team_radius(t[\"value\"])} for t in dept_teams]\n    team_positions = pack_circles_in_parent(team_circles, (dept_x, dept_y), dept_r)\n\n    for j, t in enumerate(dept_teams):\n        tx, ty = team_positions[j]\n        team_r = get_team_radius(t[\"value\"])\n        circles_data.append(\n            {\n                \"x\": tx,\n                \"y\": ty,\n                \"radius\": team_r,\n                \"label\": t[\"label\"],\n                \"value\": t[\"value\"],\n                \"depth\": 2,\n                \"color\": dept_colors[dept],\n                \"department\": dept,\n            }\n        )\n\n# Create DataFrame\ndf = pd.DataFrame(circles_data)\n\n# Create display text\ndf[\"display_value\"] = df[\"value\"].apply(lambda v: f\"${v}K\")\ndf[\"display_text\"] = df.apply(\n    lambda r: f\"{r['label']}\\n{r['display_value']}\" if r[\"depth\"] == 2 else r[\"label\"], axis=1\n)\n\n# Separate by depth for layered rendering\ndf_root = df[df[\"depth\"] == 0].copy()\ndf_depts = df[df[\"depth\"] == 1].copy()\ndf_teams = df[df[\"depth\"] == 2].copy()\n\n# Calculate dynamic scales based on actual data\nx_min, x_max = df[\"x\"].min() - df[\"radius\"].max(), df[\"x\"].max() + df[\"radius\"].max()\ny_min, y_max = df[\"y\"].min() - df[\"radius\"].max(), df[\"y\"].max() + df[\"radius\"].max()\n\n# Add padding for legend on the right\npadding = 50\nx_domain = [x_min - padding, x_max + padding + 180]  # Extra space for legend\ny_domain = [y_min - padding, y_max + padding]\n\n# Size scale (radius squared for area encoding)\nsize_domain = [df[\"radius\"].min(), df[\"radius\"].max()]\nsize_range = [df[\"radius\"].min() ** 2 * 2.5, df[\"radius\"].max() ** 2 * 2.5]\n\n# Shared scales\nx_scale = alt.Scale(domain=list(x_domain))\ny_scale = alt.Scale(domain=list(y_domain))\nsize_scale = alt.Scale(domain=size_domain, range=size_range)\n\n# Root circle layer (outermost - Company)\nroot_layer = (\n    alt.Chart(df_root)\n    .mark_circle(opacity=0.15, stroke=INK_SOFT, strokeWidth=3)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=x_scale),\n        y=alt.Y(\"y:Q\", axis=None, scale=y_scale),\n        size=alt.Size(\"radius:Q\", scale=size_scale, legend=None),\n        color=alt.value(okabe_ito[0]),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Name\"), alt.Tooltip(\"display_value:N\", title=\"Budget\")],\n    )\n)\n\n# Root label\nroot_label = (\n    alt.Chart(df_root)\n    .mark_text(color=INK, fontWeight=\"bold\", fontSize=20, dy=-root_radius + 30)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=x_scale),\n        y=alt.Y(\"y:Q\", axis=None, scale=y_scale),\n        text=alt.value(\"Company Budget\"),\n    )\n)\n\n# Department circles layer\ndept_layer = (\n    alt.Chart(df_depts)\n    .mark_circle(opacity=0.4, stroke=INK_SOFT, strokeWidth=2)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=x_scale),\n        y=alt.Y(\"y:Q\", axis=None, scale=y_scale),\n        size=alt.Size(\"radius:Q\", scale=size_scale, legend=None),\n        color=alt.Color(\"color:N\", scale=None),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Department\"), alt.Tooltip(\"display_value:N\", title=\"Budget\")],\n    )\n)\n\n# Team circles layer\nteam_layer = (\n    alt.Chart(df_teams)\n    .mark_circle(opacity=0.85, stroke=INK_SOFT, strokeWidth=1.5)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=x_scale),\n        y=alt.Y(\"y:Q\", axis=None, scale=y_scale),\n        size=alt.Size(\"radius:Q\", scale=size_scale, legend=None),\n        color=alt.Color(\"color:N\", scale=None),\n        tooltip=[\n            alt.Tooltip(\"label:N\", title=\"Team\"),\n            alt.Tooltip(\"department:N\", title=\"Department\"),\n            alt.Tooltip(\"display_value:N\", title=\"Budget\"),\n        ],\n    )\n)\n\n# Department labels - positioned at center-top of each department circle\ndf_depts_labels = df_depts.copy()\ndf_depts_labels[\"label_y\"] = df_depts_labels[\"y\"] + df_depts_labels[\"radius\"] * 0.6\n\ndept_label_layer = (\n    alt.Chart(df_depts_labels)\n    .mark_text(color=INK, fontWeight=\"bold\", fontSize=16)\n    .encode(x=alt.X(\"x:Q\", axis=None, scale=x_scale), y=alt.Y(\"label_y:Q\", axis=None, scale=y_scale), text=\"label:N\")\n)\n\n# Team labels\nteam_label_layer = (\n    alt.Chart(df_teams)\n    .mark_text(color=INK, fontWeight=\"bold\", fontSize=11, lineBreak=\"\\n\")\n    .encode(x=alt.X(\"x:Q\", axis=None, scale=x_scale), y=alt.Y(\"y:Q\", axis=None, scale=y_scale), text=\"display_text:N\")\n)\n\n# Legend positioned inside the visible area (right side)\nlegend_x = x_max + 60\nlegend_y_start = 80\nlegend_spacing = 45\n\nlegend_df = pd.DataFrame(\n    [\n        {\"department\": dept, \"color\": dept_colors[dept], \"x\": legend_x, \"y\": legend_y_start - i * legend_spacing}\n        for i, dept in enumerate([\"Engineering\", \"Sales\", \"Marketing\", \"Operations\"])\n    ]\n)\n\n# Legend circles\nlegend_circles = (\n    alt.Chart(legend_df)\n    .mark_circle(size=350, opacity=0.85, stroke=INK_SOFT, strokeWidth=1)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=x_scale),\n        y=alt.Y(\"y:Q\", axis=None, scale=y_scale),\n        color=alt.Color(\"color:N\", scale=None),\n    )\n)\n\n# Legend text\nlegend_text = (\n    alt.Chart(legend_df)\n    .mark_text(align=\"left\", dx=18, fontSize=14, fontWeight=\"bold\", color=INK_SOFT)\n    .encode(x=alt.X(\"x:Q\", axis=None, scale=x_scale), y=alt.Y(\"y:Q\", axis=None, scale=y_scale), text=\"department:N\")\n)\n\n# Combine all layers\nchart = (\n    alt.layer(\n        root_layer, root_label, dept_layer, team_layer, dept_label_layer, team_label_layer, legend_circles, legend_text\n    )\n    .properties(\n        width=1200,\n        height=1200,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"circlepacking-basic · altair · anyplot.ai\", fontSize=28, fontWeight=\"bold\", anchor=\"middle\", color=INK\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n)\n\n# Save outputs (3600x3600 px with scale_factor=3.0)\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nchart.save(os.path.join(script_dir, f\"plot-{THEME}.png\"), scale_factor=3.0)\nchart.save(os.path.join(script_dir, f\"plot-{THEME}.html\"))\n"}