Bipartite Network Graph — MUI X Charts

A bipartite network graph visualizes relationships between two distinct sets of entities, where edges only connect nodes from different sets — never within the same set. The two node groups are arranged in separate columns or rows, making the two-mode structure immediately apparent. This layout is fundamental for understanding cross-category relationships, revealing which entities from one set are linked to which entities in the other, and exposing patterns like hubs, clusters, and isolated nodes.

Bipartite Network Graph rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// network-bipartite: Bipartite Network Graph
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-09-05
//# anyplot-orientation: square
// anyplot.ai
// network-bipartite: Bipartite Network Graph
// Library: MUI X Charts | React | Node 22
// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.
// Quality: pending | Created: 2026-09-05

import { useState } from "react";
import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { ChartsLegend } from "@mui/x-charts/ChartsLegend";
import { useXScale, useYScale, useDrawingArea } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;
const TITLE = "network-bipartite · javascript · muix · anyplot.ai";

// --- Data: student-course enrollment network (in-memory, deterministic) ----
// Bipartite: every edge connects a student (set A) to a course (set B) —
// never student-student or course-course. Weight = weekly contact hours.
// "Talia Novak" and "Advanced Robotics" carry no edges on purpose, to show
// the isolated-node pattern the spec calls out.
const STUDENTS = [
  "Ava Chen", "Liam Brooks", "Noor Malik", "Ethan Diaz", "Priya Nair",
  "Marcus Lee", "Sofia Reyes", "Jamal Carter", "Elena Popov", "Diego Silva",
  "Grace Kim", "Omar Haddad", "Isla Fraser", "Victor Alves", "Talia Novak",
];

const COURSES = [
  "Linear Algebra", "Data Structures", "Organic Chemistry", "Microeconomics",
  "Cell Biology", "Machine Learning", "Thermodynamics", "World History",
  "Statistics", "Digital Design", "Advanced Robotics",
];

const EDGES = [
  { student: 0, course: 0, hours: 4 },
  { student: 0, course: 1, hours: 5 },
  { student: 0, course: 5, hours: 3 },
  { student: 0, course: 8, hours: 3 },
  { student: 1, course: 1, hours: 5 },
  { student: 1, course: 5, hours: 4 },
  { student: 1, course: 9, hours: 3 },
  { student: 2, course: 2, hours: 6 },
  { student: 2, course: 4, hours: 4 },
  { student: 2, course: 8, hours: 3 },
  { student: 3, course: 3, hours: 4 },
  { student: 3, course: 7, hours: 2 },
  { student: 3, course: 8, hours: 4 },
  { student: 4, course: 2, hours: 4 },
  { student: 4, course: 4, hours: 5 },
  { student: 4, course: 8, hours: 3 },
  { student: 5, course: 0, hours: 3 },
  { student: 5, course: 6, hours: 5 },
  { student: 5, course: 8, hours: 3 },
  { student: 6, course: 3, hours: 3 },
  { student: 6, course: 7, hours: 3 },
  { student: 7, course: 1, hours: 4 },
  { student: 7, course: 5, hours: 5 },
  { student: 7, course: 9, hours: 4 },
  { student: 8, course: 0, hours: 4 },
  { student: 8, course: 6, hours: 4 },
  { student: 9, course: 3, hours: 5 },
  { student: 9, course: 7, hours: 3 },
  { student: 9, course: 8, hours: 2 },
  { student: 10, course: 4, hours: 4 },
  { student: 10, course: 5, hours: 3 },
  { student: 10, course: 8, hours: 4 },
  { student: 11, course: 1, hours: 3 },
  { student: 11, course: 9, hours: 5 },
  { student: 12, course: 2, hours: 5 },
  { student: 12, course: 4, hours: 3 },
  { student: 13, course: 0, hours: 3 },
  { student: 13, course: 6, hours: 3 },
  { student: 13, course: 9, hours: 3 },
];

const MIN_HOURS = Math.min(...EDGES.map((e) => e.hours));
const MAX_HOURS = Math.max(...EDGES.map((e) => e.hours));

const studentDegree = STUDENTS.map(() => 0);
const courseDegree = COURSES.map(() => 0);
EDGES.forEach((e) => {
  studentDegree[e.student] += 1;
  courseDegree[e.course] += 1;
});

