{"spec_id":"circlepacking-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ncirclepacking-basic: Circle Packing Chart\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-11\n\"\"\"\n\nimport math\nimport os\nimport sys\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\n\n\n# Import pygal for styling infrastructure\n# Remove current directory from path to avoid importing this file as pygal\ncwd = os.getcwd()\nif cwd in sys.path:\n    sys.path.remove(cwd)\nif os.path.dirname(os.path.abspath(__file__)) in sys.path:\n    sys.path.remove(os.path.dirname(os.path.abspath(__file__)))\n\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Initialize pygal Style for theme-aware styling\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,\n    title_font_size=28,\n    label_font_size=18,\n)\n\n# Hierarchical data - Company structure with headcount per team\nhierarchy = [\n    (\"company\", None, None, \"TechCorp\"),\n    (\"eng\", \"company\", None, \"Engineering\"),\n    (\"mkt\", \"company\", None, \"Marketing\"),\n    (\"ops\", \"company\", None, \"Operations\"),\n    (\"sales\", \"company\", None, \"Sales\"),\n    (\"eng-backend\", \"eng\", 45, \"Backend\"),\n    (\"eng-frontend\", \"eng\", 38, \"Frontend\"),\n    (\"eng-data\", \"eng\", 28, \"Data\"),\n    (\"eng-devops\", \"eng\", 18, \"DevOps\"),\n    (\"eng-qa\", \"eng\", 15, \"QA\"),\n    (\"mkt-digital\", \"mkt\", 22, \"Digital\"),\n    (\"mkt-brand\", \"mkt\", 16, \"Brand\"),\n    (\"mkt-content\", \"mkt\", 12, \"Content\"),\n    (\"mkt-events\", \"mkt\", 8, \"Events\"),\n    (\"ops-hr\", \"ops\", 18, \"HR\"),\n    (\"ops-finance\", \"ops\", 14, \"Finance\"),\n    (\"ops-facilities\", \"ops\", 10, \"Facilities\"),\n    (\"ops-legal\", \"ops\", 6, \"Legal\"),\n    (\"sales-enterprise\", \"sales\", 35, \"Enterprise\"),\n    (\"sales-smb\", \"sales\", 25, \"SMB\"),\n    (\"sales-partners\", \"sales\", 15, \"Partners\"),\n    (\"sales-support\", \"sales\", 12, \"Support\"),\n]\n\n# Build tree structure\nnodes = {}\nfor id_, parent, value, label in hierarchy:\n    nodes[id_] = {\"id\": id_, \"parent\": parent, \"value\": value, \"label\": label, \"children\": []}\n\nfor node in nodes.values():\n    if node[\"parent\"] and node[\"parent\"] in nodes:\n        nodes[node[\"parent\"]][\"children\"].append(node)\n\n# Calculate values for parent nodes (sum of children)\nstack = [nodes[\"company\"]]\norder = []\nwhile stack:\n    n = stack.pop()\n    order.append(n)\n    stack.extend(n[\"children\"])\nfor n in reversed(order):\n    if n[\"value\"] is None:\n        n[\"value\"] = sum(c[\"value\"] for c in n[\"children\"])\n\nroot = nodes[\"company\"]\n\n# Chart dimensions (square for circle packing)\nWIDTH = 3600\nHEIGHT = 3600\nCENTER_X = WIDTH / 2\nCENTER_Y = HEIGHT / 2 + 60\n\n# Color palette by depth (using Okabe-Ito for first 3 levels)\nDEPTH_COLORS = IMPRINT[:3]\n\nPADDING = 15\n\n# All circles to draw\nall_circles = []\n\n# Root circle\nroot_r = min(WIDTH, HEIGHT) * 0.42\nall_circles.append(\n    {\"x\": CENTER_X, \"y\": CENTER_Y, \"r\": root_r, \"label\": root[\"label\"], \"value\": root[\"value\"], \"depth\": 0}\n)\n\n# Calculate department positions around center\ndepartments = root[\"children\"]\nn_depts = len(departments)\ntotal_dept_value = sum(d[\"value\"] for d in departments)\n\n# Calculate radii for each department based on value (area-proportional)\ndept_total_area = math.pi * (root_r * 0.60) ** 2\ndept_radii = []\nfor dept in departments:\n    dept_area = dept_total_area * (dept[\"value\"] / total_dept_value)\n    dept_radii.append(math.sqrt(dept_area / math.pi))\n\n# Position departments in a ring around center\ndept_ring_radius = root_r * 0.50\ndept_circles = []\nfor i, dept in enumerate(departments):\n    angle = (2 * math.pi * i / n_depts) - math.pi / 2\n    r = dept_radii[i]\n    x = CENTER_X + dept_ring_radius * math.cos(angle)\n    y = CENTER_Y + dept_ring_radius * math.sin(angle)\n    dept_circles.append({\"node\": dept, \"x\": x, \"y\": y, \"r\": r})\n    all_circles.append({\"x\": x, \"y\": y, \"r\": r, \"label\": dept[\"label\"], \"value\": dept[\"value\"], \"depth\": 1})\n\n# Pack teams within each department\nfor dc in dept_circles:\n    dept = dc[\"node\"]\n    teams = dept[\"children\"]\n    if not teams:\n        continue\n\n    dept_x, dept_y, dept_r = dc[\"x\"], dc[\"y\"], dc[\"r\"]\n    n_teams = len(teams)\n\n    # Sort teams by value (largest first)\n    sorted_teams = sorted(teams, key=lambda t: -t[\"value\"])\n    total_team_value = sum(t[\"value\"] for t in teams)\n\n    if n_teams == 1:\n        # Single team - put at center\n        scale_r = dept_r * 0.55\n        team_data = [{\"node\": sorted_teams[0], \"r\": scale_r, \"x\": dept_x, \"y\": dept_y}]\n    else:\n        # Multiple teams - arrange in optimized positions\n        max_single_r = dept_r * 0.30\n        team_data = []\n        for team in sorted_teams:\n            proportion = team[\"value\"] / total_team_value\n            tr = min(max_single_r, dept_r * 0.45 * math.sqrt(proportion * n_teams / math.pi))\n            team_data.append({\"node\": team, \"r\": tr, \"x\": 0.0, \"y\": 0.0})\n\n        # Ring radius for teams\n        max_team_r = max(td[\"r\"] for td in team_data)\n        ring_radius = dept_r - max_team_r - PADDING * 3\n\n        # Scale down if needed\n        if ring_radius < max_team_r:\n            scale = (dept_r * 0.35) / max_team_r\n            for td in team_data:\n                td[\"r\"] *= scale\n            max_team_r = max(td[\"r\"] for td in team_data)\n            ring_radius = dept_r - max_team_r - PADDING * 3\n\n        # Place teams around the ring\n        for i, td in enumerate(team_data):\n            angle = (2 * math.pi * i / n_teams) - math.pi / 2\n            td[\"x\"] = dept_x + ring_radius * 0.55 * math.cos(angle)\n            td[\"y\"] = dept_y + ring_radius * 0.55 * math.sin(angle)\n\n    # Add team circles\n    for tc in team_data:\n        all_circles.append(\n            {\n                \"x\": tc[\"x\"],\n                \"y\": tc[\"y\"],\n                \"r\": tc[\"r\"],\n                \"label\": tc[\"node\"][\"label\"],\n                \"value\": tc[\"node\"][\"value\"],\n                \"depth\": 2,\n            }\n        )\n\n# Build SVG\nsvg_ns = \"http://www.w3.org/2000/svg\"\nET.register_namespace(\"\", svg_ns)\n\nsvg_root = ET.Element(\"svg\", xmlns=svg_ns, width=str(WIDTH), height=str(HEIGHT), viewBox=f\"0 0 {WIDTH} {HEIGHT}\")\n\n# Add background rectangle\nbg_rect = ET.SubElement(svg_root, \"rect\")\nbg_rect.set(\"width\", str(WIDTH))\nbg_rect.set(\"height\", str(HEIGHT))\nbg_rect.set(\"fill\", PAGE_BG)\n\n# Add title\ntitle_elem = ET.SubElement(svg_root, \"text\")\ntitle_elem.set(\"x\", str(WIDTH / 2))\ntitle_elem.set(\"y\", \"70\")\ntitle_elem.set(\"text-anchor\", \"middle\")\ntitle_elem.set(\"fill\", INK)\ntitle_elem.set(\"font-size\", \"28px\")\ntitle_elem.set(\"font-family\", \"sans-serif\")\ntitle_elem.set(\"font-weight\", \"bold\")\ntitle_elem.text = \"circlepacking-basic · pygal · anyplot.ai\"\n\n# Create main group for circles\ng = ET.SubElement(svg_root, \"g\")\ng.set(\"class\", \"circle-packing\")\n\n# Sort by depth (draw outer circles first)\nsorted_circles = sorted(all_circles, key=lambda c: c[\"depth\"])\n\nfor circle in sorted_circles:\n    depth = circle[\"depth\"]\n    color = DEPTH_COLORS[depth]\n    opacity = 0.20 if depth == 0 else (0.40 if depth == 1 else 0.85)\n\n    # Circle element\n    elem = ET.SubElement(g, \"circle\")\n    elem.set(\"cx\", f\"{circle['x']:.1f}\")\n    elem.set(\"cy\", f\"{circle['y']:.1f}\")\n    elem.set(\"r\", f\"{circle['r']:.1f}\")\n    elem.set(\"fill\", color)\n    elem.set(\"fill-opacity\", str(opacity))\n    elem.set(\"stroke\", INK_SOFT)\n    elem.set(\"stroke-width\", \"3\" if depth < 2 else \"2\")\n\n    # Tooltip\n    title = ET.SubElement(elem, \"title\")\n    title.text = f\"{circle['label']}: {circle['value']} people\"\n\n# Draw labels\nlabels_g = ET.SubElement(svg_root, \"g\")\nlabels_g.set(\"class\", \"labels\")\n\nfor circle in sorted_circles:\n    depth = circle[\"depth\"]\n\n    if depth == 0:\n        # Root label at center\n        text = ET.SubElement(labels_g, \"text\")\n        text.set(\"x\", f\"{circle['x']:.1f}\")\n        text.set(\"y\", f\"{circle['y']:.1f}\")\n        text.set(\"text-anchor\", \"middle\")\n        text.set(\"dominant-baseline\", \"middle\")\n        text.set(\"fill\", INK)\n        text.set(\"font-size\", \"64px\")\n        text.set(\"font-family\", \"sans-serif\")\n        text.set(\"font-weight\", \"bold\")\n        text.set(\"opacity\", \"0.5\")\n        text.text = circle[\"label\"]\n    elif depth == 1:\n        # Department label above the circle\n        text = ET.SubElement(labels_g, \"text\")\n        text.set(\"x\", f\"{circle['x']:.1f}\")\n        text.set(\"y\", f\"{circle['y'] - circle['r'] - 20:.1f}\")\n        text.set(\"text-anchor\", \"middle\")\n        text.set(\"fill\", INK)\n        text.set(\"font-size\", \"52px\")\n        text.set(\"font-family\", \"sans-serif\")\n        text.set(\"font-weight\", \"bold\")\n        text.text = circle[\"label\"]\n    elif depth == 2 and circle[\"r\"] > 50:\n        # Team label inside circle (only if circle is large enough)\n        font_size = int(min(circle[\"r\"] * 0.35, 36))\n        text = ET.SubElement(labels_g, \"text\")\n        text.set(\"x\", f\"{circle['x']:.1f}\")\n        text.set(\"y\", f\"{circle['y'] - 8:.1f}\")\n        text.set(\"text-anchor\", \"middle\")\n        text.set(\"dominant-baseline\", \"middle\")\n        text.set(\"fill\", INK)\n        text.set(\"font-size\", str(font_size) + \"px\")\n        text.set(\"font-family\", \"sans-serif\")\n        text.set(\"font-weight\", \"bold\")\n        text.text = circle[\"label\"]\n\n        # Value text below label\n        if circle[\"r\"] > 65:\n            val_text = ET.SubElement(labels_g, \"text\")\n            val_text.set(\"x\", f\"{circle['x']:.1f}\")\n            val_text.set(\"y\", f\"{circle['y'] + font_size * 0.8:.1f}\")\n            val_text.set(\"text-anchor\", \"middle\")\n            val_text.set(\"dominant-baseline\", \"middle\")\n            val_text.set(\"fill\", INK_SOFT)\n            val_text.set(\"font-size\", str(int(font_size * 0.7)) + \"px\")\n            val_text.set(\"font-family\", \"sans-serif\")\n            val_text.text = f\"{circle['value']}\"\n\n# Add legend at bottom\nlegend_y = HEIGHT - 80\nlegend_items = [\n    (\"Root (Company)\", DEPTH_COLORS[0]),\n    (\"Level 1 (Departments)\", DEPTH_COLORS[1]),\n    (\"Level 2 (Teams)\", DEPTH_COLORS[2]),\n]\nlegend_x_start = WIDTH / 2 - 450\nfor i, (label, color) in enumerate(legend_items):\n    x = legend_x_start + i * 350\n    # Circle marker\n    marker = ET.SubElement(svg_root, \"circle\")\n    marker.set(\"cx\", str(x))\n    marker.set(\"cy\", str(legend_y))\n    marker.set(\"r\", \"20\")\n    marker.set(\"fill\", color)\n    marker.set(\"stroke\", INK_SOFT)\n    marker.set(\"stroke-width\", \"2\")\n    # Label\n    lbl = ET.SubElement(svg_root, \"text\")\n    lbl.set(\"x\", str(x + 35))\n    lbl.set(\"y\", str(legend_y + 8))\n    lbl.set(\"fill\", INK)\n    lbl.set(\"font-size\", \"16px\")\n    lbl.set(\"font-family\", \"sans-serif\")\n    lbl.text = label\n\n# Render to files\nsvg_output = ET.tostring(svg_root, encoding=\"unicode\")\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg_output)\n\ncairosvg.svg2png(bytestring=svg_output.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n"}