{"spec_id":"circlepacking-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// circlepacking-basic: Circle Packing Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: investment portfolio composition ($M), flat node list -----------\n// id / parent / value / label mirror the spec's data schema. `value` is set\n// only on leaf holdings — category and root totals are derived bottom-up.\nconst nodes = [\n  { id: \"portfolio\", parent: null, label: \"Portfolio\" },\n  { id: \"equities\", parent: \"portfolio\", label: \"Equities\" },\n  { id: \"tech-growth\", parent: \"equities\", label: \"Tech Growth Fund\", value: 42 },\n  { id: \"dividend-aristocrats\", parent: \"equities\", label: \"Dividend Aristocrats\", value: 31 },\n  { id: \"healthcare-sector\", parent: \"equities\", label: \"Healthcare Sector\", value: 24 },\n  { id: \"international-index\", parent: \"equities\", label: \"International Index\", value: 20 },\n  { id: \"emerging-markets\", parent: \"equities\", label: \"Emerging Markets\", value: 18 },\n  { id: \"small-cap-value\", parent: \"equities\", label: \"Small Cap Value\", value: 15 },\n  { id: \"fixed-income\", parent: \"portfolio\", label: \"Fixed Income\" },\n  { id: \"treasury-10y\", parent: \"fixed-income\", label: \"Treasury Bonds 10Y\", value: 35 },\n  { id: \"corporate-aa\", parent: \"fixed-income\", label: \"Corporate Bonds AA\", value: 27 },\n  { id: \"municipal-bonds\", parent: \"fixed-income\", label: \"Municipal Bonds\", value: 19 },\n  { id: \"high-yield\", parent: \"fixed-income\", label: \"High Yield Bonds\", value: 14 },\n  { id: \"tips\", parent: \"fixed-income\", label: \"TIPS\", value: 10 },\n  { id: \"real-estate\", parent: \"portfolio\", label: \"Real Estate\" },\n  { id: \"reit-index\", parent: \"real-estate\", label: \"REIT Index\", value: 26 },\n  { id: \"commercial-property\", parent: \"real-estate\", label: \"Commercial Property Fund\", value: 21 },\n  { id: \"residential-reit\", parent: \"real-estate\", label: \"Residential REIT\", value: 16 },\n  { id: \"industrial-warehouses\", parent: \"real-estate\", label: \"Industrial Warehouses\", value: 12 },\n  { id: \"commodities\", parent: \"portfolio\", label: \"Commodities\" },\n  { id: \"gold-etf\", parent: \"commodities\", label: \"Gold ETF\", value: 22 },\n  { id: \"silver-etf\", parent: \"commodities\", label: \"Silver ETF\", value: 13 },\n  { id: \"oil-futures\", parent: \"commodities\", label: \"Oil Futures Fund\", value: 11 },\n  { id: \"agri-commodities\", parent: \"commodities\", label: \"Agricultural Commodities\", value: 9 },\n  { id: \"alternatives\", parent: \"portfolio\", label: \"Alternatives\" },\n  { id: \"private-equity\", parent: \"alternatives\", label: \"Private Equity\", value: 17 },\n  { id: \"hedge-funds\", parent: \"alternatives\", label: \"Hedge Funds\", value: 12 },\n  { id: \"venture-capital\", parent: \"alternatives\", label: \"Venture Capital\", value: 8 },\n];\n\n// Skip palette[4] (#AE3030) — reserved as the semantic anchor for loss/error,\n// not needed here since no category carries that meaning.\nconst CATEGORY_COLORS = [t.palette[0], t.palette[1], t.palette[2], t.palette[3], t.palette[5]];\n\nfunction buildTree(id) {\n  const record = nodes.find((n) => n.id === id);\n  const childRecords = nodes.filter((n) => n.parent === id);\n  const node = { label: record.label, value: record.value };\n  if (childRecords.length > 0) node.children = childRecords.map((c) => buildTree(c.id));\n  return node;\n}\n\nconst root = buildTree(\"portfolio\");\nroot.children.forEach((category, i) => {\n  category.color = CATEGORY_COLORS[i];\n  category.children.forEach((leaf) => {\n    leaf.color = CATEGORY_COLORS[i];\n  });\n});\n\n// --- Circle packing layout ---------------------------------------------------\n// Leaf circle area (radius²) is proportional to `value`. A container's radius\n// comes from packing its children with a front-chain enclosure algorithm —\n// each new circle is placed tangent to the two nearest circles already on the\n// packed cluster's frontier, closest candidate to the center wins — the same\n// idea behind D3's pack layout, reimplemented here without the dependency.\n// World units are arbitrary; everything is rescaled to fit the canvas at\n// draw time.\nconst LEAF_SCALE = 4.2;\nconst LEAF_GAP = 4;\nconst CATEGORY_GAP = 14;\nconst GROUP_MARGIN = 10;\nconst ROOT_MARGIN = 18;\n\n// Both intersections of the two circles centered `gap`-plus-radius away from\n// `a` and `b` respectively — the two points a third circle of radius `r` can\n// sit at while staying tangent to both.\nfunction tangentCandidates(a, b, r, gap) {\n  const ra = a.radius + r + gap;\n  const rb = b.radius + r + gap;\n  const dx = b.x - a.x;\n  const dy = b.y - a.y;\n  const d2 = dx * dx + dy * dy;\n  const d = Math.sqrt(d2);\n  if (d < 1e-9) return [];\n  const l = (d2 + ra * ra - rb * rb) / (2 * d);\n  const hSq = ra * ra - l * l;\n  if (hSq < 0) return [];\n  const h = Math.sqrt(hSq);\n  const ux = dx / d;\n  const uy = dy / d;\n  const px = a.x + ux * l;\n  const py = a.y + uy * l;\n  return [\n    { x: px - uy * h, y: py + ux * h },\n    { x: px + uy * h, y: py - ux * h },\n  ];\n}\n\nfunction overlapsAny(candidate, r, placed, gap) {\n  return placed.some((c) => Math.hypot(candidate.x - c.x, candidate.y - c.y) < c.radius + r + gap - 1e-6);\n}\n\n// Packs `children` around the origin with no overlaps, largest first. Each\n// new circle is tried tangent to every consecutive pair on the current front\n// chain; the valid candidate closest to the origin is kept, keeping the\n// cluster tight instead of sprawling outward.\nfunction packSiblings(children, gap) {\n  const n = children.length;\n  if (n === 0) return;\n  if (n === 1) {\n    children[0].x = 0;\n    children[0].y = 0;\n    return;\n  }\n  const order = children.slice().sort((a, b) => b.radius - a.radius);\n  order[0].x = 0;\n  order[0].y = 0;\n  order[1].x = order[0].radius + order[1].radius + gap;\n  order[1].y = 0;\n  if (n === 2) return;\n\n  const chain = [order[0], order[1]];\n  for (let i = 2; i < n; i++) {\n    const circle = order[i];\n    let best = null;\n    let bestDist = Infinity;\n    for (let j = 0; j < chain.length; j++) {\n      const a = chain[j];\n      const b = chain[(j + 1) % chain.length];\n      for (const candidate of tangentCandidates(a, b, circle.radius, gap)) {\n        if (overlapsAny(candidate, circle.radius, chain, gap)) continue;\n        const dist = Math.hypot(candidate.x, candidate.y);\n        if (dist < bestDist) {\n          bestDist = dist;\n          best = { ...candidate, insertAfter: j };\n        }\n      }\n    }\n    if (!best) {\n      // Degenerate fallback (collinear frontier) — practically unreached for\n      // the modest, varied-radius sibling counts used in this chart.\n      const cx = chain.reduce((sum, c) => sum + c.x, 0) / chain.length;\n      const cy = chain.reduce((sum, c) => sum + c.y, 0) / chain.length;\n      best = { x: cx, y: cy, insertAfter: chain.length - 1 };\n    }\n    circle.x = best.x;\n    circle.y = best.y;\n    chain.splice(best.insertAfter + 1, 0, circle);\n  }\n}\n\n// Ritter-style bounding circle: grow a running circle to cover whichever\n// packed circle currently sits farthest outside it, a few passes over the\n// set. Used to recenter each cluster on its true center rather than the\n// arbitrary point the packing started from.\nfunction enclosingCircle(children) {\n  let cx = children[0].x;\n  let cy = children[0].y;\n  let r = children[0].radius;\n  const grow = (c) => {\n    const d = Math.hypot(c.x - cx, c.y - cy);\n    if (d + c.radius > r + 1e-6) {\n      const newR = (r + d + c.radius) / 2;\n      const k = d > 1e-6 ? (newR - r) / d : 0;\n      cx += (c.x - cx) * k;\n      cy += (c.y - cy) * k;\n      r = newR;\n    }\n  };\n  for (let pass = 0; pass < 4; pass++) children.forEach(grow);\n  return { cx, cy, r };\n}\n\nfunction layout(node, depth) {\n  if (!node.children) {\n    node.radius = Math.sqrt(node.value) * LEAF_SCALE;\n    return;\n  }\n  node.children.forEach((c) => layout(c, depth + 1));\n  packSiblings(node.children, depth === 0 ? CATEGORY_GAP : LEAF_GAP);\n  const { cx, cy, r } = enclosingCircle(node.children);\n  node.children.forEach((c) => {\n    c.x -= cx;\n    c.y -= cy;\n  });\n  node.radius = r + (depth === 0 ? ROOT_MARGIN : GROUP_MARGIN);\n  node.value = node.children.reduce((sum, c) => sum + c.value, 0);\n}\n\nfunction placeAbsolute(node, originX, originY) {\n  node.absX = originX + (node.x || 0);\n  node.absY = originY + (node.y || 0);\n  if (node.children) node.children.forEach((c) => placeAbsolute(c, node.absX, node.absY));\n}\n\nlayout(root, 0);\nplaceAbsolute(root, 0, 0);\n\n// --- Drawing helpers ---------------------------------------------------------\nconst TEXT_DARK = \"#1A1A17\";\nconst TEXT_LIGHT = \"#F0EFE8\";\n\nfunction withAlpha(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\nfunction contrastText(hex) {\n  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);\n  const lin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));\n  const luminance = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n  return luminance > 0.45 ? TEXT_DARK : TEXT_LIGHT;\n}\n\nfunction fitText(ctx, text, maxWidth) {\n  if (ctx.measureText(text).width <= maxWidth) return text;\n  let truncated = text;\n  while (truncated.length > 1 && ctx.measureText(truncated + \"…\").width > maxWidth) {\n    truncated = truncated.slice(0, -1);\n  }\n  return truncated + \"…\";\n}\n\n// Transform from world units (node.absX/absY/radius) to canvas pixels: fit\n// the root circle to the smaller chartArea dimension, centered. Shared by\n// the manual draw pass and the synthetic hover points so both stay aligned.\nfunction computeTransform(chart) {\n  const { chartArea } = chart;\n  const cx = (chartArea.left + chartArea.right) / 2;\n  const cy = (chartArea.top + chartArea.bottom) / 2;\n  const available = Math.min(chartArea.width, chartArea.height) / 2 - 8;\n  const scale = available / root.radius;\n  return { cx, cy, scale };\n}\n\n// Leaf nodes only — the smaller circles whose labels are most likely\n// truncated in the static render, so a hover path is worth the most there.\nfunction collectLeaves(node, depth, out) {\n  if (depth === 2) out.push(node);\n  else if (node.children) node.children.forEach((c) => collectLeaves(c, depth + 1, out));\n  return out;\n}\n\n// --- Mount --------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart --------------------------------------------------------------\n// The bubble type hosts the canvas, title, and layout; the packed circles\n// are drawn directly against the resolved `chartArea` in a plugin rather\n// than through the dataset — that avoids remapping our already-final pixel\n// radii through Chart.js's data-driven x/y scales. The dataset is used only\n// below to place invisible hover targets on top of the leaf circles.\nconst circlePackingPlugin = {\n  id: \"circlePacking\",\n  afterDraw(chart) {\n    const { ctx } = chart;\n    const { cx, cy, scale } = computeTransform(chart);\n\n    ctx.save();\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n\n    const drawNode = (node, depth) => {\n      const x = cx + node.absX * scale;\n      const y = cy + node.absY * scale;\n      const r = node.radius * scale;\n\n      ctx.beginPath();\n      ctx.arc(x, y, r, 0, Math.PI * 2);\n      if (depth === 0) {\n        ctx.setLineDash([4, 4]);\n        ctx.lineWidth = 1.5;\n        ctx.strokeStyle = t.inkSoft;\n        ctx.stroke();\n        ctx.setLineDash([]);\n      } else if (depth === 1) {\n        ctx.fillStyle = withAlpha(node.color, 0.12);\n        ctx.fill();\n        ctx.lineWidth = 2;\n        ctx.strokeStyle = node.color;\n        ctx.stroke();\n      } else {\n        ctx.fillStyle = node.color;\n        ctx.fill();\n        ctx.lineWidth = 1.5;\n        ctx.strokeStyle = t.pageBg;\n        ctx.stroke();\n      }\n\n      if (node.children) node.children.forEach((c) => drawNode(c, depth + 1));\n    };\n    drawNode(root, 0);\n\n    // Labels drawn after every circle so text always sits on top.\n    const labelNode = (node, depth) => {\n      const x = cx + node.absX * scale;\n      const y = cy + node.absY * scale;\n      const r = node.radius * scale;\n\n      if (depth === 0) {\n        ctx.font = \"500 13px -apple-system, sans-serif\";\n        ctx.fillStyle = t.inkSoft;\n        ctx.fillText(\"Total Portfolio\", x, y - r + 16);\n      } else if (depth === 1 && r > 46) {\n        // Sits inside the GROUP_MARGIN ring around the packed leaves, which\n        // by construction no leaf circle reaches — guaranteed clear of the\n        // leaf labels drawn beneath it, regardless of how they're arranged.\n        ctx.font = \"600 14px -apple-system, sans-serif\";\n        ctx.fillStyle = t.ink;\n        ctx.fillText(fitText(ctx, node.label, r * 1.3), x, y - r + 15);\n      } else if (depth === 2 && r > 24) {\n        // Shrink the font toward a legible floor before truncating, so\n        // longer holding names survive whole more often than a fixed\n        // radius-only font size would allow.\n        const maxWidth = r * 1.8;\n        let fontSize = Math.min(15, Math.max(11, Math.round(r * 0.42)));\n        ctx.font = `500 ${fontSize}px -apple-system, sans-serif`;\n        while (fontSize > 9 && ctx.measureText(node.label).width > maxWidth) {\n          fontSize -= 1;\n          ctx.font = `500 ${fontSize}px -apple-system, sans-serif`;\n        }\n        ctx.fillStyle = contrastText(node.color);\n        ctx.fillText(fitText(ctx, node.label, maxWidth), x, y);\n      }\n\n      if (node.children) node.children.forEach((c) => labelNode(c, depth + 1));\n    };\n    labelNode(root, 0);\n\n    ctx.restore();\n  },\n};\n\n// Leaf circles are hand-drawn pixels with no data points of their own, so\n// hovering them natively needs an invisible bubble dataset placed exactly on\n// top. `data`/`min`/`max` stay empty/[-1,1] for the first render; once the\n// chart has laid out once we know the real chartArea and back-map every\n// leaf's already-drawn pixel center through the live scale (getValueForPixel)\n// so the two coordinate systems line up exactly, then redraw.\nconst chart = new Chart(canvas, {\n  type: \"bubble\",\n  data: { datasets: [{ data: [], backgroundColor: \"transparent\", hoverBackgroundColor: \"transparent\", borderWidth: 0, hoverBorderWidth: 0 }] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 24 },\n    interaction: { mode: \"nearest\", intersect: true },\n    scales: {\n      x: { display: false, min: -1, max: 1 },\n      y: { display: false, min: -1, max: 1 },\n    },\n    plugins: {\n      legend: { display: false },\n      tooltip: {\n        enabled: true,\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.ink,\n        borderColor: t.grid,\n        borderWidth: 1,\n        callbacks: {\n          title: (items) => items[0]?.raw?.label ?? \"\",\n          label: (item) => `$${item.raw.value}M`,\n        },\n      },\n      title: {\n        display: true,\n        text: \"Investment Portfolio Composition · circlepacking-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 16, weight: \"500\" },\n        padding: { bottom: 16 },\n      },\n    },\n  },\n  plugins: [circlePackingPlugin],\n});\n\nconst { cx, cy, scale } = computeTransform(chart);\nconst leafHoverPoints = collectLeaves(root, 0, []).map((leaf) => ({\n  x: chart.scales.x.getValueForPixel(cx + leaf.absX * scale),\n  y: chart.scales.y.getValueForPixel(cy + leaf.absY * scale),\n  r: leaf.radius * scale,\n  label: leaf.label,\n  value: leaf.value,\n}));\nchart.data.datasets[0].data = leafHoverPoints;\nchart.update(\"none\");\n"}