const studentNeighbors = STUDENTS.map(() => []);
const courseNeighbors = COURSES.map(() => []);
EDGES.forEach((e) => {
  studentNeighbors[e.student].push(e.course);
  courseNeighbors[e.course].push(e.student);
});

function ranksFromOrder(order) {
  const ranks = order.map(() => 0);
  order.forEach((idx, rank) => {
    ranks[idx] = rank;
  });
  return ranks;
}

// Order each column primarily by descending degree, so hub students / hub
// courses cluster near the top and the fan-out pattern reads clearly top to
// bottom (isolated, zero-degree nodes naturally sink to the bottom). Ties
// within the same degree are broken by the barycenter of each node's
// neighbor ranks in the other column, a standard two-layer crossing-
// minimization heuristic — this keeps edges from crossing more than needed
// among otherwise-equivalent nodes.
function orderByDegreeThenBarycenter(degree, neighbors, otherRanks) {
  return degree
    .map((d, i) => {
      const neigh = neighbors[i];
      const bary = neigh.length === 0 ? Infinity : neigh.reduce((sum, j) => sum + otherRanks[j], 0) / neigh.length;
      return { i, d, bary };
    })
    .sort((a, b) => b.d - a.d || a.bary - b.bary || a.i - b.i)
    .map((x) => x.i);
}

const initialStudentOrder = STUDENTS.map((_, i) => i).sort((a, b) => studentDegree[b] - studentDegree[a] || a - b);
const initialCourseOrder = COURSES.map((_, i) => i).sort((a, b) => courseDegree[b] - courseDegree[a] || a - b);
const initialStudentRanks = ranksFromOrder(initialStudentOrder);
const initialCourseRanks = ranksFromOrder(initialCourseOrder);

const courseOrder = orderByDegreeThenBarycenter(courseDegree, courseNeighbors, initialStudentRanks);
const studentOrder = orderByDegreeThenBarycenter(studentDegree, studentNeighbors, initialCourseRanks);

const studentRow = studentOrder.map(() => 0);
studentOrder.forEach((idx, rank) => {
  studentRow[idx] = rank;
});
const courseRow = courseOrder.map(() => 0);
courseOrder.forEach((idx, rank) => {
  courseRow[idx] = rank;
});

// rank 0 (top of the sorted order) lands at y=1, the last rank at y=0 — each
// column spans the full height independently since the sets differ in size.
function rowY(rank, count) {
  return 1 - (rank + 0.5) / count;
}

const NODE_MIN_R = 12;
const NODE_MAX_R = 28;
const ISOLATED_R = 7;
const MIN_DEGREE = 1;
const MAX_DEGREE = Math.max(...studentDegree, ...courseDegree);

function nodeRadius(degree) {
  if (degree === 0) return ISOLATED_R;
  const ratio = (degree - MIN_DEGREE) / (MAX_DEGREE - MIN_DEGREE || 1);
  return NODE_MIN_R + ratio * (NODE_MAX_R - NODE_MIN_R);
}

const EDGE_MIN_W = 1.5;
const EDGE_MAX_W = 6;

function edgeWidth(hours) {
  const ratio = (hours - MIN_HOURS) / (MAX_HOURS - MIN_HOURS || 1);
  return EDGE_MIN_W + ratio * (EDGE_MAX_W - EDGE_MIN_W);
}

function edgeOpacity(hours) {
  const ratio = (hours - MIN_HOURS) / (MAX_HOURS - MIN_HOURS || 1);
  return 0.25 + ratio * 0.45;
}

const { width: CANVAS_W, height: CANVAS_H } = window.ANYPLOT_SIZE;
const MARGIN = { top: 90, right: 220, bottom: 230, left: 220 };

// Phantom series carrying no points: they exist purely so the native
// ChartsLegend component (a real MUI X primitive, not hand-drawn SVG) has
// series metadata to read the set-membership colors and labels from. The
// nodes themselves are still hand-drawn (their radius encodes degree, which
// the community ScatterChart series can't size per-point).
const LEGEND_SERIES = [
  { type: "scatter", id: "set-a", data: [], color: t.palette[0], label: "Students (set A) · size = enrolled courses" },
  { type: "scatter", id: "set-b", data: [], color: t.palette[1], label: "Courses (set B) · size = enrolled students" },
];

