{"spec_id":"donut-nested","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\ndonut-nested: Nested Donut Chart\nLibrary: plotnine 0.15.8 | Python 3.13.15\nQuality: 88/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script's directory from sys.path to avoid circular imports\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != script_dir]\n\nimport math\n\nimport pandas as pd\nfrom matplotlib.font_manager import FontProperties\nfrom matplotlib.path import Path as MplPath\nfrom matplotlib.textpath import TextPath\nfrom plotnine import (\n    aes,\n    coord_fixed,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_point,\n    geom_polygon,\n    geom_text,\n    ggplot,\n    labs,\n    scale_color_identity,\n    scale_fill_identity,\n    scale_size_identity,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\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\"\nBORDER_COLOR = \"#E8E6DC\" if THEME == \"light\" else \"#353531\"\n\n# Imprint categorical palette (positions 1-5) — regions are an abstract\n# category, so canonical ordinal order applies (default-style-guide.md\n# \"Semantic Exception\": abstract categories keep canonical order).\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data - Global technology revenue ($M) by region (inner ring) and product\n# line (outer ring). Region count (5) and children-per-region (2-5) diverge\n# from the 4-department budget scenario used by sibling library implementations.\ndata = {\n    \"North America\": [(\"Software\", 420), (\"Hardware\", 260), (\"Cloud Services\", 240), (\"Support\", 130)],\n    \"Asia Pacific\": [\n        (\"Software\", 310),\n        (\"Hardware\", 220),\n        (\"Cloud Services\", 180),\n        (\"Consulting\", 110),\n        (\"Support\", 70),\n    ],\n    \"Europe\": [(\"Software\", 300), (\"Cloud Services\", 270), (\"Hardware\", 190)],\n    \"Latin America\": [(\"Software\", 180), (\"Hardware\", 140)],\n    \"Middle East & Africa\": [(\"Hardware\", 150), (\"Cloud Services\", 90), (\"Support\", 70)],\n}\n\n# Shortened form used wherever a small-share region's full name would crowd\n# its neighbor (inner-ring label, legend entry) — only \"Middle East & Africa\"\n# is long enough to need it.\nshort_names = {\"Middle East & Africa\": \"MEA\"}\n\n\ndef lighten_hex(hex_color, amount):\n    \"\"\"Blend a hex color toward white by `amount` (0-1) for a tonal family member.\"\"\"\n    r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5))\n    r = round(r + (255 - r) * amount)\n    g = round(g + (255 - g) * amount)\n    b = round(b + (255 - b) * amount)\n    return f\"#{r:02X}{g:02X}{b:02X}\"\n\n\ndef contrast_text_color(hex_color):\n    \"\"\"Pick dark or light ink for legibility against a specific wedge fill —\n    based on that fill's own brightness, independent of the page theme, since\n    the palest tonal tints need dark text in both themes.\"\"\"\n    r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5))\n    brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255\n    return \"#1A1A17\" if brightness > 0.55 else \"#F0EFE8\"\n\n\n# Imprint families - each parent's Imprint hue plus lighter tonal shades for\n# its own children in the outer ring (consistent hue, varying lightness).\ncolor_families = {}\nfor parent, base_color in zip(data.keys(), IMPRINT_PALETTE, strict=True):\n    children = data[parent]\n    n_children = len(children)\n    tints = [lighten_hex(base_color, (i / n_children) * 0.6) for i in range(n_children)]\n    color_families[parent] = (base_color, tints)\n\n# Calculate totals for each parent\nparent_totals = {parent: sum(v for _, v in children) for parent, children in data.items()}\ngrand_total = sum(parent_totals.values())\n\n# Ring dimensions\ninner_ring_inner = 60  # Inner donut hole\ninner_ring_outer = 100  # Inner ring outer radius\nouter_ring_inner = 110  # Outer ring inner radius (gap for separation)\nouter_ring_outer = 150  # Outer ring outer radius\n\nlabel_threshold = 100  # $M — outer segments below this get a legend entry instead\n\n# Coordinate-to-inch conversion for measuring real rendered text width: the\n# panel spans FIGURE_WIDTH_IN inches across X_RANGE_UNITS data units (see\n# scale_x_continuous below), so a font-metric extent in points converts to\n# data units via points -> inches -> data units.\nFIGURE_WIDTH_IN = 6\nX_RANGE_UNITS = 380  # matches scale_x_continuous limits=(-190, 190)\nUNITS_PER_INCH = X_RANGE_UNITS / FIGURE_WIDTH_IN\n\n\ndef text_extents_units(text, font_size_pt, bold=False):\n    \"\"\"Rendered (width, height) of a label in plot data-coordinate units,\n    from actual font metrics rather than a guessed char-count heuristic — so\n    label-fit checks match what will really be rasterized.\"\"\"\n    prop = FontProperties(size=font_size_pt, weight=\"bold\" if bold else \"normal\")\n    extents = TextPath((0, 0), text, size=font_size_pt, prop=prop).get_extents()\n    return (extents.width / 72) * UNITS_PER_INCH, (extents.height / 72) * UNITS_PER_INCH\n\n\ndef label_fits(center_x, center_y, width, height, segment_path):\n    \"\"\"Whether a horizontal label's bounding box stays inside a wedge polygon.\n\n    A wedge's angular sweep translates to horizontal room very unevenly: near\n    the top/bottom of the ring a wide angle gives lots of horizontal space,\n    but the same angle near the left/right of the ring gives almost none\n    (there the sweep is mostly vertical) — so comparing text width against\n    the tangential arc length alone is unreliable. Sampling the label's\n    bounding-box edges against the real polygon (inner/outer arcs and the two\n    radial edges) via matplotlib's point-in-path test catches that directly.\n    \"\"\"\n    half_w, half_h = width / 2, height / 2\n    fracs = (-1, -0.5, 0, 0.5, 1)\n    corners = [(center_x + fx * half_w, center_y + fy * half_h) for fx in fracs for fy in fracs]\n    return bool(segment_path.contains_points(corners).all())\n\n\ndef create_annular_segment(start_angle, end_angle, inner_radius, outer_radius, n_points=50):\n    \"\"\"Create polygon points for an annular (donut) segment.\"\"\"\n    # Add small gap between segments\n    gap = 0.02\n    start_angle += gap\n    end_angle -= gap\n\n    points = []\n    # Inner arc (from start to end)\n    for angle in [start_angle + (end_angle - start_angle) * i / (n_points - 1) for i in range(n_points)]:\n        points.append((inner_radius * math.cos(angle), inner_radius * math.sin(angle)))\n\n    # Outer arc (from end back to start)\n    for angle in [end_angle + (start_angle - end_angle) * i / (n_points - 1) for i in range(n_points)]:\n        points.append((outer_radius * math.cos(angle), outer_radius * math.sin(angle)))\n\n    # Close the polygon\n    points.append(points[0])\n    return points\n\n\n# Build polygon data for inner ring (regions)\ninner_rows = []\ncurrent_angle = math.pi / 2  # Start at top (12 o'clock)\n\nparent_angles = {}  # Track start/end angles for each parent\n\nfor parent in data.keys():\n    parent_total = parent_totals[parent]\n    sweep = (parent_total / grand_total) * 2 * math.pi\n    end_angle = current_angle - sweep  # Clockwise\n\n    parent_angles[parent] = (current_angle, end_angle)\n\n    points = create_annular_segment(end_angle, current_angle, inner_ring_inner, inner_ring_outer)\n    for order, (x, y) in enumerate(points):\n        inner_rows.append({\"x\": x, \"y\": y, \"segment\": parent, \"order\": order, \"fill\": color_families[parent][0]})\n\n    current_angle = end_angle\n\ninner_df = pd.DataFrame(inner_rows)\n\n# Build polygon data for outer ring (product lines). Outer-ring labels and\n# legend entries are collected in the same loop since both need the per-child\n# color already computed here: segments >= label_threshold get an inline\n# label; smaller ones fall back to a legend entry per the spec's \"labels on\n# larger segments, use legend for smaller ones\" guidance.\nouter_rows = []\nouter_labels = []\nlegend_entries = []\n\n# Font sizes tried in order (largest first), each checked at several radii\n# (outermost first, for maximum separation from the inner ring's labels)\n# before falling back to the legend. A label that doesn't fit at any\n# size/radius combination would either spill into a neighboring segment's\n# label (as seen with Asia Pacific's Consulting/Cloud Services collision) or\n# overflow its own wedge onto the page background — where, because\n# contrast_text_color picks ink from the wedge fill only, dark text chosen\n# for a light tint can land on the dark-theme page and become invisible (the\n# North America \"Cloud Services\" clipping seen in dark only).\nouter_label_font_sizes = [7, 6, 5.5, 5]\nouter_label_radius_candidates = list(range(outer_ring_outer - 5, outer_ring_inner + 5, -5)) + [outer_ring_inner + 6]\n\nfor parent, children in data.items():\n    parent_start, parent_end = parent_angles[parent]\n    parent_total = parent_totals[parent]\n\n    child_current_angle = parent_start\n    child_colors = color_families[parent][1]\n\n    for i, (child_name, child_value) in enumerate(children):\n        child_sweep = (child_value / parent_total) * (parent_start - parent_end)\n        child_end_angle = child_current_angle - child_sweep\n        color = child_colors[i]\n\n        points = create_annular_segment(child_end_angle, child_current_angle, outer_ring_inner, outer_ring_outer)\n        segment_id = f\"{parent}_{child_name}\"\n\n        for order, (x, y) in enumerate(points):\n            outer_rows.append({\"x\": x, \"y\": y, \"segment\": segment_id, \"order\": order, \"fill\": color})\n\n        if child_value >= label_threshold:\n            mid_angle = (child_current_angle + child_end_angle) / 2\n            segment_path = MplPath(points)\n\n            placement = None\n            for font_size in outer_label_font_sizes:\n                text_w, text_h = text_extents_units(child_name, font_size)\n                for radius in outer_label_radius_candidates:\n                    cx, cy = radius * math.cos(mid_angle), radius * math.sin(mid_angle)\n                    if label_fits(cx, cy, text_w, text_h, segment_path):\n                        placement = (cx, cy, font_size)\n                        break\n                if placement is not None:\n                    break\n\n            if placement is None:\n                legend_entries.append({\"parent\": parent, \"child\": child_name, \"color\": color})\n            else:\n                label_x, label_y, fitted_size = placement\n                outer_labels.append(\n                    {\n                        \"x\": label_x,\n                        \"y\": label_y,\n                        \"label\": child_name,\n                        \"label_color\": contrast_text_color(color),\n                        \"size\": fitted_size,\n                    }\n                )\n        else:\n            legend_entries.append({\"parent\": parent, \"child\": child_name, \"color\": color})\n\n        child_current_angle = child_end_angle\n\nouter_df = pd.DataFrame(outer_rows)\nouter_label_df = pd.DataFrame(outer_labels)\n\n# Create labels for inner ring (region name + revenue share). Name and value\n# are nudged apart by a fixed cartesian dy (screen-space stacking) rather than\n# a radius difference — a radius-based offset collapses to near-zero vertical\n# separation for segments near the 3/9 o'clock angle, since it projects mostly\n# horizontally there. `dy` shifts every segment's pair by the same vector, so\n# it can't add breathing room *between* two adjacent small segments (Latin\n# America / Middle East & Africa) — what actually collided there was the\n# long \"Middle East & Africa\" name text outrunning its narrow angular slice.\n# Segments below small_share_threshold get a smaller font, the short name\n# form, and their mid-angle nudged away from any small-share neighbor, so the\n# two label blocks pull apart instead of converging on the shared boundary.\ninner_label_radius = (inner_ring_inner + inner_ring_outer) / 2\nlabel_dy = 9\nsmall_share_threshold = 0.15\nsmall_share_label_radius = inner_ring_outer - 4\nsmall_share_name_size = 6.5\nsmall_share_value_size = 5.5\nsmall_share_angle_nudge = math.radians(5)\n\nparent_list = list(data.keys())\nn_parents = len(parent_list)\nparent_shares = {p: parent_totals[p] / grand_total for p in parent_list}\n\ninner_name_labels = []\ninner_value_labels = []\nfor idx, parent in enumerate(parent_list):\n    start_angle, end_angle = parent_angles[parent]\n    mid_angle = (start_angle + end_angle) / 2\n    share = parent_shares[parent]\n    is_small = share < small_share_threshold\n    if is_small:\n        next_parent = parent_list[(idx + 1) % n_parents]\n        prev_parent = parent_list[(idx - 1) % n_parents]\n        if parent_shares[next_parent] < small_share_threshold:\n            mid_angle += small_share_angle_nudge  # pull toward own start, away from the next small segment\n        if parent_shares[prev_parent] < small_share_threshold:\n            mid_angle -= small_share_angle_nudge  # pull toward own end, away from the previous small segment\n    this_radius = small_share_label_radius if is_small else inner_label_radius\n    label_x = this_radius * math.cos(mid_angle)\n    label_y = this_radius * math.sin(mid_angle)\n    name_text = short_names.get(parent, parent) if is_small else parent\n    inner_name_labels.append({\"x\": label_x, \"y\": label_y + label_dy, \"label\": name_text, \"small\": is_small})\n    inner_value_labels.append(\n        {\n            \"x\": label_x,\n            \"y\": label_y - label_dy,\n            \"label\": f\"${parent_totals[parent]:,}M · {share * 100:.0f}%\",\n            \"small\": is_small,\n        }\n    )\n\ninner_name_df_all = pd.DataFrame(inner_name_labels)\ninner_value_df_all = pd.DataFrame(inner_value_labels)\ninner_name_df = inner_name_df_all[~inner_name_df_all[\"small\"]]\ninner_name_small_df = inner_name_df_all[inner_name_df_all[\"small\"]]\ninner_value_df = inner_value_df_all[~inner_value_df_all[\"small\"]]\ninner_value_small_df = inner_value_df_all[inner_value_df_all[\"small\"]]\n\n# Legend for outer-ring segments below label_threshold, or whose label didn't\n# fit its arc even at the smallest font (spec: \"use legend for smaller ones\")\n# — a manually drawn swatch + text row per entry, placed in the canvas margin\n# below the donut. Row height shrinks (down to a legible floor) instead of a\n# fixed 14 so the legend can grow to accommodate more fallback entries while\n# always staying within legend_bottom_y, matching the fixed y-axis limit below.\nlegend_swatch_x = -165\nlegend_text_x = -152\nlegend_start_y = -162\nlegend_bottom_y = -196\nlegend_row_height = (\n    min(14, (legend_start_y - legend_bottom_y) / (len(legend_entries) - 1)) if len(legend_entries) > 1 else 14\n)\n\nlegend_swatch_rows = [\n    {\"x\": legend_swatch_x, \"y\": legend_start_y - i * legend_row_height, \"color\": entry[\"color\"]}\n    for i, entry in enumerate(legend_entries)\n]\nlegend_text_rows = [\n    {\n        \"x\": legend_text_x,\n        \"y\": legend_start_y - i * legend_row_height,\n        \"label\": f\"{short_names.get(entry['parent'], entry['parent'])} · {entry['child']}\",\n    }\n    for i, entry in enumerate(legend_entries)\n]\nlegend_swatch_df = pd.DataFrame(legend_swatch_rows)\nlegend_text_df = pd.DataFrame(legend_text_rows)\n\n# Title — mandated format only; the region/revenue story is already told by\n# the inner-ring labels, so no descriptive prefix is needed here.\ntitle_text = \"donut-nested · python · plotnine · anyplot.ai\"\ntitle_ratio = 67 / len(title_text) if len(title_text) > 67 else 1.0\ntitle_fontsize = max(round(12 * title_ratio), 8)\n\n# Plot\nplot = (\n    ggplot()\n    # Inner ring (regions)\n    + geom_polygon(\n        aes(x=\"x\", y=\"y\", group=\"segment\", fill=\"fill\"), data=inner_df, color=BORDER_COLOR, size=0.5, alpha=0.95\n    )\n    # Outer ring (product lines)\n    + geom_polygon(\n        aes(x=\"x\", y=\"y\", group=\"segment\", fill=\"fill\"), data=outer_df, color=BORDER_COLOR, size=0.5, alpha=0.9\n    )\n    # Inner ring labels (region name + revenue share, as two independently\n    # positioned rows rather than a multi-line string). Small-share segments\n    # (Latin America, Middle East & Africa) use a smaller font so their\n    # longer/narrower slices don't collide with each other.\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=inner_name_df, size=9, fontweight=\"bold\", color=INK)\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=inner_value_df, size=7, color=INK_SOFT)\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=inner_name_small_df,\n        size=small_share_name_size,\n        fontweight=\"bold\",\n        color=INK,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"), data=inner_value_small_df, size=small_share_value_size, color=INK_SOFT\n    )\n    # Outer ring labels (product line names for large-enough segments); text\n    # color adapts per-wedge (contrast_text_color) rather than one fixed ink,\n    # since the palest tonal tints need dark text even in dark theme. Font\n    # size is per-row (shrunk to whatever fit the segment's arc — see\n    # outer_label_font_sizes above), mapped through scale_size_identity().\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\", color=\"label_color\", size=\"size\"), data=outer_label_df)\n    # Legend for the outer-ring segments below label_threshold — swatch +\n    # name, since scale_fill_identity() precludes an automatic ggplot legend.\n    + geom_point(aes(x=\"x\", y=\"y\", color=\"color\"), data=legend_swatch_df, size=4.5, shape=\"s\")\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=legend_text_df, size=5.5, color=INK_SOFT, ha=\"left\", va=\"center\")\n    # Use fill/color/size values directly (scale_fill_identity for the\n    # polygons, scale_color_identity for the legend swatches + adaptive\n    # outer-ring label text, scale_size_identity for the per-label fitted\n    # outer-ring font sizes)\n    + scale_fill_identity()\n    + scale_color_identity()\n    + scale_size_identity()\n    # Fixed aspect ratio for proper circles\n    + coord_fixed(ratio=1)\n    # Axis limits — asymmetric y range reserves margin below the donut for\n    # the legend rows; x range widened to match so coord_fixed doesn't\n    # letterbox the panel.\n    + scale_x_continuous(limits=(-190, 190))\n    + scale_y_continuous(limits=(-200, 180))\n    # Title\n    + labs(title=title_text)\n    # Clean theme with adaptive background — canonical 2400x2400 square canvas\n    + theme(\n        figure_size=(6, 6),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_title=element_text(size=title_fontsize, ha=\"center\", color=INK),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        axis_line=element_blank(),\n        panel_grid_major=element_blank(),\n        panel_grid_minor=element_blank(),\n        legend_position=\"none\",\n    )\n)\n\n# Save\noutput_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f\"plot-{THEME}.png\")\nplot.save(output_path, dpi=400, width=6, height=6, units=\"in\")\n"}