{"spec_id":"circlepacking-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// circlepacking-basic: Circle Packing Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n\n// Highcharts' packed-bubble / circle-packing series lives in the\n// highcharts-more add-on module, which is not vendored here — only the core\n// bundle (with its SVGRenderer) is loaded. So the hierarchy below is packed\n// with a small deterministic relaxation algorithm (index-seeded, no RNG) and\n// drawn natively with `chart.renderer`: a root ring, one ring per directory,\n// and (for one directory) a nested sub-ring for its files, three levels deep.\n// The same recursive layout/render pair handles every level. No other\n// charting library is used.\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: a small repository's directory sizes (KB), up to 3 levels deep ----\n// Most directories hold files directly (2 levels below root); \"components/\"\n// is further split into named files to exercise a 3rd nesting level.\nconst CATEGORIES = [\n  {\n    id: \"src\",\n    label: \"src/\",\n    children: [\n      {\n        id: \"src-components\",\n        label: \"components/\",\n        children: [\n          { id: \"src-components-table\", label: \"Table.jsx\", value: 150 },\n          { id: \"src-components-modal\", label: \"Modal.jsx\", value: 130 },\n          { id: \"src-components-form\", label: \"Form.jsx\", value: 120 },\n          { id: \"src-components-button\", label: \"Button.jsx\", value: 80 },\n        ],\n      },\n      { id: \"src-api\", label: \"api.js\", value: 340 },\n      { id: \"src-store\", label: \"store.js\", value: 210 },\n      { id: \"src-utils\", label: \"utils.js\", value: 120 },\n      { id: \"src-hooks\", label: \"hooks.js\", value: 95 },\n      { id: \"src-styles\", label: \"styles.css\", value: 70 },\n      { id: \"src-types\", label: \"types.d.ts\", value: 55 },\n    ],\n  },\n  {\n    id: \"tests\",\n    label: \"tests/\",\n    children: [\n      { id: \"tests-unit\", label: \"unit/\", value: 200 },\n      { id: \"tests-integration\", label: \"integration/\", value: 150 },\n      { id: \"tests-e2e\", label: \"e2e/\", value: 90 },\n      { id: \"tests-fixtures\", label: \"fixtures/\", value: 60 },\n      { id: \"tests-mocks\", label: \"mocks/\", value: 45 },\n    ],\n  },\n  {\n    id: \"docs\",\n    label: \"docs/\",\n    children: [\n      { id: \"docs-api\", label: \"api-reference.md\", value: 130 },\n      { id: \"docs-guide\", label: \"guide.md\", value: 85 },\n      { id: \"docs-changelog\", label: \"changelog.md\", value: 50 },\n      { id: \"docs-readme\", label: \"readme.md\", value: 45 },\n    ],\n  },\n  {\n    id: \"assets\",\n    label: \"assets/\",\n    children: [\n      { id: \"assets-images\", label: \"images/\", value: 380 },\n      { id: \"assets-fonts\", label: \"fonts/\", value: 190 },\n      { id: \"assets-videos\", label: \"videos/\", value: 140 },\n      { id: \"assets-icons\", label: \"icons/\", value: 95 },\n      { id: \"assets-logo\", label: \"logo.svg\", value: 40 },\n    ],\n  },\n  {\n    id: \"build\",\n    label: \"build/\",\n    children: [\n      { id: \"build-bundle\", label: \"bundle.js\", value: 600 },\n      { id: \"build-vendor\", label: \"vendor.js\", value: 420 },\n      { id: \"build-maps\", label: \"sourcemaps/\", value: 210 },\n      { id: \"build-manifest\", label: \"manifest.json\", value: 45 },\n    ],\n  },\n];\n\n// Directories are abstract categories, so the Imprint palette is used in\n// canonical order — src = brand green (palette[0]).\nconst categoryColor = (i) => t.palette[i % t.palette.length];\n\nconst formatSize = (kb) => (kb >= 1000 ? `${(kb / 1000).toFixed(1)} MB` : `${kb} KB`);\n\nfunction hexToRgba(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\n// Pick readable label ink by the fill's own luminance rather than the active\n// theme — a leaf circle's colour is the same in light and dark mode, so the\n// text riding on top needs the ink value with contrast to THAT colour, not to\n// the page. #1A1A17 / #F0EFE8 are exactly the light/dark INK tokens reused\n// for this purpose, never a new custom hex.\nfunction contrastInk(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 lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 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.42 ? \"#1A1A17\" : \"#F0EFE8\";\n}\n\n// --- Circle packing: a small deterministic relaxation, applied per level -----\n// Seed circles on a ring (index-based angle, no RNG), then repeatedly resolve\n// overlaps and pull toward the centroid until the group settles into a tight,\n// non-overlapping cluster. The same routine packs siblings at every depth —\n// files within a directory, a directory alongside files within its parent,\n// and top-level directories within the repository.\nfunction packCircles(items, gap) {\n  if (items.length === 0) return { placed: [], boundingRadius: 0 };\n  if (items.length === 1) {\n    return { placed: [{ id: items[0].id, x: 0, y: 0, r: items[0].r }], boundingRadius: items[0].r };\n  }\n  const n = items.length;\n  const nodes = items.map((it, i) => {\n    const theta = (i / n) * Math.PI * 2;\n    const seedR = it.r * 1.6 + i * 4;\n    return { id: it.id, r: it.r, x: seedR * Math.cos(theta), y: seedR * Math.sin(theta) };\n  });\n  for (let iter = 0; iter < 260; iter += 1) {\n    nodes.forEach((a) => {\n      a.x -= a.x * 0.02;\n      a.y -= a.y * 0.02;\n    });\n    for (let i = 0; i < n; i += 1) {\n      for (let j = i + 1; j < n; j += 1) {\n        const a = nodes[i];\n        const b = nodes[j];\n        let dx = b.x - a.x;\n        let dy = b.y - a.y;\n        let dist = Math.sqrt(dx * dx + dy * dy);\n        const minDist = a.r + b.r + gap;\n        if (dist < 1e-6) {\n          dx = 0.01 * (i + 1);\n          dy = 0.01 * (j + 1);\n          dist = Math.sqrt(dx * dx + dy * dy);\n        }\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  const minX = Math.min(...nodes.map((nd) => nd.x - nd.r));\n  const maxX = Math.max(...nodes.map((nd) => nd.x + nd.r));\n  const minY = Math.min(...nodes.map((nd) => nd.y - nd.r));\n  const maxY = Math.max(...nodes.map((nd) => nd.y + nd.r));\n  const ox = (minX + maxX) / 2;\n  const oy = (minY + maxY) / 2;\n  nodes.forEach((nd) => {\n    nd.x -= ox;\n    nd.y -= oy;\n  });\n  let boundingRadius = 0;\n  nodes.forEach((nd) => {\n    const d = Math.sqrt(nd.x * nd.x + nd.y * nd.y) + nd.r;\n    if (d > boundingRadius) boundingRadius = d;\n  });\n  return { placed: nodes, boundingRadius };\n}\n\n// --- Layout: a recursive pack, depth 0 = root, 1 = category, 2+ = nested ----\n// Circle area, not radius, encodes size: radius = sqrt(value) in abstract\n// units; everything is rescaled to pixels once the final plot area is known.\n// Every node below the root inherits its top-level category's colour index,\n// so a nested sub-directory (e.g. \"components/\") reads as part of \"src/\".\nconst LEAF_GAP = 1.4;\nconst RING_PADDING = 6.5;\nconst CATEGORY_GAP = 7;\nconst ROOT_PADDING = 7;\n\nconst leafRadius = (value) => Math.sqrt(value);\n\nfunction layoutNode(node, depth, categoryIndex) {\n  if (!node.children) {\n    return { id: node.id, label: node.label, value: node.value, r: leafRadius(node.value), depth, categoryIndex, leaf: true };\n  }\n  const childLayouts = node.children.map((child, i) => layoutNode(child, depth + 1, depth === 0 ? i : categoryIndex));\n  const gap = depth === 0 ? CATEGORY_GAP : LEAF_GAP;\n  const { placed, boundingRadius } = packCircles(\n    childLayouts.map((cl) => ({ id: cl.id, r: cl.r })),\n    gap,\n  );\n  const totalValue = childLayouts.reduce((s, cl) => s + cl.value, 0);\n  const children = childLayouts.map((cl) => {\n    const p = placed.find((pp) => pp.id === cl.id);\n    return { ...cl, x: p.x, y: p.y };\n  });\n  // Padding shrinks with depth so nested rings hug their contents a little\n  // tighter than the outer category rings do.\n  const padding = depth === 0 ? ROOT_PADDING : depth === 1 ? RING_PADDING : RING_PADDING * 0.7;\n  return {\n    id: node.id,\n    label: node.label,\n    value: totalValue,\n    r: boundingRadius + padding,\n    depth,\n    categoryIndex,\n    leaf: false,\n    children,\n  };\n}\n\nconst tree = layoutNode({ id: \"root\", label: \"repository\", children: CATEGORIES }, 0, null);\n\n// --- Chart shell (no series — every circle is drawn with the renderer) -------\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    marginTop: 100,\n    marginBottom: 40,\n    marginLeft: 40,\n    marginRight: 40,\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"circlepacking-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Repository directory sizes — circle area = file/folder size (KB), colour = directory\",\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\nconst cx = chart.plotLeft + chart.plotWidth / 2;\nconst cy = chart.plotTop + chart.plotHeight / 2;\nconst radiusMax = Math.min(chart.plotWidth, chart.plotHeight) / 2;\nconst finalScale = (radiusMax - 12) / tree.r;\nconst px = (x) => cx + x * finalScale;\nconst py = (y) => cy + y * finalScale;\nconst pr = (r) => r * finalScale;\n\nfunction addTitle(el, text) {\n  const title = document.createElementNS(\"http://www.w3.org/2000/svg\", \"title\");\n  title.textContent = text;\n  el.element.appendChild(title);\n}\n\nconst g = chart.renderer.g(\"circle-packing\").add();\n\n// --- Render: recursive, depth drives stroke weight / fill alpha / labels ----\n// depth 0 = root outline, depth 1 = category ring, depth 2+ = nested rings\n// (subtly thinner stroke, higher fill alpha) or solid leaf circles.\nfunction renderNode(node, parentX, parentY, pathPrefix) {\n  const x = px(parentX + (node.x ?? 0));\n  const y = py(parentY + (node.y ?? 0));\n  const r = pr(node.r);\n\n  if (node.leaf) {\n    const color = categoryColor(node.categoryIndex);\n    const leaf = chart.renderer\n      .circle(x, y, r)\n      .attr({ fill: color, stroke: t.pageBg, \"stroke-width\": 1.5 })\n      .add(g);\n    addTitle(leaf, `${pathPrefix}${node.label} — ${formatSize(node.value)}`);\n\n    if (r >= 28) {\n      const fontSize = Math.max(10, Math.min(13, Math.round(r * 0.26)));\n      const maxChars = Math.max(3, Math.floor((r * 1.7) / (fontSize * 0.58)));\n      const text = node.label.length > maxChars ? `${node.label.slice(0, maxChars - 1)}…` : node.label;\n      chart.renderer\n        .text(text, x, y + fontSize * 0.35)\n        .attr({ align: \"center\" })\n        .css({ color: contrastInk(color), fontSize: `${fontSize}px`, fontWeight: \"500\" })\n        .add(g);\n    }\n    return;\n  }\n\n  if (node.depth === 0) {\n    const root = chart.renderer\n      .circle(x, y, r)\n      .attr({ fill: \"transparent\", stroke: t.grid, \"stroke-width\": 1.5 })\n      .add(g);\n    addTitle(root, `repository — ${formatSize(node.value)} total`);\n  } else {\n    const isCategory = node.depth === 1;\n    const color = categoryColor(node.categoryIndex);\n    const ring = chart.renderer\n      .circle(x, y, r)\n      .attr({\n        fill: hexToRgba(color, isCategory ? 0.1 : 0.17),\n        stroke: color,\n        \"stroke-width\": isCategory ? 2.5 : 1.8,\n      })\n      .add(g);\n    addTitle(ring, `${pathPrefix}${node.label} — ${formatSize(node.value)} total`);\n\n    const labelThreshold = isCategory ? 55 : 40;\n    if (r >= labelThreshold) {\n      const fontSize = isCategory ? Math.max(12, Math.min(16, Math.round(r * 0.1))) : Math.max(11, Math.min(13, Math.round(r * 0.12)));\n      chart.renderer\n        .text(`${node.label} · ${formatSize(node.value)}`, x, y - r + fontSize + 6)\n        .attr({ align: \"center\" })\n        .css({ color: t.ink, fontSize: `${fontSize}px`, fontWeight: \"600\" })\n        .add(g);\n    }\n  }\n\n  const childPathPrefix = node.depth === 0 ? \"\" : `${pathPrefix}${node.label}`;\n  const originX = parentX + (node.x ?? 0);\n  const originY = parentY + (node.y ?? 0);\n  node.children.forEach((child) => renderNode(child, originX, originY, childPathPrefix));\n}\n\nrenderNode(tree, 0, 0, \"\");\n\n// Static-frame timing signal for the harness.\nwindow.__anyplotReady = true;\n"}