{"spec_id":"network-transport-static","library":"echarts","language":"javascript","code":"// anyplot.ai\n// network-transport-static: Static Transport Network Diagram\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: regional rail network (in-memory, deterministic) ----------------\n// Stations positioned by normalized (x, y) so the layout scales to the mount.\nconst stations = [\n  { id: \"HUB\", label: \"Central Station\", x: 0.5, y: 0.52 },\n  { id: \"NOR\", label: \"North Junction\", x: 0.5, y: 0.12 },\n  { id: \"RIV\", label: \"Riverside\", x: 0.26, y: 0.28 },\n  { id: \"LAK\", label: \"Lakeside\", x: 0.74, y: 0.22 },\n  { id: \"HIL\", label: \"Hillcrest\", x: 0.14, y: 0.55 },\n  { id: \"PAR\", label: \"Parkway\", x: 0.3, y: 0.82 },\n  { id: \"EAS\", label: \"Eastgate\", x: 0.86, y: 0.55 },\n  { id: \"SOU\", label: \"Southport\", x: 0.55, y: 0.9 },\n  { id: \"FAI\", label: \"Fairview\", x: 0.72, y: 0.78 },\n  { id: \"MIL\", label: \"Millbrook\", x: 0.92, y: 0.85 },\n];\n\n// route_id prefix -> service tier. Regional is the primary/most frequent\n// tier, so it takes the brand color; the rest follow canonical palette order.\nconst ROUTE_TIERS = [\"Regional\", \"Local\", \"Express\"];\nconst tierOf = (routeId) => {\n  if (routeId.startsWith(\"RE\")) return \"Regional\";\n  if (routeId.startsWith(\"EX\")) return \"Express\";\n  return \"Local\";\n};\nconst TIER_COLOR = {\n  Regional: t.palette[0],\n  Local: t.palette[1],\n  Express: t.palette[2],\n};\n\nconst routes = [\n  { source: \"NOR\", target: \"HUB\", route: \"RE10\", dep: \"06:20\", arr: \"06:38\" },\n  { source: \"HUB\", target: \"NOR\", route: \"RE10\", dep: \"06:45\", arr: \"07:03\" },\n  { source: \"HUB\", target: \"SOU\", route: \"RE10\", dep: \"07:05\", arr: \"07:27\" },\n  { source: \"SOU\", target: \"HUB\", route: \"RE10\", dep: \"07:35\", arr: \"07:57\" },\n  { source: \"HIL\", target: \"HUB\", route: \"S1\", dep: \"06:40\", arr: \"06:58\" },\n  { source: \"HUB\", target: \"HIL\", route: \"S1\", dep: \"07:05\", arr: \"07:23\" },\n  { source: \"HUB\", target: \"EAS\", route: \"S1\", dep: \"07:00\", arr: \"07:20\" },\n  { source: \"EAS\", target: \"HUB\", route: \"S1\", dep: \"07:25\", arr: \"07:45\" },\n  { source: \"RIV\", target: \"HUB\", route: \"RE20\", dep: \"06:50\", arr: \"07:10\" },\n  { source: \"HUB\", target: \"RIV\", route: \"RE20\", dep: \"07:15\", arr: \"07:35\" },\n  { source: \"LAK\", target: \"HUB\", route: \"RE20\", dep: \"06:30\", arr: \"06:55\" },\n  { source: \"HUB\", target: \"LAK\", route: \"RE20\", dep: \"07:10\", arr: \"07:35\" },\n  { source: \"PAR\", target: \"HUB\", route: \"S2\", dep: \"06:48\", arr: \"07:10\" },\n  { source: \"HUB\", target: \"PAR\", route: \"S2\", dep: \"07:15\", arr: \"07:37\" },\n  { source: \"HUB\", target: \"FAI\", route: \"S2\", dep: \"07:12\", arr: \"07:30\" },\n  { source: \"FAI\", target: \"HUB\", route: \"S2\", dep: \"07:36\", arr: \"07:54\" },\n  { source: \"FAI\", target: \"MIL\", route: \"S3\", dep: \"07:58\", arr: \"08:13\" },\n  { source: \"MIL\", target: \"FAI\", route: \"S3\", dep: \"08:18\", arr: \"08:33\" },\n  { source: \"EAS\", target: \"MIL\", route: \"S3\", dep: \"07:50\", arr: \"08:05\" },\n  { source: \"MIL\", target: \"EAS\", route: \"S3\", dep: \"08:10\", arr: \"08:25\" },\n  { source: \"LAK\", target: \"EAS\", route: \"EX5\", dep: \"07:00\", arr: \"07:28\" },\n  { source: \"RIV\", target: \"PAR\", route: \"EX5\", dep: \"06:55\", arr: \"07:20\" },\n];\n\n// Curve same-pair edges apart so opposite-direction (or parallel) services\n// between two stations don't sit on top of each other.\nconst pairCounts = new Map();\nroutes.forEach((r) => {\n  const key = [r.source, r.target].sort().join(\"|\");\n  pairCounts.set(key, (pairCounts.get(key) || 0) + 1);\n});\n\n// --- Layout: map normalized station coordinates to the mount, leaving room\n// for the title band and edge labels at the margins. -------------------------\nconst marginX = size.width * 0.09;\nconst marginTop = size.height * 0.16;\nconst marginBottom = size.height * 0.08;\nconst spanX = size.width - marginX * 2;\nconst spanY = size.height - marginTop - marginBottom;\n\nconst nodes = stations.map((s) => ({\n  id: s.id,\n  name: s.label,\n  x: marginX + s.x * spanX,\n  y: marginTop + s.y * spanY,\n  symbolSize: 30,\n  itemStyle: { color: t.pageBg, borderColor: t.ink, borderWidth: 2.5 },\n  label: {\n    show: true,\n    position: \"right\",\n    distance: 10,\n    color: t.ink,\n    fontSize: 16,\n    fontWeight: 500,\n    backgroundColor: t.elevatedBg,\n    padding: [3, 6],\n    borderRadius: 3,\n  },\n}));\n\n// A bidirectional pair shares one route id — label only the first-seen\n// direction so the two arcs of the same service don't print the id twice\n// on top of each other. Same-pair curves are spread across a small set of\n// curveness values (not just one) so 3+ parallel services at a busy hub\n// stay visually separated instead of bunching into one arc.\nconst labeledPairs = new Set();\nconst pairSeen = new Map();\nconst links = routes.map((r) => {\n  const key = [r.source, r.target].sort().join(\"|\");\n  const showLabel = !labeledPairs.has(key);\n  labeledPairs.add(key);\n  const seenIndex = pairSeen.get(key) || 0;\n  pairSeen.set(key, seenIndex + 1);\n  const curveness = pairCounts.get(key) > 1 ? 0.18 + seenIndex * 0.1 : 0;\n  return {\n    source: r.source,\n    target: r.target,\n    route: r.route,\n    dep: r.dep,\n    arr: r.arr,\n    category: ROUTE_TIERS.indexOf(tierOf(r.route)),\n    lineStyle: {\n      color: TIER_COLOR[tierOf(r.route)],\n      width: 2.5,\n      curveness,\n      opacity: 0.85,\n    },\n    label: {\n      show: showLabel,\n      formatter: `${r.route} | ${r.dep} → ${r.arr}`,\n      fontSize: 13,\n      color: t.inkSoft,\n      backgroundColor: t.pageBg,\n      padding: [1, 3],\n    },\n  };\n});\n\n// --- Title (length-scaled per plot-generator.md) ----------------------------\nconst titleText = \"network-transport-static · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / titleText.length)));\n\n// --- Init + option ------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: titleText,\n    left: \"center\",\n    top: size.height * 0.03,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n  },\n  legend: {\n    data: ROUTE_TIERS,\n    top: size.height * 0.09,\n    left: \"center\",\n    itemWidth: 22,\n    itemHeight: 4,\n    textStyle: { color: t.inkSoft, fontSize: 15 },\n  },\n  color: ROUTE_TIERS.map((tier) => TIER_COLOR[tier]),\n  tooltip: {\n    formatter: (params) =>\n      params.dataType === \"edge\"\n        ? `${params.data.route} | ${params.data.dep} → ${params.data.arr}`\n        : params.name,\n  },\n  series: [\n    {\n      type: \"graph\",\n      layout: \"none\",\n      roam: false,\n      symbol: \"circle\",\n      edgeSymbol: [\"none\", \"arrow\"],\n      edgeSymbolSize: [0, 13],\n      data: nodes,\n      links: links,\n      categories: ROUTE_TIERS.map((name) => ({ name })),\n      lineStyle: { curveness: 0 },\n      edgeLabel: { show: true },\n    },\n  ],\n});\n\n// ECharts' built-in category filter only applies to graph *nodes*, not\n// edges — and a station here typically carries routes from more than one\n// tier, so tagging nodes with a single category would misrepresent the\n// data. Instead, drive the legend's tier toggle explicitly: hide/show each\n// link by the tier recorded on its own `category` field above.\nchart.on(\"legendselectchanged\", (params) => {\n  chart.setOption({\n    series: [\n      {\n        links: links.map((l) => {\n          const visible = params.selected[ROUTE_TIERS[l.category]];\n          return {\n            ...l,\n            lineStyle: { ...l.lineStyle, opacity: visible ? l.lineStyle.opacity : 0 },\n            label: { ...l.label, show: l.label.show && visible },\n          };\n        }),\n      },\n    ],\n  });\n});\n"}