{"spec_id":"flowmap-origin-destination","library":"echarts","language":"javascript","code":"// anyplot.ai\n// flowmap-origin-destination: Origin-Destination Flow Map\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic): global container-shipping corridors --\nconst ports = [\n  { code: \"SHA\", name: \"Shanghai\", lon: 121.47, lat: 31.23, label: \"left\" },\n  { code: \"SIN\", name: \"Singapore\", lon: 103.82, lat: 1.35, label: \"bottom\" },\n  { code: \"BUS\", name: \"Busan\", lon: 129.04, lat: 35.1, label: \"right\" },\n  { code: \"DXB\", name: \"Jebel Ali\", lon: 55.02, lat: 25.02, label: \"bottom\" },\n  { code: \"BOM\", name: \"Mumbai\", lon: 72.93, lat: 18.95, label: \"top\" },\n  { code: \"CMB\", name: \"Colombo\", lon: 79.85, lat: 6.93, label: \"left\" },\n  { code: \"RTM\", name: \"Rotterdam\", lon: 4.48, lat: 51.95, label: \"top\" },\n  { code: \"HAM\", name: \"Hamburg\", lon: 9.97, lat: 53.55, label: \"right\" },\n  { code: \"PIR\", name: \"Piraeus\", lon: 23.63, lat: 37.94, label: \"bottom\" },\n  { code: \"LAX\", name: \"Los Angeles\", lon: -118.22, lat: 33.75, label: \"bottom\" },\n  { code: \"NYC\", name: \"New York\", lon: -74.03, lat: 40.7, label: \"top\" },\n  { code: \"SSZ\", name: \"Santos\", lon: -46.33, lat: -23.96, label: \"bottom\" },\n  { code: \"DUR\", name: \"Durban\", lon: 31.02, lat: -29.87, label: \"bottom\" },\n  { code: \"SYD\", name: \"Sydney\", lon: 151.2, lat: -33.87, label: \"bottom\" },\n];\n\n// [origin, destination, annual containerized volume in thousand TEU]\nconst routes = [\n  [\"SHA\", \"LAX\", 3800],\n  [\"SHA\", \"RTM\", 3200],\n  [\"SHA\", \"SIN\", 2600],\n  [\"SHA\", \"BUS\", 1400],\n  [\"SHA\", \"DUR\", 800],\n  [\"SIN\", \"RTM\", 2400],\n  [\"SIN\", \"DXB\", 1800],\n  [\"SIN\", \"BOM\", 1500],\n  [\"SIN\", \"CMB\", 900],\n  [\"SIN\", \"SYD\", 1100],\n  [\"BUS\", \"LAX\", 2000],\n  [\"BUS\", \"RTM\", 1300],\n  [\"DXB\", \"RTM\", 1600],\n  [\"DXB\", \"BOM\", 1200],\n  [\"DXB\", \"DUR\", 700],\n  [\"DXB\", \"SYD\", 750],\n  [\"BOM\", \"RTM\", 1100],\n  [\"BOM\", \"CMB\", 600],\n  [\"CMB\", \"RTM\", 800],\n  [\"RTM\", \"NYC\", 2100],\n  [\"HAM\", \"NYC\", 1300],\n  [\"PIR\", \"SSZ\", 900],\n  [\"NYC\", \"SSZ\", 1000],\n  [\"LAX\", \"NYC\", 1500],\n];\n\nconst byCode = Object.fromEntries(ports.map((p) => [p.code, p]));\nconst maxFlow = Math.max(...routes.map((r) => r[2]));\n\nconst throughput = {};\nfor (const [origin, dest, teu] of routes) {\n  throughput[origin] = (throughput[origin] || 0) + teu;\n  throughput[dest] = (throughput[dest] || 0) + teu;\n}\nconst maxThroughput = Math.max(...Object.values(throughput));\n\n// Hand-simplified continent silhouettes (lon/lat vertex lists) so the arcs\n// read against a basemap — no bundled world GeoJSON or network fetch is\n// available to this offline browser runtime, so coastlines are coarse,\n// low-vertex approximations rather than authoritative boundary data.\nconst NORTH_AMERICA = [\n  [-165, 68], [-140, 70], [-120, 68], [-125, 55], [-124, 42], [-117, 32],\n  [-105, 20], [-95, 15], [-85, 10], [-80, 8], [-77, 9], [-82, 20], [-80, 30],\n  [-75, 36], [-70, 42], [-65, 45], [-60, 50], [-70, 58], [-90, 65],\n  [-110, 70], [-140, 72], [-165, 68],\n];\nconst SOUTH_AMERICA = [\n  [-77, 9], [-80, 2], [-81, -4], [-80, -14], [-75, -18], [-70, -25],\n  [-71, -35], [-73, -45], [-68, -54], [-64, -52], [-58, -38], [-48, -25],\n  [-40, -10], [-50, 2], [-60, 6], [-70, 10], [-77, 9],\n];\nconst EURASIA = [\n  [-10, 36], [-9, 44], [-2, 50], [8, 55], [11, 59], [6, 62], [5, 66], [13, 69],\n  [19, 70], [24, 66], [20, 60], [30, 65], [45, 68], [60, 70], [80, 73],\n  [105, 75], [130, 73], [142, 60], [135, 48], [128, 38], [122, 32], [110, 22],\n  [100, 10], [92, 8], [80, 10], [70, 22], [60, 28], [48, 30], [38, 32],\n  [33, 36], [27, 37], [21, 39], [18, 40], [16, 38], [14, 41], [12, 44],\n  [7, 44], [3, 42], [-1, 37], [-6, 36], [-9, 38], [-9, 43], [-10, 36],\n];\nconst BAFFIN_ISLAND = [\n  [-64, 70], [-70, 73], [-78, 72], [-80, 68], [-75, 64], [-68, 63], [-64, 66],\n  [-64, 70],\n];\nconst AFRICA = [\n  [-17, 15], [-16, 25], [-8, 33], [3, 37], [12, 36], [20, 32], [32, 31],\n  [35, 25], [38, 18], [41, 15], [43, 12], [48, 11], [51, 12], [50, 5],\n  [45, -1], [40, -14], [35, -22], [33, -28], [27, -33], [18, -34], [13, -22],\n  [9, -4], [8, 4], [-5, 6], [-11, 7], [-17, 15],\n];\nconst AUSTRALIA = [\n  [113, -22], [114, -30], [121, -34], [130, -32], [138, -35], [142, -38],\n  [147, -38], [150, -36], [153, -27], [145, -16], [142, -11], [136, -12],\n  [130, -11], [124, -15], [113, -22],\n];\nconst CONTINENTS = [\n  NORTH_AMERICA,\n  BAFFIN_ISLAND,\n  SOUTH_AMERICA,\n  EURASIA,\n  AFRICA,\n  AUSTRALIA,\n];\n\nfunction renderLand(coords) {\n  return (params, api) => ({\n    type: \"polygon\",\n    shape: { points: coords.map((p) => api.coord(p)) },\n    style: { fill: t.grid, stroke: t.inkSoft, lineWidth: 1, opacity: 0.55 },\n    silent: true,\n  });\n}\n\n// Line width and opacity scale with flow share; curveness alternates so\n// converging routes into the same hub stay visually separable.\nconst lineData = routes.map(([origin, dest, teu], i) => {\n  const share = teu / maxFlow;\n  return {\n    coords: [\n      [byCode[origin].lon, byCode[origin].lat],\n      [byCode[dest].lon, byCode[dest].lat],\n    ],\n    value: teu,\n    lineStyle: {\n      width: 1.5 + share * 6,\n      opacity: 0.4 + share * 0.3,\n      curveness: 0.15 + (i % 4) * 0.05,\n    },\n  };\n});\n\nfunction portSize(volume) {\n  const s0 = Math.sqrt(0);\n  const s1 = Math.sqrt(maxThroughput);\n  const ratio = Math.sqrt(volume) / s1;\n  return 16 + ratio * 34;\n}\n\n// Rotterdam and Hamburg sit close enough in lon/lat that their throughput-\n// scaled circles visually merge; nudge each marker apart on-screen (opposite\n// directions, true coordinates untouched) so both stay individually visible.\nconst PORT_SYMBOL_OFFSET = {\n  RTM: [-14, 8],\n  HAM: [14, -8],\n};\n\nconst nodeData = ports.map((p) => ({\n  name: p.name,\n  value: [p.lon, p.lat, throughput[p.code]],\n  label: { position: p.label },\n  symbolOffset: PORT_SYMBOL_OFFSET[p.code] || [0, 0],\n}));\n\n// --- Init --------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option --------------------------------------------------------------\nconst title = \"Global Trade Flows · flowmap-origin-destination · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\nconst routesSeriesIndex = CONTINENTS.length;\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  color: [t.palette[0]],\n  title: {\n    text: title,\n    subtext: \"Arc width & color scale with annual container volume (thousand TEU) · marker size scales with port throughput\",\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n    subtextStyle: { color: t.inkSoft, fontSize: 15 },\n  },\n  tooltip: {\n    trigger: \"item\",\n    formatter: (p) =>\n      p.seriesType === \"lines\"\n        ? `${p.data.value.toLocaleString()}k TEU/yr`\n        : `${p.name}: ${p.value[2].toLocaleString()}k TEU/yr throughput`,\n  },\n  visualMap: {\n    type: \"continuous\",\n    min: 0,\n    max: maxFlow,\n    seriesIndex: routesSeriesIndex,\n    calculable: false,\n    orient: \"horizontal\",\n    left: \"center\",\n    bottom: 20,\n    itemWidth: 16,\n    itemHeight: 140,\n    text: [\"High volume\", \"Low volume\"],\n    textStyle: { color: t.inkSoft, fontSize: 13 },\n    inRange: { color: t.seq },\n  },\n  grid: { left: 110, right: 90, top: 190, bottom: 150, containLabel: true },\n  xAxis: {\n    type: \"value\",\n    min: -180,\n    max: 180,\n    interval: 30,\n    name: \"Longitude\",\n    nameLocation: \"middle\",\n    nameGap: 40,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 13,\n      formatter: (v) => `${Math.abs(v)}°${v > 0 ? \"E\" : v < 0 ? \"W\" : \"\"}`,\n    },\n    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: true, lineStyle: { color: t.grid } },\n  },\n  yAxis: {\n    type: \"value\",\n    min: -60,\n    max: 80,\n    interval: 30,\n    name: \"Latitude\",\n    nameLocation: \"middle\",\n    nameGap: 55,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 13,\n      formatter: (v) => `${Math.abs(v)}°${v > 0 ? \"N\" : v < 0 ? \"S\" : \"\"}`,\n    },\n    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: true, lineStyle: { color: t.grid } },\n  },\n  series: [\n    ...CONTINENTS.map((coords, i) => ({\n      name: `Basemap ${i}`,\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      renderItem: renderLand(coords),\n      data: [0],\n      silent: true,\n      tooltip: { show: false },\n      z: 1,\n    })),\n    {\n      name: \"Trade routes\",\n      type: \"lines\",\n      coordinateSystem: \"cartesian2d\",\n      symbol: [\"none\", \"arrow\"],\n      symbolSize: [0, 9],\n      data: lineData,\n      z: 2,\n    },\n    {\n      name: \"Ports\",\n      type: \"scatter\",\n      data: nodeData,\n      symbolSize: (val) => portSize(val[2]),\n      itemStyle: { color: t.palette[0], opacity: 0.9, borderColor: t.pageBg, borderWidth: 1.5 },\n      label: { show: true, formatter: \"{b}\", color: t.ink, fontSize: 13, fontWeight: 500 },\n      z: 3,\n    },\n  ],\n});\n"}