{"spec_id":"network-directed","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// network-directed: Directed Network Graph\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-05\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: software module import graph -------------------------------------\n// Layered left-to-right by build order; an edge (from, to) means \"from is\n// imported by to\", so the arrow flows from the dependency toward the\n// dependent module. `weight` is the spec's optional edge-weight field,\n// encoded here as line thickness (heavier coupling -> thicker edge).\n// `curve` bows an edge away from the straight line between its endpoints\n// (as a fraction of the edge length) so long \"skip\" edges that would\n// otherwise cut straight through an unrelated node (near db/validator)\n// read unambiguously instead of looking like they touch that node.\nconst nodes = [\n  { id: \"utils\", x: 0, y: 1.0 },\n  { id: \"config\", x: 0, y: 3.6 },\n  { id: \"logger\", x: 1, y: 0.4 },\n  { id: \"validator\", x: 1, y: 2.6 },\n  { id: \"db\", x: 2, y: 1.6 },\n  { id: \"cache\", x: 2, y: 3.9 },\n  { id: \"auth\", x: 3, y: 1.0 },\n  { id: \"middleware\", x: 3, y: 3.2 },\n  { id: \"api\", x: 4, y: 2.1 },\n  { id: \"router\", x: 5, y: 0.9 },\n  { id: \"ui\", x: 5, y: 3.3 },\n];\n\nconst edges = [\n  { from: \"utils\", to: \"logger\", weight: 2, curve: 0 },\n  { from: \"utils\", to: \"validator\", weight: 3, curve: 0 },\n  { from: \"config\", to: \"db\", weight: 4, curve: 0.24 },\n  { from: \"config\", to: \"cache\", weight: 2, curve: 0 },\n  { from: \"logger\", to: \"db\", weight: 3, curve: 0 },\n  { from: \"logger\", to: \"middleware\", weight: 2, curve: 0.22 },\n  { from: \"validator\", to: \"auth\", weight: 4, curve: -0.24 },\n  { from: \"db\", to: \"auth\", weight: 3, curve: 0 },\n  { from: \"cache\", to: \"middleware\", weight: 2, curve: 0 },\n  { from: \"auth\", to: \"api\", weight: 5, curve: 0 },\n  { from: \"middleware\", to: \"api\", weight: 4, curve: 0 },\n  { from: \"api\", to: \"router\", weight: 3, curve: 0 },\n  { from: \"api\", to: \"ui\", weight: 3, curve: 0 },\n];\n\nconst nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]));\n\n// Node radius scales with total degree (in + out): hub modules that many\n// others depend on (or that depend on many others) read as visually larger,\n// while leaf/sink modules stay compact -- a size-based hierarchy cue instead\n// of a uniform flat marker.\nnodes.forEach((n) => (n.degree = 0));\nedges.forEach(({ from, to }) => {\n  nodeById[from].degree += 1;\n  nodeById[to].degree += 1;\n});\nnodes.forEach((n) => (n.radius = 22 + n.degree * 4));\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Arrowhead helper ---------------------------------------------------------\nfunction drawArrowhead(ctx, endX, endY, angle, arrowLength, arrowSpread) {\n  const tipX = endX + Math.cos(angle) * 10;\n  const tipY = endY + Math.sin(angle) * 10;\n  ctx.beginPath();\n  ctx.moveTo(tipX, tipY);\n  ctx.lineTo(\n    tipX - arrowLength * Math.cos(angle - arrowSpread),\n    tipY - arrowLength * Math.sin(angle - arrowSpread),\n  );\n  ctx.lineTo(\n    tipX - arrowLength * Math.cos(angle + arrowSpread),\n    tipY - arrowLength * Math.sin(angle + arrowSpread),\n  );\n  ctx.closePath();\n  ctx.fill();\n}\n\n// --- Directed-edge plugin: arrows + node labels around the scatter dataset ---\nconst directedEdgesPlugin = {\n  id: \"directedEdges\",\n  beforeDatasetsDraw(chart) {\n    const {\n      ctx,\n      scales: { x: xScale, y: yScale },\n    } = chart;\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.fillStyle = t.inkSoft;\n    ctx.globalAlpha = 0.65;\n    const arrowSpread = Math.PI / 7;\n\n    edges.forEach(({ from, to, weight, curve }) => {\n      const s = nodeById[from];\n      const d = nodeById[to];\n      const x1 = xScale.getPixelForValue(s.x);\n      const y1 = yScale.getPixelForValue(s.y);\n      const x2 = xScale.getPixelForValue(d.x);\n      const y2 = yScale.getPixelForValue(d.y);\n      const lineWidth = 1.2 + weight * 0.7;\n      const arrowLength = 12 + weight;\n      ctx.lineWidth = lineWidth;\n\n      if (curve === 0) {\n        const angle = Math.atan2(y2 - y1, x2 - x1);\n        const startX = x1 + Math.cos(angle) * s.radius;\n        const startY = y1 + Math.sin(angle) * s.radius;\n        const endX = x2 - Math.cos(angle) * (d.radius + 10);\n        const endY = y2 - Math.sin(angle) * (d.radius + 10);\n\n        ctx.beginPath();\n        ctx.moveTo(startX, startY);\n        ctx.lineTo(endX, endY);\n        ctx.stroke();\n        drawArrowhead(ctx, endX, endY, angle, arrowLength, arrowSpread);\n        return;\n      }\n\n      // Quadratic bezier: control point offset perpendicular to the\n      // straight source->target line, bowing the edge clear of nodes it\n      // would otherwise pass close to.\n      const dx = x2 - x1;\n      const dy = y2 - y1;\n      const len = Math.hypot(dx, dy);\n      const midX = (x1 + x2) / 2;\n      const midY = (y1 + y2) / 2;\n      const perpX = -dy / len;\n      const perpY = dx / len;\n      const ctrlX = midX + perpX * len * curve;\n      const ctrlY = midY + perpY * len * curve;\n\n      // Tangent at t=0 points from the node center toward the control\n      // point; tangent at t=1 points from the control point toward the\n      // target center -- exact for a quadratic bezier, so trimming and the\n      // arrowhead angle both stay aligned with the drawn curve.\n      const startAngle = Math.atan2(ctrlY - y1, ctrlX - x1);\n      const endAngle = Math.atan2(y2 - ctrlY, x2 - ctrlX);\n      const startX = x1 + Math.cos(startAngle) * s.radius;\n      const startY = y1 + Math.sin(startAngle) * s.radius;\n      const endX = x2 - Math.cos(endAngle) * (d.radius + 10);\n      const endY = y2 - Math.sin(endAngle) * (d.radius + 10);\n\n      ctx.beginPath();\n      ctx.moveTo(startX, startY);\n      ctx.quadraticCurveTo(ctrlX, ctrlY, endX, endY);\n      ctx.stroke();\n      drawArrowhead(ctx, endX, endY, endAngle, arrowLength, arrowSpread);\n    });\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const {\n      ctx,\n      scales: { x: xScale, y: yScale },\n    } = chart;\n    ctx.save();\n    ctx.font = \"600 16px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"top\";\n    nodes.forEach((n) => {\n      const px = xScale.getPixelForValue(n.x);\n      const py = yScale.getPixelForValue(n.y);\n      ctx.fillText(n.id, px, py + n.radius + 8);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Module\",\n        data: nodes.map((n) => ({ x: n.x, y: n.y })),\n        backgroundColor: t.palette[0],\n        borderColor: t.pageBg,\n        borderWidth: 3,\n        pointRadius: nodes.map((n) => n.radius),\n        pointHoverRadius: nodes.map((n) => n.radius),\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 40 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"network-directed · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { display: false, min: -0.6, max: 5.6 },\n      y: { display: false, min: -0.6, max: 4.5 },\n    },\n  },\n  plugins: [directedEdgesPlugin],\n});\n"}