{"spec_id":"donut-nested","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ndonut-nested: Nested Donut Chart\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 85/100 | Updated: 2026-08-18\n\"\"\"\n\nimport math\nimport os\nimport re\nfrom xml.sax.saxutils import escape\n\nimport cairosvg\nimport pygal\nfrom pygal.style import Style\n\n\ndef lerp_hex(c0, c1, t):\n    \"\"\"Interpolate between two hex colors (see default-style-guide.md continuous-data lerp).\"\"\"\n    r0, g0, b0 = (int(c0[i : i + 2], 16) for i in (1, 3, 5))\n    r1, g1, b1 = (int(c1[i : i + 2], 16) for i in (1, 3, 5))\n    r, g, b = (int(round(a + (b - a) * t)) for a, b in ((r0, r1), (g0, g1), (b0, b1)))\n    return f\"#{r:02X}{g:02X}{b:02X}\"\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Background\" + \"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_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — one hue family per department, canonical order\n# since departments are abstract organizational units (no semantic color cue).\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\")\n\n# Data: annual budget allocation by department (inner ring) and expense\n# category within each department (outer ring), in $K. Every department has\n# the same number of categories so pygal's dual-series pie doesn't need to\n# zero-pad shorter series.\nbudget = {\n    \"Engineering\": {\"Salaries\": 850, \"Cloud Infrastructure\": 220, \"Software Licenses\": 90, \"R&D Equipment\": 140},\n    \"Marketing\": {\n        \"Digital Advertising\": 280,\n        \"Events & Trade Shows\": 150,\n        \"Content Production\": 90,\n        \"Marketing Analytics\": 60,\n    },\n    \"Operations\": {\"Facilities\": 260, \"Logistics\": 150, \"Equipment Maintenance\": 80, \"Safety & Compliance\": 50},\n    \"Sales\": {\"Commissions\": 380, \"Travel\": 120, \"CRM Tools\": 60, \"Sales Training\": 40},\n}\n\n# Each department's categories get a shared hue tinted toward the elevated\n# surface color, lightest-first by descending value — consistent color\n# families per parent, varying lightness for children (spec requirement).\n# Cap at 0.60 (not 0.75) so even the lightest tint keeps enough contrast\n# against the page background to stay legible in its own legend swatch.\nTINT_STEPS = (0.28, 0.42, 0.52, 0.60)\n\n# Only the top-2 (by value) categories per department are labeled directly on\n# the ring; the other 2 are \"smaller\" and are surfaced via a callout legend\n# below the chart instead (spec: \"labels on larger segments, legend for\n# smaller ones\"). pygal's own per-slice text draw silently drops any slice\n# whose angle is < 0.3 rad, which is what dropped 9/16 child labels before —\n# so this script never asks pygal to draw slice text at all (print_values /\n# print_labels are both off) and places every label itself from the exact\n# angles it computes, which guarantees all 4 department totals and the 8\n# larger-category labels are drawn, with no dependency on that cutoff.\nLABELED_RANKS = 2\n\n# Plot — pygal's Pie renders a native two-level \"dual\" donut when a series\n# carries multiple values: each department becomes a full inner wedge, and\n# its categories become a thin outer ring aligned to the same angular span.\ntitle = \"donut-nested · python · pygal · anyplot.ai\"\ntitle_font_size = round(66 * min(1.0, 67 / len(title)))\n\nCANVAS = 2400\nBASE_MARGIN = 90\n# Extra bottom margin reserved for the manual \"smaller categories\" callout\n# legend (2 rows x 4 department columns).\nCALLOUT_ROWS = 2\nCALLOUT_ROW_HEIGHT = 110\nCALLOUT_TOP_PAD = 50\nCALLOUT_HEIGHT = CALLOUT_TOP_PAD + CALLOUT_ROWS * CALLOUT_ROW_HEIGHT\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    title_font_size=title_font_size,\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    opacity=1,\n    opacity_hover=1,\n)\n\nchart = pygal.Pie(\n    width=CANVAS,\n    height=CANVAS,\n    style=custom_style,\n    title=title,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    print_values=False,\n    print_labels=False,\n    margin=BASE_MARGIN,\n    margin_bottom=BASE_MARGIN + CALLOUT_HEIGHT,\n)\n\ndepartments = list(budget.items())\nfor i, (department, categories) in enumerate(departments):\n    base = IMPRINT_PALETTE[i % len(IMPRINT_PALETTE)]\n    ordered = sorted(categories.items(), key=lambda kv: kv[1], reverse=True)\n    values = [\n        {\"value\": amount, \"color\": lerp_hex(base, ELEVATED_BG, TINT_STEPS[rank % len(TINT_STEPS)])}\n        for rank, (category, amount) in enumerate(ordered)\n    ]\n    chart.add(department, values)\n\n# Save — pygal has no native donut hole for the inner ring of a dual pie, and\n# its own slice-label placement silently skips any slice whose angle is\n# < 0.3 rad (the actual root cause of last review's missing child labels).\n# So every piece of on-chart text is authored here instead, from the exact\n# same start-angle/radius math pygal's Pie graph uses internally (see\n# pygal/graph/pie.py Pie.slice + pygal/util.py coord_project), applied to\n# the *raw* budget data — never scraped from already-rendered text.\nsvg_markup = chart.render().decode(\"utf-8\")\n\n# The rendered <path class=\"big_slice\"> for a dual pie is\n# 'M<pt0> A<big_radius> ... <pt1> L<center> A0 ... <center> z' (small_radius\n# is 0 for the big_slice, which collapses both its inner-arc endpoints onto\n# the true circle center) — the one piece of geometry that must be measured\n# from the render, since it depends on pygal's title/legend layout math.\ncenter_match = re.search(\n    r'class=\"big_slice\"><path d=\"M[\\d.]+ [\\d.]+ A([\\d.]+) [\\d.]+ 0 \\d \\d ' r\"[\\d.]+ [\\d.]+ L([\\d.]+) ([\\d.]+)\",\n    svg_markup,\n)\ndept_wedge_radius, center_x, center_y = (float(g) for g in center_match.groups())\n# The slices live inside <g transform=\"translate(ox, oy)\" class=\"plot\">, so\n# the coordinates above are in that local space — our overlay is appended at\n# the SVG root (untranslated), so the plot's own offset must be added back.\noffset_match = re.search(r'<g transform=\"translate\\(([\\d.]+), ([\\d.]+)\\)\" class=\"plot\">', svg_markup)\nplot_offset_x, plot_offset_y = (float(g) for g in offset_match.groups())\ncenter_x += plot_offset_x\ncenter_y += plot_offset_y\n# dept_wedge_radius is pygal's big_radius for the big_slice, i.e. radius * .9\n# (see Pie.slice: dual big_slice uses radius * .9, small_radius 0); the outer\n# thin category ring then runs from dept_wedge_radius out to radius * 1 / .9.\nouter_radius = dept_wedge_radius / 0.9\n\nhole_radius = dept_wedge_radius * 0.42\ndept_label_radius = (hole_radius + dept_wedge_radius) / 2\nchild_label_radius = (dept_wedge_radius + outer_radius) / 2\nring_gap_stroke = max(6.0, outer_radius * 0.012)\n\n\ndef polar_to_xy(radius, theta):\n    \"\"\"Same projection pygal's coord_abs_project uses: angle 0 = top, clockwise.\"\"\"\n    return (center_x + radius * math.sin(theta), center_y - radius * math.cos(theta))\n\n\ndef svg_text(x, y, font_size, fill, anchor, weight, content):\n    return (\n        f'<text x=\"{x:.1f}\" y=\"{y:.1f}\" font-size=\"{font_size}\" font-family=\"sans-serif\" '\n        f'fill=\"{fill}\" text-anchor=\"{anchor}\" font-weight=\"{weight}\">{escape(content)}</text>'\n    )\n\n\noverlay = [\n    f'<circle cx=\"{center_x}\" cy=\"{center_y}\" r=\"{dept_wedge_radius + ring_gap_stroke / 2}\" '\n    f'fill=\"none\" stroke=\"{PAGE_BG}\" stroke-width=\"{ring_gap_stroke}\"/>',\n    f'<circle cx=\"{center_x}\" cy=\"{center_y}\" r=\"{hole_radius}\" fill=\"{PAGE_BG}\" stroke=\"none\"/>',\n]\n\ngrand_total = sum(amount for categories in budget.values() for amount in categories.values())\ncurrent_angle = 0.0\ncallout_entries = []  # (department_index, category, amount, color) for the smaller-half legend\n\nfor i, (department, categories) in enumerate(departments):\n    base = IMPRINT_PALETTE[i % len(IMPRINT_PALETTE)]\n    ordered = sorted(categories.items(), key=lambda kv: kv[1], reverse=True)\n    dept_total = sum(categories.values())\n    dept_start_angle = current_angle\n\n    for rank, (category, amount) in enumerate(ordered):\n        child_angle = 2 * math.pi * amount / grand_total\n        child_mid_angle = current_angle + child_angle / 2\n        color = lerp_hex(base, ELEVATED_BG, TINT_STEPS[rank % len(TINT_STEPS)])\n        if rank < LABELED_RANKS:\n            x, y = polar_to_xy(child_label_radius, child_mid_angle)\n            overlay.append(svg_text(x, y + 12, 34, INK, \"middle\", \"normal\", f\"{category} (${amount}K)\"))\n        else:\n            callout_entries.append((i, category, amount, color))\n        current_angle += child_angle\n\n    dept_angle = current_angle - dept_start_angle\n    dept_mid_angle = dept_start_angle + dept_angle / 2\n    x, y = polar_to_xy(dept_label_radius, dept_mid_angle)\n    overlay.append(svg_text(x, y - 14, 40, INK, \"middle\", \"bold\", department))\n    overlay.append(svg_text(x, y + 34, 40, INK, \"middle\", \"bold\", f\"${dept_total}K\"))\n\n# Callout legend for the two smaller (unlabeled) categories per department —\n# satisfies the spec's \"labels on larger segments, legend for smaller ones\".\nside_pad = 40\ncol_width = (CANVAS - 2 * side_pad) / len(departments)\ncallout_top = CANVAS - (BASE_MARGIN + CALLOUT_HEIGHT) + CALLOUT_TOP_PAD\nswatch_r = 14\nfor entry_index, (dept_index, category, amount, color) in enumerate(callout_entries):\n    row = entry_index % 2\n    col_left = side_pad + col_width * dept_index\n    row_y = callout_top + row * CALLOUT_ROW_HEIGHT + CALLOUT_ROW_HEIGHT / 2\n    text_x = col_left + 2 * swatch_r + 16\n    overlay.append(f'<circle cx=\"{col_left + swatch_r}\" cy=\"{row_y}\" r=\"{swatch_r}\" fill=\"{color}\"/>')\n    overlay.append(svg_text(text_x, row_y + 10, 28, INK, \"start\", \"normal\", f\"{category} (${amount}K)\"))\n\nsvg_markup = svg_markup.replace(\"</svg>\", \"\".join(overlay) + \"</svg>\")\n\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg_markup)\n\ncairosvg.svg2png(bytestring=svg_markup.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n"}