{"spec_id":"bubble-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// bubble-basic: Basic Bubble Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 83/100 | Updated: 2026-09-27\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsGrid } from \"@mui/x-charts/ChartsGrid\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst TITLE = \"bubble-basic · javascript · muix · anyplot.ai\";\n// Explicit callout naming the diagonal the three archetypes trace - without\n// it, the growth/margin correlation across clusters is only an unlabeled\n// color pattern the viewer has to infer for themselves.\nconst SUBTITLE = \"Growth leaders combine the strongest revenue growth and profit margin\";\nconst TITLE_HEIGHT = 84;\nconst MARGIN = { top: 24, right: 210, bottom: 70, left: 90 };\n\n// Semi-transparent fill composites differently over the two page backgrounds:\n// the same alpha reads more saturated over #1A1A17 than over #FAF8F1, even\n// though the underlying Imprint hex values never change. The effect is\n// strongest for the palette's paler hues (lavender) — they already read\n// brighter/more saturated against the near-black surface with no extra alpha\n// at all, so a single flat dark-theme bump over-saturates lavender while\n// barely helping the darker blue, which needs the most help staying visible.\n// Scale the bump per hue instead, inversely to that hue's own luma.\nconst LIGHT_OPACITY = 0.52; // within spec's 0.5-0.7 overlap range\nconst DARK_OPACITY_MAX_BUMP = 0.16;\nfunction relativeLuma(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  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n// --- Data (in-memory, deterministic LCG — no seeded RNG in the browser) -----\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\n\nfunction randomNormal(rand, mean, stdDev) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * stdDev;\n}\n\n// Three loose company archetypes — fast-growing leaders, steady mid-market\n// challengers, and small niche players — so the cloud has real structure\n// instead of a formless blob. Each archetype gets its own hue so the\n// pattern reads immediately instead of hiding in a single-color cloud.\n// Abstract group labels (no semantic color cue), so Imprint positions are\n// used in canonical order 1->3, not cherry-picked.\n// The community package has no bubble/z-size scatter mode (ZAxisConfig\n// only maps z to colour), so bubbles are drawn as a custom SVG layer\n// positioned via the chart's own scale hooks.\n// Spreads are wide enough that the normal distributions rarely produce\n// fully-fused bubbles on their own; fillOpacity plus the pageBg stroke\n// (see spec: \"use transparency to handle overlapping bubbles\") cover the\n// rest, so no separate collision-avoidance pass is needed.\nconst ARCHETYPES = [\n  { name: \"Growth leaders\", growth: 24, margin: 19, share: 62, count: 15, xSpread: 9, ySpread: 7.5, color: t.palette[0] },\n  { name: \"Mid-market\", growth: 12, margin: 10, share: 34, count: 20, xSpread: 7, ySpread: 6, color: t.palette[1] },\n  { name: \"Niche players\", growth: 4, margin: 3, share: 14, count: 15, xSpread: 7, ySpread: 6, color: t.palette[2] },\n];\n\n// Per-archetype opacity (see the luma-scaled dark-theme bump above): the\n// palest hue in play gets almost none of the bump, the darkest gets the most.\nconst archetypeLumas = ARCHETYPES.map((a) => relativeLuma(a.color));\nconst minArchetypeLuma = Math.min(...archetypeLumas);\nconst maxArchetypeLuma = Math.max(...archetypeLumas);\nARCHETYPES.forEach((a, i) => {\n  // Growth leaders is the densest cluster (largest mean market share, so the\n  // biggest bubbles land closest together) - the previous review flagged\n  // heavy bubble-on-bubble fusion there. A thicker pageBg stroke keeps\n  // individual boundaries readable without dropping opacity below the\n  // spec's 0.5-0.7 floor.\n  a.strokeWidth = i === 0 ? 3 : 2;\n  if (window.ANYPLOT_THEME !== \"dark\") {\n    a.opacity = LIGHT_OPACITY;\n    return;\n  }\n  const paleness =\n    maxArchetypeLuma === minArchetypeLuma\n      ? 0\n      : (archetypeLumas[i] - minArchetypeLuma) / (maxArchetypeLuma - minArchetypeLuma);\n  a.opacity = LIGHT_OPACITY + DARK_OPACITY_MAX_BUMP * (1 - paleness);\n});\n\nconst rand = lcg(42);\n\nconst companies = ARCHETYPES.flatMap((a, groupIndex) =>\n  Array.from({ length: a.count }, (_, i) => ({\n    id: `${groupIndex}-${i}`,\n    x: Math.round(randomNormal(rand, a.growth, a.xSpread) * 10) / 10,\n    y: Math.round(randomNormal(rand, a.margin, a.ySpread) * 10) / 10,\n    size: Math.min(100, Math.max(10, Math.round(randomNormal(rand, a.share, 18)))),\n    color: a.color,\n    opacity: a.opacity,\n    strokeWidth: a.strokeWidth,\n  })),\n);\n\nconst sizeValues = companies.map((d) => d.size);\nconst sizeMin = Math.min(...sizeValues);\nconst sizeMax = Math.max(...sizeValues);\n\n// Scale bubbles by AREA, not radius — otherwise size differences read as\n// far more extreme than the underlying data actually is. Capped tighter\n// than the raw 10-100 domain would suggest so the densest cluster\n// (growth ~20-30%, margin ~15-25%) stays legible instead of fusing together.\nconst MIN_RADIUS = 7;\nconst MAX_RADIUS = 38;\nfunction radiusForSize(value) {\n  const ratio = (value - sizeMin) / (sizeMax - sizeMin);\n  return MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(Math.max(0, ratio));\n}\n\n// Legend geometry, computed once so the two legend blocks can be vertically\n// centered against the chart instead of pinned at fixed heights that leave a\n// visible gap of unused whitespace beneath them on tall canvases.\nconst LEGEND_GAP = 56;\nconst SIZE_LEGEND_VALUES = [\n  Math.round(sizeMax / 10) * 10,\n  Math.round((sizeMin + sizeMax) / 2 / 10) * 10,\n  Math.round(sizeMin / 10) * 10,\n];\nconst COLOR_LEGEND_HEIGHT = 20 + (ARCHETYPES.length - 1) * 24 + 12;\nconst SIZE_LEGEND_HEIGHT =\n  20 + SIZE_LEGEND_VALUES.reduce((height, value) => height + radiusForSize(value) * 2 + 16, 0);\n\nconst xValues = companies.map((d) => d.x);\nconst yValues = companies.map((d) => d.y);\nconst xDomain = [Math.min(...xValues) - 4, Math.max(...xValues) + 4];\nconst yDomain = [Math.min(...yValues) - 4, Math.max(...yValues) + 4];\n\n// --- Bubbles (reads the chart's live x/y scales via context hooks) ---------\nfunction Bubbles() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  return (\n    <g>\n      {companies.map((d) => (\n        <circle\n          key={d.id}\n          cx={xScale(d.x)}\n          cy={yScale(d.y)}\n          r={radiusForSize(d.size)}\n          fill={d.color}\n          fillOpacity={d.opacity}\n          stroke={t.pageBg}\n          strokeWidth={d.strokeWidth}\n        />\n      ))}\n    </g>\n  );\n}\n\n// --- Color legend — names the three archetypes so the clustering reads as\n// an insight instead of something the viewer has to discover unaided -------\nfunction ColorLegend({ left, top }) {\n  return (\n    <g>\n      <text x={left} y={top - 20} fontSize={14} fontWeight={600} fill={t.inkSoft}>\n        Company archetype\n      </text>\n      {ARCHETYPES.map((a, i) => {\n        const cy = top + i * 24;\n        return (\n          <g key={a.name}>\n            <circle cx={left + 6} cy={cy} r={6} fill={a.color} fillOpacity={a.opacity} stroke={a.color} strokeWidth={1.5} />\n            <text x={left + 20} y={cy} dominantBaseline=\"middle\" fontSize={14} fill={t.inkSoft}>\n              {a.name}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Size legend — three reference bubbles explain the size scaling --------\nfunction SizeLegend({ left, top }) {\n  let cursorY = top;\n\n  return (\n    <g>\n      <text x={left} y={top - 20} fontSize={14} fontWeight={600} fill={t.inkSoft}>\n        Market share index\n      </text>\n      {SIZE_LEGEND_VALUES.map((value) => {\n        const r = radiusForSize(value);\n        cursorY += r + 10;\n        const cy = cursorY;\n        cursorY += r + 16;\n        return (\n          <g key={value}>\n            <circle\n              cx={left + MAX_RADIUS}\n              cy={cy}\n              r={r}\n              fill=\"none\"\n              stroke={t.inkSoft}\n              strokeWidth={1.5}\n            />\n            <text\n              x={left + MAX_RADIUS * 2 + 14}\n              y={cy}\n              dominantBaseline=\"middle\"\n              fontSize={14}\n              fill={t.inkSoft}\n            >\n              {value}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -----------\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const chartHeight = height - TITLE_HEIGHT;\n  const margin = MARGIN;\n\n  // Center the two-part legend against the chart height instead of pinning\n  // it near the top, so it no longer leaves a tall unused gap underneath.\n  const legendBlockHeight = COLOR_LEGEND_HEIGHT + LEGEND_GAP + SIZE_LEGEND_HEIGHT;\n  const legendBlockTop = Math.max(44, (chartHeight - legendBlockHeight) / 2);\n  const colorLegendTop = legendBlockTop + 20;\n  const sizeLegendTop = legendBlockTop + COLOR_LEGEND_HEIGHT + LEGEND_GAP + 20;\n\n  return (\n    <div style={{ width, height }}>\n      <div style={{ height: TITLE_HEIGHT, paddingLeft: 24, paddingTop: 14, color: t.ink }}>\n        <div style={{ fontSize: 26, fontWeight: 500, lineHeight: \"32px\" }}>{TITLE}</div>\n        <div style={{ fontSize: 15, fontWeight: 400, lineHeight: \"20px\", marginTop: 4, color: t.inkSoft }}>\n          {SUBTITLE}\n        </div>\n      </div>\n      <ChartContainer\n        width={width}\n        height={chartHeight}\n        series={[]}\n        skipAnimation\n        margin={margin}\n        xAxis={[\n          {\n            id: \"growth\",\n            min: xDomain[0],\n            max: xDomain[1],\n            label: \"Revenue growth rate (%)\",\n            labelStyle: { fontSize: 16 },\n            tickLabelStyle: { fontSize: 14 },\n          },\n        ]}\n        yAxis={[\n          {\n            id: \"margin\",\n            min: yDomain[0],\n            max: yDomain[1],\n            label: \"Profit margin (%)\",\n            labelStyle: { fontSize: 16 },\n            tickLabelStyle: { fontSize: 14 },\n          },\n        ]}\n      >\n        <ChartsGrid horizontal vertical />\n        <Bubbles />\n        <ChartsXAxis axisId=\"growth\" />\n        <ChartsYAxis axisId=\"margin\" />\n        <ColorLegend left={width - margin.right + 24} top={colorLegendTop} />\n        <SizeLegend left={width - margin.right + 24} top={sizeLegendTop} />\n      </ChartContainer>\n    </div>\n  );\n}\n"}