{"spec_id":"network-weighted","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// network-weighted: Weighted Network Graph with Edge Thickness\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: annual bilateral trade volume between major economies ($B) ------\nconst nodes = [\n  { id: \"USA\" },\n  { id: \"CHN\" },\n  { id: \"DEU\" },\n  { id: \"JPN\" },\n  { id: \"GBR\" },\n  { id: \"FRA\" },\n  { id: \"IND\" },\n  { id: \"BRA\" },\n  { id: \"CAN\" },\n  { id: \"KOR\" },\n  { id: \"MEX\" },\n  { id: \"ITA\" },\n  { id: \"NLD\" },\n  { id: \"SGP\" },\n];\n\nconst rawEdges = [\n  [\"USA\", \"CAN\", 780],\n  [\"USA\", \"MEX\", 740],\n  [\"USA\", \"CHN\", 690],\n  [\"USA\", \"JPN\", 220],\n  [\"USA\", \"DEU\", 210],\n  [\"USA\", \"KOR\", 170],\n  [\"USA\", \"GBR\", 150],\n  [\"CHN\", \"JPN\", 340],\n  [\"CHN\", \"KOR\", 300],\n  [\"CHN\", \"DEU\", 260],\n  [\"CHN\", \"BRA\", 150],\n  [\"CHN\", \"SGP\", 130],\n  [\"CHN\", \"IND\", 115],\n  [\"DEU\", \"NLD\", 210],\n  [\"DEU\", \"FRA\", 190],\n  [\"DEU\", \"ITA\", 160],\n  [\"DEU\", \"GBR\", 140],\n  [\"FRA\", \"GBR\", 95],\n  [\"FRA\", \"ITA\", 90],\n  [\"GBR\", \"NLD\", 75],\n  [\"JPN\", \"KOR\", 85],\n  [\"GBR\", \"IND\", 40],\n  [\"NLD\", \"SGP\", 45],\n  [\"MEX\", \"BRA\", 12],\n];\n\n// --- Weighted-degree (node \"importance\") and index lookup ------------------\nconst idIndex = new Map(nodes.map((n, i) => [n.id, i]));\nconst links = rawEdges.map(([s, d, w]) => ({ s: idIndex.get(s), d: idIndex.get(d), w }));\n\nconst degree = new Array(nodes.length).fill(0);\nconst adjacency = nodes.map(() => []);\nlinks.forEach(({ s, d, w }) => {\n  degree[s] += w;\n  degree[d] += w;\n  adjacency[s].push(d);\n  adjacency[d].push(s);\n});\n\n// --- Force-directed layout (deterministic: circular seed, no RNG) ----------\n// Fruchterman-Reingold style simulation. Edge weight biases the attractive\n// force so heavily-traded pairs are pulled closer together, per spec notes.\n// A stronger repulsion constant (vs. the textbook sqrt(1/n)) keeps sparsely\n// connected nodes from collapsing into the dense center, so density stays\n// balanced across the square canvas rather than clumping one side.\nconst n = nodes.length;\nconst pos = nodes.map((_, i) => {\n  const angle = (2 * Math.PI * i) / n;\n  return { x: Math.cos(angle), y: Math.sin(angle) };\n});\n\nconst k = Math.sqrt(9 / n);\nconst maxW = Math.max(...links.map((l) => l.w));\nconst minW = Math.min(...links.map((l) => l.w));\nlet temperature = 0.12;\n\nfor (let iter = 0; iter < 300; iter++) {\n  const disp = pos.map(() => ({ x: 0, y: 0 }));\n\n  for (let i = 0; i < n; i++) {\n    for (let j = i + 1; j < n; j++) {\n      const dx = pos[i].x - pos[j].x;\n      const dy = pos[i].y - pos[j].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) || 1e-4;\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  links.forEach(({ s, d, w }) => {\n    const dx = pos[s].x - pos[d].x;\n    const dy = pos[s].y - pos[d].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) || 1e-4;\n    const strength = 0.5 + 0.9 * ((w - minW) / (maxW - minW || 1));\n    const force = ((dist * dist) / k) * strength;\n    const ux = dx / dist;\n    const uy = dy / dist;\n    disp[s].x -= ux * force;\n    disp[s].y -= uy * force;\n    disp[d].x += ux * force;\n    disp[d].y += uy * force;\n  });\n\n  for (let i = 0; i < n; i++) {\n    const len = Math.sqrt(disp[i].x ** 2 + disp[i].y ** 2) || 1e-4;\n    pos[i].x += (disp[i].x / len) * Math.min(len, temperature);\n    pos[i].y += (disp[i].y / len) * Math.min(len, temperature);\n  }\n  temperature *= 0.98;\n}\n\n// --- Fit layout to the canvas — each axis scaled to its own extent, since a\n// network diagram encodes topology, not metric distance, so isotropy isn't\n// required and independent-axis fitting uses the square canvas fully.\nconst xs = pos.map((p) => p.x);\nconst ys = pos.map((p) => p.y);\nconst cx = (Math.min(...xs) + Math.max(...xs)) / 2;\nconst cy = (Math.min(...ys) + Math.max(...ys)) / 2;\nconst halfX = (Math.max(...xs) - Math.min(...xs)) / 2 * 1.16 + 0.11;\nconst halfY = (Math.max(...ys) - Math.min(...ys)) / 2 * 1.16 + 0.11;\n\n// --- Visual scales: node radius from weighted degree, edge width from weight\nconst minDeg = Math.min(...degree);\nconst maxDeg = Math.max(...degree);\nconst nodeRadius = degree.map((deg) => {\n  const norm = (deg - minDeg) / (maxDeg - minDeg || 1);\n  return 13 + Math.sqrt(norm) * 17; // 13 .. 30 CSS px\n});\n\nfunction edgeWidth(w) {\n  const norm = (w - minW) / (maxW - minW || 1);\n  return 2 + norm * 12; // 2 .. 14 CSS px — distinguishable, never extreme\n}\n\n// The single heaviest trade corridor gets a subtle opacity boost (not a hue\n// change, so the single-series CVD-safe encoding is untouched) — a small\n// extra focal point beyond size/thickness alone, per the review's DE-03 note.\nconst heaviestW = maxW;\n\n// --- Custom plugin: draws edges beneath nodes, labels + legend above -------\nconst networkLayer = {\n  id: \"networkLayer\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    links.forEach(({ s, d, w }) => {\n      const x1 = scales.x.getPixelForValue(pos[s].x);\n      const y1 = scales.y.getPixelForValue(pos[s].y);\n      const x2 = scales.x.getPixelForValue(pos[d].x);\n      const y2 = scales.y.getPixelForValue(pos[d].y);\n      const norm = (w - minW) / (maxW - minW || 1);\n      ctx.beginPath();\n      ctx.moveTo(x1, y1);\n      ctx.lineTo(x2, y2);\n      ctx.lineWidth = edgeWidth(w);\n      ctx.lineCap = \"round\";\n      ctx.strokeStyle = t.ink;\n      ctx.globalAlpha = w === heaviestW ? 0.9 : 0.2 + norm * 0.55;\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, scales, chartArea } = chart;\n    ctx.save();\n\n    // node id labels — anchored in the widest open angular gap between a\n    // node's incident edges (falling back to straight south for isolated\n    // nodes), plus a page-background halo behind the text, so a label never\n    // visually merges with an edge stroke crossing beneath it.\n    const px = pos.map((p) => scales.x.getPixelForValue(p.x));\n    const py = pos.map((p) => scales.y.getPixelForValue(p.y));\n\n    ctx.font = \"600 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.lineJoin = \"round\";\n    nodes.forEach((node, i) => {\n      const neighbors = adjacency[i];\n      let labelAngle = Math.PI / 2; // default: straight down\n      if (neighbors.length > 0) {\n        const angles = neighbors\n          .map((j) => Math.atan2(py[j] - py[i], px[j] - px[i]))\n          .sort((a, b) => a - b);\n        let bestGap = -Infinity;\n        let bestMid = labelAngle;\n        for (let gi = 0; gi < angles.length; gi++) {\n          const a0 = angles[gi];\n          const a1 = angles[(gi + 1) % angles.length];\n          const gap = ((a1 - a0 + 2 * Math.PI) % (2 * Math.PI)) || 2 * Math.PI;\n          if (gap > bestGap) {\n            bestGap = gap;\n            bestMid = a0 + gap / 2;\n          }\n        }\n        labelAngle = bestMid;\n      }\n      const offset = nodeRadius[i] + 18;\n      const lx = px[i] + Math.cos(labelAngle) * offset;\n      const ly = py[i] + Math.sin(labelAngle) * offset;\n\n      ctx.lineWidth = 4;\n      ctx.strokeStyle = t.pageBg;\n      ctx.strokeText(node.id, lx, ly);\n      ctx.fillStyle = t.inkSoft;\n      ctx.fillText(node.id, lx, ly);\n    });\n\n    // edge-weight legend\n    const legendW = 300;\n    const legendH = 132;\n    const lx = chartArea.left + 8;\n    const ly = chartArea.top + 8;\n\n    ctx.fillStyle = t.elevatedBg;\n    ctx.fillRect(lx, ly, legendW, legendH);\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(lx, ly, legendW, legendH);\n\n    ctx.textAlign = \"left\";\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 15px sans-serif\";\n    ctx.fillText(\"Trade volume ($B)\", lx + 16, ly + 28);\n\n    const samples = [minW, (minW + maxW) / 2, maxW];\n    samples.forEach((w, i) => {\n      const rowY = ly + 56 + i * 26;\n      const norm = (w - minW) / (maxW - minW || 1);\n\n      ctx.globalAlpha = 0.2 + norm * 0.55;\n      ctx.strokeStyle = t.ink;\n      ctx.lineWidth = edgeWidth(w);\n      ctx.lineCap = \"round\";\n      ctx.beginPath();\n      ctx.moveTo(lx + 16, rowY);\n      ctx.lineTo(lx + 58, rowY);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n\n      ctx.fillStyle = t.inkSoft;\n      ctx.font = \"13px sans-serif\";\n      ctx.fillText(`$${Math.round(w)}B`, lx + 70, rowY + 4);\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Countries\",\n        data: pos,\n        pointRadius: nodeRadius,\n        pointHoverRadius: nodeRadius,\n        backgroundColor: t.palette[0],\n        borderColor: t.pageBg,\n        borderWidth: 2,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 8 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"network-weighted · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { bottom: 4 },\n      },\n      subtitle: {\n        display: true,\n        text: \"Edge thickness = trade volume · node size = total trade across all partners\",\n        color: t.inkSoft,\n        font: { size: 14, weight: \"normal\" },\n        padding: { bottom: 12 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { type: \"linear\", min: cx - halfX, max: cx + halfX, display: false },\n      y: { type: \"linear\", min: cy - halfY, max: cy + halfY, display: false },\n    },\n  },\n  plugins: [networkLayer],\n});\n"}