{"spec_id":"scatter-categorical","library":"muix","language":"javascript","code":"// anyplot.ai\n// scatter-categorical: Categorical Scatter Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 86/100 | Created: 2026-09-05\n//# anyplot-orientation: landscape\n// anyplot.ai\n// scatter-categorical: Categorical Scatter Plot\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-05\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// Deterministic LCG (seed 42) — no Math.random() in the browser harness\nlet seed = 42;\nfunction rng() {\n  seed = (1664525 * seed + 1013904223) >>> 0;\n  return seed / 4294967296;\n}\nfunction randn() {\n  const u = Math.max(rng(), 1e-9);\n  const v = rng();\n  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n}\n\n// --- Data: penguin body measurements by species — flipper length vs body\n// mass, three overlapping-but-separable clusters (a classic categorical\n// scatter scenario). ---------------------------------------------------------\nconst SPECIES = [\n  {\n    name: \"Adelie\",\n    flipperMean: 190,\n    flipperSd: 6.5,\n    massMean: 3700,\n    massSd: 460,\n    n: 40,\n  },\n  {\n    name: \"Chinstrap\",\n    flipperMean: 196,\n    flipperSd: 7.2,\n    massMean: 3730,\n    massSd: 380,\n    n: 34,\n  },\n  {\n    name: \"Gentoo\",\n    flipperMean: 217,\n    flipperSd: 6.5,\n    massMean: 5090,\n    massSd: 500,\n    n: 38,\n  },\n];\n\nlet pointId = 0;\nconst series = SPECIES.map((sp, i) => ({\n  id: sp.name,\n  label: sp.name,\n  color: t.palette[i],\n  markerSize: 7,\n  // Fade the other two species when one is hovered, showcasing MUI X's\n  // built-in cross-series highlight/fade interaction (purely additive —\n  // has no effect on the static screenshot).\n  highlightScope: { highlight: \"series\", fade: \"global\" },\n  data: Array.from({ length: sp.n }, () => ({\n    x: Math.round(sp.flipperMean + randn() * sp.flipperSd),\n    // Body mass in kg (not g) — keeps y tick labels short (\"3.7\", not\n    // \"3,700\"), which avoids the label colliding with the axis title (MUI X\n    // offsets the y-axis title from a fixed tick-fontsize heuristic, not the\n    // tick label's actual rendered width).\n    y: Math.round(sp.massMean + randn() * sp.massSd) / 1000,\n    id: pointId++,\n  })),\n}));\n\nconst allPoints = series.flatMap((s) => s.data);\nconst xs = allPoints.map((p) => p.x);\nconst ys = allPoints.map((p) => p.y);\nconst xPad = (Math.max(...xs) - Math.min(...xs)) * 0.1;\nconst yPad = (Math.max(...ys) - Math.min(...ys)) * 0.1;\nconst X_MIN = Math.min(...xs) - xPad;\nconst X_MAX = Math.max(...xs) + xPad;\nconst Y_MIN = Math.min(...ys) - yPad;\nconst Y_MAX = Math.max(...ys) + yPad;\n\nconst TITLE = \"scatter-categorical · javascript · muix · anyplot.ai\";\nconst MARGIN = { top: 90, right: 210, bottom: 90, left: 120 };\nconst Y_LABEL_X = 36;\nconst Y_LABEL_Y = MARGIN.top + (height - MARGIN.top - MARGIN.bottom) / 2;\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  return (\n    <ScatterChart\n      width={width}\n      height={height}\n      margin={MARGIN}\n      series={series}\n      skipAnimation\n      grid={{ vertical: true, horizontal: true }}\n      // Scatter markers are the only <circle> elements this chart renders\n      // (legend swatches are <rect>s) — a page-background-colored edge\n      // stroke plus slight fill transparency keeps overlapping Adelie/\n      // Chinstrap points individually distinguishable instead of merging\n      // into solid blobs, in both themes.\n      sx={{\n        \"& circle\": {\n          stroke: t.pageBg,\n          strokeWidth: 1.5,\n          fillOpacity: 0.82,\n        },\n      }}\n      xAxis={[\n        {\n          min: X_MIN,\n          max: X_MAX,\n          label: \"Flipper Length (mm)\",\n          labelStyle: { fontSize: 16, fill: t.ink, fontWeight: 500 },\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          stroke: t.inkSoft,\n        },\n      ]}\n      yAxis={[\n        {\n          min: Y_MIN,\n          max: Y_MAX,\n          valueFormatter: (v) => v.toFixed(1),\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          stroke: t.inkSoft,\n        },\n      ]}\n      legend={{\n        position: { vertical: \"middle\", horizontal: \"right\" },\n        direction: \"column\",\n        labelStyle: { fontSize: 15, fill: t.ink },\n        itemMarkWidth: 14,\n        itemMarkHeight: 14,\n        markGap: 8,\n        itemGap: 16,\n      }}\n    >\n      <text\n        x={width / 2}\n        y={48}\n        textAnchor=\"middle\"\n        fontSize={26}\n        fontWeight={600}\n        fill={t.ink}\n      >\n        {TITLE}\n      </text>\n      {/* Manually placed y-axis title (independent of MUI X's built-in\n          label offset, which is sized off tickFontSize rather than the tick\n          labels' actual rendered width) — guarantees no collision with the\n          tick numbers regardless of their digit count. */}\n      <text\n        x={Y_LABEL_X}\n        y={Y_LABEL_Y}\n        textAnchor=\"middle\"\n        dominantBaseline=\"central\"\n        fontSize={16}\n        fontWeight={500}\n        fill={t.ink}\n        transform={`rotate(-90, ${Y_LABEL_X}, ${Y_LABEL_Y})`}\n      >\n        Body Mass (kg)\n      </text>\n    </ScatterChart>\n  );\n}\n"}