{"spec_id":"network-force-directed","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// network-force-directed: Force-Directed Graph\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-08-24\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: a layered software dependency graph ------------------------------\n// Layers follow a typical service architecture; edges point from a dependent\n// module to the module it depends on. Weight (1-5) is the coupling strength —\n// how many call sites/imports tie the two modules together.\nconst LAYER_NAMES = [\"Infrastructure\", \"Domain\", \"Application\", \"UI\"];\n\nconst nodes = [\n  { id: 0, name: \"db-driver\", layer: 0 },\n  { id: 1, name: \"cache\", layer: 0 },\n  { id: 2, name: \"queue\", layer: 0 },\n  { id: 3, name: \"logger\", layer: 0 },\n  { id: 4, name: \"config\", layer: 0 },\n  { id: 5, name: \"http-client\", layer: 0 },\n  { id: 6, name: \"user-repo\", layer: 1 },\n  { id: 7, name: \"order-repo\", layer: 1 },\n  { id: 8, name: \"payment-repo\", layer: 1 },\n  { id: 9, name: \"inventory-repo\", layer: 1 },\n  { id: 10, name: \"pricing-rules\", layer: 1 },\n  { id: 11, name: \"auth-domain\", layer: 1 },\n  { id: 12, name: \"checkout-svc\", layer: 2 },\n  { id: 13, name: \"catalog-svc\", layer: 2 },\n  { id: 14, name: \"user-svc\", layer: 2 },\n  { id: 15, name: \"notification-svc\", layer: 2 },\n  { id: 16, name: \"search-svc\", layer: 2 },\n  { id: 17, name: \"recommender-svc\", layer: 2 },\n  { id: 18, name: \"checkout-ui\", layer: 3 },\n  { id: 19, name: \"catalog-ui\", layer: 3 },\n  { id: 20, name: \"account-ui\", layer: 3 },\n  { id: 21, name: \"admin-ui\", layer: 3 },\n  { id: 22, name: \"search-ui\", layer: 3 },\n  { id: 23, name: \"mobile-app\", layer: 3 },\n];\n\n// Directed edges [dependent, dependency, weight] — arrow points at the module\n// being depended on.\nconst edges = [\n  [3, 4, 2], [5, 4, 2], [2, 4, 1], [1, 4, 1], [0, 4, 2],\n  [6, 0, 5], [6, 1, 2],\n  [7, 0, 5], [7, 2, 3],\n  [8, 0, 4], [8, 5, 3],\n  [9, 0, 4], [9, 1, 2],\n  [10, 1, 2],\n  [11, 0, 3], [11, 1, 3],\n  [7, 10, 3], [7, 9, 2], [8, 11, 2],\n  [12, 7, 5], [12, 8, 5], [12, 10, 3], [12, 9, 3], [12, 14, 2],\n  [13, 9, 4], [13, 10, 3],\n  [14, 6, 5], [14, 11, 4],\n  [15, 6, 2], [15, 7, 2], [15, 2, 4],\n  [16, 9, 3], [16, 13, 3], [16, 5, 2],\n  [17, 13, 2], [17, 14, 2],\n  [18, 12, 5],\n  [19, 13, 5], [19, 16, 3],\n  [20, 14, 5],\n  [21, 14, 3], [21, 13, 3], [21, 12, 2],\n  [22, 16, 5],\n  [23, 12, 4], [23, 13, 4], [23, 14, 4], [23, 15, 2],\n];\n\n// Degree (in + out) per node — drives marker size and hub-label selection\nconst degree = new Array(nodes.length).fill(0);\nedges.forEach(([a, b]) => {\n  degree[a] += 1;\n  degree[b] += 1;\n});\nconst nodeRadius = (id) => 9 + degree[id] * 1.9;\n\n// Top-3 most-connected modules — labeled directly since the full 24-name set\n// would clutter the canvas, but the central hubs are worth naming.\nconst hubs = [...nodes].sort((a, b) => degree[b.id] - degree[a.id]).slice(0, 3);\n\n// --- Force-directed layout (Fruchterman-Reingold), deterministic via a\n// fixed-seed LCG. Edge weight scales the attractive force so tightly-coupled\n// modules are pulled closer together than loosely-coupled ones. ---\nfunction lcg(seed) {\n  let s = seed;\n  return () => {\n    s = (s * 1664525 + 1013904223) % 4294967296;\n    return s / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst nodeCount = nodes.length;\nconst area = 4; // layout unfolds inside a [-1, 1] x [-1, 1] square\n// A slightly stronger repulsion constant than the classic sqrt(area/n) gives\n// dense hub neighborhoods (catalog-svc/checkout-svc/user-svc + shared infra)\n// more breathing room so individual edges stay traceable at a glance.\nconst k = Math.sqrt(area / nodeCount) * 1.3;\nconst pos = nodes.map(() => ({ x: rand() * 2 - 1, y: rand() * 2 - 1 }));\n\nlet temperature = 0.15;\nconst iterations = 400;\nfor (let iter = 0; iter < iterations; iter++) {\n  const disp = pos.map(() => ({ x: 0, y: 0 }));\n\n  // Repulsion between every pair of nodes keeps clusters from collapsing\n  for (let i = 0; i < nodeCount; i++) {\n    for (let j = i + 1; j < nodeCount; j++) {\n      let dx = pos[i].x - pos[j].x;\n      let dy = pos[i].y - pos[j].y;\n      const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01);\n      const force = (k * k) / dist;\n      dx = (dx / dist) * force;\n      dy = (dy / dist) * force;\n      disp[i].x += dx;\n      disp[i].y += dy;\n      disp[j].x -= dx;\n      disp[j].y -= dy;\n    }\n  }\n\n  // Attraction along edges, scaled by coupling weight\n  edges.forEach(([a, b, weight]) => {\n    let dx = pos[a].x - pos[b].x;\n    let dy = pos[a].y - pos[b].y;\n    const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01);\n    const force = ((dist * dist) / k) * (0.5 + weight / 5);\n    dx = (dx / dist) * force;\n    dy = (dy / dist) * force;\n    disp[a].x -= dx;\n    disp[a].y -= dy;\n    disp[b].x += dx;\n    disp[b].y += dy;\n  });\n\n  // Weak centering (gravity) force pulls every node gently toward the\n  // origin so no straggling branch drifts the whole point cloud toward one\n  // corner, which otherwise leaves the opposite quadrant of the canvas\n  // empty once the layout stabilizes.\n  for (let i = 0; i < nodeCount; i++) {\n    disp[i].x -= pos[i].x * 0.02;\n    disp[i].y -= pos[i].y * 0.02;\n  }\n\n  // Apply displacement, capped by the cooling temperature\n  for (let i = 0; i < nodeCount; i++) {\n    const d = Math.max(Math.sqrt(disp[i].x ** 2 + disp[i].y ** 2), 0.0001);\n    pos[i].x += (disp[i].x / d) * Math.min(d, temperature);\n    pos[i].y += (disp[i].y / d) * Math.min(d, temperature);\n  }\n  temperature *= 0.99;\n}\n\n// Fit each axis to its own extent (rather than a shared symmetric bound) so\n// a single peripheral node on one axis doesn't force empty padding on the\n// other — network layout coordinates are arbitrary, so independent x/y\n// scaling fills the canvas without implying a false distance metric.\nconst minX = Math.min(...pos.map((p) => p.x));\nconst maxX = Math.max(...pos.map((p) => p.x));\nconst minY = Math.min(...pos.map((p) => p.y));\nconst maxY = Math.max(...pos.map((p) => p.y));\nconst padX = (maxX - minX) * 0.08;\nconst padY = (maxY - minY) * 0.08;\nconst xRange = { min: minX - padX, max: maxX + padX };\nconst yRange = { min: minY - padY, max: maxY + padY };\nnodes.forEach((node, i) => {\n  node.x = pos[i].x;\n  node.y = pos[i].y;\n});\n\n// --- Mount ---\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Edges: drawn under the node markers via a lightweight inline plugin.\n// Each edge carries a small arrowhead at the dependency (target) end so the\n// static PNG conveys direction, not just adjacency. Thickness and opacity\n// scale with coupling weight. ---\nconst edgePlugin = {\n  id: \"networkEdges\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.fillStyle = t.inkSoft;\n    edges.forEach(([a, b, weight]) => {\n      const x1 = scales.x.getPixelForValue(nodes[a].x);\n      const y1 = scales.y.getPixelForValue(nodes[a].y);\n      const x2 = scales.x.getPixelForValue(nodes[b].x);\n      const y2 = scales.y.getPixelForValue(nodes[b].y);\n      const angle = Math.atan2(y2 - y1, x2 - x1);\n      const rTarget = nodeRadius(b) + 2;\n      const tipX = x2 - Math.cos(angle) * rTarget;\n      const tipY = y2 - Math.sin(angle) * rTarget;\n\n      ctx.globalAlpha = 0.2 + weight * 0.11;\n      ctx.lineWidth = 1 + weight * 0.45;\n      ctx.beginPath();\n      ctx.moveTo(x1, y1);\n      ctx.lineTo(tipX, tipY);\n      ctx.stroke();\n\n      const arrowLen = 7 + weight * 0.6;\n      ctx.beginPath();\n      ctx.moveTo(tipX, tipY);\n      ctx.lineTo(tipX - arrowLen * Math.cos(angle - Math.PI / 7), tipY - arrowLen * Math.sin(angle - Math.PI / 7));\n      ctx.lineTo(tipX - arrowLen * Math.cos(angle + Math.PI / 7), tipY - arrowLen * Math.sin(angle + Math.PI / 7));\n      ctx.closePath();\n      ctx.fill();\n    });\n    ctx.restore();\n  },\n};\n\n// --- Hub labels: name tags for the three highest-degree modules, drawn on\n// top of everything so the static PNG identifies the central dependencies ---\nconst hubLabelPlugin = {\n  id: \"networkHubLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.font = \"600 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"bottom\";\n    hubs.forEach((node) => {\n      const x = scales.x.getPixelForValue(node.x);\n      const y = scales.y.getPixelForValue(node.y) - nodeRadius(node.id) - 6;\n      const text = node.name;\n      const padX = 5;\n      const { width } = ctx.measureText(text);\n      ctx.fillStyle = t.pageBg;\n      ctx.globalAlpha = 0.85;\n      ctx.fillRect(x - width / 2 - padX, y - 15, width + padX * 2, 18);\n      ctx.globalAlpha = 1;\n      ctx.fillStyle = t.ink;\n      ctx.fillText(text, x, y);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Nodes: one dataset per architectural layer so the legend reads as\n// layer color ---\nconst layerNodes = LAYER_NAMES.map((_, l) => nodes.filter((node) => node.layer === l));\nconst datasets = layerNodes.map((layer, l) => ({\n  label: LAYER_NAMES[l],\n  data: layer.map((node) => ({ x: node.x, y: node.y })),\n  backgroundColor: t.palette[l],\n  borderColor: t.pageBg,\n  borderWidth: 2,\n  pointRadius: layer.map((node) => nodeRadius(node.id)),\n  pointHoverRadius: layer.map((node) => nodeRadius(node.id) + 4),\n  showLine: false,\n}));\n\n// --- Chart ---\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  plugins: [edgePlugin, hubLabelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: {\n      padding: { top: 10, right: 30, bottom: 20, left: 30 },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"network-force-directed · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"normal\" },\n        padding: { top: 12, bottom: 16 },\n      },\n      legend: {\n        display: true,\n        position: \"bottom\",\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true, boxWidth: 10 },\n      },\n      tooltip: {\n        callbacks: {\n          title: (items) => (items.length ? LAYER_NAMES[items[0].datasetIndex] : \"\"),\n          label: (item) => {\n            const node = layerNodes[item.datasetIndex][item.dataIndex];\n            return `${node.name} — ${degree[node.id]} dependencies`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: { display: false, min: xRange.min, max: xRange.max },\n      y: { display: false, min: yRange.min, max: yRange.max },\n    },\n  },\n});\n"}