Nested Donut Chart — Bokeh

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 Bokeh

Renders

Python source (Bokeh)

""" anyplot.ai
donut-nested: Nested Donut Chart
Library: bokeh 3.9.2 | Python 3.13.15
Quality: 92/100 | Updated: 2026-08-18
"""

import os
import time
from colorsys import hls_to_rgb, rgb_to_hls
from math import pi
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, LabelSet
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"

# Data: Budget allocation by department and expense categories
# Inner ring: departments, outer ring: expense categories within each department
data = {
    "Engineering": {"Salaries": 450, "Equipment": 120, "Training": 80, "Cloud": 150},
    "Marketing": {"Advertising": 280, "Events": 90, "Content": 60},
    "Sales": {"Salaries": 320, "Travel": 85, "Tools": 45},
    "Operations": {"Facilities": 180, "IT Support": 95, "Utilities": 55},
}

# Imprint palette, positions 1-4 (one hue per department)
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]


def hex_to_rgb01(hex_color):
    hex_color = hex_color.lstrip("#")
    return tuple(int(hex_color[i : i + 2], 16) / 255.0 for i in (0, 2, 4))


def rgb01_to_hex(rgb):
    return "#{:02X}{:02X}{:02X}".format(*(round(max(0.0, min(1.0, c)) * 255) for c in rgb))


def tint_family(base_hex, count):
    # Genuine same-hue tints: walk lightness toward the page background while
    # keeping hue and saturation from the department's Imprint base color.
    r, g, b = hex_to_rgb01(base_hex)
    hue, lightness, sat = rgb_to_hls(r, g, b)
    steps = max(count - 1, 1)
    return [rgb01_to_hex(hls_to_rgb(hue, lightness + (0.82 - lightness) * (i / steps), sat)) for i in range(count)]


def contrast_ink(hex_color):
    # Outer-ring tints run pale regardless of theme (data colors never flip),
    # so pick label ink from the wedge's own luminance rather than the theme
    # token — a theme-only INK_SOFT washes out against the palest tints.
    r, g, b = hex_to_rgb01(hex_color)
    luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
    return "#1A1A17" if luminance > 0.55 else "#F0EFE8"


color_palettes = {dept: tint_family(IMPRINT_PALETTE[i], len(cats)) for i, (dept, cats) in enumerate(data.items())}

# Calculate totals and angles
dept_totals = {dept: sum(cats.values()) for dept, cats in data.items()}
total = sum(dept_totals.values())
largest_dept = max(dept_totals, key=dept_totals.get)

# Ring radii (inner ring = departments, outer ring = expense categories)
inner_radius_inner, inner_radius_outer = 0.35, 0.60
outer_radius_inner, outer_radius_outer = 0.65, 0.90
emphasis_bump = 0.04  # nudges the largest department's wedge outward

# Build data for inner ring (departments)
inner_start_angle, inner_end_angle = [], []
inner_outer_radius, inner_colors = [], []
inner_labels, inner_values = [], []
inner_x, inner_y = [], []

# Build data for outer ring (categories)
outer_start_angle, outer_end_angle = [], []
outer_outer_radius, outer_colors = [], []
outer_labels, outer_values, outer_dept = [], [], []
outer_x, outer_y = [], []

current_angle = pi / 2  # start at top

