{"spec_id":"donut-nested","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ndonut-nested: Nested Donut Chart\nLibrary: altair 6.2.2 | Python 3.13.15\nQuality: 89/100 | Updated: 2026-08-18\n\"\"\"\n\nimport colorsys\nimport math\nimport os\n\nimport altair as alt\nimport pandas as pd\nfrom PIL import Image\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\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Direct-on-fill label ink - contrasted against each segment's own color, not\n# the page theme, since a light pastel child segment renders identically in\n# both themes and a theme-only gray fails against it (near-white on near-white)\nTEXT_DARK = \"#1A1A17\"\nTEXT_LIGHT = \"#F0EFE8\"\n\n# Imprint palette (canonical order)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data - Market share by region (inner) and product lines within each region (outer)\ndata = {\n    \"level_1\": [\"Americas\", \"Americas\", \"Americas\", \"EMEA\", \"EMEA\", \"EMEA\", \"EMEA\", \"Asia\", \"Asia\", \"Asia\", \"Asia\"],\n    \"level_2\": [\n        \"Cloud Services\",\n        \"Software Licenses\",\n        \"Consulting\",\n        \"Cloud Services\",\n        \"Software Licenses\",\n        \"Hardware\",\n        \"Support Services\",\n        \"Cloud Services\",\n        \"Hardware\",\n        \"Software Licenses\",\n        \"Training\",\n    ],\n    \"value\": [420, 280, 150, 320, 240, 180, 160, 380, 200, 220, 140],\n}\n\ndf = pd.DataFrame(data)\n\n# Calculate parent totals for inner ring\ninner_df = df.groupby(\"level_1\", as_index=False, sort=False)[\"value\"].sum()\ninner_df[\"level_2\"] = inner_df[\"level_1\"]\n\n# Inner ring colors (Imprint palette, canonical order)\ninner_color_map = {}\nfor i, parent in enumerate(inner_df[\"level_1\"]):\n    inner_color_map[parent] = IMPRINT[i % len(IMPRINT)]\ninner_df[\"color\"] = inner_df[\"level_1\"].map(inner_color_map)\n\n# Outer ring colors - same hue/saturation as the parent, stepped lightness per\n# child so each color family reads as one region at a glance. Capped short of\n# white so children stay legible against the page background.\ncolor_map = {}\nfor i, parent in enumerate(inner_df[\"level_1\"]):\n    parent_color = IMPRINT[i % len(IMPRINT)]\n    r = int(parent_color[1:3], 16) / 255\n    g = int(parent_color[3:5], 16) / 255\n    b = int(parent_color[5:7], 16) / 255\n    hue, lightness, saturation = colorsys.rgb_to_hls(r, g, b)\n    parent_children = df[df[\"level_1\"] == parent][\"level_2\"].tolist()\n    color_map[parent] = {}\n    for j, child in enumerate(parent_children):\n        child_lightness = min(0.82, lightness + j * 0.12)\n        cr, cg, cb = colorsys.hls_to_rgb(hue, child_lightness, saturation)\n        color_map[parent][child] = \"#{:02x}{:02x}{:02x}\".format(round(cr * 255), round(cg * 255), round(cb * 255))\n\nouter_colors = []\nfor _, row in df.iterrows():\n    outer_colors.append(color_map[row[\"level_1\"]][row[\"level_2\"]])\ndf[\"color\"] = outer_colors\n\n# Format values for tooltip\ndf[\"formatted_value\"] = df[\"value\"].apply(lambda x: f\"${x}M\")\ninner_df[\"formatted_value\"] = inner_df[\"value\"].apply(lambda x: f\"${x}M\")\n\n# Ring geometry (view units, before scale_factor) - sized to fit the 500x460\n# square inner view with room left for the title above\nINNER_R0, INNER_R1 = 55, 135\nOUTER_R0, OUTER_R1 = 147, 215\nOUTER_LABEL_R = (OUTER_R0 + OUTER_R1) / 2\n\n# Show labels only on segments both large enough (>=150) and geometrically wide\n# enough to hold the text without bleeding past the wedge's own boundary. Text\n# is rendered horizontally regardless of the wedge's angular position, so a\n# wedge whose mid-angle sits near the 3-o'clock/9-o'clock extremes needs far\n# less horizontal offset to push the label past the outer radius than a wedge\n# near 12/6-o'clock - a plain chord-width estimate misses this and was exactly\n# how \"Software Licenses\" bled into \"Consulting\" in the prior review. Check\n# both text ends against the wedge's true polar boundary (outer radius AND\n# angular span) instead.\nOUTER_LABEL_FONTSIZE = 10\nAVG_CHAR_PX = 5.6  # empirical average glyph width at this font size\nRADIAL_MARGIN = 2  # px of slack before the outer rim\n\n\ndef _label_fits(text, theta_start_deg, theta_end_deg):\n    mid = math.radians((theta_start_deg + theta_end_deg) / 2)\n    x0, y0 = OUTER_LABEL_R * math.sin(mid), -OUTER_LABEL_R * math.cos(mid)\n    half_width = len(text) * AVG_CHAR_PX / 2\n    for x in (x0 - half_width, x0 + half_width):\n        if math.hypot(x, y0) > OUTER_R1 - RADIAL_MARGIN:\n            return False\n        angle = math.degrees(math.atan2(x, -y0)) % 360\n        if not (theta_start_deg - 0.5 <= angle <= theta_end_deg + 0.5):\n            return False\n    return True\n\n\n_total_value = df[\"value\"].sum()\n_cum_value = df[\"value\"].cumsum() - df[\"value\"]\ndf[\"theta_start\"] = _cum_value / _total_value * 360\ndf[\"theta_end\"] = (_cum_value + df[\"value\"]) / _total_value * 360\ndf[\"label\"] = df.apply(\n    lambda row: (\n        row[\"level_2\"]\n        if row[\"value\"] >= 150 and _label_fits(row[\"level_2\"], row[\"theta_start\"], row[\"theta_end\"])\n        else \"\"\n    ),\n    axis=1,\n)\n\n# Per-segment label ink - pick dark or light text by the fill's own perceived\n# luminance so labels stay legible on every family, from full-saturation\n# parents to the palest children, in both themes (data colors don't change)\ninner_label_colors = []\nfor hex_color in inner_df[\"color\"]:\n    r = int(hex_color[1:3], 16) / 255\n    g = int(hex_color[3:5], 16) / 255\n    b = int(hex_color[5:7], 16) / 255\n    luma = 0.299 * r + 0.587 * g + 0.114 * b\n    inner_label_colors.append(TEXT_DARK if luma >= 0.6 else TEXT_LIGHT)\ninner_df[\"label_color\"] = inner_label_colors\n\nouter_label_colors = []\nfor hex_color in df[\"color\"]:\n    r = int(hex_color[1:3], 16) / 255\n    g = int(hex_color[3:5], 16) / 255\n    b = int(hex_color[5:7], 16) / 255\n    luma = 0.299 * r + 0.587 * g + 0.114 * b\n    outer_label_colors.append(TEXT_DARK if luma >= 0.6 else TEXT_LIGHT)\ndf[\"label_color\"] = outer_label_colors\n\n# Explicit stack order, shared by every layer below - without it, Vega-Lite is\n# free to pick a different implicit sort per layer (e.g. by the \"color\" field\n# for arcs vs. by the \"text\" field for labels), which rotates the arc and\n# label layers out of sync and puts a name on the wrong wedge\ninner_df[\"sort_order\"] = range(len(inner_df))\ndf[\"sort_order\"] = range(len(df))\n\n# Inner ring (parent categories)\ninner_ring = (\n    alt.Chart(inner_df)\n    .mark_arc(innerRadius=INNER_R0, outerRadius=INNER_R1, cornerRadius=3, padAngle=0.01, stroke=PAGE_BG, strokeWidth=2)\n    .encode(\n        theta=alt.Theta(\"value:Q\", stack=True),\n        order=alt.Order(\"sort_order:Q\"),\n        color=alt.Color(\"color:N\", scale=None, legend=None),\n        tooltip=[alt.Tooltip(\"level_1:N\", title=\"Region\"), alt.Tooltip(\"formatted_value:N\", title=\"Total Revenue\")],\n    )\n)\n\n# Outer ring (child categories)\nouter_ring = (\n    alt.Chart(df)\n    .mark_arc(innerRadius=OUTER_R0, outerRadius=OUTER_R1, cornerRadius=3, padAngle=0.01, stroke=PAGE_BG, strokeWidth=2)\n    .encode(\n        theta=alt.Theta(\"value:Q\", stack=True),\n        order=alt.Order(\"sort_order:Q\"),\n        color=alt.Color(\"color:N\", scale=None, legend=None),\n        tooltip=[\n            alt.Tooltip(\"level_1:N\", title=\"Region\"),\n            alt.Tooltip(\"level_2:N\", title=\"Product\"),\n            alt.Tooltip(\"formatted_value:N\", title=\"Revenue\"),\n        ],\n    )\n)\n\n# Labels for inner ring (region names)\ninner_labels = (\n    alt.Chart(inner_df)\n    .mark_text(radius=(INNER_R0 + INNER_R1) / 2, fontSize=13, fontWeight=\"bold\")\n    .encode(\n        theta=alt.Theta(\"value:Q\", stack=True),\n        order=alt.Order(\"sort_order:Q\"),\n        text=\"level_1:N\",\n        color=alt.Color(\"label_color:N\", scale=None, legend=None),\n    )\n)\n\n# Labels for outer ring (only on segments large and wide enough to hold text -\n# see _label_fits above)\nouter_labels = (\n    alt.Chart(df)\n    .mark_text(radius=OUTER_LABEL_R, fontSize=OUTER_LABEL_FONTSIZE)\n    .encode(\n        theta=alt.Theta(\"value:Q\", stack=True),\n        order=alt.Order(\"sort_order:Q\"),\n        text=\"label:N\",\n        color=alt.Color(\"label_color:N\", scale=None, legend=None),\n    )\n)\n\n# Legend fallback for segments too small/narrow to carry an inline label (spec:\n# \"use legend for smaller ones\") - an invisible mark carries a real Color scale\n# so Vega-Lite draws the standard legend from its domain/range, without needing\n# a visible layer of its own\nlayers = [inner_ring, outer_ring, inner_labels, outer_labels]\nunlabeled_df = df[df[\"label\"] == \"\"].copy()\nif not unlabeled_df.empty:\n    unlabeled_df[\"legend_key\"] = unlabeled_df[\"level_1\"] + \": \" + unlabeled_df[\"level_2\"]\n    legend_layer = (\n        alt.Chart(unlabeled_df)\n        .mark_point(opacity=0)\n        .encode(\n            color=alt.Color(\n                \"legend_key:N\",\n                scale=alt.Scale(domain=unlabeled_df[\"legend_key\"].tolist(), range=unlabeled_df[\"color\"].tolist()),\n                legend=alt.Legend(\n                    title=None, orient=\"bottom\", direction=\"horizontal\", symbolType=\"square\", labelFontSize=10\n                ),\n            )\n        )\n    )\n    layers.append(legend_layer)\n\n# Combine all layers\nchart = (\n    alt.layer(*layers)\n    .resolve_scale(color=\"independent\")\n    .properties(\n        width=500,\n        height=460,\n        background=PAGE_BG,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        title=alt.Title(\"donut-nested · altair · anyplot.ai\", fontSize=16, anchor=\"middle\", offset=16, color=INK),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None, strokeWidth=0)\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK, titleColor=INK)\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad the saved PNG up to the exact 2400x2400 canonical target - never crop,\n# since cropping would clip title/label content at the edges (see\n# prompts/library/altair.md \"Canvas\" for why vl-convert overshoots width/height)\nTW, TH = 2400, 2400\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}