Nested Donut Chart — plotnine

A nested donut chart displays hierarchical data as multiple concentric rings, where each ring represents a level of the hierarchy. Inner rings show parent categories while outer rings show their subdivisions. This visualization effectively reveals part-to-whole relationships across multiple levels while maintaining the familiar donut format.

Nested Donut Chart rendered with plotnine

Renders

Python source (plotnine)

""" anyplot.ai
donut-nested: Nested Donut Chart
Library: plotnine 0.15.8 | Python 3.13.15
Quality: 88/100 | Updated: 2026-08-18
"""

import os
import sys


# Remove the script's directory from sys.path to avoid circular imports
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != script_dir]

import math

import pandas as pd
from matplotlib.font_manager import FontProperties
from matplotlib.path import Path as MplPath
from matplotlib.textpath import TextPath
from plotnine import (
    aes,
    coord_fixed,
    element_blank,
    element_rect,
    element_text,
    geom_point,
    geom_polygon,
    geom_text,
    ggplot,
    labs,
    scale_color_identity,
    scale_fill_identity,
    scale_size_identity,
    scale_x_continuous,
    scale_y_continuous,
    theme,
)


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
BORDER_COLOR = "#E8E6DC" if THEME == "light" else "#353531"

# Imprint categorical palette (positions 1-5) — regions are an abstract
# category, so canonical ordinal order applies (default-style-guide.md
# "Semantic Exception": abstract categories keep canonical order).
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030"]

# Data - Global technology revenue ($M) by region (inner ring) and product
# line (outer ring). Region count (5) and children-per-region (2-5) diverge
# from the 4-department budget scenario used by sibling library implementations.
data = {
    "North America": [("Software", 420), ("Hardware", 260), ("Cloud Services", 240), ("Support", 130)],
    "Asia Pacific": [
        ("Software", 310),
        ("Hardware", 220),
        ("Cloud Services", 180),
        ("Consulting", 110),
        ("Support", 70),
    ],
    "Europe": [("Software", 300), ("Cloud Services", 270), ("Hardware", 190)],
    "Latin America": [("Software", 180), ("Hardware", 140)],
    "Middle East & Africa": [("Hardware", 150), ("Cloud Services", 90), ("Support", 70)],
}

# Shortened form used wherever a small-share region's full name would crowd
# its neighbor (inner-ring label, legend entry) — only "Middle East & Africa"
# is long enough to need it.
short_names = {"Middle East & Africa": "MEA"}


def lighten_hex(hex_color, amount):
    """Blend a hex color toward white by `amount` (0-1) for a tonal family member."""
    r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5))
    r = round(r + (255 - r) * amount)
    g = round(g + (255 - g) * amount)
    b = round(b + (255 - b) * amount)
    return f"#{r:02X}{g:02X}{b:02X}"


def contrast_text_color(hex_color):
    """Pick dark or light ink for legibility against a specific wedge fill —
    based on that fill's own brightness, independent of the page theme, since
    the palest tonal tints need dark text in both themes."""
    r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5))
    brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255
    return "#1A1A17" if brightness > 0.55 else "#F0EFE8"


# Imprint families - each parent's Imprint hue plus lighter tonal shades for
# its own children in the outer ring (consistent hue, varying lightness).
color_families = {}
for parent, base_color in zip(data.keys(), IMPRINT_PALETTE, strict=True):
    children = data[parent]
    n_children = len(children)
    tints = [lighten_hex(base_color, (i / n_children) * 0.6) for i in range(n_children)]
    color_families[parent] = (base_color, tints)

# Calculate totals for each parent
parent_totals = {parent: sum(v for _, v in children) for parent, children in data.items()}
grand_total = sum(parent_totals.values())

# Ring dimensions
inner_ring_inner = 60  # Inner donut hole
inner_ring_outer = 100  # Inner ring outer radius
outer_ring_inner = 110  # Outer ring inner radius (gap for separation)
outer_ring_outer = 150  # Outer ring outer radius

label_threshold = 100  # $M — outer segments below this get a legend entry instead

# Coordinate-to-inch conversion for measuring real rendered text width: the
# panel spans FIGURE_WIDTH_IN inches across X_RANGE_UNITS data units (see
# scale_x_continuous below), so a font-metric extent in points converts to
# data units via points -> inches -> data units.
FIGURE_WIDTH_IN = 6
X_RANGE_UNITS = 380  # matches scale_x_continuous limits=(-190, 190)
UNITS_PER_INCH = X_RANGE_UNITS / FIGURE_WIDTH_IN


