{"spec_id":"network-weighted","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// network-weighted: Weighted Network Graph with Edge Thickness\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n\n// Only the core Highcharts bundle is loaded (no `networkgraph` module), so\n// node positions are computed here with a Fruchterman-Reingold force-directed\n// layout — edge weight feeds directly into the attractive force so heavily\n// traded pairs are pulled closer together, not just drawn thicker. Every edge\n// is its own two-point `line` series so width and opacity can scale\n// continuously with weight (a single series can't vary per-segment).\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: bilateral goods-trade volume among 15 major economies (USD billions, approx.) ---\nconst NODES = [\n  { id: \"USA\", region: \"North America\" },\n  { id: \"Canada\", region: \"North America\" },\n  { id: \"Mexico\", region: \"North America\" },\n  { id: \"Brazil\", region: \"South America\" },\n  { id: \"Germany\", region: \"Europe\" },\n  { id: \"France\", region: \"Europe\" },\n  { id: \"UK\", region: \"Europe\" },\n  { id: \"Netherlands\", region: \"Europe\" },\n  { id: \"Italy\", region: \"Europe\" },\n  { id: \"China\", region: \"Asia\" },\n  { id: \"Japan\", region: \"Asia\" },\n  { id: \"South Korea\", region: \"Asia\" },\n  { id: \"India\", region: \"Asia\" },\n  { id: \"Singapore\", region: \"Asia\" },\n  { id: \"Australia\", region: \"Oceania\" },\n];\n\nconst EDGES = [\n  [\"USA\", \"China\", 575], [\"USA\", \"Mexico\", 780], [\"USA\", \"Canada\", 770],\n  [\"USA\", \"Germany\", 200], [\"USA\", \"Japan\", 220], [\"USA\", \"UK\", 140],\n  [\"USA\", \"India\", 120], [\"USA\", \"South Korea\", 130], [\"USA\", \"Brazil\", 90],\n  [\"USA\", \"Singapore\", 50], [\"Canada\", \"China\", 100], [\"Mexico\", \"China\", 100],\n  [\"China\", \"Germany\", 260], [\"China\", \"Japan\", 210], [\"China\", \"South Korea\", 300],\n  [\"China\", \"Australia\", 220], [\"China\", \"Netherlands\", 100], [\"China\", \"Brazil\", 150],\n  [\"China\", \"India\", 115], [\"China\", \"Singapore\", 90], [\"Germany\", \"France\", 170],\n  [\"Germany\", \"Netherlands\", 190], [\"Germany\", \"Italy\", 130], [\"Germany\", \"UK\", 130],\n  [\"France\", \"UK\", 90], [\"France\", \"Italy\", 80], [\"Netherlands\", \"UK\", 70],\n  [\"Japan\", \"South Korea\", 80], [\"Japan\", \"Australia\", 60],\n];\n\n// Weighted degree (sum of incident trade volume) drives node size — a hub\n// with many small links can rank below a pair with one dominant trade lane.\nconst weightedDegree = {};\nNODES.forEach((node) => { weightedDegree[node.id] = 0; });\nEDGES.forEach(([a, b, w]) => {\n  weightedDegree[a] += w;\n  weightedDegree[b] += w;\n});\nconst degreeValues = Object.values(weightedDegree);\nconst minDegree = Math.min(...degreeValues);\nconst maxDegree = Math.max(...degreeValues);\nfunction nodeRadius(id) {\n  const norm = (weightedDegree[id] - minDegree) / (maxDegree - minDegree);\n  return 14 + Math.sqrt(norm) * (34 - 14);\n}\n// All country labels share one size — node radius alone carries the degree\n// hierarchy, so the labels stay uniform and easy to scan.\nconst NODE_LABEL_SIZE = 13;\n\n// Edge weight -> line width / opacity, both scaled continuously (never a\n// fixed handful of tiers) so the thickness itself communicates magnitude.\nconst edgeWeights = EDGES.map(([, , w]) => w);\nconst minWeight = Math.min(...edgeWeights);\nconst maxWeight = Math.max(...edgeWeights);\nfunction edgeWidth(w) {\n  const norm = (w - minWeight) / (maxWeight - minWeight);\n  return 1.25 + norm * (9 - 1.25);\n}\nfunction edgeAlpha(w) {\n  const norm = (w - minWeight) / (maxWeight - minWeight);\n  return 0.2 + norm * (0.75 - 0.2);\n}\n\n// --- Force-directed layout (Fruchterman-Reingold, weight-aware attraction) --\nconst AREA = 100;\nconst idealDistance = Math.sqrt((AREA * AREA) / NODES.length);\nconst avgWeight = edgeWeights.reduce((sum, w) => sum + w, 0) / edgeWeights.length;\nconst pos = {};\nNODES.forEach((node, i) => {\n  const angle = (i / NODES.length) * 2 * Math.PI;\n  pos[node.id] = { x: 42 * Math.cos(angle), y: 42 * Math.sin(angle) };\n});\n\nlet temperature = AREA / 10;\nfor (let iter = 0; iter < 350; iter += 1) {\n  const disp = {};\n  NODES.forEach((node) => { disp[node.id] = { x: 0, y: 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 a = NODES[i].id;\n      const b = NODES[j].id;\n      const dx = pos[a].x - pos[b].x;\n      const dy = pos[a].y - pos[b].y;\n      const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01);\n      const force = (idealDistance * idealDistance) / dist;\n      disp[a].x += (dx / dist) * force;\n      disp[a].y += (dy / dist) * force;\n      disp[b].x -= (dx / dist) * force;\n      disp[b].y -= (dy / dist) * force;\n    }\n  }\n\n  // Attraction scales with edge weight relative to the network average — a\n  // trade lane twice the average volume pulls its two endpoints twice as\n  // hard, so heavily-linked economies cluster while thin ties stay loose.\n  EDGES.forEach(([a, b, w]) => {\n    const dx = pos[a].x - pos[b].x;\n    const dy = pos[a].y - pos[b].y;\n    const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01);\n    const weightFactor = w / avgWeight;\n    const force = ((dist * dist) / idealDistance) * weightFactor;\n    disp[a].x -= (dx / dist) * force;\n    disp[a].y -= (dy / dist) * force;\n    disp[b].x += (dx / dist) * force;\n    disp[b].y += (dy / dist) * force;\n  });\n\n  NODES.forEach((node) => {\n    const dx = disp[node.id].x;\n    const dy = disp[node.id].y;\n    const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01);\n    const capped = Math.min(dist, temperature);\n    pos[node.id].x += (dx / dist) * capped;\n    pos[node.id].y += (dy / dist) * capped;\n  });\n  temperature *= 0.97;\n}\n\n// Weighted-degree hubs sit close to the centroid while lightly-connected\n// nodes (Singapore, Italy) settle far outside the core under pure repulsion —\n// a continuous spread, not just one or two outliers. A min/max rescale\n// stretches the whole canvas to fit the single farthest node, cramming the\n// rest of the network into a small central patch and leaving wide empty\n// bands near the edges. Apply a radial power-law compression (exponent < 1)\n// around the centroid: distances shrink relative to each other the farther\n// out they are, so the far nodes move inward and the near ones spread out,\n// filling the canvas evenly while preserving each node's direction and\n// relative ordering from the centroid.\nconst centroid0 = { x: 0, y: 0 };\nNODES.forEach((node) => {\n  centroid0.x += pos[node.id].x / NODES.length;\n  centroid0.y += pos[node.id].y / NODES.length;\n});\nconst distsFromCentroid = NODES.map((node) => {\n  const dx = pos[node.id].x - centroid0.x;\n  const dy = pos[node.id].y - centroid0.y;\n  return Math.sqrt(dx * dx + dy * dy);\n}).sort((a, b) => a - b);\nconst medianDist = distsFromCentroid[Math.floor(distsFromCentroid.length / 2)];\nconst RADIAL_COMPRESSION = 0.6;\nNODES.forEach((node) => {\n  const dx = pos[node.id].x - centroid0.x;\n  const dy = pos[node.id].y - centroid0.y;\n  const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01);\n  const newDist = medianDist * (dist / medianDist) ** RADIAL_COMPRESSION;\n  const scale = newDist / dist;\n  pos[node.id].x = centroid0.x + dx * scale;\n  pos[node.id].y = centroid0.y + dy * scale;\n});\n\n// The layout has no fixed boundary — recenter and rescale it into a known\n// frame before handing coordinates to the axes.\nlet minX = Infinity;\nlet maxX = -Infinity;\nlet minY = Infinity;\nlet maxY = -Infinity;\nNODES.forEach((node) => {\n  minX = Math.min(minX, pos[node.id].x);\n  maxX = Math.max(maxX, pos[node.id].x);\n  minY = Math.min(minY, pos[node.id].y);\n  maxY = Math.max(maxY, pos[node.id].y);\n});\n// Independent x/y scaling (rather than a shared aspect-preserving factor) —\n// force-directed positions only encode approximate proximity, not exact\n// distance, so stretching each axis to fill the square canvas is safe and\n// avoids leaving one axis mostly blank when the graph's natural bounding\n// box isn't itself square.\nconst centerX = (minX + maxX) / 2;\nconst centerY = (minY + maxY) / 2;\nconst scaleX = 48 / ((maxX - minX) / 2);\nconst scaleY = 48 / ((maxY - minY) / 2);\nNODES.forEach((node) => {\n  pos[node.id].x = (pos[node.id].x - centerX) * scaleX;\n  pos[node.id].y = (pos[node.id].y - centerY) * scaleY;\n});\n\n// --- Chart -------------------------------------------------------------------\nconst REGIONS = [\"North America\", \"South America\", \"Europe\", \"Asia\", \"Oceania\"];\nconst REGION_COLOR = {};\nconst REGION_SYMBOL = {};\nconst SYMBOLS = [\"circle\", \"square\", \"diamond\", \"triangle\", \"triangle-down\"];\nREGIONS.forEach((region, i) => {\n  REGION_COLOR[region] = t.palette[i];\n  REGION_SYMBOL[region] = SYMBOLS[i];\n});\n\nconst edgeSeries = EDGES.map(([a, b, w]) => ({\n  type: \"line\",\n  name: `${a} ↔ ${b}`,\n  data: [[pos[a].x, pos[a].y], [pos[b].x, pos[b].y]],\n  color: t.grid.replace(/[\\d.]+\\)$/, `${edgeAlpha(w)})`),\n  lineWidth: edgeWidth(w),\n  marker: { enabled: false },\n  enableMouseTracking: true,\n  stickyTracking: false,\n  showInLegend: false,\n  custom: { source: a, target: b, weight: w },\n  zIndex: 0,\n}));\n\nconst nodeSeries = REGIONS.map((region) => ({\n  type: \"scatter\",\n  name: region,\n  color: REGION_COLOR[region],\n  marker: { symbol: REGION_SYMBOL[region], lineColor: t.pageBg, lineWidth: 1.5 },\n  data: NODES.filter((node) => node.region === region).map((node) => {\n    const radius = nodeRadius(node.id);\n    return {\n      x: pos[node.id].x,\n      y: pos[node.id].y,\n      name: node.id,\n      custom: { weightedDegree: weightedDegree[node.id] },\n      marker: { radius },\n      dataLabels: { y: -(radius + 8), style: { fontSize: `${NODE_LABEL_SIZE}px` } },\n    };\n  }),\n  dataLabels: {\n    enabled: true,\n    format: \"{point.name}\",\n    allowOverlap: false,\n    style: { color: t.ink, fontWeight: \"normal\", textOutline: \"none\" },\n  },\n  zIndex: 1,\n}));\n\nHighcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  title: {\n    text: \"network-weighted · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Bilateral trade volume among 15 economies — edge thickness & opacity = USD billions traded, node size = weighted trade degree\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false, min: -58, max: 58 },\n  yAxis: { visible: false, min: -58, max: 58, title: { text: null } },\n  legend: {\n    enabled: true,\n    title: { text: \"Region\", style: { color: t.inkSoft, fontSize: \"13px\" } },\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: {\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink, fontSize: \"13px\" },\n    formatter: function formatTooltip() {\n      const seriesCustom = this.series.userOptions.custom;\n      if (seriesCustom && seriesCustom.weight !== undefined) {\n        return `<b>${seriesCustom.source} ↔ ${seriesCustom.target}</b><br/>Trade volume: $${seriesCustom.weight}B`;\n      }\n      return `<b>${this.point.name}</b><br/>Weighted trade degree: $${this.point.custom.weightedDegree}B`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: { states: { hover: { enabled: false } } },\n    line: { states: { hover: { enabled: false } } },\n  },\n  series: [...edgeSeries, ...nodeSeries],\n});\n"}