{"spec_id":"bubble-packed","library":"d3","language":"javascript","code":"// anyplot.ai\n// bubble-packed: Basic Packed Bubble Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-24\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) — R&D budget ($M) by project, grouped by department ---\nconst GROUPS = [\"AI & ML\", \"Cloud Infra\", \"Hardware\", \"Dev Tools\"];\nconst data = [\n  { label: \"Foundation Models\", value: 118, group: \"AI & ML\" },\n  { label: \"Computer Vision\", value: 64, group: \"AI & ML\" },\n  { label: \"Speech Recognition\", value: 42, group: \"AI & ML\" },\n  { label: \"Recommendation Engine\", value: 58, group: \"AI & ML\" },\n  { label: \"AutoML Platform\", value: 27, group: \"AI & ML\" },\n  { label: \"Model Monitoring\", value: 15, group: \"AI & ML\" },\n  { label: \"Container Orchestration\", value: 96, group: \"Cloud Infra\" },\n  { label: \"Serverless Runtime\", value: 51, group: \"Cloud Infra\" },\n  { label: \"Edge Network\", value: 39, group: \"Cloud Infra\" },\n  { label: \"Data Lake\", value: 73, group: \"Cloud Infra\" },\n  { label: \"Service Mesh\", value: 22, group: \"Cloud Infra\" },\n  { label: \"Cost Optimization\", value: 12, group: \"Cloud Infra\" },\n  { label: \"Custom AI Chips\", value: 132, group: \"Hardware\" },\n  { label: \"Sensor Arrays\", value: 46, group: \"Hardware\" },\n  { label: \"Battery Systems\", value: 68, group: \"Hardware\" },\n  { label: \"Thermal Design\", value: 24, group: \"Hardware\" },\n  { label: \"Robotics Platform\", value: 55, group: \"Hardware\" },\n  { label: \"Prototyping Lab\", value: 9, group: \"Hardware\" },\n  { label: \"IDE Plugins\", value: 33, group: \"Dev Tools\" },\n  { label: \"CI/CD Pipeline\", value: 41, group: \"Dev Tools\" },\n  { label: \"Testing Framework\", value: 19, group: \"Dev Tools\" },\n  { label: \"Documentation Portal\", value: 8, group: \"Dev Tools\" },\n  { label: \"SDK Libraries\", value: 29, group: \"Dev Tools\" },\n  { label: \"Debugger Suite\", value: 14, group: \"Dev Tools\" },\n];\n\n// --- Layout — one cluster per group, spaced across the width -----------------\nconst margin = { top: 100, right: 40, bottom: 30, left: 40 };\nconst bubbleTop = margin.top + 55; // room for the group label above each cluster\nconst bubbleBottom = height - margin.bottom;\nconst clusterWidth = (width - margin.left - margin.right) / GROUPS.length;\nconst clusterCenterY = bubbleTop + (bubbleBottom - bubbleTop) / 2;\nconst clusterCenters = GROUPS.map((_, i) => ({\n  x: margin.left + clusterWidth * (i + 0.5),\n  y: clusterCenterY,\n}));\nconst color = d3.scaleOrdinal().domain(GROUPS).range(t.palette);\n\n// --- Radius scale — area-proportional, not radius-proportional ---------------\nconst radius = d3\n  .scaleSqrt()\n  .domain([0, d3.max(data, (d) => d.value)])\n  .range([6, 95]);\nconst nodes = data.map((d) => ({ ...d, r: radius(d.value) }));\n\n// --- Force simulation: cluster by group, resolve overlap via collision -------\nconst simulation = d3\n  .forceSimulation(nodes)\n  .force(\"x\", d3.forceX((d) => clusterCenters[GROUPS.indexOf(d.group)].x).strength(0.06))\n  .force(\"y\", d3.forceY(clusterCenterY).strength(0.06))\n  .force(\n    \"collide\",\n    d3\n      .forceCollide((d) => d.r + 3)\n      .strength(1)\n      .iterations(3)\n  )\n  .stop();\n\nfor (let i = 0; i < 400; i += 1) {\n  simulation.tick();\n  for (const d of nodes) {\n    d.x = Math.max(margin.left + d.r, Math.min(width - margin.right - d.r, d.x));\n    d.y = Math.max(bubbleTop + d.r, Math.min(bubbleBottom - d.r, d.y));\n  }\n}\n\n// Packed clusters settle compactly near clusterCenterY, leaving unbalanced\n// whitespace above and below. Stretch the vertical spread to use the full\n// bubbleTop-bubbleBottom band; this only increases inter-node distance\n// (never shrinks it), so it cannot introduce new overlap.\nconst yMin = d3.min(nodes, (d) => d.y - d.r);\nconst yMax = d3.max(nodes, (d) => d.y + d.r);\nconst yScale = (bubbleBottom - bubbleTop) / (yMax - yMin);\nfor (const d of nodes) {\n  d.y = Math.max(bubbleTop + d.r, Math.min(bubbleBottom - d.r, bubbleTop + (d.y - yMin) * yScale));\n}\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Group labels — double as the color legend, no separate box needed -------\nsvg\n  .selectAll(\"text.group\")\n  .data(GROUPS)\n  .join(\"text\")\n  .attr(\"class\", \"group\")\n  .attr(\"x\", (_, i) => clusterCenters[i].x)\n  .attr(\"y\", margin.top + 26)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", (g) => color(g))\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"600\")\n  .text((g) => g);\n\n// --- Bubbles --------------------------------------------------------------\nfunction relativeLuminance(hex) {\n  const [r, g, b] = hex.match(/\\w\\w/g).map((c) => parseInt(c, 16) / 255);\n  const linear = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);\n  return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);\n}\nconst labelInk = (hex) => (relativeLuminance(hex) > 0.42 ? \"#1A1A17\" : \"#F5F4EE\");\n\n// Largest project per group — emphasized with a bolder stroke to reinforce\n// the size hierarchy (the \"hero\" bubble of each cluster).\nconst heroValueByGroup = new Map(GROUPS.map((g) => [g, d3.max(nodes.filter((d) => d.group === g), (d) => d.value)]));\nconst isHero = (d) => d.value === heroValueByGroup.get(d.group);\n\nconst bubble = svg\n  .selectAll(\"g.bubble\")\n  .data(nodes)\n  .join(\"g\")\n  .attr(\"transform\", (d) => `translate(${d.x},${d.y})`);\n\nbubble\n  .append(\"circle\")\n  .attr(\"r\", (d) => d.r)\n  .attr(\"fill\", (d) => color(d.group))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", (d) => (isHero(d) ? 3.5 : 2));\n\n// Labels only where the circle is large enough to hold them; word-wrapped,\n// sized to the available chord so text never spills past its own bubble, and\n// paired with a smaller value line (\"$118M\") so magnitudes are readable\n// directly, not just relative rank.\nbubble\n  .filter((d) => d.r > 50)\n  .append(\"text\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", (d) => labelInk(color(d.group)))\n  .each(function (d) {\n    const words = d.label.split(\" \");\n    const nameLines =\n      words.length > 1\n        ? [words.slice(0, Math.ceil(words.length / 2)).join(\" \"), words.slice(Math.ceil(words.length / 2)).join(\" \")]\n        : [d.label];\n    const valueLine = `$${d.value}M`;\n    const totalLines = nameLines.length + 1;\n    const longest = Math.max(...nameLines.map((l) => l.length), valueLine.length * 0.85);\n    const fontSize = Math.max(9, Math.min(15, d.r * 0.24, (d.r * 1.6) / (longest * 0.58)));\n    const valueFontSize = fontSize * 0.8;\n    const lineHeight = fontSize * 1.15;\n    const sel = d3.select(this).style(\"font-size\", `${fontSize}px`).style(\"font-weight\", \"500\");\n    sel\n      .selectAll(\"tspan.name\")\n      .data(nameLines)\n      .join(\"tspan\")\n      .attr(\"class\", \"name\")\n      .attr(\"x\", 0)\n      .attr(\"y\", (_, i) => (i - (totalLines - 1) / 2) * lineHeight)\n      .text((line) => line);\n    sel\n      .append(\"tspan\")\n      .attr(\"class\", \"value\")\n      .attr(\"x\", 0)\n      .attr(\"y\", (nameLines.length - (totalLines - 1) / 2) * lineHeight)\n      .style(\"font-size\", `${valueFontSize}px`)\n      .style(\"font-weight\", \"400\")\n      .style(\"opacity\", 0.85)\n      .text(valueLine);\n  });\n\n// --- Title ----------------------------------------------------------------\nconst TITLE = \"R&D Budget by Project · bubble-packed · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(14, Math.round(22 * Math.min(1, 67 / TITLE.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(TITLE);\n"}