def text_extents_units(text, font_size_pt, bold=False):
    """Rendered (width, height) of a label in plot data-coordinate units,
    from actual font metrics rather than a guessed char-count heuristic — so
    label-fit checks match what will really be rasterized."""
    prop = FontProperties(size=font_size_pt, weight="bold" if bold else "normal")
    extents = TextPath((0, 0), text, size=font_size_pt, prop=prop).get_extents()
    return (extents.width / 72) * UNITS_PER_INCH, (extents.height / 72) * UNITS_PER_INCH


def label_fits(center_x, center_y, width, height, segment_path):
    """Whether a horizontal label's bounding box stays inside a wedge polygon.

    A wedge's angular sweep translates to horizontal room very unevenly: near
    the top/bottom of the ring a wide angle gives lots of horizontal space,
    but the same angle near the left/right of the ring gives almost none
    (there the sweep is mostly vertical) — so comparing text width against
    the tangential arc length alone is unreliable. Sampling the label's
    bounding-box edges against the real polygon (inner/outer arcs and the two
    radial edges) via matplotlib's point-in-path test catches that directly.
    """
    half_w, half_h = width / 2, height / 2
    fracs = (-1, -0.5, 0, 0.5, 1)
    corners = [(center_x + fx * half_w, center_y + fy * half_h) for fx in fracs for fy in fracs]
    return bool(segment_path.contains_points(corners).all())


def create_annular_segment(start_angle, end_angle, inner_radius, outer_radius, n_points=50):
    """Create polygon points for an annular (donut) segment."""
    # Add small gap between segments
    gap = 0.02
    start_angle += gap
    end_angle -= gap

    points = []
    # Inner arc (from start to end)
    for angle in [start_angle + (end_angle - start_angle) * i / (n_points - 1) for i in range(n_points)]:
        points.append((inner_radius * math.cos(angle), inner_radius * math.sin(angle)))

    # Outer arc (from end back to start)
    for angle in [end_angle + (start_angle - end_angle) * i / (n_points - 1) for i in range(n_points)]:
        points.append((outer_radius * math.cos(angle), outer_radius * math.sin(angle)))

    # Close the polygon
    points.append(points[0])
    return points


# Build polygon data for inner ring (regions)
inner_rows = []
current_angle = math.pi / 2  # Start at top (12 o'clock)

parent_angles = {}  # Track start/end angles for each parent

for parent in data.keys():
    parent_total = parent_totals[parent]
    sweep = (parent_total / grand_total) * 2 * math.pi
    end_angle = current_angle - sweep  # Clockwise

    parent_angles[parent] = (current_angle, end_angle)

    points = create_annular_segment(end_angle, current_angle, inner_ring_inner, inner_ring_outer)
    for order, (x, y) in enumerate(points):
        inner_rows.append({"x": x, "y": y, "segment": parent, "order": order, "fill": color_families[parent][0]})

    current_angle = end_angle

inner_df = pd.DataFrame(inner_rows)

# Build polygon data for outer ring (product lines). Outer-ring labels and
# legend entries are collected in the same loop since both need the per-child
# color already computed here: segments >= label_threshold get an inline
# label; smaller ones fall back to a legend entry per the spec's "labels on
# larger segments, use legend for smaller ones" guidance.
outer_rows = []
outer_labels = []
legend_entries = []

# Font sizes tried in order (largest first), each checked at several radii
# (outermost first, for maximum separation from the inner ring's labels)
# before falling back to the legend. A label that doesn't fit at any
# size/radius combination would either spill into a neighboring segment's
# label (as seen with Asia Pacific's Consulting/Cloud Services collision) or
# overflow its own wedge onto the page background — where, because
# contrast_text_color picks ink from the wedge fill only, dark text chosen
# for a light tint can land on the dark-theme page and become invisible (the
# North America "Cloud Services" clipping seen in dark only).
outer_label_font_sizes = [7, 6, 5.5, 5]
outer_label_radius_candidates = list(range(outer_ring_outer - 5, outer_ring_inner + 5, -5)) + [outer_ring_inner + 6]