// --- Overlay: title, drawn inside the reserved top margin -------------------
function GraphTitle() {
  return (
    <text
      x={CANVAS_W / 2}
      y={46}
      textAnchor="middle"
      dominantBaseline="hanging"
      fontSize={26}
      fontWeight={600}
      fill={t.ink}
    >
      {TITLE}
    </text>
  );
}

// --- Overlay: edges + nodes, hoverable for the interactive HTML export ------
function BipartiteOverlay({ onHoverChange }) {
  const xScale = useXScale();
  const yScale = useYScale();

  const studentX = xScale(0);
  const courseX = xScale(1);

  return (
    <g>
      {EDGES.map((edge, i) => {
        const x1 = studentX;
        const y1 = yScale(rowY(studentRow[edge.student], STUDENTS.length));
        const x2 = courseX;
        const y2 = yScale(rowY(courseRow[edge.course], COURSES.length));
        const tooltip = {
          label: `${STUDENTS[edge.student]} → ${COURSES[edge.course]}`,
          detail: `${edge.hours} h/week`,
          x: (x1 + x2) / 2,
          y: (y1 + y2) / 2,
        };
        return (
          <g key={`edge-${i}`}>
            <line
              x1={x1}
              y1={y1}
              x2={x2}
              y2={y2}
              stroke={t.inkSoft}
              strokeOpacity={edgeOpacity(edge.hours)}
              strokeWidth={edgeWidth(edge.hours)}
              strokeLinecap="round"
            />
            {/* Wider transparent hit path: the visible stroke is often too thin to hover reliably. */}
            <line
              x1={x1}
              y1={y1}
              x2={x2}
              y2={y2}
              stroke="transparent"
              strokeWidth={16}
              style={{ cursor: "pointer" }}
              onMouseEnter={() => onHoverChange(tooltip)}
              onMouseLeave={() => onHoverChange(null)}
            />
          </g>
        );
      })}
      {STUDENTS.map((label, i) => {
        const cx = studentX;
        const cy = yScale(rowY(studentRow[i], STUDENTS.length));
        const r = nodeRadius(studentDegree[i]);
        const tooltip = {
          label,
          detail: `${studentDegree[i]} course${studentDegree[i] === 1 ? "" : "s"}`,
          x: cx,
          y: cy,
        };
        return (
          <g key={`student-${i}`}>
            <circle
              cx={cx}
              cy={cy}
              r={r}
              fill={t.palette[0]}
              fillOpacity={studentDegree[i] === 0 ? 0.4 : 1}
              stroke={t.pageBg}
              strokeWidth={2.5}
              strokeDasharray={studentDegree[i] === 0 ? "3 2" : undefined}
              style={{ cursor: "pointer" }}
              onMouseEnter={() => onHoverChange(tooltip)}
              onMouseLeave={() => onHoverChange(null)}
            />
            <text
              x={cx - r - 10}
              y={cy}
              textAnchor="end"
              dominantBaseline="middle"
              fontSize={15}
              fill={t.ink}
              style={{ pointerEvents: "none" }}
            >
              {label}
            </text>
          </g>
        );
      })}
      {COURSES.map((label, i) => {
        const cx = courseX;
        const cy = yScale(rowY(courseRow[i], COURSES.length));
        const r = nodeRadius(courseDegree[i]);
        const tooltip = {
          label,
          detail: `${courseDegree[i]} student${courseDegree[i] === 1 ? "" : "s"}`,
          x: cx,
          y: cy,
        };
        return (
          <g key={`course-${i}`}>
            <circle
              cx={cx}
              cy={cy}
              r={r}
              fill={t.palette[1]}
              fillOpacity={courseDegree[i] === 0 ? 0.4 : 1}
              stroke={t.pageBg}
              strokeWidth={2.5}
              strokeDasharray={courseDegree[i] === 0 ? "3 2" : undefined}
              style={{ cursor: "pointer" }}
              onMouseEnter={() => onHoverChange(tooltip)}
              onMouseLeave={() => onHoverChange(null)}
            />
            <text
              x={cx + r + 10}
              y={cy}
              textAnchor="start"
              dominantBaseline="middle"
              fontSize={15}
              fill={t.ink}
              style={{ pointerEvents: "none" }}
            >
              {label}
            </text>
          </g>
        );
      })}
    </g>
  );
}

