{"spec_id":"donut-nested","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// donut-nested: Nested Donut Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-18\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Company budget: department totals (inner ring) split into expense\n// categories (outer ring). Category values sum exactly to their parent's\n// total, and children are listed grouped by department in the same order as\n// the departments array, so the two rings' arc boundaries line up radially.\nconst departments = [\n  {\n    name: \"Engineering\",\n    color: t.palette[0],\n    children: [\n      { name: \"R&D\", value: 18 },\n      { name: \"Infrastructure\", value: 9 },\n      { name: \"QA\", value: 6 },\n    ],\n  },\n  {\n    name: \"Marketing\",\n    color: t.palette[1],\n    children: [\n      { name: \"Advertising\", value: 10 },\n      { name: \"Content\", value: 6 },\n      { name: \"Events\", value: 4 },\n    ],\n  },\n  {\n    name: \"Sales\",\n    color: t.palette[2],\n    children: [\n      { name: \"Direct Sales\", value: 14 },\n      { name: \"Partnerships\", value: 5 },\n      { name: \"Customer Success\", value: 6 },\n    ],\n  },\n  {\n    name: \"Operations\",\n    color: t.palette[3],\n    children: [\n      { name: \"Facilities\", value: 8 },\n      { name: \"Logistics\", value: 5 },\n    ],\n  },\n];\n\n// --- Color families: children share their parent's hue at rising lightness -\nfunction hexToHsl(hex) {\n  const r = parseInt(hex.slice(1, 3), 16) / 255;\n  const g = parseInt(hex.slice(3, 5), 16) / 255;\n  const b = parseInt(hex.slice(5, 7), 16) / 255;\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  let h = 0;\n  let s = 0;\n  const l = (max + min) / 2;\n  if (max !== min) {\n    const d = max - min;\n    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n    if (max === r) h = (g - b) / d + (g < b ? 6 : 0);\n    else if (max === g) h = (b - r) / d + 2;\n    else h = (r - g) / d + 4;\n    h /= 6;\n  }\n  return [h * 360, s * 100, l * 100];\n}\n\nfunction hslToHex(h, s, l) {\n  const sf = s / 100;\n  const lf = l / 100;\n  const c = (1 - Math.abs(2 * lf - 1)) * sf;\n  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));\n  const m = lf - c / 2;\n  let rgb;\n  if (h < 60) rgb = [c, x, 0];\n  else if (h < 120) rgb = [x, c, 0];\n  else if (h < 180) rgb = [0, c, x];\n  else if (h < 240) rgb = [0, x, c];\n  else if (h < 300) rgb = [x, 0, c];\n  else rgb = [c, 0, x];\n  const toHex = (v) =>\n    Math.round((v + m) * 255)\n      .toString(16)\n      .padStart(2, \"0\");\n  return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;\n}\n\nfunction childShade(baseHex, index, count) {\n  const [h, s, l] = hexToHsl(baseHex);\n  const step = count > 1 ? 24 / (count - 1) : 0;\n  const lightness = Math.min(l + 12 + step * index, 88);\n  return hslToHex(h, s, lightness);\n}\n\n// --- Flatten into the two rings ---------------------------------------------\nconst departmentLabels = departments.map((d) => d.name);\nconst departmentTotals = departments.map((d) =>\n  d.children.reduce((sum, c) => sum + c.value, 0),\n);\nconst departmentColors = departments.map((d) => d.color);\n\nconst categoryLabels = departments.flatMap((d) => d.children.map((c) => c.name));\nconst categoryValues = departments.flatMap((d) => d.children.map((c) => c.value));\nconst categoryColors = departments.flatMap((d) =>\n  d.children.map((c, i) => childShade(d.color, i, d.children.length)),\n);\nconst grandTotal = departmentTotals.reduce((sum, v) => sum + v, 0);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Plugin: direct labels on the department ring's large segments ---------\n// (Chart.js draws dataset 0 as the outermost ring, working inward, so dataset\n// index 1 — the department ring — is the one with enough radial room and few\n// enough segments for on-arc labels; the finer category ring gets a legend.)\nconst ringLabelsPlugin = {\n  id: \"ringLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    const meta = chart.getDatasetMeta(1);\n    ctx.save();\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.lineJoin = \"round\";\n    meta.data.forEach((arc, i) => {\n      const angle = (arc.startAngle + arc.endAngle) / 2;\n      const radius = (arc.innerRadius + arc.outerRadius) / 2;\n      const x = arc.x + Math.cos(angle) * radius;\n      const y = arc.y + Math.sin(angle) * radius;\n\n      ctx.font = \"600 21px sans-serif\";\n      ctx.strokeStyle = \"rgba(0,0,0,0.35)\";\n      ctx.lineWidth = 4;\n      ctx.strokeText(departmentLabels[i], x, y - 14);\n      ctx.fillStyle = \"#FFFFFF\";\n      ctx.fillText(departmentLabels[i], x, y - 14);\n\n      ctx.font = \"400 20px sans-serif\";\n      ctx.strokeText(`$${departmentTotals[i]}M`, x, y + 14);\n      ctx.fillText(`$${departmentTotals[i]}M`, x, y + 14);\n    });\n\n    // Center label in the donut hole: the grand total ties the two rings\n    // together into one headline number rather than a plain breakdown.\n    const center = meta.data[0];\n    ctx.font = \"400 17px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.strokeStyle = \"transparent\";\n    ctx.lineWidth = 0;\n    ctx.fillText(\"Total Budget\", center.x, center.y - 16);\n    ctx.font = \"700 30px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.fillText(`$${grandTotal}M`, center.x, center.y + 16);\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"doughnut\",\n  data: {\n    labels: categoryLabels,\n    datasets: [\n      {\n        label: \"Expense Category\",\n        data: categoryValues,\n        backgroundColor: categoryColors,\n        borderColor: t.pageBg,\n        borderWidth: 3,\n        weight: 1.4,\n      },\n      {\n        label: \"Department\",\n        data: departmentTotals,\n        backgroundColor: departmentColors,\n        borderColor: t.pageBg,\n        borderWidth: 3,\n        weight: 1,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    cutout: \"34%\",\n    layout: {\n      padding: { left: 50, right: 50, top: 10, bottom: 10 },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"donut-nested · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 24 },\n      },\n      legend: {\n        position: \"right\",\n        labels: {\n          color: t.inkSoft,\n          font: { size: 15 },\n          boxWidth: 16,\n          boxHeight: 16,\n          padding: 10,\n          generateLabels() {\n            return categoryLabels.map((text, i) => ({\n              text,\n              fillStyle: categoryColors[i],\n              strokeStyle: categoryColors[i],\n              fontColor: t.inkSoft,\n              lineWidth: 0,\n              index: i,\n              datasetIndex: 0,\n            }));\n          },\n        },\n      },\n      tooltip: {\n        callbacks: {\n          title: () => \"\",\n          label(ctx) {\n            const label =\n              ctx.datasetIndex === 0\n                ? categoryLabels[ctx.dataIndex]\n                : departmentLabels[ctx.dataIndex];\n            return `${label}: $${ctx.parsed}M`;\n          },\n        },\n      },\n    },\n  },\n  plugins: [ringLabelsPlugin],\n});\n"}