{"spec_id":"network-bipartite","library":"muix","language":"javascript","code":"// anyplot.ai\n// network-bipartite: Bipartite Network Graph\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// network-bipartite: Bipartite Network Graph\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\n\nimport { useState } from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsLegend } from \"@mui/x-charts/ChartsLegend\";\nimport { useXScale, useYScale, useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst TITLE = \"network-bipartite · javascript · muix · anyplot.ai\";\n\n// --- Data: student-course enrollment network (in-memory, deterministic) ----\n// Bipartite: every edge connects a student (set A) to a course (set B) —\n// never student-student or course-course. Weight = weekly contact hours.\n// \"Talia Novak\" and \"Advanced Robotics\" carry no edges on purpose, to show\n// the isolated-node pattern the spec calls out.\nconst STUDENTS = [\n  \"Ava Chen\", \"Liam Brooks\", \"Noor Malik\", \"Ethan Diaz\", \"Priya Nair\",\n  \"Marcus Lee\", \"Sofia Reyes\", \"Jamal Carter\", \"Elena Popov\", \"Diego Silva\",\n  \"Grace Kim\", \"Omar Haddad\", \"Isla Fraser\", \"Victor Alves\", \"Talia Novak\",\n];\n\nconst COURSES = [\n  \"Linear Algebra\", \"Data Structures\", \"Organic Chemistry\", \"Microeconomics\",\n  \"Cell Biology\", \"Machine Learning\", \"Thermodynamics\", \"World History\",\n  \"Statistics\", \"Digital Design\", \"Advanced Robotics\",\n];\n\nconst EDGES = [\n  { student: 0, course: 0, hours: 4 },\n  { student: 0, course: 1, hours: 5 },\n  { student: 0, course: 5, hours: 3 },\n  { student: 0, course: 8, hours: 3 },\n  { student: 1, course: 1, hours: 5 },\n  { student: 1, course: 5, hours: 4 },\n  { student: 1, course: 9, hours: 3 },\n  { student: 2, course: 2, hours: 6 },\n  { student: 2, course: 4, hours: 4 },\n  { student: 2, course: 8, hours: 3 },\n  { student: 3, course: 3, hours: 4 },\n  { student: 3, course: 7, hours: 2 },\n  { student: 3, course: 8, hours: 4 },\n  { student: 4, course: 2, hours: 4 },\n  { student: 4, course: 4, hours: 5 },\n  { student: 4, course: 8, hours: 3 },\n  { student: 5, course: 0, hours: 3 },\n  { student: 5, course: 6, hours: 5 },\n  { student: 5, course: 8, hours: 3 },\n  { student: 6, course: 3, hours: 3 },\n  { student: 6, course: 7, hours: 3 },\n  { student: 7, course: 1, hours: 4 },\n  { student: 7, course: 5, hours: 5 },\n  { student: 7, course: 9, hours: 4 },\n  { student: 8, course: 0, hours: 4 },\n  { student: 8, course: 6, hours: 4 },\n  { student: 9, course: 3, hours: 5 },\n  { student: 9, course: 7, hours: 3 },\n  { student: 9, course: 8, hours: 2 },\n  { student: 10, course: 4, hours: 4 },\n  { student: 10, course: 5, hours: 3 },\n  { student: 10, course: 8, hours: 4 },\n  { student: 11, course: 1, hours: 3 },\n  { student: 11, course: 9, hours: 5 },\n  { student: 12, course: 2, hours: 5 },\n  { student: 12, course: 4, hours: 3 },\n  { student: 13, course: 0, hours: 3 },\n  { student: 13, course: 6, hours: 3 },\n  { student: 13, course: 9, hours: 3 },\n];\n\nconst MIN_HOURS = Math.min(...EDGES.map((e) => e.hours));\nconst MAX_HOURS = Math.max(...EDGES.map((e) => e.hours));\n\nconst studentDegree = STUDENTS.map(() => 0);\nconst courseDegree = COURSES.map(() => 0);\nEDGES.forEach((e) => {\n  studentDegree[e.student] += 1;\n  courseDegree[e.course] += 1;\n});\n\nconst studentNeighbors = STUDENTS.map(() => []);\nconst courseNeighbors = COURSES.map(() => []);\nEDGES.forEach((e) => {\n  studentNeighbors[e.student].push(e.course);\n  courseNeighbors[e.course].push(e.student);\n});\n\nfunction ranksFromOrder(order) {\n  const ranks = order.map(() => 0);\n  order.forEach((idx, rank) => {\n    ranks[idx] = rank;\n  });\n  return ranks;\n}\n\n// Order each column primarily by descending degree, so hub students / hub\n// courses cluster near the top and the fan-out pattern reads clearly top to\n// bottom (isolated, zero-degree nodes naturally sink to the bottom). Ties\n// within the same degree are broken by the barycenter of each node's\n// neighbor ranks in the other column, a standard two-layer crossing-\n// minimization heuristic — this keeps edges from crossing more than needed\n// among otherwise-equivalent nodes.\nfunction orderByDegreeThenBarycenter(degree, neighbors, otherRanks) {\n  return degree\n    .map((d, i) => {\n      const neigh = neighbors[i];\n      const bary = neigh.length === 0 ? Infinity : neigh.reduce((sum, j) => sum + otherRanks[j], 0) / neigh.length;\n      return { i, d, bary };\n    })\n    .sort((a, b) => b.d - a.d || a.bary - b.bary || a.i - b.i)\n    .map((x) => x.i);\n}\n\nconst initialStudentOrder = STUDENTS.map((_, i) => i).sort((a, b) => studentDegree[b] - studentDegree[a] || a - b);\nconst initialCourseOrder = COURSES.map((_, i) => i).sort((a, b) => courseDegree[b] - courseDegree[a] || a - b);\nconst initialStudentRanks = ranksFromOrder(initialStudentOrder);\nconst initialCourseRanks = ranksFromOrder(initialCourseOrder);\n\nconst courseOrder = orderByDegreeThenBarycenter(courseDegree, courseNeighbors, initialStudentRanks);\nconst studentOrder = orderByDegreeThenBarycenter(studentDegree, studentNeighbors, initialCourseRanks);\n\nconst studentRow = studentOrder.map(() => 0);\nstudentOrder.forEach((idx, rank) => {\n  studentRow[idx] = rank;\n});\nconst courseRow = courseOrder.map(() => 0);\ncourseOrder.forEach((idx, rank) => {\n  courseRow[idx] = rank;\n});\n\n// rank 0 (top of the sorted order) lands at y=1, the last rank at y=0 — each\n// column spans the full height independently since the sets differ in size.\nfunction rowY(rank, count) {\n  return 1 - (rank + 0.5) / count;\n}\n\nconst NODE_MIN_R = 12;\nconst NODE_MAX_R = 28;\nconst ISOLATED_R = 7;\nconst MIN_DEGREE = 1;\nconst MAX_DEGREE = Math.max(...studentDegree, ...courseDegree);\n\nfunction nodeRadius(degree) {\n  if (degree === 0) return ISOLATED_R;\n  const ratio = (degree - MIN_DEGREE) / (MAX_DEGREE - MIN_DEGREE || 1);\n  return NODE_MIN_R + ratio * (NODE_MAX_R - NODE_MIN_R);\n}\n\nconst EDGE_MIN_W = 1.5;\nconst EDGE_MAX_W = 6;\n\nfunction edgeWidth(hours) {\n  const ratio = (hours - MIN_HOURS) / (MAX_HOURS - MIN_HOURS || 1);\n  return EDGE_MIN_W + ratio * (EDGE_MAX_W - EDGE_MIN_W);\n}\n\nfunction edgeOpacity(hours) {\n  const ratio = (hours - MIN_HOURS) / (MAX_HOURS - MIN_HOURS || 1);\n  return 0.25 + ratio * 0.45;\n}\n\nconst { width: CANVAS_W, height: CANVAS_H } = window.ANYPLOT_SIZE;\nconst MARGIN = { top: 90, right: 220, bottom: 230, left: 220 };\n\n// Phantom series carrying no points: they exist purely so the native\n// ChartsLegend component (a real MUI X primitive, not hand-drawn SVG) has\n// series metadata to read the set-membership colors and labels from. The\n// nodes themselves are still hand-drawn (their radius encodes degree, which\n// the community ScatterChart series can't size per-point).\nconst LEGEND_SERIES = [\n  { type: \"scatter\", id: \"set-a\", data: [], color: t.palette[0], label: \"Students (set A) · size = enrolled courses\" },\n  { type: \"scatter\", id: \"set-b\", data: [], color: t.palette[1], label: \"Courses (set B) · size = enrolled students\" },\n];\n\n// --- Overlay: title, drawn inside the reserved top margin -------------------\nfunction GraphTitle() {\n  return (\n    <text\n      x={CANVAS_W / 2}\n      y={46}\n      textAnchor=\"middle\"\n      dominantBaseline=\"hanging\"\n      fontSize={26}\n      fontWeight={600}\n      fill={t.ink}\n    >\n      {TITLE}\n    </text>\n  );\n}\n\n// --- Overlay: edges + nodes, hoverable for the interactive HTML export ------\nfunction BipartiteOverlay({ onHoverChange }) {\n  const xScale = useXScale();\n  const yScale = useYScale();\n\n  const studentX = xScale(0);\n  const courseX = xScale(1);\n\n  return (\n    <g>\n      {EDGES.map((edge, i) => {\n        const x1 = studentX;\n        const y1 = yScale(rowY(studentRow[edge.student], STUDENTS.length));\n        const x2 = courseX;\n        const y2 = yScale(rowY(courseRow[edge.course], COURSES.length));\n        const tooltip = {\n          label: `${STUDENTS[edge.student]} → ${COURSES[edge.course]}`,\n          detail: `${edge.hours} h/week`,\n          x: (x1 + x2) / 2,\n          y: (y1 + y2) / 2,\n        };\n        return (\n          <g key={`edge-${i}`}>\n            <line\n              x1={x1}\n              y1={y1}\n              x2={x2}\n              y2={y2}\n              stroke={t.inkSoft}\n              strokeOpacity={edgeOpacity(edge.hours)}\n              strokeWidth={edgeWidth(edge.hours)}\n              strokeLinecap=\"round\"\n            />\n            {/* Wider transparent hit path: the visible stroke is often too thin to hover reliably. */}\n            <line\n              x1={x1}\n              y1={y1}\n              x2={x2}\n              y2={y2}\n              stroke=\"transparent\"\n              strokeWidth={16}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(tooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n          </g>\n        );\n      })}\n      {STUDENTS.map((label, i) => {\n        const cx = studentX;\n        const cy = yScale(rowY(studentRow[i], STUDENTS.length));\n        const r = nodeRadius(studentDegree[i]);\n        const tooltip = {\n          label,\n          detail: `${studentDegree[i]} course${studentDegree[i] === 1 ? \"\" : \"s\"}`,\n          x: cx,\n          y: cy,\n        };\n        return (\n          <g key={`student-${i}`}>\n            <circle\n              cx={cx}\n              cy={cy}\n              r={r}\n              fill={t.palette[0]}\n              fillOpacity={studentDegree[i] === 0 ? 0.4 : 1}\n              stroke={t.pageBg}\n              strokeWidth={2.5}\n              strokeDasharray={studentDegree[i] === 0 ? \"3 2\" : undefined}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(tooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n            <text\n              x={cx - r - 10}\n              y={cy}\n              textAnchor=\"end\"\n              dominantBaseline=\"middle\"\n              fontSize={15}\n              fill={t.ink}\n              style={{ pointerEvents: \"none\" }}\n            >\n              {label}\n            </text>\n          </g>\n        );\n      })}\n      {COURSES.map((label, i) => {\n        const cx = courseX;\n        const cy = yScale(rowY(courseRow[i], COURSES.length));\n        const r = nodeRadius(courseDegree[i]);\n        const tooltip = {\n          label,\n          detail: `${courseDegree[i]} student${courseDegree[i] === 1 ? \"\" : \"s\"}`,\n          x: cx,\n          y: cy,\n        };\n        return (\n          <g key={`course-${i}`}>\n            <circle\n              cx={cx}\n              cy={cy}\n              r={r}\n              fill={t.palette[1]}\n              fillOpacity={courseDegree[i] === 0 ? 0.4 : 1}\n              stroke={t.pageBg}\n              strokeWidth={2.5}\n              strokeDasharray={courseDegree[i] === 0 ? \"3 2\" : undefined}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(tooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n            <text\n              x={cx + r + 10}\n              y={cy}\n              textAnchor=\"start\"\n              dominantBaseline=\"middle\"\n              fontSize={15}\n              fill={t.ink}\n              style={{ pointerEvents: \"none\" }}\n            >\n              {label}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Overlay: hover tooltip for edges and nodes ------------------------------\nfunction HoverTooltip({ hover }) {\n  if (!hover) return null;\n  const charWidth = 7.2;\n  const width = Math.max(hover.label.length, hover.detail.length) * charWidth + 24;\n  const height = 46;\n  const x = Math.min(Math.max(hover.x - width / 2, 8), CANVAS_W - width - 8);\n  const y = Math.max(hover.y - height - 16, 8);\n  return (\n    <g style={{ pointerEvents: \"none\" }}>\n      <rect x={x} y={y} width={width} height={height} rx={6} fill={t.elevatedBg} stroke={t.inkSoft} strokeOpacity={0.4} />\n      <text x={x + width / 2} y={y + 19} textAnchor=\"middle\" fontSize={13} fontWeight={600} fill={t.ink}>\n        {hover.label}\n      </text>\n      <text x={x + width / 2} y={y + 36} textAnchor=\"middle\" fontSize={12} fill={t.inkSoft}>\n        {hover.detail}\n      </text>\n    </g>\n  );\n}\n\n// --- Overlay: edge-weight scale + isolated-node note, below the native\n// ChartsLegend row (set-membership colors/labels are handled by that real\n// MUI X component instead of hand-drawn swatches).\nfunction WeightLegend() {\n  const drawingArea = useDrawingArea();\n  const rowY2 = drawingArea.top + drawingArea.height + 115;\n  const rowY3 = rowY2 + 62;\n\n  const hourSamples = [MIN_HOURS, Math.round((MIN_HOURS + MAX_HOURS) / 2), MAX_HOURS];\n\n  return (\n    <g>\n      <text x={drawingArea.left} y={rowY2 - 8} fontSize={14} fill={t.inkSoft}>\n        Edge weight = weekly contact hours\n      </text>\n      {hourSamples.map((h, i) => {\n        const x = drawingArea.left + i * 140;\n        const lineY = rowY2 + 20;\n        return (\n          <g key={h}>\n            <line\n              x1={x}\n              y1={lineY}\n              x2={x + 60}\n              y2={lineY}\n              stroke={t.inkSoft}\n              strokeOpacity={edgeOpacity(h)}\n              strokeWidth={edgeWidth(h)}\n              strokeLinecap=\"round\"\n            />\n            <text x={x + 30} y={lineY + 22} textAnchor=\"middle\" fontSize={14} fill={t.inkSoft}>\n              {h}h\n            </text>\n          </g>\n        );\n      })}\n      <text x={drawingArea.left} y={rowY3} fontSize={14} fill={t.inkSoft}>\n        Dashed outline, faded fill = isolated node (no enrollments)\n      </text>\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  const [hover, setHover] = useState(null);\n  return (\n    <ChartContainer\n      width={CANVAS_W}\n      height={CANVAS_H}\n      margin={MARGIN}\n      series={LEGEND_SERIES}\n      skipAnimation\n      disableAxisListener\n      xAxis={[{ scaleType: \"linear\", min: 0, max: 1 }]}\n      yAxis={[{ scaleType: \"linear\", min: 0, max: 1 }]}\n    >\n      <BipartiteOverlay onHoverChange={setHover} />\n      <GraphTitle />\n      <ChartsLegend\n        position={{ horizontal: \"middle\", vertical: \"bottom\" }}\n        direction=\"row\"\n        padding={{ top: 0, right: 0, bottom: 130, left: 0 }}\n        itemMarkWidth={18}\n        itemMarkHeight={18}\n        markGap={10}\n        itemGap={50}\n        labelStyle={{ fontSize: 16, fill: t.inkSoft }}\n      />\n      <WeightLegend />\n      <HoverTooltip hover={hover} />\n    </ChartContainer>\n  );\n}\n"}