{"spec_id":"donut-nested","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ndonut-nested: Nested Donut Chart\nLibrary: letsplot 4.11.0 | Python 3.13.15\nQuality: 94/100 | Updated: 2026-08-18\n\"\"\"\n\nimport colorsys\nimport math\nimport os\n\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    coord_fixed,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_pie,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    scale_fill_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\n\n# Segment labels sit on top of the (theme-invariant) Imprint fill colors, not on the\n# page background, so - unlike the center total label below, which does sit on the page\n# background and stays theme-adaptive - they keep a fixed dark ink in both themes: every\n# department/child hue here is light enough that a light, dark-theme-adaptive ink would\n# lose contrast against it.\nDATA_LABEL_INK = \"#1A1A17\"\nDATA_LABEL_INK_SOFT = \"#4A4A44\"\n\n# Imprint palette (first series always #009E73)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data - budget allocation by department (inner ring) and expense category (outer ring)\nrecords = [\n    {\"level_1\": \"Marketing\", \"level_2\": \"Advertising\", \"value\": 18},\n    {\"level_1\": \"Marketing\", \"level_2\": \"Events\", \"value\": 8},\n    {\"level_1\": \"Marketing\", \"level_2\": \"Content\", \"value\": 6},\n    {\"level_1\": \"Operations\", \"level_2\": \"Facilities\", \"value\": 12},\n    {\"level_1\": \"Operations\", \"level_2\": \"IT Support\", \"value\": 10},\n    {\"level_1\": \"Operations\", \"level_2\": \"Logistics\", \"value\": 8},\n    {\"level_1\": \"R&D\", \"level_2\": \"Product Dev\", \"value\": 15},\n    {\"level_1\": \"R&D\", \"level_2\": \"Research\", \"value\": 10},\n    {\"level_1\": \"Sales\", \"level_2\": \"Field Sales\", \"value\": 9},\n    {\"level_1\": \"Sales\", \"level_2\": \"Inside Sales\", \"value\": 4},\n]\ndf = pd.DataFrame(records)\ntotal_value = int(df[\"value\"].sum())\nlevel1_order = [\"Marketing\", \"Operations\", \"R&D\", \"Sales\"]\n\n\ndef lighten(hex_color, amount):\n    r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5))\n    h, lightness, s = colorsys.rgb_to_hls(r, g, b)\n    lightness = min(0.88, lightness + amount)\n    r, g, b = colorsys.hls_to_rgb(h, lightness, s)\n    return \"#{:02X}{:02X}{:02X}\".format(round(r * 255), round(g * 255), round(b * 255))\n\n\ndef with_mid_angle(frame):\n    frac = frame[\"pct\"] / 100\n    cum_end = frac.cumsum()\n    cum_start = cum_end - frac\n    mid_frac = (cum_start + cum_end) / 2\n    frame[\"mid_angle\"] = math.radians(90) - mid_frac * 2 * math.pi\n    return frame\n\n\n# Both rings share a single fill scale, so both \"level_1\" and \"level_2\" are cast to the\n# same explicitly ordered categorical (department names first, then children grouped\n# department-by-department) - a plain string column would fall back to alphabetical\n# order and scramble both the wedge draw order and the color assignment.\nlevel2_order = [r[\"level_2\"] for r in records]\nall_categories = level1_order + level2_order\n\n# Inner ring: one wedge per department, in a fixed display order\ninner_df = df.groupby(\"level_1\", as_index=False)[\"value\"].sum()\ninner_df[\"level_1\"] = pd.Categorical(inner_df[\"level_1\"], categories=all_categories, ordered=True)\ninner_df = inner_df.sort_values(\"level_1\").reset_index(drop=True)\ninner_df[\"pct\"] = inner_df[\"value\"] / total_value * 100\ninner_df[\"x\"], inner_df[\"y\"] = 0.0, 0.0\n\n# Outer ring: children, grouped department-by-department in the same order as the inner\n# ring so that wedge boundaries between the two rings line up exactly.\nouter_df = df.copy()\nouter_df[\"level_2\"] = pd.Categorical(outer_df[\"level_2\"], categories=all_categories, ordered=True)\nouter_df[\"x\"], outer_df[\"y\"] = 0.0, 0.0\nouter_df[\"pct\"] = outer_df[\"value\"] / total_value * 100\n\n# Color families: department gets the base Imprint hue, children of that department\n# lighten in ranked order so the largest child stays closest to the parent hue and\n# smaller children read as progressively lighter tints - a second, color-coded cue\n# for the size hierarchy the wedge angles already encode.\ncolor_by_dept = dict(zip(level1_order, IMPRINT, strict=False))\nchild_colors = {}\nfor dept in level1_order:\n    dept_children = df[df[\"level_1\"] == dept].sort_values(\"value\", ascending=False)\n    for rank, child in enumerate(dept_children[\"level_2\"]):\n        child_colors[child] = lighten(color_by_dept[dept], rank * 0.14)\n\n# scale_fill_manual take a plain list matched positionally to all_categories - a dict\n# keyed by name does not reliably resolve against a shared categorical fill scale.\nall_colors = [color_by_dept[c] for c in level1_order] + [child_colors[c] for c in level2_order]\n\ninner_df = with_mid_angle(inner_df)\nouter_df = with_mid_angle(outer_df)\n\n# Ring geometry (data units), with a gap between rings for visual separation.\n# A generously sized center hole keeps the inner-ring labels clear of the total label.\nr_inner_1, r_outer_1 = 20, 38\nr_inner_2, r_outer_2 = 41, 64\n\n# Biased toward the outer edge of the ring, not the midpoint: a horizontal label whose\n# anchor sits at a diagonal angle can swing closer to the center than its anchor radius\n# as it extends sideways, so the largest wedge needs the extra clearance from the hole.\nlabel_r_inner = r_inner_1 + (r_outer_1 - r_inner_1) * 0.6\ninner_df[\"label_x\"] = label_r_inner * inner_df[\"mid_angle\"].apply(math.cos)\ninner_df[\"label_y\"] = label_r_inner * inner_df[\"mid_angle\"].apply(math.sin)\n\nlabel_r_outer = (r_inner_2 + r_outer_2) / 2\nouter_df[\"label_x\"] = label_r_outer * outer_df[\"mid_angle\"].apply(math.cos)\nouter_df[\"label_y\"] = label_r_outer * outer_df[\"mid_angle\"].apply(math.sin)\n\n# geom_text size is in mm, unlike element_text size (pt) - convert the intended pt sizes\nMM_PER_PT = 1 / 2.845\nINNER_LABEL_MM = 12 * MM_PER_PT\nOUTER_LABEL_MM = 10 * MM_PER_PT\nCENTER_LABEL_MM = 18 * MM_PER_PT\n\n# Theme configuration\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    plot_title=element_text(size=16, color=INK, hjust=0.5),\n    axis_title=element_blank(),\n    axis_text=element_blank(),\n    axis_ticks=element_blank(),\n    axis_line=element_blank(),\n    panel_grid=element_blank(),\n    legend_position=\"none\",\n)\n\n# Plot - geom_pie (lets-plot's native pie/donut geom, with no ggplot2 equivalent) draws\n# each ring; two rings sharing the same start angle and direction, with values that\n# both sum to the same grand total, keep the wedge boundaries radially aligned.\nplot = (\n    ggplot()\n    + geom_pie(\n        aes(x=\"x\", y=\"y\", slice=\"value\", fill=\"level_1\"),\n        data=inner_df,\n        stat=\"identity\",\n        size=2 * r_outer_1,\n        size_unit=\"x\",\n        hole=r_inner_1 / r_outer_1,\n        start=0,\n        direction=1,\n        color=PAGE_BG,\n        stroke=3,\n    )\n    + geom_pie(\n        aes(x=\"x\", y=\"y\", slice=\"value\", fill=\"level_2\"),\n        data=outer_df,\n        stat=\"identity\",\n        size=2 * r_outer_2,\n        size_unit=\"x\",\n        hole=r_inner_2 / r_outer_2,\n        start=0,\n        direction=1,\n        color=PAGE_BG,\n        stroke=3,\n    )\n    + geom_text(\n        aes(x=\"label_x\", y=\"label_y\", label=\"level_1\"),\n        data=inner_df,\n        size=INNER_LABEL_MM,\n        color=DATA_LABEL_INK,\n        fontface=\"bold\",\n    )\n    + geom_text(\n        aes(x=\"label_x\", y=\"label_y\", label=\"level_2\"), data=outer_df, size=OUTER_LABEL_MM, color=DATA_LABEL_INK_SOFT\n    )\n    + geom_text(x=0, y=0, label=f\"Total\\n${total_value}M\", size=CENTER_LABEL_MM, color=INK, fontface=\"bold\")\n    + scale_fill_manual(values=all_colors)\n    + coord_fixed(ratio=1)\n    + scale_x_continuous(limits=(-78, 78))\n    + scale_y_continuous(limits=(-78, 78))\n    + labs(title=\"donut-nested · python · letsplot · anyplot.ai\")\n    + ggsize(600, 600)\n    + anyplot_theme\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}