{"spec_id":"bubble-packed","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// bubble-packed: Basic Packed Bubble Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-08-24\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\n// Monthly cloud infrastructure spend by service, grouped by owning team.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\nconst GROUPS = [\"Platform\", \"Data\", \"Growth\", \"Security\"];\nconst ITEMS = [\n  { label: \"Compute\", value: 210, group: \"Platform\" },\n  { label: \"Storage\", value: 165, group: \"Platform\" },\n  { label: \"Networking\", value: 98, group: \"Platform\" },\n  { label: \"Load Balancer\", value: 54, group: \"Platform\" },\n  { label: \"CDN\", value: 72, group: \"Platform\" },\n  { label: \"Registry\", value: 31, group: \"Platform\" },\n  { label: \"Warehouse\", value: 188, group: \"Data\" },\n  { label: \"ETL Pipeline\", value: 124, group: \"Data\" },\n  { label: \"Data Lake\", value: 96, group: \"Data\" },\n  { label: \"Streaming\", value: 58, group: \"Data\" },\n  { label: \"Backups\", value: 42, group: \"Data\" },\n  { label: \"Cache\", value: 27, group: \"Data\" },\n  { label: \"Analytics\", value: 143, group: \"Growth\" },\n  { label: \"Marketing Auto.\", value: 82, group: \"Growth\" },\n  { label: \"A/B Testing\", value: 67, group: \"Growth\" },\n  { label: \"Personalization\", value: 55, group: \"Growth\" },\n  { label: \"Notifications\", value: 38, group: \"Growth\" },\n  { label: \"Experiments\", value: 24, group: \"Growth\" },\n  { label: \"Identity & Access\", value: 112, group: \"Security\" },\n  { label: \"Secrets Manager\", value: 45, group: \"Security\" },\n  { label: \"Threat Detection\", value: 39, group: \"Security\" },\n  { label: \"Audit Logging\", value: 33, group: \"Security\" },\n  { label: \"Compliance Scans\", value: 28, group: \"Security\" },\n  { label: \"WAF\", value: 21, group: \"Security\" },\n];\n\n// --- Layout: pack circles via a small force simulation -----------------------\n// Radius scales with sqrt(value) so circle AREA (not radius) is proportional\n// to spend, then positions relax so same-group circles nestle tightly while\n// different-group circles keep a visible gap — a manual stand-in for the\n// packedbubble series type, which lives in highcharts-more and isn't loaded.\nconst MAX_VALUE = Math.max(...ITEMS.map((d) => d.value));\nconst RADIUS_SCALE = 122 / Math.sqrt(MAX_VALUE);\nconst radiusFor = (value) => RADIUS_SCALE * Math.sqrt(value);\n\n// Fit a data label to its circle: prefer a two-line \"name + value\" label,\n// fall back to a single shrunk-to-fit line, then an ellipsis-truncated line,\n// so text never spills past the bubble's edge regardless of radius.\nconst LABEL_PAD = 0.86;\nconst AVG_CHAR_WIDTH = 0.6;\nconst MIN_FONT = 9;\nconst MAX_FONT = 14;\nfunction fitLabel(name, value, radius) {\n  const usableWidth = radius * 2 * LABEL_PAD;\n  const fits = (text, fontSize) => text.length * fontSize * AVG_CHAR_WIDTH <= usableWidth;\n\n  let fontSize = Math.min(MAX_FONT, Math.max(MIN_FONT, Math.round(radius * 2 * 0.16)));\n  const valueLine = `$${value}K`;\n  const twoLineHeight = fontSize * 1.15 * 2;\n  if (twoLineHeight <= usableWidth && fits(name, fontSize) && fits(valueLine, fontSize)) {\n    return { text: `${name}<br/>${valueLine}`, fontSize };\n  }\n\n  while (fontSize > MIN_FONT && !fits(name, fontSize)) {\n    fontSize -= 1;\n  }\n  if (fits(name, fontSize)) {\n    return { text: name, fontSize };\n  }\n\n  const maxChars = Math.floor(usableWidth / (fontSize * AVG_CHAR_WIDTH));\n  if (maxChars < 3) return null;\n  return { text: `${name.slice(0, maxChars - 1)}…`, fontSize };\n}\n\nconst GROUP_START_RADIUS = 220;\nconst nodes = ITEMS.map((item) => {\n  const groupIndex = GROUPS.indexOf(item.group);\n  const theta = Math.PI / 4 + groupIndex * (Math.PI / 2);\n  const cx = GROUP_START_RADIUS * Math.cos(theta);\n  const cy = GROUP_START_RADIUS * Math.sin(theta);\n  const r = radiusFor(item.value);\n  return {\n    ...item,\n    groupIndex,\n    r,\n    labelPlan: fitLabel(item.label, item.value, r),\n    x: cx + (rand() - 0.5) * 160,\n    y: cy + (rand() - 0.5) * 160,\n  };\n});\n\nconst ITERATIONS = 480;\nconst GROUP_PULL = 0.04;\nconst CENTER_PULL = 0.006;\nconst SAME_GROUP_GAP = 4;\nconst DIFF_GROUP_GAP = 22;\n\nfor (let iter = 0; iter < ITERATIONS; iter += 1) {\n  const centroids = GROUPS.map((_, gi) => {\n    const members = nodes.filter((n) => n.groupIndex === gi);\n    const sx = members.reduce((s, n) => s + n.x, 0) / members.length;\n    const sy = members.reduce((s, n) => s + n.y, 0) / members.length;\n    return { x: sx, y: sy };\n  });\n\n  nodes.forEach((n) => {\n    const c = centroids[n.groupIndex];\n    n.x += (c.x - n.x) * GROUP_PULL;\n    n.y += (c.y - n.y) * GROUP_PULL;\n    n.x += (0 - n.x) * CENTER_PULL;\n    n.y += (0 - n.y) * CENTER_PULL;\n  });\n\n  for (let pass = 0; pass < 3; pass += 1) {\n    for (let i = 0; i < nodes.length; i += 1) {\n      for (let j = i + 1; j < nodes.length; 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        if (dist < 1e-3) {\n          dx = (rand() - 0.5) * 0.5;\n          dy = (rand() - 0.5) * 0.5;\n          dist = Math.sqrt(dx * dx + dy * dy) || 1e-3;\n        }\n        const gap = a.groupIndex === b.groupIndex ? SAME_GROUP_GAP : DIFF_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  }\n}\n\n// Center the packed cluster on the origin using its true bounding box.\nconst minX = Math.min(...nodes.map((n) => n.x - n.r));\nconst maxX = Math.max(...nodes.map((n) => n.x + n.r));\nconst minY = Math.min(...nodes.map((n) => n.y - n.r));\nconst maxY = Math.max(...nodes.map((n) => n.y + n.r));\nconst offsetX = (minX + maxX) / 2;\nconst offsetY = (minY + maxY) / 2;\nnodes.forEach((n) => {\n  n.x -= offsetX;\n  n.y -= offsetY;\n});\nconst clusterWidth = maxX - minX;\nconst clusterHeight = maxY - minY;\n\n// --- Chart -------------------------------------------------------------------\n// Position carries no meaning here — only size (area = spend) and color\n// (team) do. Core bundle has no packedbubble series (that lives in a module\n// that isn't loaded), so the pack above feeds plain \"scatter\" points with a\n// per-point marker.radius, one series per group for a clean color legend.\nconst series = GROUPS.map((group, gi) => ({\n  name: group,\n  type: \"scatter\",\n  color: t.palette[gi],\n  data: nodes\n    .filter((n) => n.groupIndex === gi)\n    .map((n) => ({\n      x: n.x,\n      y: n.y,\n      name: n.label,\n      marker: { radius: n.r },\n      custom: { value: n.value, radius: n.r },\n      dataLabels: n.labelPlan\n        ? { enabled: true, format: n.labelPlan.text, style: { fontSize: `${n.labelPlan.fontSize}px` } }\n        : { enabled: false },\n    })),\n}));\n\nHighcharts.chart(\n  \"container\",\n  {\n    chart: {\n      type: \"scatter\",\n      backgroundColor: \"transparent\",\n      animation: false,\n      style: { fontFamily: \"inherit\" },\n    },\n    credits: { enabled: false },\n    colors: t.palette,\n    title: {\n      text: \"bubble-packed · javascript · highcharts · anyplot.ai\",\n      style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    },\n    subtitle: {\n      text: \"Monthly cloud spend by service ($K) — circle area = spend, color = team\",\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n    },\n    xAxis: { visible: false },\n    yAxis: { visible: false },\n    legend: {\n      enabled: true,\n      layout: \"horizontal\",\n      align: \"center\",\n      verticalAlign: \"bottom\",\n      itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n      itemHoverStyle: { color: t.ink },\n    },\n    tooltip: {\n      useHTML: true,\n      backgroundColor: t.elevatedBg,\n      borderColor: t.grid,\n      style: { color: t.ink, fontSize: \"13px\" },\n      pointFormatter: function pointFormatter() {\n        const swatch =\n          `<span style=\"display:inline-block;width:9px;height:9px;border-radius:50%;` +\n          `background:${this.color};margin-right:5px;\"></span>`;\n        return (\n          `<b>${this.name}</b><br/>${swatch}${this.series.name}<br/>` + `$${this.custom.value}K / month`\n        );\n      },\n    },\n    plotOptions: {\n      series: {\n        animation: false,\n        marker: {\n          symbol: \"circle\",\n          lineColor: t.pageBg,\n          lineWidth: 2,\n          states: { hover: { lineWidthPlus: 2 } },\n        },\n        dataLabels: {\n          style: { color: \"contrast\", textOutline: \"1px contrast\", fontWeight: \"600\" },\n        },\n      },\n    },\n    series,\n  },\n  function onLoad(chart) {\n    // marker.radius is fixed CSS px regardless of axis scale, so spacing may\n    // only ever be spread OUT (scale >= 1) to use the plot area — shrinking\n    // (scale < 1) would pull circle centers closer than their fixed radii\n    // and create real overlaps. The pack was sized to comfortably undershoot\n    // the plot area, so the fitted scale below is expected to land above 1.\n    const fitScale = Math.min(chart.plotWidth / clusterWidth, chart.plotHeight / clusterHeight);\n    const scale = Math.max(1, fitScale * 0.97);\n    const halfW = chart.plotWidth / (2 * scale);\n    const halfH = chart.plotHeight / (2 * scale);\n    chart.xAxis[0].setExtremes(-halfW, halfW, false);\n    chart.yAxis[0].setExtremes(-halfH, halfH, true);\n  },\n);\n"}