{"spec_id":"network-transport-static","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// network-transport-static: Static Transport Network Diagram\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 84/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: a regional rail network (in-memory, deterministic) --------------\n// Two trunk lines (east-west, north-south) crossing at the interchange, plus\n// three short branches — a layout that spreads edges across the canvas\n// instead of radiating every route through a single hub.\nconst STATIONS = [\n  { id: \"CTR\", label: \"Central Station\", nx: 0.5, ny: 0.5, hub: true, dir: \"upper-right\" },\n  { id: \"WFD\", label: \"Westfield\", nx: 0.04, ny: 0.5, dir: \"up\" },\n  { id: \"OTN\", label: \"Old Town\", nx: 0.22, ny: 0.5, dir: \"up\" },\n  { id: \"RIV\", label: \"Riverside\", nx: 0.78, ny: 0.5, dir: \"up\" },\n  { id: \"EGT\", label: \"Eastgate\", nx: 0.96, ny: 0.5, dir: \"up\" },\n  { id: \"NOJ\", label: \"North Junction\", nx: 0.5, ny: 0.04, dir: \"right\" },\n  { id: \"SMH\", label: \"Summit Hill\", nx: 0.5, ny: 0.22, dir: \"left\" },\n  { id: \"LKS\", label: \"Lakeside\", nx: 0.5, ny: 0.78, dir: \"right\" },\n  { id: \"APT\", label: \"Airport Terminal\", nx: 0.5, ny: 0.96, dir: \"right\" },\n  { id: \"HBP\", label: \"Harbor Point\", nx: 0.9, ny: 0.8, dir: \"right\" },\n  { id: \"GRW\", label: \"Greenwood\", nx: 0.06, ny: 0.82, dir: \"down\" },\n  { id: \"MLB\", label: \"Millbrook\", nx: 0.18, ny: 0.87, dir: \"down\" },\n];\n\nconst ROUTE_TYPES = {\n  regional: { name: \"Regional\", color: t.palette[0] },\n  local: { name: \"Local\", color: t.palette[1] },\n  express: { name: \"Express\", color: t.palette[2] },\n};\n\nconst ROUTES = [\n  { source: \"WFD\", target: \"OTN\", id: \"R101\", dep: \"07:05\", arr: \"07:20\", type: \"regional\" },\n  { source: \"OTN\", target: \"WFD\", id: \"R102\", dep: \"07:40\", arr: \"07:55\", type: \"regional\" },\n  { source: \"OTN\", target: \"CTR\", id: \"R103\", dep: \"07:25\", arr: \"07:40\", type: \"regional\" },\n  { source: \"CTR\", target: \"OTN\", id: \"R104\", dep: \"07:50\", arr: \"08:05\", type: \"regional\" },\n  { source: \"CTR\", target: \"RIV\", id: \"R105\", dep: \"07:45\", arr: \"08:05\", type: \"regional\" },\n  { source: \"RIV\", target: \"CTR\", id: \"R106\", dep: \"08:15\", arr: \"08:35\", type: \"regional\" },\n  { source: \"RIV\", target: \"EGT\", id: \"R107\", dep: \"08:10\", arr: \"08:30\", type: \"regional\" },\n  { source: \"EGT\", target: \"RIV\", id: \"R108\", dep: \"08:45\", arr: \"09:05\", type: \"regional\" },\n  { source: \"NOJ\", target: \"SMH\", id: \"R201\", dep: \"07:10\", arr: \"07:25\", type: \"regional\" },\n  { source: \"SMH\", target: \"NOJ\", id: \"R202\", dep: \"07:40\", arr: \"07:55\", type: \"regional\" },\n  { source: \"SMH\", target: \"CTR\", id: \"R203\", dep: \"07:30\", arr: \"07:48\", type: \"regional\" },\n  { source: \"CTR\", target: \"SMH\", id: \"R204\", dep: \"08:00\", arr: \"08:18\", type: \"regional\" },\n  { source: \"CTR\", target: \"LKS\", id: \"R205\", dep: \"07:55\", arr: \"08:12\", type: \"regional\" },\n  { source: \"LKS\", target: \"CTR\", id: \"R206\", dep: \"08:20\", arr: \"08:37\", type: \"regional\" },\n  { source: \"LKS\", target: \"APT\", id: \"R207\", dep: \"08:15\", arr: \"08:33\", type: \"regional\" },\n  { source: \"APT\", target: \"LKS\", id: \"R208\", dep: \"08:50\", arr: \"09:08\", type: \"regional\" },\n  { source: \"RIV\", target: \"HBP\", id: \"L301\", dep: \"08:20\", arr: \"08:42\", type: \"local\" },\n  { source: \"HBP\", target: \"RIV\", id: \"L302\", dep: \"08:55\", arr: \"09:17\", type: \"local\" },\n  { source: \"OTN\", target: \"GRW\", id: \"L303\", dep: \"07:35\", arr: \"07:58\", type: \"local\" },\n  { source: \"GRW\", target: \"OTN\", id: \"L304\", dep: \"08:05\", arr: \"08:28\", type: \"local\" },\n  { source: \"LKS\", target: \"MLB\", id: \"L305\", dep: \"08:25\", arr: \"08:44\", type: \"local\" },\n  { source: \"MLB\", target: \"LKS\", id: \"L306\", dep: \"08:55\", arr: \"09:14\", type: \"local\" },\n  { source: \"CTR\", target: \"RIV\", id: \"E401\", dep: \"09:00\", arr: \"09:15\", type: \"express\" },\n  { source: \"CTR\", target: \"LKS\", id: \"E402\", dep: \"09:05\", arr: \"09:20\", type: \"express\" },\n];\n\n// --- Title -------------------------------------------------------------------\nconst TITLE = \"Regional Rail Network · network-transport-static · javascript · highcharts · anyplot.ai\";\nconst TITLE_FONT_SIZE = `${Math.max(15, Math.round(22 * Math.min(1, 67 / TITLE.length)))}px`;\n\n// --- Geometry ------------------------------------------------------------\nconst margin = { top: 118, right: 70, bottom: 66, left: 70 };\nconst plotW = size.width - margin.left - margin.right;\nconst plotH = size.height - margin.top - margin.bottom;\nconst NODE_R = 28;\nconst HUB_R = 33;\n\n// Edge-label backing boxes are sized from measured text width rather than\n// SVGElement.getBBox() — calling getBBox() on a freshly-added Highcharts\n// text element mid-loop only reflects the actually laid-out box for a few\n// of the labels (a batched-relayout quirk), leaving the rest with a\n// stale/zero-size box. Canvas 2D text measurement is synchronous and\n// unaffected by that, so every label gets a consistent box.\nconst LABEL_FONT_SIZE = 12;\nconst LABEL_FONT_WEIGHT = 500;\nconst LABEL_FONT = `${LABEL_FONT_WEIGHT} ${LABEL_FONT_SIZE}px -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif`;\nconst measureCtx = document.createElement(\"canvas\").getContext(\"2d\");\nmeasureCtx.font = LABEL_FONT;\nfunction labelBoxFor(text, cx, cy) {\n  const width = measureCtx.measureText(text).width;\n  const height = LABEL_FONT_SIZE * 1.3;\n  return { x: cx - width / 2, y: cy - height * 0.72, width, height };\n}\n\nfunction pixelOf(station) {\n  return { x: margin.left + station.nx * plotW, y: margin.top + station.ny * plotH };\n}\nconst pixels = {};\nconst radii = {};\nSTATIONS.forEach((s) => {\n  pixels[s.id] = pixelOf(s);\n  radii[s.id] = s.hub ? HUB_R : NODE_R;\n});\n\n// Group routes by unordered station pair so repeated connections (either\n// direction) fan out into distinguishable curves instead of overlapping.\n// The perpendicular normal is derived from a *canonical* (sorted) station\n// order shared by the whole group — deriving it from each route's own\n// source->target direction would flip sign on the reverse-direction routes\n// and collapse every bidirectional pair back onto a single curve.\nconst CURVE_STEP = 46;\nconst pairGroups = new Map();\nROUTES.forEach((route) => {\n  const key = [route.source, route.target].sort().join(\"|\");\n  if (!pairGroups.has(key)) pairGroups.set(key, []);\n  pairGroups.get(key).push(route);\n});\n// Label placement is deliberately decoupled from the (subtle) line curve: a\n// label box is ~150px wide, far more than a tasteful curve bulge, so labels\n// get their own larger perpendicular spacing plus an along-the-edge stagger.\n// One of the two always ends up as a vertical (row) separation regardless of\n// whether the edge itself runs mostly horizontal or mostly vertical.\nconst LABEL_PERP_STEP = 92;\nconst LABEL_ALONG_STEP = 46;\n// On short station pairs (adjacent stations) the fixed offsets above leave\n// little room between the pair midpoint and the arrow-trim point near the\n// target node, so the label ends up sitting on top of the arrowhead. Boost\n// both offsets inversely with edge length so close-together stations still\n// get enough clearance; longer edges are unaffected (boost clamps to 1).\nconst REFERENCE_EDGE_LEN = 260;\nconst MAX_LABEL_BOOST = 2.6;\npairGroups.forEach((group, key) => {\n  const [a, b] = key.split(\"|\");\n  const pa = pixels[a];\n  const pb = pixels[b];\n  const len = Math.hypot(pb.x - pa.x, pb.y - pa.y) || 1;\n  const canonNx = -(pb.y - pa.y) / len;\n  const canonNy = (pb.x - pa.x) / len;\n  const alongX = (pb.x - pa.x) / len;\n  const alongY = (pb.y - pa.y) / len;\n  const labelBoost = Math.min(MAX_LABEL_BOOST, Math.max(1, REFERENCE_EDGE_LEN / len));\n  const n = group.length;\n  group.forEach((route, i) => {\n    route.curveNx = canonNx;\n    route.curveNy = canonNy;\n    const idx = n === 1 ? 0 : i - (n - 1) / 2;\n    route.curveOffset = idx * CURVE_STEP;\n    route.labelPerpX = canonNx * idx * LABEL_PERP_STEP * labelBoost;\n    route.labelPerpY = canonNy * idx * LABEL_PERP_STEP * labelBoost;\n    route.labelAlongX = alongX * idx * LABEL_ALONG_STEP * labelBoost;\n    route.labelAlongY = alongY * idx * LABEL_ALONG_STEP * labelBoost;\n  });\n});\n\n// Anchor point + text-anchor for a station's outside name label, chosen per\n// station to dodge that station's own incident edges.\nfunction labelAnchor(p, r, dir) {\n  const gap = 16;\n  switch (dir) {\n    case \"up\":\n      return { x: p.x, y: p.y - r - gap, align: \"center\" };\n    case \"down\":\n      return { x: p.x, y: p.y + r + gap + 13, align: \"center\" };\n    case \"left\":\n      return { x: p.x - r - gap, y: p.y + 5, align: \"right\" };\n    case \"right\":\n      return { x: p.x + r + gap, y: p.y + 5, align: \"left\" };\n    case \"upper-right\":\n      return { x: p.x + r * 0.7 + gap, y: p.y - r * 0.7 - gap, align: \"left\" };\n    default:\n      return { x: p.x, y: p.y - r - gap, align: \"center\" };\n  }\n}\n\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load: function () {\n        const renderer = this.renderer;\n\n        // --- Edges: curved/offset paths with an arrowhead at the target --\n        ROUTES.forEach((route) => {\n          const p0 = pixels[route.source];\n          const p2 = pixels[route.target];\n          const r0 = radii[route.source];\n          const r2 = radii[route.target];\n          const color = ROUTE_TYPES[route.type].color;\n\n          const mx = (p0.x + p2.x) / 2;\n          const my = (p0.y + p2.y) / 2;\n          const cx = mx + route.curveNx * route.curveOffset;\n          const cy = my + route.curveNy * route.curveOffset;\n\n          // Trim the curve so it starts/ends at the node's edge, not center.\n          const s0x = cx - p0.x;\n          const s0y = cy - p0.y;\n          const s0len = Math.hypot(s0x, s0y) || 1;\n          const startX = p0.x + (s0x / s0len) * r0;\n          const startY = p0.y + (s0y / s0len) * r0;\n\n          const s1x = cx - p2.x;\n          const s1y = cy - p2.y;\n          const s1len = Math.hypot(s1x, s1y) || 1;\n          const tipX = p2.x + (s1x / s1len) * r2;\n          const tipY = p2.y + (s1y / s1len) * r2;\n\n          // Arrowhead: a small triangle oriented along the arrival tangent\n          // (from the control point toward the trimmed end point).\n          const adx = tipX - cx;\n          const ady = tipY - cy;\n          const alen = Math.hypot(adx, ady) || 1;\n          const adirX = adx / alen;\n          const adirY = ady / alen;\n          const ARROW_LEN = 15;\n          const ARROW_W = 8.5;\n          const baseX = tipX - adirX * ARROW_LEN;\n          const baseY = tipY - adirY * ARROW_LEN;\n          const perpX = -adirY;\n          const perpY = adirX;\n\n          renderer\n            .path([\"M\", startX, startY, \"Q\", cx, cy, baseX, baseY])\n            .attr({ stroke: color, \"stroke-width\": 2.5, fill: \"none\", opacity: 0.85, zIndex: 2 })\n            .add();\n          renderer\n            .path([\n              \"M\", tipX, tipY,\n              \"L\", baseX + perpX * ARROW_W, baseY + perpY * ARROW_W,\n              \"L\", baseX - perpX * ARROW_W, baseY - perpY * ARROW_W,\n              \"Z\",\n            ])\n            .attr({ fill: color, stroke: \"none\", zIndex: 3 })\n            .add();\n\n          // Edge label (\"route id | dep -> arr\") on a legible backing box,\n          // offset from the station-pair midpoint (see LABEL_PERP_STEP /\n          // LABEL_ALONG_STEP above) so parallel-edge labels don't stack.\n          const labelX = mx + route.labelPerpX + route.labelAlongX;\n          const labelY = my + route.labelPerpY + route.labelAlongY;\n          const labelText = `${route.id} | ${route.dep}→${route.arr}`;\n          const box = labelBoxFor(labelText, labelX, labelY);\n          renderer\n            .rect(box.x - 5, box.y - 3, box.width + 10, box.height + 6, 4)\n            .attr({ fill: t.elevatedBg, stroke: color, \"stroke-width\": 1, opacity: 0.92, zIndex: 5 })\n            .add();\n          renderer\n            .text(labelText, labelX, labelY)\n            .attr({ align: \"center\", zIndex: 6 })\n            .css({ color: t.ink, fontSize: `${LABEL_FONT_SIZE}px`, fontWeight: String(LABEL_FONT_WEIGHT) })\n            .add();\n        });\n\n        // --- Nodes: circle + station code inside, full name outside ------\n        STATIONS.forEach((station) => {\n          const p = pixels[station.id];\n          const r = radii[station.id];\n          renderer\n            .circle(p.x, p.y, r)\n            .attr({ fill: t.elevatedBg, stroke: t.ink, \"stroke-width\": 2.5, zIndex: 8 })\n            .add();\n          renderer\n            .text(station.id, p.x, p.y + 5)\n            .attr({ align: \"center\", zIndex: 9 })\n            .css({ color: t.ink, fontSize: \"13px\", fontWeight: \"700\" })\n            .add();\n          const anchor = labelAnchor(p, r, station.dir);\n          renderer\n            .text(station.label, anchor.x, anchor.y)\n            .attr({ align: anchor.align, zIndex: 9 })\n            .css({ color: t.ink, fontSize: \"15px\", fontWeight: \"600\" })\n            .add();\n        });\n      },\n    },\n  },\n  title: {\n    text: TITLE,\n    style: { color: t.ink, fontSize: TITLE_FONT_SIZE, fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"12 stations · 24 scheduled services — curved edges mark multiple daily services between the same stations\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  credits: { enabled: false },\n  xAxis: { visible: false },\n  yAxis: { visible: false },\n  legend: {\n    enabled: true,\n    verticalAlign: \"bottom\",\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  plotOptions: { series: { animation: false, enableMouseTracking: false } },\n  series: Object.values(ROUTE_TYPES).map((rt) => ({\n    type: \"line\",\n    name: rt.name,\n    data: [],\n    color: rt.color,\n    lineWidth: 4,\n    marker: { enabled: false },\n    showInLegend: true,\n  })),\n});\n"}