// --- Overlay: hover tooltip for edges and nodes ------------------------------
function HoverTooltip({ hover }) {
  if (!hover) return null;
  const charWidth = 7.2;
  const width = Math.max(hover.label.length, hover.detail.length) * charWidth + 24;
  const height = 46;
  const x = Math.min(Math.max(hover.x - width / 2, 8), CANVAS_W - width - 8);
  const y = Math.max(hover.y - height - 16, 8);
  return (
    <g style={{ pointerEvents: "none" }}>
      <rect x={x} y={y} width={width} height={height} rx={6} fill={t.elevatedBg} stroke={t.inkSoft} strokeOpacity={0.4} />
      <text x={x + width / 2} y={y + 19} textAnchor="middle" fontSize={13} fontWeight={600} fill={t.ink}>
        {hover.label}
      </text>
      <text x={x + width / 2} y={y + 36} textAnchor="middle" fontSize={12} fill={t.inkSoft}>
        {hover.detail}
      </text>
    </g>
  );
}

// --- Overlay: edge-weight scale + isolated-node note, below the native
// ChartsLegend row (set-membership colors/labels are handled by that real
// MUI X component instead of hand-drawn swatches).
function WeightLegend() {
  const drawingArea = useDrawingArea();
  const rowY2 = drawingArea.top + drawingArea.height + 115;
  const rowY3 = rowY2 + 62;

  const hourSamples = [MIN_HOURS, Math.round((MIN_HOURS + MAX_HOURS) / 2), MAX_HOURS];

  return (
    <g>
      <text x={drawingArea.left} y={rowY2 - 8} fontSize={14} fill={t.inkSoft}>
        Edge weight = weekly contact hours
      </text>
      {hourSamples.map((h, i) => {
        const x = drawingArea.left + i * 140;
        const lineY = rowY2 + 20;
        return (
          <g key={h}>
            <line
              x1={x}
              y1={lineY}
              x2={x + 60}
              y2={lineY}
              stroke={t.inkSoft}
              strokeOpacity={edgeOpacity(h)}
              strokeWidth={edgeWidth(h)}
              strokeLinecap="round"
            />
            <text x={x + 30} y={lineY + 22} textAnchor="middle" fontSize={14} fill={t.inkSoft}>
              {h}h
            </text>
          </g>
        );
      })}
      <text x={drawingArea.left} y={rowY3} fontSize={14} fill={t.inkSoft}>
        Dashed outline, faded fill = isolated node (no enrollments)
      </text>
    </g>
  );
}

// --- Chart (default-exported component — the harness mounts it) -------------
export default function Chart() {
  const [hover, setHover] = useState(null);
  return (
    <ChartContainer
      width={CANVAS_W}
      height={CANVAS_H}
      margin={MARGIN}
      series={LEGEND_SERIES}
      skipAnimation
      disableAxisListener
      xAxis={[{ scaleType: "linear", min: 0, max: 1 }]}
      yAxis={[{ scaleType: "linear", min: 0, max: 1 }]}
    >
      <BipartiteOverlay onHoverChange={setHover} />
      <GraphTitle />
      <ChartsLegend
        position={{ horizontal: "middle", vertical: "bottom" }}
        direction="row"
        padding={{ top: 0, right: 0, bottom: 130, left: 0 }}
        itemMarkWidth={18}
        itemMarkHeight={18}
        markGap={10}
        itemGap={50}
        labelStyle={{ fontSize: 16, fill: t.inkSoft }}
      />
      <WeightLegend />
      <HoverTooltip hover={hover} />
    </ChartContainer>
  );
}

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/network-bipartite/muix/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "network-bipartite",
  "language": "javascript",
  "library": "muix",
  "page": "https://anyplot.ai/network-bipartite/javascript/muix",
  "hub": "https://anyplot.ai/network-bipartite",
  "code_json": "https://api.anyplot.ai/specs/network-bipartite/muix/code",
  "spec_json": "https://api.anyplot.ai/specs/network-bipartite",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/network-bipartite/javascript/muix/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/network-bipartite/javascript/muix/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/network-bipartite/javascript/muix/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/network-bipartite/javascript/muix/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Bipartite Network Graph on anyplot.ai.

Other implementations