{"spec_id":"bubble-packed","library":"echarts","language":"javascript","code":"// anyplot.ai\n// bubble-packed: Basic Packed Bubble Chart\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-08-24\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width: W, height: H } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Global app-store revenue by category, $M / year\nconst categories = [\n  { label: \"Games\", value: 1250 },\n  { label: \"Social Networking\", value: 640 },\n  { label: \"Entertainment\", value: 520 },\n  { label: \"Photo & Video\", value: 410 },\n  { label: \"Health & Fitness\", value: 350 },\n  { label: \"Music\", value: 300 },\n  { label: \"Productivity\", value: 260 },\n  { label: \"Education\", value: 220 },\n  { label: \"Finance\", value: 195 },\n  { label: \"Shopping\", value: 175 },\n  { label: \"Business\", value: 150 },\n  { label: \"Lifestyle\", value: 120 },\n  { label: \"Utilities\", value: 95 },\n  { label: \"Travel\", value: 70 },\n  { label: \"Food & Drink\", value: 50 },\n  { label: \"Weather\", value: 28 },\n];\n\n// --- Layout geometry (square working area for a distortion-free pack) -------\nconst titleZone = Math.round(H * 0.16);\nconst sideMargin = Math.round(W * 0.05);\nconst bottomMargin = Math.round(H * 0.05);\nconst boxSize = Math.min(W - sideMargin * 2, H - titleZone - bottomMargin);\nconst gridLeft = Math.round((W - boxSize) / 2);\nconst gridTop = H - bottomMargin - boxSize;\n\n// --- Circle packing (deterministic centering + collision simulation) -------\n// No native packing series in ECharts — circles are laid out here in pixel\n// units that match the square grid 1:1, then drawn with a plain scatter\n// series (symbolSize = 2 * radius, position = simulated (x, y)).\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\nconst maxValue = Math.max(...categories.map((c) => c.value));\nconst radiusScale = boxSize * 0.15 / Math.sqrt(maxValue);\nconst radii = categories.map((c) => radiusScale * Math.sqrt(c.value));\n\nconst n = categories.length;\nconst cx = boxSize / 2;\nconst cy = boxSize / 2;\nconst positions = categories.map((_, i) => {\n  const angle = (i / n) * Math.PI * 2;\n  const dist = boxSize * 0.28 * (0.6 + rand() * 0.4);\n  return { x: cx + Math.cos(angle) * dist, y: cy + Math.sin(angle) * dist };\n});\n\nconst PADDING = 6;\nconst ITERATIONS = 500;\nfor (let iter = 0; iter < ITERATIONS; iter++) {\n  for (let i = 0; i < n; i++) {\n    positions[i].x += (cx - positions[i].x) * 0.02;\n    positions[i].y += (cy - positions[i].y) * 0.02;\n  }\n  for (let i = 0; i < n; i++) {\n    for (let j = i + 1; j < n; j++) {\n      const dx = positions[j].x - positions[i].x;\n      const dy = positions[j].y - positions[i].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) || 0.001;\n      const minDist = radii[i] + radii[j] + PADDING;\n      if (dist < minDist) {\n        const overlap = (minDist - dist) / 2;\n        const nx = dx / dist;\n        const ny = dy / dist;\n        positions[i].x -= nx * overlap;\n        positions[i].y -= ny * overlap;\n        positions[j].x += nx * overlap;\n        positions[j].y += ny * overlap;\n      }\n    }\n  }\n  for (let i = 0; i < n; i++) {\n    const r = radii[i];\n    positions[i].x = Math.min(boxSize - r, Math.max(r, positions[i].x));\n    positions[i].y = Math.min(boxSize - r, Math.max(r, positions[i].y));\n  }\n}\n\n// The weak centering force leaves a residual drift after finite iterations —\n// re-center the whole blob's bounding box onto the box center so it doesn't\n// settle off-axis and waste canvas on one side.\nfunction boundingBox() {\n  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n  for (let i = 0; i < n; i++) {\n    minX = Math.min(minX, positions[i].x - radii[i]);\n    maxX = Math.max(maxX, positions[i].x + radii[i]);\n    minY = Math.min(minY, positions[i].y - radii[i]);\n    maxY = Math.max(maxY, positions[i].y + radii[i]);\n  }\n  return { minX, maxX, minY, maxY };\n}\n\nlet bbox = boundingBox();\nconst offsetX = cx - (bbox.minX + bbox.maxX) / 2;\nconst offsetY = cy - (bbox.minY + bbox.maxY) / 2;\nfor (let i = 0; i < n; i++) {\n  positions[i].x += offsetX;\n  positions[i].y += offsetY;\n}\n\n// Centering alone leaves a loosely-packed cluster far smaller than the box\n// (collision padding + a weak central pull don't compact it fully) — scale\n// the whole blob up around its own center so it fills most of the working\n// square instead of leaving a wide, evenly-distributed margin on every side.\nbbox = boundingBox();\nconst FILL_RATIO = 0.92;\nconst fillScale = (boxSize * FILL_RATIO) / Math.max(bbox.maxX - bbox.minX, bbox.maxY - bbox.minY);\nfor (let i = 0; i < n; i++) {\n  positions[i].x = cx + (positions[i].x - cx) * fillScale;\n  positions[i].y = cy + (positions[i].y - cy) * fillScale;\n  radii[i] *= fillScale;\n}\n\n// Fill luminance decides label ink so text stays legible on every hue.\nfunction labelColorFor(hex) {\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  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n  return luminance > 0.55 ? \"#1A1A17\" : \"#FFFDF6\";\n}\n\n// Lighten a hex color toward white for the radial-gradient sphere highlight.\nfunction lighten(hex, amount) {\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  const mix = (c) => Math.round(c + (255 - c) * amount);\n  return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`;\n}\n\nconst LABEL_MIN_RADIUS = boxSize * 0.045;\nconst bubbleData = categories.map((c, i) => {\n  const r = radii[i];\n  const fill = t.palette[i % t.palette.length];\n  // Past the first 8 categories the palette recycles hues; give that second\n  // lap a dashed ring so a same-colored, unlabeled small circle stays\n  // distinguishable from its first-lap sibling without relying on the label.\n  const recycled = i >= t.palette.length;\n  return {\n    name: c.label,\n    value: [positions[i].x, positions[i].y, c.value],\n    symbolSize: r * 2,\n    itemStyle: {\n      // Subtle radial highlight (light source top-left) gives the flat fill\n      // a touch of sphere-like depth instead of a plain solid disc.\n      color: new echarts.graphic.RadialGradient(0.3, 0.3, 0.75, [\n        { offset: 0, color: lighten(fill, 0.22) },\n        { offset: 1, color: fill },\n      ]),\n      borderColor: t.pageBg,\n      borderWidth: recycled ? 3 : 2,\n      borderType: recycled ? \"dashed\" : \"solid\",\n      opacity: 0.92,\n    },\n    label: {\n      show: r > LABEL_MIN_RADIUS,\n      position: \"inside\",\n      formatter: c.label,\n      color: labelColorFor(fill),\n      fontSize: Math.max(11, Math.min(20, r * 0.18)),\n      fontWeight: 500,\n      overflow: \"truncate\",\n      width: r * 1.7,\n    },\n    emphasis: { itemStyle: { opacity: 1 } },\n  };\n});\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"bubble-packed · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: Math.round(H * 0.045),\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },\n  },\n  tooltip: {\n    formatter: (params) => `${params.name}<br/>$${params.value[2].toLocaleString()}M`,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    textStyle: { color: t.ink },\n  },\n  grid: { left: gridLeft, top: gridTop, width: boxSize, height: boxSize },\n  xAxis: { type: \"value\", min: 0, max: boxSize, show: false },\n  yAxis: { type: \"value\", min: 0, max: boxSize, show: false },\n  series: [\n    {\n      type: \"scatter\",\n      symbol: \"circle\",\n      data: bubbleData,\n    },\n  ],\n});\n"}