{"spec_id":"network-transport-static","library":"d3","language":"javascript","code":"// anyplot.ai\n// network-transport-static: Static Transport Network Diagram\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 90, right: 50, bottom: 95, left: 50 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: regional rail network (fixed layout, no force simulation) -------\nconst stations = [\n  { id: \"cen\", label: \"Central Station\", x: 500, y: 330 },\n  { id: \"noj\", label: \"North Junction\", x: 500, y: 90 },\n  { id: \"lak\", label: \"Lakeside\", x: 220, y: 160 },\n  { id: \"riv\", label: \"Riverside\", x: 830, y: 210 },\n  { id: \"egt\", label: \"Eastgate\", x: 900, y: 330 },\n  { id: \"wfd\", label: \"Westfield\", x: 130, y: 330 },\n  { id: \"spt\", label: \"Southport\", x: 320, y: 520 },\n  { id: \"hbv\", label: \"Harborview\", x: 680, y: 520 },\n  { id: \"mlb\", label: \"Millbrook\", x: 500, y: 600 },\n  { id: \"apt\", label: \"Airport\", x: 1040, y: 140 },\n  { id: \"olt\", label: \"Oldtown\", x: 20, y: 560 },\n  { id: \"grw\", label: \"Greenwood\", x: 760, y: 60 },\n];\n\nconst routes = [\n  { source: \"cen\", target: \"noj\", routeId: \"RE1\", dep: \"08:00\", arr: \"08:20\", type: \"regional\" },\n  { source: \"noj\", target: \"cen\", routeId: \"RE1\", dep: \"08:35\", arr: \"08:55\", type: \"regional\" },\n  { source: \"cen\", target: \"lak\", routeId: \"RE2\", dep: \"08:05\", arr: \"08:32\", type: \"regional\" },\n  { source: \"lak\", target: \"cen\", routeId: \"RE2\", dep: \"08:40\", arr: \"09:07\", type: \"regional\" },\n  { source: \"cen\", target: \"riv\", routeId: \"RE3\", dep: \"08:10\", arr: \"08:38\", type: \"regional\" },\n  { source: \"riv\", target: \"cen\", routeId: \"RE3\", dep: \"08:45\", arr: \"09:13\", type: \"regional\" },\n  { source: \"cen\", target: \"egt\", routeId: \"IC10\", dep: \"08:00\", arr: \"08:22\", type: \"express\" },\n  { source: \"egt\", target: \"cen\", routeId: \"IC10\", dep: \"08:30\", arr: \"08:52\", type: \"express\" },\n  { source: \"cen\", target: \"egt\", routeId: \"RE6\", dep: \"08:15\", arr: \"08:45\", type: \"regional\" },\n  { source: \"cen\", target: \"wfd\", routeId: \"S1\", dep: \"08:15\", arr: \"08:28\", type: \"local\" },\n  { source: \"wfd\", target: \"cen\", routeId: \"S1\", dep: \"08:35\", arr: \"08:48\", type: \"local\" },\n  { source: \"cen\", target: \"spt\", routeId: \"S2\", dep: \"08:20\", arr: \"08:42\", type: \"local\" },\n  { source: \"spt\", target: \"cen\", routeId: \"S2\", dep: \"08:50\", arr: \"09:12\", type: \"local\" },\n  { source: \"cen\", target: \"hbv\", routeId: \"RE4\", dep: \"08:25\", arr: \"08:53\", type: \"regional\" },\n  { source: \"hbv\", target: \"cen\", routeId: \"RE4\", dep: \"09:00\", arr: \"09:28\", type: \"regional\" },\n  { source: \"cen\", target: \"mlb\", routeId: \"S3\", dep: \"08:30\", arr: \"08:40\", type: \"local\" },\n  { source: \"noj\", target: \"grw\", routeId: \"RE5\", dep: \"08:40\", arr: \"08:58\", type: \"regional\" },\n  { source: \"grw\", target: \"apt\", routeId: \"IC11\", dep: \"09:05\", arr: \"09:22\", type: \"express\" },\n  { source: \"noj\", target: \"lak\", routeId: \"S4\", dep: \"08:50\", arr: \"09:05\", type: \"local\" },\n  { source: \"wfd\", target: \"olt\", routeId: \"S5\", dep: \"08:45\", arr: \"08:58\", type: \"local\" },\n  { source: \"olt\", target: \"wfd\", routeId: \"S5\", dep: \"09:05\", arr: \"09:18\", type: \"local\" },\n];\n\nconst typeColor = { regional: t.palette[0], express: t.palette[1], local: t.palette[2] };\nconst typeLabel = { regional: \"Regional\", express: \"Express\", local: \"Local\" };\nconst nodeRadius = 32;\n\n// --- Layout: map data coordinates into the drawing area, aspect preserved --\n// (a single uniform `scale` keeps xScale/yScale slopes equal, so d3.scaleLinear\n// can be used for both axes without distorting the network's shape)\nconst stationById = new Map(stations.map((s) => [s.id, s]));\nconst xExtent = d3.extent(stations, (d) => d.x);\nconst yExtent = d3.extent(stations, (d) => d.y);\nconst dataW = xExtent[1] - xExtent[0];\nconst dataH = yExtent[1] - yExtent[0];\nconst pad = nodeRadius + 45;\nconst scale = Math.min((iw - 2 * pad) / dataW, (ih - 2 * pad) / dataH);\nconst offsetX = (iw - dataW * scale) / 2;\nconst offsetY = (ih - dataH * scale) / 2;\nconst xScale = d3.scaleLinear().domain(xExtent).range([offsetX, offsetX + dataW * scale]);\nconst yScale = d3.scaleLinear().domain(yExtent).range([offsetY, offsetY + dataH * scale]);\nfor (const s of stations) {\n  s.cx = xScale(s.x);\n  s.cy = yScale(s.y);\n}\n\n// --- SVG mount --------------------------------------------------------------\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height)\n  .style(\"font-family\", \"'Inter', 'Helvetica Neue', Arial, sans-serif\");\nconst defs = svg.append(\"defs\");\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// arrowhead markers, one per route type color\nfor (const [key, color] of Object.entries(typeColor)) {\n  defs\n    .append(\"marker\")\n    .attr(\"id\", `arrow-${key}`)\n    .attr(\"viewBox\", \"0 -5 10 10\")\n    .attr(\"refX\", 9)\n    .attr(\"refY\", 0)\n    .attr(\"markerWidth\", 7)\n    .attr(\"markerHeight\", 7)\n    .attr(\"orient\", \"auto\")\n    .append(\"path\")\n    .attr(\"d\", \"M0,-5L10,0L0,5\")\n    .attr(\"fill\", color);\n}\n\n// --- Station nodes (drawn first so their footprint can repel edge labels;\n// raised above the edges/labels layers at the very end for correct z-order) --\nconst nodeLayer = g.append(\"g\").attr(\"class\", \"nodes\");\nconst nodeObstacles = [];\n\nconst nodeCircles = nodeLayer\n  .selectAll(\"circle.station\")\n  .data(stations)\n  .join(\"circle\")\n  .attr(\"class\", \"station\")\n  .attr(\"cx\", (d) => d.cx)\n  .attr(\"cy\", (d) => d.cy)\n  .attr(\"r\", nodeRadius)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 3);\nnodeCircles.append(\"title\").text((d) => d.label);\nstations.forEach((s) => nodeObstacles.push({ x: s.cx, y: s.cy, hw: nodeRadius + 10, hh: nodeRadius + 10 }));\n\nconst nodeLabelGroups = nodeLayer\n  .selectAll(\"g.station-label\")\n  .data(stations)\n  .join(\"g\")\n  .attr(\"class\", \"station-label\")\n  .attr(\"transform\", (d) => `translate(${d.cx},${d.cy + nodeRadius + 22})`);\nnodeLabelGroups\n  .append(\"text\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .style(\"font-size\", \"15px\")\n  .style(\"font-weight\", \"600\")\n  .attr(\"fill\", t.ink)\n  .text((d) => d.label);\nnodeLabelGroups.each(function (d) {\n  const group = d3.select(this);\n  const bbox = group.select(\"text\").node().getBBox();\n  group\n    .insert(\"rect\", \"text\")\n    .attr(\"x\", bbox.x - 6)\n    .attr(\"y\", bbox.y - 3)\n    .attr(\"width\", bbox.width + 12)\n    .attr(\"height\", bbox.height + 6)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", t.pageBg)\n    .attr(\"opacity\", 0.85);\n  nodeObstacles.push({ x: d.cx, y: d.cy + nodeRadius + 22, hw: bbox.width / 2 + 8, hh: bbox.height / 2 + 5 });\n});\n\n// --- Group routes by unordered station pair, so parallel routes fan out;\n// trim endpoints so each path (and arrowhead) stops at the node's edge -----\nconst trim = (p, towards) => {\n  const vx = towards.x - p.x;\n  const vy = towards.y - p.y;\n  const len = Math.sqrt(vx * vx + vy * vy) || 1;\n  return { x: p.x + (vx / len) * nodeRadius, y: p.y + (vy / len) * nodeRadius };\n};\n\nconst pairGroups = d3.group(routes, (r) => [r.source, r.target].sort().join(\"|\"));\nconst routeGeometry = [];\nfor (const [key, group] of pairGroups) {\n  const [aId, bId] = key.split(\"|\");\n  const a = stationById.get(aId);\n  const b = stationById.get(bId);\n  const dx = b.cx - a.cx;\n  const dy = b.cy - a.cy;\n  const dist = Math.sqrt(dx * dx + dy * dy) || 1;\n  const nx = -dy / dist;\n  const ny = dx / dist;\n  const step = 46;\n\n  group.forEach((route, i) => {\n    const offset = (i - (group.length - 1) / 2) * step;\n    const src = stationById.get(route.source);\n    const tgt = stationById.get(route.target);\n    const mid = { x: (src.cx + tgt.cx) / 2 + nx * offset, y: (src.cy + tgt.cy) / 2 + ny * offset };\n    const start = trim({ x: src.cx, y: src.cy }, mid);\n    const end = trim({ x: tgt.cx, y: tgt.cy }, mid);\n    // label anchored at the curve's midpoint (t=0.5 on the quadratic Bezier);\n    // exact placement is resolved below by the collision-avoidance pass, since\n    // labels from unrelated edges meeting at the same hub can also collide\n    const labelPoint = { x: 0.25 * src.cx + 0.5 * mid.x + 0.25 * tgt.cx, y: 0.25 * src.cy + 0.5 * mid.y + 0.25 * tgt.cy };\n    routeGeometry.push({ route, src, tgt, start, mid, end, labelPoint });\n  });\n}\n\nconst edgeLayer = g.append(\"g\").attr(\"class\", \"edges\");\nconst edgePaths = edgeLayer\n  .selectAll(\"path.route\")\n  .data(routeGeometry)\n  .join(\"path\")\n  .attr(\"class\", \"route\")\n  .attr(\"d\", (d) => `M${d.start.x},${d.start.y} Q${d.mid.x},${d.mid.y} ${d.end.x},${d.end.y}`)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", (d) => typeColor[d.route.type])\n  .attr(\"stroke-width\", 3)\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"marker-end\", (d) => `url(#arrow-${d.route.type})`);\nedgePaths\n  .append(\"title\")\n  .text((d) => `${d.route.routeId}: ${d.src.label} → ${d.tgt.label} (${d.route.dep} → ${d.route.arr})`);\n\nconst edgeLabelLayer = g.append(\"g\").attr(\"class\", \"edge-labels\");\nconst edgeLabelGroups = edgeLabelLayer\n  .selectAll(\"g.edge-label\")\n  .data(routeGeometry)\n  .join(\"g\")\n  .attr(\"class\", \"edge-label\");\nedgeLabelGroups\n  .append(\"text\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .style(\"font-size\", \"14px\")\n  .attr(\"fill\", t.inkSoft)\n  .text((d) => `${d.route.routeId} | ${d.route.dep} → ${d.route.arr}`);\n\nconst edgeLabels = [];\nedgeLabelGroups.each(function (d) {\n  const group = d3.select(this);\n  const bbox = group.select(\"text\").node().getBBox();\n  group\n    .insert(\"rect\", \"text\")\n    .attr(\"x\", bbox.x - 5)\n    .attr(\"y\", bbox.y - 3)\n    .attr(\"width\", bbox.width + 10)\n    .attr(\"height\", bbox.height + 6)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", t.elevatedBg)\n    .attr(\"opacity\", 0.92);\n  edgeLabels.push({ el: group, x: d.labelPoint.x, y: d.labelPoint.y, hw: bbox.width / 2 + 6, hh: bbox.height / 2 + 4 });\n});\n\n// declutter: nudge apart any edge labels whose padded boxes still overlap —\n// happens when several edges from the same hub meet at similar angles — and\n// push edge labels away from the (fixed) station circles/labels they land on.\n// Run as separate phases (not interleaved) so obstacle pushes can't undo a\n// pairwise fix (or vice versa) before the system settles.\nconst declutterPairs = () => {\n  for (let iter = 0; iter < 60; iter += 1) {\n    for (let i = 0; i < edgeLabels.length; i += 1) {\n      for (let j = i + 1; j < edgeLabels.length; j += 1) {\n        const p = edgeLabels[i];\n        const q = edgeLabels[j];\n        const dx = q.x - p.x || 1;\n        const dy = q.y - p.y || 1;\n        const overlapX = p.hw + q.hw - Math.abs(dx);\n        const overlapY = p.hh + q.hh - Math.abs(dy);\n        if (overlapX > 0 && overlapY > 0) {\n          if (overlapX < overlapY) {\n            const shift = (overlapX / 2) * Math.sign(dx);\n            p.x -= shift;\n            q.x += shift;\n          } else {\n            const shift = (overlapY / 2) * Math.sign(dy);\n            p.y -= shift;\n            q.y += shift;\n          }\n        }\n      }\n    }\n  }\n};\nconst declutterObstacles = () => {\n  for (let iter = 0; iter < 20; iter += 1) {\n    for (const p of edgeLabels) {\n      for (const obstacle of nodeObstacles) {\n        const dx = p.x - obstacle.x || 1;\n        const dy = p.y - obstacle.y || 1;\n        const overlapX = p.hw + obstacle.hw - Math.abs(dx);\n        const overlapY = p.hh + obstacle.hh - Math.abs(dy);\n        if (overlapX > 0 && overlapY > 0) {\n          if (overlapX < overlapY) {\n            p.x += overlapX * Math.sign(dx);\n          } else {\n            p.y += overlapY * Math.sign(dy);\n          }\n        }\n      }\n    }\n  }\n};\nfor (let round = 0; round < 5; round += 1) {\n  declutterPairs();\n  declutterObstacles();\n}\n\nfor (const label of edgeLabels) {\n  label.el.attr(\"transform\", `translate(${label.x},${label.y})`);\n}\nnodeLayer.raise();\n\n// --- Legend: route type -> color, centered below the network ----------------\nconst legendItems = Object.keys(typeColor);\nconst legendGap = 220;\nconst legendStartX = width / 2 - ((legendItems.length - 1) * legendGap) / 2;\nconst legendY = height - 46;\nconst legend = svg.append(\"g\").attr(\"class\", \"legend\");\nlegendItems.forEach((key, i) => {\n  const lx = legendStartX + i * legendGap;\n  legend\n    .append(\"line\")\n    .attr(\"x1\", lx)\n    .attr(\"x2\", lx + 36)\n    .attr(\"y1\", legendY)\n    .attr(\"y2\", legendY)\n    .attr(\"stroke\", typeColor[key])\n    .attr(\"stroke-width\", 4)\n    .attr(\"stroke-linecap\", \"round\");\n  legend\n    .append(\"text\")\n    .attr(\"x\", lx + 48)\n    .attr(\"y\", legendY)\n    .attr(\"dominant-baseline\", \"middle\")\n    .style(\"font-size\", \"15px\")\n    .attr(\"fill\", t.inkSoft)\n    .text(typeLabel[key]);\n});\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"26px\")\n  .style(\"font-weight\", \"600\")\n  .attr(\"fill\", t.ink)\n  .text(\"network-transport-static · javascript · d3 · anyplot.ai\");\n"}