for dept, categories in data.items():
    dept_total = dept_totals[dept]
    dept_angle = 2 * pi * (dept_total / total)
    is_largest = dept == largest_dept
    bump = emphasis_bump if is_largest else 0.0

    # Inner ring segment
    inner_start_angle.append(current_angle)
    inner_end_angle.append(current_angle + dept_angle)
    inner_outer_radius.append(inner_radius_outer + bump)
    inner_colors.append(color_palettes[dept][0])
    inner_labels.append(dept)
    inner_values.append(dept_total)

    # Label position for inner ring
    mid_angle = current_angle + dept_angle / 2
    label_radius = (inner_radius_outer + inner_radius_inner) / 2 + bump / 2
    inner_x.append(label_radius * np.cos(mid_angle))
    inner_y.append(label_radius * np.sin(mid_angle))

    # Outer ring segments (categories within this department)
    cat_start = current_angle
    for i, (cat, val) in enumerate(categories.items()):
        cat_angle = 2 * pi * (val / total)

        outer_start_angle.append(cat_start)
        outer_end_angle.append(cat_start + cat_angle)
        outer_outer_radius.append(outer_radius_outer + bump)
        outer_colors.append(color_palettes[dept][i % len(color_palettes[dept])])
        outer_labels.append(cat)
        outer_values.append(val)
        outer_dept.append(dept)

        # Label position for outer ring
        cat_mid_angle = cat_start + cat_angle / 2
        cat_label_radius = (outer_radius_outer + outer_radius_inner) / 2 + bump / 2
        outer_x.append(cat_label_radius * np.cos(cat_mid_angle))
        outer_y.append(cat_label_radius * np.sin(cat_mid_angle))

        cat_start += cat_angle

    current_angle += dept_angle

# Create figure (square format for donut) — canonical anyplot canvas
title_text = "donut-nested · python · bokeh · anyplot.ai"

p = figure(
    width=2400,
    height=2400,
    title=title_text,
    x_range=(-1.35, 1.35),
    y_range=(-1.35, 1.35),
    tools="",
    toolbar_location=None,
    min_border_top=90,
    min_border_bottom=40,
    min_border_left=40,
    min_border_right=40,
)

# Style the figure
p.title.text_font_size = "50pt"
p.title.text_font_style = "bold"
p.title.text_color = INK
p.title.align = "center"
p.axis.visible = False
p.grid.visible = False
p.outline_line_color = None
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG

# Elevated disc behind the donut hole — lifts the center total off the page
p.annulus(x=0, y=0, inner_radius=0, outer_radius=inner_radius_inner - 0.015, fill_color=ELEVATED_BG, line_color=None)

# Inner ring (departments)
inner_source = ColumnDataSource(
    data={
        "start_angle": inner_start_angle,
        "end_angle": inner_end_angle,
        "outer_radius": inner_outer_radius,
        "color": inner_colors,
        "label": inner_labels,
        "value": inner_values,
        "x": inner_x,
        "y": inner_y,
    }
)

inner_glyph = p.annular_wedge(
    x=0,
    y=0,
    inner_radius=inner_radius_inner,
    outer_radius="outer_radius",
    start_angle="start_angle",
    end_angle="end_angle",
    color="color",
    line_color=PAGE_BG,
    line_width=4,
    source=inner_source,
)

# Outer ring (categories)
outer_source = ColumnDataSource(
    data={
        "start_angle": outer_start_angle,
        "end_angle": outer_end_angle,
        "outer_radius": outer_outer_radius,
        "color": outer_colors,
        "label": outer_labels,
        "value": outer_values,
        "dept": outer_dept,
        "x": outer_x,
        "y": outer_y,
    }
)

outer_glyph = p.annular_wedge(
    x=0,
    y=0,
    inner_radius=outer_radius_inner,
    outer_radius="outer_radius",
    start_angle="start_angle",
    end_angle="end_angle",
    color="color",
    line_color=PAGE_BG,
    line_width=3,
    source=outer_source,
)

# Hover tooltips — genuinely interactive in the saved HTML (inspect tools stay
# live without a toolbar button), naming the exact department/category + value.
p.add_tools(
    HoverTool(renderers=[inner_glyph], tooltips=[("Department", "@label"), ("Budget", "$@value{0,0}K")]),
    HoverTool(
        renderers=[outer_glyph], tooltips=[("Category", "@label"), ("Department", "@dept"), ("Budget", "$@value{0,0}K")]
    ),
)

# Labels for inner ring (departments with values) — ink picked per wedge
# color via contrast_ink(), matching the outer ring's approach, so labels
# stay readable on both the saturated and pale department wedges.
inner_label_text = [f"{lbl}\n${val}K" for lbl, val in zip(inner_labels, inner_values, strict=True)]
inner_label_ink = [contrast_ink(c) for c in inner_colors]
inner_label_source = ColumnDataSource(
    data={"x": inner_x, "y": inner_y, "text": inner_label_text, "ink": inner_label_ink}
)

