{"spec_id":"bubble-packed","library":"muix","language":"javascript","code":"// anyplot.ai\n// bubble-packed: Basic Packed Bubble Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 82/100 | Created: 2026-08-24\n//# anyplot-orientation: square\n// anyplot.ai\n// bubble-packed: Basic Packed Bubble Chart\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: 79/100 | Created: 2026-08-24\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width: SIZE_W, height: SIZE_H } = window.ANYPLOT_SIZE;\nconst TITLE = \"bubble-packed · javascript · muix · anyplot.ai\";\n\n// --- Data (in-memory, hard-coded and deterministic) -------------------------\n// Product-line revenue ($M) across four divisions of a fictional electronics\n// group. Circle size encodes revenue; color encodes division.\nconst DIVISIONS = [\n  { key: \"consumer\", name: \"Consumer Electronics\", color: t.palette[0] },\n  { key: \"software\", name: \"Software & Cloud\", color: t.palette[1] },\n  { key: \"industrial\", name: \"Industrial Equipment\", color: t.palette[2] },\n  { key: \"health\", name: \"Health & Wearables\", color: t.palette[3] },\n];\n\nconst PRODUCTS = [\n  { name: \"Smart Speakers\", division: \"consumer\", revenue: 42 },\n  { name: \"Wireless Earbuds\", division: \"consumer\", revenue: 68 },\n  { name: \"4K Televisions\", division: \"consumer\", revenue: 55 },\n  { name: \"Home Routers\", division: \"consumer\", revenue: 24 },\n  { name: \"Streaming Devices\", division: \"consumer\", revenue: 31 },\n  { name: \"Digital Cameras\", division: \"consumer\", revenue: 18 },\n  { name: \"Gaming Consoles\", division: \"consumer\", revenue: 74 },\n  { name: \"Tablets\", division: \"consumer\", revenue: 47 },\n  { name: \"Laptops\", division: \"consumer\", revenue: 89 },\n  { name: \"Cloud Storage\", division: \"software\", revenue: 95 },\n  { name: \"Productivity Suite\", division: \"software\", revenue: 61 },\n  { name: \"Video Conferencing\", division: \"software\", revenue: 38 },\n  { name: \"Cybersecurity Tools\", division: \"software\", revenue: 52 },\n  { name: \"Analytics Platform\", division: \"software\", revenue: 44 },\n  { name: \"CRM Software\", division: \"software\", revenue: 33 },\n  { name: \"Developer Tools\", division: \"software\", revenue: 27 },\n  { name: \"AI Assistant\", division: \"software\", revenue: 58 },\n  { name: \"Collaboration Suite\", division: \"software\", revenue: 29 },\n  { name: \"Robotic Arms\", division: \"industrial\", revenue: 71 },\n  { name: \"CNC Machines\", division: \"industrial\", revenue: 48 },\n  { name: \"Conveyor Systems\", division: \"industrial\", revenue: 22 },\n  { name: \"Industrial Sensors\", division: \"industrial\", revenue: 36 },\n  { name: \"3D Printers\", division: \"industrial\", revenue: 19 },\n  { name: \"Hydraulic Presses\", division: \"industrial\", revenue: 41 },\n  { name: \"Welding Robots\", division: \"industrial\", revenue: 53 },\n  { name: \"Packaging Machines\", division: \"industrial\", revenue: 26 },\n  { name: \"Quality Scanners\", division: \"industrial\", revenue: 15 },\n  { name: \"Fitness Trackers\", division: \"health\", revenue: 39 },\n  { name: \"Smartwatches\", division: \"health\", revenue: 62 },\n  { name: \"Blood Pressure Monitors\", division: \"health\", revenue: 21 },\n  { name: \"Glucose Monitors\", division: \"health\", revenue: 34 },\n  { name: \"Sleep Trackers\", division: \"health\", revenue: 17 },\n  { name: \"Posture Sensors\", division: \"health\", revenue: 12 },\n  { name: \"Hearing Aids\", division: \"health\", revenue: 45 },\n  { name: \"Smart Scales\", division: \"health\", revenue: 14 },\n  { name: \"Recovery Devices\", division: \"health\", revenue: 23 },\n];\n\nconst divisionByKey = Object.fromEntries(DIVISIONS.map((d) => [d.key, d]));\n\n// --- Per-division label ink, chosen for WCAG contrast against the bubble's OWN\n// fill color (not the page theme) so on-bubble text clears 4.5:1 AA in both\n// themes regardless of how saturated/light the division hue is. -------------\nconst DARK_TEXT = \"#1A1A17\";\nconst LIGHT_TEXT = \"#FAF8F1\";\nfunction relativeLuminance(hex) {\n  const n = hex.replace(\"#\", \"\");\n  const channel = (v) => {\n    const c = parseInt(v, 16) / 255;\n    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n  };\n  const r = channel(n.slice(0, 2));\n  const g = channel(n.slice(2, 4));\n  const b = channel(n.slice(4, 6));\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\nfunction contrastRatio(l1, l2) {\n  const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];\n  return (hi + 0.05) / (lo + 0.05);\n}\nfunction bestLabelInk(fillHex) {\n  const fillLum = relativeLuminance(fillHex);\n  const darkContrast = contrastRatio(fillLum, relativeLuminance(DARK_TEXT));\n  const lightContrast = contrastRatio(fillLum, relativeLuminance(LIGHT_TEXT));\n  return darkContrast >= lightContrast ? DARK_TEXT : LIGHT_TEXT;\n}\nDIVISIONS.forEach((d) => {\n  d.labelInk = bestLabelInk(d.color);\n});\n\n// --- Layout geometry (local to the ChartContainer surface, below the title) -\nconst TITLE_HEIGHT = 56;\nconst LEGEND_HEIGHT = 40;\nconst MARGIN = 24;\nconst surfaceHeight = SIZE_H - TITLE_HEIGHT;\nconst chartLeft = MARGIN;\nconst chartTop = LEGEND_HEIGHT + 16;\nconst chartWidth = SIZE_W - 2 * MARGIN;\nconst chartHeight = surfaceHeight - chartTop - MARGIN;\n\n// --- Circle radii, scaled by AREA so revenue differences read accurately ----\n// The fill ratio leaves headroom for the packing simulation below to settle\n// without heavy overlap correction — clustered packed bubbles rarely exceed\n// ~45% of the bounding rectangle once inter-group spacing is accounted for.\nconst FILL_RATIO = 0.42;\nconst totalRevenue = PRODUCTS.reduce((sum, p) => sum + p.revenue, 0);\nconst radiusScale = Math.sqrt((FILL_RATIO * chartWidth * chartHeight) / (Math.PI * totalRevenue));\n\nconst bubbles = PRODUCTS.map((p) => ({ ...p, r: radiusScale * Math.sqrt(p.revenue) }));\n\n// --- Force-directed packing: pull each bubble toward its division's anchor,\n// then resolve overlaps iteratively — a small hand-rolled version of the\n// classic bubble-chart force simulation the spec calls for. The community\n// package has no circle-packing layout of its own, so the geometry is\n// computed here rather than reached for from another charting library. -----\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst anchors = {\n  consumer: { x: chartLeft + chartWidth * 0.27, y: chartTop + chartHeight * 0.28 },\n  software: { x: chartLeft + chartWidth * 0.73, y: chartTop + chartHeight * 0.28 },\n  industrial: { x: chartLeft + chartWidth * 0.27, y: chartTop + chartHeight * 0.75 },\n  health: { x: chartLeft + chartWidth * 0.73, y: chartTop + chartHeight * 0.75 },\n};\n\nbubbles.forEach((b) => {\n  const anchor = anchors[b.division];\n  b.x = anchor.x + (rand() - 0.5) * 60;\n  b.y = anchor.y + (rand() - 0.5) * 60;\n});\n\nconst PADDING = 5;\nconst ATTRACTION = 0.03;\nconst ITERATIONS = 400;\nfor (let iter = 0; iter < ITERATIONS; iter++) {\n  for (const b of bubbles) {\n    const anchor = anchors[b.division];\n    b.x += (anchor.x - b.x) * ATTRACTION;\n    b.y += (anchor.y - b.y) * ATTRACTION;\n  }\n  for (let i = 0; i < bubbles.length; i++) {\n    for (let j = i + 1; j < bubbles.length; j++) {\n      const a = bubbles[i];\n      const c = bubbles[j];\n      const dx = c.x - a.x;\n      const dy = c.y - a.y;\n      const dist = Math.hypot(dx, dy) || 0.01;\n      const minDist = a.r + c.r + PADDING;\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        c.x += ux * overlap;\n        c.y += uy * overlap;\n      }\n    }\n  }\n  for (const b of bubbles) {\n    b.x = Math.min(chartLeft + chartWidth - b.r, Math.max(chartLeft + b.r, b.x));\n    b.y = Math.min(chartTop + chartHeight - b.r, Math.max(chartTop + b.r, b.y));\n  }\n}\n\n// Truncate on a word boundary so a cut lands after a whole word (\"Productivity…\")\n// rather than mid-word (\"Productivity Su…\"); when no whole word fits, fall back\n// to initials (\"PS\") instead of an unreadable 2-3 letter fragment.\nfunction initials(label) {\n  return label\n    .split(\" \")\n    .map((w) => w[0])\n    .join(\"\");\n}\nfunction truncate(label, maxChars) {\n  if (label.length <= maxChars) return label;\n  const cut = label.slice(0, maxChars);\n  const lastSpace = cut.lastIndexOf(\" \");\n  if (lastSpace >= 3) return `${cut.slice(0, lastSpace)}…`;\n  return maxChars >= 6 ? `${cut}…` : initials(label);\n}\n\n// --- Legend (division colors) -----------------------------------------------\nfunction Legend() {\n  const itemWidth = chartWidth / DIVISIONS.length;\n  const cy = LEGEND_HEIGHT / 2;\n  return (\n    <g>\n      {DIVISIONS.map((d, i) => {\n        const cx = chartLeft + itemWidth * i + 10;\n        return (\n          <g key={d.key}>\n            <circle cx={cx} cy={cy} r={7} fill={d.color} />\n            <text x={cx + 16} y={cy} dominantBaseline=\"middle\" fontSize={14} fill={t.inkSoft}>\n              {d.name}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Bubbles: circle area encodes revenue, fill color encodes division -----\nfunction Bubbles() {\n  return (\n    <g>\n      {bubbles.map((b) => {\n        const division = divisionByKey[b.division];\n        const nameFontSize = Math.max(10, Math.min(15, b.r * 0.2));\n        const valueFontSize = Math.max(9, nameFontSize * 0.85);\n        const showName = b.r >= 52;\n        const showValue = b.r >= 36;\n        const maxChars = Math.max(4, Math.floor((b.r * 1.7) / (nameFontSize * 0.55)));\n        return (\n          <g key={b.name}>\n            <circle\n              cx={b.x}\n              cy={b.y}\n              r={b.r}\n              fill={division.color}\n              fillOpacity={0.95}\n              stroke={t.pageBg}\n              strokeWidth={2}\n            >\n              <title>{`${b.name} (${division.name}): $${b.revenue}M`}</title>\n            </circle>\n            {showName && (\n              <text\n                x={b.x}\n                y={b.y - nameFontSize * 0.35}\n                textAnchor=\"middle\"\n                fontSize={nameFontSize}\n                fontWeight={600}\n                fill={division.labelInk}\n              >\n                {truncate(b.name, maxChars)}\n              </text>\n            )}\n            {showValue && (\n              <text\n                x={b.x}\n                y={b.y + (showName ? nameFontSize * 1.1 : 4)}\n                textAnchor=\"middle\"\n                fontSize={valueFontSize}\n                fill={division.labelInk}\n              >\n                {`$${b.revenue}M`}\n              </text>\n            )}\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -----------\nexport default function Chart() {\n  return (\n    <div style={{ width: SIZE_W, height: SIZE_H }}>\n      <div\n        style={{\n          height: TITLE_HEIGHT,\n          lineHeight: `${TITLE_HEIGHT}px`,\n          paddingLeft: MARGIN,\n          fontSize: 22,\n          fontWeight: 500,\n          color: t.ink,\n        }}\n      >\n        {TITLE}\n      </div>\n      <ChartContainer\n        width={SIZE_W}\n        height={surfaceHeight}\n        series={[]}\n        skipAnimation\n        margin={{ top: 0, right: 0, bottom: 0, left: 0 }}\n      >\n        <Legend />\n        <Bubbles />\n      </ChartContainer>\n    </div>\n  );\n}\n"}