{"spec_id":"circlepacking-basic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// circlepacking-basic: Circle Packing Chart\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: R&D budget hierarchy (root -> division -> team), $ millions -----\nconst records = [\n  { id: \"root\", parent: null, value: null, label: \"R&D Portfolio\" },\n  { id: \"cloud\", parent: \"root\", value: null, label: \"Cloud Platform\" },\n  { id: \"ai\", parent: \"root\", value: null, label: \"AI Research\" },\n  { id: \"hardware\", parent: \"root\", value: null, label: \"Hardware Engineering\" },\n  { id: \"mobile\", parent: \"root\", value: null, label: \"Mobile Apps\" },\n\n  { id: \"cloud-compute\", parent: \"cloud\", value: 18, label: \"Compute Infra\" },\n  { id: \"cloud-storage\", parent: \"cloud\", value: 12, label: \"Storage Systems\" },\n  { id: \"cloud-network\", parent: \"cloud\", value: 9, label: \"Networking\" },\n  { id: \"cloud-k8s\", parent: \"cloud\", value: 15, label: \"Kubernetes Platform\" },\n  { id: \"cloud-db\", parent: \"cloud\", value: 11, label: \"Database Services\" },\n  { id: \"cloud-devops\", parent: \"cloud\", value: 7, label: \"DevOps Tooling\" },\n\n  { id: \"ai-llm\", parent: \"ai\", value: 26, label: \"Large Language Models\" },\n  { id: \"ai-vision\", parent: \"ai\", value: 14, label: \"Computer Vision\" },\n  { id: \"ai-rl\", parent: \"ai\", value: 8, label: \"Reinforcement Learning\" },\n  { id: \"ai-mlops\", parent: \"ai\", value: 10, label: \"MLOps\" },\n  { id: \"ai-labeling\", parent: \"ai\", value: 6, label: \"Data Labeling\" },\n  { id: \"ai-safety\", parent: \"ai\", value: 9, label: \"AI Safety\" },\n\n  { id: \"hw-chip\", parent: \"hardware\", value: 22, label: \"Chip Design\" },\n  { id: \"hw-sensors\", parent: \"hardware\", value: 10, label: \"Sensors\" },\n  { id: \"hw-power\", parent: \"hardware\", value: 7, label: \"Power Systems\" },\n  { id: \"hw-thermal\", parent: \"hardware\", value: 6, label: \"Thermal Engineering\" },\n  { id: \"hw-proto\", parent: \"hardware\", value: 9, label: \"Prototyping Lab\" },\n  { id: \"hw-qa\", parent: \"hardware\", value: 5, label: \"Quality Testing\" },\n\n  { id: \"mob-ios\", parent: \"mobile\", value: 13, label: \"iOS Development\" },\n  { id: \"mob-android\", parent: \"mobile\", value: 13, label: \"Android Development\" },\n  { id: \"mob-sdk\", parent: \"mobile\", value: 8, label: \"Cross-platform SDK\" },\n  { id: \"mob-analytics\", parent: \"mobile\", value: 5, label: \"App Analytics\" },\n  { id: \"mob-ux\", parent: \"mobile\", value: 6, label: \"UX Research\" },\n  { id: \"mob-push\", parent: \"mobile\", value: 4, label: \"Push Notifications\" },\n];\n\n// --- Build the tree ----------------------------------------------------\nconst byId = {};\nrecords.forEach((rec) => {\n  byId[rec.id] = { ...rec, children: [] };\n});\nrecords.forEach((rec) => {\n  if (rec.parent !== null) byId[rec.parent].children.push(byId[rec.id]);\n});\nconst root = byId.root;\n\n// --- Circle packing: deterministic spiral seed + iterative repulsion -------\n// (per spec notes: \"pack circles efficiently using force simulation\";\n// no d3/library layout is used — this is a from-scratch physical relaxation)\nconst GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));\nconst PADDING = 0.15;\nconst ITERATIONS = 400;\n\nconst packCircles = (nodes) => {\n  const n = nodes.length;\n  if (n === 0) return 0;\n  if (n === 1) {\n    nodes[0].x = 0;\n    nodes[0].y = 0;\n    return nodes[0].r;\n  }\n  const sorted = nodes.slice().sort((a, b) => b.r - a.r);\n  sorted.forEach((node, i) => {\n    const spiralR = 2.2 * Math.sqrt(i) * (sorted[0].r + 0.4);\n    node.x = spiralR * Math.cos(i * GOLDEN_ANGLE);\n    node.y = spiralR * Math.sin(i * GOLDEN_ANGLE);\n  });\n  for (let iter = 0; iter < ITERATIONS; iter += 1) {\n    for (let i = 0; i < n; i += 1) {\n      const a = sorted[i];\n      for (let j = i + 1; j < n; j += 1) {\n        const b = sorted[j];\n        const dx = b.x - a.x;\n        const dy = b.y - a.y;\n        const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;\n        const minDist = a.r + b.r + PADDING;\n        if (dist < minDist) {\n          const overlap = (minDist - dist) / 2;\n          const nx = dx / dist;\n          const ny = dy / dist;\n          a.x -= nx * overlap;\n          a.y -= ny * overlap;\n          b.x += nx * overlap;\n          b.y += ny * overlap;\n        }\n      }\n    }\n    for (let i = 0; i < n; i += 1) {\n      sorted[i].x *= 0.993;\n      sorted[i].y *= 0.993;\n    }\n  }\n  let enclosing = 0;\n  for (let i = 0; i < n; i += 1) {\n    const d = Math.sqrt(sorted[i].x * sorted[i].x + sorted[i].y * sorted[i].y) + sorted[i].r;\n    if (d > enclosing) enclosing = d;\n  }\n  return enclosing + PADDING * 2;\n};\n\n// Leaf radius scales with sqrt(value) so area (not radius) encodes value.\n// Internal-node radius is the enclosing circle of its packed children.\nconst layoutNode = (node) => {\n  if (node.children.length === 0) {\n    node.r = Math.sqrt(node.value);\n    return;\n  }\n  node.children.forEach(layoutNode);\n  node.r = packCircles(node.children);\n};\nlayoutNode(root);\n\nconst placeAbsolute = (node, ox, oy) => {\n  node.absX = ox;\n  node.absY = oy;\n  node.children.forEach((child) => placeAbsolute(child, ox + child.x, oy + child.y));\n};\nplaceAbsolute(root, 0, 0);\n\n// --- Budget rollups (for tooltips: every node, not just leaves, gets a $ total) --\nconst computeTotal = (node) => {\n  node.total = node.children.length === 0 ? node.value : node.children.reduce((sum, c) => sum + computeTotal(c), 0);\n  return node.total;\n};\ncomputeTotal(root);\n\n// --- Solid depth-2 fill: mix the hue with a fixed literal (not the theme\n// background) so the composited pixel color is identical in both themes —\n// canvas alpha over a theme-dependent backdrop would otherwise drift.\nconst hexToRgb = (hex) => [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\nconst mixColor = (hexA, hexB, ratio) => {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  return `#${a.map((v, i) => Math.round(v * ratio + b[i] * (1 - ratio)).toString(16).padStart(2, \"0\")).join(\"\")}`;\n};\n\n// --- Flatten with depth-based Imprint coloring ------------------------------\n// Divisions (depth 1) take Imprint positions 1-4 in declared order; leaves\n// (depth 2) inherit their division's hue, darkened by a fixed literal ratio\n// (not theme-dependent alpha) to read as nested.\nconst flatData = [];\nconst flatten = (node, depth, color) => {\n  flatData.push({\n    x: node.absX,\n    y: node.absY,\n    r: node.r,\n    depth,\n    label: node.label,\n    amount: node.total,\n    color,\n    parentId: node.parent,\n  });\n  node.children.forEach((child, idx) => {\n    const childColor = depth === 0 ? t.palette[idx % t.palette.length] : color;\n    flatten(child, depth + 1, childColor);\n  });\n};\nflatten(root, 0, t.muted);\n\n// --- Label layout: precompute placement + collision avoidance --------------\n// The packing algorithm seeds each group's largest circle at the local\n// origin, which coincides with the parent's own center — so a naive\n// centered label would collide with its biggest child every time. Instead:\n// division labels sit near the rim (the packed children leave that area\n// empty by construction, since PADDING pads the enclosing radius), leaf\n// labels sit on their own circle, and every candidate is rejected if its\n// estimated box collides with an already-accepted one (bigger leaves win).\nconst domain = root.r * 1.06;\nconst mountSize = window.ANYPLOT_SIZE;\nconst GRID_LR = 0.09;\nconst GRID_TOP = 0.12;\nconst GRID_BOTTOM = 0.06;\nconst gridPx = Math.min(mountSize.width * (1 - GRID_LR * 2), mountSize.height * (1 - GRID_TOP - GRID_BOTTOM));\nconst pxPerUnit = gridPx / (2 * domain);\n\nconst DIVISION_FONT = 22;\nconst LEAF_FONT_MIN = 12;\nconst LEAF_FONT_MAX = 17;\n\nconst labelCandidates = [];\nflatData.forEach((d, idx) => {\n  if (d.depth === 0) return;\n  const rPx = d.r * pxPerUnit;\n  if (d.depth === 1) {\n    if (rPx < 60) return;\n    labelCandidates.push({\n      idx,\n      metric: Infinity, // division rims are reserved space, never evicted by a leaf\n      text: d.label,\n      maxFontSize: DIVISION_FONT,\n      minFontSize: DIVISION_FONT,\n      bold: true,\n      charW: 0.62,\n      positions: [{ dx: 0, dy: -d.r * 0.62 }],\n    });\n  } else {\n    if (rPx < 34) return;\n    const fontSize = Math.min(LEAF_FONT_MAX, Math.max(LEAF_FONT_MIN, rPx * 0.34));\n    labelCandidates.push({\n      idx,\n      metric: d.r, // real circle size, so bigger-value leaves always get first claim\n      text: d.label,\n      maxFontSize: fontSize,\n      minFontSize: LEAF_FONT_MIN * 0.75,\n      bold: false,\n      charW: 0.56,\n      // Own center first, then a few nudges toward the circle's own rim —\n      // enough freedom to dodge a bigger neighbor's box without drifting\n      // onto a sibling circle.\n      positions: [\n        { dx: 0, dy: 0 },\n        { dx: 0, dy: -d.r * 0.45 },\n        { dx: 0, dy: d.r * 0.45 },\n        { dx: -d.r * 0.4, dy: 0 },\n        { dx: d.r * 0.4, dy: 0 },\n      ],\n    });\n  }\n});\n// Larger circles claim label space first.\nlabelCandidates.sort((a, b) => b.metric - a.metric);\n\nconst placedRects = [];\nconst rectsOverlap = (a, b) => !(a.x2 < b.x1 || a.x1 > b.x2 || a.y2 < b.y1 || a.y1 > b.y2);\nconst boxFor = (cand, cx, cy, fontSize) => {\n  const wUnits = (cand.text.length * fontSize * cand.charW + 8) / pxPerUnit;\n  const hUnits = (fontSize * 1.3 + 6) / pxPerUnit;\n  return { x1: cx - wUnits / 2, x2: cx + wUnits / 2, y1: cy - hUnits / 2, y2: cy + hUnits / 2 };\n};\nconst acceptLabel = (cand, node, fontSize, rect, pos) => {\n  placedRects.push({ ...rect, metric: cand.metric, idx: cand.idx });\n  node.showLabel = true;\n  node.labelFontSize = fontSize;\n  node.labelBold = cand.bold;\n  node.labelYOffsetUnits = pos.dy;\n  node.labelXOffsetUnits = pos.dx;\n  node.labelCharW = cand.charW;\n};\n// Every position is tried at full size before any position is tried at a\n// smaller size (nudging beats shrinking); only the primary (center) position\n// at full size may evict an already-placed label, and only if every\n// colliding label belongs to a strictly smaller circle — long names are the\n// usual reason a big circle's default box collides where a shorter-named\n// smaller sibling's box does not.\nconst tryPlace = (cand, node, allowEvict) => {\n  for (let fontSize = cand.maxFontSize; fontSize >= cand.minFontSize; fontSize -= 1) {\n    for (let p = 0; p < cand.positions.length; p += 1) {\n      const pos = cand.positions[p];\n      const cx = node.x + pos.dx;\n      const cy = node.y + pos.dy;\n      const rect = boxFor(cand, cx, cy, fontSize);\n      const collisions = placedRects.filter((r) => rectsOverlap(rect, r));\n      if (collisions.length === 0) {\n        acceptLabel(cand, node, fontSize, rect, pos);\n        return true;\n      }\n      if (allowEvict && p === 0 && fontSize === cand.maxFontSize && collisions.every((r) => r.metric < cand.metric)) {\n        collisions.forEach((r) => placedRects.splice(placedRects.indexOf(r), 1));\n        acceptLabel(cand, node, fontSize, rect, pos);\n        return true;\n      }\n    }\n  }\n  return false;\n};\n\nconst candByIdx = {};\nlabelCandidates.forEach((cand) => {\n  candByIdx[cand.idx] = cand;\n  tryPlace(cand, flatData[cand.idx], true);\n});\n\n// Local-monotonicity repair: a bigger leaf can still lose its label slot to\n// an even-bigger sibling's box, while a smaller sibling elsewhere happens to\n// sit in open space and keeps its label — within one parent cluster, evict\n// that smaller sibling's label and retry the bigger circle in the freed space\n// so a reader never sees \"smaller labeled, bigger not\" inside the same circle.\nconst leafByParent = {};\nflatData.forEach((d, idx) => {\n  if (d.depth === 2) (leafByParent[d.parentId] ||= []).push(idx);\n});\nObject.values(leafByParent).forEach((siblingIdxs) => {\n  const bySizeDesc = siblingIdxs.slice().sort((a, b) => flatData[b].r - flatData[a].r);\n  bySizeDesc.forEach((idx) => {\n    const node = flatData[idx];\n    if (node.showLabel) return;\n    const cand = candByIdx[idx];\n    if (!cand) return;\n    const victimIdx = siblingIdxs.find((j) => flatData[j].showLabel && flatData[j].r < node.r);\n    if (victimIdx === undefined) return;\n    const rectPos = placedRects.findIndex((r) => r.idx === victimIdx);\n    if (rectPos === -1) return;\n    const savedRect = placedRects[rectPos];\n    placedRects.splice(rectPos, 1);\n    if (tryPlace(cand, node, false)) {\n      flatData[victimIdx].showLabel = false;\n    } else {\n      placedRects.push(savedRect);\n    }\n  });\n});\n\nconst seriesData = flatData.map((d) => {\n  if (d.depth === 0) {\n    return {\n      value: [d.x, d.y, d.r],\n      itemStyle: { color: \"transparent\", borderColor: t.inkSoft, borderWidth: 1.5, opacity: 0.4 },\n      label: d.label,\n      amount: d.amount,\n    };\n  }\n  if (d.depth === 1) {\n    return {\n      value: [d.x, d.y, d.r],\n      itemStyle: { color: d.color, opacity: 0.85, borderColor: t.pageBg, borderWidth: 2.5 },\n      label: d.label,\n      amount: d.amount,\n    };\n  }\n  // Solid literal-mixed fill (not canvas alpha over the theme background) so\n  // the composited leaf color is pixel-identical between light and dark.\n  return {\n    value: [d.x, d.y, d.r],\n    itemStyle: { color: mixColor(d.color, \"#000000\", 0.82), opacity: 1, borderColor: t.pageBg, borderWidth: 1.2 },\n    label: d.label,\n    amount: d.amount,\n  };\n});\n\n// --- Custom-series renderers -----------------------------------------------\n// Circles and labels are two separate series (circles drawn first, labels\n// second) so every label chip paints on top of ALL circles — a label must\n// never be covered by a sibling/child circle drawn later in tree order.\nconst renderCircle = (params, api) => {\n  const center = api.coord([api.value(0), api.value(1)]);\n  const rPixel = api.size([api.value(2), 0])[0];\n  return { type: \"circle\", shape: { cx: center[0], cy: center[1], r: rPixel }, style: api.style() };\n};\n\nconst labeledNodes = flatData.filter((d) => d.showLabel);\nconst labelSeriesData = labeledNodes.map((d) => ({ value: [d.x, d.y] }));\n\nconst renderLabel = (params, api) => {\n  const raw = labeledNodes[params.dataIndex];\n  const labelCenter = api.coord([api.value(0) + raw.labelXOffsetUnits, api.value(1) + raw.labelYOffsetUnits]);\n  const w = raw.label.length * raw.labelFontSize * raw.labelCharW + 8;\n  const h = raw.labelFontSize * 1.3 + 6;\n  return {\n    type: \"group\",\n    children: [\n      {\n        type: \"rect\",\n        shape: { x: labelCenter[0] - w / 2, y: labelCenter[1] - h / 2, width: w, height: h, r: 3 },\n        style: { fill: t.elevatedBg },\n      },\n      {\n        type: \"text\",\n        style: {\n          text: raw.label,\n          x: labelCenter[0],\n          y: labelCenter[1],\n          fill: t.ink,\n          fontSize: raw.labelFontSize,\n          fontWeight: raw.labelBold ? 600 : 500,\n          textAlign: \"center\",\n          textVerticalAlign: \"middle\",\n        },\n      },\n    ],\n  };\n};\n\n// --- Chart -------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"circlepacking-basic · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  // Recovers labels dropped by the static collision-avoidance layout: any\n  // circle (labeled or not) shows its name + budget on hover.\n  tooltip: {\n    trigger: \"item\",\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    textStyle: { color: t.ink, fontSize: 14 },\n    formatter: (params) => (params.data && params.data.amount != null ? `<strong>${params.data.label}</strong><br/>$${params.data.amount}M` : \"\"),\n  },\n  grid: { left: \"9%\", right: \"9%\", top: \"12%\", bottom: \"6%\" },\n  xAxis: { type: \"value\", min: -domain, max: domain, show: false, splitLine: { show: false } },\n  yAxis: { type: \"value\", min: -domain, max: domain, show: false, splitLine: { show: false } },\n  series: [\n    {\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      renderItem: renderCircle,\n      data: seriesData,\n      clip: false,\n    },\n    {\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      renderItem: renderLabel,\n      data: labelSeriesData,\n      tooltip: { show: false },\n      clip: false,\n    },\n  ],\n});\n"}