{"spec_id":"network-force-directed","library":"muix","language":"javascript","code":"// anyplot.ai\n// network-force-directed: Force-Directed Graph\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-24\n//# anyplot-orientation: square\n// anyplot.ai\n// network-force-directed: Force-Directed 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-08-24\nimport * as React from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: research co-authorship network (in-memory, deterministic) -------\n// Four research groups, each with a \"lab lead\" hub who co-authors with every\n// group member, plus a peer chain within the group and a few inter-group\n// bridge collaborations — the kind of modular structure force layouts are\n// good at revealing.\nconst GROUPS = [\n  { name: \"Machine Learning\" },\n  { name: \"Robotics\" },\n  { name: \"Bioinformatics\" },\n  { name: \"Network Science\" },\n  { name: \"Cryptography\" },\n];\nconst GROUP_SIZE = 7;\n\n// Tiny fixed-seed LCG — the browser has no seeded RNG.\nfunction makeLcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\n\nconst groupRanges = GROUPS.map((_, groupIndex) => [\n  groupIndex * GROUP_SIZE,\n  groupIndex * GROUP_SIZE + GROUP_SIZE,\n]);\n\nconst nodes = [];\nGROUPS.forEach((group, groupIndex) => {\n  for (let i = 0; i < GROUP_SIZE; i += 1) {\n    nodes.push({\n      id: `${groupIndex}-${i}`,\n      group: groupIndex,\n      isHub: i === 0,\n      label: group.name,\n      x: 0,\n      y: 0,\n    });\n  }\n});\n\n// edges: [sourceIndex, targetIndex, weight, isBridge] — weight ~ co-authored\n// papers; isBridge marks the thin inter-group collaborations so they can be\n// styled (dashed, lower alpha) distinctly from intra-group edges.\nconst edges = [];\ngroupRanges.forEach(([start, end]) => {\n  const hub = start; // lab lead co-authors with every member\n  for (let i = start + 1; i < end; i += 1) {\n    edges.push([hub, i, 2 + Math.floor(rand() * 2), false]);\n  }\n  for (let i = start + 1; i < end - 1; i += 1) {\n    edges.push([i, i + 1, 1 + Math.floor(rand() * 2), false]); // peer chain\n  }\n});\ngroupRanges.forEach(([start], groupIndex) => {\n  const [nextStart] = groupRanges[(groupIndex + 1) % groupRanges.length];\n  edges.push([start, nextStart, 1, true]); // thin cross-group bridge between hubs\n});\n\nconst degree = new Array(nodes.length).fill(0);\nedges.forEach(([a, b]) => {\n  degree[a] += 1;\n  degree[b] += 1;\n});\nconst maxDegree = Math.max(...degree);\n\n// --- Initial layout: cluster around a per-group anchor on a ring -----------\nconst GROUP_CENTERS = GROUPS.map((_, i) => {\n  const angle = (i / GROUPS.length) * Math.PI * 2;\n  return { x: Math.cos(angle) * 260, y: Math.sin(angle) * 260 };\n});\nnodes.forEach((node, idx) => {\n  const [start] = groupRanges[node.group];\n  const within = idx - start;\n  const angle = (within / GROUP_SIZE) * Math.PI * 2;\n  const center = GROUP_CENTERS[node.group];\n  node.x = center.x + Math.cos(angle) * 70 + (rand() - 0.5) * 24;\n  node.y = center.y + Math.sin(angle) * 70 + (rand() - 0.5) * 24;\n});\n\n// --- Force-directed simulation (Fruchterman-Reingold, fixed iterations) ----\nconst IDEAL_DISTANCE = Math.sqrt((1000 * 1000) / nodes.length);\nconst ITERATIONS = 300;\nlet temperature = 80;\nconst COOLING = temperature / ITERATIONS;\n\nfor (let iter = 0; iter < ITERATIONS; iter += 1) {\n  const dispX = new Array(nodes.length).fill(0);\n  const dispY = new Array(nodes.length).fill(0);\n\n  for (let i = 0; i < nodes.length; i += 1) {\n    for (let j = i + 1; j < nodes.length; j += 1) {\n      const dx = nodes[i].x - nodes[j].x;\n      const dy = nodes[i].y - nodes[j].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n      const force = (IDEAL_DISTANCE * IDEAL_DISTANCE) / dist;\n      const fx = (dx / dist) * force;\n      const fy = (dy / dist) * force;\n      dispX[i] += fx;\n      dispY[i] += fy;\n      dispX[j] -= fx;\n      dispY[j] -= fy;\n    }\n  }\n\n  edges.forEach(([a, b, weight]) => {\n    const dx = nodes[a].x - nodes[b].x;\n    const dy = nodes[a].y - nodes[b].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n    const force = ((dist * dist) / IDEAL_DISTANCE) * (0.6 + weight * 0.2);\n    const fx = (dx / dist) * force;\n    const fy = (dy / dist) * force;\n    dispX[a] -= fx;\n    dispY[a] -= fy;\n    dispX[b] += fx;\n    dispY[b] += fy;\n  });\n\n  nodes.forEach((node, i) => {\n    dispX[i] -= node.x * 0.01; // mild centering gravity\n    dispY[i] -= node.y * 0.01;\n    const dist = Math.sqrt(dispX[i] * dispX[i] + dispY[i] * dispY[i]) || 0.01;\n    const capped = Math.min(dist, temperature);\n    node.x += (dispX[i] / dist) * capped;\n    node.y += (dispY[i] / dist) * capped;\n  });\n\n  temperature = Math.max(temperature - COOLING, 1);\n}\n\n// Square, padded domain so the layout renders with equal x/y scale.\nconst xs = nodes.map((n) => n.x);\nconst ys = nodes.map((n) => n.y);\nconst PADDING = 110;\nconst xMid = (Math.min(...xs) + Math.max(...xs)) / 2;\nconst yMid = (Math.min(...ys) + Math.max(...ys)) / 2;\nconst span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) + PADDING * 2;\nconst domain = {\n  xMin: xMid - span / 2,\n  xMax: xMid + span / 2,\n  yMin: yMid - span / 2,\n  yMax: yMid + span / 2,\n};\n\nconst nodeRadius = (i) => 7 + (degree[i] / maxDegree) * 16;\n\n// --- Custom SVG layers, positioned via the chart's own scales --------------\nfunction GraphEdges() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  return (\n    <g data-drawing-container>\n      {edges.map(([a, b, weight, isBridge], i) => (\n        <line\n          key={`edge-${i}`}\n          x1={xScale(nodes[a].x)}\n          y1={yScale(nodes[a].y)}\n          x2={xScale(nodes[b].x)}\n          y2={yScale(nodes[b].y)}\n          stroke={t.inkSoft}\n          strokeOpacity={isBridge ? 0.22 : 0.4}\n          strokeWidth={isBridge ? 1.1 : 0.8 + weight * 0.7}\n          strokeDasharray={isBridge ? \"5,4\" : undefined}\n        />\n      ))}\n    </g>\n  );\n}\n\nfunction GraphNodes() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  return (\n    <g data-drawing-container>\n      {nodes.map((node, i) => (\n        <React.Fragment key={node.id}>\n          {node.isHub && (\n            <circle\n              cx={xScale(node.x)}\n              cy={yScale(node.y)}\n              r={nodeRadius(i) + 7}\n              fill={t.palette[node.group]}\n              opacity={0.2}\n            />\n          )}\n          <circle\n            cx={xScale(node.x)}\n            cy={yScale(node.y)}\n            r={nodeRadius(i)}\n            fill={t.palette[node.group]}\n            stroke={t.pageBg}\n            strokeWidth={1.5}\n          />\n          {node.isHub && (\n            <text\n              x={xScale(node.x)}\n              y={yScale(node.y) - nodeRadius(i) - 8}\n              textAnchor=\"middle\"\n              fontSize={15}\n              fill={t.ink}\n            >\n              {node.label}\n            </text>\n          )}\n        </React.Fragment>\n      ))}\n    </g>\n  );\n}\n\n// --- Title + legend chrome ---------------------------------------------------\nconst TITLE = \"network-force-directed · javascript · muix · anyplot.ai\";\nconst TITLE_FONT_DEFAULT = 25;\nconst titleFontSize =\n  TITLE.length > 67 ? Math.round(TITLE_FONT_DEFAULT * (67 / TITLE.length)) : TITLE_FONT_DEFAULT;\nconst TITLE_H = 46;\nconst LEGEND_H = 34;\n\nfunction Legend() {\n  return (\n    <div style={{ height: LEGEND_H, display: \"flex\", alignItems: \"center\", gap: \"20px\", flexWrap: \"wrap\" }}>\n      {GROUPS.map((group, i) => (\n        <div key={group.name} style={{ display: \"flex\", alignItems: \"center\", gap: \"7px\" }}>\n          <span\n            style={{\n              width: \"12px\",\n              height: \"12px\",\n              borderRadius: \"50%\",\n              backgroundColor: t.palette[i],\n              display: \"inline-block\",\n            }}\n          />\n          <span style={{ fontSize: \"14px\", color: t.inkSoft }}>{group.name}</span>\n        </div>\n      ))}\n    </div>\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_H - LEGEND_H;\n\n  return (\n    <div style={{ width, height, display: \"flex\", flexDirection: \"column\" }}>\n      <div\n        style={{\n          height: `${TITLE_H}px`,\n          lineHeight: `${TITLE_H}px`,\n          fontSize: `${titleFontSize}px`,\n          fontWeight: 500,\n          color: t.ink,\n        }}\n      >\n        {TITLE}\n      </div>\n      <Legend />\n      <ChartContainer\n        width={width}\n        height={chartHeight}\n        series={[]}\n        margin={{ top: 8, bottom: 8, left: 8, right: 8 }}\n        xAxis={[{ id: \"x\", scaleType: \"linear\", min: domain.xMin, max: domain.xMax }]}\n        yAxis={[{ id: \"y\", scaleType: \"linear\", min: domain.yMin, max: domain.yMax }]}\n        skipAnimation\n      >\n        <GraphEdges />\n        <GraphNodes />\n      </ChartContainer>\n    </div>\n  );\n}\n"}