{"spec_id":"network-transport-static","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// network-transport-static: Static Transport Network Diagram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-02\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: a regional rail network — stations keep their fixed x/y map\n// coordinates (no force-directed layout), routes are directed timetabled\n// services between them ------------------------------------------------------\nconst stations = [\n  { id: 0, label: \"Central Station\", x: 0, y: 0 },\n  { id: 1, label: \"North Junction\", x: -1.5, y: 2.8 },\n  { id: 2, label: \"Eastport\", x: 4.2, y: 0.8 },\n  { id: 3, label: \"Westfield\", x: -4.5, y: 0.6 },\n  { id: 4, label: \"Southgate\", x: 0.3, y: -2.6 },\n  { id: 5, label: \"Lakeview\", x: 1.8, y: 3.4 },\n  { id: 6, label: \"Millbrook\", x: -3.8, y: -2.3 },\n  { id: 7, label: \"Harborview\", x: 5.8, y: -1.8 },\n  { id: 8, label: \"Riverside\", x: 5.8, y: -4.9 },\n  { id: 9, label: \"Hilltop\", x: 4.0, y: 5.6 },\n  { id: 10, label: \"Bayside\", x: 10.8, y: 1.0 },\n];\n\n// Each entry is one directed, timetabled service. Station pairs served in\n// both directions (e.g. 0<->1) are deliberately duplicated with independent\n// times, since real timetables run separate outbound/return trains.\nconst routes = [\n  { source: 0, target: 1, routeId: \"IC1\", departure: \"08:00\", arrival: \"08:35\", type: \"intercity\" },\n  { source: 1, target: 0, routeId: \"IC1\", departure: \"08:50\", arrival: \"09:25\", type: \"intercity\" },\n  { source: 0, target: 2, routeId: \"RE5\", departure: \"08:15\", arrival: \"08:50\", type: \"express\" },\n  { source: 2, target: 0, routeId: \"RE5\", departure: \"09:05\", arrival: \"09:40\", type: \"express\" },\n  { source: 0, target: 3, routeId: \"RE7\", departure: \"08:10\", arrival: \"08:40\", type: \"express\" },\n  { source: 3, target: 0, routeId: \"RE7\", departure: \"08:55\", arrival: \"09:25\", type: \"express\" },\n  { source: 0, target: 4, routeId: \"S2\", departure: \"08:05\", arrival: \"08:25\", type: \"local\" },\n  { source: 4, target: 0, routeId: \"S2\", departure: \"08:35\", arrival: \"08:55\", type: \"local\" },\n  { source: 1, target: 5, routeId: \"S9\", departure: \"08:40\", arrival: \"09:00\", type: \"local\" },\n  { source: 5, target: 1, routeId: \"S9\", departure: \"09:10\", arrival: \"09:30\", type: \"local\" },\n  { source: 2, target: 7, routeId: \"RE12\", departure: \"09:00\", arrival: \"09:35\", type: \"express\" },\n  { source: 3, target: 6, routeId: \"S4\", departure: \"08:45\", arrival: \"09:10\", type: \"local\" },\n  { source: 4, target: 7, routeId: \"IC3\", departure: \"08:30\", arrival: \"09:15\", type: \"intercity\" },\n  { source: 6, target: 4, routeId: \"S4\", departure: \"09:15\", arrival: \"09:40\", type: \"local\" },\n  { source: 2, target: 5, routeId: \"S11\", departure: \"09:10\", arrival: \"09:30\", type: \"local\" },\n  { source: 4, target: 8, routeId: \"S6\", departure: \"08:20\", arrival: \"08:40\", type: \"local\" },\n  { source: 8, target: 4, routeId: \"S6\", departure: \"08:50\", arrival: \"09:10\", type: \"local\" },\n  { source: 8, target: 7, routeId: \"RE14\", departure: \"09:05\", arrival: \"09:35\", type: \"express\" },\n  { source: 5, target: 9, routeId: \"S12\", departure: \"08:15\", arrival: \"08:35\", type: \"local\" },\n  { source: 9, target: 5, routeId: \"S12\", departure: \"08:45\", arrival: \"09:05\", type: \"local\" },\n  { source: 2, target: 9, routeId: \"IC5\", departure: \"08:25\", arrival: \"08:55\", type: \"intercity\" },\n  { source: 7, target: 10, routeId: \"RE16\", departure: \"08:40\", arrival: \"09:00\", type: \"express\" },\n  { source: 10, target: 7, routeId: \"RE16\", departure: \"09:10\", arrival: \"09:30\", type: \"express\" },\n  { source: 2, target: 10, routeId: \"IC7\", departure: \"08:35\", arrival: \"09:05\", type: \"intercity\" },\n];\n\nconst ROUTE_TYPES = [\"express\", \"intercity\", \"local\"];\nconst ROUTE_TYPE_LABELS = { express: \"Regional Express\", intercity: \"InterCity\", local: \"Local / S-Bahn\" };\nconst ROUTE_COLORS = { express: t.palette[0], intercity: t.palette[1], local: t.palette[2] };\n\n// Degree (routes touching a station, either direction) drives node size so\n// interchange hubs read as visually larger.\nconst degree = new Array(stations.length).fill(0);\nroutes.forEach((route) => {\n  degree[route.source] += 1;\n  degree[route.target] += 1;\n});\nconst nodeRadius = (id) => 18 + degree[id] * 4;\n\n// Station-pair \"lanes\": when two or more routes connect the same pair of\n// stations, each gets its own curve offset so they never overlap on screen.\nconst pairGroups = new Map();\nroutes.forEach((route, idx) => {\n  const key = [route.source, route.target].sort((a, b) => a - b).join(\"-\");\n  if (!pairGroups.has(key)) pairGroups.set(key, []);\n  pairGroups.get(key).push(idx);\n});\nconst laneInfo = new Map();\npairGroups.forEach((idxs) => {\n  idxs.forEach((idx, lane) => laneInfo.set(idx, { lane, count: idxs.length }));\n});\n\n// --- Data-space scale, expanded to the mount's 16:9 aspect so pixel-per-unit\n// is identical on both axes (keeps circles round and arrow angles true) ------\nconst xs = stations.map((s) => s.x);\nconst ys = stations.map((s) => s.y);\nconst xMin = Math.min(...xs);\nconst xMax = Math.max(...xs);\nconst yMin = Math.min(...ys);\nconst yMax = Math.max(...ys);\nconst centroid = { x: (xMin + xMax) / 2, y: (yMin + yMax) / 2 };\nconst padFrac = 1.32;\nlet xRange = (xMax - xMin) * padFrac;\nlet yRange = (yMax - yMin) * padFrac;\nconst targetAspect = 16 / 9;\nif (xRange / yRange < targetAspect) {\n  xRange = yRange * targetAspect;\n} else {\n  yRange = xRange / targetAspect;\n}\nconst xScaleMin = centroid.x - xRange / 2;\nconst xScaleMax = centroid.x + xRange / 2;\nconst yScaleMin = centroid.y - yRange / 2;\nconst yScaleMax = centroid.y + yRange / 2;\n\n// Perpendicular offset (CSS px) applied per lane when 2+ routes share a\n// station pair, so their curves and labels stay clear of each other.\nconst LANE_OFFSET_PX = 60;\nconst MOUNT_CSS_WIDTH = 1600;\n\n// Data-space equivalent of LANE_OFFSET_PX (ppu is identical on both axes\n// since xRange/yRange are aspect-matched), used to place the invisible\n// route hit-points near each curve's real midpoint.\nconst dataOffsetUnit = xRange * (LANE_OFFSET_PX / MOUNT_CSS_WIDTH);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Edges: curved, arrowed, colored by route type. Drawn under the node\n// markers via a lightweight inline plugin (Chart.js's own extension point —\n// not a community chartjs-chart-* plugin) ------------------------------------\nconst routeEdgePlugin = {\n  id: \"transportEdges\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const toPx = (x, y) => ({ x: scales.x.getPixelForValue(x), y: scales.y.getPixelForValue(y) });\n    ctx.save();\n    ctx.lineWidth = 2.4;\n\n    routes.forEach((route, idx) => {\n      const from = stations[route.source];\n      const to = stations[route.target];\n      const S = toPx(from.x, from.y);\n      const T = toPx(to.x, to.y);\n\n      // Perpendicular direction from the canonical (low-id -> high-id) axis of\n      // this station pair, so both directions of a bidirectional pair curve\n      // to two consistent, opposite sides.\n      const lo = stations[Math.min(route.source, route.target)];\n      const hi = stations[Math.max(route.source, route.target)];\n      const loPx = toPx(lo.x, lo.y);\n      const hiPx = toPx(hi.x, hi.y);\n      const canonLen = Math.max(Math.hypot(hiPx.x - loPx.x, hiPx.y - loPx.y), 1e-6);\n      const perpX = -(hiPx.y - loPx.y) / canonLen;\n      const perpY = (hiPx.x - loPx.x) / canonLen;\n      // Shared chord angle for the whole station pair (not the per-direction\n      // curve tangent) so both lanes of a bidirectional pair always render\n      // their labels at the same rotation, never fighting each other.\n      route._pairAngle = Math.atan2(hiPx.y - loPx.y, hiPx.x - loPx.x);\n\n      const { lane, count } = laneInfo.get(idx);\n      const offset = count > 1 ? (lane - (count - 1) / 2) * LANE_OFFSET_PX : 0;\n      const midX = (S.x + T.x) / 2 + perpX * offset;\n      const midY = (S.y + T.y) / 2 + perpY * offset;\n\n      const startLen = Math.max(Math.hypot(midX - S.x, midY - S.y), 1e-6);\n      const startDirX = (midX - S.x) / startLen;\n      const startDirY = (midY - S.y) / startLen;\n      const endLen = Math.max(Math.hypot(T.x - midX, T.y - midY), 1e-6);\n      const endDirX = (T.x - midX) / endLen;\n      const endDirY = (T.y - midY) / endLen;\n\n      const startX = S.x + startDirX * (nodeRadius(route.source) + 3);\n      const startY = S.y + startDirY * (nodeRadius(route.source) + 3);\n      const arrowLen = 16;\n      const tipX = T.x - endDirX * (nodeRadius(route.target) + 3);\n      const tipY = T.y - endDirY * (nodeRadius(route.target) + 3);\n      const baseX = tipX - endDirX * arrowLen;\n      const baseY = tipY - endDirY * arrowLen;\n\n      const color = ROUTE_COLORS[route.type];\n      ctx.strokeStyle = color;\n      ctx.globalAlpha = 0.82;\n      ctx.beginPath();\n      ctx.moveTo(startX, startY);\n      ctx.quadraticCurveTo(midX, midY, baseX, baseY);\n      ctx.stroke();\n\n      // Arrowhead — a filled triangle pointing along the curve's end tangent.\n      const arrowHalfWidth = 7;\n      const perpArrowX = -endDirY;\n      const perpArrowY = endDirX;\n      ctx.fillStyle = color;\n      ctx.globalAlpha = 0.95;\n      ctx.beginPath();\n      ctx.moveTo(tipX, tipY);\n      ctx.lineTo(baseX + perpArrowX * arrowHalfWidth, baseY + perpArrowY * arrowHalfWidth);\n      ctx.lineTo(baseX - perpArrowX * arrowHalfWidth, baseY - perpArrowY * arrowHalfWidth);\n      ctx.closePath();\n      ctx.fill();\n\n      // Cache the curve midpoint for the label plugin below (rotation uses\n      // the shared _pairAngle set above, not this curve's own tangent).\n      route._mid = { x: midX, y: midY };\n    });\n    ctx.restore();\n  },\n};\n\n// --- Labels: route id + departure/arrival on every edge, station names\n// anchored outward from the network's centroid so text doesn't sit on top of\n// the lines converging on a hub. Drawn on top of everything. ------------------\nconst labelPlugin = {\n  id: \"transportLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const toPx = (x, y) => ({ x: scales.x.getPixelForValue(x), y: scales.y.getPixelForValue(y) });\n    ctx.save();\n\n    ctx.font = \"600 13px sans-serif\";\n    ctx.textBaseline = \"middle\";\n    ctx.textAlign = \"center\";\n    routes.forEach((route) => {\n      const { x, y } = route._mid;\n      let angle = route._pairAngle;\n      if (angle > Math.PI / 2 || angle < -Math.PI / 2) angle += Math.PI;\n      // Clamp near-vertical labels to a readable max tilt instead of following\n      // the chord exactly (a steep edge would otherwise force a head-tilt).\n      const maxLabelAngle = Math.PI / 4;\n      if (angle > maxLabelAngle) angle = maxLabelAngle;\n      if (angle < -maxLabelAngle) angle = -maxLabelAngle;\n      const text = `${route.routeId}  ${route.departure}→${route.arrival}`;\n      const { width } = ctx.measureText(text);\n      const boxW = width + 12;\n      const boxH = 20;\n\n      ctx.save();\n      ctx.translate(x, y);\n      ctx.rotate(angle);\n      ctx.fillStyle = t.pageBg;\n      ctx.globalAlpha = 0.88;\n      ctx.fillRect(-boxW / 2, -boxH / 2, boxW, boxH);\n      ctx.globalAlpha = 1;\n      ctx.fillStyle = ROUTE_COLORS[route.type];\n      ctx.fillText(text, 0, 0);\n      ctx.restore();\n    });\n\n    // Direction each station's own routes leave it in (pixel space), so the\n    // outward label can steer clear of them instead of blindly following the\n    // centroid direction (which can coincide exactly with an incident edge,\n    // e.g. a hub station whose only westward neighbor is also due west of it).\n    const incidentDirs = new Map(stations.map((s) => [s.id, []]));\n    routes.forEach((route) => {\n      const a = toPx(stations[route.source].x, stations[route.source].y);\n      const b = toPx(stations[route.target].x, stations[route.target].y);\n      const abLen = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1e-6);\n      incidentDirs.get(route.source).push({ x: (b.x - a.x) / abLen, y: (b.y - a.y) / abLen });\n      incidentDirs.get(route.target).push({ x: (a.x - b.x) / abLen, y: (a.y - b.y) / abLen });\n    });\n\n    const centroidPx = toPx(centroid.x, centroid.y);\n    ctx.font = \"700 16px sans-serif\";\n    stations.forEach((station) => {\n      const p = toPx(station.x, station.y);\n      let dx = p.x - centroidPx.x;\n      let dy = p.y - centroidPx.y;\n      const len = Math.hypot(dx, dy);\n      if (len < 8) {\n        dx = 0;\n        dy = -1;\n      } else {\n        dx /= len;\n        dy /= len;\n        // Among 8 candidate directions (the centroid-outward direction plus\n        // 45-degree rotations of it), pick whichever stays furthest from\n        // every route leaving this station (lowest worst-case alignment with\n        // an incident edge) — a hub can easily have 4+ edges roughly 90\n        // degrees apart, so only sampling 90-degree rotations can leave every\n        // candidate pinned to an edge; 45-degree steps find the gap between.\n        const candidates = Array.from({ length: 8 }, (_, k) => {\n          const theta = (k * Math.PI) / 4;\n          const cos = Math.cos(theta);\n          const sin = Math.sin(theta);\n          return { x: dx * cos - dy * sin, y: dx * sin + dy * cos };\n        });\n        const neighborDirs = incidentDirs.get(station.id);\n        let best = candidates[0];\n        let bestWorstAlignment = Infinity;\n        candidates.forEach((c) => {\n          const worstAlignment = neighborDirs.reduce((m, n) => Math.max(m, c.x * n.x + c.y * n.y), -Infinity);\n          if (worstAlignment < bestWorstAlignment) {\n            bestWorstAlignment = worstAlignment;\n            best = c;\n          }\n        });\n        dx = best.x;\n        dy = best.y;\n      }\n      const offset = nodeRadius(station.id) + 32;\n      const labelX = p.x + dx * offset;\n      const labelY = p.y + dy * offset;\n\n      ctx.textAlign = Math.abs(dx) < 0.35 ? \"center\" : dx > 0 ? \"left\" : \"right\";\n      ctx.textBaseline = Math.abs(dy) < 0.35 ? \"middle\" : dy > 0 ? \"top\" : \"bottom\";\n      const { width } = ctx.measureText(station.label);\n      const padX = 5;\n      let boxX = labelX - padX;\n      if (ctx.textAlign === \"center\") boxX = labelX - width / 2 - padX;\n      if (ctx.textAlign === \"right\") boxX = labelX - width - padX;\n      let boxY = labelY - 11;\n      if (ctx.textBaseline === \"top\") boxY = labelY - 2;\n      if (ctx.textBaseline === \"bottom\") boxY = labelY - 20;\n\n      ctx.fillStyle = t.pageBg;\n      ctx.globalAlpha = 0.92;\n      ctx.fillRect(boxX, boxY, width + padX * 2, 22);\n      ctx.globalAlpha = 1;\n      ctx.fillStyle = t.ink;\n      ctx.fillText(station.label, labelX, labelY);\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Datasets: real station markers, plus zero-data \"swatch\" datasets so the\n// three route types get a legend entry (color-coded route type per spec) ----\nconst nodeDataset = {\n  label: \"Stations\",\n  data: stations.map((s) => ({ x: s.x, y: s.y })),\n  backgroundColor: t.ink,\n  borderColor: t.pageBg,\n  borderWidth: 3,\n  pointRadius: stations.map((s) => nodeRadius(s.id)),\n  pointHoverRadius: stations.map((s) => nodeRadius(s.id) + 4),\n  showLine: false,\n};\n\nconst legendSwatches = ROUTE_TYPES.map((type) => ({\n  label: ROUTE_TYPE_LABELS[type],\n  data: [],\n  backgroundColor: ROUTE_COLORS[type],\n  borderColor: ROUTE_COLORS[type],\n  pointStyle: \"line\",\n  showLine: false,\n}));\n\n// Invisible hit-testable points at each route's curve midpoint (approximated\n// in data space, same lane-offset logic as the pixel-space edge plugin above)\n// so hovering an edge shows its full route details, not just station degree.\nconst routeHitDataset = {\n  label: \"Routes\",\n  data: routes.map((route, idx) => {\n    const from = stations[route.source];\n    const to = stations[route.target];\n    const lo = stations[Math.min(route.source, route.target)];\n    const hi = stations[Math.max(route.source, route.target)];\n    const canonLen = Math.max(Math.hypot(hi.x - lo.x, hi.y - lo.y), 1e-6);\n    const perpX = -(hi.y - lo.y) / canonLen;\n    const perpY = (hi.x - lo.x) / canonLen;\n    const { lane, count } = laneInfo.get(idx);\n    const offsetMag = count > 1 ? (lane - (count - 1) / 2) * dataOffsetUnit : 0;\n    return { x: (from.x + to.x) / 2 + perpX * offsetMag, y: (from.y + to.y) / 2 + perpY * offsetMag };\n  }),\n  backgroundColor: \"rgba(0, 0, 0, 0)\",\n  borderColor: \"rgba(0, 0, 0, 0)\",\n  pointRadius: 0,\n  pointHoverRadius: 0,\n  pointHitRadius: 14,\n  showLine: false,\n};\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [...legendSwatches, nodeDataset, routeHitDataset] },\n  plugins: [routeEdgePlugin, labelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: {\n      padding: { top: 10, right: 20, bottom: 10, left: 20 },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"network-transport-static · 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: {\n          color: t.ink,\n          font: { size: 16 },\n          usePointStyle: true,\n          boxWidth: 24,\n          filter: (item) => item.text !== \"Stations\" && item.text !== \"Routes\",\n        },\n      },\n      tooltip: {\n        callbacks: {\n          title: (items) => {\n            if (!items.length) return \"\";\n            const ds = items[0].chart.data.datasets[items[0].datasetIndex];\n            if (ds.label === \"Stations\") return stations[items[0].dataIndex].label;\n            if (ds.label === \"Routes\") return routes[items[0].dataIndex].routeId;\n            return \"\";\n          },\n          label: (item) => {\n            const ds = item.chart.data.datasets[item.datasetIndex];\n            if (ds.label === \"Stations\") {\n              const station = stations[item.dataIndex];\n              return `${degree[station.id]} services`;\n            }\n            if (ds.label === \"Routes\") {\n              const route = routes[item.dataIndex];\n              return `${route.departure} → ${route.arrival} (${ROUTE_TYPE_LABELS[route.type]})`;\n            }\n            return \"\";\n          },\n        },\n      },\n    },\n    scales: {\n      x: { display: false, min: xScaleMin, max: xScaleMax },\n      y: { display: false, min: yScaleMin, max: yScaleMax },\n    },\n  },\n});\n"}