{"spec_id":"circlepacking-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// circlepacking-basic: Circle Packing Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n// anyplot.ai\n// circlepacking-basic: Circle Packing Chart\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-02\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\nimport { ChartsText } from \"@mui/x-charts/ChartsText\";\n\nconst t = window.ANYPLOT_TOKENS;\n\nconst title = \"circlepacking-basic · javascript · muix · anyplot.ai\";\nconst TITLE_DEFAULT = 22;\nconst TITLE_FLOOR = 15;\nconst titleFontSize = Math.max(TITLE_FLOOR, Math.round(TITLE_DEFAULT * Math.min(1, 67 / title.length)));\n\n// --- Data: investment portfolio composition -- one of the spec's listed\n// applications (\"breaking down investments by asset class and holdings\").\n// Flat id/parent/value/label rows, exactly the fields the spec's Data\n// section describes; `value` is only present on leaf holdings, matching\n// \"size value determining circle area (for leaf nodes)\". 20 nodes across\n// 3 levels: portfolio -> asset class -> holding. -----------------------------\nconst NODES = [\n  { id: \"portfolio\", parent: null, label: \"Portfolio\" },\n  { id: \"equities\", parent: \"portfolio\", label: \"Equities\" },\n  { id: \"us-large-cap\", parent: \"equities\", label: \"US Large Cap\", value: 420 },\n  { id: \"us-small-cap\", parent: \"equities\", label: \"US Small Cap\", value: 140 },\n  { id: \"intl-developed\", parent: \"equities\", label: \"Int'l Developed\", value: 210 },\n  { id: \"emerging-markets\", parent: \"equities\", label: \"Emerging Markets\", value: 95 },\n  { id: \"fixed-income\", parent: \"portfolio\", label: \"Fixed Income\" },\n  { id: \"gov-bonds\", parent: \"fixed-income\", label: \"Government Bonds\", value: 260 },\n  { id: \"corp-bonds\", parent: \"fixed-income\", label: \"Corporate Bonds\", value: 180 },\n  { id: \"muni-bonds\", parent: \"fixed-income\", label: \"Municipal Bonds\", value: 90 },\n  { id: \"real-estate\", parent: \"portfolio\", label: \"Real Estate\" },\n  { id: \"reits\", parent: \"real-estate\", label: \"REITs\", value: 150 },\n  { id: \"direct-property\", parent: \"real-estate\", label: \"Direct Property\", value: 110 },\n  { id: \"alternatives\", parent: \"portfolio\", label: \"Alternatives\" },\n  { id: \"private-equity\", parent: \"alternatives\", label: \"Private Equity\", value: 130 },\n  { id: \"commodities\", parent: \"alternatives\", label: \"Commodities\", value: 70 },\n  { id: \"hedge-funds\", parent: \"alternatives\", label: \"Hedge Funds\", value: 85 },\n  { id: \"cash\", parent: \"portfolio\", label: \"Cash & Equivalents\" },\n  { id: \"money-market\", parent: \"cash\", label: \"Money Market\", value: 60 },\n  { id: \"treasury-bills\", parent: \"cash\", label: \"Treasury Bills\", value: 40 },\n];\n\nfunction buildTree(nodes) {\n  const byId = new Map();\n  nodes.forEach((n) => byId.set(n.id, { ...n, children: [] }));\n  let treeRoot = null;\n  byId.forEach((node) => {\n    if (node.parent == null) treeRoot = node;\n    else byId.get(node.parent).children.push(node);\n  });\n  return treeRoot;\n}\nfunction computeValue(node) {\n  if (node.children.length === 0) return node.value;\n  node.value = node.children.reduce((sum, c) => sum + computeValue(c), 0);\n  return node.value;\n}\nconst root = buildTree(NODES);\ncomputeValue(root);\n\n// First branch keeps the mandatory brand green; remaining branches follow\n// canonical Imprint order (asset classes are abstract categories -- no\n// semantic color expectation to override the default order).\nroot.children.forEach((branch, i) => {\n  branch.color = t.palette[i % t.palette.length];\n});\n\n// --- Circle packing: the community package has no packing layout of its\n// own, so the geometry is a small hand-rolled force simulation -- each\n// sibling set is attracted toward its shared local center and pushed apart\n// on overlap, then the parent's own radius is set to the enclosing circle\n// of its settled children plus padding. Recursing bottom-up produces true\n// nested packing (not just flat non-overlapping bubbles). ------------------\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nfunction packChildren(children) {\n  const sorted = [...children].sort((a, b) => b.r - a.r);\n  const seedRadius = sorted[0].r * 1.4;\n  sorted.forEach((c, i) => {\n    const angle = (2 * Math.PI * i) / sorted.length;\n    c.x = Math.cos(angle) * seedRadius + (rand() - 0.5) * 4;\n    c.y = Math.sin(angle) * seedRadius + (rand() - 0.5) * 4;\n  });\n\n  const PADDING = 6;\n  const ATTRACTION = 0.02;\n  const ITERATIONS = 400;\n  for (let iter = 0; iter < ITERATIONS; iter++) {\n    for (const c of children) {\n      c.x -= c.x * ATTRACTION;\n      c.y -= c.y * ATTRACTION;\n    }\n    for (let i = 0; i < children.length; i++) {\n      for (let j = i + 1; j < children.length; j++) {\n        const a = children[i];\n        const b = children[j];\n        const dx = b.x - a.x;\n        const dy = b.y - a.y;\n        const dist = Math.hypot(dx, dy) || 0.01;\n        const minDist = a.r + b.r + PADDING;\n        if (dist < minDist) {\n          const overlap = (minDist - dist) / 2;\n          const ux = dx / dist;\n          const uy = dy / dist;\n          a.x -= ux * overlap;\n          a.y -= uy * overlap;\n          b.x += ux * overlap;\n          b.y += uy * overlap;\n        }\n      }\n    }\n  }\n\n  let enclosing = 0;\n  for (const c of children) enclosing = Math.max(enclosing, Math.hypot(c.x, c.y) + c.r);\n  return enclosing;\n}\n\nconst LEAF_RADIUS_SCALE = 8;\nconst NODE_PADDING = 10;\n\nfunction layout(node) {\n  if (node.children.length === 0) {\n    node.r = LEAF_RADIUS_SCALE * Math.sqrt(node.value);\n    return;\n  }\n  node.children.forEach(layout);\n  node.r = packChildren(node.children) + NODE_PADDING;\n}\nlayout(root);\n\nfunction place(node, cx, cy) {\n  node.cx = cx;\n  node.cy = cy;\n  node.children.forEach((c) => place(c, cx + c.x, cy + c.y));\n}\nplace(root, 0, 0);\n\n// --- Color: leaves get a white-mixed tint of their branch's hue, scaled by\n// their value relative to the largest sibling, so shade intensity echoes\n// relative size within the asset class while the hue keeps the grouping. --\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction relativeLuminance([r, g, b]) {\n  const chan = (v) => {\n    const c = v / 255;\n    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n  };\n  return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b);\n}\nfunction mixWithWhite(rgb, factor) {\n  return rgb.map((c) => Math.round(c + (255 - c) * factor));\n}\nfunction rgbToCss([r, g, b]) {\n  return `rgb(${r}, ${g}, ${b})`;\n}\nfunction textColorFor(rgb) {\n  return relativeLuminance(rgb) > 0.45 ? \"#1A1A17\" : \"#FAF8F1\";\n}\nfunction truncateLabel(label, maxChars) {\n  if (label.length <= maxChars) return label;\n  const clipped = label.slice(0, maxChars);\n  const lastSpace = clipped.lastIndexOf(\" \");\n  return lastSpace >= 3 ? `${clipped.slice(0, lastSpace)}…` : `${clipped}…`;\n}\n// Wrap a two-or-more-word label onto the most balanced two lines that both\n// fit `maxCharsPerLine`, so labels like \"Corporate Bonds\" show in full\n// instead of ellipsis-truncating mid-word. Falls back to truncation for\n// single-word labels or when no split fits.\nfunction wrapLabel(label, maxCharsPerLine) {\n  if (label.length <= maxCharsPerLine) return [label];\n  const words = label.split(\" \");\n  if (words.length < 2) return [truncateLabel(label, maxCharsPerLine)];\n  let best = null;\n  for (let i = 1; i < words.length; i++) {\n    const line1 = words.slice(0, i).join(\" \");\n    const line2 = words.slice(i).join(\" \");\n    if (line1.length <= maxCharsPerLine && line2.length <= maxCharsPerLine) {\n      const diff = Math.abs(line1.length - line2.length);\n      if (!best || diff < best.diff) best = { line1, line2, diff };\n    }\n  }\n  return best ? [best.line1, best.line2] : [truncateLabel(label, maxCharsPerLine)];\n}\n\n// --- Legend: branch color identity, read once above the packing area so the\n// circles themselves stay uncluttered (no in-circle branch labels fighting\n// the child circles they contain). ------------------------------------------\nfunction Legend({ x, y, width: legendWidth }) {\n  const itemWidth = legendWidth / root.children.length;\n  return (\n    <g>\n      {root.children.map((branch, i) => {\n        const itemX = x + itemWidth * i;\n        return (\n          <g key={branch.id}>\n            <circle cx={itemX + 8} cy={y} r={6} fill={branch.color} />\n            <text x={itemX + 20} y={y} dominantBaseline=\"middle\" fontSize={13} fill={t.inkSoft}>\n              {branch.label}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Circles: root boundary, branch zones (light fill + colored stroke),\n// leaf holdings (solid tint, labeled when large enough). Leaf labels use\n// MUI X's own ChartsText primitive (not a raw <text>) so long names wrap\n// onto two lines via its native \"\\n\"-line-splitting instead of truncating\n// mid-word -- ChartsTooltip/ChartsLegend don't apply here since they key\n// off a `series` data model this hand-rolled packing geometry has none of. -\n\nfunction CirclePacking() {\n  const { left, top, width, height } = useDrawingArea();\n  const availableRadius = Math.min(width, height) / 2 - 4;\n  const scale = availableRadius / root.r;\n  const originX = left + width / 2;\n  const originY = top + height / 2;\n\n  function project(node) {\n    return { cx: originX + node.cx * scale, cy: originY + node.cy * scale, r: node.r * scale };\n  }\n\n  const rootCircle = project(root);\n\n  return (\n    <g>\n      <circle cx={rootCircle.cx} cy={rootCircle.cy} r={rootCircle.r} fill=\"none\" stroke={t.inkSoft} strokeOpacity={0.3} strokeWidth={1.5} />\n      {root.children.map((branch) => {\n        const b = project(branch);\n        const maxLeafValue = Math.max(...branch.children.map((c) => c.value));\n        return (\n          <g key={branch.id}>\n            <circle cx={b.cx} cy={b.cy} r={b.r} fill={branch.color} fillOpacity={0.14} stroke={branch.color} strokeOpacity={0.8} strokeWidth={2}>\n              <title>{`${branch.label}: $${branch.value}K`}</title>\n            </circle>\n            {branch.children.map((leaf) => {\n              const l = project(leaf);\n              const tintFactor = maxLeafValue > 0 ? (1 - leaf.value / maxLeafValue) * 0.6 : 0;\n              const rgb = mixWithWhite(hexToRgb(branch.color), tintFactor);\n              const fill = rgbToCss(rgb);\n              const ink = textColorFor(rgb);\n              const nameSize = Math.max(10, Math.min(16, l.r * 0.22));\n              const valueSize = Math.round(nameSize * 0.82);\n              const showName = l.r >= 26;\n              const showValue = l.r >= 40;\n              const maxCharsPerLine = Math.max(4, Math.floor((l.r * 1.7) / (nameSize * 0.55)));\n              const nameLines = showName ? wrapLabel(leaf.label, maxCharsPerLine) : [];\n              const LINE_HEIGHT_EM = 1.18;\n              const nameHalfHeight = (nameLines.length * nameSize * LINE_HEIGHT_EM) / 2;\n              const valueHalfHeight = (valueSize * LINE_HEIGHT_EM) / 2;\n              const gap = nameSize * 0.3;\n              const nameCenterY = showValue ? l.cy - valueHalfHeight - gap / 2 : l.cy;\n              const valueCenterY = l.cy + nameHalfHeight + gap / 2;\n              return (\n                <g key={leaf.id}>\n                  <circle cx={l.cx} cy={l.cy} r={l.r} fill={fill} stroke={t.pageBg} strokeWidth={2}>\n                    <title>{`${branch.label} / ${leaf.label}: $${leaf.value}K`}</title>\n                  </circle>\n                  {showName && (\n                    <ChartsText\n                      text={nameLines.join(\"\\n\")}\n                      x={l.cx}\n                      y={nameCenterY}\n                      style={{\n                        fontSize: nameSize,\n                        fontWeight: 600,\n                        fill: ink,\n                        textAnchor: \"middle\",\n                        dominantBaseline: \"central\",\n                      }}\n                    />\n                  )}\n                  {showValue && (\n                    <ChartsText\n                      text={`$${leaf.value}K`}\n                      x={l.cx}\n                      y={valueCenterY}\n                      opacity={0.85}\n                      style={{ fontSize: valueSize, fill: ink, textAnchor: \"middle\", dominantBaseline: \"central\" }}\n                    />\n                  )}\n                </g>\n              );\n            })}\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component -- the harness mounts it) ----------\n// ChartContainer supplies the <ChartsSurface> SVG root and theme context; its\n// `margin` prop drives the DrawingProvider that CirclePacking() reads back\n// via useDrawingArea(), the same layout primitive MUI X's own axis/legend\n// components use. The packing body itself is laid out in local, scale-free\n// units by layout()/place() above and only projected into pixel space here,\n// so no axis/scale is needed -- xAxis/yAxis are omitted entirely.\nconst MARGIN = { top: 176, right: 24, bottom: 24, left: 24 };\n\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n\n  return (\n    <ChartContainer width={width} height={height} series={[]} margin={MARGIN} skipAnimation>\n      <text x={width / 2} y={44} textAnchor=\"middle\" fontSize={titleFontSize} fontWeight={600} fill={t.ink}>\n        {title}\n      </text>\n      <text x={width / 2} y={74} textAnchor=\"middle\" fontSize={14} fill={t.inkSoft}>\n        Investment portfolio by asset class and holding · circle area proportional to market value ($K)\n      </text>\n      <Legend x={MARGIN.left} y={116} width={width - MARGIN.left - MARGIN.right} />\n      <CirclePacking />\n    </ChartContainer>\n  );\n}\n"}