{"spec_id":"donut-nested","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ndonut-nested: Nested Donut Chart\nLibrary: bokeh 3.9.2 | Python 3.13.15\nQuality: 92/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport time\nfrom colorsys import hls_to_rgb, rgb_to_hls\nfrom math import pi\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, LabelSet\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens\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\"\n\n# Data: Budget allocation by department and expense categories\n# Inner ring: departments, outer ring: expense categories within each department\ndata = {\n    \"Engineering\": {\"Salaries\": 450, \"Equipment\": 120, \"Training\": 80, \"Cloud\": 150},\n    \"Marketing\": {\"Advertising\": 280, \"Events\": 90, \"Content\": 60},\n    \"Sales\": {\"Salaries\": 320, \"Travel\": 85, \"Tools\": 45},\n    \"Operations\": {\"Facilities\": 180, \"IT Support\": 95, \"Utilities\": 55},\n}\n\n# Imprint palette, positions 1-4 (one hue per department)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n\ndef hex_to_rgb01(hex_color):\n    hex_color = hex_color.lstrip(\"#\")\n    return tuple(int(hex_color[i : i + 2], 16) / 255.0 for i in (0, 2, 4))\n\n\ndef rgb01_to_hex(rgb):\n    return \"#{:02X}{:02X}{:02X}\".format(*(round(max(0.0, min(1.0, c)) * 255) for c in rgb))\n\n\ndef tint_family(base_hex, count):\n    # Genuine same-hue tints: walk lightness toward the page background while\n    # keeping hue and saturation from the department's Imprint base color.\n    r, g, b = hex_to_rgb01(base_hex)\n    hue, lightness, sat = rgb_to_hls(r, g, b)\n    steps = max(count - 1, 1)\n    return [rgb01_to_hex(hls_to_rgb(hue, lightness + (0.82 - lightness) * (i / steps), sat)) for i in range(count)]\n\n\ndef contrast_ink(hex_color):\n    # Outer-ring tints run pale regardless of theme (data colors never flip),\n    # so pick label ink from the wedge's own luminance rather than the theme\n    # token — a theme-only INK_SOFT washes out against the palest tints.\n    r, g, b = hex_to_rgb01(hex_color)\n    luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b\n    return \"#1A1A17\" if luminance > 0.55 else \"#F0EFE8\"\n\n\ncolor_palettes = {dept: tint_family(IMPRINT_PALETTE[i], len(cats)) for i, (dept, cats) in enumerate(data.items())}\n\n# Calculate totals and angles\ndept_totals = {dept: sum(cats.values()) for dept, cats in data.items()}\ntotal = sum(dept_totals.values())\nlargest_dept = max(dept_totals, key=dept_totals.get)\n\n# Ring radii (inner ring = departments, outer ring = expense categories)\ninner_radius_inner, inner_radius_outer = 0.35, 0.60\nouter_radius_inner, outer_radius_outer = 0.65, 0.90\nemphasis_bump = 0.04  # nudges the largest department's wedge outward\n\n# Build data for inner ring (departments)\ninner_start_angle, inner_end_angle = [], []\ninner_outer_radius, inner_colors = [], []\ninner_labels, inner_values = [], []\ninner_x, inner_y = [], []\n\n# Build data for outer ring (categories)\nouter_start_angle, outer_end_angle = [], []\nouter_outer_radius, outer_colors = [], []\nouter_labels, outer_values, outer_dept = [], [], []\nouter_x, outer_y = [], []\n\ncurrent_angle = pi / 2  # start at top\n\nfor dept, categories in data.items():\n    dept_total = dept_totals[dept]\n    dept_angle = 2 * pi * (dept_total / total)\n    is_largest = dept == largest_dept\n    bump = emphasis_bump if is_largest else 0.0\n\n    # Inner ring segment\n    inner_start_angle.append(current_angle)\n    inner_end_angle.append(current_angle + dept_angle)\n    inner_outer_radius.append(inner_radius_outer + bump)\n    inner_colors.append(color_palettes[dept][0])\n    inner_labels.append(dept)\n    inner_values.append(dept_total)\n\n    # Label position for inner ring\n    mid_angle = current_angle + dept_angle / 2\n    label_radius = (inner_radius_outer + inner_radius_inner) / 2 + bump / 2\n    inner_x.append(label_radius * np.cos(mid_angle))\n    inner_y.append(label_radius * np.sin(mid_angle))\n\n    # Outer ring segments (categories within this department)\n    cat_start = current_angle\n    for i, (cat, val) in enumerate(categories.items()):\n        cat_angle = 2 * pi * (val / total)\n\n        outer_start_angle.append(cat_start)\n        outer_end_angle.append(cat_start + cat_angle)\n        outer_outer_radius.append(outer_radius_outer + bump)\n        outer_colors.append(color_palettes[dept][i % len(color_palettes[dept])])\n        outer_labels.append(cat)\n        outer_values.append(val)\n        outer_dept.append(dept)\n\n        # Label position for outer ring\n        cat_mid_angle = cat_start + cat_angle / 2\n        cat_label_radius = (outer_radius_outer + outer_radius_inner) / 2 + bump / 2\n        outer_x.append(cat_label_radius * np.cos(cat_mid_angle))\n        outer_y.append(cat_label_radius * np.sin(cat_mid_angle))\n\n        cat_start += cat_angle\n\n    current_angle += dept_angle\n\n# Create figure (square format for donut) — canonical anyplot canvas\ntitle_text = \"donut-nested · python · bokeh · anyplot.ai\"\n\np = figure(\n    width=2400,\n    height=2400,\n    title=title_text,\n    x_range=(-1.35, 1.35),\n    y_range=(-1.35, 1.35),\n    tools=\"\",\n    toolbar_location=None,\n    min_border_top=90,\n    min_border_bottom=40,\n    min_border_left=40,\n    min_border_right=40,\n)\n\n# Style the figure\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"bold\"\np.title.text_color = INK\np.title.align = \"center\"\np.axis.visible = False\np.grid.visible = False\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\n# Elevated disc behind the donut hole — lifts the center total off the page\np.annulus(x=0, y=0, inner_radius=0, outer_radius=inner_radius_inner - 0.015, fill_color=ELEVATED_BG, line_color=None)\n\n# Inner ring (departments)\ninner_source = ColumnDataSource(\n    data={\n        \"start_angle\": inner_start_angle,\n        \"end_angle\": inner_end_angle,\n        \"outer_radius\": inner_outer_radius,\n        \"color\": inner_colors,\n        \"label\": inner_labels,\n        \"value\": inner_values,\n        \"x\": inner_x,\n        \"y\": inner_y,\n    }\n)\n\ninner_glyph = p.annular_wedge(\n    x=0,\n    y=0,\n    inner_radius=inner_radius_inner,\n    outer_radius=\"outer_radius\",\n    start_angle=\"start_angle\",\n    end_angle=\"end_angle\",\n    color=\"color\",\n    line_color=PAGE_BG,\n    line_width=4,\n    source=inner_source,\n)\n\n# Outer ring (categories)\nouter_source = ColumnDataSource(\n    data={\n        \"start_angle\": outer_start_angle,\n        \"end_angle\": outer_end_angle,\n        \"outer_radius\": outer_outer_radius,\n        \"color\": outer_colors,\n        \"label\": outer_labels,\n        \"value\": outer_values,\n        \"dept\": outer_dept,\n        \"x\": outer_x,\n        \"y\": outer_y,\n    }\n)\n\nouter_glyph = p.annular_wedge(\n    x=0,\n    y=0,\n    inner_radius=outer_radius_inner,\n    outer_radius=\"outer_radius\",\n    start_angle=\"start_angle\",\n    end_angle=\"end_angle\",\n    color=\"color\",\n    line_color=PAGE_BG,\n    line_width=3,\n    source=outer_source,\n)\n\n# Hover tooltips — genuinely interactive in the saved HTML (inspect tools stay\n# live without a toolbar button), naming the exact department/category + value.\np.add_tools(\n    HoverTool(renderers=[inner_glyph], tooltips=[(\"Department\", \"@label\"), (\"Budget\", \"$@value{0,0}K\")]),\n    HoverTool(\n        renderers=[outer_glyph], tooltips=[(\"Category\", \"@label\"), (\"Department\", \"@dept\"), (\"Budget\", \"$@value{0,0}K\")]\n    ),\n)\n\n# Labels for inner ring (departments with values) — ink picked per wedge\n# color via contrast_ink(), matching the outer ring's approach, so labels\n# stay readable on both the saturated and pale department wedges.\ninner_label_text = [f\"{lbl}\\n${val}K\" for lbl, val in zip(inner_labels, inner_values, strict=True)]\ninner_label_ink = [contrast_ink(c) for c in inner_colors]\ninner_label_source = ColumnDataSource(\n    data={\"x\": inner_x, \"y\": inner_y, \"text\": inner_label_text, \"ink\": inner_label_ink}\n)\n\ninner_labels_set = LabelSet(\n    x=\"x\",\n    y=\"y\",\n    text=\"text\",\n    source=inner_label_source,\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    text_font_size=\"20pt\",\n    text_color=\"ink\",\n    text_font_style=\"bold\",\n)\np.add_layout(inner_labels_set)\n\n# Labels for outer ring (only for larger segments)\nouter_label_text, outer_label_x, outer_label_y, outer_label_ink = [], [], [], []\nfor label, value, x, y, start, end, color in zip(\n    outer_labels, outer_values, outer_x, outer_y, outer_start_angle, outer_end_angle, outer_colors, strict=True\n):\n    segment_angle = abs(end - start)\n    # Only label segments larger than 0.25 radians (~14 degrees)\n    if segment_angle > 0.25:\n        outer_label_text.append(f\"{label}\\n${value}K\")\n        outer_label_x.append(x)\n        outer_label_y.append(y)\n        outer_label_ink.append(contrast_ink(color))\n\nouter_label_source = ColumnDataSource(\n    data={\"x\": outer_label_x, \"y\": outer_label_y, \"text\": outer_label_text, \"ink\": outer_label_ink}\n)\n\nouter_labels_set = LabelSet(\n    x=\"x\",\n    y=\"y\",\n    text=\"text\",\n    source=outer_label_source,\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    text_font_size=\"16pt\",\n    text_color=\"ink\",\n)\np.add_layout(outer_labels_set)\n\n# Compact legend for outer segments below the on-chart label threshold — the\n# spec calls for \"use legend for smaller ones\" so no wedge is left\n# unidentifiable in the static PNG.\nbelow_threshold = [\n    (label, value, dept, color)\n    for label, value, dept, start, end, color in zip(\n        outer_labels, outer_values, outer_dept, outer_start_angle, outer_end_angle, outer_colors, strict=True\n    )\n    if abs(end - start) <= 0.25\n]\n\nif below_threshold:\n    legend_x, legend_row_h = -1.32, 0.09\n    legend_top_y = -1.35 + legend_row_h * len(below_threshold)\n\n    legend_swatch_source = ColumnDataSource(\n        data={\n            \"x\": [legend_x] * len(below_threshold),\n            \"y\": [legend_top_y - i * legend_row_h for i in range(len(below_threshold))],\n            \"color\": [color for _, _, _, color in below_threshold],\n        }\n    )\n    p.scatter(x=\"x\", y=\"y\", source=legend_swatch_source, marker=\"square\", size=16, color=\"color\", line_color=None)\n\n    legend_label_source = ColumnDataSource(\n        data={\n            \"x\": [legend_x + 0.04] * len(below_threshold),\n            \"y\": [legend_top_y - i * legend_row_h for i in range(len(below_threshold))],\n            \"text\": [f\"{dept} · {label} (${value}K)\" for label, value, dept, _ in below_threshold],\n        }\n    )\n    legend_labels_set = LabelSet(\n        x=\"x\",\n        y=\"y\",\n        text=\"text\",\n        source=legend_label_source,\n        text_align=\"left\",\n        text_baseline=\"middle\",\n        text_font_size=\"16pt\",\n        text_color=INK,\n    )\n    p.add_layout(legend_labels_set)\n\n# Center text showing the grand total\np.text(\n    x=[0],\n    y=[0],\n    text=[f\"Total\\n${total}K\"],\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    text_font_size=\"26pt\",\n    text_font_style=\"bold\",\n    text_color=INK,\n)\n\n# Save HTML output\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome using Selenium\nW, H = 2400, 2400\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}