inner_labels_set = LabelSet(
    x="x",
    y="y",
    text="text",
    source=inner_label_source,
    text_align="center",
    text_baseline="middle",
    text_font_size="20pt",
    text_color="ink",
    text_font_style="bold",
)
p.add_layout(inner_labels_set)

# Labels for outer ring (only for larger segments)
outer_label_text, outer_label_x, outer_label_y, outer_label_ink = [], [], [], []
for label, value, x, y, start, end, color in zip(
    outer_labels, outer_values, outer_x, outer_y, outer_start_angle, outer_end_angle, outer_colors, strict=True
):
    segment_angle = abs(end - start)
    # Only label segments larger than 0.25 radians (~14 degrees)
    if segment_angle > 0.25:
        outer_label_text.append(f"{label}\n${value}K")
        outer_label_x.append(x)
        outer_label_y.append(y)
        outer_label_ink.append(contrast_ink(color))

outer_label_source = ColumnDataSource(
    data={"x": outer_label_x, "y": outer_label_y, "text": outer_label_text, "ink": outer_label_ink}
)

outer_labels_set = LabelSet(
    x="x",
    y="y",
    text="text",
    source=outer_label_source,
    text_align="center",
    text_baseline="middle",
    text_font_size="16pt",
    text_color="ink",
)
p.add_layout(outer_labels_set)

# Compact legend for outer segments below the on-chart label threshold — the
# spec calls for "use legend for smaller ones" so no wedge is left
# unidentifiable in the static PNG.
below_threshold = [
    (label, value, dept, color)
    for label, value, dept, start, end, color in zip(
        outer_labels, outer_values, outer_dept, outer_start_angle, outer_end_angle, outer_colors, strict=True
    )
    if abs(end - start) <= 0.25
]

if below_threshold:
    legend_x, legend_row_h = -1.32, 0.09
    legend_top_y = -1.35 + legend_row_h * len(below_threshold)

    legend_swatch_source = ColumnDataSource(
        data={
            "x": [legend_x] * len(below_threshold),
            "y": [legend_top_y - i * legend_row_h for i in range(len(below_threshold))],
            "color": [color for _, _, _, color in below_threshold],
        }
    )
    p.scatter(x="x", y="y", source=legend_swatch_source, marker="square", size=16, color="color", line_color=None)

    legend_label_source = ColumnDataSource(
        data={
            "x": [legend_x + 0.04] * len(below_threshold),
            "y": [legend_top_y - i * legend_row_h for i in range(len(below_threshold))],
            "text": [f"{dept} · {label} (${value}K)" for label, value, dept, _ in below_threshold],
        }
    )
    legend_labels_set = LabelSet(
        x="x",
        y="y",
        text="text",
        source=legend_label_source,
        text_align="left",
        text_baseline="middle",
        text_font_size="16pt",
        text_color=INK,
    )
    p.add_layout(legend_labels_set)

# Center text showing the grand total
p.text(
    x=[0],
    y=[0],
    text=[f"Total\n${total}K"],
    text_align="center",
    text_baseline="middle",
    text_font_size="26pt",
    text_font_style="bold",
    text_color=INK,
)

# Save HTML output
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome using Selenium
W, H = 2400, 2400
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/donut-nested/bokeh/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": "bokeh",
  "page": "https://anyplot.ai/donut-nested/python/bokeh",
  "hub": "https://anyplot.ai/donut-nested",
  "code_json": "https://api.anyplot.ai/specs/donut-nested/bokeh/code",
  "spec_json": "https://api.anyplot.ai/specs/donut-nested",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/donut-nested/python/bokeh/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/donut-nested/python/bokeh/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/donut-nested/python/bokeh/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/donut-nested/python/bokeh/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Nested Donut Chart on anyplot.ai.

Other implementations