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

Python source (Bokeh)

""" anyplot.ai
donut-nested: Nested Donut Chart
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-08
"""

import os
import time
from math import pi
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, 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"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# 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},
}

# Color families: Each department gets a color family based on Okabe-Ito positions
# Using consistent hue with varying lightness per department
color_palettes = {
    "Engineering": ["#009E73", "#1ab98d", "#35d5a7", "#4ff1c1"],  # Greens (position 1)
    "Marketing": ["#C475FD", "#e07714", "#eb8f28", "#f6a83c"],  # Oranges (position 2)
    "Sales": ["#4467A3", "#1f8ac9", "#3ea2e0", "#5dbaf7"],  # Blues (position 3)
    "Operations": ["#BD8233", "#d98cbc", "#e69fd1", "#f3b2e6"],  # Purples (position 4)
}

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

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

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

# Inner ring radii
inner_radius_outer = 0.6
inner_radius_inner = 0.35

# Outer ring radii
outer_radius_outer = 0.9
outer_radius_inner = 0.65

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)

    # Inner ring segment
    inner_start_angle.append(current_angle)
    inner_end_angle.append(current_angle + dept_angle)
    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
    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_colors.append(color_palettes[dept][i % len(color_palettes[dept])])
        outer_labels.append(cat)
        outer_values.append(val)

        # Label position for outer ring
        cat_mid_angle = cat_start + cat_angle / 2
        cat_label_radius = (outer_radius_outer + outer_radius_inner) / 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)
p = figure(
    width=3600,
    height=3600,
    title="donut-nested · bokeh · anyplot.ai",
    x_range=(-1.3, 1.3),
    y_range=(-1.3, 1.3),
    tools="",
    toolbar_location=None,
)

# Style the figure
p.title.text_font_size = "28pt"
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

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

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

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

p.annular_wedge(
    x=0,
    y=0,
    inner_radius=outer_radius_inner,
    outer_radius=outer_radius_outer,
    color="color",
    line_color=PAGE_BG,
    line_width=2,
    source=outer_source,
)

# Labels for inner ring (departments with values)
inner_label_text = [f"{lbl}\n${val}K" for lbl, val in zip(inner_labels, inner_values, strict=True)]
inner_label_source = ColumnDataSource(data={"x": inner_x, "y": inner_y, "text": inner_label_text})

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 = []
for label, value, x, y, start, end in zip(
    outer_labels, outer_values, outer_x, outer_y, outer_start_angle, outer_end_angle, 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_source = ColumnDataSource(data={"x": outer_label_x, "y": outer_label_y, "text": outer_label_text})

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_SOFT,
)
p.add_layout(outer_labels_set)

# Add center text showing total
p.text(
    x=[0],
    y=[0],
    text=[f"Total\n${total}K"],
    text_align="center",
    text_baseline="middle",
    text_font_size="24pt",
    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 = 3600, 3600
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()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Nested Donut Chart on anyplot.ai.

Other implementations