{"spec_id":"network-weighted","library":"muix","language":"javascript","code":"// anyplot.ai\n// network-weighted: Weighted Network Graph with Edge Thickness\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n// anyplot.ai\n// network-weighted: Weighted Network Graph with Edge Thickness\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-02\nimport { useState } from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useXScale, useYScale, useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst TITLE = \"network-weighted · javascript · muix · anyplot.ai\";\n\n// --- Data: research-lab co-authorship network (in-memory, deterministic) ----\n// Four research domains, colored by the Imprint categorical palette in\n// canonical order (abstract groups, no semantic-color exception applies).\nconst DOMAINS = [\"Life Sciences\", \"Physical Sciences\", \"Computer Science\", \"Engineering\"];\n\nconst nodes = [\n  { id: \"GEN\", label: \"Genomics Lab\", domain: 0 },\n  { id: \"IMM\", label: \"Immunology Institute\", domain: 0 },\n  { id: \"NEU\", label: \"Neuroscience Institute\", domain: 0 },\n  { id: \"MAR\", label: \"Marine Biology Station\", domain: 0 },\n  { id: \"QPL\", label: \"Quantum Physics Lab\", domain: 1 },\n  { id: \"MAT\", label: \"Materials Science Institute\", domain: 1 },\n  { id: \"AST\", label: \"Astrophysics Observatory\", domain: 1 },\n  { id: \"CLI\", label: \"Climate Science Center\", domain: 1 },\n  { id: \"AIR\", label: \"AI Research Center\", domain: 2 },\n  { id: \"DSI\", label: \"Data Science Institute\", domain: 2 },\n  { id: \"ROB\", label: \"Robotics Lab\", domain: 2 },\n  { id: \"CHE\", label: \"Chemical Engineering Lab\", domain: 3 },\n  { id: \"BIO\", label: \"Bioengineering Institute\", domain: 3 },\n  { id: \"ENV\", label: \"Environmental Engineering Lab\", domain: 3 },\n];\n\n// Weight = co-authored papers, 2020-2024, between the two labs.\nconst edges = [\n  { source: \"GEN\", target: \"IMM\", weight: 42 },\n  { source: \"GEN\", target: \"NEU\", weight: 16 },\n  { source: \"GEN\", target: \"BIO\", weight: 22 },\n  { source: \"IMM\", target: \"BIO\", weight: 12 },\n  { source: \"IMM\", target: \"NEU\", weight: 6 },\n  { source: \"NEU\", target: \"AIR\", weight: 19 },\n  { source: \"MAR\", target: \"ENV\", weight: 27 },\n  { source: \"MAR\", target: \"CLI\", weight: 31 },\n  { source: \"QPL\", target: \"MAT\", weight: 36 },\n  { source: \"QPL\", target: \"AST\", weight: 21 },\n  { source: \"MAT\", target: \"CHE\", weight: 33 },\n  { source: \"MAT\", target: \"ENV\", weight: 9 },\n  { source: \"AST\", target: \"CLI\", weight: 8 },\n  { source: \"CLI\", target: \"ENV\", weight: 24 },\n  { source: \"AIR\", target: \"DSI\", weight: 45 },\n  { source: \"AIR\", target: \"ROB\", weight: 29 },\n  { source: \"DSI\", target: \"ROB\", weight: 18 },\n  { source: \"DSI\", target: \"GEN\", weight: 15 },\n  { source: \"DSI\", target: \"CLI\", weight: 12 },\n  { source: \"DSI\", target: \"MAT\", weight: 7 },\n  { source: \"DSI\", target: \"BIO\", weight: 10 },\n  { source: \"ROB\", target: \"BIO\", weight: 14 },\n  { source: \"CHE\", target: \"BIO\", weight: 20 },\n  { source: \"CHE\", target: \"ENV\", weight: 17 },\n];\n\nconst nodeIndex = {};\nnodes.forEach((n, i) => {\n  nodeIndex[n.id] = i;\n});\n\nconst edgeWeights = edges.map((e) => e.weight);\nconst MIN_WEIGHT = Math.min(...edgeWeights);\nconst MAX_WEIGHT = Math.max(...edgeWeights);\n\n// Weighted degree = sum of incident edge weights, drives node radius.\nconst weightedDegree = nodes.map(() => 0);\nedges.forEach((e) => {\n  weightedDegree[nodeIndex[e.source]] += e.weight;\n  weightedDegree[nodeIndex[e.target]] += e.weight;\n});\nconst MIN_DEGREE = Math.min(...weightedDegree);\nconst MAX_DEGREE = Math.max(...weightedDegree);\n\n// --- Force-directed layout (Fruchterman-Reingold, weighted attraction) ------\n// A tiny fixed-seed LCG stands in for a seeded RNG (the browser has none);\n// only the initial scatter is randomized, the physics is fully deterministic.\nlet lcgState = 42;\nfunction rand() {\n  lcgState = (lcgState * 1664525 + 1013904223) % 4294967296;\n  return lcgState / 4294967296;\n}\n\nconst N = nodes.length;\nconst AREA = 4.5;\nconst K = Math.sqrt(AREA / N);\nconst ITERATIONS = 500;\n\nconst positions = nodes.map(() => ({ x: rand() * 2 - 1, y: rand() * 2 - 1 }));\nlet temperature = 0.12;\n\nfor (let iter = 0; iter < ITERATIONS; iter++) {\n  const disp = positions.map(() => ({ x: 0, y: 0 }));\n\n  // Repulsion between every node pair keeps the layout from collapsing.\n  for (let i = 0; i < N; i++) {\n    for (let j = i + 1; j < N; j++) {\n      const dx = positions[i].x - positions[j].x;\n      const dy = positions[i].y - positions[j].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n      const force = (K * K) / dist;\n      const ux = dx / dist;\n      const uy = dy / dist;\n      disp[i].x += ux * force;\n      disp[i].y += uy * force;\n      disp[j].x -= ux * force;\n      disp[j].y -= uy * force;\n    }\n  }\n\n  // Attraction along edges — heavier weight pulls the pair closer together,\n  // so the layout itself, not just line width, communicates connection strength.\n  edges.forEach((e) => {\n    const i = nodeIndex[e.source];\n    const j = nodeIndex[e.target];\n    const dx = positions[i].x - positions[j].x;\n    const dy = positions[i].y - positions[j].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n    const wRatio = (e.weight - MIN_WEIGHT) / (MAX_WEIGHT - MIN_WEIGHT || 1);\n    const idealDist = K * (1.5 - 1.0 * wRatio);\n    const force = (dist * dist) / idealDist;\n    const ux = dx / dist;\n    const uy = dy / dist;\n    disp[i].x -= ux * force;\n    disp[i].y -= uy * force;\n    disp[j].x += ux * force;\n    disp[j].y += uy * force;\n  });\n\n  for (let i = 0; i < N; i++) {\n    const dx = disp[i].x;\n    const dy = disp[i].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n    const limited = Math.min(dist, temperature);\n    positions[i].x += (dx / dist) * limited;\n    positions[i].y += (dy / dist) * limited;\n  }\n  temperature *= 0.99;\n}\n\n// Center the layout, then fit its actual (generally non-circular) bounding\n// box to the drawing area independently per axis — a chain-shaped network\n// like this one would otherwise sit inside a huge, mostly-empty circle.\nconst centroidX = positions.reduce((s, p) => s + p.x, 0) / N;\nconst centroidY = positions.reduce((s, p) => s + p.y, 0) / N;\npositions.forEach((p) => {\n  p.x -= centroidX;\n  p.y -= centroidY;\n});\n\nconst MARGIN = { top: 100, right: 60, bottom: 170, left: 60 };\nconst { width: CANVAS_W, height: CANVAS_H } = window.ANYPLOT_SIZE;\nconst PAD = 1.25; // headroom for node radius + label above the outermost nodes\nconst rangeX = Math.max(...positions.map((p) => Math.abs(p.x))) || 1;\nconst rangeY = Math.max(...positions.map((p) => Math.abs(p.y))) || 1;\nconst X_HALF = rangeX * PAD;\nconst Y_HALF = rangeY * PAD;\n\nconst NODE_MIN_R = 15;\nconst NODE_MAX_R = 40;\nconst EDGE_MIN_W = 2;\nconst EDGE_MAX_W = 13;\n\nfunction nodeRadius(i) {\n  const ratio = (weightedDegree[i] - MIN_DEGREE) / (MAX_DEGREE - MIN_DEGREE || 1);\n  return NODE_MIN_R + ratio * (NODE_MAX_R - NODE_MIN_R);\n}\n\nfunction edgeWidth(weight) {\n  const ratio = (weight - MIN_WEIGHT) / (MAX_WEIGHT - MIN_WEIGHT || 1);\n  return EDGE_MIN_W + ratio * (EDGE_MAX_W - EDGE_MIN_W);\n}\n\n// Draw heaviest edges last so the strongest collaborations stay legible on top.\nconst sortedEdges = [...edges].sort((a, b) => a.weight - b.weight);\n\n// --- Overlay: title drawn in the reserved top margin -------------------------\nfunction GraphTitle() {\n  return (\n    <text x={CANVAS_W / 2} y={44} textAnchor=\"middle\" dominantBaseline=\"hanging\" fontSize={28} fontWeight={500} fill={t.ink}>\n      {TITLE}\n    </text>\n  );\n}\n\n// --- Overlay: edges + nodes, both hoverable for the interactive HTML export -\nfunction NetworkOverlay({ onHoverChange }) {\n  const xScale = useXScale();\n  const yScale = useYScale();\n\n  return (\n    <g>\n      {sortedEdges.map((edge, i) => {\n        const s = positions[nodeIndex[edge.source]];\n        const d = positions[nodeIndex[edge.target]];\n        const x1 = xScale(s.x);\n        const y1 = yScale(s.y);\n        const x2 = xScale(d.x);\n        const y2 = yScale(d.y);\n        const width = edgeWidth(edge.weight);\n        const ratio = (edge.weight - MIN_WEIGHT) / (MAX_WEIGHT - MIN_WEIGHT || 1);\n        const tooltip = {\n          label: `${nodes[nodeIndex[edge.source]].label} ↔ ${nodes[nodeIndex[edge.target]].label}`,\n          detail: `${edge.weight} co-authored papers`,\n          x: (x1 + x2) / 2,\n          y: (y1 + y2) / 2,\n        };\n        return (\n          <g key={`${edge.source}-${edge.target}-${i}`}>\n            <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={t.ink} strokeOpacity={0.22 + ratio * 0.4} strokeWidth={width} strokeLinecap=\"round\" />\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={Math.max(width, 18)}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(tooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n          </g>\n        );\n      })}\n      {nodes.map((node, i) => {\n        const p = positions[i];\n        const cx = xScale(p.x);\n        const cy = yScale(p.y);\n        const radius = nodeRadius(i);\n        const tooltip = {\n          label: node.label,\n          detail: `${DOMAINS[node.domain]} · weighted degree ${weightedDegree[i]}`,\n          x: cx,\n          y: cy - radius - 10,\n        };\n        return (\n          <g key={node.id}>\n            <circle\n              cx={cx}\n              cy={cy}\n              r={radius}\n              fill={t.palette[node.domain]}\n              stroke={t.pageBg}\n              strokeWidth={3}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(tooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n            <text x={cx} y={cy - radius - 10} textAnchor=\"middle\" fontSize={16} fontWeight={600} fill={t.ink} style={{ pointerEvents: \"none\" }}>\n              {node.id}\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.4;\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 - 12, 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: domain-color legend + edge-weight scale, in the bottom margin -\nfunction Legend() {\n  const drawingArea = useDrawingArea();\n  const rowY = drawingArea.top + drawingArea.height + 55;\n  const swatchR = 9;\n  const groupGap = 225;\n\n  const weightSamples = [MIN_WEIGHT, Math.round((MIN_WEIGHT + MAX_WEIGHT) / 2), MAX_WEIGHT];\n  const weightRowY = rowY + 55;\n  const weightStartX = drawingArea.left;\n\n  return (\n    <g>\n      {DOMAINS.map((name, i) => {\n        const x = drawingArea.left + i * groupGap;\n        return (\n          <g key={name}>\n            <circle cx={x} cy={rowY} r={swatchR} fill={t.palette[i]} />\n            <text x={x + swatchR + 8} y={rowY + 5} fontSize={16} fill={t.inkSoft}>\n              {name}\n            </text>\n          </g>\n        );\n      })}\n      <text x={weightStartX} y={weightRowY - 14} fontSize={14} fill={t.inkSoft}>\n        Edge width = co-authored papers · node size = weighted degree\n      </text>\n      {weightSamples.map((w, i) => {\n        const x = weightStartX + i * 140;\n        const lineY = weightRowY + 12;\n        return (\n          <g key={w}>\n            <line x1={x} y1={lineY} x2={x + 60} y2={lineY} stroke={t.ink} strokeOpacity={0.55} strokeWidth={edgeWidth(w)} strokeLinecap=\"round\" />\n            <text x={x + 30} y={lineY + 22} textAnchor=\"middle\" fontSize={14} fill={t.inkSoft}>\n              {w}\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 [hover, setHover] = useState(null);\n  return (\n    <ChartContainer\n      width={CANVAS_W}\n      height={CANVAS_H}\n      margin={MARGIN}\n      series={[]}\n      skipAnimation\n      disableAxisListener\n      xAxis={[{ scaleType: \"linear\", min: -X_HALF, max: X_HALF }]}\n      yAxis={[{ scaleType: \"linear\", min: -Y_HALF, max: Y_HALF }]}\n    >\n      <NetworkOverlay onHoverChange={setHover} />\n      <GraphTitle />\n      <Legend />\n      <HoverTooltip hover={hover} />\n    </ChartContainer>\n  );\n}\n"}