for parent, children in data.items():
    parent_start, parent_end = parent_angles[parent]
    parent_total = parent_totals[parent]

    child_current_angle = parent_start
    child_colors = color_families[parent][1]

    for i, (child_name, child_value) in enumerate(children):
        child_sweep = (child_value / parent_total) * (parent_start - parent_end)
        child_end_angle = child_current_angle - child_sweep
        color = child_colors[i]

        points = create_annular_segment(child_end_angle, child_current_angle, outer_ring_inner, outer_ring_outer)
        segment_id = f"{parent}_{child_name}"

        for order, (x, y) in enumerate(points):
            outer_rows.append({"x": x, "y": y, "segment": segment_id, "order": order, "fill": color})

        if child_value >= label_threshold:
            mid_angle = (child_current_angle + child_end_angle) / 2
            segment_path = MplPath(points)

            placement = None
            for font_size in outer_label_font_sizes:
                text_w, text_h = text_extents_units(child_name, font_size)
                for radius in outer_label_radius_candidates:
                    cx, cy = radius * math.cos(mid_angle), radius * math.sin(mid_angle)
                    if label_fits(cx, cy, text_w, text_h, segment_path):
                        placement = (cx, cy, font_size)
                        break
                if placement is not None:
                    break

            if placement is None:
                legend_entries.append({"parent": parent, "child": child_name, "color": color})
            else:
                label_x, label_y, fitted_size = placement
                outer_labels.append(
                    {
                        "x": label_x,
                        "y": label_y,
                        "label": child_name,
                        "label_color": contrast_text_color(color),
                        "size": fitted_size,
                    }
                )
        else:
            legend_entries.append({"parent": parent, "child": child_name, "color": color})

        child_current_angle = child_end_angle

outer_df = pd.DataFrame(outer_rows)
outer_label_df = pd.DataFrame(outer_labels)

# Create labels for inner ring (region name + revenue share). Name and value
# are nudged apart by a fixed cartesian dy (screen-space stacking) rather than
# a radius difference — a radius-based offset collapses to near-zero vertical
# separation for segments near the 3/9 o'clock angle, since it projects mostly
# horizontally there. `dy` shifts every segment's pair by the same vector, so
# it can't add breathing room *between* two adjacent small segments (Latin
# America / Middle East & Africa) — what actually collided there was the
# long "Middle East & Africa" name text outrunning its narrow angular slice.
# Segments below small_share_threshold get a smaller font, the short name
# form, and their mid-angle nudged away from any small-share neighbor, so the
# two label blocks pull apart instead of converging on the shared boundary.
inner_label_radius = (inner_ring_inner + inner_ring_outer) / 2
label_dy = 9
small_share_threshold = 0.15
small_share_label_radius = inner_ring_outer - 4
small_share_name_size = 6.5
small_share_value_size = 5.5
small_share_angle_nudge = math.radians(5)

parent_list = list(data.keys())
n_parents = len(parent_list)
parent_shares = {p: parent_totals[p] / grand_total for p in parent_list}

inner_name_labels = []
inner_value_labels = []
for idx, parent in enumerate(parent_list):
    start_angle, end_angle = parent_angles[parent]
    mid_angle = (start_angle + end_angle) / 2
    share = parent_shares[parent]
    is_small = share < small_share_threshold
    if is_small:
        next_parent = parent_list[(idx + 1) % n_parents]
        prev_parent = parent_list[(idx - 1) % n_parents]
        if parent_shares[next_parent] < small_share_threshold:
            mid_angle += small_share_angle_nudge  # pull toward own start, away from the next small segment
        if parent_shares[prev_parent] < small_share_threshold:
            mid_angle -= small_share_angle_nudge  # pull toward own end, away from the previous small segment
    this_radius = small_share_label_radius if is_small else inner_label_radius
    label_x = this_radius * math.cos(mid_angle)
    label_y = this_radius * math.sin(mid_angle)
    name_text = short_names.get(parent, parent) if is_small else parent
    inner_name_labels.append({"x": label_x, "y": label_y + label_dy, "label": name_text, "small": is_small})
    inner_value_labels.append(
        {
            "x": label_x,
            "y": label_y - label_dy,
            "label": f"${parent_totals[parent]:,}M · {share * 100:.0f}%",
            "small": is_small,
        }
    )

inner_name_df_all = pd.DataFrame(inner_name_labels)
inner_value_df_all = pd.DataFrame(inner_value_labels)
inner_name_df = inner_name_df_all[~inner_name_df_all["small"]]
inner_name_small_df = inner_name_df_all[inner_name_df_all["small"]]
inner_value_df = inner_value_df_all[~inner_value_df_all["small"]]
inner_value_small_df = inner_value_df_all[inner_value_df_all["small"]]

# Legend for outer-ring segments below label_threshold, or whose label didn't
# fit its arc even at the smallest font (spec: "use legend for smaller ones")
# — a manually drawn swatch + text row per entry, placed in the canvas margin
# below the donut. Row height shrinks (down to a legible floor) instead of a
# fixed 14 so the legend can grow to accommodate more fallback entries while
# always staying within legend_bottom_y, matching the fixed y-axis limit below.
legend_swatch_x = -165
legend_text_x = -152
legend_start_y = -162
legend_bottom_y = -196
legend_row_height = (
    min(14, (legend_start_y - legend_bottom_y) / (len(legend_entries) - 1)) if len(legend_entries) > 1 else 14
)

