{"spec_id":"bubble-packed","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// bubble-packed: Basic Packed Bubble Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-24\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: annual department budgets ($k), clustered by division -----------\nconst departments = [\n  { label: \"Backend\", value: 420, group: \"Engineering\" },\n  { label: \"Frontend\", value: 310, group: \"Engineering\" },\n  { label: \"Mobile\", value: 265, group: \"Engineering\" },\n  { label: \"DevOps\", value: 190, group: \"Engineering\" },\n  { label: \"QA\", value: 150, group: \"Engineering\" },\n  { label: \"Digital Ads\", value: 380, group: \"Marketing\" },\n  { label: \"Content\", value: 210, group: \"Marketing\" },\n  { label: \"Events\", value: 175, group: \"Marketing\" },\n  { label: \"Brand\", value: 140, group: \"Marketing\" },\n  { label: \"SEO\", value: 95, group: \"Marketing\" },\n  { label: \"Facilities\", value: 260, group: \"Operations\" },\n  { label: \"Finance\", value: 230, group: \"Operations\" },\n  { label: \"HR\", value: 205, group: \"Operations\" },\n  { label: \"IT Support\", value: 165, group: \"Operations\" },\n  { label: \"Legal\", value: 120, group: \"Operations\" },\n];\n\nconst GROUPS = [\"Engineering\", \"Marketing\", \"Operations\"];\nconst groupColor = Object.fromEntries(GROUPS.map((g, i) => [g, t.palette[i]]));\n// Anchors reach toward all four corners of the square canvas (top-left,\n// top-right, bottom-center) with the vertical gap between the top row and\n// the bottom cluster tightened so bubble ink — not blank margin — dominates.\nconst groupCenters = {\n  Engineering: { x: 290, y: 320 },\n  Marketing: { x: 910, y: 320 },\n  Operations: { x: 600, y: 860 },\n};\n\n// --- Radius by area (not radius) so circle size tracks value linearly ------\nconst MAX_R = 165;\nconst maxValue = Math.max(...departments.map((d) => d.value));\nconst radiusScale = (v) => (MAX_R * Math.sqrt(v)) / Math.sqrt(maxValue);\n\n// --- Circle packing via iterative collision relaxation (no external libs) --\n// Deterministic LCG for the initial jitter — the browser has no seeded RNG.\nfunction lcg(seed) {\n  let s = seed;\n  return () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;\n}\nconst rand = lcg(42);\n\nconst LAYOUT_W = 1200;\nconst LAYOUT_H = 1200;\nconst SAME_GROUP_GAP = 6;\nconst CROSS_GROUP_GAP = 22;\nconst ITERATIONS = 600;\nconst CENTER_PULL = 0.015;\n\nconst nodes = departments.map((d) => {\n  const center = groupCenters[d.group];\n  const angle = rand() * Math.PI * 2;\n  const jitter = rand() * 60;\n  return {\n    ...d,\n    r: radiusScale(d.value),\n    x: center.x + Math.cos(angle) * jitter,\n    y: center.y + Math.sin(angle) * jitter,\n  };\n});\n\nfor (let iter = 0; iter < ITERATIONS; iter++) {\n  for (let i = 0; i < nodes.length; i++) {\n    for (let j = i + 1; j < nodes.length; j++) {\n      const a = nodes[i];\n      const b = nodes[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.01;\n      const gap = a.group === b.group ? SAME_GROUP_GAP : CROSS_GROUP_GAP;\n      const minDist = a.r + b.r + gap;\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  for (const n of nodes) {\n    const center = groupCenters[n.group];\n    n.x += (center.x - n.x) * CENTER_PULL;\n    n.y += (center.y - n.y) * CENTER_PULL;\n    n.x = Math.min(LAYOUT_W - n.r - 8, Math.max(n.r + 8, n.x));\n    n.y = Math.min(LAYOUT_H - n.r - 8, Math.max(n.r + 8, n.y));\n  }\n}\n\n// --- Label contrast: pick ink-dark or ink-light text per bubble fill -------\nfunction relativeLuminance(r, g, b) {\n  const lin = (c) => {\n    const s = c / 255;\n    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n  };\n  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\nfunction contrastRatio(l1, l2) {\n  const lighter = Math.max(l1, l2);\n  const darker = Math.min(l1, l2);\n  return (lighter + 0.05) / (darker + 0.05);\n}\nfunction labelColorFor(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  const luminance = relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255);\n  const darkContrast = contrastRatio(luminance, relativeLuminance(0x1a, 0x1a, 0x17));\n  const lightContrast = contrastRatio(luminance, relativeLuminance(0xf0, 0xef, 0xe8));\n  return darkContrast >= lightContrast ? \"#1A1A17\" : \"#F0EFE8\";\n}\n\n// --- Custom plugin: name+value label inside each bubble (dropping the value\n// line below a radius floor so small circles never cram two lines), plus a\n// manually-drawn legend key (kept off the Chart.js layout system so the\n// hidden x/y scales stay at a 1:1 pixel-to-data ratio and circles stay round).\nconst VALUE_LINE_MIN_R = 46;\nconst bubbleLabels = {\n  id: \"bubbleLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    chart.data.datasets.forEach((dataset, dsIndex) => {\n      const meta = chart.getDatasetMeta(dsIndex);\n      meta.data.forEach((element, i) => {\n        const point = dataset.data[i];\n        const { x, y } = element.getProps([\"x\", \"y\"], true);\n        const r = element.options.radius;\n        const showValue = r >= VALUE_LINE_MIN_R;\n        let nameFont = Math.min(16, Math.max(9, Math.round(r * 0.24)));\n        ctx.font = `600 ${nameFont}px sans-serif`;\n        while (nameFont > 9 && ctx.measureText(point.label).width > r * 1.7) {\n          nameFont -= 1;\n          ctx.font = `600 ${nameFont}px sans-serif`;\n        }\n        ctx.fillStyle = dataset.labelColor;\n        if (showValue) {\n          ctx.fillText(point.label, x, y - nameFont * 0.65);\n          const valueFont = Math.max(8, nameFont - 2);\n          ctx.font = `400 ${valueFont}px sans-serif`;\n          ctx.fillText(`$${point.value}k`, x, y + valueFont * 0.75);\n        } else {\n          ctx.fillText(point.label, x, y);\n        }\n      });\n    });\n    ctx.restore();\n  },\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    ctx.save();\n    ctx.font = \"600 16px sans-serif\";\n    ctx.textBaseline = \"middle\";\n    const dotR = 8;\n    const dotTextGap = 10;\n    const itemGap = 34;\n    const widths = GROUPS.map((g) => dotR * 2 + dotTextGap + ctx.measureText(g).width);\n    const totalWidth = widths.reduce((a, b) => a + b, 0) + itemGap * (GROUPS.length - 1);\n    let lx = (chartArea.left + chartArea.right - totalWidth) / 2;\n    const ly = chartArea.top + 22;\n    GROUPS.forEach((g, i) => {\n      ctx.fillStyle = groupColor[g];\n      ctx.beginPath();\n      ctx.arc(lx + dotR, ly, dotR, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.fillStyle = t.ink;\n      ctx.textAlign = \"left\";\n      ctx.fillText(g, lx + dotR * 2 + dotTextGap, ly);\n      lx += widths[i] + itemGap;\n    });\n    ctx.restore();\n  },\n};\nChart.register(bubbleLabels);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart: bubble type used purely as a positioned-circle renderer --------\n// Position (x, y) carries no meaning — only size (value) and color (group)\n// do. Scales are hidden and bounded to the packing layout's own coordinate\n// space; layout.autoPadding is off so that space maps 1:1 onto chart pixels\n// (Chart.js otherwise reserves extra margin sized to the largest bubble\n// radius, which would compress the layout and leave circles under-scaled).\nnew Chart(canvas, {\n  type: \"bubble\",\n  data: {\n    datasets: GROUPS.map((g) => ({\n      label: g,\n      labelColor: labelColorFor(groupColor[g]),\n      backgroundColor: groupColor[g],\n      borderColor: t.pageBg,\n      borderWidth: 2,\n      data: nodes\n        .filter((n) => n.group === g)\n        .map((n) => ({ x: n.x, y: n.y, r: n.r, label: n.label, value: n.value })),\n    })),\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 0, autoPadding: false },\n    plugins: {\n      title: {\n        display: true,\n        text: \"bubble-packed · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { top: 16, bottom: 24 },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label: (item) => `${item.raw.label}: $${item.raw.value}k`,\n        },\n      },\n    },\n    scales: {\n      x: { display: false, min: 0, max: LAYOUT_W },\n      y: { display: false, min: 0, max: LAYOUT_H, reverse: true },\n    },\n  },\n});\n"}