{"spec_id":"bubble-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// bubble-basic: Basic Bubble Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.3\n// Quality: 82/100 | Updated: 2026-09-30\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function () {\n    state = (1103515245 * state + 12345) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\n\nconst N = 70;\nconst products = [];\nfor (let i = 0; i < N; i++) {\n  const price = 15 + rand() * 205; // $15 - $220\n  const noise = (rand() - 0.5) * 4;\n  const quality = Math.min(9.8, Math.max(2.0, 2.5 + (price / 220) * 4 + noise));\n  const salesVolume = 10 + rand() * 90; // 10-100 units sold per month\n  products.push({ price, quality, salesVolume });\n}\n\nconst sizeValues = products.map((p) => p.salesVolume);\nconst sizeMin = Math.min(...sizeValues);\nconst sizeMax = Math.max(...sizeValues);\n// Wider radius spread than a smaller range would give, keeping bubbles in the\n// dense $80-140 cluster individuated by size alone.\nconst R_MIN = 6;\nconst R_MAX = 42;\n\nfunction bubbleRadius(size) {\n  // area-proportional (not radius-proportional) to avoid overstating large values\n  const frac = (size - sizeMin) / (sizeMax - sizeMin);\n  const area = R_MIN * R_MIN + frac * (R_MAX * R_MAX - R_MIN * R_MIN);\n  return Math.sqrt(area);\n}\n\nfunction hexToRgba(hex, alpha) {\n  const n = parseInt(hex.slice(1), 16);\n  const r = (n >> 16) & 255;\n  const g = (n >> 8) & 255;\n  const b = n & 255;\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// Draw largest bubbles first so smaller ones render on top of them instead of\n// being buried underneath — meaningfully improves individuation in the dense\n// $80-140 cluster where same-size bubbles previously stacked in insert order.\nconst orderedProducts = [...products].sort(\n  (a, b) => b.salesVolume - a.salesVolume,\n);\nconst bubbleData = orderedProducts.map((p) => ({\n  x: p.price,\n  y: p.quality,\n  r: bubbleRadius(p.salesVolume),\n}));\n\n// --- Size legend plugin (static key explaining the bubble-area encoding) ---\n// Drawn as an elevated card (ELEVATED_BG + thin rule) rather than bare text\n// floating over the plot area, matching the style guide's callout-box role.\nconst legendValues = [sizeMin, (sizeMin + sizeMax) / 2, sizeMax];\nconst sizeLegend = {\n  id: \"sizeLegend\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    // Offset clear of the y-axis tick-label gutter so the card never\n    // overlaps the \"10\"/\"9\" labels now that the axis is pinned to 1-10.\n    const left = chartArea.left + 56;\n    const cx = left + R_MAX + 24;\n    const spacing = 2 * R_MAX + 20;\n    const panelX = left - 16;\n    const panelY = chartArea.top - 4;\n    const panelW = 2 * (R_MAX + 12) + 90;\n    const panelH = 54 + 2 * R_MAX + (legendValues.length - 1) * spacing;\n    ctx.save();\n    ctx.fillStyle = t.elevatedBg;\n    ctx.beginPath();\n    ctx.roundRect(panelX, panelY, panelW, panelH, 10);\n    ctx.fill();\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    ctx.stroke();\n    // Bold, higher-contrast header outranks the regular-weight value labels —\n    // a clearer typographic hierarchy than a flat single-weight legend.\n    ctx.font = \"bold 16px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"Monthly sales (units)\", left, chartArea.top + 16);\n    legendValues.forEach((val, i) => {\n      const r = bubbleRadius(val);\n      const cy = chartArea.top + 36 + R_MAX + i * spacing;\n      ctx.beginPath();\n      ctx.arc(cx, cy, r, 0, Math.PI * 2);\n      ctx.fillStyle = hexToRgba(t.palette[0], 0.35);\n      ctx.fill();\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = t.inkSoft;\n      ctx.stroke();\n      ctx.font = \"16px sans-serif\";\n      ctx.fillStyle = t.inkSoft;\n      ctx.fillText(`${Math.round(val)}`, cx + R_MAX + 12, cy + 5);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bubble\",\n  data: {\n    datasets: [\n      {\n        label: \"Products\",\n        data: bubbleData,\n        // A single ink stroke (not page-bg-matched) so overlapping bubbles in\n        // the dense clusters stay separable from each other, not only from\n        // the page; alpha keeps every bubble visible through overlap.\n        backgroundColor: hexToRgba(t.palette[0], 0.55),\n        borderColor: hexToRgba(t.ink, 0.65),\n        borderWidth: 1.5,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"bubble-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label: (ctx) => {\n            const p = orderedProducts[ctx.dataIndex];\n            return `Price $${p.price.toFixed(0)} · Quality ${p.quality.toFixed(1)} · Sales ${Math.round(p.salesVolume)} units`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (val) => `$${val}`,\n        },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Price ($)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        min: 1,\n        max: 10,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Quality Rating (1–10)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [sizeLegend],\n});\n"}