legend_swatch_rows = [
    {"x": legend_swatch_x, "y": legend_start_y - i * legend_row_height, "color": entry["color"]}
    for i, entry in enumerate(legend_entries)
]
legend_text_rows = [
    {
        "x": legend_text_x,
        "y": legend_start_y - i * legend_row_height,
        "label": f"{short_names.get(entry['parent'], entry['parent'])} · {entry['child']}",
    }
    for i, entry in enumerate(legend_entries)
]
legend_swatch_df = pd.DataFrame(legend_swatch_rows)
legend_text_df = pd.DataFrame(legend_text_rows)

# Title — mandated format only; the region/revenue story is already told by
# the inner-ring labels, so no descriptive prefix is needed here.
title_text = "donut-nested · python · plotnine · anyplot.ai"
title_ratio = 67 / len(title_text) if len(title_text) > 67 else 1.0
title_fontsize = max(round(12 * title_ratio), 8)

# Plot
plot = (
    ggplot()
    # Inner ring (regions)
    + geom_polygon(
        aes(x="x", y="y", group="segment", fill="fill"), data=inner_df, color=BORDER_COLOR, size=0.5, alpha=0.95
    )
    # Outer ring (product lines)
    + geom_polygon(
        aes(x="x", y="y", group="segment", fill="fill"), data=outer_df, color=BORDER_COLOR, size=0.5, alpha=0.9
    )
    # Inner ring labels (region name + revenue share, as two independently
    # positioned rows rather than a multi-line string). Small-share segments
    # (Latin America, Middle East & Africa) use a smaller font so their
    # longer/narrower slices don't collide with each other.
    + geom_text(aes(x="x", y="y", label="label"), data=inner_name_df, size=9, fontweight="bold", color=INK)
    + geom_text(aes(x="x", y="y", label="label"), data=inner_value_df, size=7, color=INK_SOFT)
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=inner_name_small_df,
        size=small_share_name_size,
        fontweight="bold",
        color=INK,
    )
    + geom_text(
        aes(x="x", y="y", label="label"), data=inner_value_small_df, size=small_share_value_size, color=INK_SOFT
    )
    # Outer ring labels (product line names for large-enough segments); text
    # color adapts per-wedge (contrast_text_color) rather than one fixed ink,
    # since the palest tonal tints need dark text even in dark theme. Font
    # size is per-row (shrunk to whatever fit the segment's arc — see
    # outer_label_font_sizes above), mapped through scale_size_identity().
    + geom_text(aes(x="x", y="y", label="label", color="label_color", size="size"), data=outer_label_df)
    # Legend for the outer-ring segments below label_threshold — swatch +
    # name, since scale_fill_identity() precludes an automatic ggplot legend.
    + geom_point(aes(x="x", y="y", color="color"), data=legend_swatch_df, size=4.5, shape="s")
    + geom_text(aes(x="x", y="y", label="label"), data=legend_text_df, size=5.5, color=INK_SOFT, ha="left", va="center")
    # Use fill/color/size values directly (scale_fill_identity for the
    # polygons, scale_color_identity for the legend swatches + adaptive
    # outer-ring label text, scale_size_identity for the per-label fitted
    # outer-ring font sizes)
    + scale_fill_identity()
    + scale_color_identity()
    + scale_size_identity()
    # Fixed aspect ratio for proper circles
    + coord_fixed(ratio=1)
    # Axis limits — asymmetric y range reserves margin below the donut for
    # the legend rows; x range widened to match so coord_fixed doesn't
    # letterbox the panel.
    + scale_x_continuous(limits=(-190, 190))
    + scale_y_continuous(limits=(-200, 180))
    # Title
    + labs(title=title_text)
    # Clean theme with adaptive background — canonical 2400x2400 square canvas
    + theme(
        figure_size=(6, 6),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        plot_title=element_text(size=title_fontsize, ha="center", color=INK),
        axis_title=element_blank(),
        axis_text=element_blank(),
        axis_ticks=element_blank(),
        axis_line=element_blank(),
        panel_grid_major=element_blank(),
        panel_grid_minor=element_blank(),
        legend_position="none",
    )
)

# Save
output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f"plot-{THEME}.png")
plot.save(output_path, dpi=400, width=6, height=6, units="in")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/donut-nested/plotnine/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "donut-nested",
  "language": "python",
  "library": "plotnine",
  "page": "https://anyplot.ai/donut-nested/python/plotnine",
  "hub": "https://anyplot.ai/donut-nested",
  "code_json": "https://api.anyplot.ai/specs/donut-nested/plotnine/code",
  "spec_json": "https://api.anyplot.ai/specs/donut-nested",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/donut-nested/python/plotnine/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/donut-nested/python/plotnine/plot-dark.png",
  "quality_score": 88.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Nested Donut Chart on anyplot.ai.

Other implementations