{"spec_id":"sunburst-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// sunburst-basic: Basic Sunburst Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.1\n// Quality: 91/100 | Created: 2026-07-26\n\n//# anyplot-orientation: square\n\n// Only the core Highcharts bundle is loaded (no `sunburst`/`treemap` module),\n// so the rings are drawn natively with `chart.renderer.arc()` — the same\n// public SVGRenderer method the core pie series itself draws slices with.\n// Angles are accumulated per branch, then per leaf within each branch, so\n// inner segments always encompass the angular span of their children. The\n// Compute branch carries an optional 3rd (environment) level to demonstrate\n// the plot type's multi-level depth — a sunburst need not go equally deep on\n// every path, so the other 3 branches simply terminate at ring 2.\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: cloud infrastructure spend by category and service --------------\n// (USD thousands / year). 4 root categories, 14 leaves; Compute's 4 leaves\n// further split by environment for a 3rd-ring example.\nconst DATA = [\n  {\n    level1: \"Compute\",\n    level2: \"Web Servers\",\n    value: 420,\n    level3: [\n      { name: \"Prod\", value: 300 },\n      { name: \"Staging\", value: 120 },\n    ],\n  },\n  {\n    level1: \"Compute\",\n    level2: \"Batch Jobs\",\n    value: 260,\n    level3: [\n      { name: \"Scheduled\", value: 180 },\n      { name: \"Ad-hoc\", value: 80 },\n    ],\n  },\n  {\n    level1: \"Compute\",\n    level2: \"ML Training\",\n    value: 180,\n    level3: [\n      { name: \"Training\", value: 130 },\n      { name: \"Inference\", value: 50 },\n    ],\n  },\n  {\n    level1: \"Compute\",\n    level2: \"CI/CD Runners\",\n    value: 90,\n    level3: [\n      { name: \"Linux\", value: 60 },\n      { name: \"Windows\", value: 30 },\n    ],\n  },\n  { level1: \"Storage\", level2: \"Object Storage\", value: 310 },\n  { level1: \"Storage\", level2: \"Block Volumes\", value: 150 },\n  { level1: \"Storage\", level2: \"Backups\", value: 95 },\n  { level1: \"Networking\", level2: \"Load Balancers\", value: 140 },\n  { level1: \"Networking\", level2: \"CDN\", value: 120 },\n  { level1: \"Networking\", level2: \"VPN Gateways\", value: 55 },\n  { level1: \"Databases\", level2: \"Relational\", value: 260 },\n  { level1: \"Databases\", level2: \"Analytics Warehouse\", value: 175 },\n  { level1: \"Databases\", level2: \"Cache\", value: 70 },\n  { level1: \"Databases\", level2: \"Search Index\", value: 60 },\n];\n\n// First categorical series is always the brand green (Imprint position 1);\n// the remaining root categories follow the canonical order. Every leaf keeps\n// its parent's hue (lightened) so the branch stays identifiable ring-to-ring.\nconst CATEGORIES = [\"Compute\", \"Storage\", \"Networking\", \"Databases\"];\nconst branches = CATEGORIES.map((name, i) => {\n  const leaves = DATA.filter((d) => d.level1 === name);\n  return { name, color: t.palette[i], leaves, total: leaves.reduce((s, d) => s + d.value, 0) };\n});\nconst grandTotal = branches.reduce((s, b) => s + b.total, 0);\n\n// --- Color + label helpers ---------------------------------------------------\nconst mix = (hex, target, amount) => {\n  const a = parseInt(hex.slice(1), 16);\n  const b = parseInt(target.slice(1), 16);\n  const chan = (shift) => {\n    const c1 = (a >> shift) & 255;\n    const c2 = (b >> shift) & 255;\n    return Math.round(c1 + (c2 - c1) * amount);\n  };\n  return `#${[16, 8, 0].map((s) => chan(s).toString(16).padStart(2, \"0\")).join(\"\")}`;\n};\nconst luma = (hex) => {\n  const c = parseInt(hex.slice(1), 16);\n  const r = (c >> 16) & 255;\n  const g = (c >> 8) & 255;\n  const b = c & 255;\n  return (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n};\nconst labelColorFor = (bgHex) => (luma(bgHex) > 0.55 ? \"#1A1A17\" : \"#FFFDF6\");\nconst money = (v) => `$${v.toLocaleString(\"en-US\")}K`;\n\n// --- Chart shell (no series/axes — both rings are drawn by hand) -----------\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    marginTop: 130,\n    marginBottom: 70,\n    marginLeft: 140,\n    marginRight: 140,\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"sunburst-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Cloud infrastructure spend by category (inner ring), service (middle ring), and Compute's environment split (outer ring) — USD thousands/year, arc angle ∝ spend\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false },\n  yAxis: { visible: false },\n  legend: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  series: [],\n});\n\n// --- Layout: concentric rings sized from the plot's own pixel space --------\nconst cx = chart.plotLeft + chart.plotWidth / 2;\nconst cy = chart.plotTop + chart.plotHeight / 2;\nconst maxR = Math.min(chart.plotWidth, chart.plotHeight) / 2;\n\n// Ring 1 (short branch names) stays thin, ring 2 (longest labels, e.g.\n// \"Analytics Warehouse\") keeps most of the original 2-ring radius so its\n// labels still have enough arc length to fit, and ring 3 (short env names)\n// takes the remainder.\nconst holeR = maxR * 0.16;\nconst innerR1 = holeR;\nconst outerR1 = innerR1 + maxR * 0.2;\nconst innerR2 = outerR1 + maxR * 0.01;\nconst outerR2 = innerR2 + maxR * 0.3;\nconst innerR3 = outerR2 + maxR * 0.01;\nconst outerR3 = innerR3 + maxR * 0.14;\n\nconst TAU = Math.PI * 2;\nconst TOP = -Math.PI / 2; // renderer.arc: 0 = 3 o'clock, -PI/2 = 12 o'clock, clockwise\n\nconst g = chart.renderer.g(\"sunburst\").add();\nconst arcs = []; // { el, branchIndex } — driving the hover-highlight below\n\n// Draws one ring wedge, an optional centered label, and a native hover\n// tooltip (<title>) — the same hand-drawn-tooltip technique used for the\n// sankey diagram, since no series/tooltip module is loaded either.\n//\n// Labels shrink-to-fit: rather than an all-or-nothing check at the nominal\n// fontSize (which let a bigger segment with a long name lose its label to a\n// smaller segment with a short one), we step the size down to minFontSize\n// and take the largest size that actually fits the wedge's arc length. A\n// value-ranked segment therefore keeps a (possibly smaller) label instead of\n// being dropped outright.\nfunction drawWedge(innerR, outerR, start, end, fill, branchIndex, tooltipText, labelText, fontSize, minFontSize, fontWeight) {\n  const el = chart.renderer\n    .arc(cx, cy, outerR, innerR, start, end)\n    .attr({ fill, stroke: t.pageBg, \"stroke-width\": 2 })\n    .css({ cursor: \"pointer\" })\n    .add(g);\n  const titleEl = document.createElementNS(\"http://www.w3.org/2000/svg\", \"title\");\n  titleEl.textContent = tooltipText;\n  el.element.appendChild(titleEl);\n  arcs.push({ el, branchIndex, baseFill: fill });\n\n  const span = end - start;\n  const midR = (innerR + outerR) / 2;\n  const arcLen = midR * span;\n  let size = fontSize;\n  while (size >= minFontSize) {\n    const estWidth = labelText.length * size * 0.58 + 8;\n    if (arcLen >= estWidth && outerR - innerR >= size * 1.6) break;\n    size -= 1;\n  }\n  if (labelText && size >= minFontSize) {\n    const mid = (start + end) / 2;\n    const lx = cx + midR * Math.cos(mid);\n    const ly = cy + midR * Math.sin(mid);\n    chart.renderer\n      .text(labelText, lx, ly + size * 0.35)\n      .attr({ align: \"center\", zIndex: 5 })\n      .css({ color: labelColorFor(fill), fontSize: `${size}px`, fontWeight, pointerEvents: \"none\" })\n      .add(g);\n  }\n  return el;\n}\n\n// --- Ring 1: root categories -------------------------------------------------\nlet cursor = TOP;\nbranches.forEach((branch, i) => {\n  const start = cursor;\n  const end = start + (branch.total / grandTotal) * TAU;\n  cursor = end;\n  const pct = ((branch.total / grandTotal) * 100).toFixed(1);\n  drawWedge(innerR1, outerR1, start, end, branch.color, i, `${branch.name}: ${money(branch.total)} (${pct}%)`, branch.name, 17, 14, \"600\");\n});\n\n// --- Ring 2: services, angularly nested under their parent category --------\ncursor = TOP;\nbranches.forEach((branch, i) => {\n  const leafColor = mix(branch.color, \"#FFFFFF\", 0.32);\n  branch.leaves.forEach((leaf) => {\n    const start = cursor;\n    const end = start + (leaf.value / grandTotal) * TAU;\n    cursor = end;\n    const pct = ((leaf.value / grandTotal) * 100).toFixed(1);\n    drawWedge(\n      innerR2,\n      outerR2,\n      start,\n      end,\n      leafColor,\n      i,\n      `${branch.name} → ${leaf.level2}: ${money(leaf.value)} (${pct}%)`,\n      leaf.level2,\n      12,\n      9,\n      \"500\",\n    );\n  });\n});\n\n// --- Ring 3: environment split for Compute's services only -----------------\n// Only branches whose leaves carry a `level3` array go a level deeper; the\n// other branches simply have no arcs drawn in this radial band, which is a\n// valid (and common) uneven-depth sunburst shape.\ncursor = TOP;\nbranches.forEach((branch, i) => {\n  const childColor = mix(branch.color, \"#FFFFFF\", 0.55);\n  branch.leaves.forEach((leaf) => {\n    const leafStart = cursor;\n    const leafEnd = leafStart + (leaf.value / grandTotal) * TAU;\n    cursor = leafEnd;\n    if (!leaf.level3) return;\n    let childCursor = leafStart;\n    leaf.level3.forEach((child) => {\n      const start = childCursor;\n      const end = start + (child.value / leaf.value) * (leafEnd - leafStart);\n      childCursor = end;\n      const pct = ((child.value / grandTotal) * 100).toFixed(1);\n      drawWedge(\n        innerR3,\n        outerR3,\n        start,\n        end,\n        childColor,\n        i,\n        `${branch.name} → ${leaf.level2} → ${child.name}: ${money(child.value)} (${pct}%)`,\n        child.name,\n        10,\n        8,\n        \"500\",\n      );\n    });\n  });\n});\n\n// Genuine hover-highlight for the HTML view: dim every wedge outside the\n// hovered branch (root + its own services), no series/tooltip module needed.\narcs.forEach((arc) => {\n  arc.el.on(\"mouseover\", () => {\n    arcs.forEach((other) => other.el.attr({ \"fill-opacity\": other.branchIndex === arc.branchIndex ? 1 : 0.25 }));\n  });\n  arc.el.on(\"mouseout\", () => {\n    arcs.forEach((other) => other.el.attr({ \"fill-opacity\": 1 }));\n  });\n});\n\n// --- Center label: grand total ----------------------------------------------\nchart.renderer\n  .text(money(grandTotal), cx, cy - 4)\n  .attr({ align: \"center\" })\n  .css({ color: t.ink, fontSize: \"24px\", fontWeight: \"700\" })\n  .add(g);\nchart.renderer\n  .text(\"total spend / year\", cx, cy + 20)\n  .attr({ align: \"center\" })\n  .css({ color: t.inkSoft, fontSize: \"13px\" })\n  .add(g);\n\n// Static-frame timing signal for the harness.\nwindow.__anyplotReady = true;\n"}